portal-mojo 0.2.7 → 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.
- package/docs/README.md +1 -0
- package/docs/account.md +260 -0
- package/docs/auth-pages.md +8 -3
- package/docs/auth.md +45 -2
- package/docs/feedback.md +9 -0
- package/docs/idioms.md +12 -0
- package/package.json +2 -1
- package/src/account/AccountModal.tsx +275 -0
- package/src/account/AccountSections.tsx +370 -0
- package/src/account/NotificationPreferences.tsx +152 -0
- package/src/account/PasskeyList.tsx +144 -0
- package/src/account/api.ts +236 -0
- package/src/account/credential-models.ts +153 -0
- package/src/account/dialogs.tsx +822 -0
- package/src/account/index.ts +16 -0
- package/src/account/models.ts +76 -0
- package/src/account/sections/ApiKeysSection.tsx +138 -0
- package/src/admin/credentials/secret-dialog.tsx +3 -77
- package/src/admin/identity/users/models.ts +16 -128
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +3 -119
- package/src/admin/identity/users/sections/NotificationsSection.tsx +23 -102
- package/src/admin/identity/users/sections/PersonalSection.tsx +18 -5
- package/src/admin/identity/users/sections/actions.tsx +7 -100
- package/src/client/auth.ts +192 -5
- package/src/client/mock.ts +472 -7
- package/src/ui/FileField.tsx +12 -7
- package/src/ui/UserMenu.tsx +6 -2
- package/src/ui/index.ts +1 -0
- package/src/ui/modal.tsx +76 -0
- package/src/ui/secret-dialog.tsx +83 -0
- package/src/ui/toast.tsx +20 -2
|
@@ -1,117 +1,38 @@
|
|
|
1
|
-
// Notifications — AdminNotificationsSection port (read in full 2026-08-05)
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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 {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
{
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
<div
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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={
|
|
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={
|
|
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={
|
|
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,
|
|
14
|
-
import { mojoCall,
|
|
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 {
|
|
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 —
|
|
668
|
-
* /api/account/passkeys
|
|
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
|
-
|
|
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>
|
package/src/client/auth.ts
CHANGED
|
@@ -133,7 +133,12 @@ export function subscribeAuth(cb: () => void): () => void {
|
|
|
133
133
|
|
|
134
134
|
// ── Auth events ───────────────────────────────────────────────────────
|
|
135
135
|
|
|
136
|
-
|
|
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
|
-
|
|
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(
|
|
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;
|