portal-mojo 0.2.7 → 0.2.9
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 +59 -11
- package/docs/DetailView.md +3 -2
- package/docs/README.md +7 -2
- package/docs/account.md +261 -0
- package/docs/admin-bouncer.md +3 -3
- package/docs/admin-credentials.md +4 -3
- package/docs/admin-devices-geoip.md +1 -1
- package/docs/admin-dns-certificates.md +2 -2
- package/docs/admin-dns-records.md +1 -1
- package/docs/admin-edge.md +210 -0
- package/docs/admin-identity-directories-sources.json +2 -2
- package/docs/admin-identity-directories.md +9 -0
- package/docs/admin-identity-users.md +2 -2
- package/docs/admin-jobs.md +4 -4
- package/docs/admin-media-rendering.md +142 -0
- package/docs/admin-modals.md +7 -0
- package/docs/admin-monitoring.md +4 -4
- package/docs/admin-network-security.md +2 -2
- package/docs/admin-phonehub.md +4 -1
- package/docs/admin-storage.md +5 -2
- package/docs/auth-pages.md +8 -3
- package/docs/auth.md +45 -2
- package/docs/calendar.md +1 -1
- package/docs/client.md +159 -26
- package/docs/collection-multiselect.md +2 -2
- package/docs/combobox.md +2 -2
- package/docs/dataview.md +1 -1
- package/docs/datepicker.md +3 -3
- package/docs/daterange.md +1 -1
- package/docs/datetimepicker.md +1 -1
- package/docs/defineModel.md +40 -7
- package/docs/detail-primitives.md +1 -1
- package/docs/feedback.md +18 -8
- package/docs/file-upload-ux.md +2 -2
- package/docs/idioms.md +13 -1
- package/docs/loading.md +2 -2
- package/docs/location-address.md +2 -1
- package/docs/markdown.md +3 -3
- package/docs/menus-and-access.md +21 -1
- package/docs/multiselect-dropdown.md +1 -1
- package/docs/popover.md +1 -1
- package/docs/record-feed.md +2 -2
- package/docs/taginput.md +2 -1
- package/docs/timepicker.md +1 -1
- package/docs/timezone-select.md +4 -3
- package/docs/user-menu.md +4 -3
- package/docs/worldmap.md +2 -2
- package/package.json +4 -2
- package/src/account/AccountModal.tsx +276 -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/core/index.ts +1 -1
- package/src/admin/credentials/secret-dialog.tsx +3 -77
- package/src/admin/dns/models.ts +1 -1
- package/src/admin/domains/infrastructure.ts +12 -2
- package/src/admin/domains/security.ts +3 -1
- package/src/admin/edge/BlocklistPage.tsx +110 -0
- package/src/admin/edge/UpstreamsPage.tsx +113 -0
- package/src/admin/edge/VhostDetail.tsx +166 -0
- package/src/admin/edge/VhostWizard.tsx +273 -0
- package/src/admin/edge/VhostsPage.tsx +47 -0
- package/src/admin/edge/api.ts +56 -0
- package/src/admin/edge/index.ts +15 -0
- package/src/admin/edge/models.ts +395 -0
- package/src/admin/edge/permissions.ts +13 -0
- package/src/admin/identity/users/PersonalApiKeysPage.tsx +10 -0
- 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 +10 -103
- package/src/admin/index.ts +1 -0
- package/src/admin/phonehub/api.ts +5 -3
- package/src/admin/public/infrastructure.ts +1 -0
- package/src/admin/registry.ts +2 -1
- package/src/admin/storage/MediaRenderingPage.tsx +258 -0
- package/src/admin/storage/file-renditions.ts +12 -0
- package/src/admin/storage/index.ts +2 -0
- package/src/admin/storage/rendition-settings.ts +264 -0
- package/src/charts/exportChart.ts +1 -1
- package/src/client/action-result.ts +23 -61
- package/src/client/active-group.ts +9 -3
- package/src/client/auth.ts +207 -6
- package/src/client/client.ts +49 -10
- package/src/client/errors.ts +57 -2
- package/src/client/group-context.ts +2 -0
- package/src/client/group.tsx +112 -20
- package/src/client/hooks.ts +7 -3
- package/src/client/mock.ts +1429 -20
- package/src/client/model.ts +15 -3
- package/src/client/renditions-mock.ts +212 -0
- package/src/client/scoped.ts +19 -5
- package/src/styles/components/account.css +304 -0
- package/src/styles/components/admin-assistant.css +28 -0
- package/src/styles/components/admin-bouncer.css +53 -0
- package/src/styles/components/admin-cloudwatch.css +23 -0
- package/src/styles/components/admin-dashboard.css +16 -0
- package/src/styles/components/admin-devices.css +260 -0
- package/src/styles/components/admin-dns-certificates.css +28 -0
- package/src/styles/components/admin-dns-registrar.css +1 -0
- package/src/styles/components/admin-dns.css +43 -0
- package/src/styles/components/admin-edge.css +1 -0
- package/src/styles/components/admin-incidents.css +14 -0
- package/src/styles/components/admin-jobs.css +237 -0
- package/src/styles/components/admin-media-rendering.css +38 -0
- package/src/styles/components/admin-messaging.css +3 -0
- package/src/styles/components/admin-monitoring.css +152 -0
- package/src/styles/components/admin-network.css +406 -0
- package/src/styles/components/admin-phonehub.css +61 -0
- package/src/styles/components/admin-push.css +30 -0
- package/src/styles/components/admin-rules.css +23 -0
- package/src/styles/components/admin-security.css +37 -0
- package/src/styles/components/admin-shortlinks.css +8 -0
- package/src/styles/components/admin-signin.css +64 -0
- package/src/styles/components/admin-storage.css +4 -0
- package/src/styles/components/auth.css +162 -0
- package/src/styles/components/calendar.css +228 -0
- package/src/styles/components/charts-c2.css +249 -0
- package/src/styles/components/collection-multiselect.css +114 -0
- package/src/styles/components/collection-select.css +63 -0
- package/src/styles/components/combobox.css +42 -0
- package/src/styles/components/dataview.css +139 -0
- package/src/styles/components/datepicker.css +106 -0
- package/src/styles/components/daterange.css +119 -0
- package/src/styles/components/datetimepicker.css +230 -0
- package/src/styles/components/detail-primitives.css +246 -0
- package/src/styles/components/detailview.css +69 -0
- package/src/styles/components/drawer.css +111 -0
- package/src/styles/components/file-upload.css +112 -0
- package/src/styles/components/form-wizard.css +11 -0
- package/src/styles/components/formview.css +74 -0
- package/src/styles/components/group-admin.css +302 -0
- package/src/styles/components/guardrail.css +29 -0
- package/src/styles/components/idioms.css +84 -0
- package/src/styles/components/image-editor.css +80 -0
- package/src/styles/components/loading.css +200 -0
- package/src/styles/components/location-address.css +32 -0
- package/src/styles/components/markdown.css +192 -0
- package/src/styles/components/multiselect-dropdown.css +92 -0
- package/src/styles/components/popover.css +35 -0
- package/src/styles/components/record-feed.css +161 -0
- package/src/styles/components/right-panel.css +37 -0
- package/src/styles/components/tabs.css +26 -0
- package/src/styles/components/taginput.css +132 -0
- package/src/styles/components/timepicker.css +282 -0
- package/src/styles/components/user-admin.css +189 -0
- package/src/styles/components/user-menu.css +98 -0
- package/src/styles/components/worldmap.css +97 -0
- package/src/styles/core.css +807 -0
- package/src/styles/index.css +69 -0
- package/src/styles/required-tokens.json +32 -0
- package/src/ui/FileField.tsx +12 -7
- package/src/ui/FormWizard.tsx +9 -3
- package/src/ui/ThemeProvider.tsx +1 -1
- package/src/ui/UserMenu.tsx +6 -2
- package/src/ui/detail/index.ts +2 -2
- package/src/ui/index.ts +4 -2
- package/src/ui/loading.tsx +1 -1
- package/src/ui/modal.tsx +76 -0
- package/src/ui/secret-dialog.tsx +83 -0
- package/src/ui/toast.tsx +21 -3
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|