@synqauth/react 0.0.0-stage → 0.2.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 +48 -2
- package/dist/index.d.ts +575 -0
- package/dist/index.js +2180 -0
- package/dist/styles.css +268 -0
- package/package.json +53 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,2180 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import { createContext, use, useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { SynqError, problemError } from "@synqauth/js";
|
|
4
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
//#region src/messages.ts
|
|
6
|
+
/** The components' English words, which an app's own messages override one by one. */
|
|
7
|
+
const DEFAULT_MESSAGES = {
|
|
8
|
+
signIn: "Sign in",
|
|
9
|
+
signOut: "Sign out",
|
|
10
|
+
manageAccount: "Manage account",
|
|
11
|
+
account: "Account",
|
|
12
|
+
loading: "Loading…",
|
|
13
|
+
save: "Save",
|
|
14
|
+
saving: "Saving…",
|
|
15
|
+
saved: "Saved",
|
|
16
|
+
cancel: "Cancel",
|
|
17
|
+
profile: "Profile",
|
|
18
|
+
name: "Name",
|
|
19
|
+
username: "Username",
|
|
20
|
+
waysToSignIn: "Ways to sign in",
|
|
21
|
+
remove: "Remove",
|
|
22
|
+
removeAccount: (provider, subject) => `Remove ${provider} ${subject}`,
|
|
23
|
+
providers: {
|
|
24
|
+
email: "Email",
|
|
25
|
+
solana: "Wallet",
|
|
26
|
+
google: "Google",
|
|
27
|
+
microsoft: "Microsoft",
|
|
28
|
+
discord: "Discord"
|
|
29
|
+
},
|
|
30
|
+
devices: "Devices",
|
|
31
|
+
signedIn: "Signed in",
|
|
32
|
+
thisDevice: "This device",
|
|
33
|
+
signOutDevice: (when) => `Sign out the session from ${when}`,
|
|
34
|
+
apps: (count) => count === 1 ? "1 app" : `${count} apps`,
|
|
35
|
+
wallets: "Wallets",
|
|
36
|
+
noWallets: "No wallets yet.",
|
|
37
|
+
primary: "Primary",
|
|
38
|
+
rename: "Rename",
|
|
39
|
+
renameWallet: (name) => `Rename ${name}`,
|
|
40
|
+
makePrimary: "Make primary",
|
|
41
|
+
makeWalletPrimary: (name) => `Make ${name} primary`,
|
|
42
|
+
walletName: (address) => `Name for ${address}`,
|
|
43
|
+
apiKeys: "API keys",
|
|
44
|
+
noApiKeys: "No API keys yet.",
|
|
45
|
+
newApiKey: "New API key",
|
|
46
|
+
api: "API",
|
|
47
|
+
keyScopes: "What it may do",
|
|
48
|
+
expires: "Expires",
|
|
49
|
+
never: "Never",
|
|
50
|
+
inDays: (days) => `In ${days} days`,
|
|
51
|
+
makeKey: "Make key",
|
|
52
|
+
makingKey: "Making…",
|
|
53
|
+
revoke: "Revoke",
|
|
54
|
+
revokeKey: (name) => `Revoke ${name}`,
|
|
55
|
+
revoked: "Revoked",
|
|
56
|
+
expired: "Expired",
|
|
57
|
+
lastUsed: (when) => `Last used ${when}`,
|
|
58
|
+
neverUsed: "Never used",
|
|
59
|
+
expiresOn: (when) => `Expires ${when}`,
|
|
60
|
+
copyKeyNow: "Copy this key now: you won’t see it again.",
|
|
61
|
+
copy: "Copy",
|
|
62
|
+
copied: "Copied",
|
|
63
|
+
done: "Done",
|
|
64
|
+
keyNeedsFreshSignIn: "Making a key needs a recent sign-in.",
|
|
65
|
+
signInAgain: "Sign in again",
|
|
66
|
+
email: "Email",
|
|
67
|
+
password: "Password",
|
|
68
|
+
signInWithPasskey: "Sign in with a passkey",
|
|
69
|
+
passkeyFor: (domain) => `Use a passkey for ${domain}`,
|
|
70
|
+
emailMeACode: "Email me a code",
|
|
71
|
+
or: "or",
|
|
72
|
+
code: "Code",
|
|
73
|
+
codeSentTo: (email) => `We sent a code to ${email}.`,
|
|
74
|
+
verifyYourEmail: (email) => `Enter the code we sent to ${email} to verify your email address.`,
|
|
75
|
+
verify: "Verify",
|
|
76
|
+
sendAnotherCode: "Send another code",
|
|
77
|
+
useAnotherWay: "Use another way",
|
|
78
|
+
skipForNow: "Skip for now",
|
|
79
|
+
forgotPassword: "Forgot your password?",
|
|
80
|
+
createAccount: "Create account",
|
|
81
|
+
newHere: "New here?",
|
|
82
|
+
haveAnAccount: "Have an account?",
|
|
83
|
+
continuingToSignIn: "Taking you to sign in…",
|
|
84
|
+
tryAgainIn: (seconds) => seconds < 60 ? `Try again in ${seconds} seconds.` : `Try again in ${Math.ceil(seconds / 60)} minutes.`,
|
|
85
|
+
signInProblems: {
|
|
86
|
+
missing: "Fill in every field.",
|
|
87
|
+
invalid_email: "Enter a valid email address.",
|
|
88
|
+
wrong_credentials: "That email and password don’t match.",
|
|
89
|
+
email_taken: "An account already uses that email address. Sign in instead.",
|
|
90
|
+
password_too_short: "That password is too short.",
|
|
91
|
+
password_too_long: "That password is too long.",
|
|
92
|
+
password_guessable: "That password is too easy to guess.",
|
|
93
|
+
password_breached: "That password has appeared in a data breach. Choose another.",
|
|
94
|
+
wrong_code: "That code isn’t right.",
|
|
95
|
+
code_expired: "That code has expired. Send another.",
|
|
96
|
+
code_used_up: "Too many tries with that code. Send another.",
|
|
97
|
+
address_suppressed: "Email to that address doesn’t arrive. Use another address.",
|
|
98
|
+
team_only: "Only the team can sign in here.",
|
|
99
|
+
send_failed: "The email couldn’t be sent. Try again.",
|
|
100
|
+
passkey_failed: "That passkey didn’t work. Try again.",
|
|
101
|
+
passkey_unknown: "That passkey isn’t for an account here.",
|
|
102
|
+
passkey_expired: "That took too long. Try your passkey again.",
|
|
103
|
+
passkey_cancelled: "No passkey was used.",
|
|
104
|
+
slow_down: "Wait a moment, then try again.",
|
|
105
|
+
locked: "Too many tries.",
|
|
106
|
+
limited: "Too many tries.",
|
|
107
|
+
expired: "That sign-in timed out. Start again.",
|
|
108
|
+
unavailable: "Sign-in isn’t working right now. Try again."
|
|
109
|
+
},
|
|
110
|
+
signInRefusals: {
|
|
111
|
+
disabled: "This account is disabled.",
|
|
112
|
+
deleting: "This account is being deleted.",
|
|
113
|
+
sign_up_closed: "New accounts can’t be made here.",
|
|
114
|
+
already_exists: "An account already uses that email address."
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/mount.ts
|
|
119
|
+
/** A request to `basePath`'s routes from the app's own page: same origin, with the cookie, never cached. */
|
|
120
|
+
function mountRequest(basePath, fetcher) {
|
|
121
|
+
const base = `/${basePath.replace(/^\/+|\/+$/g, "")}`;
|
|
122
|
+
return (path, init = {}) => {
|
|
123
|
+
const headers = new Headers(init.headers);
|
|
124
|
+
if (!headers.has("accept")) headers.set("accept", "application/json");
|
|
125
|
+
return fetcher(`${base}/${path.replace(/^\/+/, "")}`, {
|
|
126
|
+
...init,
|
|
127
|
+
headers,
|
|
128
|
+
credentials: "same-origin",
|
|
129
|
+
cache: "no-store"
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/** Calls the mount and reads its JSON; a problem it answers, or no answer at all, is the result's `error`. */
|
|
134
|
+
async function call(request, path, init) {
|
|
135
|
+
let response;
|
|
136
|
+
try {
|
|
137
|
+
response = await request(path, init);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return failure(new SynqError("network_error", `The app couldn't be reached: ${String(error)}`, { cause: error }));
|
|
140
|
+
}
|
|
141
|
+
const text = await response.text();
|
|
142
|
+
let json;
|
|
143
|
+
try {
|
|
144
|
+
json = text ? JSON.parse(text) : void 0;
|
|
145
|
+
} catch {
|
|
146
|
+
json = void 0;
|
|
147
|
+
}
|
|
148
|
+
if (!response.ok) return failure(problemError(response.status, json !== null && typeof json === "object" ? json : {}));
|
|
149
|
+
return {
|
|
150
|
+
data: json,
|
|
151
|
+
error: null
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function failure(error) {
|
|
155
|
+
return {
|
|
156
|
+
data: null,
|
|
157
|
+
error
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/store.ts
|
|
162
|
+
const list = (path) => ({
|
|
163
|
+
path,
|
|
164
|
+
pick: (json) => json.data
|
|
165
|
+
});
|
|
166
|
+
const ROUTES = {
|
|
167
|
+
profile: {
|
|
168
|
+
path: "account/v1/profile",
|
|
169
|
+
pick: (json) => json
|
|
170
|
+
},
|
|
171
|
+
metadata: {
|
|
172
|
+
path: "account/v1/metadata",
|
|
173
|
+
pick: (json) => json
|
|
174
|
+
},
|
|
175
|
+
wallets: list("account/v1/wallets"),
|
|
176
|
+
accounts: list("account/v1/identities"),
|
|
177
|
+
sessions: list("account/v1/sessions"),
|
|
178
|
+
apiKeys: list("account/v1/api-keys"),
|
|
179
|
+
keyableApis: list("account/v1/apis")
|
|
180
|
+
};
|
|
181
|
+
const EMPTY = {
|
|
182
|
+
data: null,
|
|
183
|
+
error: null,
|
|
184
|
+
loading: false
|
|
185
|
+
};
|
|
186
|
+
const NO_RESOURCES = {
|
|
187
|
+
profile: EMPTY,
|
|
188
|
+
metadata: EMPTY,
|
|
189
|
+
wallets: EMPTY,
|
|
190
|
+
accounts: EMPTY,
|
|
191
|
+
sessions: EMPTY,
|
|
192
|
+
apiKeys: EMPTY,
|
|
193
|
+
keyableApis: EMPTY
|
|
194
|
+
};
|
|
195
|
+
function createStore(request, initial) {
|
|
196
|
+
let state = {
|
|
197
|
+
session: initial === void 0 ? {
|
|
198
|
+
status: "loading",
|
|
199
|
+
user: null,
|
|
200
|
+
expiresAt: null,
|
|
201
|
+
error: null
|
|
202
|
+
} : sessionOf(initial),
|
|
203
|
+
resources: NO_RESOURCES
|
|
204
|
+
};
|
|
205
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
206
|
+
const reads = /* @__PURE__ */ new Map();
|
|
207
|
+
let asking;
|
|
208
|
+
let running = false;
|
|
209
|
+
let failures = 0;
|
|
210
|
+
let retry;
|
|
211
|
+
let generation = 0;
|
|
212
|
+
function set(next) {
|
|
213
|
+
state = next;
|
|
214
|
+
for (const listener of listeners) listener();
|
|
215
|
+
}
|
|
216
|
+
function setResource(name, value) {
|
|
217
|
+
set({
|
|
218
|
+
...state,
|
|
219
|
+
resources: {
|
|
220
|
+
...state.resources,
|
|
221
|
+
[name]: value
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
function setSession(info) {
|
|
226
|
+
const next = sessionOf(info);
|
|
227
|
+
const same = next.user?.sub === state.session.user?.sub && next.status === state.session.status;
|
|
228
|
+
if (same && next.expiresAt === state.session.expiresAt && !state.session.error) return;
|
|
229
|
+
if (!same) {
|
|
230
|
+
generation += 1;
|
|
231
|
+
reads.clear();
|
|
232
|
+
}
|
|
233
|
+
set({
|
|
234
|
+
session: next,
|
|
235
|
+
resources: same ? state.resources : NO_RESOURCES
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
function notice(error) {
|
|
239
|
+
if (error?.status === 401) setSession(null);
|
|
240
|
+
}
|
|
241
|
+
const store = {
|
|
242
|
+
getState: () => state,
|
|
243
|
+
subscribe(listener) {
|
|
244
|
+
listeners.add(listener);
|
|
245
|
+
return () => listeners.delete(listener);
|
|
246
|
+
},
|
|
247
|
+
refreshSession() {
|
|
248
|
+
asking ??= call(request, "session").then((result) => {
|
|
249
|
+
asking = void 0;
|
|
250
|
+
if (result.data) {
|
|
251
|
+
failures = 0;
|
|
252
|
+
setSession(result.data);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const unknown = state.session.status === "loading" || state.session.status === "error";
|
|
256
|
+
const status = unknown ? "error" : state.session.status;
|
|
257
|
+
set({
|
|
258
|
+
...state,
|
|
259
|
+
session: {
|
|
260
|
+
...state.session,
|
|
261
|
+
status,
|
|
262
|
+
error: result.error
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
if (unknown && running) {
|
|
266
|
+
clearTimeout(retry);
|
|
267
|
+
retry = setTimeout(() => void store.refreshSession(), backoff(failures));
|
|
268
|
+
failures += 1;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
return asking;
|
|
272
|
+
},
|
|
273
|
+
start() {
|
|
274
|
+
running = true;
|
|
275
|
+
const { status } = state.session;
|
|
276
|
+
if (status === "loading" || status === "error") store.refreshSession();
|
|
277
|
+
},
|
|
278
|
+
stop() {
|
|
279
|
+
running = false;
|
|
280
|
+
clearTimeout(retry);
|
|
281
|
+
},
|
|
282
|
+
setSession,
|
|
283
|
+
load(name) {
|
|
284
|
+
const running = reads.get(name);
|
|
285
|
+
if (running) return running;
|
|
286
|
+
const route = ROUTES[name];
|
|
287
|
+
const asked = generation;
|
|
288
|
+
setResource(name, {
|
|
289
|
+
...state.resources[name],
|
|
290
|
+
loading: true
|
|
291
|
+
});
|
|
292
|
+
const read = call(request, route.path).then((result) => {
|
|
293
|
+
const outcome = result.error ? result : {
|
|
294
|
+
data: route.pick(result.data),
|
|
295
|
+
error: null
|
|
296
|
+
};
|
|
297
|
+
if (asked !== generation) return outcome;
|
|
298
|
+
reads.delete(name);
|
|
299
|
+
setResource(name, outcome.error ? {
|
|
300
|
+
...state.resources[name],
|
|
301
|
+
error: outcome.error,
|
|
302
|
+
loading: false
|
|
303
|
+
} : {
|
|
304
|
+
data: outcome.data,
|
|
305
|
+
error: null,
|
|
306
|
+
loading: false
|
|
307
|
+
});
|
|
308
|
+
notice(outcome.error);
|
|
309
|
+
return outcome;
|
|
310
|
+
});
|
|
311
|
+
reads.set(name, read);
|
|
312
|
+
return read;
|
|
313
|
+
},
|
|
314
|
+
ensure(name) {
|
|
315
|
+
const resource = state.resources[name];
|
|
316
|
+
if (state.session.status !== "signed-in") return;
|
|
317
|
+
if (resource.loading || resource.data !== null || resource.error !== null) return;
|
|
318
|
+
store.load(name);
|
|
319
|
+
},
|
|
320
|
+
put(name, update) {
|
|
321
|
+
const data = update(state.resources[name].data);
|
|
322
|
+
if (data !== null) setResource(name, {
|
|
323
|
+
...state.resources[name],
|
|
324
|
+
data,
|
|
325
|
+
error: null
|
|
326
|
+
});
|
|
327
|
+
},
|
|
328
|
+
notice
|
|
329
|
+
};
|
|
330
|
+
return store;
|
|
331
|
+
}
|
|
332
|
+
/** Milliseconds before asking again after `failures` failures: 1, 2, 4... seconds, at most 30. */
|
|
333
|
+
function backoff(failures) {
|
|
334
|
+
return Math.min(1e3 * 2 ** failures, 3e4);
|
|
335
|
+
}
|
|
336
|
+
function sessionOf(info) {
|
|
337
|
+
if (!info?.user) return {
|
|
338
|
+
status: "signed-out",
|
|
339
|
+
user: null,
|
|
340
|
+
expiresAt: null,
|
|
341
|
+
error: null
|
|
342
|
+
};
|
|
343
|
+
return {
|
|
344
|
+
status: "signed-in",
|
|
345
|
+
user: info.user,
|
|
346
|
+
expiresAt: info.expiresAt,
|
|
347
|
+
error: null
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
//#endregion
|
|
351
|
+
//#region src/provider.tsx
|
|
352
|
+
const SynqContext = createContext(null);
|
|
353
|
+
/** How long after asking who is signed in the provider waits before asking again when the page is back in view. */
|
|
354
|
+
const RECHECK_MS = 3e4;
|
|
355
|
+
/** Synq for the components under it. Its route mount and `fetch` are read once, when it mounts. */
|
|
356
|
+
function SynqProvider({ initialSession, basePath = "/api/auth", plugins, fetch: fetcher, navigate, messages, children }) {
|
|
357
|
+
const [{ store, request, base }] = useState(() => {
|
|
358
|
+
const base = `/${basePath.replace(/^\/+|\/+$/g, "")}`;
|
|
359
|
+
const request = mountRequest(base, fetcher ?? ((input, init) => fetch(input, init)));
|
|
360
|
+
return {
|
|
361
|
+
store: createStore(request, initialSession),
|
|
362
|
+
request,
|
|
363
|
+
base
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
if (initialSession !== void 0) store.setSession(initialSession);
|
|
368
|
+
}, [store, initialSession]);
|
|
369
|
+
useEffect(() => {
|
|
370
|
+
store.start();
|
|
371
|
+
let asked = Date.now();
|
|
372
|
+
const recheck = () => {
|
|
373
|
+
if (document.visibilityState !== "visible" || Date.now() - asked < RECHECK_MS) return;
|
|
374
|
+
asked = Date.now();
|
|
375
|
+
store.refreshSession();
|
|
376
|
+
};
|
|
377
|
+
document.addEventListener("visibilitychange", recheck);
|
|
378
|
+
return () => {
|
|
379
|
+
document.removeEventListener("visibilitychange", recheck);
|
|
380
|
+
store.stop();
|
|
381
|
+
};
|
|
382
|
+
}, [store]);
|
|
383
|
+
const value = useMemo(() => ({
|
|
384
|
+
store,
|
|
385
|
+
request,
|
|
386
|
+
basePath: base,
|
|
387
|
+
navigate: navigate ?? ((url) => window.location.assign(url)),
|
|
388
|
+
plugins: Object.fromEntries((plugins ?? []).map((plugin) => [plugin.id, plugin.actions(request)])),
|
|
389
|
+
messages: {
|
|
390
|
+
...DEFAULT_MESSAGES,
|
|
391
|
+
...messages
|
|
392
|
+
}
|
|
393
|
+
}), [
|
|
394
|
+
store,
|
|
395
|
+
request,
|
|
396
|
+
base,
|
|
397
|
+
navigate,
|
|
398
|
+
plugins,
|
|
399
|
+
messages
|
|
400
|
+
]);
|
|
401
|
+
return /* @__PURE__ */ jsx(SynqContext, {
|
|
402
|
+
value,
|
|
403
|
+
children
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
function useSynqContext() {
|
|
407
|
+
const context = use(SynqContext);
|
|
408
|
+
if (!context) throw new Error("@synqauth/react: put the app inside <SynqProvider>");
|
|
409
|
+
return context;
|
|
410
|
+
}
|
|
411
|
+
/** A browser plugin's actions, typed by the plugin, which must be one of the provider's `plugins`. */
|
|
412
|
+
function usePlugin(plugin) {
|
|
413
|
+
const { plugins } = useSynqContext();
|
|
414
|
+
if (!(plugin.id in plugins)) throw new Error(`@synqauth/react: pass the ${plugin.id} plugin to <SynqProvider plugins>`);
|
|
415
|
+
return plugins[plugin.id];
|
|
416
|
+
}
|
|
417
|
+
/** The components' words: the provider's `messages`, English for any it leaves out. */
|
|
418
|
+
function useSynqMessages() {
|
|
419
|
+
return useSynqContext().messages;
|
|
420
|
+
}
|
|
421
|
+
//#endregion
|
|
422
|
+
//#region src/hooks.ts
|
|
423
|
+
function useSynqState(select) {
|
|
424
|
+
const { store } = useSynqContext();
|
|
425
|
+
const read = () => select(store.getState());
|
|
426
|
+
return useSyncExternalStore(store.subscribe, read, read);
|
|
427
|
+
}
|
|
428
|
+
/** Who is signed in. It reads the session alone, and asks the account API nothing. */
|
|
429
|
+
function useSession() {
|
|
430
|
+
const { store } = useSynqContext();
|
|
431
|
+
const session = useSynqState((state) => state.session);
|
|
432
|
+
return useMemo(() => ({
|
|
433
|
+
status: session.status,
|
|
434
|
+
isLoaded: session.status !== "loading",
|
|
435
|
+
isSignedIn: session.status === "signed-in",
|
|
436
|
+
user: session.user,
|
|
437
|
+
expiresAt: session.expiresAt,
|
|
438
|
+
error: session.error,
|
|
439
|
+
refresh: () => store.refreshSession()
|
|
440
|
+
}), [session, store]);
|
|
441
|
+
}
|
|
442
|
+
/** Something the account API keeps, read once someone is signed in and shared by every component that asks. */
|
|
443
|
+
function useAccountData(name) {
|
|
444
|
+
const { store } = useSynqContext();
|
|
445
|
+
const status = useSynqState((state) => state.session.status);
|
|
446
|
+
const resource = useSynqState((state) => state.resources[name]);
|
|
447
|
+
useEffect(() => {
|
|
448
|
+
if (status === "signed-in") store.ensure(name);
|
|
449
|
+
}, [
|
|
450
|
+
status,
|
|
451
|
+
store,
|
|
452
|
+
name
|
|
453
|
+
]);
|
|
454
|
+
return useMemo(() => ({
|
|
455
|
+
data: resource.data,
|
|
456
|
+
error: resource.error,
|
|
457
|
+
isLoading: resource.loading || status === "signed-in" && resource.data === null && resource.error === null,
|
|
458
|
+
reload: async () => {
|
|
459
|
+
await store.load(name);
|
|
460
|
+
}
|
|
461
|
+
}), [
|
|
462
|
+
resource,
|
|
463
|
+
status,
|
|
464
|
+
store,
|
|
465
|
+
name
|
|
466
|
+
]);
|
|
467
|
+
}
|
|
468
|
+
/** The person's wallets, the primary one marked. */
|
|
469
|
+
function useWallets() {
|
|
470
|
+
return useAccountData("wallets");
|
|
471
|
+
}
|
|
472
|
+
/** The ways the person signs in: their email address, wallets, social accounts. */
|
|
473
|
+
function useLinkedAccounts() {
|
|
474
|
+
return useAccountData("accounts");
|
|
475
|
+
}
|
|
476
|
+
/** The person's sessions at the brand, on every device. */
|
|
477
|
+
function useAccountSessions() {
|
|
478
|
+
return useAccountData("sessions");
|
|
479
|
+
}
|
|
480
|
+
/** The person's API keys for the brand's APIs (AERO-1693), revoked and expired ones too. */
|
|
481
|
+
function useApiKeys() {
|
|
482
|
+
return useAccountData("apiKeys");
|
|
483
|
+
}
|
|
484
|
+
/** The brand's APIs that let the person make keys, with the scopes each defines. */
|
|
485
|
+
function useKeyableApis() {
|
|
486
|
+
return useAccountData("keyableApis");
|
|
487
|
+
}
|
|
488
|
+
/** The signed-in person: their ID token's claims at once, then their profile and metadata. */
|
|
489
|
+
function useUser() {
|
|
490
|
+
const session = useSession();
|
|
491
|
+
const profile = useAccountData("profile");
|
|
492
|
+
const metadata = useAccountData("metadata");
|
|
493
|
+
const { store } = useSynqContext();
|
|
494
|
+
const change = useChanges();
|
|
495
|
+
const update = useCallback(async (changes) => {
|
|
496
|
+
const result = await change("PATCH", "v1/profile", changes);
|
|
497
|
+
if (result.data) store.put("profile", () => result.data);
|
|
498
|
+
return result;
|
|
499
|
+
}, [change, store]);
|
|
500
|
+
return useMemo(() => ({
|
|
501
|
+
isLoaded: session.isLoaded,
|
|
502
|
+
isSignedIn: session.isSignedIn,
|
|
503
|
+
user: session.user,
|
|
504
|
+
profile: profile.data,
|
|
505
|
+
metadata: metadata.data,
|
|
506
|
+
isLoading: profile.isLoading || metadata.isLoading,
|
|
507
|
+
error: profile.error ?? metadata.error,
|
|
508
|
+
update,
|
|
509
|
+
reload: async () => {
|
|
510
|
+
await Promise.all([profile.reload(), metadata.reload()]);
|
|
511
|
+
}
|
|
512
|
+
}), [
|
|
513
|
+
session,
|
|
514
|
+
profile,
|
|
515
|
+
metadata,
|
|
516
|
+
update
|
|
517
|
+
]);
|
|
518
|
+
}
|
|
519
|
+
/** Everything the signed-in person can do. */
|
|
520
|
+
function useAuth() {
|
|
521
|
+
const { store, request, basePath, navigate, plugins } = useSynqContext();
|
|
522
|
+
const session = useSession();
|
|
523
|
+
const change = useChanges();
|
|
524
|
+
const signingOut = useRef(null);
|
|
525
|
+
return useMemo(() => {
|
|
526
|
+
const signInUrl = (options = {}) => {
|
|
527
|
+
const params = new URLSearchParams({ returnTo: options.returnTo ?? currentPath$1() });
|
|
528
|
+
if (options.method) params.set("method", options.method);
|
|
529
|
+
if (options.loginHint) params.set("login_hint", options.loginHint);
|
|
530
|
+
if (options.prompt) params.set("prompt", options.prompt);
|
|
531
|
+
if (options.maxAge !== void 0) params.set("max_age", String(options.maxAge));
|
|
532
|
+
if (options.intent) params.set("intent", options.intent);
|
|
533
|
+
return `${basePath}/sign-in?${params.toString()}`;
|
|
534
|
+
};
|
|
535
|
+
const read = (name) => () => store.load(name);
|
|
536
|
+
const id = (value) => encodeURIComponent(value);
|
|
537
|
+
return {
|
|
538
|
+
isLoaded: session.isLoaded,
|
|
539
|
+
isSignedIn: session.isSignedIn,
|
|
540
|
+
userId: session.user?.sub ?? null,
|
|
541
|
+
signIn: (options) => navigate(signInUrl(options)),
|
|
542
|
+
signInUrl,
|
|
543
|
+
signOut() {
|
|
544
|
+
signingOut.current ??= call(request, "sign-out", { method: "POST" }).then((result) => {
|
|
545
|
+
signingOut.current = null;
|
|
546
|
+
if (result.error) return result;
|
|
547
|
+
store.setSession(null);
|
|
548
|
+
navigate(result.data.redirect);
|
|
549
|
+
return {
|
|
550
|
+
data: null,
|
|
551
|
+
error: null
|
|
552
|
+
};
|
|
553
|
+
});
|
|
554
|
+
return signingOut.current;
|
|
555
|
+
},
|
|
556
|
+
async link(method, options = {}) {
|
|
557
|
+
const result = await call(request, "link", {
|
|
558
|
+
method: "POST",
|
|
559
|
+
headers: { "content-type": "application/json" },
|
|
560
|
+
body: JSON.stringify({
|
|
561
|
+
method,
|
|
562
|
+
returnTo: options.returnTo ?? currentPath$1()
|
|
563
|
+
})
|
|
564
|
+
});
|
|
565
|
+
if (result.error) return result;
|
|
566
|
+
navigate(result.data.redirect);
|
|
567
|
+
return {
|
|
568
|
+
data: null,
|
|
569
|
+
error: null
|
|
570
|
+
};
|
|
571
|
+
},
|
|
572
|
+
wallets: {
|
|
573
|
+
list: read("wallets"),
|
|
574
|
+
async rename(wallet, label) {
|
|
575
|
+
const result = await change("PATCH", `v1/wallets/${id(wallet)}`, { label });
|
|
576
|
+
const { data } = result;
|
|
577
|
+
if (data) store.put("wallets", (list) => list && list.map((w) => w.id === data.id ? data : w));
|
|
578
|
+
return result;
|
|
579
|
+
},
|
|
580
|
+
async setPrimary(wallet) {
|
|
581
|
+
const result = await change("PATCH", `v1/wallets/${id(wallet)}`, { primary: true });
|
|
582
|
+
const { data } = result;
|
|
583
|
+
if (data) store.put("wallets", (list) => list && list.map((w) => w.id === data.id ? data : {
|
|
584
|
+
...w,
|
|
585
|
+
primary: false
|
|
586
|
+
}));
|
|
587
|
+
return result;
|
|
588
|
+
}
|
|
589
|
+
},
|
|
590
|
+
accounts: {
|
|
591
|
+
list: read("accounts"),
|
|
592
|
+
async unlink(account) {
|
|
593
|
+
const result = await change("DELETE", `v1/identities/${id(account)}`);
|
|
594
|
+
if (!result.error) store.put("accounts", (list) => list && list.filter((a) => a.id !== account));
|
|
595
|
+
return result;
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
sessions: {
|
|
599
|
+
list: read("sessions"),
|
|
600
|
+
async end(ended) {
|
|
601
|
+
const result = await change("DELETE", `v1/sessions/${id(ended)}`);
|
|
602
|
+
if (!result.error) store.put("sessions", (list) => list && list.filter((s) => s.id !== ended));
|
|
603
|
+
return result;
|
|
604
|
+
}
|
|
605
|
+
},
|
|
606
|
+
apiKeys: {
|
|
607
|
+
list: read("apiKeys"),
|
|
608
|
+
async create(input) {
|
|
609
|
+
const result = await change("POST", "v1/api-keys", input);
|
|
610
|
+
const { data } = result;
|
|
611
|
+
if (data) {
|
|
612
|
+
const made = withoutKey(data);
|
|
613
|
+
store.put("apiKeys", (list) => list && [...list, made]);
|
|
614
|
+
}
|
|
615
|
+
return result;
|
|
616
|
+
},
|
|
617
|
+
async revoke(revoked) {
|
|
618
|
+
const result = await change("DELETE", `v1/api-keys/${id(revoked)}`);
|
|
619
|
+
if (!result.error) {
|
|
620
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
621
|
+
store.put("apiKeys", (list) => list && list.map((k) => k.id === revoked ? {
|
|
622
|
+
...k,
|
|
623
|
+
revoked_at: at
|
|
624
|
+
} : k));
|
|
625
|
+
}
|
|
626
|
+
return result;
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
plugins
|
|
630
|
+
};
|
|
631
|
+
}, [
|
|
632
|
+
store,
|
|
633
|
+
request,
|
|
634
|
+
basePath,
|
|
635
|
+
navigate,
|
|
636
|
+
plugins,
|
|
637
|
+
session,
|
|
638
|
+
change
|
|
639
|
+
]);
|
|
640
|
+
}
|
|
641
|
+
/** A new key as the list keeps it: its description, never the key itself. */
|
|
642
|
+
function withoutKey(made) {
|
|
643
|
+
const copy = { ...made };
|
|
644
|
+
delete copy.key;
|
|
645
|
+
return copy;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Sends changes to the account API for one mounted component. Each change
|
|
649
|
+
* carries an idempotency key minted here; the key is used again only when
|
|
650
|
+
* no answer came back, so a retry is the same request to Synq. A change
|
|
651
|
+
* asked again while it is under way joins it rather than sending another.
|
|
652
|
+
*/
|
|
653
|
+
function useChanges() {
|
|
654
|
+
const { store, request } = useSynqContext();
|
|
655
|
+
const keys = useRef(/* @__PURE__ */ new Map());
|
|
656
|
+
const running = useRef(/* @__PURE__ */ new Map());
|
|
657
|
+
return useCallback((method, path, body) => {
|
|
658
|
+
const json = body === void 0 ? void 0 : JSON.stringify(body);
|
|
659
|
+
const change = `${method} ${path} ${json ?? ""}`;
|
|
660
|
+
const already = running.current.get(change);
|
|
661
|
+
if (already) return already;
|
|
662
|
+
let key = keys.current.get(change);
|
|
663
|
+
if (!key) {
|
|
664
|
+
key = crypto.randomUUID();
|
|
665
|
+
keys.current.set(change, key);
|
|
666
|
+
}
|
|
667
|
+
const headers = new Headers({ "idempotency-key": key });
|
|
668
|
+
if (json !== void 0) headers.set("content-type", "application/json");
|
|
669
|
+
const sent = call(request, `account/${path}`, {
|
|
670
|
+
method,
|
|
671
|
+
headers,
|
|
672
|
+
body: json
|
|
673
|
+
}).then((result) => {
|
|
674
|
+
running.current.delete(change);
|
|
675
|
+
if (result.error?.code !== "network_error") keys.current.delete(change);
|
|
676
|
+
store.notice(result.error);
|
|
677
|
+
return result.error ? result : {
|
|
678
|
+
data: result.data ?? null,
|
|
679
|
+
error: null
|
|
680
|
+
};
|
|
681
|
+
});
|
|
682
|
+
running.current.set(change, sent);
|
|
683
|
+
return sent;
|
|
684
|
+
}, [store, request]);
|
|
685
|
+
}
|
|
686
|
+
function currentPath$1() {
|
|
687
|
+
if (typeof window === "undefined") return "/";
|
|
688
|
+
return `${window.location.pathname}${window.location.search}`;
|
|
689
|
+
}
|
|
690
|
+
//#endregion
|
|
691
|
+
//#region src/sign-in.ts
|
|
692
|
+
function webAuthn() {
|
|
693
|
+
return globalThis.PublicKeyCredential;
|
|
694
|
+
}
|
|
695
|
+
/** Whether this browser takes the issuer's passkeys from this page, and offers them as people type. */
|
|
696
|
+
async function passkeyCapabilities() {
|
|
697
|
+
const statics = webAuthn();
|
|
698
|
+
if (!statics?.getClientCapabilities || !statics.parseRequestOptionsFromJSON) return {
|
|
699
|
+
supported: false,
|
|
700
|
+
autofill: false
|
|
701
|
+
};
|
|
702
|
+
try {
|
|
703
|
+
const capabilities = await statics.getClientCapabilities();
|
|
704
|
+
const supported = capabilities.relatedOrigins === true;
|
|
705
|
+
return {
|
|
706
|
+
supported,
|
|
707
|
+
autofill: supported && capabilities.conditionalGet === true
|
|
708
|
+
};
|
|
709
|
+
} catch {
|
|
710
|
+
return {
|
|
711
|
+
supported: false,
|
|
712
|
+
autofill: false
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/** One sign-in on the app's own screens, through the route mount's `sign-in/flow`. */
|
|
717
|
+
function useSignIn(options = {}) {
|
|
718
|
+
const { request, navigate, store } = useSynqContext();
|
|
719
|
+
const auth = useAuth();
|
|
720
|
+
const [step, setStep] = useState("start");
|
|
721
|
+
const [pending, setPending] = useState(0);
|
|
722
|
+
const [problem, setProblem] = useState(null);
|
|
723
|
+
const [email, setEmail] = useState(void 0);
|
|
724
|
+
const [canSkip, setCanSkip] = useState(false);
|
|
725
|
+
const [handover, setHandover] = useState(null);
|
|
726
|
+
const [relatedDomain, setRelatedDomain] = useState(null);
|
|
727
|
+
const [capabilities, setCapabilities] = useState({
|
|
728
|
+
supported: false,
|
|
729
|
+
autofill: false
|
|
730
|
+
});
|
|
731
|
+
const autofilling = useRef(null);
|
|
732
|
+
const latest = useRef(options);
|
|
733
|
+
useEffect(() => {
|
|
734
|
+
latest.current = options;
|
|
735
|
+
});
|
|
736
|
+
const { returnTo } = options;
|
|
737
|
+
useEffect(() => {
|
|
738
|
+
let live = true;
|
|
739
|
+
passkeyCapabilities().then((found) => {
|
|
740
|
+
if (live) setCapabilities(found);
|
|
741
|
+
});
|
|
742
|
+
return () => {
|
|
743
|
+
live = false;
|
|
744
|
+
autofilling.current?.abort();
|
|
745
|
+
};
|
|
746
|
+
}, []);
|
|
747
|
+
const actions = useMemo(() => {
|
|
748
|
+
async function post(body) {
|
|
749
|
+
setPending((n) => n + 1);
|
|
750
|
+
try {
|
|
751
|
+
const result = await call(request, "sign-in/flow", {
|
|
752
|
+
method: "POST",
|
|
753
|
+
headers: { "content-type": "application/json" },
|
|
754
|
+
body: JSON.stringify({
|
|
755
|
+
...body,
|
|
756
|
+
returnTo: latest.current.returnTo ?? currentPath()
|
|
757
|
+
})
|
|
758
|
+
});
|
|
759
|
+
if (result.error) {
|
|
760
|
+
setProblem({
|
|
761
|
+
code: "unavailable",
|
|
762
|
+
message: result.error.message
|
|
763
|
+
});
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
return result.data;
|
|
767
|
+
} finally {
|
|
768
|
+
setPending((n) => n - 1);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/** Shows what the mount answered. */
|
|
772
|
+
async function show(answer) {
|
|
773
|
+
if (!answer) return;
|
|
774
|
+
switch (answer.status) {
|
|
775
|
+
case "signed-in":
|
|
776
|
+
autofilling.current?.abort();
|
|
777
|
+
setStep("done");
|
|
778
|
+
setProblem(null);
|
|
779
|
+
await store.refreshSession();
|
|
780
|
+
if (latest.current.onSignedIn) latest.current.onSignedIn(answer.returnTo);
|
|
781
|
+
else navigate(answer.returnTo);
|
|
782
|
+
return;
|
|
783
|
+
case "code-sent":
|
|
784
|
+
setStep("code");
|
|
785
|
+
setEmail(answer.email);
|
|
786
|
+
setProblem(null);
|
|
787
|
+
return;
|
|
788
|
+
case "verify-email":
|
|
789
|
+
setStep("verify-email");
|
|
790
|
+
setEmail(answer.email);
|
|
791
|
+
setCanSkip(answer.canSkip);
|
|
792
|
+
setProblem(null);
|
|
793
|
+
return;
|
|
794
|
+
case "passkey":
|
|
795
|
+
setRelatedDomain(answer.passkey.related?.rpId ?? null);
|
|
796
|
+
if (answer.problem) setProblem({
|
|
797
|
+
code: answer.problem,
|
|
798
|
+
message: answer.message ?? ""
|
|
799
|
+
});
|
|
800
|
+
return;
|
|
801
|
+
case "retry":
|
|
802
|
+
setProblem({
|
|
803
|
+
code: answer.problem,
|
|
804
|
+
message: answer.message,
|
|
805
|
+
...answer.retryAfter === void 0 ? {} : { retryAfter: answer.retryAfter }
|
|
806
|
+
});
|
|
807
|
+
return;
|
|
808
|
+
case "hosted":
|
|
809
|
+
autofilling.current?.abort();
|
|
810
|
+
setHandover(answer.hosted);
|
|
811
|
+
return;
|
|
812
|
+
case "refused":
|
|
813
|
+
setStep("start");
|
|
814
|
+
setProblem({
|
|
815
|
+
code: "refused",
|
|
816
|
+
reason: answer.reason,
|
|
817
|
+
message: answer.message
|
|
818
|
+
});
|
|
819
|
+
return;
|
|
820
|
+
case "expired":
|
|
821
|
+
setStep("start");
|
|
822
|
+
setProblem({
|
|
823
|
+
code: "expired",
|
|
824
|
+
message: answer.message
|
|
825
|
+
});
|
|
826
|
+
return;
|
|
827
|
+
case "restarted": return;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
const run = async (body) => show(await post(body));
|
|
831
|
+
/** Asks Synq for a passkey's options, the browser for the passkey, and Synq to take it. */
|
|
832
|
+
async function passkey(related, mediation) {
|
|
833
|
+
const statics = webAuthn();
|
|
834
|
+
if (!statics?.parseRequestOptionsFromJSON) return;
|
|
835
|
+
autofilling.current?.abort();
|
|
836
|
+
const answer = await post({ step: "passkey-options" });
|
|
837
|
+
if (answer?.status !== "passkey") {
|
|
838
|
+
if (answer?.status === "hosted" && answer.hosted.reason === "passkey_unavailable") setCapabilities({
|
|
839
|
+
supported: false,
|
|
840
|
+
autofill: false
|
|
841
|
+
});
|
|
842
|
+
if (mediation) return;
|
|
843
|
+
return show(answer);
|
|
844
|
+
}
|
|
845
|
+
setRelatedDomain(answer.passkey.related?.rpId ?? null);
|
|
846
|
+
const chosen = related ? answer.passkey.related : void 0;
|
|
847
|
+
const controller = new AbortController();
|
|
848
|
+
if (mediation) autofilling.current = controller;
|
|
849
|
+
let credential;
|
|
850
|
+
try {
|
|
851
|
+
credential = await navigator.credentials.get({
|
|
852
|
+
publicKey: statics.parseRequestOptionsFromJSON(chosen?.options ?? answer.passkey.options),
|
|
853
|
+
signal: controller.signal,
|
|
854
|
+
...mediation ? { mediation } : {}
|
|
855
|
+
});
|
|
856
|
+
} catch (error) {
|
|
857
|
+
if (!mediation && !controller.signal.aborted) setProblem({
|
|
858
|
+
code: "passkey_cancelled",
|
|
859
|
+
message: String(error)
|
|
860
|
+
});
|
|
861
|
+
return;
|
|
862
|
+
} finally {
|
|
863
|
+
if (autofilling.current === controller) autofilling.current = null;
|
|
864
|
+
}
|
|
865
|
+
if (!credential) return;
|
|
866
|
+
await run({
|
|
867
|
+
step: "passkey",
|
|
868
|
+
credential: credential.toJSON(),
|
|
869
|
+
related: Boolean(chosen)
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
return {
|
|
873
|
+
password: {
|
|
874
|
+
signIn: ({ email: address, password }) => run({
|
|
875
|
+
step: "password",
|
|
876
|
+
email: address,
|
|
877
|
+
password
|
|
878
|
+
}),
|
|
879
|
+
signUp: ({ email: address, password }) => run({
|
|
880
|
+
step: "password",
|
|
881
|
+
screen: "sign-up",
|
|
882
|
+
email: address,
|
|
883
|
+
password
|
|
884
|
+
})
|
|
885
|
+
},
|
|
886
|
+
emailCode: {
|
|
887
|
+
send: (address) => run({
|
|
888
|
+
step: "email",
|
|
889
|
+
email: address
|
|
890
|
+
}),
|
|
891
|
+
verify: (code) => run({
|
|
892
|
+
step: "code",
|
|
893
|
+
code
|
|
894
|
+
}),
|
|
895
|
+
resend: () => run({ step: "resend" })
|
|
896
|
+
},
|
|
897
|
+
verifyEmail: {
|
|
898
|
+
verify: (code) => run({
|
|
899
|
+
step: "code",
|
|
900
|
+
code
|
|
901
|
+
}),
|
|
902
|
+
resend: () => run({ step: "resend" }),
|
|
903
|
+
skip: () => run({ step: "skip" })
|
|
904
|
+
},
|
|
905
|
+
passkeySignIn: ({ related = false } = {}) => passkey(related),
|
|
906
|
+
passkeyAutofill: () => passkey(false, "conditional"),
|
|
907
|
+
startOver: async () => {
|
|
908
|
+
autofilling.current?.abort();
|
|
909
|
+
await post({ step: "restart" });
|
|
910
|
+
setStep("start");
|
|
911
|
+
setProblem(null);
|
|
912
|
+
setEmail(void 0);
|
|
913
|
+
setCanSkip(false);
|
|
914
|
+
setHandover(null);
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
}, [
|
|
918
|
+
request,
|
|
919
|
+
navigate,
|
|
920
|
+
store
|
|
921
|
+
]);
|
|
922
|
+
const startAutofill = useCallback(() => capabilities.autofill ? actions.passkeyAutofill() : Promise.resolve(), [capabilities.autofill, actions]);
|
|
923
|
+
const continueOnHostedPage = useCallback((overrides = {}) => {
|
|
924
|
+
const method = overrides.method ?? handover?.method;
|
|
925
|
+
const loginHint = overrides.loginHint ?? handover?.loginHint ?? email;
|
|
926
|
+
auth.signIn({
|
|
927
|
+
...returnTo ? { returnTo } : {},
|
|
928
|
+
...method ? { method } : {},
|
|
929
|
+
...loginHint ? { loginHint } : {}
|
|
930
|
+
});
|
|
931
|
+
}, [
|
|
932
|
+
auth,
|
|
933
|
+
handover,
|
|
934
|
+
email,
|
|
935
|
+
returnTo
|
|
936
|
+
]);
|
|
937
|
+
return {
|
|
938
|
+
step,
|
|
939
|
+
pending: pending > 0,
|
|
940
|
+
problem,
|
|
941
|
+
email,
|
|
942
|
+
canSkip,
|
|
943
|
+
handover,
|
|
944
|
+
password: actions.password,
|
|
945
|
+
emailCode: actions.emailCode,
|
|
946
|
+
verifyEmail: actions.verifyEmail,
|
|
947
|
+
passkey: {
|
|
948
|
+
supported: capabilities.supported,
|
|
949
|
+
autofill: capabilities.autofill,
|
|
950
|
+
relatedDomain,
|
|
951
|
+
signIn: actions.passkeySignIn,
|
|
952
|
+
startAutofill
|
|
953
|
+
},
|
|
954
|
+
continueOnHostedPage,
|
|
955
|
+
startOver: actions.startOver
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
function currentPath() {
|
|
959
|
+
if (typeof window === "undefined") return "/";
|
|
960
|
+
return `${window.location.pathname}${window.location.search}`;
|
|
961
|
+
}
|
|
962
|
+
//#endregion
|
|
963
|
+
//#region src/components/SignedIn.tsx
|
|
964
|
+
/** Shows its children only while someone is signed in. It renders no element of its own. */
|
|
965
|
+
function SignedIn({ children }) {
|
|
966
|
+
return useSession().isSignedIn ? children : null;
|
|
967
|
+
}
|
|
968
|
+
//#endregion
|
|
969
|
+
//#region src/components/SignedOut.tsx
|
|
970
|
+
/** Shows its children only once the app has said nobody is signed in. It renders no element of its own. */
|
|
971
|
+
function SignedOut({ children }) {
|
|
972
|
+
return useSession().status === "signed-out" ? children : null;
|
|
973
|
+
}
|
|
974
|
+
//#endregion
|
|
975
|
+
//#region src/components/styling.ts
|
|
976
|
+
/** The classes of an inner element: `synq-<name>` for each name, or none when unstyled. */
|
|
977
|
+
function styler(unstyled) {
|
|
978
|
+
return (...names) => unstyled ? void 0 : names.map((name) => `synq-${name}`).join(" ");
|
|
979
|
+
}
|
|
980
|
+
/** The classes of a component's outer element: `synq` and its own when styled, and the app's. */
|
|
981
|
+
function rootClass(unstyled, name, className) {
|
|
982
|
+
const names = unstyled ? [] : ["synq", `synq-${name}`];
|
|
983
|
+
if (className) names.push(className);
|
|
984
|
+
return names.length > 0 ? names.join(" ") : void 0;
|
|
985
|
+
}
|
|
986
|
+
/** What to tell the person when a change failed: the problem's field messages, or its detail. */
|
|
987
|
+
function failureText(error) {
|
|
988
|
+
const fields = (error.cause?.errors ?? []).map((field) => field.message).filter((message) => typeof message === "string");
|
|
989
|
+
return fields.length > 0 ? fields.join(". ") : error.message;
|
|
990
|
+
}
|
|
991
|
+
/** A long address, shortened to its ends: `seedYs…dRKA`. */
|
|
992
|
+
function shortAddress(address) {
|
|
993
|
+
return address.length > 12 ? `${address.slice(0, 6)}…${address.slice(-4)}` : address;
|
|
994
|
+
}
|
|
995
|
+
//#endregion
|
|
996
|
+
//#region src/components/SignInButton.tsx
|
|
997
|
+
/** A button that sends the browser to sign in, through the app's route mount. */
|
|
998
|
+
function SignInButton({ unstyled, className, children, ...options }) {
|
|
999
|
+
const { signIn } = useAuth();
|
|
1000
|
+
const m = useSynqMessages();
|
|
1001
|
+
return /* @__PURE__ */ jsx("button", {
|
|
1002
|
+
type: "button",
|
|
1003
|
+
className: rootClass(unstyled, "sign-in-button", className),
|
|
1004
|
+
onClick: () => signIn(options),
|
|
1005
|
+
children: children ?? m.signIn
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
//#endregion
|
|
1009
|
+
//#region src/components/SignInForm.tsx
|
|
1010
|
+
const ALL_METHODS = [
|
|
1011
|
+
"passkey",
|
|
1012
|
+
"email",
|
|
1013
|
+
"password"
|
|
1014
|
+
];
|
|
1015
|
+
/**
|
|
1016
|
+
* Sign-in on the app's own page: a passkey where the browser takes the
|
|
1017
|
+
* issuer's, then an emailed code or a password, and the code or the new
|
|
1018
|
+
* address's proof after. Whatever only the hosted page can do, it goes
|
|
1019
|
+
* there for.
|
|
1020
|
+
*/
|
|
1021
|
+
function SignInForm({ methods = ALL_METHODS, screen: initialScreen = "sign-in", returnTo, onSignedIn, unstyled, className }) {
|
|
1022
|
+
const signIn = useSignIn({
|
|
1023
|
+
...returnTo === void 0 ? {} : { returnTo },
|
|
1024
|
+
...onSignedIn === void 0 ? {} : { onSignedIn }
|
|
1025
|
+
});
|
|
1026
|
+
const m = useSynqMessages();
|
|
1027
|
+
const c = styler(unstyled);
|
|
1028
|
+
const [screen, setScreen] = useState(initialScreen);
|
|
1029
|
+
const passwords = methods.includes("password");
|
|
1030
|
+
const codes = methods.includes("email");
|
|
1031
|
+
const passkeys = methods.includes("passkey") && signIn.passkey.supported;
|
|
1032
|
+
const signingUp = screen === "sign-up" && passwords;
|
|
1033
|
+
const { step, handover, continueOnHostedPage } = signIn;
|
|
1034
|
+
useEffect(() => {
|
|
1035
|
+
if (handover) continueOnHostedPage();
|
|
1036
|
+
}, [handover, continueOnHostedPage]);
|
|
1037
|
+
const root = rootClass(unstyled, "sign-in-form", className);
|
|
1038
|
+
if (handover || step === "done") return /* @__PURE__ */ jsx("div", {
|
|
1039
|
+
className: root,
|
|
1040
|
+
children: /* @__PURE__ */ jsx("p", {
|
|
1041
|
+
className: c("muted"),
|
|
1042
|
+
role: "status",
|
|
1043
|
+
children: handover ? m.continuingToSignIn : m.signedIn
|
|
1044
|
+
})
|
|
1045
|
+
});
|
|
1046
|
+
if (step === "code") return /* @__PURE__ */ jsx("div", {
|
|
1047
|
+
className: root,
|
|
1048
|
+
children: /* @__PURE__ */ jsx(EmailCodeForm, {
|
|
1049
|
+
signIn,
|
|
1050
|
+
unstyled
|
|
1051
|
+
})
|
|
1052
|
+
});
|
|
1053
|
+
if (step === "verify-email") return /* @__PURE__ */ jsx("div", {
|
|
1054
|
+
className: root,
|
|
1055
|
+
children: /* @__PURE__ */ jsx(VerifyEmailForm, {
|
|
1056
|
+
signIn,
|
|
1057
|
+
unstyled
|
|
1058
|
+
})
|
|
1059
|
+
});
|
|
1060
|
+
const others = codes || passwords;
|
|
1061
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1062
|
+
className: root,
|
|
1063
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
1064
|
+
className: c("form"),
|
|
1065
|
+
children: [
|
|
1066
|
+
passkeys && !signingUp ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1067
|
+
/* @__PURE__ */ jsx(PasskeyButton, {
|
|
1068
|
+
signIn,
|
|
1069
|
+
unstyled
|
|
1070
|
+
}),
|
|
1071
|
+
signIn.passkey.relatedDomain ? /* @__PURE__ */ jsx(PasskeyButton, {
|
|
1072
|
+
signIn,
|
|
1073
|
+
related: true,
|
|
1074
|
+
unstyled
|
|
1075
|
+
}) : null,
|
|
1076
|
+
others ? /* @__PURE__ */ jsx("p", {
|
|
1077
|
+
className: c("muted"),
|
|
1078
|
+
children: m.or
|
|
1079
|
+
}) : null
|
|
1080
|
+
] }) : null,
|
|
1081
|
+
passwords ? /* @__PURE__ */ jsx(PasswordForm, {
|
|
1082
|
+
signIn,
|
|
1083
|
+
screen: signingUp ? "sign-up" : "sign-in",
|
|
1084
|
+
emailCode: codes,
|
|
1085
|
+
autofill: passkeys,
|
|
1086
|
+
unstyled
|
|
1087
|
+
}) : codes ? /* @__PURE__ */ jsx(EmailCodeForm, {
|
|
1088
|
+
signIn,
|
|
1089
|
+
autofill: passkeys,
|
|
1090
|
+
unstyled
|
|
1091
|
+
}) : signIn.problem ? /* @__PURE__ */ jsx(ProblemText, {
|
|
1092
|
+
problem: signIn.problem,
|
|
1093
|
+
m,
|
|
1094
|
+
c
|
|
1095
|
+
}) : null,
|
|
1096
|
+
passwords ? /* @__PURE__ */ jsxs("p", {
|
|
1097
|
+
className: c("muted"),
|
|
1098
|
+
children: [
|
|
1099
|
+
signingUp ? m.haveAnAccount : m.newHere,
|
|
1100
|
+
" ",
|
|
1101
|
+
/* @__PURE__ */ jsx("button", {
|
|
1102
|
+
type: "button",
|
|
1103
|
+
className: c("link"),
|
|
1104
|
+
onClick: () => setScreen(signingUp ? "sign-in" : "sign-up"),
|
|
1105
|
+
children: signingUp ? m.signIn : m.createAccount
|
|
1106
|
+
})
|
|
1107
|
+
]
|
|
1108
|
+
}) : null
|
|
1109
|
+
]
|
|
1110
|
+
})
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
/** The button for a passkey: the brand's, or with `related` its own host's on its custom domain. */
|
|
1114
|
+
function PasskeyButton({ signIn, related = false, children, unstyled, className }) {
|
|
1115
|
+
const m = useSynqMessages();
|
|
1116
|
+
const domain = signIn.passkey.relatedDomain;
|
|
1117
|
+
if (!signIn.passkey.supported || related && !domain) return null;
|
|
1118
|
+
return /* @__PURE__ */ jsx("button", {
|
|
1119
|
+
type: "button",
|
|
1120
|
+
className: rootClass(unstyled, "button", className),
|
|
1121
|
+
disabled: signIn.pending,
|
|
1122
|
+
onClick: () => void signIn.passkey.signIn({ related }),
|
|
1123
|
+
children: children ?? (related && domain ? m.passkeyFor(domain) : m.signInWithPasskey)
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* An email address and a password: signs in, or with `screen="sign-up"`
|
|
1128
|
+
* makes an account. With `emailCode`, it offers a code by email for the
|
|
1129
|
+
* same address too.
|
|
1130
|
+
*/
|
|
1131
|
+
function PasswordForm({ signIn, screen = "sign-in", emailCode = false, autofill = false, unstyled, className }) {
|
|
1132
|
+
const m = useSynqMessages();
|
|
1133
|
+
const c = styler(unstyled);
|
|
1134
|
+
const signingUp = screen === "sign-up";
|
|
1135
|
+
const onSubmit = (event) => {
|
|
1136
|
+
event.preventDefault();
|
|
1137
|
+
const data = new FormData(event.currentTarget);
|
|
1138
|
+
const input = {
|
|
1139
|
+
email: field(data, "email").trim(),
|
|
1140
|
+
password: field(data, "password")
|
|
1141
|
+
};
|
|
1142
|
+
signingUp ? signIn.password.signUp(input) : signIn.password.signIn(input);
|
|
1143
|
+
};
|
|
1144
|
+
return /* @__PURE__ */ jsx("form", {
|
|
1145
|
+
className: rootClass(unstyled, "password-form", className),
|
|
1146
|
+
onSubmit,
|
|
1147
|
+
"aria-busy": signIn.pending,
|
|
1148
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
1149
|
+
className: c("form"),
|
|
1150
|
+
children: [
|
|
1151
|
+
/* @__PURE__ */ jsx(EmailField, {
|
|
1152
|
+
signIn,
|
|
1153
|
+
autofill: autofill && !signingUp,
|
|
1154
|
+
signingUp,
|
|
1155
|
+
c
|
|
1156
|
+
}),
|
|
1157
|
+
/* @__PURE__ */ jsxs("label", {
|
|
1158
|
+
className: c("field"),
|
|
1159
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1160
|
+
className: c("label"),
|
|
1161
|
+
children: m.password
|
|
1162
|
+
}), /* @__PURE__ */ jsx("input", {
|
|
1163
|
+
className: c("input"),
|
|
1164
|
+
name: "password",
|
|
1165
|
+
type: "password",
|
|
1166
|
+
required: true,
|
|
1167
|
+
autoComplete: signingUp ? "new-password" : "current-password"
|
|
1168
|
+
})]
|
|
1169
|
+
}),
|
|
1170
|
+
signIn.problem ? /* @__PURE__ */ jsx(ProblemText, {
|
|
1171
|
+
problem: signIn.problem,
|
|
1172
|
+
m,
|
|
1173
|
+
c
|
|
1174
|
+
}) : null,
|
|
1175
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1176
|
+
className: c("actions"),
|
|
1177
|
+
children: [/* @__PURE__ */ jsx("button", {
|
|
1178
|
+
type: "submit",
|
|
1179
|
+
className: c("button", "button-primary"),
|
|
1180
|
+
disabled: signIn.pending,
|
|
1181
|
+
children: signingUp ? m.createAccount : m.signIn
|
|
1182
|
+
}), emailCode && !signingUp ? /* @__PURE__ */ jsx("button", {
|
|
1183
|
+
type: "button",
|
|
1184
|
+
className: c("button"),
|
|
1185
|
+
disabled: signIn.pending,
|
|
1186
|
+
onClick: (event) => {
|
|
1187
|
+
const email = event.currentTarget.form?.elements.namedItem("email");
|
|
1188
|
+
if (!(email instanceof HTMLInputElement) || !email.reportValidity()) return;
|
|
1189
|
+
signIn.emailCode.send(email.value.trim());
|
|
1190
|
+
},
|
|
1191
|
+
children: m.emailMeACode
|
|
1192
|
+
}) : null]
|
|
1193
|
+
}),
|
|
1194
|
+
signingUp ? null : /* @__PURE__ */ jsx("button", {
|
|
1195
|
+
type: "button",
|
|
1196
|
+
className: c("link"),
|
|
1197
|
+
onClick: (event) => {
|
|
1198
|
+
const email = event.currentTarget.form?.elements.namedItem("email");
|
|
1199
|
+
const typed = email instanceof HTMLInputElement ? email.value.trim() : "";
|
|
1200
|
+
signIn.continueOnHostedPage({
|
|
1201
|
+
method: "password",
|
|
1202
|
+
...typed ? { loginHint: typed } : {}
|
|
1203
|
+
});
|
|
1204
|
+
},
|
|
1205
|
+
children: m.forgotPassword
|
|
1206
|
+
})
|
|
1207
|
+
]
|
|
1208
|
+
})
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
/** An emailed code: the address to send it to, then the code it held. */
|
|
1212
|
+
function EmailCodeForm({ signIn, autofill = false, unstyled, className }) {
|
|
1213
|
+
const m = useSynqMessages();
|
|
1214
|
+
const c = styler(unstyled);
|
|
1215
|
+
const id = useId();
|
|
1216
|
+
const root = rootClass(unstyled, "email-code-form", className);
|
|
1217
|
+
const problem = signIn.problem ? /* @__PURE__ */ jsx(ProblemText, {
|
|
1218
|
+
problem: signIn.problem,
|
|
1219
|
+
m,
|
|
1220
|
+
c
|
|
1221
|
+
}) : null;
|
|
1222
|
+
if (signIn.step === "code") {
|
|
1223
|
+
const onSubmit = (event) => {
|
|
1224
|
+
event.preventDefault();
|
|
1225
|
+
signIn.emailCode.verify(field(new FormData(event.currentTarget), "code").trim());
|
|
1226
|
+
};
|
|
1227
|
+
return /* @__PURE__ */ jsx("form", {
|
|
1228
|
+
className: root,
|
|
1229
|
+
onSubmit,
|
|
1230
|
+
"aria-busy": signIn.pending,
|
|
1231
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
1232
|
+
className: c("form"),
|
|
1233
|
+
children: [
|
|
1234
|
+
/* @__PURE__ */ jsx("p", {
|
|
1235
|
+
className: c("muted"),
|
|
1236
|
+
id: `${id}-sent`,
|
|
1237
|
+
children: m.codeSentTo(signIn.email ?? "")
|
|
1238
|
+
}),
|
|
1239
|
+
/* @__PURE__ */ jsx(OneTimeCodeInput, {
|
|
1240
|
+
label: m.code,
|
|
1241
|
+
"aria-describedby": `${id}-sent`,
|
|
1242
|
+
unstyled
|
|
1243
|
+
}),
|
|
1244
|
+
problem,
|
|
1245
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1246
|
+
className: c("actions"),
|
|
1247
|
+
children: [
|
|
1248
|
+
/* @__PURE__ */ jsx("button", {
|
|
1249
|
+
type: "submit",
|
|
1250
|
+
className: c("button", "button-primary"),
|
|
1251
|
+
disabled: signIn.pending,
|
|
1252
|
+
children: m.verify
|
|
1253
|
+
}),
|
|
1254
|
+
/* @__PURE__ */ jsx("button", {
|
|
1255
|
+
type: "button",
|
|
1256
|
+
className: c("button"),
|
|
1257
|
+
disabled: signIn.pending,
|
|
1258
|
+
onClick: () => void signIn.emailCode.resend(),
|
|
1259
|
+
children: m.sendAnotherCode
|
|
1260
|
+
}),
|
|
1261
|
+
/* @__PURE__ */ jsx("button", {
|
|
1262
|
+
type: "button",
|
|
1263
|
+
className: c("button"),
|
|
1264
|
+
disabled: signIn.pending,
|
|
1265
|
+
onClick: () => void signIn.startOver(),
|
|
1266
|
+
children: m.useAnotherWay
|
|
1267
|
+
})
|
|
1268
|
+
]
|
|
1269
|
+
})
|
|
1270
|
+
]
|
|
1271
|
+
})
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
const onSubmit = (event) => {
|
|
1275
|
+
event.preventDefault();
|
|
1276
|
+
signIn.emailCode.send(field(new FormData(event.currentTarget), "email").trim());
|
|
1277
|
+
};
|
|
1278
|
+
return /* @__PURE__ */ jsx("form", {
|
|
1279
|
+
className: root,
|
|
1280
|
+
onSubmit,
|
|
1281
|
+
"aria-busy": signIn.pending,
|
|
1282
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
1283
|
+
className: c("form"),
|
|
1284
|
+
children: [
|
|
1285
|
+
/* @__PURE__ */ jsx(EmailField, {
|
|
1286
|
+
signIn,
|
|
1287
|
+
autofill,
|
|
1288
|
+
signingUp: false,
|
|
1289
|
+
c
|
|
1290
|
+
}),
|
|
1291
|
+
problem,
|
|
1292
|
+
/* @__PURE__ */ jsx("div", {
|
|
1293
|
+
className: c("actions"),
|
|
1294
|
+
children: /* @__PURE__ */ jsx("button", {
|
|
1295
|
+
type: "submit",
|
|
1296
|
+
className: c("button", "button-primary"),
|
|
1297
|
+
disabled: signIn.pending,
|
|
1298
|
+
children: m.emailMeACode
|
|
1299
|
+
})
|
|
1300
|
+
})
|
|
1301
|
+
]
|
|
1302
|
+
})
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
/** The code that proves a new account's address, or "Skip for now" where the brand allows. */
|
|
1306
|
+
function VerifyEmailForm({ signIn, unstyled, className }) {
|
|
1307
|
+
const m = useSynqMessages();
|
|
1308
|
+
const c = styler(unstyled);
|
|
1309
|
+
const id = useId();
|
|
1310
|
+
const onSubmit = (event) => {
|
|
1311
|
+
event.preventDefault();
|
|
1312
|
+
signIn.verifyEmail.verify(field(new FormData(event.currentTarget), "code").trim());
|
|
1313
|
+
};
|
|
1314
|
+
return /* @__PURE__ */ jsx("form", {
|
|
1315
|
+
className: rootClass(unstyled, "verify-email-form", className),
|
|
1316
|
+
onSubmit,
|
|
1317
|
+
"aria-busy": signIn.pending,
|
|
1318
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
1319
|
+
className: c("form"),
|
|
1320
|
+
children: [
|
|
1321
|
+
/* @__PURE__ */ jsx("p", {
|
|
1322
|
+
className: c("muted"),
|
|
1323
|
+
id: `${id}-sent`,
|
|
1324
|
+
children: m.verifyYourEmail(signIn.email ?? "")
|
|
1325
|
+
}),
|
|
1326
|
+
/* @__PURE__ */ jsx(OneTimeCodeInput, {
|
|
1327
|
+
label: m.code,
|
|
1328
|
+
"aria-describedby": `${id}-sent`,
|
|
1329
|
+
unstyled
|
|
1330
|
+
}),
|
|
1331
|
+
signIn.problem ? /* @__PURE__ */ jsx(ProblemText, {
|
|
1332
|
+
problem: signIn.problem,
|
|
1333
|
+
m,
|
|
1334
|
+
c
|
|
1335
|
+
}) : null,
|
|
1336
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1337
|
+
className: c("actions"),
|
|
1338
|
+
children: [
|
|
1339
|
+
/* @__PURE__ */ jsx("button", {
|
|
1340
|
+
type: "submit",
|
|
1341
|
+
className: c("button", "button-primary"),
|
|
1342
|
+
disabled: signIn.pending,
|
|
1343
|
+
children: m.verify
|
|
1344
|
+
}),
|
|
1345
|
+
/* @__PURE__ */ jsx("button", {
|
|
1346
|
+
type: "button",
|
|
1347
|
+
className: c("button"),
|
|
1348
|
+
disabled: signIn.pending,
|
|
1349
|
+
onClick: () => void signIn.verifyEmail.resend(),
|
|
1350
|
+
children: m.sendAnotherCode
|
|
1351
|
+
}),
|
|
1352
|
+
signIn.canSkip ? /* @__PURE__ */ jsx("button", {
|
|
1353
|
+
type: "button",
|
|
1354
|
+
className: c("button"),
|
|
1355
|
+
disabled: signIn.pending,
|
|
1356
|
+
onClick: () => void signIn.verifyEmail.skip(),
|
|
1357
|
+
children: m.skipForNow
|
|
1358
|
+
}) : null
|
|
1359
|
+
]
|
|
1360
|
+
})
|
|
1361
|
+
]
|
|
1362
|
+
})
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
/** The field for an emailed code, named `code`: digits, filled from the message where the device can. */
|
|
1366
|
+
function OneTimeCodeInput({ label, unstyled, className, ...input }) {
|
|
1367
|
+
const c = styler(unstyled);
|
|
1368
|
+
return /* @__PURE__ */ jsxs("label", {
|
|
1369
|
+
className: c("field"),
|
|
1370
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1371
|
+
className: c("label"),
|
|
1372
|
+
children: label
|
|
1373
|
+
}), /* @__PURE__ */ jsx("input", {
|
|
1374
|
+
className: [c("input", "code-input"), className].filter(Boolean).join(" ") || void 0,
|
|
1375
|
+
name: "code",
|
|
1376
|
+
required: true,
|
|
1377
|
+
inputMode: "numeric",
|
|
1378
|
+
pattern: "[0-9]*",
|
|
1379
|
+
maxLength: 10,
|
|
1380
|
+
autoComplete: "one-time-code",
|
|
1381
|
+
autoFocus: true,
|
|
1382
|
+
...input
|
|
1383
|
+
})]
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
/** The address field, which offers passkeys as the person types where it may, and starts that. */
|
|
1387
|
+
function EmailField({ signIn, autofill, signingUp, c }) {
|
|
1388
|
+
const m = useSynqMessages();
|
|
1389
|
+
const offer = autofill && signIn.passkey.autofill && signIn.step === "start";
|
|
1390
|
+
const startAutofill = signIn.passkey.startAutofill;
|
|
1391
|
+
useEffect(() => {
|
|
1392
|
+
if (offer) startAutofill();
|
|
1393
|
+
}, [offer, startAutofill]);
|
|
1394
|
+
return /* @__PURE__ */ jsxs("label", {
|
|
1395
|
+
className: c("field"),
|
|
1396
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1397
|
+
className: c("label"),
|
|
1398
|
+
children: m.email
|
|
1399
|
+
}), /* @__PURE__ */ jsx("input", {
|
|
1400
|
+
className: c("input"),
|
|
1401
|
+
name: "email",
|
|
1402
|
+
type: "email",
|
|
1403
|
+
required: true,
|
|
1404
|
+
autoComplete: signingUp ? "email" : offer ? "username webauthn" : "username",
|
|
1405
|
+
defaultValue: signIn.email ?? ""
|
|
1406
|
+
})]
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
/** What the person typed in a form's field. */
|
|
1410
|
+
function field(data, name) {
|
|
1411
|
+
const value = data.get(name);
|
|
1412
|
+
return typeof value === "string" ? value : "";
|
|
1413
|
+
}
|
|
1414
|
+
/** What went wrong, in the app's words where it gave them, and how long to wait. */
|
|
1415
|
+
function ProblemText({ problem, m, c }) {
|
|
1416
|
+
const said = (problem.code === "refused" && problem.reason ? m.signInRefusals[problem.reason] : void 0) ?? m.signInProblems[problem.code] ?? problem.message;
|
|
1417
|
+
const wait = problem.retryAfter === void 0 ? "" : ` ${m.tryAgainIn(problem.retryAfter)}`;
|
|
1418
|
+
return /* @__PURE__ */ jsxs("p", {
|
|
1419
|
+
className: c("error"),
|
|
1420
|
+
role: "alert",
|
|
1421
|
+
children: [said, wait]
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
//#endregion
|
|
1425
|
+
//#region src/components/UserButton.tsx
|
|
1426
|
+
/**
|
|
1427
|
+
* The signed-in person's picture and name, showing a panel to manage their
|
|
1428
|
+
* account or sign out: a disclosure (a button that shows and hides it), so
|
|
1429
|
+
* its link and button are reached with Tab as anywhere else. Escape or a
|
|
1430
|
+
* click elsewhere hides it. Nothing while nobody is signed in.
|
|
1431
|
+
*/
|
|
1432
|
+
function UserButton({ unstyled, className, accountHref }) {
|
|
1433
|
+
const { user } = useSession();
|
|
1434
|
+
const { signOut } = useAuth();
|
|
1435
|
+
const m = useSynqMessages();
|
|
1436
|
+
const [open, setOpen] = useState(false);
|
|
1437
|
+
const root = useRef(null);
|
|
1438
|
+
const trigger = useRef(null);
|
|
1439
|
+
const panel = useId();
|
|
1440
|
+
const c = styler(unstyled);
|
|
1441
|
+
useEffect(() => {
|
|
1442
|
+
if (!open) return;
|
|
1443
|
+
const onKey = (event) => {
|
|
1444
|
+
if (event.key !== "Escape") return;
|
|
1445
|
+
setOpen(false);
|
|
1446
|
+
trigger.current?.focus();
|
|
1447
|
+
};
|
|
1448
|
+
const onPointer = (event) => {
|
|
1449
|
+
if (!root.current?.contains(event.target)) setOpen(false);
|
|
1450
|
+
};
|
|
1451
|
+
document.addEventListener("keydown", onKey);
|
|
1452
|
+
document.addEventListener("pointerdown", onPointer);
|
|
1453
|
+
return () => {
|
|
1454
|
+
document.removeEventListener("keydown", onKey);
|
|
1455
|
+
document.removeEventListener("pointerdown", onPointer);
|
|
1456
|
+
};
|
|
1457
|
+
}, [open]);
|
|
1458
|
+
if (!user) return null;
|
|
1459
|
+
const name = displayName(user, m.account);
|
|
1460
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1461
|
+
ref: root,
|
|
1462
|
+
className: rootClass(unstyled, "user-button", className),
|
|
1463
|
+
children: [/* @__PURE__ */ jsxs("button", {
|
|
1464
|
+
ref: trigger,
|
|
1465
|
+
type: "button",
|
|
1466
|
+
className: c("user-button-trigger"),
|
|
1467
|
+
"aria-expanded": open,
|
|
1468
|
+
"aria-controls": panel,
|
|
1469
|
+
onClick: () => setOpen((was) => !was),
|
|
1470
|
+
children: [/* @__PURE__ */ jsx(Avatar, {
|
|
1471
|
+
user,
|
|
1472
|
+
name,
|
|
1473
|
+
className: c("avatar")
|
|
1474
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
1475
|
+
className: c("user-button-name"),
|
|
1476
|
+
children: name
|
|
1477
|
+
})]
|
|
1478
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
1479
|
+
id: panel,
|
|
1480
|
+
hidden: !open,
|
|
1481
|
+
className: c("menu"),
|
|
1482
|
+
children: [accountHref && /* @__PURE__ */ jsx("a", {
|
|
1483
|
+
href: accountHref,
|
|
1484
|
+
className: c("menu-item"),
|
|
1485
|
+
children: m.manageAccount
|
|
1486
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
1487
|
+
type: "button",
|
|
1488
|
+
className: c("menu-item"),
|
|
1489
|
+
onClick: () => void signOut(),
|
|
1490
|
+
children: m.signOut
|
|
1491
|
+
})]
|
|
1492
|
+
})]
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
function Avatar({ user, name, className }) {
|
|
1496
|
+
if (user.picture) return /* @__PURE__ */ jsx("img", {
|
|
1497
|
+
className,
|
|
1498
|
+
src: user.picture,
|
|
1499
|
+
alt: "",
|
|
1500
|
+
width: 32,
|
|
1501
|
+
height: 32,
|
|
1502
|
+
referrerPolicy: "no-referrer"
|
|
1503
|
+
});
|
|
1504
|
+
return /* @__PURE__ */ jsx("span", {
|
|
1505
|
+
className,
|
|
1506
|
+
"aria-hidden": "true",
|
|
1507
|
+
children: initials(name)
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
/** What to call the person: their name, username or email address. */
|
|
1511
|
+
function displayName(user, fallback) {
|
|
1512
|
+
return user.name ?? user.preferred_username ?? user.email ?? fallback;
|
|
1513
|
+
}
|
|
1514
|
+
function initials(name) {
|
|
1515
|
+
return name.split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map((word) => [...word][0].toUpperCase()).join("");
|
|
1516
|
+
}
|
|
1517
|
+
//#endregion
|
|
1518
|
+
//#region src/components/AccountManager.tsx
|
|
1519
|
+
const ALL = [
|
|
1520
|
+
"profile",
|
|
1521
|
+
"accounts",
|
|
1522
|
+
"sessions",
|
|
1523
|
+
"apiKeys"
|
|
1524
|
+
];
|
|
1525
|
+
/** Manage the signed-in person's account. Nothing while nobody is signed in. */
|
|
1526
|
+
function AccountManager({ unstyled, className, sections = ALL }) {
|
|
1527
|
+
const { isSignedIn } = useSession();
|
|
1528
|
+
if (!isSignedIn) return null;
|
|
1529
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1530
|
+
className: rootClass(unstyled, "account-manager", className),
|
|
1531
|
+
children: sections.map((section) => section === "profile" ? /* @__PURE__ */ jsx(ProfileSection, { unstyled }, section) : section === "accounts" ? /* @__PURE__ */ jsx(AccountsSection, { unstyled }, section) : section === "sessions" ? /* @__PURE__ */ jsx(SessionsSection, { unstyled }, section) : /* @__PURE__ */ jsx(ApiKeysSection, { unstyled }, section))
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
function Section({ title, unstyled, children }) {
|
|
1535
|
+
const heading = useId();
|
|
1536
|
+
const c = styler(unstyled);
|
|
1537
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
1538
|
+
"aria-labelledby": heading,
|
|
1539
|
+
className: c("section"),
|
|
1540
|
+
children: [/* @__PURE__ */ jsx("h2", {
|
|
1541
|
+
id: heading,
|
|
1542
|
+
className: c("heading"),
|
|
1543
|
+
children: title
|
|
1544
|
+
}), children]
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
/** What a section shows while its data is read, or when it couldn't be. */
|
|
1548
|
+
function Pending({ isLoading, error, unstyled }) {
|
|
1549
|
+
const c = styler(unstyled);
|
|
1550
|
+
const m = useSynqMessages();
|
|
1551
|
+
if (isLoading) return /* @__PURE__ */ jsx("p", {
|
|
1552
|
+
className: c("muted"),
|
|
1553
|
+
children: m.loading
|
|
1554
|
+
});
|
|
1555
|
+
if (error) return /* @__PURE__ */ jsx("p", {
|
|
1556
|
+
role: "alert",
|
|
1557
|
+
className: c("error"),
|
|
1558
|
+
children: error.message
|
|
1559
|
+
});
|
|
1560
|
+
return null;
|
|
1561
|
+
}
|
|
1562
|
+
function ProfileSection({ unstyled }) {
|
|
1563
|
+
const { profile, isLoading, error } = useUser();
|
|
1564
|
+
const m = useSynqMessages();
|
|
1565
|
+
return /* @__PURE__ */ jsx(Section, {
|
|
1566
|
+
title: m.profile,
|
|
1567
|
+
unstyled,
|
|
1568
|
+
children: profile ? /* @__PURE__ */ jsx(ProfileForm, {
|
|
1569
|
+
profile,
|
|
1570
|
+
unstyled
|
|
1571
|
+
}, profile.sub) : /* @__PURE__ */ jsx(Pending, {
|
|
1572
|
+
isLoading,
|
|
1573
|
+
error,
|
|
1574
|
+
unstyled
|
|
1575
|
+
})
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
function ProfileForm({ profile, unstyled }) {
|
|
1579
|
+
const { update } = useUser();
|
|
1580
|
+
const m = useSynqMessages();
|
|
1581
|
+
const [name, setName] = useState(profile.name ?? "");
|
|
1582
|
+
const [username, setUsername] = useState(profile.username ?? "");
|
|
1583
|
+
const [saving, setSaving] = useState(false);
|
|
1584
|
+
const [outcome, setOutcome] = useState(null);
|
|
1585
|
+
const c = styler(unstyled);
|
|
1586
|
+
const changes = {};
|
|
1587
|
+
if ((name.trim() || null) !== profile.name) changes.name = name.trim() || null;
|
|
1588
|
+
if ((username.trim() || null) !== profile.username) changes.username = username.trim() || null;
|
|
1589
|
+
const changed = Object.keys(changes).length > 0;
|
|
1590
|
+
async function save(event) {
|
|
1591
|
+
event.preventDefault();
|
|
1592
|
+
setSaving(true);
|
|
1593
|
+
setOutcome(null);
|
|
1594
|
+
const result = await update(changes);
|
|
1595
|
+
setSaving(false);
|
|
1596
|
+
setOutcome(result.error ? {
|
|
1597
|
+
ok: false,
|
|
1598
|
+
text: failureText(result.error)
|
|
1599
|
+
} : {
|
|
1600
|
+
ok: true,
|
|
1601
|
+
text: m.saved
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
return /* @__PURE__ */ jsxs("form", {
|
|
1605
|
+
className: c("form"),
|
|
1606
|
+
onSubmit: (event) => void save(event),
|
|
1607
|
+
children: [
|
|
1608
|
+
/* @__PURE__ */ jsxs("label", {
|
|
1609
|
+
className: c("field"),
|
|
1610
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1611
|
+
className: c("label"),
|
|
1612
|
+
children: m.name
|
|
1613
|
+
}), /* @__PURE__ */ jsx("input", {
|
|
1614
|
+
className: c("input"),
|
|
1615
|
+
name: "name",
|
|
1616
|
+
autoComplete: "name",
|
|
1617
|
+
maxLength: 200,
|
|
1618
|
+
value: name,
|
|
1619
|
+
onChange: (event) => setName(event.target.value)
|
|
1620
|
+
})]
|
|
1621
|
+
}),
|
|
1622
|
+
/* @__PURE__ */ jsxs("label", {
|
|
1623
|
+
className: c("field"),
|
|
1624
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1625
|
+
className: c("label"),
|
|
1626
|
+
children: m.username
|
|
1627
|
+
}), /* @__PURE__ */ jsx("input", {
|
|
1628
|
+
className: c("input"),
|
|
1629
|
+
name: "username",
|
|
1630
|
+
autoComplete: "username",
|
|
1631
|
+
maxLength: 64,
|
|
1632
|
+
value: username,
|
|
1633
|
+
onChange: (event) => setUsername(event.target.value)
|
|
1634
|
+
})]
|
|
1635
|
+
}),
|
|
1636
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1637
|
+
className: c("actions"),
|
|
1638
|
+
children: [/* @__PURE__ */ jsx("button", {
|
|
1639
|
+
type: "submit",
|
|
1640
|
+
className: c("button", "button-primary"),
|
|
1641
|
+
disabled: saving || !changed,
|
|
1642
|
+
children: saving ? m.saving : m.save
|
|
1643
|
+
}), outcome && /* @__PURE__ */ jsx("p", {
|
|
1644
|
+
role: outcome.ok ? "status" : "alert",
|
|
1645
|
+
className: c(outcome.ok ? "muted" : "error"),
|
|
1646
|
+
children: outcome.text
|
|
1647
|
+
})]
|
|
1648
|
+
})
|
|
1649
|
+
]
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
function AccountsSection({ unstyled }) {
|
|
1653
|
+
const { data, isLoading, error } = useLinkedAccounts();
|
|
1654
|
+
const m = useSynqMessages();
|
|
1655
|
+
const c = styler(unstyled);
|
|
1656
|
+
return /* @__PURE__ */ jsx(Section, {
|
|
1657
|
+
title: m.waysToSignIn,
|
|
1658
|
+
unstyled,
|
|
1659
|
+
children: data ? /* @__PURE__ */ jsx("ul", {
|
|
1660
|
+
className: c("list"),
|
|
1661
|
+
children: data.map((account) => /* @__PURE__ */ jsx(AccountRow, {
|
|
1662
|
+
account,
|
|
1663
|
+
removable: data.length > 1,
|
|
1664
|
+
unstyled
|
|
1665
|
+
}, account.id))
|
|
1666
|
+
}) : /* @__PURE__ */ jsx(Pending, {
|
|
1667
|
+
isLoading,
|
|
1668
|
+
error,
|
|
1669
|
+
unstyled
|
|
1670
|
+
})
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
function AccountRow({ account, removable, unstyled }) {
|
|
1674
|
+
const { accounts } = useAuth();
|
|
1675
|
+
const m = useSynqMessages();
|
|
1676
|
+
const [busy, setBusy] = useState(false);
|
|
1677
|
+
const [failure, setFailure] = useState(null);
|
|
1678
|
+
const c = styler(unstyled);
|
|
1679
|
+
const provider = m.providers[account.provider] ?? capitalized(account.provider);
|
|
1680
|
+
const subject = account.handle ? `@${account.handle}` : account.provider === "solana" ? shortAddress(account.subject) : account.subject;
|
|
1681
|
+
async function remove() {
|
|
1682
|
+
setBusy(true);
|
|
1683
|
+
const result = await accounts.unlink(account.id);
|
|
1684
|
+
setBusy(false);
|
|
1685
|
+
setFailure(result.error ? failureText(result.error) : null);
|
|
1686
|
+
}
|
|
1687
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
1688
|
+
className: c("row"),
|
|
1689
|
+
children: [
|
|
1690
|
+
/* @__PURE__ */ jsx("span", {
|
|
1691
|
+
className: c("row-title"),
|
|
1692
|
+
children: provider
|
|
1693
|
+
}),
|
|
1694
|
+
/* @__PURE__ */ jsx("span", {
|
|
1695
|
+
className: c("muted"),
|
|
1696
|
+
title: account.subject,
|
|
1697
|
+
children: subject
|
|
1698
|
+
}),
|
|
1699
|
+
/* @__PURE__ */ jsx("button", {
|
|
1700
|
+
type: "button",
|
|
1701
|
+
className: c("button"),
|
|
1702
|
+
disabled: busy || !removable,
|
|
1703
|
+
"aria-label": m.removeAccount(provider, subject),
|
|
1704
|
+
onClick: () => void remove(),
|
|
1705
|
+
children: m.remove
|
|
1706
|
+
}),
|
|
1707
|
+
failure && /* @__PURE__ */ jsx("p", {
|
|
1708
|
+
role: "alert",
|
|
1709
|
+
className: c("error"),
|
|
1710
|
+
children: failure
|
|
1711
|
+
})
|
|
1712
|
+
]
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
function SessionsSection({ unstyled }) {
|
|
1716
|
+
const { data, isLoading, error } = useAccountSessions();
|
|
1717
|
+
const { sessions } = useAuth();
|
|
1718
|
+
const m = useSynqMessages();
|
|
1719
|
+
const [failure, setFailure] = useState(null);
|
|
1720
|
+
const c = styler(unstyled);
|
|
1721
|
+
async function end(id) {
|
|
1722
|
+
const result = await sessions.end(id);
|
|
1723
|
+
setFailure(result.error ? failureText(result.error) : null);
|
|
1724
|
+
}
|
|
1725
|
+
return /* @__PURE__ */ jsxs(Section, {
|
|
1726
|
+
title: m.devices,
|
|
1727
|
+
unstyled,
|
|
1728
|
+
children: [data ? /* @__PURE__ */ jsx("ul", {
|
|
1729
|
+
className: c("list"),
|
|
1730
|
+
children: data.map((session) => {
|
|
1731
|
+
const when = session.signed_in_at ? formatDate(session.signed_in_at) : m.signedIn;
|
|
1732
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
1733
|
+
className: c("row"),
|
|
1734
|
+
children: [
|
|
1735
|
+
/* @__PURE__ */ jsx("span", {
|
|
1736
|
+
className: c("row-title"),
|
|
1737
|
+
children: when
|
|
1738
|
+
}),
|
|
1739
|
+
session.current && /* @__PURE__ */ jsx("span", {
|
|
1740
|
+
className: c("badge"),
|
|
1741
|
+
children: m.thisDevice
|
|
1742
|
+
}),
|
|
1743
|
+
/* @__PURE__ */ jsx("span", {
|
|
1744
|
+
className: c("muted"),
|
|
1745
|
+
children: m.apps(session.apps.length)
|
|
1746
|
+
}),
|
|
1747
|
+
/* @__PURE__ */ jsx("button", {
|
|
1748
|
+
type: "button",
|
|
1749
|
+
className: c("button"),
|
|
1750
|
+
"aria-label": m.signOutDevice(when),
|
|
1751
|
+
onClick: () => void end(session.id),
|
|
1752
|
+
children: m.signOut
|
|
1753
|
+
})
|
|
1754
|
+
]
|
|
1755
|
+
}, session.id);
|
|
1756
|
+
})
|
|
1757
|
+
}) : /* @__PURE__ */ jsx(Pending, {
|
|
1758
|
+
isLoading,
|
|
1759
|
+
error,
|
|
1760
|
+
unstyled
|
|
1761
|
+
}), failure && /* @__PURE__ */ jsx("p", {
|
|
1762
|
+
role: "alert",
|
|
1763
|
+
className: c("error"),
|
|
1764
|
+
children: failure
|
|
1765
|
+
})]
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
/** How long a new key may last, in days: the account API takes up to 366. */
|
|
1769
|
+
const KEY_LIFETIMES = [
|
|
1770
|
+
30,
|
|
1771
|
+
90,
|
|
1772
|
+
365
|
|
1773
|
+
];
|
|
1774
|
+
function ApiKeysSection({ unstyled }) {
|
|
1775
|
+
const keys = useApiKeys();
|
|
1776
|
+
const apis = useKeyableApis();
|
|
1777
|
+
const m = useSynqMessages();
|
|
1778
|
+
const [made, setMade] = useState(null);
|
|
1779
|
+
const c = styler(unstyled);
|
|
1780
|
+
if ((apis.data?.length ?? 0) === 0 && (keys.data?.length ?? 0) === 0) return null;
|
|
1781
|
+
return /* @__PURE__ */ jsxs(Section, {
|
|
1782
|
+
title: m.apiKeys,
|
|
1783
|
+
unstyled,
|
|
1784
|
+
children: [
|
|
1785
|
+
made && /* @__PURE__ */ jsx(NewKey, {
|
|
1786
|
+
made,
|
|
1787
|
+
onDone: () => setMade(null),
|
|
1788
|
+
unstyled
|
|
1789
|
+
}),
|
|
1790
|
+
keys.data ? keys.data.length > 0 ? /* @__PURE__ */ jsx("ul", {
|
|
1791
|
+
className: c("list"),
|
|
1792
|
+
children: keys.data.map((key) => /* @__PURE__ */ jsx(ApiKeyRow, {
|
|
1793
|
+
apiKey: key,
|
|
1794
|
+
unstyled
|
|
1795
|
+
}, key.id))
|
|
1796
|
+
}) : /* @__PURE__ */ jsx("p", {
|
|
1797
|
+
className: c("muted"),
|
|
1798
|
+
children: m.noApiKeys
|
|
1799
|
+
}) : /* @__PURE__ */ jsx(Pending, {
|
|
1800
|
+
isLoading: keys.isLoading,
|
|
1801
|
+
error: keys.error,
|
|
1802
|
+
unstyled
|
|
1803
|
+
}),
|
|
1804
|
+
apis.data && apis.data.length > 0 && /* @__PURE__ */ jsx(NewKeyForm, {
|
|
1805
|
+
apis: apis.data,
|
|
1806
|
+
onMade: setMade,
|
|
1807
|
+
unstyled
|
|
1808
|
+
})
|
|
1809
|
+
]
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
/** The key just made, shown this once, with a way to copy it. */
|
|
1813
|
+
function NewKey({ made, onDone, unstyled }) {
|
|
1814
|
+
const m = useSynqMessages();
|
|
1815
|
+
const [copied, setCopied] = useState(false);
|
|
1816
|
+
const c = styler(unstyled);
|
|
1817
|
+
async function copy() {
|
|
1818
|
+
try {
|
|
1819
|
+
await navigator.clipboard.writeText(made.key);
|
|
1820
|
+
setCopied(true);
|
|
1821
|
+
} catch {
|
|
1822
|
+
setCopied(false);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1826
|
+
role: "status",
|
|
1827
|
+
className: c("form"),
|
|
1828
|
+
children: [
|
|
1829
|
+
/* @__PURE__ */ jsx("p", {
|
|
1830
|
+
className: c("row-title"),
|
|
1831
|
+
children: m.copyKeyNow
|
|
1832
|
+
}),
|
|
1833
|
+
/* @__PURE__ */ jsx("code", {
|
|
1834
|
+
className: c("input"),
|
|
1835
|
+
"aria-label": made.name,
|
|
1836
|
+
children: made.key
|
|
1837
|
+
}),
|
|
1838
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1839
|
+
className: c("actions"),
|
|
1840
|
+
children: [/* @__PURE__ */ jsx("button", {
|
|
1841
|
+
type: "button",
|
|
1842
|
+
className: c("button", "button-primary"),
|
|
1843
|
+
onClick: () => void copy(),
|
|
1844
|
+
children: copied ? m.copied : m.copy
|
|
1845
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
1846
|
+
type: "button",
|
|
1847
|
+
className: c("button"),
|
|
1848
|
+
onClick: onDone,
|
|
1849
|
+
children: m.done
|
|
1850
|
+
})]
|
|
1851
|
+
})
|
|
1852
|
+
]
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
function ApiKeyRow({ apiKey, unstyled }) {
|
|
1856
|
+
const { apiKeys } = useAuth();
|
|
1857
|
+
const m = useSynqMessages();
|
|
1858
|
+
const [busy, setBusy] = useState(false);
|
|
1859
|
+
const [failure, setFailure] = useState(null);
|
|
1860
|
+
const [shownAt] = useState(() => Date.now());
|
|
1861
|
+
const c = styler(unstyled);
|
|
1862
|
+
const expired = apiKey.expires_at !== null && Date.parse(apiKey.expires_at) <= shownAt;
|
|
1863
|
+
const live = apiKey.revoked_at === null && !expired;
|
|
1864
|
+
async function revoke() {
|
|
1865
|
+
setBusy(true);
|
|
1866
|
+
const result = await apiKeys.revoke(apiKey.id);
|
|
1867
|
+
setBusy(false);
|
|
1868
|
+
setFailure(result.error ? failureText(result.error) : null);
|
|
1869
|
+
}
|
|
1870
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
1871
|
+
className: c("row"),
|
|
1872
|
+
children: [
|
|
1873
|
+
/* @__PURE__ */ jsx("span", {
|
|
1874
|
+
className: c("row-title"),
|
|
1875
|
+
children: apiKey.name
|
|
1876
|
+
}),
|
|
1877
|
+
apiKey.revoked_at !== null ? /* @__PURE__ */ jsx("span", {
|
|
1878
|
+
className: c("badge"),
|
|
1879
|
+
children: m.revoked
|
|
1880
|
+
}) : expired && /* @__PURE__ */ jsx("span", {
|
|
1881
|
+
className: c("badge"),
|
|
1882
|
+
children: m.expired
|
|
1883
|
+
}),
|
|
1884
|
+
/* @__PURE__ */ jsx("span", {
|
|
1885
|
+
className: c("muted"),
|
|
1886
|
+
children: `${apiKey.prefix}… · ${apiKey.api.name} · ${apiKey.scopes.join(", ")}`
|
|
1887
|
+
}),
|
|
1888
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1889
|
+
className: c("muted"),
|
|
1890
|
+
children: [apiKey.last_used_at ? m.lastUsed(formatDate(apiKey.last_used_at)) : m.neverUsed, apiKey.expires_at && live ? ` · ${m.expiresOn(formatDate(apiKey.expires_at))}` : ""]
|
|
1891
|
+
}),
|
|
1892
|
+
live && /* @__PURE__ */ jsx("button", {
|
|
1893
|
+
type: "button",
|
|
1894
|
+
className: c("button"),
|
|
1895
|
+
disabled: busy,
|
|
1896
|
+
"aria-label": m.revokeKey(apiKey.name),
|
|
1897
|
+
onClick: () => void revoke(),
|
|
1898
|
+
children: m.revoke
|
|
1899
|
+
}),
|
|
1900
|
+
failure && /* @__PURE__ */ jsx("p", {
|
|
1901
|
+
role: "alert",
|
|
1902
|
+
className: c("error"),
|
|
1903
|
+
children: failure
|
|
1904
|
+
})
|
|
1905
|
+
]
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
function NewKeyForm({ apis, onMade, unstyled }) {
|
|
1909
|
+
const { apiKeys, signIn } = useAuth();
|
|
1910
|
+
const m = useSynqMessages();
|
|
1911
|
+
const [api, setApi] = useState(apis[0].identifier);
|
|
1912
|
+
const [name, setName] = useState("");
|
|
1913
|
+
const [scopes, setScopes] = useState([]);
|
|
1914
|
+
const [lifetime, setLifetime] = useState(null);
|
|
1915
|
+
const [busy, setBusy] = useState(false);
|
|
1916
|
+
const [failure, setFailure] = useState(null);
|
|
1917
|
+
const scopeLegend = useId();
|
|
1918
|
+
const c = styler(unstyled);
|
|
1919
|
+
const chosen = apis.find((a) => a.identifier === api) ?? apis[0];
|
|
1920
|
+
async function make(event) {
|
|
1921
|
+
event.preventDefault();
|
|
1922
|
+
setBusy(true);
|
|
1923
|
+
setFailure(null);
|
|
1924
|
+
const result = await apiKeys.create({
|
|
1925
|
+
api: chosen.identifier,
|
|
1926
|
+
name: name.trim(),
|
|
1927
|
+
scopes: [...scopes],
|
|
1928
|
+
...lifetime === null ? {} : { expires_at: new Date(Date.now() + lifetime * 864e5).toISOString() }
|
|
1929
|
+
});
|
|
1930
|
+
setBusy(false);
|
|
1931
|
+
if (result.error) {
|
|
1932
|
+
setFailure({
|
|
1933
|
+
text: failureText(result.error),
|
|
1934
|
+
fresh: result.error.code === "fresh_sign_in_required"
|
|
1935
|
+
});
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
setName("");
|
|
1939
|
+
setScopes([]);
|
|
1940
|
+
onMade(result.data);
|
|
1941
|
+
}
|
|
1942
|
+
return /* @__PURE__ */ jsxs("form", {
|
|
1943
|
+
className: c("form"),
|
|
1944
|
+
onSubmit: (event) => void make(event),
|
|
1945
|
+
children: [
|
|
1946
|
+
/* @__PURE__ */ jsx("h3", {
|
|
1947
|
+
className: c("row-title"),
|
|
1948
|
+
children: m.newApiKey
|
|
1949
|
+
}),
|
|
1950
|
+
apis.length > 1 && /* @__PURE__ */ jsxs("label", {
|
|
1951
|
+
className: c("field"),
|
|
1952
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1953
|
+
className: c("label"),
|
|
1954
|
+
children: m.api
|
|
1955
|
+
}), /* @__PURE__ */ jsx("select", {
|
|
1956
|
+
className: c("input"),
|
|
1957
|
+
value: chosen.identifier,
|
|
1958
|
+
onChange: (event) => {
|
|
1959
|
+
setApi(event.target.value);
|
|
1960
|
+
setScopes([]);
|
|
1961
|
+
},
|
|
1962
|
+
children: apis.map((a) => /* @__PURE__ */ jsx("option", {
|
|
1963
|
+
value: a.identifier,
|
|
1964
|
+
children: a.name
|
|
1965
|
+
}, a.identifier))
|
|
1966
|
+
})]
|
|
1967
|
+
}),
|
|
1968
|
+
/* @__PURE__ */ jsxs("label", {
|
|
1969
|
+
className: c("field"),
|
|
1970
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1971
|
+
className: c("label"),
|
|
1972
|
+
children: m.name
|
|
1973
|
+
}), /* @__PURE__ */ jsx("input", {
|
|
1974
|
+
className: c("input"),
|
|
1975
|
+
name: "key-name",
|
|
1976
|
+
required: true,
|
|
1977
|
+
maxLength: 100,
|
|
1978
|
+
value: name,
|
|
1979
|
+
onChange: (event) => setName(event.target.value)
|
|
1980
|
+
})]
|
|
1981
|
+
}),
|
|
1982
|
+
/* @__PURE__ */ jsxs("fieldset", {
|
|
1983
|
+
className: c("field"),
|
|
1984
|
+
"aria-labelledby": scopeLegend,
|
|
1985
|
+
children: [/* @__PURE__ */ jsx("legend", {
|
|
1986
|
+
id: scopeLegend,
|
|
1987
|
+
className: c("label"),
|
|
1988
|
+
children: m.keyScopes
|
|
1989
|
+
}), chosen.scopes.map((scope) => /* @__PURE__ */ jsxs("label", {
|
|
1990
|
+
className: c("row"),
|
|
1991
|
+
children: [/* @__PURE__ */ jsx("input", {
|
|
1992
|
+
type: "checkbox",
|
|
1993
|
+
checked: scopes.includes(scope.name),
|
|
1994
|
+
onChange: (event) => setScopes((now) => event.target.checked ? [...now, scope.name] : now.filter((one) => one !== scope.name))
|
|
1995
|
+
}), /* @__PURE__ */ jsx("span", { children: scope.description || scope.name })]
|
|
1996
|
+
}, scope.name))]
|
|
1997
|
+
}),
|
|
1998
|
+
/* @__PURE__ */ jsxs("label", {
|
|
1999
|
+
className: c("field"),
|
|
2000
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
2001
|
+
className: c("label"),
|
|
2002
|
+
children: m.expires
|
|
2003
|
+
}), /* @__PURE__ */ jsxs("select", {
|
|
2004
|
+
className: c("input"),
|
|
2005
|
+
value: lifetime === null ? "" : String(lifetime),
|
|
2006
|
+
onChange: (event) => setLifetime(event.target.value === "" ? null : Number(event.target.value)),
|
|
2007
|
+
children: [/* @__PURE__ */ jsx("option", {
|
|
2008
|
+
value: "",
|
|
2009
|
+
children: m.never
|
|
2010
|
+
}), KEY_LIFETIMES.map((days) => /* @__PURE__ */ jsx("option", {
|
|
2011
|
+
value: days,
|
|
2012
|
+
children: m.inDays(days)
|
|
2013
|
+
}, days))]
|
|
2014
|
+
})]
|
|
2015
|
+
}),
|
|
2016
|
+
/* @__PURE__ */ jsx("div", {
|
|
2017
|
+
className: c("actions"),
|
|
2018
|
+
children: /* @__PURE__ */ jsx("button", {
|
|
2019
|
+
type: "submit",
|
|
2020
|
+
className: c("button", "button-primary"),
|
|
2021
|
+
disabled: busy || name.trim() === "" || scopes.length === 0,
|
|
2022
|
+
children: busy ? m.makingKey : m.makeKey
|
|
2023
|
+
})
|
|
2024
|
+
}),
|
|
2025
|
+
failure && /* @__PURE__ */ jsxs("div", {
|
|
2026
|
+
role: "alert",
|
|
2027
|
+
className: c("error"),
|
|
2028
|
+
children: [/* @__PURE__ */ jsx("p", { children: failure.fresh ? m.keyNeedsFreshSignIn : failure.text }), failure.fresh && /* @__PURE__ */ jsx("button", {
|
|
2029
|
+
type: "button",
|
|
2030
|
+
className: c("button"),
|
|
2031
|
+
onClick: () => signIn({ maxAge: 300 }),
|
|
2032
|
+
children: m.signInAgain
|
|
2033
|
+
})]
|
|
2034
|
+
})
|
|
2035
|
+
]
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
function capitalized(provider) {
|
|
2039
|
+
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
|
2040
|
+
}
|
|
2041
|
+
function formatDate(iso) {
|
|
2042
|
+
return new Intl.DateTimeFormat(void 0, {
|
|
2043
|
+
dateStyle: "medium",
|
|
2044
|
+
timeStyle: "short"
|
|
2045
|
+
}).format(new Date(iso));
|
|
2046
|
+
}
|
|
2047
|
+
//#endregion
|
|
2048
|
+
//#region src/components/WalletManager.tsx
|
|
2049
|
+
/** Manage the signed-in person's wallets. Nothing while nobody is signed in. */
|
|
2050
|
+
function WalletManager({ unstyled, className }) {
|
|
2051
|
+
const { isSignedIn } = useSession();
|
|
2052
|
+
const { data, isLoading, error } = useWallets();
|
|
2053
|
+
const m = useSynqMessages();
|
|
2054
|
+
const heading = useId();
|
|
2055
|
+
const c = styler(unstyled);
|
|
2056
|
+
if (!isSignedIn) return null;
|
|
2057
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
2058
|
+
"aria-labelledby": heading,
|
|
2059
|
+
className: rootClass(unstyled, "wallet-manager", className),
|
|
2060
|
+
children: [/* @__PURE__ */ jsx("h2", {
|
|
2061
|
+
id: heading,
|
|
2062
|
+
className: c("heading"),
|
|
2063
|
+
children: m.wallets
|
|
2064
|
+
}), data ? data.length > 0 ? /* @__PURE__ */ jsx("ul", {
|
|
2065
|
+
className: c("list"),
|
|
2066
|
+
children: data.map((wallet) => /* @__PURE__ */ jsx(WalletRow, {
|
|
2067
|
+
wallet,
|
|
2068
|
+
unstyled
|
|
2069
|
+
}, wallet.id))
|
|
2070
|
+
}) : /* @__PURE__ */ jsx("p", {
|
|
2071
|
+
className: c("muted"),
|
|
2072
|
+
children: m.noWallets
|
|
2073
|
+
}) : isLoading ? /* @__PURE__ */ jsx("p", {
|
|
2074
|
+
className: c("muted"),
|
|
2075
|
+
children: m.loading
|
|
2076
|
+
}) : error && /* @__PURE__ */ jsx("p", {
|
|
2077
|
+
role: "alert",
|
|
2078
|
+
className: c("error"),
|
|
2079
|
+
children: error.message
|
|
2080
|
+
})]
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
function WalletRow({ wallet, unstyled }) {
|
|
2084
|
+
const { wallets } = useAuth();
|
|
2085
|
+
const m = useSynqMessages();
|
|
2086
|
+
const [editing, setEditing] = useState(false);
|
|
2087
|
+
const [label, setLabel] = useState(wallet.label ?? "");
|
|
2088
|
+
const [busy, setBusy] = useState(false);
|
|
2089
|
+
const [failure, setFailure] = useState(null);
|
|
2090
|
+
const c = styler(unstyled);
|
|
2091
|
+
const title = wallet.label ?? shortAddress(wallet.address);
|
|
2092
|
+
async function rename(event) {
|
|
2093
|
+
event.preventDefault();
|
|
2094
|
+
setBusy(true);
|
|
2095
|
+
const result = await wallets.rename(wallet.id, label.trim() || null);
|
|
2096
|
+
setBusy(false);
|
|
2097
|
+
setFailure(result.error ? failureText(result.error) : null);
|
|
2098
|
+
if (!result.error) setEditing(false);
|
|
2099
|
+
}
|
|
2100
|
+
async function makePrimary() {
|
|
2101
|
+
setBusy(true);
|
|
2102
|
+
const result = await wallets.setPrimary(wallet.id);
|
|
2103
|
+
setBusy(false);
|
|
2104
|
+
setFailure(result.error ? failureText(result.error) : null);
|
|
2105
|
+
}
|
|
2106
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
2107
|
+
className: c("row"),
|
|
2108
|
+
children: [editing ? /* @__PURE__ */ jsxs("form", {
|
|
2109
|
+
className: c("form", "form-inline"),
|
|
2110
|
+
onSubmit: (event) => void rename(event),
|
|
2111
|
+
children: [
|
|
2112
|
+
/* @__PURE__ */ jsxs("label", {
|
|
2113
|
+
className: c("field"),
|
|
2114
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
2115
|
+
className: c("label"),
|
|
2116
|
+
children: m.walletName(shortAddress(wallet.address))
|
|
2117
|
+
}), /* @__PURE__ */ jsx("input", {
|
|
2118
|
+
className: c("input"),
|
|
2119
|
+
name: "label",
|
|
2120
|
+
maxLength: 64,
|
|
2121
|
+
value: label,
|
|
2122
|
+
onChange: (event) => setLabel(event.target.value)
|
|
2123
|
+
})]
|
|
2124
|
+
}),
|
|
2125
|
+
/* @__PURE__ */ jsx("button", {
|
|
2126
|
+
type: "submit",
|
|
2127
|
+
className: c("button", "button-primary"),
|
|
2128
|
+
disabled: busy,
|
|
2129
|
+
children: m.save
|
|
2130
|
+
}),
|
|
2131
|
+
/* @__PURE__ */ jsx("button", {
|
|
2132
|
+
type: "button",
|
|
2133
|
+
className: c("button"),
|
|
2134
|
+
onClick: () => {
|
|
2135
|
+
setEditing(false);
|
|
2136
|
+
setLabel(wallet.label ?? "");
|
|
2137
|
+
setFailure(null);
|
|
2138
|
+
},
|
|
2139
|
+
children: m.cancel
|
|
2140
|
+
})
|
|
2141
|
+
]
|
|
2142
|
+
}) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
2143
|
+
/* @__PURE__ */ jsx("span", {
|
|
2144
|
+
className: c("row-title"),
|
|
2145
|
+
title: wallet.address,
|
|
2146
|
+
children: title
|
|
2147
|
+
}),
|
|
2148
|
+
wallet.label && /* @__PURE__ */ jsx("span", {
|
|
2149
|
+
className: c("muted"),
|
|
2150
|
+
title: wallet.address,
|
|
2151
|
+
children: shortAddress(wallet.address)
|
|
2152
|
+
}),
|
|
2153
|
+
wallet.primary && /* @__PURE__ */ jsx("span", {
|
|
2154
|
+
className: c("badge"),
|
|
2155
|
+
children: m.primary
|
|
2156
|
+
}),
|
|
2157
|
+
/* @__PURE__ */ jsx("button", {
|
|
2158
|
+
type: "button",
|
|
2159
|
+
className: c("button"),
|
|
2160
|
+
"aria-label": m.renameWallet(title),
|
|
2161
|
+
onClick: () => setEditing(true),
|
|
2162
|
+
children: m.rename
|
|
2163
|
+
}),
|
|
2164
|
+
!wallet.primary && /* @__PURE__ */ jsx("button", {
|
|
2165
|
+
type: "button",
|
|
2166
|
+
className: c("button"),
|
|
2167
|
+
disabled: busy,
|
|
2168
|
+
"aria-label": m.makeWalletPrimary(title),
|
|
2169
|
+
onClick: () => void makePrimary(),
|
|
2170
|
+
children: m.makePrimary
|
|
2171
|
+
})
|
|
2172
|
+
] }), failure && /* @__PURE__ */ jsx("p", {
|
|
2173
|
+
role: "alert",
|
|
2174
|
+
className: c("error"),
|
|
2175
|
+
children: failure
|
|
2176
|
+
})]
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
//#endregion
|
|
2180
|
+
export { AccountManager, DEFAULT_MESSAGES, EmailCodeForm, OneTimeCodeInput, PasskeyButton, PasswordForm, SignInButton, SignInForm, SignedIn, SignedOut, SynqProvider, UserButton, VerifyEmailForm, WalletManager, useAccountSessions, useApiKeys, useAuth, useKeyableApis, useLinkedAccounts, usePlugin, useSession, useSignIn, useUser, useWallets };
|