@synqauth/react-native 0.0.0-stage → 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +36 -2
- package/dist/index.d.ts +145 -0
- package/dist/index.js +256 -0
- package/package.json +49 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aerosol
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @synqauth/react-native
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Synq for React Native: sign-in through the system browser, with tokens in the device's secure storage.
|
|
4
|
+
|
|
5
|
+
Synq is customer identity for businesses: an OpenID Connect issuer for each of your brands, with Solana wallet, social, email and passkey sign-in. The documentation is at [docs.synq.build](https://docs.synq.build).
|
|
6
|
+
|
|
7
|
+
This package is at 0.x and hasn't been published yet: its API can change in any release until Synq's first customer depends on it.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Register a native app with a redirect URI your app owns: an https link it claims (an iOS universal link, an Android App Link), or a scheme of its own, a reverse domain name such as `com.example.app:/callback`. Then:
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import 'react-native-quick-crypto' // a WebCrypto polyfill: Hermes has none
|
|
15
|
+
import * as SecureStore from 'expo-secure-store'
|
|
16
|
+
import * as WebBrowser from 'expo-web-browser'
|
|
17
|
+
import { SynqNativeProvider, createSynqNative, useAuth, useSession } from '@synqauth/react-native'
|
|
18
|
+
|
|
19
|
+
const issuer = 'https://acme.auth.synq.build'
|
|
20
|
+
const synq = createSynqNative({
|
|
21
|
+
issuer,
|
|
22
|
+
clientId: 'app_…',
|
|
23
|
+
redirectUri: 'https://app.example.com/synq/callback',
|
|
24
|
+
webBrowser: WebBrowser,
|
|
25
|
+
secureStore: SecureStore,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// <SynqNativeProvider synq={synq} issuer={issuer}> around the app, then:
|
|
29
|
+
const { isSignedIn } = useSession()
|
|
30
|
+
const { signIn, signOut } = useAuth()
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Sign-in opens the system browser (ASWebAuthenticationSession on iOS, Custom Tabs on Android), never an embedded web view. The session, refresh token included, stays in the device's secure storage.
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { AccountClient, AuthorizationUrlOptions, IdTokenClaims, Session, SynqClient, SynqError, SynqStorage } from "@synqauth/js";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
//#region src/storage.d.ts
|
|
4
|
+
/** The part of `expo-secure-store` the SDK uses: pass the module itself. */
|
|
5
|
+
interface SecureStoreModule {
|
|
6
|
+
getItemAsync(key: string): Promise<string | null>;
|
|
7
|
+
setItemAsync(key: string, value: string): Promise<void>;
|
|
8
|
+
deleteItemAsync(key: string): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The most one item holds: iOS's keychain takes more, but `expo-secure-store`
|
|
12
|
+
* warns past 2048 bytes, and a session's tokens are bigger than that.
|
|
13
|
+
*/
|
|
14
|
+
export declare const CHUNK_SIZE = 1800;
|
|
15
|
+
/**
|
|
16
|
+
* The device's secure storage (the iOS keychain, Android's keystore) as a
|
|
17
|
+
* `SynqStorage`, through `expo-secure-store`: the session's tokens, the
|
|
18
|
+
* refresh token included, and a sign-in's transaction. A value too big for
|
|
19
|
+
* one item is split across several (`<key>.0`, `<key>.1`...), with the count
|
|
20
|
+
* under the key itself. One app is one process, so the lock is in memory.
|
|
21
|
+
*/
|
|
22
|
+
export declare function secureStorage(store: SecureStoreModule): SynqStorage;
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/client.d.ts
|
|
25
|
+
/** The part of `expo-web-browser` the SDK uses: pass the module itself. */
|
|
26
|
+
interface WebBrowserModule {
|
|
27
|
+
openAuthSessionAsync(url: string, redirectUrl: string): Promise<{
|
|
28
|
+
type: 'success';
|
|
29
|
+
url: string;
|
|
30
|
+
} | {
|
|
31
|
+
type: string;
|
|
32
|
+
}>;
|
|
33
|
+
}
|
|
34
|
+
/** What `createSynqNative` takes: the app at the brand, and the two Expo modules it runs on. */
|
|
35
|
+
interface SynqNativeOptions {
|
|
36
|
+
/** The brand's issuer URL. */
|
|
37
|
+
issuer: string;
|
|
38
|
+
/** The app's client id: a native app, registered with the redirect URI below. */
|
|
39
|
+
clientId: string;
|
|
40
|
+
/**
|
|
41
|
+
* Where Synq sends the browser back to, one of the app's registered
|
|
42
|
+
* redirect URIs: an https link the app claims (a universal link, an App
|
|
43
|
+
* Link) or a scheme of its own, a reverse domain name such as
|
|
44
|
+
* `com.example.app:/callback` (RFC 8252). Never plain http.
|
|
45
|
+
*/
|
|
46
|
+
redirectUri: string;
|
|
47
|
+
/** Where the browser lands after signing out: one of the app's registered post-logout URIs. */
|
|
48
|
+
postLogoutRedirectUri?: string;
|
|
49
|
+
/** The scopes: `openid offline_access` by default, so the session lasts past its first access token. */
|
|
50
|
+
scope?: string;
|
|
51
|
+
/** The API the access token is for: a registered API's identifier. */
|
|
52
|
+
resource?: string;
|
|
53
|
+
/** `expo-web-browser`, which opens the system browser (ASWebAuthenticationSession, Custom Tabs). */
|
|
54
|
+
webBrowser: WebBrowserModule;
|
|
55
|
+
/** `expo-secure-store`, where the session and the refresh token are kept. */
|
|
56
|
+
secureStore: SecureStoreModule;
|
|
57
|
+
/** The `fetch` to call Synq with: the global one by default, `expo/fetch`'s included. */
|
|
58
|
+
fetch?: typeof fetch;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Synq in a React Native app: `@synqauth/js`'s client, signing in through
|
|
62
|
+
* the system browser (never an embedded web view, which can read what the
|
|
63
|
+
* person types) with PKCE and a callback on a link or scheme the app owns,
|
|
64
|
+
* and keeping the session in the device's secure storage.
|
|
65
|
+
*/
|
|
66
|
+
interface SynqNative {
|
|
67
|
+
/** The underlying `@synqauth/js` client: tokens, refresh, events. */
|
|
68
|
+
readonly client: SynqClient;
|
|
69
|
+
/** The API the session's access token is for, as given. */
|
|
70
|
+
readonly resource: string | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Opens the brand's sign-in in the system browser and finishes it when the
|
|
73
|
+
* app's link comes back. Rejects with `cancelled` when the person closes it.
|
|
74
|
+
*/
|
|
75
|
+
signIn(options?: AuthorizationUrlOptions): Promise<Session>;
|
|
76
|
+
/**
|
|
77
|
+
* Ends the session on the device and at Synq. With `browser`, also signs
|
|
78
|
+
* the person out of the brand's session in the system browser, which asks
|
|
79
|
+
* them first; without it, the browser's session at Synq stays.
|
|
80
|
+
*/
|
|
81
|
+
signOut(options?: {
|
|
82
|
+
browser?: boolean;
|
|
83
|
+
}): Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
/** Synq for a React Native app: the system browser for sign-in, the device's secure storage for the session. */
|
|
86
|
+
export declare function createSynqNative(options: SynqNativeOptions): SynqNative;
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/provider.d.ts
|
|
89
|
+
/** A call's outcome: its data, or the `SynqError` it failed with. */
|
|
90
|
+
type SynqResult<T> = {
|
|
91
|
+
readonly data: T;
|
|
92
|
+
readonly error: null;
|
|
93
|
+
} | {
|
|
94
|
+
readonly data: null;
|
|
95
|
+
readonly error: SynqError;
|
|
96
|
+
};
|
|
97
|
+
/** What `SynqNativeProvider` takes. */
|
|
98
|
+
interface SynqNativeProviderProps {
|
|
99
|
+
/** The client `createSynqNative` made, once, outside any component. */
|
|
100
|
+
synq: SynqNative;
|
|
101
|
+
/** The brand's issuer, as given to `createSynqNative`: for the account API. */
|
|
102
|
+
issuer: string;
|
|
103
|
+
children?: ReactNode;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Gives the hooks the device's session: it reads it once from secure
|
|
107
|
+
* storage, then follows every sign-in, refresh and sign-out.
|
|
108
|
+
*/
|
|
109
|
+
export declare function SynqNativeProvider({ synq, issuer, children }: SynqNativeProviderProps): import("react").JSX.Element;
|
|
110
|
+
/** What `useSession()` answers: whether the device's session is read yet, and who is signed in. */
|
|
111
|
+
interface UseSession {
|
|
112
|
+
readonly isLoaded: boolean;
|
|
113
|
+
readonly isSignedIn: boolean;
|
|
114
|
+
readonly session: Session | null;
|
|
115
|
+
}
|
|
116
|
+
/** The device's session. */
|
|
117
|
+
export declare function useSession(): UseSession;
|
|
118
|
+
/** What `useUser()` answers: the ID token's claims, and the brand's account API for the person. */
|
|
119
|
+
interface UseUser {
|
|
120
|
+
readonly isLoaded: boolean;
|
|
121
|
+
/** Who signed in, from the ID token; null when nobody is. */
|
|
122
|
+
readonly user: IdTokenClaims | null;
|
|
123
|
+
/**
|
|
124
|
+
* The brand's account API with the session's token, when the session's
|
|
125
|
+
* token is for it (`createSynqNative({ resource: accountApiUrl(issuer) })`).
|
|
126
|
+
*/
|
|
127
|
+
readonly account: AccountClient | null;
|
|
128
|
+
}
|
|
129
|
+
/** Who is signed in, and their account. */
|
|
130
|
+
export declare function useUser(): UseUser;
|
|
131
|
+
/** What `useAuth()` answers: signing in and out, each answering `{ data, error }`. */
|
|
132
|
+
interface UseAuth {
|
|
133
|
+
readonly isLoaded: boolean;
|
|
134
|
+
readonly isSignedIn: boolean;
|
|
135
|
+
/** Opens the brand's sign-in in the system browser. A closed browser is the error `cancelled`. */
|
|
136
|
+
signIn(options?: AuthorizationUrlOptions): Promise<SynqResult<Session>>;
|
|
137
|
+
/** Ends the session; with `browser`, at the brand in the system browser too. */
|
|
138
|
+
signOut(options?: {
|
|
139
|
+
browser?: boolean;
|
|
140
|
+
}): Promise<SynqResult<null>>;
|
|
141
|
+
}
|
|
142
|
+
/** Signing in and out. */
|
|
143
|
+
export declare function useAuth(): UseAuth;
|
|
144
|
+
//#endregion
|
|
145
|
+
export type { SecureStoreModule, SynqNative, SynqNativeOptions, SynqNativeProviderProps, SynqResult, UseAuth, UseSession, UseUser, WebBrowserModule };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { SynqError, accountApiUrl, createAccountClient, createSynqClient, isSynqError } from "@synqauth/js";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
//#region src/storage.ts
|
|
5
|
+
/**
|
|
6
|
+
* The most one item holds: iOS's keychain takes more, but `expo-secure-store`
|
|
7
|
+
* warns past 2048 bytes, and a session's tokens are bigger than that.
|
|
8
|
+
*/
|
|
9
|
+
const CHUNK_SIZE = 1800;
|
|
10
|
+
/** A secure-store key: the store takes letters, digits, `.`, `-` and `_` only. */
|
|
11
|
+
function safeKey(key) {
|
|
12
|
+
return key.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The device's secure storage (the iOS keychain, Android's keystore) as a
|
|
16
|
+
* `SynqStorage`, through `expo-secure-store`: the session's tokens, the
|
|
17
|
+
* refresh token included, and a sign-in's transaction. A value too big for
|
|
18
|
+
* one item is split across several (`<key>.0`, `<key>.1`...), with the count
|
|
19
|
+
* under the key itself. One app is one process, so the lock is in memory.
|
|
20
|
+
*/
|
|
21
|
+
function secureStorage(store) {
|
|
22
|
+
const locks = /* @__PURE__ */ new Map();
|
|
23
|
+
async function remove(key) {
|
|
24
|
+
const base = safeKey(key);
|
|
25
|
+
const head = await store.getItemAsync(base);
|
|
26
|
+
const count = head?.startsWith("chunks:") ? Number(head.slice(7)) : 0;
|
|
27
|
+
for (let i = 0; i < count; i++) await store.deleteItemAsync(`${base}.${i}`);
|
|
28
|
+
await store.deleteItemAsync(base);
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
holdsRefreshTokens: true,
|
|
32
|
+
async get(key) {
|
|
33
|
+
const base = safeKey(key);
|
|
34
|
+
const head = await store.getItemAsync(base);
|
|
35
|
+
if (head === null || !head.startsWith("chunks:")) return head?.slice(6) ?? null;
|
|
36
|
+
const count = Number(head.slice(7));
|
|
37
|
+
const parts = [];
|
|
38
|
+
for (let i = 0; i < count; i++) {
|
|
39
|
+
const part = await store.getItemAsync(`${base}.${i}`);
|
|
40
|
+
if (part === null) return null;
|
|
41
|
+
parts.push(part);
|
|
42
|
+
}
|
|
43
|
+
return parts.join("");
|
|
44
|
+
},
|
|
45
|
+
async set(key, value) {
|
|
46
|
+
await remove(key);
|
|
47
|
+
const base = safeKey(key);
|
|
48
|
+
if (value.length <= 1800) {
|
|
49
|
+
await store.setItemAsync(base, `value:${value}`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const count = Math.ceil(value.length / CHUNK_SIZE);
|
|
53
|
+
for (let i = 0; i < count; i++) await store.setItemAsync(`${base}.${i}`, value.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE));
|
|
54
|
+
await store.setItemAsync(base, `chunks:${count}`);
|
|
55
|
+
},
|
|
56
|
+
remove,
|
|
57
|
+
lock(name, fn) {
|
|
58
|
+
const run = (locks.get(name) ?? Promise.resolve()).then(fn, fn);
|
|
59
|
+
const settled = run.then(() => void 0, () => void 0);
|
|
60
|
+
locks.set(name, settled);
|
|
61
|
+
settled.then(() => {
|
|
62
|
+
if (locks.get(name) === settled) locks.delete(name);
|
|
63
|
+
});
|
|
64
|
+
return run;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/client.ts
|
|
70
|
+
/** A WebCrypto the SDK needs for PKCE: Hermes has none, so the app installs one first. */
|
|
71
|
+
function assertWebCrypto() {
|
|
72
|
+
const crypto = globalThis.crypto;
|
|
73
|
+
if (typeof crypto?.subtle?.digest !== "function" || typeof crypto.getRandomValues !== "function") throw new SynqError("invalid_options", "@synqauth/react-native needs WebCrypto (crypto.subtle and getRandomValues): install a polyfill such as react-native-quick-crypto's install() before creating the client");
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* `fetch`, answering the global `Response`. Expo (SDK 52 on) replaces the
|
|
77
|
+
* global `fetch` with `expo/fetch`, whose responses are its own class: not
|
|
78
|
+
* React Native's `Response`, which has no `Symbol.toStringTag` either, so
|
|
79
|
+
* oauth4webapi refuses every one ("must be an instance of Response"). Such
|
|
80
|
+
* a response is read whole and handed on as a `Response`; Synq's answers
|
|
81
|
+
* are small JSON.
|
|
82
|
+
*/
|
|
83
|
+
function standardResponses(fetcher) {
|
|
84
|
+
return async (input, init) => {
|
|
85
|
+
const response = await fetcher(input, init);
|
|
86
|
+
if (response instanceof Response) return response;
|
|
87
|
+
const status = response.status;
|
|
88
|
+
return new Response(status === 204 || status === 205 || status === 304 ? null : await response.arrayBuffer(), {
|
|
89
|
+
status,
|
|
90
|
+
statusText: response.statusText,
|
|
91
|
+
headers: response.headers
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Synq for a React Native app: the system browser for sign-in, the device's secure storage for the session. */
|
|
96
|
+
function createSynqNative(options) {
|
|
97
|
+
assertWebCrypto();
|
|
98
|
+
if (/^http:/i.test(options.redirectUri)) throw new SynqError("invalid_options", "A mobile app's redirect URI is an https link it claims (a universal link or App Link) or a scheme of its own, not http");
|
|
99
|
+
const storage = secureStorage(options.secureStore);
|
|
100
|
+
const client = createSynqClient({
|
|
101
|
+
issuer: options.issuer,
|
|
102
|
+
clientId: options.clientId,
|
|
103
|
+
redirectUri: options.redirectUri,
|
|
104
|
+
postLogoutRedirectUri: options.postLogoutRedirectUri,
|
|
105
|
+
scope: options.scope ?? "openid offline_access",
|
|
106
|
+
resource: options.resource,
|
|
107
|
+
storage,
|
|
108
|
+
transactionStorage: storage,
|
|
109
|
+
fetch: standardResponses(options.fetch ?? ((input, init) => globalThis.fetch(input, init)))
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
client,
|
|
113
|
+
resource: options.resource,
|
|
114
|
+
async signIn(signInOptions) {
|
|
115
|
+
const url = await client.authorizationUrl(signInOptions);
|
|
116
|
+
const result = await options.webBrowser.openAuthSessionAsync(url.href, options.redirectUri);
|
|
117
|
+
if (result.type !== "success" || !("url" in result)) throw new SynqError("cancelled", "The person closed the sign-in");
|
|
118
|
+
return client.handleCallback(result.url);
|
|
119
|
+
},
|
|
120
|
+
async signOut({ browser = false } = {}) {
|
|
121
|
+
const url = await client.signOut();
|
|
122
|
+
if (browser && url && options.postLogoutRedirectUri) await options.webBrowser.openAuthSessionAsync(url.href, options.postLogoutRedirectUri);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/provider.tsx
|
|
128
|
+
const SynqContext = createContext(null);
|
|
129
|
+
/**
|
|
130
|
+
* Gives the hooks the device's session: it reads it once from secure
|
|
131
|
+
* storage, then follows every sign-in, refresh and sign-out.
|
|
132
|
+
*/
|
|
133
|
+
function SynqNativeProvider({ synq, issuer, children }) {
|
|
134
|
+
const [state, setState] = useState({
|
|
135
|
+
status: "loading",
|
|
136
|
+
session: null
|
|
137
|
+
});
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
let live = true;
|
|
140
|
+
const unsubscribe = synq.client.subscribe((event) => {
|
|
141
|
+
if (!live) return;
|
|
142
|
+
setState(event.session ? {
|
|
143
|
+
status: "signed-in",
|
|
144
|
+
session: event.session
|
|
145
|
+
} : {
|
|
146
|
+
status: "signed-out",
|
|
147
|
+
session: null
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
synq.client.getSession().then((session) => {
|
|
151
|
+
if (!live) return;
|
|
152
|
+
setState((current) => current.status !== "loading" ? current : session ? {
|
|
153
|
+
status: "signed-in",
|
|
154
|
+
session
|
|
155
|
+
} : {
|
|
156
|
+
status: "signed-out",
|
|
157
|
+
session: null
|
|
158
|
+
});
|
|
159
|
+
}, () => {
|
|
160
|
+
if (live) setState({
|
|
161
|
+
status: "signed-out",
|
|
162
|
+
session: null
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
return () => {
|
|
166
|
+
live = false;
|
|
167
|
+
unsubscribe();
|
|
168
|
+
};
|
|
169
|
+
}, [synq]);
|
|
170
|
+
const value = useMemo(() => ({
|
|
171
|
+
synq,
|
|
172
|
+
state,
|
|
173
|
+
issuer
|
|
174
|
+
}), [
|
|
175
|
+
synq,
|
|
176
|
+
state,
|
|
177
|
+
issuer
|
|
178
|
+
]);
|
|
179
|
+
return /* @__PURE__ */ jsx(SynqContext.Provider, {
|
|
180
|
+
value,
|
|
181
|
+
children
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
function useSynq() {
|
|
185
|
+
const context = useContext(SynqContext);
|
|
186
|
+
if (!context) throw new Error("Synq hooks need a <SynqNativeProvider> above them");
|
|
187
|
+
return context;
|
|
188
|
+
}
|
|
189
|
+
/** The device's session. */
|
|
190
|
+
function useSession() {
|
|
191
|
+
const { state } = useSynq();
|
|
192
|
+
return {
|
|
193
|
+
isLoaded: state.status !== "loading",
|
|
194
|
+
isSignedIn: state.status === "signed-in",
|
|
195
|
+
session: state.session
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/** Who is signed in, and their account. */
|
|
199
|
+
function useUser() {
|
|
200
|
+
const { synq, state, issuer } = useSynq();
|
|
201
|
+
const account = useMemo(() => state.session && synq.resource === accountApiUrl(issuer) ? createAccountClient({
|
|
202
|
+
issuer,
|
|
203
|
+
accessToken: () => synq.client.getAccessToken()
|
|
204
|
+
}) : null, [
|
|
205
|
+
synq,
|
|
206
|
+
state.session,
|
|
207
|
+
issuer
|
|
208
|
+
]);
|
|
209
|
+
return {
|
|
210
|
+
isLoaded: state.status !== "loading",
|
|
211
|
+
user: state.session?.claims ?? null,
|
|
212
|
+
account
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/** Signing in and out. */
|
|
216
|
+
function useAuth() {
|
|
217
|
+
const { synq, state } = useSynq();
|
|
218
|
+
const signIn = useCallback(async (options) => {
|
|
219
|
+
try {
|
|
220
|
+
return {
|
|
221
|
+
data: await synq.signIn(options),
|
|
222
|
+
error: null
|
|
223
|
+
};
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return {
|
|
226
|
+
data: null,
|
|
227
|
+
error: asSynqError(error)
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
}, [synq]);
|
|
231
|
+
const signOut = useCallback(async (options) => {
|
|
232
|
+
try {
|
|
233
|
+
await synq.signOut(options);
|
|
234
|
+
return {
|
|
235
|
+
data: null,
|
|
236
|
+
error: null
|
|
237
|
+
};
|
|
238
|
+
} catch (error) {
|
|
239
|
+
return {
|
|
240
|
+
data: null,
|
|
241
|
+
error: asSynqError(error)
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}, [synq]);
|
|
245
|
+
return {
|
|
246
|
+
isLoaded: state.status !== "loading",
|
|
247
|
+
isSignedIn: state.status === "signed-in",
|
|
248
|
+
signIn,
|
|
249
|
+
signOut
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
function asSynqError(error) {
|
|
253
|
+
return isSynqError(error) ? error : new SynqError("network_error", String(error), { cause: error });
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
256
|
+
export { CHUNK_SIZE, SynqNativeProvider, createSynqNative, secureStorage, useAuth, useSession, useUser };
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synqauth/react-native",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Synq for React Native: sign-in through the system browser, with tokens in the device's secure storage.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/aerosol-build/synq.git",
|
|
9
|
+
"directory": "packages/react-native"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@synqauth/js": "0.0.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"react": ">=19.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@testing-library/dom": "10.4.2",
|
|
37
|
+
"@testing-library/react": "16.3.3",
|
|
38
|
+
"@types/react": "19.3.0",
|
|
39
|
+
"@types/react-dom": "19.3.0",
|
|
40
|
+
"@vitest/coverage-v8": "5.0.2",
|
|
41
|
+
"jsdom": "30.1.1",
|
|
42
|
+
"react": "19.3.0",
|
|
43
|
+
"react-dom": "19.3.0",
|
|
44
|
+
"vitest": "5.0.2"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsdown",
|
|
48
|
+
"lint": "eslint .",
|
|
49
|
+
"typecheck": "tsc",
|
|
50
|
+
"test": "vitest run --coverage"
|
|
51
|
+
}
|
|
6
52
|
}
|