@synqauth/react 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/dist/index.js ADDED
@@ -0,0 +1,1107 @@
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
+ };
44
+ //#endregion
45
+ //#region src/mount.ts
46
+ /** A request to `basePath`'s routes from the app's own page: same origin, with the cookie, never cached. */
47
+ function mountRequest(basePath, fetcher) {
48
+ const base = `/${basePath.replace(/^\/+|\/+$/g, "")}`;
49
+ return (path, init = {}) => {
50
+ const headers = new Headers(init.headers);
51
+ if (!headers.has("accept")) headers.set("accept", "application/json");
52
+ return fetcher(`${base}/${path.replace(/^\/+/, "")}`, {
53
+ ...init,
54
+ headers,
55
+ credentials: "same-origin",
56
+ cache: "no-store"
57
+ });
58
+ };
59
+ }
60
+ /** Calls the mount and reads its JSON; a problem it answers, or no answer at all, is the result's `error`. */
61
+ async function call(request, path, init) {
62
+ let response;
63
+ try {
64
+ response = await request(path, init);
65
+ } catch (error) {
66
+ return failure(new SynqError("network_error", `The app couldn't be reached: ${String(error)}`, { cause: error }));
67
+ }
68
+ const text = await response.text();
69
+ let json;
70
+ try {
71
+ json = text ? JSON.parse(text) : void 0;
72
+ } catch {
73
+ json = void 0;
74
+ }
75
+ if (!response.ok) return failure(problemError(response.status, json !== null && typeof json === "object" ? json : {}));
76
+ return {
77
+ data: json,
78
+ error: null
79
+ };
80
+ }
81
+ function failure(error) {
82
+ return {
83
+ data: null,
84
+ error
85
+ };
86
+ }
87
+ //#endregion
88
+ //#region src/store.ts
89
+ const list = (path) => ({
90
+ path,
91
+ pick: (json) => json.data
92
+ });
93
+ const ROUTES = {
94
+ profile: {
95
+ path: "account/v1/profile",
96
+ pick: (json) => json
97
+ },
98
+ metadata: {
99
+ path: "account/v1/metadata",
100
+ pick: (json) => json
101
+ },
102
+ wallets: list("account/v1/wallets"),
103
+ accounts: list("account/v1/identities"),
104
+ sessions: list("account/v1/sessions")
105
+ };
106
+ const EMPTY = {
107
+ data: null,
108
+ error: null,
109
+ loading: false
110
+ };
111
+ const NO_RESOURCES = {
112
+ profile: EMPTY,
113
+ metadata: EMPTY,
114
+ wallets: EMPTY,
115
+ accounts: EMPTY,
116
+ sessions: EMPTY
117
+ };
118
+ function createStore(request, initial) {
119
+ let state = {
120
+ session: initial === void 0 ? {
121
+ status: "loading",
122
+ user: null,
123
+ expiresAt: null,
124
+ error: null
125
+ } : sessionOf(initial),
126
+ resources: NO_RESOURCES
127
+ };
128
+ const listeners = /* @__PURE__ */ new Set();
129
+ const reads = /* @__PURE__ */ new Map();
130
+ let asking;
131
+ let running = false;
132
+ let failures = 0;
133
+ let retry;
134
+ let generation = 0;
135
+ function set(next) {
136
+ state = next;
137
+ for (const listener of listeners) listener();
138
+ }
139
+ function setResource(name, value) {
140
+ set({
141
+ ...state,
142
+ resources: {
143
+ ...state.resources,
144
+ [name]: value
145
+ }
146
+ });
147
+ }
148
+ function setSession(info) {
149
+ const next = sessionOf(info);
150
+ const same = next.user?.sub === state.session.user?.sub && next.status === state.session.status;
151
+ if (same && next.expiresAt === state.session.expiresAt && !state.session.error) return;
152
+ if (!same) {
153
+ generation += 1;
154
+ reads.clear();
155
+ }
156
+ set({
157
+ session: next,
158
+ resources: same ? state.resources : NO_RESOURCES
159
+ });
160
+ }
161
+ function notice(error) {
162
+ if (error?.status === 401) setSession(null);
163
+ }
164
+ const store = {
165
+ getState: () => state,
166
+ subscribe(listener) {
167
+ listeners.add(listener);
168
+ return () => listeners.delete(listener);
169
+ },
170
+ refreshSession() {
171
+ asking ??= call(request, "session").then((result) => {
172
+ asking = void 0;
173
+ if (result.data) {
174
+ failures = 0;
175
+ setSession(result.data);
176
+ return;
177
+ }
178
+ const unknown = state.session.status === "loading" || state.session.status === "error";
179
+ const status = unknown ? "error" : state.session.status;
180
+ set({
181
+ ...state,
182
+ session: {
183
+ ...state.session,
184
+ status,
185
+ error: result.error
186
+ }
187
+ });
188
+ if (unknown && running) {
189
+ clearTimeout(retry);
190
+ retry = setTimeout(() => void store.refreshSession(), backoff(failures));
191
+ failures += 1;
192
+ }
193
+ });
194
+ return asking;
195
+ },
196
+ start() {
197
+ running = true;
198
+ const { status } = state.session;
199
+ if (status === "loading" || status === "error") store.refreshSession();
200
+ },
201
+ stop() {
202
+ running = false;
203
+ clearTimeout(retry);
204
+ },
205
+ setSession,
206
+ load(name) {
207
+ const running = reads.get(name);
208
+ if (running) return running;
209
+ const route = ROUTES[name];
210
+ const asked = generation;
211
+ setResource(name, {
212
+ ...state.resources[name],
213
+ loading: true
214
+ });
215
+ const read = call(request, route.path).then((result) => {
216
+ const outcome = result.error ? result : {
217
+ data: route.pick(result.data),
218
+ error: null
219
+ };
220
+ if (asked !== generation) return outcome;
221
+ reads.delete(name);
222
+ setResource(name, outcome.error ? {
223
+ ...state.resources[name],
224
+ error: outcome.error,
225
+ loading: false
226
+ } : {
227
+ data: outcome.data,
228
+ error: null,
229
+ loading: false
230
+ });
231
+ notice(outcome.error);
232
+ return outcome;
233
+ });
234
+ reads.set(name, read);
235
+ return read;
236
+ },
237
+ ensure(name) {
238
+ const resource = state.resources[name];
239
+ if (state.session.status !== "signed-in") return;
240
+ if (resource.loading || resource.data !== null || resource.error !== null) return;
241
+ store.load(name);
242
+ },
243
+ put(name, update) {
244
+ const data = update(state.resources[name].data);
245
+ if (data !== null) setResource(name, {
246
+ ...state.resources[name],
247
+ data,
248
+ error: null
249
+ });
250
+ },
251
+ notice
252
+ };
253
+ return store;
254
+ }
255
+ /** Milliseconds before asking again after `failures` failures: 1, 2, 4... seconds, at most 30. */
256
+ function backoff(failures) {
257
+ return Math.min(1e3 * 2 ** failures, 3e4);
258
+ }
259
+ function sessionOf(info) {
260
+ if (!info?.user) return {
261
+ status: "signed-out",
262
+ user: null,
263
+ expiresAt: null,
264
+ error: null
265
+ };
266
+ return {
267
+ status: "signed-in",
268
+ user: info.user,
269
+ expiresAt: info.expiresAt,
270
+ error: null
271
+ };
272
+ }
273
+ //#endregion
274
+ //#region src/provider.tsx
275
+ const SynqContext = createContext(null);
276
+ /** How long after asking who is signed in the provider waits before asking again when the page is back in view. */
277
+ const RECHECK_MS = 3e4;
278
+ /** Synq for the components under it. Its route mount and `fetch` are read once, when it mounts. */
279
+ function SynqProvider({ initialSession, basePath = "/api/auth", plugins, fetch: fetcher, navigate, messages, children }) {
280
+ const [{ store, request, base }] = useState(() => {
281
+ const base = `/${basePath.replace(/^\/+|\/+$/g, "")}`;
282
+ const request = mountRequest(base, fetcher ?? ((input, init) => fetch(input, init)));
283
+ return {
284
+ store: createStore(request, initialSession),
285
+ request,
286
+ base
287
+ };
288
+ });
289
+ useEffect(() => {
290
+ if (initialSession !== void 0) store.setSession(initialSession);
291
+ }, [store, initialSession]);
292
+ useEffect(() => {
293
+ store.start();
294
+ let asked = Date.now();
295
+ const recheck = () => {
296
+ if (document.visibilityState !== "visible" || Date.now() - asked < RECHECK_MS) return;
297
+ asked = Date.now();
298
+ store.refreshSession();
299
+ };
300
+ document.addEventListener("visibilitychange", recheck);
301
+ return () => {
302
+ document.removeEventListener("visibilitychange", recheck);
303
+ store.stop();
304
+ };
305
+ }, [store]);
306
+ const value = useMemo(() => ({
307
+ store,
308
+ request,
309
+ basePath: base,
310
+ navigate: navigate ?? ((url) => window.location.assign(url)),
311
+ plugins: Object.fromEntries((plugins ?? []).map((plugin) => [plugin.id, plugin.actions(request)])),
312
+ messages: {
313
+ ...DEFAULT_MESSAGES,
314
+ ...messages
315
+ }
316
+ }), [
317
+ store,
318
+ request,
319
+ base,
320
+ navigate,
321
+ plugins,
322
+ messages
323
+ ]);
324
+ return /* @__PURE__ */ jsx(SynqContext, {
325
+ value,
326
+ children
327
+ });
328
+ }
329
+ function useSynqContext() {
330
+ const context = use(SynqContext);
331
+ if (!context) throw new Error("@synqauth/react: put the app inside <SynqProvider>");
332
+ return context;
333
+ }
334
+ /** A browser plugin's actions, typed by the plugin, which must be one of the provider's `plugins`. */
335
+ function usePlugin(plugin) {
336
+ const { plugins } = useSynqContext();
337
+ if (!(plugin.id in plugins)) throw new Error(`@synqauth/react: pass the ${plugin.id} plugin to <SynqProvider plugins>`);
338
+ return plugins[plugin.id];
339
+ }
340
+ /** The components' words: the provider's `messages`, English for any it leaves out. */
341
+ function useSynqMessages() {
342
+ return useSynqContext().messages;
343
+ }
344
+ //#endregion
345
+ //#region src/hooks.ts
346
+ function useSynqState(select) {
347
+ const { store } = useSynqContext();
348
+ const read = () => select(store.getState());
349
+ return useSyncExternalStore(store.subscribe, read, read);
350
+ }
351
+ /** Who is signed in. It reads the session alone, and asks the account API nothing. */
352
+ function useSession() {
353
+ const { store } = useSynqContext();
354
+ const session = useSynqState((state) => state.session);
355
+ return useMemo(() => ({
356
+ status: session.status,
357
+ isLoaded: session.status !== "loading",
358
+ isSignedIn: session.status === "signed-in",
359
+ user: session.user,
360
+ expiresAt: session.expiresAt,
361
+ error: session.error,
362
+ refresh: () => store.refreshSession()
363
+ }), [session, store]);
364
+ }
365
+ /** Something the account API keeps, read once someone is signed in and shared by every component that asks. */
366
+ function useAccountData(name) {
367
+ const { store } = useSynqContext();
368
+ const status = useSynqState((state) => state.session.status);
369
+ const resource = useSynqState((state) => state.resources[name]);
370
+ useEffect(() => {
371
+ if (status === "signed-in") store.ensure(name);
372
+ }, [
373
+ status,
374
+ store,
375
+ name
376
+ ]);
377
+ return useMemo(() => ({
378
+ data: resource.data,
379
+ error: resource.error,
380
+ isLoading: resource.loading || status === "signed-in" && resource.data === null && resource.error === null,
381
+ reload: async () => {
382
+ await store.load(name);
383
+ }
384
+ }), [
385
+ resource,
386
+ status,
387
+ store,
388
+ name
389
+ ]);
390
+ }
391
+ /** The person's wallets, the primary one marked. */
392
+ function useWallets() {
393
+ return useAccountData("wallets");
394
+ }
395
+ /** The ways the person signs in: their email address, wallets, social accounts. */
396
+ function useLinkedAccounts() {
397
+ return useAccountData("accounts");
398
+ }
399
+ /** The person's sessions at the brand, on every device. */
400
+ function useAccountSessions() {
401
+ return useAccountData("sessions");
402
+ }
403
+ /** The signed-in person: their ID token's claims at once, then their profile and metadata. */
404
+ function useUser() {
405
+ const session = useSession();
406
+ const profile = useAccountData("profile");
407
+ const metadata = useAccountData("metadata");
408
+ const { store } = useSynqContext();
409
+ const change = useChanges();
410
+ const update = useCallback(async (changes) => {
411
+ const result = await change("PATCH", "v1/profile", changes);
412
+ if (result.data) store.put("profile", () => result.data);
413
+ return result;
414
+ }, [change, store]);
415
+ return useMemo(() => ({
416
+ isLoaded: session.isLoaded,
417
+ isSignedIn: session.isSignedIn,
418
+ user: session.user,
419
+ profile: profile.data,
420
+ metadata: metadata.data,
421
+ isLoading: profile.isLoading || metadata.isLoading,
422
+ error: profile.error ?? metadata.error,
423
+ update,
424
+ reload: async () => {
425
+ await Promise.all([profile.reload(), metadata.reload()]);
426
+ }
427
+ }), [
428
+ session,
429
+ profile,
430
+ metadata,
431
+ update
432
+ ]);
433
+ }
434
+ /** Everything the signed-in person can do. */
435
+ function useAuth() {
436
+ const { store, request, basePath, navigate, plugins } = useSynqContext();
437
+ const session = useSession();
438
+ const change = useChanges();
439
+ const signingOut = useRef(null);
440
+ return useMemo(() => {
441
+ const signInUrl = (options = {}) => {
442
+ const params = new URLSearchParams({ returnTo: options.returnTo ?? currentPath() });
443
+ if (options.method) params.set("method", options.method);
444
+ if (options.loginHint) params.set("login_hint", options.loginHint);
445
+ if (options.prompt) params.set("prompt", options.prompt);
446
+ if (options.maxAge !== void 0) params.set("max_age", String(options.maxAge));
447
+ if (options.intent) params.set("intent", options.intent);
448
+ return `${basePath}/sign-in?${params.toString()}`;
449
+ };
450
+ const read = (name) => () => store.load(name);
451
+ const id = (value) => encodeURIComponent(value);
452
+ return {
453
+ isLoaded: session.isLoaded,
454
+ isSignedIn: session.isSignedIn,
455
+ userId: session.user?.sub ?? null,
456
+ signIn: (options) => navigate(signInUrl(options)),
457
+ signInUrl,
458
+ signOut() {
459
+ signingOut.current ??= call(request, "sign-out", { method: "POST" }).then((result) => {
460
+ signingOut.current = null;
461
+ if (result.error) return result;
462
+ store.setSession(null);
463
+ navigate(result.data.redirect);
464
+ return {
465
+ data: null,
466
+ error: null
467
+ };
468
+ });
469
+ return signingOut.current;
470
+ },
471
+ async link(method, options = {}) {
472
+ const result = await call(request, "link", {
473
+ method: "POST",
474
+ headers: { "content-type": "application/json" },
475
+ body: JSON.stringify({
476
+ method,
477
+ returnTo: options.returnTo ?? currentPath()
478
+ })
479
+ });
480
+ if (result.error) return result;
481
+ navigate(result.data.redirect);
482
+ return {
483
+ data: null,
484
+ error: null
485
+ };
486
+ },
487
+ wallets: {
488
+ list: read("wallets"),
489
+ async rename(wallet, label) {
490
+ const result = await change("PATCH", `v1/wallets/${id(wallet)}`, { label });
491
+ const { data } = result;
492
+ if (data) store.put("wallets", (list) => list && list.map((w) => w.id === data.id ? data : w));
493
+ return result;
494
+ },
495
+ async setPrimary(wallet) {
496
+ const result = await change("PATCH", `v1/wallets/${id(wallet)}`, { primary: true });
497
+ const { data } = result;
498
+ if (data) store.put("wallets", (list) => list && list.map((w) => w.id === data.id ? data : {
499
+ ...w,
500
+ primary: false
501
+ }));
502
+ return result;
503
+ }
504
+ },
505
+ accounts: {
506
+ list: read("accounts"),
507
+ async unlink(account) {
508
+ const result = await change("DELETE", `v1/identities/${id(account)}`);
509
+ if (!result.error) store.put("accounts", (list) => list && list.filter((a) => a.id !== account));
510
+ return result;
511
+ }
512
+ },
513
+ sessions: {
514
+ list: read("sessions"),
515
+ async end(ended) {
516
+ const result = await change("DELETE", `v1/sessions/${id(ended)}`);
517
+ if (!result.error) store.put("sessions", (list) => list && list.filter((s) => s.id !== ended));
518
+ return result;
519
+ }
520
+ },
521
+ plugins
522
+ };
523
+ }, [
524
+ store,
525
+ request,
526
+ basePath,
527
+ navigate,
528
+ plugins,
529
+ session,
530
+ change
531
+ ]);
532
+ }
533
+ /**
534
+ * Sends changes to the account API for one mounted component. Each change
535
+ * carries an idempotency key minted here; the key is used again only when
536
+ * no answer came back, so a retry is the same request to Synq. A change
537
+ * asked again while it is under way joins it rather than sending another.
538
+ */
539
+ function useChanges() {
540
+ const { store, request } = useSynqContext();
541
+ const keys = useRef(/* @__PURE__ */ new Map());
542
+ const running = useRef(/* @__PURE__ */ new Map());
543
+ return useCallback((method, path, body) => {
544
+ const json = body === void 0 ? void 0 : JSON.stringify(body);
545
+ const change = `${method} ${path} ${json ?? ""}`;
546
+ const already = running.current.get(change);
547
+ if (already) return already;
548
+ let key = keys.current.get(change);
549
+ if (!key) {
550
+ key = crypto.randomUUID();
551
+ keys.current.set(change, key);
552
+ }
553
+ const headers = new Headers({ "idempotency-key": key });
554
+ if (json !== void 0) headers.set("content-type", "application/json");
555
+ const sent = call(request, `account/${path}`, {
556
+ method,
557
+ headers,
558
+ body: json
559
+ }).then((result) => {
560
+ running.current.delete(change);
561
+ if (result.error?.code !== "network_error") keys.current.delete(change);
562
+ store.notice(result.error);
563
+ return result.error ? result : {
564
+ data: result.data ?? null,
565
+ error: null
566
+ };
567
+ });
568
+ running.current.set(change, sent);
569
+ return sent;
570
+ }, [store, request]);
571
+ }
572
+ function currentPath() {
573
+ if (typeof window === "undefined") return "/";
574
+ return `${window.location.pathname}${window.location.search}`;
575
+ }
576
+ //#endregion
577
+ //#region src/components/SignedIn.tsx
578
+ /** Shows its children only while someone is signed in. It renders no element of its own. */
579
+ function SignedIn({ children }) {
580
+ return useSession().isSignedIn ? children : null;
581
+ }
582
+ //#endregion
583
+ //#region src/components/SignedOut.tsx
584
+ /** Shows its children only once the app has said nobody is signed in. It renders no element of its own. */
585
+ function SignedOut({ children }) {
586
+ return useSession().status === "signed-out" ? children : null;
587
+ }
588
+ //#endregion
589
+ //#region src/components/styling.ts
590
+ /** The classes of an inner element: `synq-<name>` for each name, or none when unstyled. */
591
+ function styler(unstyled) {
592
+ return (...names) => unstyled ? void 0 : names.map((name) => `synq-${name}`).join(" ");
593
+ }
594
+ /** The classes of a component's outer element: `synq` and its own when styled, and the app's. */
595
+ function rootClass(unstyled, name, className) {
596
+ const names = unstyled ? [] : ["synq", `synq-${name}`];
597
+ if (className) names.push(className);
598
+ return names.length > 0 ? names.join(" ") : void 0;
599
+ }
600
+ /** What to tell the person when a change failed: the problem's field messages, or its detail. */
601
+ function failureText(error) {
602
+ const fields = (error.cause?.errors ?? []).map((field) => field.message).filter((message) => typeof message === "string");
603
+ return fields.length > 0 ? fields.join(". ") : error.message;
604
+ }
605
+ /** A long address, shortened to its ends: `seedYs…dRKA`. */
606
+ function shortAddress(address) {
607
+ return address.length > 12 ? `${address.slice(0, 6)}…${address.slice(-4)}` : address;
608
+ }
609
+ //#endregion
610
+ //#region src/components/SignInButton.tsx
611
+ /** A button that sends the browser to sign in, through the app's route mount. */
612
+ function SignInButton({ unstyled, className, children, ...options }) {
613
+ const { signIn } = useAuth();
614
+ const m = useSynqMessages();
615
+ return /* @__PURE__ */ jsx("button", {
616
+ type: "button",
617
+ className: rootClass(unstyled, "sign-in-button", className),
618
+ onClick: () => signIn(options),
619
+ children: children ?? m.signIn
620
+ });
621
+ }
622
+ //#endregion
623
+ //#region src/components/UserButton.tsx
624
+ /**
625
+ * The signed-in person's picture and name, showing a panel to manage their
626
+ * account or sign out: a disclosure (a button that shows and hides it), so
627
+ * its link and button are reached with Tab as anywhere else. Escape or a
628
+ * click elsewhere hides it. Nothing while nobody is signed in.
629
+ */
630
+ function UserButton({ unstyled, className, accountHref }) {
631
+ const { user } = useSession();
632
+ const { signOut } = useAuth();
633
+ const m = useSynqMessages();
634
+ const [open, setOpen] = useState(false);
635
+ const root = useRef(null);
636
+ const trigger = useRef(null);
637
+ const panel = useId();
638
+ const c = styler(unstyled);
639
+ useEffect(() => {
640
+ if (!open) return;
641
+ const onKey = (event) => {
642
+ if (event.key !== "Escape") return;
643
+ setOpen(false);
644
+ trigger.current?.focus();
645
+ };
646
+ const onPointer = (event) => {
647
+ if (!root.current?.contains(event.target)) setOpen(false);
648
+ };
649
+ document.addEventListener("keydown", onKey);
650
+ document.addEventListener("pointerdown", onPointer);
651
+ return () => {
652
+ document.removeEventListener("keydown", onKey);
653
+ document.removeEventListener("pointerdown", onPointer);
654
+ };
655
+ }, [open]);
656
+ if (!user) return null;
657
+ const name = displayName(user, m.account);
658
+ return /* @__PURE__ */ jsxs("div", {
659
+ ref: root,
660
+ className: rootClass(unstyled, "user-button", className),
661
+ children: [/* @__PURE__ */ jsxs("button", {
662
+ ref: trigger,
663
+ type: "button",
664
+ className: c("user-button-trigger"),
665
+ "aria-expanded": open,
666
+ "aria-controls": panel,
667
+ onClick: () => setOpen((was) => !was),
668
+ children: [/* @__PURE__ */ jsx(Avatar, {
669
+ user,
670
+ name,
671
+ className: c("avatar")
672
+ }), /* @__PURE__ */ jsx("span", {
673
+ className: c("user-button-name"),
674
+ children: name
675
+ })]
676
+ }), /* @__PURE__ */ jsxs("div", {
677
+ id: panel,
678
+ hidden: !open,
679
+ className: c("menu"),
680
+ children: [accountHref && /* @__PURE__ */ jsx("a", {
681
+ href: accountHref,
682
+ className: c("menu-item"),
683
+ children: m.manageAccount
684
+ }), /* @__PURE__ */ jsx("button", {
685
+ type: "button",
686
+ className: c("menu-item"),
687
+ onClick: () => void signOut(),
688
+ children: m.signOut
689
+ })]
690
+ })]
691
+ });
692
+ }
693
+ function Avatar({ user, name, className }) {
694
+ if (user.picture) return /* @__PURE__ */ jsx("img", {
695
+ className,
696
+ src: user.picture,
697
+ alt: "",
698
+ width: 32,
699
+ height: 32,
700
+ referrerPolicy: "no-referrer"
701
+ });
702
+ return /* @__PURE__ */ jsx("span", {
703
+ className,
704
+ "aria-hidden": "true",
705
+ children: initials(name)
706
+ });
707
+ }
708
+ /** What to call the person: their name, username or email address. */
709
+ function displayName(user, fallback) {
710
+ return user.name ?? user.preferred_username ?? user.email ?? fallback;
711
+ }
712
+ function initials(name) {
713
+ return name.split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map((word) => [...word][0].toUpperCase()).join("");
714
+ }
715
+ //#endregion
716
+ //#region src/components/AccountManager.tsx
717
+ const ALL = [
718
+ "profile",
719
+ "accounts",
720
+ "sessions"
721
+ ];
722
+ /** Manage the signed-in person's account. Nothing while nobody is signed in. */
723
+ function AccountManager({ unstyled, className, sections = ALL }) {
724
+ const { isSignedIn } = useSession();
725
+ if (!isSignedIn) return null;
726
+ return /* @__PURE__ */ jsx("div", {
727
+ className: rootClass(unstyled, "account-manager", className),
728
+ children: sections.map((section) => section === "profile" ? /* @__PURE__ */ jsx(ProfileSection, { unstyled }, section) : section === "accounts" ? /* @__PURE__ */ jsx(AccountsSection, { unstyled }, section) : /* @__PURE__ */ jsx(SessionsSection, { unstyled }, section))
729
+ });
730
+ }
731
+ function Section({ title, unstyled, children }) {
732
+ const heading = useId();
733
+ const c = styler(unstyled);
734
+ return /* @__PURE__ */ jsxs("section", {
735
+ "aria-labelledby": heading,
736
+ className: c("section"),
737
+ children: [/* @__PURE__ */ jsx("h2", {
738
+ id: heading,
739
+ className: c("heading"),
740
+ children: title
741
+ }), children]
742
+ });
743
+ }
744
+ /** What a section shows while its data is read, or when it couldn't be. */
745
+ function Pending({ isLoading, error, unstyled }) {
746
+ const c = styler(unstyled);
747
+ const m = useSynqMessages();
748
+ if (isLoading) return /* @__PURE__ */ jsx("p", {
749
+ className: c("muted"),
750
+ children: m.loading
751
+ });
752
+ if (error) return /* @__PURE__ */ jsx("p", {
753
+ role: "alert",
754
+ className: c("error"),
755
+ children: error.message
756
+ });
757
+ return null;
758
+ }
759
+ function ProfileSection({ unstyled }) {
760
+ const { profile, isLoading, error } = useUser();
761
+ const m = useSynqMessages();
762
+ return /* @__PURE__ */ jsx(Section, {
763
+ title: m.profile,
764
+ unstyled,
765
+ children: profile ? /* @__PURE__ */ jsx(ProfileForm, {
766
+ profile,
767
+ unstyled
768
+ }, profile.sub) : /* @__PURE__ */ jsx(Pending, {
769
+ isLoading,
770
+ error,
771
+ unstyled
772
+ })
773
+ });
774
+ }
775
+ function ProfileForm({ profile, unstyled }) {
776
+ const { update } = useUser();
777
+ const m = useSynqMessages();
778
+ const [name, setName] = useState(profile.name ?? "");
779
+ const [username, setUsername] = useState(profile.username ?? "");
780
+ const [saving, setSaving] = useState(false);
781
+ const [outcome, setOutcome] = useState(null);
782
+ const c = styler(unstyled);
783
+ const changes = {};
784
+ if ((name.trim() || null) !== profile.name) changes.name = name.trim() || null;
785
+ if ((username.trim() || null) !== profile.username) changes.username = username.trim() || null;
786
+ const changed = Object.keys(changes).length > 0;
787
+ async function save(event) {
788
+ event.preventDefault();
789
+ setSaving(true);
790
+ setOutcome(null);
791
+ const result = await update(changes);
792
+ setSaving(false);
793
+ setOutcome(result.error ? {
794
+ ok: false,
795
+ text: failureText(result.error)
796
+ } : {
797
+ ok: true,
798
+ text: m.saved
799
+ });
800
+ }
801
+ return /* @__PURE__ */ jsxs("form", {
802
+ className: c("form"),
803
+ onSubmit: (event) => void save(event),
804
+ children: [
805
+ /* @__PURE__ */ jsxs("label", {
806
+ className: c("field"),
807
+ children: [/* @__PURE__ */ jsx("span", {
808
+ className: c("label"),
809
+ children: m.name
810
+ }), /* @__PURE__ */ jsx("input", {
811
+ className: c("input"),
812
+ name: "name",
813
+ autoComplete: "name",
814
+ maxLength: 200,
815
+ value: name,
816
+ onChange: (event) => setName(event.target.value)
817
+ })]
818
+ }),
819
+ /* @__PURE__ */ jsxs("label", {
820
+ className: c("field"),
821
+ children: [/* @__PURE__ */ jsx("span", {
822
+ className: c("label"),
823
+ children: m.username
824
+ }), /* @__PURE__ */ jsx("input", {
825
+ className: c("input"),
826
+ name: "username",
827
+ autoComplete: "username",
828
+ maxLength: 64,
829
+ value: username,
830
+ onChange: (event) => setUsername(event.target.value)
831
+ })]
832
+ }),
833
+ /* @__PURE__ */ jsxs("div", {
834
+ className: c("actions"),
835
+ children: [/* @__PURE__ */ jsx("button", {
836
+ type: "submit",
837
+ className: c("button", "button-primary"),
838
+ disabled: saving || !changed,
839
+ children: saving ? m.saving : m.save
840
+ }), outcome && /* @__PURE__ */ jsx("p", {
841
+ role: outcome.ok ? "status" : "alert",
842
+ className: c(outcome.ok ? "muted" : "error"),
843
+ children: outcome.text
844
+ })]
845
+ })
846
+ ]
847
+ });
848
+ }
849
+ function AccountsSection({ unstyled }) {
850
+ const { data, isLoading, error } = useLinkedAccounts();
851
+ const m = useSynqMessages();
852
+ const c = styler(unstyled);
853
+ return /* @__PURE__ */ jsx(Section, {
854
+ title: m.waysToSignIn,
855
+ unstyled,
856
+ children: data ? /* @__PURE__ */ jsx("ul", {
857
+ className: c("list"),
858
+ children: data.map((account) => /* @__PURE__ */ jsx(AccountRow, {
859
+ account,
860
+ removable: data.length > 1,
861
+ unstyled
862
+ }, account.id))
863
+ }) : /* @__PURE__ */ jsx(Pending, {
864
+ isLoading,
865
+ error,
866
+ unstyled
867
+ })
868
+ });
869
+ }
870
+ function AccountRow({ account, removable, unstyled }) {
871
+ const { accounts } = useAuth();
872
+ const m = useSynqMessages();
873
+ const [busy, setBusy] = useState(false);
874
+ const [failure, setFailure] = useState(null);
875
+ const c = styler(unstyled);
876
+ const provider = m.providers[account.provider] ?? capitalized(account.provider);
877
+ const subject = account.provider === "solana" ? shortAddress(account.subject) : account.subject;
878
+ async function remove() {
879
+ setBusy(true);
880
+ const result = await accounts.unlink(account.id);
881
+ setBusy(false);
882
+ setFailure(result.error ? failureText(result.error) : null);
883
+ }
884
+ return /* @__PURE__ */ jsxs("li", {
885
+ className: c("row"),
886
+ children: [
887
+ /* @__PURE__ */ jsx("span", {
888
+ className: c("row-title"),
889
+ children: provider
890
+ }),
891
+ /* @__PURE__ */ jsx("span", {
892
+ className: c("muted"),
893
+ title: account.subject,
894
+ children: subject
895
+ }),
896
+ /* @__PURE__ */ jsx("button", {
897
+ type: "button",
898
+ className: c("button"),
899
+ disabled: busy || !removable,
900
+ "aria-label": m.removeAccount(provider, subject),
901
+ onClick: () => void remove(),
902
+ children: m.remove
903
+ }),
904
+ failure && /* @__PURE__ */ jsx("p", {
905
+ role: "alert",
906
+ className: c("error"),
907
+ children: failure
908
+ })
909
+ ]
910
+ });
911
+ }
912
+ function SessionsSection({ unstyled }) {
913
+ const { data, isLoading, error } = useAccountSessions();
914
+ const { sessions } = useAuth();
915
+ const m = useSynqMessages();
916
+ const [failure, setFailure] = useState(null);
917
+ const c = styler(unstyled);
918
+ async function end(id) {
919
+ const result = await sessions.end(id);
920
+ setFailure(result.error ? failureText(result.error) : null);
921
+ }
922
+ return /* @__PURE__ */ jsxs(Section, {
923
+ title: m.devices,
924
+ unstyled,
925
+ children: [data ? /* @__PURE__ */ jsx("ul", {
926
+ className: c("list"),
927
+ children: data.map((session) => {
928
+ const when = session.signed_in_at ? formatDate(session.signed_in_at) : m.signedIn;
929
+ return /* @__PURE__ */ jsxs("li", {
930
+ className: c("row"),
931
+ children: [
932
+ /* @__PURE__ */ jsx("span", {
933
+ className: c("row-title"),
934
+ children: when
935
+ }),
936
+ session.current && /* @__PURE__ */ jsx("span", {
937
+ className: c("badge"),
938
+ children: m.thisDevice
939
+ }),
940
+ /* @__PURE__ */ jsx("span", {
941
+ className: c("muted"),
942
+ children: m.apps(session.apps.length)
943
+ }),
944
+ /* @__PURE__ */ jsx("button", {
945
+ type: "button",
946
+ className: c("button"),
947
+ "aria-label": m.signOutDevice(when),
948
+ onClick: () => void end(session.id),
949
+ children: m.signOut
950
+ })
951
+ ]
952
+ }, session.id);
953
+ })
954
+ }) : /* @__PURE__ */ jsx(Pending, {
955
+ isLoading,
956
+ error,
957
+ unstyled
958
+ }), failure && /* @__PURE__ */ jsx("p", {
959
+ role: "alert",
960
+ className: c("error"),
961
+ children: failure
962
+ })]
963
+ });
964
+ }
965
+ function capitalized(provider) {
966
+ return provider.charAt(0).toUpperCase() + provider.slice(1);
967
+ }
968
+ function formatDate(iso) {
969
+ return new Intl.DateTimeFormat(void 0, {
970
+ dateStyle: "medium",
971
+ timeStyle: "short"
972
+ }).format(new Date(iso));
973
+ }
974
+ //#endregion
975
+ //#region src/components/WalletManager.tsx
976
+ /** Manage the signed-in person's wallets. Nothing while nobody is signed in. */
977
+ function WalletManager({ unstyled, className }) {
978
+ const { isSignedIn } = useSession();
979
+ const { data, isLoading, error } = useWallets();
980
+ const m = useSynqMessages();
981
+ const heading = useId();
982
+ const c = styler(unstyled);
983
+ if (!isSignedIn) return null;
984
+ return /* @__PURE__ */ jsxs("section", {
985
+ "aria-labelledby": heading,
986
+ className: rootClass(unstyled, "wallet-manager", className),
987
+ children: [/* @__PURE__ */ jsx("h2", {
988
+ id: heading,
989
+ className: c("heading"),
990
+ children: m.wallets
991
+ }), data ? data.length > 0 ? /* @__PURE__ */ jsx("ul", {
992
+ className: c("list"),
993
+ children: data.map((wallet) => /* @__PURE__ */ jsx(WalletRow, {
994
+ wallet,
995
+ unstyled
996
+ }, wallet.id))
997
+ }) : /* @__PURE__ */ jsx("p", {
998
+ className: c("muted"),
999
+ children: m.noWallets
1000
+ }) : isLoading ? /* @__PURE__ */ jsx("p", {
1001
+ className: c("muted"),
1002
+ children: m.loading
1003
+ }) : error && /* @__PURE__ */ jsx("p", {
1004
+ role: "alert",
1005
+ className: c("error"),
1006
+ children: error.message
1007
+ })]
1008
+ });
1009
+ }
1010
+ function WalletRow({ wallet, unstyled }) {
1011
+ const { wallets } = useAuth();
1012
+ const m = useSynqMessages();
1013
+ const [editing, setEditing] = useState(false);
1014
+ const [label, setLabel] = useState(wallet.label ?? "");
1015
+ const [busy, setBusy] = useState(false);
1016
+ const [failure, setFailure] = useState(null);
1017
+ const c = styler(unstyled);
1018
+ const title = wallet.label ?? shortAddress(wallet.address);
1019
+ async function rename(event) {
1020
+ event.preventDefault();
1021
+ setBusy(true);
1022
+ const result = await wallets.rename(wallet.id, label.trim() || null);
1023
+ setBusy(false);
1024
+ setFailure(result.error ? failureText(result.error) : null);
1025
+ if (!result.error) setEditing(false);
1026
+ }
1027
+ async function makePrimary() {
1028
+ setBusy(true);
1029
+ const result = await wallets.setPrimary(wallet.id);
1030
+ setBusy(false);
1031
+ setFailure(result.error ? failureText(result.error) : null);
1032
+ }
1033
+ return /* @__PURE__ */ jsxs("li", {
1034
+ className: c("row"),
1035
+ children: [editing ? /* @__PURE__ */ jsxs("form", {
1036
+ className: c("form", "form-inline"),
1037
+ onSubmit: (event) => void rename(event),
1038
+ children: [
1039
+ /* @__PURE__ */ jsxs("label", {
1040
+ className: c("field"),
1041
+ children: [/* @__PURE__ */ jsx("span", {
1042
+ className: c("label"),
1043
+ children: m.walletName(shortAddress(wallet.address))
1044
+ }), /* @__PURE__ */ jsx("input", {
1045
+ className: c("input"),
1046
+ name: "label",
1047
+ maxLength: 64,
1048
+ value: label,
1049
+ onChange: (event) => setLabel(event.target.value)
1050
+ })]
1051
+ }),
1052
+ /* @__PURE__ */ jsx("button", {
1053
+ type: "submit",
1054
+ className: c("button", "button-primary"),
1055
+ disabled: busy,
1056
+ children: m.save
1057
+ }),
1058
+ /* @__PURE__ */ jsx("button", {
1059
+ type: "button",
1060
+ className: c("button"),
1061
+ onClick: () => {
1062
+ setEditing(false);
1063
+ setLabel(wallet.label ?? "");
1064
+ setFailure(null);
1065
+ },
1066
+ children: m.cancel
1067
+ })
1068
+ ]
1069
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [
1070
+ /* @__PURE__ */ jsx("span", {
1071
+ className: c("row-title"),
1072
+ title: wallet.address,
1073
+ children: title
1074
+ }),
1075
+ wallet.label && /* @__PURE__ */ jsx("span", {
1076
+ className: c("muted"),
1077
+ title: wallet.address,
1078
+ children: shortAddress(wallet.address)
1079
+ }),
1080
+ wallet.primary && /* @__PURE__ */ jsx("span", {
1081
+ className: c("badge"),
1082
+ children: m.primary
1083
+ }),
1084
+ /* @__PURE__ */ jsx("button", {
1085
+ type: "button",
1086
+ className: c("button"),
1087
+ "aria-label": m.renameWallet(title),
1088
+ onClick: () => setEditing(true),
1089
+ children: m.rename
1090
+ }),
1091
+ !wallet.primary && /* @__PURE__ */ jsx("button", {
1092
+ type: "button",
1093
+ className: c("button"),
1094
+ disabled: busy,
1095
+ "aria-label": m.makeWalletPrimary(title),
1096
+ onClick: () => void makePrimary(),
1097
+ children: m.makePrimary
1098
+ })
1099
+ ] }), failure && /* @__PURE__ */ jsx("p", {
1100
+ role: "alert",
1101
+ className: c("error"),
1102
+ children: failure
1103
+ })]
1104
+ });
1105
+ }
1106
+ //#endregion
1107
+ export { AccountManager, DEFAULT_MESSAGES, SignInButton, SignedIn, SignedOut, SynqProvider, UserButton, WalletManager, useAccountSessions, useAuth, useLinkedAccounts, usePlugin, useSession, useUser, useWallets };