portal-mojo 0.2.6 → 0.2.8

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.
@@ -1,117 +1,38 @@
1
- // Notifications — AdminNotificationsSection port (read in full 2026-08-05):
2
- // the per-kind × per-channel (In-App / Email / Push) toggle grid over
3
- // /api/account/notification/preferences. A toggle POSTs the PARTIAL update
4
- // {preferences: {kind: {channel: bool}}} optimistically and reverts on
5
- // failure (source semantics; absent channels read as ON — `!== false`).
1
+ // Notifications — AdminNotificationsSection port (read in full 2026-08-05),
2
+ // now rendered by the shared portal-mojo/account NotificationPreferences:
3
+ // three master channel cards (the reserved `"*"` all-kinds entry) over the
4
+ // per-kind × channel grid on /api/account/notification/preferences. A
5
+ // toggle POSTs the PARTIAL update {preferences: {kind: {channel: bool}}}
6
+ // optimistically and reverts on failure (absent channels read as ON).
6
7
  //
7
8
  // LIVE-SCOPE NOTE (django-mojo notification_prefs.py, read 2026-08-05): the
8
9
  // real handler reads request.user and IGNORES the user param — the
9
10
  // admin-views-another-user path is not a backend surface. Other-user detail
10
11
  // therefore stays explicitly unavailable and never issues this query.
11
- import { useQuery, useQueryClient } from '@tanstack/react-query';
12
- import { mojoCall, useMe } from '../../../../client/runtime';
13
- import { Eyebrow, toast } from '../../../../ui';
12
+ import { useMe } from '../../../../client/runtime';
13
+ import { Eyebrow } from '../../../../ui';
14
+ import type { NotificationKind } from '../../../../account/api';
15
+ import { NotificationPreferences } from '../../../../account/NotificationPreferences';
14
16
  import type { UserRow } from '../models';
15
17
 
