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.
- package/README.md +2 -2
- package/docs/README.md +2 -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/docs/user-menu.md +131 -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 +13 -17
- package/src/ui/UserMenu.tsx +405 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/modal.tsx +76 -0
- package/src/ui/safe-url.ts +14 -0
- package/src/ui/secret-dialog.tsx +83 -0
- package/src/ui/shell.ts +1 -0
- package/src/ui/toast.tsx +20 -2
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// NotificationPreferences — the caller's notification settings over
|
|
2
|
+
// /api/account/notification/preferences (django-mojo notification_prefs):
|
|
3
|
+
//
|
|
4
|
+
// CHANNELS three master cards (Email / Push / In app), one switch
|
|
5
|
+
// each, writing the reserved all-kinds entry `"*"`:
|
|
6
|
+
// `{"*": {email: false}}` silences every kind on email,
|
|
7
|
+
// including kinds registered later.
|
|
8
|
+
// WHAT TO SEND one row per kind × channel checkbox grid (partial update
|
|
9
|
+
// `{kind: {channel: bool}}`; absent = on). A channel whose
|
|
10
|
+
// master is off renders its column disabled; a kind that is
|
|
11
|
+
// not delivered on a channel shows "—".
|
|
12
|
+
//
|
|
13
|
+
// Kinds: the `kinds` prop, else the server's registered catalogue (GET
|
|
14
|
+
// `kinds`), plus any kind already stored in the caller's preferences; with
|
|
15
|
+
// none at all, one "General" row. Every toggle is optimistic and reverts +
|
|
16
|
+
// toasts the server's message on failure. Caller-only: the server reads
|
|
17
|
+
// request.user and ignores any user param.
|
|
18
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
19
|
+
import { useMe } from '../client/runtime';
|
|
20
|
+
import { toast } from '../ui';
|
|
21
|
+
import {
|
|
22
|
+
ALL_KINDS, NOTIFICATION_CHANNELS, setNotificationPreferences,
|
|
23
|
+
type NotificationChannel, type NotificationKind, type NotificationPrefsMap as Prefs,
|
|
24
|
+
type NotificationPreferencesResponse,
|
|
25
|
+
} from './api';
|
|
26
|
+
import { accountKeys, useNotificationPreferences } from './models';
|
|
27
|
+
|
|
28
|
+
const CHANNEL_LABEL: Record<NotificationChannel, string> = { email: 'Email', push: 'Push', in_app: 'In app' };
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_NOTIFICATION_KINDS: NotificationKind[] = [{ kind: 'general', label: 'General' }];
|
|
31
|
+
|
|
32
|
+
function titleCase(kind: string): string {
|
|
33
|
+
return kind.replace(/[_.-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** prop kinds ▸ server catalogue ▸ stored kinds ▸ "General". */
|
|
37
|
+
export function resolveNotificationKinds(
|
|
38
|
+
kinds: NotificationKind[] | undefined,
|
|
39
|
+
serverKinds: NotificationKind[] | null | undefined,
|
|
40
|
+
preferences: Prefs | undefined,
|
|
41
|
+
): NotificationKind[] {
|
|
42
|
+
const base = (kinds ?? serverKinds ?? []).filter((k) => k.kind !== ALL_KINDS);
|
|
43
|
+
const listed = new Set(base.map((k) => k.kind));
|
|
44
|
+
const stored = Object.keys(preferences ?? {})
|
|
45
|
+
.filter((kind) => kind !== ALL_KINDS && !listed.has(kind))
|
|
46
|
+
.sort()
|
|
47
|
+
.map((kind) => ({ kind, label: titleCase(kind) }));
|
|
48
|
+
const all = [...base, ...stored];
|
|
49
|
+
return all.length ? all : DEFAULT_NOTIFICATION_KINDS;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface NotificationPreferencesProps {
|
|
53
|
+
/** The kinds this app sends (label/description/channels). Overrides the server catalogue. */
|
|
54
|
+
kinds?: NotificationKind[];
|
|
55
|
+
/** Sub-lines under the channel cards; defaults describe each channel. */
|
|
56
|
+
channelHints?: Partial<Record<NotificationChannel, string>>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function NotificationPreferences({ kinds, channelHints }: NotificationPreferencesProps) {
|
|
60
|
+
const qc = useQueryClient();
|
|
61
|
+
const { data: me } = useMe();
|
|
62
|
+
const uid = me?.id ?? null;
|
|
63
|
+
const key = accountKeys.notificationPreferences(uid);
|
|
64
|
+
const { data, isPending, isError, error } = useNotificationPreferences(uid);
|
|
65
|
+
const prefs = data?.preferences ?? {};
|
|
66
|
+
const rows = resolveNotificationKinds(kinds, data?.kinds, prefs);
|
|
67
|
+
const masterOn = (channel: NotificationChannel) => prefs[ALL_KINDS]?.[channel] !== false;
|
|
68
|
+
|
|
69
|
+
const write = async (kind: string, channel: NotificationChannel, next: boolean) => {
|
|
70
|
+
const prev = qc.getQueryData<NotificationPreferencesResponse>(key);
|
|
71
|
+
const current = prev?.preferences ?? {};
|
|
72
|
+
qc.setQueryData<NotificationPreferencesResponse>(key, {
|
|
73
|
+
kinds: prev?.kinds ?? null,
|
|
74
|
+
preferences: { ...current, [kind]: { ...(current[kind] ?? {}), [channel]: next } },
|
|
75
|
+
});
|
|
76
|
+
try {
|
|
77
|
+
const merged = await setNotificationPreferences({ [kind]: { [channel]: next } });
|
|
78
|
+
qc.setQueryData<NotificationPreferencesResponse>(key, (now) => ({ kinds: now?.kinds ?? prev?.kinds ?? null, preferences: merged }));
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (prev) qc.setQueryData(key, prev);
|
|
81
|
+
toast.error(err instanceof Error ? err.message : 'Failed to update notification settings');
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const hints: Record<NotificationChannel, string> = {
|
|
86
|
+
email: channelHints?.email ?? (typeof me?.email === 'string' && me.email ? me.email : 'Your email address'),
|
|
87
|
+
push: channelHints?.push ?? 'Phones and apps',
|
|
88
|
+
in_app: channelHints?.in_app ?? 'Portal bell',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
if (isPending) return <div className="acct-skel" aria-busy="true"><span className="skel skel-block" /></div>;
|
|
92
|
+
if (isError) return <p className="dim" role="alert">{error instanceof Error ? error.message : 'Failed to load notification settings.'}</p>;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className="acct-notify">
|
|
96
|
+
<div className="acct-label">Channels</div>
|
|
97
|
+
<div className="acct-channels">
|
|
98
|
+
{NOTIFICATION_CHANNELS.map((channel) => (
|
|
99
|
+
<label key={channel} className={`acct-channel${masterOn(channel) ? '' : ' is-off'}`}>
|
|
100
|
+
<span className="acct-channel-text">
|
|
101
|
+
<span className="acct-channel-name">{CHANNEL_LABEL[channel]}</span>
|
|
102
|
+
<span className="acct-channel-hint" title={hints[channel]}>{hints[channel]}</span>
|
|
103
|
+
</span>
|
|
104
|
+
<input
|
|
105
|
+
type="checkbox"
|
|
106
|
+
role="switch"
|
|
107
|
+
className="switch"
|
|
108
|
+
checked={masterOn(channel)}
|
|
109
|
+
aria-label={`${CHANNEL_LABEL[channel]} notifications`}
|
|
110
|
+
onChange={(e) => void write(ALL_KINDS, channel, e.target.checked)}
|
|
111
|
+
/>
|
|
112
|
+
</label>
|
|
113
|
+
))}
|
|
114
|
+
</div>
|
|
115
|
+
<p className="acct-note">Turning a channel off silences everything below on that channel, including kinds added later.</p>
|
|
116
|
+
|
|
117
|
+
<div className="acct-grid" role="table" aria-label="What to send">
|
|
118
|
+
<div className="acct-grid-head" role="row">
|
|
119
|
+
<span className="acct-label" role="columnheader">What to send</span>
|
|
120
|
+
{NOTIFICATION_CHANNELS.map((channel) => (
|
|
121
|
+
<span key={channel} className="acct-grid-col" role="columnheader">{CHANNEL_LABEL[channel]}</span>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
{rows.map((row) => (
|
|
125
|
+
<div key={row.kind} className="acct-grid-row" role="row">
|
|
126
|
+
<span className="acct-grid-kind" role="rowheader">
|
|
127
|
+
<span className="acct-grid-label">{row.label}</span>
|
|
128
|
+
{row.description && <span className="acct-grid-desc">{row.description}</span>}
|
|
129
|
+
</span>
|
|
130
|
+
{NOTIFICATION_CHANNELS.map((channel) => {
|
|
131
|
+
const delivered = !row.channels || row.channels.includes(channel);
|
|
132
|
+
if (!delivered) return <span key={channel} className="acct-grid-cell acct-grid-na" role="cell" aria-label={`${row.label} is not sent by ${CHANNEL_LABEL[channel]}`}>—</span>;
|
|
133
|
+
const on = prefs[row.kind]?.[channel] !== false;
|
|
134
|
+
return (
|
|
135
|
+
<span key={channel} className="acct-grid-cell" role="cell">
|
|
136
|
+
<input
|
|
137
|
+
type="checkbox"
|
|
138
|
+
checked={on}
|
|
139
|
+
disabled={!masterOn(channel)}
|
|
140
|
+
title={masterOn(channel) ? undefined : `${CHANNEL_LABEL[channel]} is off for everything`}
|
|
141
|
+
aria-label={`${row.label} by ${CHANNEL_LABEL[channel].toLowerCase()}`}
|
|
142
|
+
onChange={(e) => void write(row.kind, channel, e.target.checked)}
|
|
143
|
+
/>
|
|
144
|
+
</span>
|
|
145
|
+
);
|
|
146
|
+
})}
|
|
147
|
+
</div>
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// PasskeyList — one user's passkeys with rename / enable-disable / remove,
|
|
2
|
+
// over /api/account/passkeys?user=<id> (the two REST-editable fields are
|
|
3
|
+
// friendly_name and is_enabled; delete is real, CAN_DELETE). Generalised
|
|
4
|
+
// from the admin User detail's module-private PasskeysModal so the
|
|
5
|
+
// self-service AccountModal and the admin modal render ONE list.
|
|
6
|
+
//
|
|
7
|
+
// Authority: the live owner, or a global user-management grant. It is
|
|
8
|
+
// re-read at every confirmation (permission ref) — losing it while a
|
|
9
|
+
// confirm is open, or unmounting, refuses the pending write.
|
|
10
|
+
import { useEffect, useRef } from 'react';
|
|
11
|
+
import { useCan, useMe } from '../client/runtime';
|
|
12
|
+
import { Badge, fmt, formModal, modal, toast } from '../ui';
|
|
13
|
+
import { PasskeyModel, type PasskeyRow } from './credential-models';
|
|
14
|
+
|
|
15
|
+
const USER_ADMIN_PERMISSIONS = ['sys.users', 'sys.manage_users'];
|
|
16
|
+
|
|
17
|
+
export interface PasskeyListProps {
|
|
18
|
+
userId: number;
|
|
19
|
+
/** Offer Disable on enabled rows (admin). Enable on disabled rows is always offered. */
|
|
20
|
+
showDisable?: boolean;
|
|
21
|
+
/** Empty-state copy override. */
|
|
22
|
+
emptyText?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SHORT_DATE = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' });
|
|
26
|
+
const SHORT_DATE_YEAR = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
27
|
+
|
|
28
|
+
/** "Aug 21" this year, "Aug 21, 2024" otherwise. Epoch seconds in. */
|
|
29
|
+
export function shortDate(epochSeconds: number | null | undefined): string {
|
|
30
|
+
if (epochSeconds == null) return '—';
|
|
31
|
+
const d = new Date(epochSeconds * 1000);
|
|
32
|
+
if (Number.isNaN(d.getTime())) return '—';
|
|
33
|
+
return (d.getFullYear() === new Date().getFullYear() ? SHORT_DATE : SHORT_DATE_YEAR).format(d);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function passkeyIcon(row: PasskeyRow): string {
|
|
37
|
+
const transports = (row.transports ?? '').split(',');
|
|
38
|
+
if (transports.includes('usb') || transports.includes('nfc')) return 'bi-usb-drive';
|
|
39
|
+
const name = (row.friendly_name ?? '').toLowerCase();
|
|
40
|
+
if (/iphone|android|phone/.test(name)) return 'bi-phone';
|
|
41
|
+
return 'bi-fingerprint';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function PasskeyList({ userId, showDisable = true, emptyText }: PasskeyListProps) {
|
|
45
|
+
const admin = useCan(USER_ADMIN_PERMISSIONS).can;
|
|
46
|
+
const meId = useMe().data?.id;
|
|
47
|
+
const isSelf = meId === userId;
|
|
48
|
+
const allowed = isSelf || admin;
|
|
49
|
+
const permission = useRef(allowed); permission.current = allowed;
|
|
50
|
+
useEffect(() => { permission.current = allowed; return () => { permission.current = false; }; }, [allowed]);
|
|
51
|
+
const { data, isPending, isError, error } = PasskeyModel.useList({ user: userId, size: 25, sort: '-created' });
|
|
52
|
+
const save = PasskeyModel.useSave();
|
|
53
|
+
const del = PasskeyModel.useDelete();
|
|
54
|
+
const rows = data?.rows ?? [];
|
|
55
|
+
const label = (row: PasskeyRow | undefined) => row?.friendly_name || 'Unnamed passkey';
|
|
56
|
+
|
|
57
|
+
const rename = async (row: PasskeyRow) => {
|
|
58
|
+
const result = await formModal({
|
|
59
|
+
title: 'Rename passkey',
|
|
60
|
+
submitText: 'Save',
|
|
61
|
+
fields: [{
|
|
62
|
+
name: 'friendly_name', type: 'text', label: 'Name', required: true,
|
|
63
|
+
placeholder: 'MacBook Pro · Touch ID', help: 'It only has to make sense to you.',
|
|
64
|
+
}],
|
|
65
|
+
initial: { friendly_name: row.friendly_name ?? '' },
|
|
66
|
+
});
|
|
67
|
+
const name = result?.friendly_name;
|
|
68
|
+
if (!permission.current || typeof name !== 'string' || !name.trim()) return;
|
|
69
|
+
try {
|
|
70
|
+
await save.mutateAsync({ id: row.id, changes: { friendly_name: name.trim() } });
|
|
71
|
+
toast.success('Passkey renamed');
|
|
72
|
+
} catch (err) {
|
|
73
|
+
toast.error(err instanceof Error ? err.message : 'Failed to rename passkey');
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const setEnabled = async (row: PasskeyRow, next: boolean) => {
|
|
78
|
+
if (!permission.current) return;
|
|
79
|
+
try {
|
|
80
|
+
await save.mutateAsync({ id: row.id, changes: { is_enabled: next } });
|
|
81
|
+
toast.success(next ? 'Passkey enabled' : 'Passkey disabled');
|
|
82
|
+
} catch (err) {
|
|
83
|
+
toast.error(err instanceof Error ? err.message : 'Failed to update passkey');
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const remove = async (row: PasskeyRow) => {
|
|
88
|
+
const ok = await modal.confirm({
|
|
89
|
+
title: 'Remove passkey',
|
|
90
|
+
message: isSelf
|
|
91
|
+
? <>Remove <b>{label(row)}</b> ({row.rp_id || 'unknown site'}) from your account? You’ll need to add it again to sign in with it. The device itself is not erased.</>
|
|
92
|
+
: <>Remove <b>{label(row)}</b> for {row.rp_id || 'unknown relying party'}, user #{userId}? Reenrollment is required to restore it. The physical device is not erased.</>,
|
|
93
|
+
confirmText: 'Remove',
|
|
94
|
+
danger: true,
|
|
95
|
+
});
|
|
96
|
+
if (!ok || !permission.current) return;
|
|
97
|
+
try {
|
|
98
|
+
await del.mutateAsync({ id: row.id });
|
|
99
|
+
toast.success('Passkey removed');
|
|
100
|
+
} catch (err) {
|
|
101
|
+
toast.error(err instanceof Error ? err.message : 'Failed to remove passkey');
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (isPending) return <div className="acct-skel" aria-busy="true"><span className="skel skel-block" /></div>;
|
|
106
|
+
if (isError) return <p className="dim" role="alert">{error instanceof Error ? error.message : 'Failed to load passkeys.'}</p>;
|
|
107
|
+
if (rows.length === 0) {
|
|
108
|
+
return (
|
|
109
|
+
<div className="us-empty">
|
|
110
|
+
<i className="bi bi-fingerprint" />
|
|
111
|
+
<div>{emptyText ?? 'No passkeys registered'}</div>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const busy = !allowed || save.isPending || del.isPending;
|
|
116
|
+
return (
|
|
117
|
+
<ul className="acct-cards" aria-label="Passkeys">
|
|
118
|
+
{rows.map((p) => (
|
|
119
|
+
<li key={p.id} className={`acct-card${p.is_enabled ? '' : ' is-off'}`}>
|
|
120
|
+
<span className="acct-card-icon" aria-hidden="true"><i className={`bi ${passkeyIcon(p)}`} /></span>
|
|
121
|
+
<div className="acct-card-info">
|
|
122
|
+
<span className="acct-card-title">
|
|
123
|
+
{label(p)}
|
|
124
|
+
{!p.is_enabled && <Badge tone="muted">Disabled</Badge>}
|
|
125
|
+
</span>
|
|
126
|
+
<span className="acct-card-meta">
|
|
127
|
+
Added {shortDate(p.created)} · {p.last_used ? `last used ${fmt.relative(p.last_used)}` : 'never used'}
|
|
128
|
+
</span>
|
|
129
|
+
</div>
|
|
130
|
+
<div className="acct-card-actions" inert={busy}>
|
|
131
|
+
<button type="button" className="btn btn-compact" aria-label={`Rename ${label(p)}`} onClick={() => void rename(p)}>Rename</button>
|
|
132
|
+
{!p.is_enabled && (
|
|
133
|
+
<button type="button" className="btn btn-compact" aria-label={`Enable ${label(p)}`} onClick={() => void setEnabled(p, true)}>Enable</button>
|
|
134
|
+
)}
|
|
135
|
+
{p.is_enabled && showDisable && (
|
|
136
|
+
<button type="button" className="btn btn-compact" aria-label={`Disable ${label(p)}`} onClick={() => void setEnabled(p, false)}>Disable</button>
|
|
137
|
+
)}
|
|
138
|
+
<button type="button" className="btn btn-compact acct-danger" aria-label={`Remove ${label(p)}`} onClick={() => void remove(p)}>Remove</button>
|
|
139
|
+
</div>
|
|
140
|
+
</li>
|
|
141
|
+
))}
|
|
142
|
+
</ul>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// account/api — thin, typed wrappers over the django-mojo self-service
|
|
2
|
+
// account wire (the AccountModal's transport). Each function is ONE call
|
|
3
|
+
// through the single envelope boundary (mojoCall); every server-gated call
|
|
4
|
+
// (@requires_fresh_auth) runs under withFreshAuth so a 440 re-prompts over
|
|
5
|
+
// the current screen and retries once.
|
|
6
|
+
//
|
|
7
|
+
// One-time values (TOTP secret/uri/QR, recovery codes, the phone-change
|
|
8
|
+
// session_token) are RETURNED to the caller and never written to any
|
|
9
|
+
// TanStack cache here — callers keep them in component state for the life of
|
|
10
|
+
// the dialog that shows them.
|
|
11
|
+
//
|
|
12
|
+
// Wire authority: django-mojo account/rest/totp.py, rest/user.py,
|
|
13
|
+
// rest/verify.py, rest/notification_prefs.py (read 2026-10-07).
|
|
14
|
+
import { MojoError } from '../client/errors';
|
|
15
|
+
import { mojoCall } from '../client/client';
|
|
16
|
+
import { withFreshAuth } from '../client/auth';
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
confirmEmailChange,
|
|
20
|
+
isPasskeyRegistrationSupported,
|
|
21
|
+
passkeyErrorMessage,
|
|
22
|
+
registerPasskey,
|
|
23
|
+
revokeOtherSessions,
|
|
24
|
+
suggestPasskeyName,
|
|
25
|
+
type RegisteredPasskey,
|
|
26
|
+
} from '../client/auth';
|
|
27
|
+
|
|
28
|
+
const message = (body: { message?: string }, fallback: string) =>
|
|
29
|
+
typeof body.message === 'string' && body.message ? body.message : fallback;
|
|
30
|
+
|
|
31
|
+
// ── Authenticator app (TOTP) ──────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export interface TotpSetup {
|
|
34
|
+
/** Base32 secret for manual entry. One-time: keep in dialog state only. */
|
|
35
|
+
secret: string;
|
|
36
|
+
/** otpauth:// provisioning URI. */
|
|
37
|
+
uri: string;
|
|
38
|
+
/** `data:image/…;base64,` QR of `uri`, or null when it is not a safe data image. */
|
|
39
|
+
qrCode: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SAFE_QR = /^data:image\/(png|svg\+xml);base64,[A-Za-z0-9+/=]+$/;
|
|
43
|
+
|
|
44
|
+
/** Only a base64 PNG/SVG data URL may become an <img src>. */
|
|
45
|
+
export function safeQrDataUrl(value: unknown): string | null {
|
|
46
|
+
return typeof value === 'string' && SAFE_QR.test(value) ? value : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `POST /api/account/totp/setup` (fresh auth). Replaces any stored secret and
|
|
51
|
+
* DISABLES an active authenticator until `confirmTotp` succeeds.
|
|
52
|
+
*/
|
|
53
|
+
export async function startTotpSetup(): Promise<TotpSetup> {
|
|
54
|
+
const body = await withFreshAuth(() => mojoCall('/api/account/totp/setup', { method: 'POST', body: {} }));
|
|
55
|
+
const data = (body.data ?? {}) as { secret?: unknown; uri?: unknown; qr_code?: unknown };
|
|
56
|
+
return {
|
|
57
|
+
secret: typeof data.secret === 'string' ? data.secret : '',
|
|
58
|
+
uri: typeof data.uri === 'string' ? data.uri : '',
|
|
59
|
+
qrCode: safeQrDataUrl(data.qr_code),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `POST /api/account/totp/confirm {code}` (fresh auth) → the 8 recovery codes,
|
|
65
|
+
* shown ONCE. The server also sets requires_mfa.
|
|
66
|
+
*/
|
|
67
|
+
export async function confirmTotp(code: string): Promise<string[]> {
|
|
68
|
+
const body = await withFreshAuth(() => mojoCall('/api/account/totp/confirm', { method: 'POST', body: { code } }));
|
|
69
|
+
return recoveryCodesOf(body.data);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `DELETE /api/account/totp` (fresh auth). requires_mfa is NOT cleared. */
|
|
73
|
+
export async function disableTotp(): Promise<void> {
|
|
74
|
+
await withFreshAuth(() => mojoCall('/api/account/totp', { method: 'DELETE' }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface RecoveryCodeStatus {
|
|
78
|
+
enrolled: boolean;
|
|
79
|
+
remaining: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Enrollment + remaining recovery codes from `GET …/totp/recovery-codes` —
|
|
84
|
+
* the only self-visible "authenticator is on" signal (400 = not enrolled).
|
|
85
|
+
* The masked hints are dropped here so a cached status never holds them.
|
|
86
|
+
*/
|
|
87
|
+
export async function getRecoveryCodeStatus(): Promise<RecoveryCodeStatus> {
|
|
88
|
+
try {
|
|
89
|
+
const body = await mojoCall('/api/account/totp/recovery-codes');
|
|
90
|
+
const data = (body.data ?? {}) as { remaining?: unknown };
|
|
91
|
+
return { enrolled: true, remaining: typeof data.remaining === 'number' ? data.remaining : 0 };
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error instanceof MojoError && error.status === 400) return { enrolled: false, remaining: 0 };
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The masked hints (`a4k2-xxxx-xxxx`) for the View dialog — component state only. */
|
|
99
|
+
export async function getMaskedRecoveryCodes(): Promise<{ remaining: number; codes: string[] }> {
|
|
100
|
+
const body = await mojoCall('/api/account/totp/recovery-codes');
|
|
101
|
+
const data = (body.data ?? {}) as { remaining?: unknown; codes?: unknown };
|
|
102
|
+
return {
|
|
103
|
+
remaining: typeof data.remaining === 'number' ? data.remaining : 0,
|
|
104
|
+
codes: Array.isArray(data.codes) ? data.codes.filter((c): c is string => typeof c === 'string') : [],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `POST …/recovery-codes/regenerate {code}` (fresh auth) → a fresh set, shown ONCE. */
|
|
109
|
+
export async function regenerateRecoveryCodes(code: string): Promise<string[]> {
|
|
110
|
+
const body = await withFreshAuth(() => mojoCall('/api/account/totp/recovery-codes/regenerate', { method: 'POST', body: { code } }));
|
|
111
|
+
return recoveryCodesOf(body.data);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function recoveryCodesOf(data: unknown): string[] {
|
|
115
|
+
const codes = (data as { recovery_codes?: unknown } | undefined)?.recovery_codes;
|
|
116
|
+
return Array.isArray(codes) ? codes.filter((c): c is string => typeof c === 'string') : [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Password ──────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Self password change: `POST /api/user/me {current_password, new_password}`.
|
|
123
|
+
* Wrong/missing current → 400 with the server's message. Not fresh-auth
|
|
124
|
+
* gated and no session rotation (user.py:819-828).
|
|
125
|
+
*/
|
|
126
|
+
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
|
127
|
+
await mojoCall('/api/user/me', {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
body: { current_password: currentPassword, new_password: newPassword },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Email / phone changes and verification ────────────────────────────
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `POST /api/auth/email/change/request {email, method:'code'}` (fresh auth).
|
|
137
|
+
* A 6-digit code goes to the NEW address; finish with confirmEmailChange().
|
|
138
|
+
*/
|
|
139
|
+
export async function requestEmailChange(email: string): Promise<string> {
|
|
140
|
+
const body = await withFreshAuth(() => mojoCall('/api/auth/email/change/request', {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
body: { email, method: 'code' },
|
|
143
|
+
}));
|
|
144
|
+
return message(body, 'A verification code has been sent to your new email address.');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface PhoneChangeTicket {
|
|
148
|
+
/** One-time token pairing the code with this request. Dialog state only. */
|
|
149
|
+
sessionToken: string;
|
|
150
|
+
message: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `POST /api/auth/phone/change/request {phone_number}` (fresh auth). The
|
|
155
|
+
* session_token rides at the envelope's TOP level, not under `data`.
|
|
156
|
+
*/
|
|
157
|
+
export async function requestPhoneChange(phoneNumber: string): Promise<PhoneChangeTicket> {
|
|
158
|
+
const body = await withFreshAuth(() => mojoCall('/api/auth/phone/change/request', {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
body: { phone_number: phoneNumber },
|
|
161
|
+
}));
|
|
162
|
+
const token = (body as { session_token?: unknown }).session_token;
|
|
163
|
+
if (typeof token !== 'string' || !token) throw new MojoError('The server did not return a phone change session', 0);
|
|
164
|
+
return { sessionToken: token, message: message(body, 'A verification code has been sent to your new phone number.') };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** `POST /api/auth/phone/change/confirm {session_token, code}` — commits + verifies the number. */
|
|
168
|
+
export async function confirmPhoneChange(sessionToken: string, code: string): Promise<string> {
|
|
169
|
+
const body = await mojoCall('/api/auth/phone/change/confirm', {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
body: { session_token: sessionToken, code },
|
|
172
|
+
});
|
|
173
|
+
return message(body, 'Phone number updated successfully.');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `POST /api/auth/verify/{email|phone}/send` — a code (email asks for `method:'code'`). */
|
|
177
|
+
export async function sendVerificationCode(channel: 'email' | 'phone'): Promise<string> {
|
|
178
|
+
const body = await mojoCall(`/api/auth/verify/${channel}/send`, {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
body: channel === 'email' ? { method: 'code' } : {},
|
|
181
|
+
});
|
|
182
|
+
return message(body, 'Verification code sent');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** `POST /api/auth/verify/{email|phone}/confirm {code}`. */
|
|
186
|
+
export async function confirmVerificationCode(channel: 'email' | 'phone', code: string): Promise<string> {
|
|
187
|
+
const body = await mojoCall(`/api/auth/verify/${channel}/confirm`, { method: 'POST', body: { code } });
|
|
188
|
+
return message(body, channel === 'email' ? 'Email verified' : 'Phone verified');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** `POST /api/auth/username/change {username}` (fresh auth) → the saved username. */
|
|
192
|
+
export async function changeUsername(username: string): Promise<string> {
|
|
193
|
+
const body = await withFreshAuth(() => mojoCall('/api/auth/username/change', { method: 'POST', body: { username } }));
|
|
194
|
+
const saved = (body.data as { username?: unknown } | undefined)?.username;
|
|
195
|
+
return typeof saved === 'string' ? saved : username;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Notification preferences ──────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
export type NotificationChannel = 'email' | 'push' | 'in_app';
|
|
201
|
+
export const NOTIFICATION_CHANNELS: readonly NotificationChannel[] = ['email', 'push', 'in_app'];
|
|
202
|
+
/** The reserved all-kinds entry: `"*".<channel> === false` silences a channel. */
|
|
203
|
+
export const ALL_KINDS = '*';
|
|
204
|
+
|
|
205
|
+
export type NotificationPrefsMap = Record<string, Partial<Record<NotificationChannel, boolean>>>;
|
|
206
|
+
|
|
207
|
+
export interface NotificationKind {
|
|
208
|
+
kind: string;
|
|
209
|
+
label: string;
|
|
210
|
+
description?: string;
|
|
211
|
+
/** Channels this kind is delivered on; null/absent = all. */
|
|
212
|
+
channels?: NotificationChannel[] | null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface NotificationPreferencesResponse {
|
|
216
|
+
preferences: NotificationPrefsMap;
|
|
217
|
+
/** The server's registered kinds, when it ships the registry. */
|
|
218
|
+
kinds: NotificationKind[] | null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function getNotificationPreferences(): Promise<NotificationPreferencesResponse> {
|
|
222
|
+
const body = await mojoCall('/api/account/notification/preferences');
|
|
223
|
+
const data = (body.data ?? {}) as { preferences?: unknown; kinds?: unknown };
|
|
224
|
+
const preferences = data.preferences && typeof data.preferences === 'object' ? data.preferences as NotificationPrefsMap : {};
|
|
225
|
+
const kinds = Array.isArray(data.kinds)
|
|
226
|
+
? data.kinds.filter((k): k is NotificationKind => Boolean(k) && typeof (k as NotificationKind).kind === 'string' && (k as NotificationKind).kind !== ALL_KINDS)
|
|
227
|
+
: null;
|
|
228
|
+
return { preferences, kinds };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Partial update `{preferences: {kind: {channel: bool}}}` → the merged map. */
|
|
232
|
+
export async function setNotificationPreferences(partial: NotificationPrefsMap): Promise<NotificationPrefsMap> {
|
|
233
|
+
const body = await mojoCall('/api/account/notification/preferences', { method: 'POST', body: { preferences: partial } });
|
|
234
|
+
const data = (body.data ?? {}) as { preferences?: unknown };
|
|
235
|
+
return data.preferences && typeof data.preferences === 'object' ? data.preferences as NotificationPrefsMap : {};
|
|
236
|
+
}
|