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.
Files changed (168) hide show
  1. package/README.md +59 -11
  2. package/docs/DetailView.md +3 -2
  3. package/docs/README.md +7 -2
  4. package/docs/account.md +261 -0
  5. package/docs/admin-bouncer.md +3 -3
  6. package/docs/admin-credentials.md +4 -3
  7. package/docs/admin-devices-geoip.md +1 -1
  8. package/docs/admin-dns-certificates.md +2 -2
  9. package/docs/admin-dns-records.md +1 -1
  10. package/docs/admin-edge.md +210 -0
  11. package/docs/admin-identity-directories-sources.json +2 -2
  12. package/docs/admin-identity-directories.md +9 -0
  13. package/docs/admin-identity-users.md +2 -2
  14. package/docs/admin-jobs.md +4 -4
  15. package/docs/admin-media-rendering.md +142 -0
  16. package/docs/admin-modals.md +7 -0
  17. package/docs/admin-monitoring.md +4 -4
  18. package/docs/admin-network-security.md +2 -2
  19. package/docs/admin-phonehub.md +4 -1
  20. package/docs/admin-storage.md +5 -2
  21. package/docs/auth-pages.md +8 -3
  22. package/docs/auth.md +45 -2
  23. package/docs/calendar.md +1 -1
  24. package/docs/client.md +159 -26
  25. package/docs/collection-multiselect.md +2 -2
  26. package/docs/combobox.md +2 -2
  27. package/docs/dataview.md +1 -1
  28. package/docs/datepicker.md +3 -3
  29. package/docs/daterange.md +1 -1
  30. package/docs/datetimepicker.md +1 -1
  31. package/docs/defineModel.md +40 -7
  32. package/docs/detail-primitives.md +1 -1
  33. package/docs/feedback.md +18 -8
  34. package/docs/file-upload-ux.md +2 -2
  35. package/docs/idioms.md +13 -1
  36. package/docs/loading.md +2 -2
  37. package/docs/location-address.md +2 -1
  38. package/docs/markdown.md +3 -3
  39. package/docs/menus-and-access.md +21 -1
  40. package/docs/multiselect-dropdown.md +1 -1
  41. package/docs/popover.md +1 -1
  42. package/docs/record-feed.md +2 -2
  43. package/docs/taginput.md +2 -1
  44. package/docs/timepicker.md +1 -1
  45. package/docs/timezone-select.md +4 -3
  46. package/docs/user-menu.md +4 -3
  47. package/docs/worldmap.md +2 -2
  48. package/package.json +4 -2
  49. package/src/account/AccountModal.tsx +276 -0
  50. package/src/account/AccountSections.tsx +370 -0
  51. package/src/account/NotificationPreferences.tsx +152 -0
  52. package/src/account/PasskeyList.tsx +144 -0
  53. package/src/account/api.ts +236 -0
  54. package/src/account/credential-models.ts +153 -0
  55. package/src/account/dialogs.tsx +822 -0
  56. package/src/account/index.ts +16 -0
  57. package/src/account/models.ts +76 -0
  58. package/src/account/sections/ApiKeysSection.tsx +138 -0
  59. package/src/admin/core/index.ts +1 -1
  60. package/src/admin/credentials/secret-dialog.tsx +3 -77
  61. package/src/admin/dns/models.ts +1 -1
  62. package/src/admin/domains/infrastructure.ts +12 -2
  63. package/src/admin/domains/security.ts +3 -1
  64. package/src/admin/edge/BlocklistPage.tsx +110 -0
  65. package/src/admin/edge/UpstreamsPage.tsx +113 -0
  66. package/src/admin/edge/VhostDetail.tsx +166 -0
  67. package/src/admin/edge/VhostWizard.tsx +273 -0
  68. package/src/admin/edge/VhostsPage.tsx +47 -0
  69. package/src/admin/edge/api.ts +56 -0
  70. package/src/admin/edge/index.ts +15 -0
  71. package/src/admin/edge/models.ts +395 -0
  72. package/src/admin/edge/permissions.ts +13 -0
  73. package/src/admin/identity/users/PersonalApiKeysPage.tsx +10 -0
  74. package/src/admin/identity/users/models.ts +16 -128
  75. package/src/admin/identity/users/sections/ApiKeysSection.tsx +3 -119
  76. package/src/admin/identity/users/sections/NotificationsSection.tsx +23 -102
  77. package/src/admin/identity/users/sections/PersonalSection.tsx +18 -5
  78. package/src/admin/identity/users/sections/actions.tsx +10 -103
  79. package/src/admin/index.ts +1 -0
  80. package/src/admin/phonehub/api.ts +5 -3
  81. package/src/admin/public/infrastructure.ts +1 -0
  82. package/src/admin/registry.ts +2 -1
  83. package/src/admin/storage/MediaRenderingPage.tsx +258 -0
  84. package/src/admin/storage/file-renditions.ts +12 -0
  85. package/src/admin/storage/index.ts +2 -0
  86. package/src/admin/storage/rendition-settings.ts +264 -0
  87. package/src/charts/exportChart.ts +1 -1
  88. package/src/client/action-result.ts +23 -61
  89. package/src/client/active-group.ts +9 -3
  90. package/src/client/auth.ts +207 -6
  91. package/src/client/client.ts +49 -10
  92. package/src/client/errors.ts +57 -2
  93. package/src/client/group-context.ts +2 -0
  94. package/src/client/group.tsx +112 -20
  95. package/src/client/hooks.ts +7 -3
  96. package/src/client/mock.ts +1429 -20
  97. package/src/client/model.ts +15 -3
  98. package/src/client/renditions-mock.ts +212 -0
  99. package/src/client/scoped.ts +19 -5
  100. package/src/styles/components/account.css +304 -0
  101. package/src/styles/components/admin-assistant.css +28 -0
  102. package/src/styles/components/admin-bouncer.css +53 -0
  103. package/src/styles/components/admin-cloudwatch.css +23 -0
  104. package/src/styles/components/admin-dashboard.css +16 -0
  105. package/src/styles/components/admin-devices.css +260 -0
  106. package/src/styles/components/admin-dns-certificates.css +28 -0
  107. package/src/styles/components/admin-dns-registrar.css +1 -0
  108. package/src/styles/components/admin-dns.css +43 -0
  109. package/src/styles/components/admin-edge.css +1 -0
  110. package/src/styles/components/admin-incidents.css +14 -0
  111. package/src/styles/components/admin-jobs.css +237 -0
  112. package/src/styles/components/admin-media-rendering.css +38 -0
  113. package/src/styles/components/admin-messaging.css +3 -0
  114. package/src/styles/components/admin-monitoring.css +152 -0
  115. package/src/styles/components/admin-network.css +406 -0
  116. package/src/styles/components/admin-phonehub.css +61 -0
  117. package/src/styles/components/admin-push.css +30 -0
  118. package/src/styles/components/admin-rules.css +23 -0
  119. package/src/styles/components/admin-security.css +37 -0
  120. package/src/styles/components/admin-shortlinks.css +8 -0
  121. package/src/styles/components/admin-signin.css +64 -0
  122. package/src/styles/components/admin-storage.css +4 -0
  123. package/src/styles/components/auth.css +162 -0
  124. package/src/styles/components/calendar.css +228 -0
  125. package/src/styles/components/charts-c2.css +249 -0
  126. package/src/styles/components/collection-multiselect.css +114 -0
  127. package/src/styles/components/collection-select.css +63 -0
  128. package/src/styles/components/combobox.css +42 -0
  129. package/src/styles/components/dataview.css +139 -0
  130. package/src/styles/components/datepicker.css +106 -0
  131. package/src/styles/components/daterange.css +119 -0
  132. package/src/styles/components/datetimepicker.css +230 -0
  133. package/src/styles/components/detail-primitives.css +246 -0
  134. package/src/styles/components/detailview.css +69 -0
  135. package/src/styles/components/drawer.css +111 -0
  136. package/src/styles/components/file-upload.css +112 -0
  137. package/src/styles/components/form-wizard.css +11 -0
  138. package/src/styles/components/formview.css +74 -0
  139. package/src/styles/components/group-admin.css +302 -0
  140. package/src/styles/components/guardrail.css +29 -0
  141. package/src/styles/components/idioms.css +84 -0
  142. package/src/styles/components/image-editor.css +80 -0
  143. package/src/styles/components/loading.css +200 -0
  144. package/src/styles/components/location-address.css +32 -0
  145. package/src/styles/components/markdown.css +192 -0
  146. package/src/styles/components/multiselect-dropdown.css +92 -0
  147. package/src/styles/components/popover.css +35 -0
  148. package/src/styles/components/record-feed.css +161 -0
  149. package/src/styles/components/right-panel.css +37 -0
  150. package/src/styles/components/tabs.css +26 -0
  151. package/src/styles/components/taginput.css +132 -0
  152. package/src/styles/components/timepicker.css +282 -0
  153. package/src/styles/components/user-admin.css +189 -0
  154. package/src/styles/components/user-menu.css +98 -0
  155. package/src/styles/components/worldmap.css +97 -0
  156. package/src/styles/core.css +807 -0
  157. package/src/styles/index.css +69 -0
  158. package/src/styles/required-tokens.json +32 -0
  159. package/src/ui/FileField.tsx +12 -7
  160. package/src/ui/FormWizard.tsx +9 -3
  161. package/src/ui/ThemeProvider.tsx +1 -1
  162. package/src/ui/UserMenu.tsx +6 -2
  163. package/src/ui/detail/index.ts +2 -2
  164. package/src/ui/index.ts +4 -2
  165. package/src/ui/loading.tsx +1 -1
  166. package/src/ui/modal.tsx +76 -0
  167. package/src/ui/secret-dialog.tsx +83 -0
  168. 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&rsquo;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&rsquo;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&rsquo;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
+ }