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
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// AccountModal — the self-service "My account" modal (portal-mojo #7184,
|
|
2
|
+
// approved shape 2026-10-07). ONE modal: a short rail is the whole
|
|
3
|
+
// navigation, each entry one flat section (no drill-ins, no cross-links),
|
|
4
|
+
// Sign out at the rail's foot, the section title + Close in the header.
|
|
5
|
+
// ≈760×520 on desktop; under 720px a full-screen sheet whose rail becomes a
|
|
6
|
+
// row of tabs across the top.
|
|
7
|
+
//
|
|
8
|
+
// Opened imperatively on the toolkit modal stack:
|
|
9
|
+
//
|
|
10
|
+
// <UserMenu items={[{ label: 'My account', icon: 'bi-person-circle', onSelect: () => openAccountModal() }]} />
|
|
11
|
+
//
|
|
12
|
+
// Every focused flow (add passkey, authenticator, recovery codes, password,
|
|
13
|
+
// email/phone change, verify, photo) stacks its own dialog over this one;
|
|
14
|
+
// fresh-auth step-ups come from the app's FreshAuthHost. Styles: the app's
|
|
15
|
+
// theme/account.css (tokens only, both themes).
|
|
16
|
+
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
|
17
|
+
import { hostedAuthUrl, logout, onAuth, redirectToHostedAuth, useAuthSnapshot, useMe, type Me } from '../client/runtime';
|
|
18
|
+
import { fmt, modal, toast, useAvatarUrl, type UserMenuAuthMode } from '../ui';
|
|
19
|
+
import type { NotificationKind } from './api';
|
|
20
|
+
import {
|
|
21
|
+
AccountApiKeysSection, AccountNotificationsSection, AccountPasskeysSection, AccountProfileSection,
|
|
22
|
+
AccountSecuritySection, AccountSessionsSection,
|
|
23
|
+
} from './AccountSections';
|
|
24
|
+
|
|
25
|
+
export type AccountSectionKey = 'profile' | 'passkeys' | 'security' | 'sessions' | 'notifications' | 'api-keys';
|
|
26
|
+
|
|
27
|
+
export const ACCOUNT_SECTIONS: readonly AccountSectionKey[] = ['profile', 'passkeys', 'security', 'sessions', 'notifications', 'api-keys'];
|
|
28
|
+
|
|
29
|
+
export interface AccountSectionContext {
|
|
30
|
+
me: Me;
|
|
31
|
+
/** Close the whole modal. */
|
|
32
|
+
close: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** An app-supplied rail entry, appended after the built-ins. */
|
|
36
|
+
export interface AccountSectionDef {
|
|
37
|
+
key: string;
|
|
38
|
+
label: string;
|
|
39
|
+
/** Bootstrap-icons class, e.g. 'bi-building'. */
|
|
40
|
+
icon: string;
|
|
41
|
+
render: (ctx: AccountSectionContext) => ReactNode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AccountModalProps {
|
|
45
|
+
/** Built-in sections to show, in order (default all six). Unknown keys warn and drop. */
|
|
46
|
+
sections?: AccountSectionKey[];
|
|
47
|
+
/** App sections appended to the rail. */
|
|
48
|
+
extraSections?: AccountSectionDef[];
|
|
49
|
+
/** Section open on mount (built-in or extra key). Unknown keys warn and fall back to the first. */
|
|
50
|
+
initialSection?: string;
|
|
51
|
+
/** Replaces the default `logout()` on Sign out. A rejection toasts and keeps the modal open. */
|
|
52
|
+
onSignOut?: () => void | Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* With the default sign out: 'hosted' sends the user to the hosted
|
|
55
|
+
* auth page after logout (when the deployment has one); 'inapp' goes to
|
|
56
|
+
* `#/auth/login`; omitted leaves routing to the app's auth gate.
|
|
57
|
+
*/
|
|
58
|
+
authMode?: UserMenuAuthMode;
|
|
59
|
+
/** The notification kinds this app sends (labels/descriptions/channels). */
|
|
60
|
+
notificationKinds?: NotificationKind[];
|
|
61
|
+
/** Called when the modal should close (Close, Escape via the host, sign out). */
|
|
62
|
+
onClose: () => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const BUILT_INS: Record<AccountSectionKey, { label: string; icon: string }> = {
|
|
66
|
+
profile: { label: 'Profile', icon: 'bi-person' },
|
|
67
|
+
passkeys: { label: 'Passkeys', icon: 'bi-fingerprint' },
|
|
68
|
+
security: { label: 'Security', icon: 'bi-shield-lock' },
|
|
69
|
+
sessions: { label: 'Sessions', icon: 'bi-display' },
|
|
70
|
+
notifications: { label: 'Notifications', icon: 'bi-bell' },
|
|
71
|
+
'api-keys': { label: 'API keys', icon: 'bi-key' },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
interface ResolvedSection {
|
|
75
|
+
key: string;
|
|
76
|
+
label: string;
|
|
77
|
+
icon: string;
|
|
78
|
+
render: (ctx: AccountSectionContext) => ReactNode;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const warned = new Set<string>();
|
|
82
|
+
function warnOnce(message: string) {
|
|
83
|
+
if (warned.has(message)) return;
|
|
84
|
+
warned.add(message);
|
|
85
|
+
console.warn(message);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function AccountModal({
|
|
89
|
+
sections = [...ACCOUNT_SECTIONS],
|
|
90
|
+
extraSections,
|
|
91
|
+
initialSection,
|
|
92
|
+
onSignOut,
|
|
93
|
+
authMode,
|
|
94
|
+
notificationKinds,
|
|
95
|
+
onClose,
|
|
96
|
+
}: AccountModalProps) {
|
|
97
|
+
const auth = useAuthSnapshot();
|
|
98
|
+
const { data: me, isPending, isError, error } = useMe();
|
|
99
|
+
const uid = useId();
|
|
100
|
+
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
|
|
101
|
+
const [signingOut, setSigningOut] = useState(false);
|
|
102
|
+
|
|
103
|
+
const resolved = useMemo<ResolvedSection[]>(() => {
|
|
104
|
+
const out: ResolvedSection[] = [];
|
|
105
|
+
for (const key of sections) {
|
|
106
|
+
if (!(key in BUILT_INS)) { warnOnce(`AccountModal: unknown section "${String(key)}" — dropped`); continue; }
|
|
107
|
+
const meta = BUILT_INS[key];
|
|
108
|
+
out.push({
|
|
109
|
+
key,
|
|
110
|
+
...meta,
|
|
111
|
+
render: ({ me: row }) => {
|
|
112
|
+
switch (key) {
|
|
113
|
+
case 'profile': return <AccountProfileSection me={row} />;
|
|
114
|
+
case 'passkeys': return <AccountPasskeysSection me={row} />;
|
|
115
|
+
case 'security': return <AccountSecuritySection me={row} />;
|
|
116
|
+
case 'sessions': return <AccountSessionsSection me={row} />;
|
|
117
|
+
case 'notifications': return <AccountNotificationsSection me={row} kinds={notificationKinds} />;
|
|
118
|
+
case 'api-keys': return <AccountApiKeysSection me={row} />;
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
for (const extra of extraSections ?? []) {
|
|
124
|
+
if (out.some((s) => s.key === extra.key)) { warnOnce(`AccountModal: duplicate section key "${extra.key}" — dropped`); continue; }
|
|
125
|
+
out.push(extra);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}, [sections, extraSections, notificationKinds]);
|
|
129
|
+
|
|
130
|
+
const [active, setActive] = useState<string>(() => {
|
|
131
|
+
if (initialSection && resolved.some((s) => s.key === initialSection)) return initialSection;
|
|
132
|
+
if (initialSection) warnOnce(`AccountModal: unknown initialSection "${initialSection}" — opening "${resolved[0]?.key ?? ''}"`);
|
|
133
|
+
return resolved[0]?.key ?? '';
|
|
134
|
+
});
|
|
135
|
+
const current = resolved.find((s) => s.key === active) ?? resolved[0];
|
|
136
|
+
|
|
137
|
+
// On phones the rail is a scrolling tab strip: keep the active tab visible.
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
tabRefs.current.get(active)?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
|
140
|
+
}, [active]);
|
|
141
|
+
|
|
142
|
+
// The session ended underneath us (sign out elsewhere, refresh failure):
|
|
143
|
+
// there is no account to show. The auth events cover an app that drops
|
|
144
|
+
// this host on sign-out before the snapshot effect can run; the toolkit
|
|
145
|
+
// modal stack closes the child dialogs on the same events.
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!auth.authenticated) onClose();
|
|
148
|
+
}, [auth.authenticated, onClose]);
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
const offs = [onAuth('logout', onClose), onAuth('unauthorized', onClose)];
|
|
151
|
+
return () => { for (const off of offs) off(); };
|
|
152
|
+
}, [onClose]);
|
|
153
|
+
|
|
154
|
+
const [avatarSrc, onAvatarError] = useAvatarUrl(me?.avatar?.id ?? null);
|
|
155
|
+
const name = (typeof me?.display_name === 'string' && me.display_name) || (typeof me?.email === 'string' && me.email) || auth.email || 'My account';
|
|
156
|
+
|
|
157
|
+
const signOut = async () => {
|
|
158
|
+
if (signingOut) return;
|
|
159
|
+
setSigningOut(true);
|
|
160
|
+
try {
|
|
161
|
+
if (onSignOut) {
|
|
162
|
+
await onSignOut();
|
|
163
|
+
} else {
|
|
164
|
+
logout();
|
|
165
|
+
if (authMode === 'hosted' && hostedAuthUrl()) redirectToHostedAuth();
|
|
166
|
+
else if (authMode === 'inapp' && typeof window !== 'undefined') window.location.hash = '#/auth/login';
|
|
167
|
+
}
|
|
168
|
+
onClose();
|
|
169
|
+
} catch (err) {
|
|
170
|
+
toast.error(`Sign out failed: ${err instanceof Error && err.message ? err.message : 'unknown error'}`);
|
|
171
|
+
setSigningOut(false);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Roving tabs: arrows move between rail entries (vertical rail on
|
|
176
|
+
// desktop, horizontal strip on phones), Home/End jump to the ends.
|
|
177
|
+
const onTabKey = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
178
|
+
const keys = resolved.map((s) => s.key);
|
|
179
|
+
const at = keys.indexOf(active);
|
|
180
|
+
let next = -1;
|
|
181
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') next = (at + 1) % keys.length;
|
|
182
|
+
else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') next = (at - 1 + keys.length) % keys.length;
|
|
183
|
+
else if (event.key === 'Home') next = 0;
|
|
184
|
+
else if (event.key === 'End') next = keys.length - 1;
|
|
185
|
+
if (next < 0) return;
|
|
186
|
+
event.preventDefault();
|
|
187
|
+
setActive(keys[next]!);
|
|
188
|
+
tabRefs.current.get(keys[next]!)?.focus();
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const tabId = (key: string) => `${uid}-tab-${key}`;
|
|
192
|
+
const panelId = `${uid}-panel`;
|
|
193
|
+
const titleId = `${uid}-title`;
|
|
194
|
+
|
|
195
|
+
const signOutButton = (className: string) => (
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
className={`acct-rail-item is-danger ${className}`}
|
|
199
|
+
aria-busy={signingOut || undefined}
|
|
200
|
+
disabled={signingOut}
|
|
201
|
+
onClick={() => void signOut()}
|
|
202
|
+
>
|
|
203
|
+
<i className={signingOut ? 'bi bi-arrow-repeat spin' : 'bi bi-box-arrow-right'} aria-hidden="true" />
|
|
204
|
+
<span>{signingOut ? 'Signing out…' : 'Sign out'}</span>
|
|
205
|
+
</button>
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div className="acct" aria-labelledby={titleId}>
|
|
210
|
+
<nav className="acct-rail" aria-label="Account sections">
|
|
211
|
+
<div className="acct-who">
|
|
212
|
+
<span className="acct-avatar acct-avatar-sm" aria-hidden="true">
|
|
213
|
+
{avatarSrc ? <img src={avatarSrc} alt="" referrerPolicy="no-referrer" onError={onAvatarError} /> : fmt.initials(name)}
|
|
214
|
+
</span>
|
|
215
|
+
<span className="acct-who-text">
|
|
216
|
+
<span className="acct-who-name">{name}</span>
|
|
217
|
+
<span className="acct-who-sub">My account</span>
|
|
218
|
+
</span>
|
|
219
|
+
</div>
|
|
220
|
+
<div className="acct-tabs" role="tablist" aria-label="Account sections" aria-orientation="vertical">
|
|
221
|
+
{resolved.map((section) => (
|
|
222
|
+
<button
|
|
223
|
+
key={section.key}
|
|
224
|
+
ref={(node) => { if (node) tabRefs.current.set(section.key, node); else tabRefs.current.delete(section.key); }}
|
|
225
|
+
id={tabId(section.key)}
|
|
226
|
+
type="button"
|
|
227
|
+
role="tab"
|
|
228
|
+
aria-selected={section.key === current?.key}
|
|
229
|
+
aria-controls={panelId}
|
|
230
|
+
tabIndex={section.key === current?.key ? 0 : -1}
|
|
231
|
+
className={`acct-rail-item${section.key === current?.key ? ' is-active' : ''}`}
|
|
232
|
+
onClick={() => setActive(section.key)}
|
|
233
|
+
onKeyDown={onTabKey}
|
|
234
|
+
>
|
|
235
|
+
<i className={`bi ${section.icon}`} aria-hidden="true" />
|
|
236
|
+
<span>{section.label}</span>
|
|
237
|
+
</button>
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
{signOutButton('acct-signout-rail')}
|
|
241
|
+
</nav>
|
|
242
|
+
<div className="acct-panel">
|
|
243
|
+
<header className="acct-head">
|
|
244
|
+
<h2 id={titleId} className="acct-title">
|
|
245
|
+
<span className="acct-title-section">{current?.label ?? 'My account'}</span>
|
|
246
|
+
<span className="acct-title-sheet">My account</span>
|
|
247
|
+
</h2>
|
|
248
|
+
<button type="button" className="btn-icon" aria-label="Close" title="Close" onClick={onClose}>
|
|
249
|
+
<i className="bi bi-x-lg" aria-hidden="true" />
|
|
250
|
+
</button>
|
|
251
|
+
</header>
|
|
252
|
+
<div
|
|
253
|
+
id={panelId}
|
|
254
|
+
className="acct-body"
|
|
255
|
+
role="tabpanel"
|
|
256
|
+
aria-labelledby={current ? tabId(current.key) : undefined}
|
|
257
|
+
data-section={current?.key}
|
|
258
|
+
>
|
|
259
|
+
{isPending && <div className="acct-skel" aria-busy="true"><span className="skel skel-block" /><span className="skel skel-block" /></div>}
|
|
260
|
+
{isError && <div className="form-alert" role="alert">{error instanceof Error ? error.message : 'Could not load your account.'}</div>}
|
|
261
|
+
{me && current && <div key={current.key} className="acct-section">{current.render({ me, close: onClose })}</div>}
|
|
262
|
+
{signOutButton('acct-signout-sheet')}
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Open the AccountModal on the toolkit modal stack. Resolves when it closes.
|
|
271
|
+
* Pass any AccountModalProps except `onClose`.
|
|
272
|
+
*/
|
|
273
|
+
export function openAccountModal(props: Omit<AccountModalProps, 'onClose'> = {}): Promise<unknown> {
|
|
274
|
+
return modal.open((close) => <AccountModal {...props} onClose={() => close(null)} />, { size: 'lg' });
|
|
275
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// The AccountModal's six flat sections (brief #7184, approved shape):
|
|
2
|
+
// Profile · Passkeys · Security · Sessions · Notifications · API keys.
|
|
3
|
+
// Each takes the signed-in `me` row and renders on its own — an app can
|
|
4
|
+
// mount any of them outside the modal (e.g. a settings page).
|
|
5
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
6
|
+
import type { ReactNode } from 'react';
|
|
7
|
+
import { forgotPassword, getDuid, isPasskeyRegistrationSupported, revokeOtherSessions, type Me } from '../client/runtime';
|
|
8
|
+
import { Badge, FormView, fmt, modal, toast, useAvatarUrl } from '../ui';
|
|
9
|
+
import type { Field } from '../client/runtime';
|
|
10
|
+
import { disableTotp, type NotificationKind } from './api';
|
|
11
|
+
import {
|
|
12
|
+
LoginEventModel, UserDeviceModel, browserLabel, deviceIcon,
|
|
13
|
+
type LoginEventRow, type UserDeviceRow,
|
|
14
|
+
} from '../admin/security/devices/models';
|
|
15
|
+
import {
|
|
16
|
+
accountErrorMessage, openAddPasskeyDialog, openAvatarDialog, openChangePasswordDialog,
|
|
17
|
+
openEmailChangeDialog, openPhoneChangeDialog, openRecoveryCodesView, openRegenerateRecoveryCodes,
|
|
18
|
+
openTotpEnrolDialog, openVerifyDialog,
|
|
19
|
+
} from './dialogs';
|
|
20
|
+
import { MeSaveModel, OAuthConnectionModel, accountKeys, invalidateMe, useRecoveryCodeStatus } from './models';
|
|
21
|
+
import { NotificationPreferences } from './NotificationPreferences';
|
|
22
|
+
import { PasskeyList } from './PasskeyList';
|
|
23
|
+
import { ApiKeysSection } from './sections/ApiKeysSection';
|
|
24
|
+
|
|
25
|
+
export interface AccountSectionProps {
|
|
26
|
+
me: Me;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const str = (value: unknown): string | null => (typeof value === 'string' && value ? value : null);
|
|
30
|
+
|
|
31
|
+
function Row({ label, value, children }: { label: string; value: ReactNode; children?: ReactNode }) {
|
|
32
|
+
return (
|
|
33
|
+
<div className="acct-row">
|
|
34
|
+
<div className="acct-row-text">
|
|
35
|
+
<span className="acct-row-label">{label}</span>
|
|
36
|
+
<span className="acct-row-value">{value}</span>
|
|
37
|
+
</div>
|
|
38
|
+
{children && <div className="acct-row-actions">{children}</div>}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function Card({ icon, title, meta, metaTone, chip, children }: {
|
|
44
|
+
icon: string;
|
|
45
|
+
title: string;
|
|
46
|
+
meta: ReactNode;
|
|
47
|
+
metaTone?: 'warn';
|
|
48
|
+
chip?: ReactNode;
|
|
49
|
+
children?: ReactNode;
|
|
50
|
+
}) {
|
|
51
|
+
return (
|
|
52
|
+
<div className="acct-card">
|
|
53
|
+
<span className="acct-card-icon" aria-hidden="true"><i className={`bi ${icon}`} /></span>
|
|
54
|
+
<div className="acct-card-info">
|
|
55
|
+
<span className="acct-card-title">{title}{chip}</span>
|
|
56
|
+
<span className={`acct-card-meta${metaTone === 'warn' ? ' is-warn' : ''}`}>{meta}</span>
|
|
57
|
+
</div>
|
|
58
|
+
{children && <div className="acct-card-actions">{children}</div>}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Profile ───────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
const PERSONAL_FIELDS: Field[] = [
|
|
66
|
+
{ name: 'display_name', type: 'text', label: 'Display name', columns: 6 },
|
|
67
|
+
{ name: 'metadata.timezone', type: 'timezone', label: 'Timezone', columns: 6 },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
export function AccountProfileSection({ me }: AccountSectionProps) {
|
|
71
|
+
const [avatarSrc, onAvatarError] = useAvatarUrl(me.avatar?.id ?? null);
|
|
72
|
+
const name = str(me.display_name) ?? str(me.email) ?? 'You';
|
|
73
|
+
const email = str(me.email);
|
|
74
|
+
const phone = str(me.phone_number);
|
|
75
|
+
const emailVerified = me.is_email_verified === true;
|
|
76
|
+
const phoneVerified = me.is_phone_verified === true;
|
|
77
|
+
return (
|
|
78
|
+
<div className="acct-stack">
|
|
79
|
+
<div className="acct-identity">
|
|
80
|
+
<div className="acct-avatar-wrap">
|
|
81
|
+
<span className="acct-avatar acct-avatar-lg" aria-hidden="true">
|
|
82
|
+
{avatarSrc ? <img src={avatarSrc} alt="" referrerPolicy="no-referrer" onError={onAvatarError} /> : fmt.initials(name)}
|
|
83
|
+
</span>
|
|
84
|
+
<button type="button" className="acct-avatar-edit" aria-label="Change photo" title="Change photo" onClick={() => void openAvatarDialog()}>
|
|
85
|
+
<i className="bi bi-camera" aria-hidden="true" />
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="acct-identity-text">
|
|
89
|
+
<span className="acct-identity-name">{name}</span>
|
|
90
|
+
<span className="acct-identity-sub">{[email, me.is_superuser ? 'System admin' : null].filter(Boolean).join(' · ')}</span>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<section className="acct-block" aria-label="Contact">
|
|
95
|
+
<div className="acct-label">Contact</div>
|
|
96
|
+
<Row
|
|
97
|
+
label="Email"
|
|
98
|
+
value={email
|
|
99
|
+
? <>{email} <Badge tone={emailVerified ? 'success' : 'warning'}>{emailVerified ? 'Verified' : 'Unverified'}</Badge></>
|
|
100
|
+
: <span className="dim-italic">Not set</span>}
|
|
101
|
+
>
|
|
102
|
+
{email && !emailVerified && <button type="button" className="btn btn-compact" onClick={() => void openVerifyDialog('email')}>Verify</button>}
|
|
103
|
+
<button type="button" className="btn btn-compact" onClick={() => void openEmailChangeDialog()}>Change</button>
|
|
104
|
+
</Row>
|
|
105
|
+
<Row
|
|
106
|
+
label="Phone"
|
|
107
|
+
value={phone
|
|
108
|
+
? <>{phone} <Badge tone={phoneVerified ? 'success' : 'warning'}>{phoneVerified ? 'Verified' : 'Unverified'}</Badge></>
|
|
109
|
+
: <span className="dim-italic">Not set</span>}
|
|
110
|
+
>
|
|
111
|
+
{phone && !phoneVerified && <button type="button" className="btn btn-compact" onClick={() => void openVerifyDialog('phone')}>Verify</button>}
|
|
112
|
+
<button type="button" className="btn btn-compact" onClick={() => void openPhoneChangeDialog()}>{phone ? 'Change' : 'Add'}</button>
|
|
113
|
+
</Row>
|
|
114
|
+
</section>
|
|
115
|
+
|
|
116
|
+
<section className="acct-block" aria-label="Personal">
|
|
117
|
+
<div className="acct-label-row">
|
|
118
|
+
<span className="acct-label">Personal</span>
|
|
119
|
+
<span className="acct-hint">Saves as you type</span>
|
|
120
|
+
</div>
|
|
121
|
+
<FormView model={MeSaveModel} row={me} fields={PERSONAL_FIELDS} className="acct-form" />
|
|
122
|
+
</section>
|
|
123
|
+
|
|
124
|
+
<LinkedSignIns userId={me.id} />
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const PROVIDERS: Record<string, { label: string; icon: string }> = {
|
|
130
|
+
google: { label: 'Google', icon: 'bi-google' },
|
|
131
|
+
apple: { label: 'Apple', icon: 'bi-apple' },
|
|
132
|
+
github: { label: 'GitHub', icon: 'bi-github' },
|
|
133
|
+
microsoft: { label: 'Microsoft', icon: 'bi-microsoft' },
|
|
134
|
+
facebook: { label: 'Facebook', icon: 'bi-facebook' },
|
|
135
|
+
};
|
|
136
|
+
const provider = (name: string) => PROVIDERS[name.toLowerCase()] ?? { label: name.charAt(0).toUpperCase() + name.slice(1), icon: 'bi-link-45deg' };
|
|
137
|
+
|
|
138
|
+
/** The linked Google/Apple/… sign-ins, one row each, with Unlink. */
|
|
139
|
+
export function LinkedSignIns({ userId }: { userId: number }) {
|
|
140
|
+
const { data } = OAuthConnectionModel.useList({ user: userId, size: 10, sort: '-created' });
|
|
141
|
+
const del = OAuthConnectionModel.useDelete();
|
|
142
|
+
const rows = data?.rows ?? [];
|
|
143
|
+
if (rows.length === 0) return null;
|
|
144
|
+
|
|
145
|
+
const unlink = async (id: number, label: string) => {
|
|
146
|
+
const ok = await modal.confirm({
|
|
147
|
+
title: `Unlink ${label}?`,
|
|
148
|
+
message: <>You won’t be able to sign in with <b>{label}</b> until you sign in with it again from the sign-in page. Your {label} account itself is untouched.</>,
|
|
149
|
+
confirmText: 'Unlink',
|
|
150
|
+
danger: true,
|
|
151
|
+
});
|
|
152
|
+
if (!ok) return;
|
|
153
|
+
try {
|
|
154
|
+
await del.mutateAsync({ id });
|
|
155
|
+
toast.success(`${label} unlinked`);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
// e.g. 400 "Cannot unlink your only login method. Set a password first." — as written.
|
|
158
|
+
toast.error(accountErrorMessage(err));
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<section className="acct-block acct-linked" aria-label="Linked sign-ins">
|
|
164
|
+
{rows.map((row) => {
|
|
165
|
+
const p = provider(row.provider);
|
|
166
|
+
return (
|
|
167
|
+
<div key={row.id} className="acct-row">
|
|
168
|
+
<div className="acct-linked-text">
|
|
169
|
+
<span className="acct-linked-icon" aria-hidden="true"><i className={`bi ${p.icon}`} /></span>
|
|
170
|
+
<span>Signed in with {p.label}{row.email && <> · <span className="dim">{row.email}</span></>}</span>
|
|
171
|
+
</div>
|
|
172
|
+
<div className="acct-row-actions">
|
|
173
|
+
<button type="button" className="btn btn-compact" disabled={del.isPending} onClick={() => void unlink(row.id, p.label)}>Unlink</button>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
})}
|
|
178
|
+
</section>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Passkeys ──────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
export function AccountPasskeysSection({ me }: AccountSectionProps) {
|
|
185
|
+
const supported = isPasskeyRegistrationSupported();
|
|
186
|
+
return (
|
|
187
|
+
<div className="acct-stack acct-fill">
|
|
188
|
+
<p className="acct-intro">Sign in with Touch ID, Face ID, Windows Hello or a security key instead of a password. A passkey works on the site it was made on.</p>
|
|
189
|
+
<PasskeyList userId={me.id} showDisable={false} emptyText="No passkeys yet." />
|
|
190
|
+
{supported
|
|
191
|
+
? (
|
|
192
|
+
<button type="button" className="acct-add" onClick={() => void openAddPasskeyDialog()}>
|
|
193
|
+
<i className="bi bi-plus-lg" aria-hidden="true" /> Add a passkey on this device
|
|
194
|
+
</button>
|
|
195
|
+
)
|
|
196
|
+
: <p className="acct-note">This browser can’t create passkeys. Try a current Chrome, Safari, Edge or Firefox.</p>}
|
|
197
|
+
<p className="acct-foot">Removing a passkey signs out nothing. Keep at least one other way to sign in.</p>
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Security ──────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
export function AccountSecuritySection({ me }: AccountSectionProps) {
|
|
205
|
+
const qc = useQueryClient();
|
|
206
|
+
const status = useRecoveryCodeStatus(me.id);
|
|
207
|
+
const enrolled = status.data?.enrolled === true;
|
|
208
|
+
const remaining = status.data?.remaining ?? 0;
|
|
209
|
+
const required = me.requires_mfa === true;
|
|
210
|
+
const hasPassword = me.has_password !== false;
|
|
211
|
+
const email = str(me.email);
|
|
212
|
+
|
|
213
|
+
const turnOff = async () => {
|
|
214
|
+
const ok = await modal.confirm({
|
|
215
|
+
title: 'Turn off the authenticator app?',
|
|
216
|
+
message: required
|
|
217
|
+
? <>Codes from the app stop working right away. <b>Two-step sign-in stays required on this account</b> — finish signing in with a passkey or a verified phone, or set up a new app. Only an administrator can remove the requirement.</>
|
|
218
|
+
: <>Codes from the app stop working right away and sign-in no longer asks for them.</>,
|
|
219
|
+
confirmText: 'Turn off',
|
|
220
|
+
danger: true,
|
|
221
|
+
});
|
|
222
|
+
if (!ok) return;
|
|
223
|
+
try {
|
|
224
|
+
await disableTotp();
|
|
225
|
+
toast.success('Authenticator app turned off');
|
|
226
|
+
} catch (err) {
|
|
227
|
+
toast.error(accountErrorMessage(err));
|
|
228
|
+
} finally {
|
|
229
|
+
void qc.invalidateQueries({ queryKey: accountKeys.recoveryStatus(me.id) });
|
|
230
|
+
void invalidateMe(qc);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const setPassword = async () => {
|
|
235
|
+
if (!email) return;
|
|
236
|
+
try {
|
|
237
|
+
await forgotPassword(email, 'link');
|
|
238
|
+
toast.success(`We sent a link to ${email} to set your password.`);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
toast.error(accountErrorMessage(err));
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div className="acct-stack">
|
|
246
|
+
<p className="acct-intro">
|
|
247
|
+
{required ? 'Two-step sign-in is required on this account.' : 'Add an authenticator app for two-step sign-in.'} Passkeys have their own section.
|
|
248
|
+
</p>
|
|
249
|
+
<Card
|
|
250
|
+
icon="bi-shield-lock"
|
|
251
|
+
title="Authenticator app"
|
|
252
|
+
chip={status.data && <Badge tone={enrolled ? 'success' : 'muted'}>{enrolled ? 'On' : 'Off'}</Badge>}
|
|
253
|
+
meta={status.isPending ? 'Checking…' : enrolled ? 'Sign-in asks for a code from your app' : 'Use 1Password, Google Authenticator or a similar app'}
|
|
254
|
+
>
|
|
255
|
+
<button type="button" className="btn btn-compact" disabled={status.isPending} onClick={() => void openTotpEnrolDialog({ replacing: enrolled })}>
|
|
256
|
+
{enrolled ? 'Set up a new app' : 'Set up'}
|
|
257
|
+
</button>
|
|
258
|
+
{enrolled && <button type="button" className="btn btn-compact acct-danger" onClick={() => void turnOff()}>Turn off</button>}
|
|
259
|
+
</Card>
|
|
260
|
+
<Card
|
|
261
|
+
icon="bi-key"
|
|
262
|
+
title="Recovery codes"
|
|
263
|
+
meta={enrolled ? `${remaining} of 8 left · regenerate a fresh set any time` : 'Available once an authenticator app is on'}
|
|
264
|
+
metaTone={enrolled && remaining <= 2 ? 'warn' : undefined}
|
|
265
|
+
>
|
|
266
|
+
{enrolled && <button type="button" className="btn btn-compact" onClick={() => void openRecoveryCodesView()}>View</button>}
|
|
267
|
+
{enrolled && <button type="button" className="btn btn-compact" onClick={() => void openRegenerateRecoveryCodes()}>Regenerate</button>}
|
|
268
|
+
</Card>
|
|
269
|
+
<Card
|
|
270
|
+
icon="bi-asterisk"
|
|
271
|
+
title="Password"
|
|
272
|
+
meta={hasPassword ? 'Change it any time with your current password' : 'Not set — you sign in with a passkey or a linked account'}
|
|
273
|
+
>
|
|
274
|
+
{hasPassword
|
|
275
|
+
? <button type="button" className="btn btn-compact" onClick={() => void openChangePasswordDialog()}>Change</button>
|
|
276
|
+
: <button type="button" className="btn btn-compact" disabled={!email} onClick={() => void setPassword()}>Email me a link</button>}
|
|
277
|
+
</Card>
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Sessions ──────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
const ACTIVE_WINDOW_SECONDS = 5 * 60;
|
|
285
|
+
|
|
286
|
+
function osFamily(row: UserDeviceRow): string {
|
|
287
|
+
const family = row.device_info?.os?.family;
|
|
288
|
+
if (!family || family === 'Other') return 'unknown OS';
|
|
289
|
+
return family === 'Mac OS X' ? 'macOS' : family;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function place(login: LoginEventRow | undefined): string | null {
|
|
293
|
+
if (!login) return null;
|
|
294
|
+
const parts = [login.city, login.region_code || login.region, login.city || login.region ? null : login.country_code].filter(Boolean);
|
|
295
|
+
return parts.length ? parts.join(', ') : login.country_code ?? null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function AccountSessionsSection({ me }: AccountSectionProps) {
|
|
299
|
+
const devices = UserDeviceModel.useList({ user: me.id, sort: '-last_seen', size: 4 });
|
|
300
|
+
const logins = LoginEventModel.useList({ user: me.id, sort: '-created', size: 50, graph: 'default' });
|
|
301
|
+
const thisDuid = getDuid();
|
|
302
|
+
const rows = devices.data?.rows ?? [];
|
|
303
|
+
const more = Math.max(0, (devices.data?.count ?? 0) - rows.length);
|
|
304
|
+
const latestLogin = (duid: string) => (logins.data?.rows ?? []).find((row) => row.device?.duid === duid);
|
|
305
|
+
const now = Date.now() / 1000;
|
|
306
|
+
|
|
307
|
+
const revoke = async () => {
|
|
308
|
+
const ok = await modal.confirm({
|
|
309
|
+
title: 'Sign out everywhere else?',
|
|
310
|
+
message: 'Every other browser and app signed in to your account is signed out now. This device stays signed in.',
|
|
311
|
+
confirmText: 'Sign out others',
|
|
312
|
+
danger: true,
|
|
313
|
+
});
|
|
314
|
+
if (!ok) return;
|
|
315
|
+
try {
|
|
316
|
+
await revokeOtherSessions();
|
|
317
|
+
toast.success('Signed out of every other session');
|
|
318
|
+
} catch (err) {
|
|
319
|
+
toast.error(accountErrorMessage(err));
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
return (
|
|
324
|
+
<div className="acct-stack acct-fill">
|
|
325
|
+
<p className="acct-intro">Devices that have used your account recently.</p>
|
|
326
|
+
{devices.isPending && <div className="acct-skel" aria-busy="true"><span className="skel skel-block" /></div>}
|
|
327
|
+
{devices.isError && <p className="dim" role="alert">{accountErrorMessage(devices.error)}</p>}
|
|
328
|
+
{!devices.isPending && !devices.isError && rows.length === 0 && <p className="dim">No devices recorded yet.</p>}
|
|
329
|
+
<ul className="acct-cards" aria-label="Devices">
|
|
330
|
+
{rows.map((row) => {
|
|
331
|
+
const login = latestLogin(row.duid);
|
|
332
|
+
const isThis = row.duid === thisDuid;
|
|
333
|
+
const active = isThis || now - row.last_seen < ACTIVE_WINDOW_SECONDS;
|
|
334
|
+
const where = place(login);
|
|
335
|
+
const browser = browserLabel(row.device_info).replace(/ \d.*$/, '');
|
|
336
|
+
return (
|
|
337
|
+
<li key={row.id} className="acct-card">
|
|
338
|
+
<span className="acct-card-icon" aria-hidden="true"><i className={`bi ${deviceIcon(row.device_info)}`} /></span>
|
|
339
|
+
<div className="acct-card-info">
|
|
340
|
+
<span className="acct-card-title">{browser} on {osFamily(row)}</span>
|
|
341
|
+
<span className="acct-card-meta">{[where, active ? 'active now' : fmt.relative(row.last_seen)].filter(Boolean).join(' · ')}</span>
|
|
342
|
+
</div>
|
|
343
|
+
<div className="acct-card-actions">
|
|
344
|
+
{isThis && <Badge tone="success">This device</Badge>}
|
|
345
|
+
{login && (login.is_new_country || login.is_new_region) && <Badge tone="warning">New location</Badge>}
|
|
346
|
+
</div>
|
|
347
|
+
</li>
|
|
348
|
+
);
|
|
349
|
+
})}
|
|
350
|
+
</ul>
|
|
351
|
+
{more > 0 && <p className="acct-note">And {more} more device{more === 1 ? '' : 's'}.</p>}
|
|
352
|
+
<div className="acct-signout-others">
|
|
353
|
+
<span>Don’t recognise one? Sign the others out. This device stays signed in.</span>
|
|
354
|
+
<button type="button" className="btn btn-compact acct-danger" onClick={() => void revoke()}>Sign out everywhere else</button>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── Notifications ─────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
export function AccountNotificationsSection({ kinds }: AccountSectionProps & { kinds?: NotificationKind[] }) {
|
|
363
|
+
return <NotificationPreferences kinds={kinds} />;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── API keys ──────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
export function AccountApiKeysSection({ me }: AccountSectionProps) {
|
|
369
|
+
return <ApiKeysSection user={{ id: me.id, display_name: str(me.display_name), email: str(me.email) }} canManage isSelf />;
|
|
370
|
+
}
|