16
- const CHANNELS = ['in_app', 'email', 'push'] as const;
17
- const CHANNEL_LABELS: Record<string, string> = {
18
- in_app: 'In-App',
19
- email: 'Email',
20
- push: 'Push',
21
- };
22
-
23
- type Preferences = Record<string, Record<string, boolean>>;
24
-
25
- const prefsKey = (userId: number) => ['/api/account/notification/preferences', userId] as const;
26
-
27
- export function NotificationsSection({ user }: { user: UserRow }) {
28
- const qc = useQueryClient();
18
+ export function NotificationsSection({ user, kinds }: {
19
+ user: UserRow;
20
+ /** The kinds this app sends; defaults to the server catalogue + stored kinds. */
21
+ kinds?: NotificationKind[];
22
+ }) {
29
23
  const { data: me } = useMe();
30
24
  const isSelf = me?.id === user.id;
31
- const { data: prefs, isPending, isError, error } = useQuery<Preferences>({
32
- queryKey: prefsKey(user.id),
33
- queryFn: async () => {
34
- const body = await mojoCall('/api/account/notification/preferences');
35
- const data = body.data as { preferences?: Preferences } | undefined;
36
- return data?.preferences ?? {};
37
- },
38
- enabled: isSelf,
39
- });
40
-
41
- const toggle = async (kind: string, channel: string, next: boolean) => {
42
- // Optimistic write into the query cache; revert + toast on rejection.
43
- const prev = qc.getQueryData<Preferences>(prefsKey(user.id)) ?? {};
44
- qc.setQueryData<Preferences>(prefsKey(user.id), {
45
- ...prev,
46
- [kind]: { ...(prev[kind] ?? {}), [channel]: next },
47
- });
48
- try {
49
- await mojoCall('/api/account/notification/preferences', {
50
- method: 'POST',
51
- body: { preferences: { [kind]: { [channel]: next } } },
52
- });
53
- } catch (err) {
54
- qc.setQueryData(prefsKey(user.id), prev);
55
- toast.error(err instanceof Error ? err.message : 'Failed to update preference');
56
- }
57
- };
58
-
59
- const kinds = Object.keys(prefs ?? {}).sort();
60
- const kindLabel = (kind: string) =>
61
- kind.replace(/[_-]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
62
-
63
25
  return (
64
26
  <>
65
27
  <Eyebrow>Notification preferences</Eyebrow>
66
- {!isSelf && (
67
- <div className="us-empty">
68
- <i className="bi bi-lock" />
69
- <div>Notification preferences are caller-only and cannot be administered for another user.</div>
70
- </div>
71
- )}
72
- {isSelf && <>
73
- {isPending && <p className="dim">Loading…</p>}
74
- {isError && <p className="dim">{error instanceof Error ? error.message : 'Failed to load preferences.'}</p>}
75
- {!isPending && !isError && kinds.length === 0 && (
76
- <div className="us-empty">
77
- <i className="bi bi-bell" />
78
- <div>No notification preferences configured</div>
79
- </div>
80
- )}
81
- {kinds.length > 0 && (
82
- <table className="us-prefs-table">
83
- <thead>
84
- <tr>
85
- <th>Type</th>
86
- {CHANNELS.map((ch) => <th key={ch}>{CHANNEL_LABELS[ch]}</th>)}
87
- </tr>
88
- </thead>
89
- <tbody>
90
- {kinds.map((kind) => (
91
- <tr key={kind}>
92
- <td>{kindLabel(kind)}</td>
93
- {CHANNELS.map((ch) => {
94
- // Absent channel == enabled (source `!== false`).
95
- const checked = prefs?.[kind]?.[ch] !== false;
96
- return (
97
- <td key={ch}>
98
- <input
99
- type="checkbox"
100
- role="switch"
101
- className="switch"
102
- checked={checked}
103
- aria-label={`${kindLabel(kind)} via ${CHANNEL_LABELS[ch]}`}
104
- onChange={(e) => void toggle(kind, ch, e.target.checked)}
105
- />
106
- </td>
107
- );
108
- })}
109
- </tr>
110
- ))}
111
- </tbody>
112
- </table>
113
- )}
114
- </>}
28
+ {isSelf
29
+ ? <NotificationPreferences kinds={kinds} />
30
+ : (
31
+ <div className="us-empty">
32
+ <i className="bi bi-lock" />
33
+ <div>Notification preferences are caller-only and cannot be administered for another user.</div>
34
+ </div>
35
+ )}
115
36
  </>
116
37
  );
117
38
  }
@@ -9,8 +9,13 @@
9
9
  // django-mojo's NO_SAVE_FIELDS (user.py RestMeta, read 2026-08-05), so the
10
10
  // server silently DROPS the write and the source UI toasts a success that
11
11
  // never happened. The verified badge still renders from the row.
12
+ //
13
+ // `model` (default UserModel) lets the signed-in user's own surfaces save
14
+ // through portal-mojo/account's MeSaveModel (`/api/user/me`). Self-saves
15
+ // cannot replace a date of birth once set (the server 403s), so with any
16
+ // non-admin model a set DOB renders read-only.
12
17
  import { Badge, Eyebrow, FlatRow, FormView } from '../../../../ui';
13
- import type { Field } from '../../../../client/runtime';
18
+ import type { Field, ModelDef } from '../../../../client/runtime';
14
19
  import { UserModel, type UserRow } from '../models';
15
20
 
16
21
  const NAME_FIELDS: Field[] = [
@@ -35,11 +40,19 @@ const ADDRESS_FIELDS: Field[] = [
35
40
  { name: 'metadata.country', type: 'text', label: 'Country', columns: 6 },
36
41
  ];
37
42
 
38
- export function PersonalSection({ user }: { user: UserRow }) {
43
+ export interface PersonalSectionProps {
44
+ user: UserRow;
45
+ /** Save surface; default UserModel (admin). Pass MeSaveModel for self. */
46
+ model?: Pick<ModelDef<UserRow>, 'useSave'>;
47
+ }
48
+
49
+ export function PersonalSection({ user, model = UserModel }: PersonalSectionProps) {
50
+ const dobLocked = model !== UserModel && Boolean(user.dob);
51
+ const detailFields = dobLocked ? DETAIL_FIELDS.filter((field) => field.name !== 'dob') : DETAIL_FIELDS;
39
52
  return (
40
53
  <>
41
54
  <Eyebrow>Name</Eyebrow>
42
- <FormView model={UserModel} row={user} fields={NAME_FIELDS} />
55
+ <FormView model={model} row={user} fields={NAME_FIELDS} />
43
56
 
44
57
  <Eyebrow>Details</Eyebrow>
45
58
  <FlatRow label="DOB status">
@@ -54,13 +67,13 @@ export function PersonalSection({ user }: { user: UserRow }) {
54
67
  )
55
68
  : <span className="dim-italic">Not set</span>}
56
69
  </FlatRow>
57
- <FormView model={UserModel} row={user} fields={DETAIL_FIELDS} />
70
+ <FormView model={model} row={user} fields={detailFields} />
58
71
 
59
72
  <Eyebrow>Address</Eyebrow>
60
73
  <FlatRow label="On file">
61
74
  {addressSummary(user) || <span className="dim-italic">Not set</span>}
62
75
  </FlatRow>
63
- <FormView model={UserModel} row={user} fields={ADDRESS_FIELDS} />
76
+ <FormView model={model} row={user} fields={ADDRESS_FIELDS} />
64
77
  </>
65
78
  );
66
79
  }
@@ -10,19 +10,18 @@
10
10
  // the public, admin-targetable verification sender (NOT the JWT-scoped
11
11
  // auth/verify/email/send).
12
12
  import { useQueryClient } from '@tanstack/react-query';
13
- import { useEffect, useRef, useState } from 'react';
14
- import { mojoCall, useCan, useMe, withFreshAuth, type Field } from '../../../../client/runtime';
13
+ import { useEffect, useState } from 'react';
14
+ import { mojoCall, withFreshAuth, type Field } from '../../../../client/runtime';
15
15
  import {
16
- Badge,
17
16
  ImageField,
18
17
  PasswordStrengthMeter,
19
- fmt,
20
18
  formModal,
21
19
  modal,
22
20
  toast,
23
21
  type FileFieldOwnerResult,
24
22
  } from '../../../../ui';
25
- import { PasskeyModel, USER_MANAGE_PERMISSIONS, UserModel, type UserRow } from '../models';
23
+ import { PasskeyList } from '../../../../account/PasskeyList';
24
+ import { UserModel, type UserRow } from '../models';
26
25
  import { OAuthConnectionList } from './OAuthSection';
27
26
  import { openGroupDetail, useAdminCaller } from './shared';
28
27
 
@@ -664,106 +663,14 @@ function AvatarModal({ user, onClose, onPendingChange }: { user: UserRow; onClos
664
663
  }
665
664
 
666
665
  /**
667
- * Manage passkeys — list / rename / enable-disable / delete over
668
- * /api/account/passkeys (?user=<id>): the two REST-editable fields are
669
- * friendly_name and is_enabled; delete is real (CAN_DELETE).
666
+ * Manage passkeys — the shared portal-mojo/account PasskeyList (rename /
667
+ * enable-disable / remove over /api/account/passkeys?user=<id>).
670
668
  */
671
669
  function PasskeysModal({ userId, onClose }: { userId: number; onClose: () => void }) {
672
- const admin = useCan(USER_MANAGE_PERMISSIONS).can;
673
- const allowed = useMe().data?.id === userId || admin;
674
- const permission = useRef(allowed); permission.current = allowed;
675
- useEffect(() => { permission.current = allowed; return () => { permission.current = false; }; }, [allowed]);
676
- const { data, isPending } = PasskeyModel.useList({ user: userId, size: 25, sort: '-created' });
677
- const save = PasskeyModel.useSave();
678
- const del = PasskeyModel.useDelete();
679
- const rows = data?.rows ?? [];
680
-
681
- const rename = async (id: number, current: string | null) => {
682
- const data = await formModal({
683
- title: 'Edit passkey',
684
- submitText: 'Save',
685
- fields: [{
686
- name: 'friendly_name', type: 'text', label: 'Name', required: true,
687
- placeholder: 'My iPhone', help: 'A friendly name to identify this passkey',
688
- }],
689
- initial: { friendly_name: current ?? '' },
690
- });
691
- const name = data?.friendly_name;
692
- if (!permission.current || typeof name !== 'string' || !name.trim()) return;
693
- try {
694
- await save.mutateAsync({ id, changes: { friendly_name: name.trim() } });
695
- toast.success('Passkey updated');
696
- } catch (err) {
697
- toast.error(err instanceof Error ? err.message : 'Failed to update passkey');
698
- }
699
- };
700
-
701
- const toggleEnabled = async (id: number, next: boolean) => {
702
- if (!permission.current) return;
703
- try {
704
- await save.mutateAsync({ id, changes: { is_enabled: next } });
705
- toast.success(next ? 'Passkey enabled' : 'Passkey disabled');
706
- } catch (err) {
707
- toast.error(err instanceof Error ? err.message : 'Failed to update passkey');
708
- }
709
- };
710
-
711
- const remove = async (id: number) => {
712
- const ok = await modal.confirm({
713
- title: 'Delete passkey',
714
- message: `Remove ${rows.find(row => row.id === id)?.friendly_name || 'Unnamed passkey'} for ${rows.find(row => row.id === id)?.rp_id || 'unknown relying party'}, user #${userId}? Reenrollment is required to restore it. The physical device is not erased.`,
715
- confirmText: 'Delete',
716
- danger: true,
717
- });
718
- if (!ok || !permission.current) return;
719
- try {
720
- await del.mutateAsync({ id });
721
- toast.success('Passkey deleted');
722
- } catch (err) {
723
- toast.error(err instanceof Error ? err.message : 'Failed to delete passkey');
724
- }
725
- };
726
-
727
670
  return (
728
671
  <div className="modal-pad">
729
672
  <h2 className="modal-title">Passkeys</h2>
730
- {isPending && <p className="dim">Loading…</p>}
731
- {!isPending && rows.length === 0 && (
732
- <div className="us-empty">
733
- <i className="bi bi-fingerprint" />
734
- <div>No passkeys registered</div>
735
- </div>
736
- )}
737
- {rows.map((p) => (
738
- <div key={p.id} className="us-passkey-row">
739
- <div className="us-row-icon"><i className="bi bi-fingerprint" /></div>
740
- <div className="us-row-info">
741
- <div className="us-row-title">
742
- {p.friendly_name || 'Unnamed passkey'}
743
- {!p.is_enabled && <Badge tone="muted">Disabled</Badge>}
744
- </div>
745
- <div className="us-row-meta">
746
- Created {fmt.date(p.created)} · Last used {fmt.relative(p.last_used, 'never')} · {p.sign_count} uses
747
- </div>
748
- </div>
749
- <div className="us-row-actions" inert={!allowed || save.isPending || del.isPending}>
750
- <button className="btn-icon btn-icon-sm" title="Rename" aria-label={`Rename ${p.friendly_name ?? 'passkey'}`} onClick={() => void rename(p.id, p.friendly_name)}>
751
- <i className="bi bi-pencil" />
752
- </button>
753
- <button
754
- className="btn-icon btn-icon-sm"
755
- title={p.is_enabled ? 'Disable' : 'Enable'}
756
- aria-label={p.is_enabled ? 'Disable passkey' : 'Enable passkey'}
757
- onClick={() => void toggleEnabled(p.id, !p.is_enabled)}
758
- >
759
- <i className={`bi ${p.is_enabled ? 'bi-toggle-on' : 'bi-toggle-off'}`} />
760
- </button>
761
- <button className="btn-icon btn-icon-sm us-danger" title="Delete" aria-label="Delete passkey" onClick={() => void remove(p.id)}>
762
- <i className="bi bi-trash" />
763
- </button>
764
- </div>
765
- </div>
766
- ))}
673
+ <PasskeyList userId={userId} />
767
674
  <div className="modal-actions">
768
675
  <button className="btn" onClick={onClose}>Close</button>
769
676
  </div>
@@ -133,7 +133,12 @@ export function subscribeAuth(cb: () => void): () => void {
133
133
 
134
134
  // ── Auth events ───────────────────────────────────────────────────────
135
135
 
136
- export type AuthEvent = 'login' | 'logout' | 'refreshed' | 'refresh-failed' | 'unauthorized';
136
+ /**
137
+ * 'login' — an explicit, completed sign-in (password/MFA/passkey/magic/reset/
138
+ * exchange). 'rotated' — the SAME user's session re-issued by a credential
139
+ * change (sessions revoke, email-change confirm): new tokens, not a login.
140
+ */
141
+ export type AuthEvent = 'login' | 'rotated' | 'logout' | 'refreshed' | 'refresh-failed' | 'unauthorized';
137
142
  const eventListeners = new Map<AuthEvent, Set<(detail?: unknown) => void>>();
138
143
 
139
144
  export function onAuth(event: AuthEvent, cb: (detail?: unknown) => void): () => void {
@@ -195,6 +200,9 @@ async function doRefresh(): Promise<boolean> {
195
200
  const persistent = sessionIsPersistent();
196
201
  try {
197
202
  const body = await mojoCall(REFRESH_PATH, { method: 'POST', body: { refresh_token: refresh } });
203
+ // The session changed while this POST was in flight (a new login was
204
+ // adopted, or logout): never write the old session's pair over it.
205
+ if (getRefreshToken() !== refresh) return getRefreshToken() != null;
198
206
  const pkg = body.data as { access_token: string; refresh_token?: string };
199
207
  setTokens(pkg.access_token, pkg.refresh_token, persistent);
200
208
  emitAuth('refreshed');
@@ -296,11 +304,26 @@ interface TokenGrant {
296
304
  expires_in?: number;
297
305
  }
298
306
 
299
- function adoptGrant(data: TokenGrant, remember = true): AuthUser {
307
+ /** Copy for a grant that carries no usable session (e.g. a forced-password answer). */
308
+ export const GRANT_SIGN_IN_AGAIN = 'Sign in again to continue';
309
+
310
+ /**
311
+ * Store a TokenGrant as THE session. Validates first — a django-mojo
312
+ * forced_password_response, for one, answers without tokens after auth_key
313
+ * rotated — and leaves storage untouched (rejecting) when the grant has no
314
+ * access + refresh pair. Serialised behind any in-flight refresh so a
315
+ * refresh that started on the old session can never land on top of it.
316
+ */
317
+ async function adoptGrant(data: TokenGrant | null | undefined, remember = true, event: 'login' | 'rotated' = 'login'): Promise<AuthUser> {
318
+ if (!data || typeof data.access_token !== 'string' || !data.access_token
319
+ || typeof data.refresh_token !== 'string' || !data.refresh_token) {
320
+ throw new Error(GRANT_SIGN_IN_AGAIN);
321
+ }
322
+ if (refreshPromise) await refreshPromise;
300
323
  setTokens(data.access_token, data.refresh_token, remember);
301
324
  startAutoRefresh();
302
325
  const user = data.user ?? {};
303
- emitAuth('login', user);
326
+ emitAuth(event, user);
304
327
  return user;
305
328
  }
306
329
 
@@ -317,7 +340,7 @@ export async function login(username: string, password: string, opts: LoginOptio
317
340
  if (data.mfa_required) {
318
341
  return { kind: 'mfa', mfaToken: data.mfa_token ?? '', methods: data.mfa_methods ?? [], expiresIn: data.expires_in ?? 300 };
319
342
  }
320
- return { kind: 'authenticated', user: adoptGrant(data, opts.remember ?? true) };
343
+ return { kind: 'authenticated', user: await adoptGrant(data, opts.remember ?? true) };
321
344
  }
322
345
 
323
346
  // ── MFA completion (deferred from A1; landed with the C3 pages) ───────
@@ -499,6 +522,170 @@ export async function loginWithPasskey(username?: string, opts: PasskeyLoginOpti
499
522
  return adoptGrant(body.data as TokenGrant, opts.remember ?? true);
500
523
  }
501
524
 
525
+ // ── Passkey registration (self-service, signed in) ────────────────────
526
+ // django-mojo account/rest/passkeys.py:59-150 + utils/passkeys.py:143-201.
527
+ // Both legs are @requires_fresh_auth, so each runs under withFreshAuth. The
528
+ // pair is retry-safe: complete matches on credential_id and the challenge is
529
+ // deleted only after a successful complete, so a 440 on complete re-prompts
530
+ // and replays complete with the SAME challenge and credential.
531
+
532
+ /** Registration needs `create`, not just the `get` that login checks. */
533
+ export function isPasskeyRegistrationSupported(): boolean {
534
+ return typeof window !== 'undefined'
535
+ && typeof window.PublicKeyCredential !== 'undefined'
536
+ && typeof navigator.credentials?.create === 'function';
537
+ }
538
+
539
+ /**
540
+ * A friendly default passkey name from this browser — web-mojo
541
+ * Passkey.suggestName() (core/models/Passkeys.js:42-59), e.g. "Mac — Chrome".
542
+ */
543
+ export function suggestPasskeyName(userAgent?: string): string {
544
+ const ua = userAgent ?? (typeof navigator !== 'undefined' ? navigator.userAgent : '');
545
+ let device = 'Device';
546
+ if (/iPad/.test(ua)) device = 'iPad';
547
+ else if (/iPhone/.test(ua)) device = 'iPhone';
548
+ else if (/Macintosh|MacIntel/.test(ua)) device = 'Mac';
549
+ else if (/Android/.test(ua)) device = 'Android';
550
+ else if (/Windows/.test(ua)) device = 'Windows PC';
551
+ else if (/Linux/.test(ua)) device = 'Linux';
552
+ let browser = '';
553
+ if (/Edg\//.test(ua)) browser = 'Edge';
554
+ else if (/Chrome\//.test(ua) && !/Chromium/.test(ua)) browser = 'Chrome';
555
+ else if (/Safari\//.test(ua) && !/Chrome/.test(ua)) browser = 'Safari';
556
+ else if (/Firefox\//.test(ua)) browser = 'Firefox';
557
+ return browser ? `${device} — ${browser}` : device;
558
+ }
559
+
560
+ interface PasskeyRegisterBeginResponse {
561
+ challenge_id: string;
562
+ publicKey: Omit<PublicKeyCredentialCreationOptions, 'challenge' | 'user' | 'excludeCredentials'> & {
563
+ challenge: unknown;
564
+ user: { id: unknown; name: string; displayName: string };
565
+ excludeCredentials?: { id: unknown; type: string; transports?: string[] }[];
566
+ };
567
+ expiresAt?: string;
568
+ }
569
+
570
+ /** The saved Passkey row `register/complete` answers with (default graph). */
571
+ export interface RegisteredPasskey {
572
+ id: number;
573
+ friendly_name: string | null;
574
+ [field: string]: unknown;
575
+ }
576
+
577
+ /**
578
+ * Register a passkey for the signed-in user on THIS browser/authenticator.
579
+ * `name` defaults to suggestPasskeyName(). Rejects with the authenticator's
580
+ * DOMException (NotAllowedError, InvalidStateError, …) or the server's
581
+ * MojoError — pass either to passkeyErrorMessage(). Callers refresh the
582
+ * passkey list and `me` (has_passkey) afterwards.
583
+ */
584
+ export async function registerPasskey(name?: string): Promise<RegisteredPasskey> {
585
+ if (!isPasskeyRegistrationSupported()) throw new Error('Passkeys are not supported in this browser');
586
+
587
+ const begin = await withFreshAuth(() => mojoCall('/api/account/passkeys/register/begin', { method: 'POST', body: {} }));
588
+ const { challenge_id, publicKey } = begin.data as unknown as PasskeyRegisterBeginResponse;
589
+
590
+ const options: PublicKeyCredentialCreationOptions = {
591
+ ...publicKey,
592
+ challenge: base64urlToBuffer(String(publicKey.challenge)),
593
+ user: { ...publicKey.user, id: base64urlToBuffer(String(publicKey.user.id)) },
594
+ excludeCredentials: publicKey.excludeCredentials?.map((c) => ({
595
+ type: 'public-key' as const,
596
+ id: base64urlToBuffer(String(c.id)),
597
+ transports: c.transports as AuthenticatorTransport[] | undefined,
598
+ })),
599
+ };
600
+
601
+ const credential = (await navigator.credentials.create({ publicKey: options })) as PublicKeyCredential | null;
602
+ if (!credential) throw new Error('No credential received from authenticator');
603
+ const attestation = credential.response as AuthenticatorAttestationResponse;
604
+ const rawId = bufferToBase64url(credential.rawId);
605
+ // The server rejects id ≠ rawId; `transports` rides at the credential's
606
+ // TOP level (utils/passkeys.py:200), not inside `response`.
607
+ const payload = {
608
+ id: credential.id,
609
+ rawId,
610
+ type: credential.type,
611
+ response: {
612
+ clientDataJSON: bufferToBase64url(attestation.clientDataJSON),
613
+ attestationObject: bufferToBase64url(attestation.attestationObject),
614
+ },
615
+ transports: attestation.getTransports?.() ?? [],
616
+ };
617
+ const friendly_name = name?.trim() || suggestPasskeyName();
618
+ try {
619
+ const complete = await withFreshAuth(() => mojoCall('/api/account/passkeys/register/complete', {
620
+ method: 'POST',
621
+ body: { challenge_id, credential: payload, friendly_name },
622
+ }));
623
+ return complete.data as RegisteredPasskey;
624
+ } catch (error) {
625
+ // The authenticator now holds a credential the server never saved
626
+ // (complete failed, or its step-up was dismissed): tell the
627
+ // authenticator to forget it where the browser supports that.
628
+ signalUnknownPasskey(publicKey.rp?.id || window.location.hostname, rawId);
629
+ throw error;
630
+ }
631
+ }
632
+
633
+ type SignalUnknownCredential = (options: { rpId: string; credentialId: string }) => Promise<void>;
634
+
635
+ /** Best-effort WebAuthn Signal API call; never throws, never blocks. */
636
+ function signalUnknownPasskey(rpId: string, credentialId: string): void {
637
+ try {
638
+ const ctor = window.PublicKeyCredential as unknown as { signalUnknownCredential?: SignalUnknownCredential } | undefined;
639
+ const signal = ctor?.signalUnknownCredential;
640
+ if (typeof signal !== 'function') return;
641
+ void Promise.resolve(signal.call(ctor, { rpId, credentialId })).catch(() => {});
642
+ } catch { /* unsupported or refused — the orphan stays, as before */ }
643
+ }
644
+
645
+ /**
646
+ * User-facing copy for a failed passkey ceremony (login, step-up or
647
+ * registration). Authenticator DOMExceptions map to fixed copy; anything
648
+ * else — including server refusals — is shown as written.
649
+ */
650
+ export function passkeyErrorMessage(error: unknown): string {
651
+ const name = error != null && typeof error === 'object' && typeof (error as { name?: unknown }).name === 'string'
652
+ ? (error as { name: string }).name : '';
653
+ switch (name) {
654
+ case 'NotAllowedError': return 'Passkey prompt was dismissed';
655
+ case 'InvalidStateError': return 'This authenticator already holds a passkey for your account';
656
+ case 'SecurityError': return 'Passkeys aren’t available on this domain (HTTPS and a matching domain are required)';
657
+ case 'AbortError': return 'Passkey request was cancelled';
658
+ default: break;
659
+ }
660
+ if (error instanceof Error && error.message) return error.message;
661
+ return 'Something went wrong. Please try again.';
662
+ }
663
+
664
+ // ── Self-service credential changes that re-issue the session ─────────
665
+
666
+ /**
667
+ * Finish a code-flow email change (`POST /api/auth/email/change/confirm
668
+ * {code}`, rest/user.py:1953-1994). The server commits the new address,
669
+ * rotates auth_key (every other session dies) and answers with a NEW login,
670
+ * adopted here into the storage this session already uses. Emits 'rotated'
671
+ * (never 'login': no explicit sign-in happened).
672
+ */
673
+ export async function confirmEmailChange(code: string): Promise<AuthUser> {
674
+ const body = await mojoCall('/api/auth/email/change/confirm', { method: 'POST', body: { code } });
675
+ return adoptGrant(body.data as TokenGrant, sessionIsPersistent(), 'rotated');
676
+ }
677
+
678
+ /**
679
+ * Sign out every OTHER session (`POST /api/auth/sessions/revoke`,
680
+ * rest/user.py:2331-2355, fresh-auth gated): auth_key rotates and the
681
+ * returned login keeps THIS session alive. Never the `revoke_sessions` user
682
+ * action — that one kills the caller's own session too.
683
+ */
684
+ export async function revokeOtherSessions(): Promise<AuthUser> {
685
+ const body = await withFreshAuth(() => mojoCall('/api/auth/sessions/revoke', { method: 'POST', body: {} }));
686
+ return adoptGrant(body.data as TokenGrant, sessionIsPersistent(), 'rotated');
687
+ }
688
+
502
689
  // ── Fresh-auth (step-up) challenges ───────────────────────────────────
503
690
  // django-mojo's freshness gate (account/services/fresh_auth.py): sensitive
504
691
  // operations can require that the JWT's `auth_time` claim — stamped at the
@@ -609,7 +796,7 @@ async function doExchange(code: string): Promise<AuthUser | null> {
609
796
  // block exchanging the fresh one-time login code.
610
797
  if (checkTokenStatus().action === 'logout') clearTokens({ silent: true });
611
798
  const body = await mojoCall('/api/auth/exchange', { method: 'POST', body: { code } });
612
- return adoptGrant(body.data as TokenGrant);
799
+ return await adoptGrant(body.data as TokenGrant);
613
800
  } catch (error) {
614
801
  emitAuth('refresh-failed', error);
615
802
  return null;