Skip to content
Clone

feat(web/app): add the account settings domain

Port profile, security, sessions, preferences and push toggles. SSH keys now go
through /auth/ssh-keys (algorithm replaces the old key type); access-key
management is removed because the current API has no equivalent.
ZhenYi committed
parent 95df59f commit cdeb6af Browse files

15 files changed

+1802 −0

File tree 15
Added view/app/src/domains/settings/model/api.ts View view/app/src/domains/settings/model/api.ts
@@ -0,0 +1,105 @@
1/**
2 * User-settings API: wrappers for every user-account endpoint (profile,
3 * security, sessions, devices, emails, preferences, access keys, ssh keys,
4 * suspensions). Workspace navigation data lives in the page slice's
5 * shell-api; space mutations in features/space-management.
6 */
7
8import { ApiFailure, settingsKey, unwrapApi } from '@app/lib'
9import type { ChangePasswordCmd, UpdatePreferencesCmd, UpdateProfileCmd } from '@app/services/api'
10import Apis, { trackFetch } from '@app/services/api'
11import { invalidateCache, writeCache } from '@app/services/storage'
12import type {
13 AvatarInfo,
14 CountResult,
15 DeviceRow,
16 RecoveryCodes,
17 SessionPage,
18 SshKey,
19 SshKeyPage,
20 SuspensionRow,
21 TotpSetup,
22 TotpStatus,
23 UserPreferences,
24 UserPublic,
25} from './user-settings'
26
27/* ---- account & profile ---------------------------------------------------- */
28export const updateProfile = (cmd: UpdateProfileCmd) =>
29 unwrapApi<UserPublic>(Apis.account.update_profile({ data: cmd }))
30export const updateUsername = (username: string) =>
31 unwrapApi<UserPublic>(Apis.account.update_username({ data: { username } }))
32
33/** Binary upload — the generated client types the body as number[], so PUT
34 * the raw bytes with fetch and reuse the standard envelope contract. */
35export async function uploadAvatar(file: File): Promise<AvatarInfo> {
36 const res = await trackFetch('/api/v1/me/avatar', {
37 method: 'PUT',
38 body: file,
39 headers: { 'content-type': file.type || 'application/octet-stream' },
40 })
41 const env = (await res.json()) as {
42 code: number
43 message: string
44 data?: AvatarInfo | null
45 error?: string | null
46 }
47 if (env.code !== 200) {
48 throw new ApiFailure(env.error || env.message || 'avatar upload failed', env.code)
49 }
50 return env.data as AvatarInfo
51}
52
53export const deleteAvatar = () => unwrapApi<null>(Apis.user.delete_avatar())
54
55/* ---- password & 2FA ------------------------------------------------------- */
56export const changePassword = (cmd: ChangePasswordCmd) => unwrapApi<null>(Apis.auth.change_password({ data: cmd }))
57export const totpStatus = () => unwrapApi<TotpStatus>(Apis.auth.totp_status())
58export const totpSetup = () => unwrapApi<TotpSetup>(Apis.auth.totp_setup())
59/** Enabling 2FA hands back the one-time recovery codes. */
60export const totpEnable = (code: string, password: string) =>
61 unwrapApi<RecoveryCodes>(Apis.auth.totp_enable({ data: { code, password } }))
62export const totpDisable = (code: string) => unwrapApi<null>(Apis.auth.totp_disable({ data: { code } }))
63export const recoveryCodes = () => unwrapApi<RecoveryCodes>(Apis.auth.recovery_codes())
64/** Rotation requires the current password. */
65export const regenerateRecoveryCodes = (password: string) =>
66 unwrapApi<RecoveryCodes>(Apis.auth.regenerate_recovery_codes({ data: { password } }))
67
68/* ---- sessions & devices --------------------------------------------------- */
69export const listSessions = () => unwrapApi<SessionPage>(Apis.user.list_sessions({ params: { limit: 25 } }))
70export const revokeSession = (sessionId: string) =>
71 unwrapApi<null>(Apis.user.revoke_session({ pathParams: { session_id: sessionId } }))
72export const revokeAllSessions = () => unwrapApi<CountResult>(Apis.user.revoke_all_sessions())
73export const listDevices = () => unwrapApi<DeviceRow[]>(Apis.account.list_devices({ params: {} }))
74
75/* ---- preferences ---------------------------------------------------------- */
76/** Canonical home of the preference endpoints; features/space-management keeps
77 * its own four-line copies for the personal privacy dialog (features may not
78 * import each other). */
79export const fetchPreferences = () => unwrapApi<UserPreferences>(Apis.account.get_preferences())
80
81export const updatePreferences = (cmd: UpdatePreferencesCmd) =>
82 unwrapApi<UserPreferences>(Apis.account.update_preferences({ data: cmd })).then((prefs) => {
83 // The response is the canonical row: store it so every preferences
84 // consumer sees the change immediately, and drop the derived diff-style
85 // read the commit view caches under its own key.
86 writeCache(settingsKey('preferences'), prefs)
87 invalidateCache(settingsKey('diff-style'))
88 return prefs
89 })
90
91/* ---- ssh public keys ------------------------------------------------------ */
92export const listSshKeys = async (): Promise<SshKeyPage> =>
93 unwrapApi<SshKeyPage>(Apis.auth.auth_ssh_key_list({ params: { limit: 100 } }))
94
95export const addSshKey = async (name: string, content: string) =>
96 unwrapApi<SshKey>(Apis.auth.auth_ssh_key_add({ data: { name, public_key: content } }))
97
98export const deleteSshKey = (keyId: string) =>
99 unwrapApi<unknown>(Apis.auth.auth_ssh_key_delete({ pathParams: { key_id: keyId } }))
100
101/* ---- account status ------------------------------------------------------- */
102export const listSuspensions = async (): Promise<SuspensionRow[]> => {
103 const page = await unwrapApi<{ items?: SuspensionRow[] }>(Apis.user.my_suspensions({ params: {} }))
104 return page?.items ?? []
105}
Added view/app/src/domains/settings/model/preferences.ts View view/app/src/domains/settings/model/preferences.ts
@@ -0,0 +1,7 @@
1import { settingsKey, useAsync } from '@app/lib'
2import { fetchPreferences } from './api'
3
4/** Load the viewer's preferences once per section mount, local-first. */
5export function usePreferences() {
6 return useAsync(fetchPreferences, [], { cacheKey: settingsKey('preferences') })
7}
Added view/app/src/domains/settings/model/push.ts View view/app/src/domains/settings/model/push.ts
@@ -0,0 +1,141 @@
1/**
2 * Browser system-notification plumbing: Service Worker registration, the
3 * `PushSubscription` lifecycle, and the server-side subscription row.
4 *
5 * The module is deliberately framework-free. Features must not import each
6 * other, and both the settings section and any future "ask me again" surface
7 * need the same three operations, so they live here rather than in the UI.
8 */
9
10import { unwrapApi } from '@app/lib'
11import type { PushSubscriptionPlatform } from '@app/services/api'
12import Apis, { trackFetch } from '@app/services/api'
13
14const SERVICE_WORKER_URL = '/sw.js'
15
16export type PushPermission = 'unsupported' | 'default' | 'granted' | 'denied'
17
18/**
19 * `user_preferences.push_flags` bits, mirrored from `export::push`.
20 *
21 * The mask is a *disable* mask: the server treats a set bit as "do not push this
22 * category", so the stored default of `0` keeps everything on for accounts that
23 * never opened this page.
24 */
25export const PUSH_FLAG = {
26 mention: 1 << 0,
27 reply: 1 << 1,
28 dm: 1 << 2,
29 reminder: 1 << 3,
30} as const
31
32/** What the browser can actually do, independent of the server-side config. */
33export function pushSupport(): PushPermission {
34 if (typeof window === 'undefined') return 'unsupported'
35 if (!('serviceWorker' in navigator) || !('PushManager' in window)) return 'unsupported'
36 if (!('Notification' in window)) return 'unsupported'
37 return Notification.permission as PushPermission
38}
39
40/**
41 * base64url → bytes; `applicationServerKey` rejects a plain string.
42 *
43 * The buffer is allocated explicitly (`new ArrayBuffer`) instead of letting
44 * `Uint8Array` pick one: the DOM type only accepts a view over a real
45 * `ArrayBuffer`, while a bare `Uint8Array` is typed against `ArrayBufferLike`
46 * (which also admits `SharedArrayBuffer`).
47 */
48function decodeBase64Url(value: string): Uint8Array<ArrayBuffer> {
49 const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
50 const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=')
51 const binary = atob(padded)
52 const buffer = new ArrayBuffer(binary.length)
53 const bytes = new Uint8Array(buffer)
54 for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
55 return bytes
56}
57
58async function serviceWorkerRegistration(): Promise<ServiceWorkerRegistration> {
59 return navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: '/' })
60}
61
62/** The browser's current subscription for this origin, if any. */
63export async function currentSubscription(): Promise<PushSubscription | null> {
64 if (pushSupport() !== 'granted') return null
65 const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_URL)
66 if (!registration) return null
67 return registration.pushManager.getSubscription()
68}
69
70/**
71 * Ask for permission and register this device.
72 *
73 * Returns `'denied'` when the user declines rather than throwing: a refusal is a
74 * normal outcome of a click, not an error to surface as a failure banner. The
75 * server-side config is checked first so a disabled deployment never triggers a
76 * permission prompt.
77 */
78export async function enablePush(publicKey: string): Promise<PushPermission> {
79 const support = pushSupport()
80 if (support === 'unsupported') return 'unsupported'
81 const permission = Notification.permission === 'granted' ? 'granted' : await Notification.requestPermission()
82 if (permission !== 'granted') return permission as PushPermission
83
84 const registration = await serviceWorkerRegistration()
85 await navigator.serviceWorker.ready
86 const existing = await registration.pushManager.getSubscription()
87 const subscription =
88 existing ??
89 (await registration.pushManager.subscribe({
90 userVisibleOnly: true,
91 applicationServerKey: decodeBase64Url(publicKey),
92 }))
93
94 await upsertSubscription(subscription)
95 return 'granted'
96}
97
98/** Unsubscribe this device and drop the matching server row. */
99export async function disablePush(): Promise<void> {
100 const subscription = await currentSubscription()
101 if (!subscription) return
102 const endpoint = subscription.endpoint
103 await subscription.unsubscribe()
104 const rows = await listSubscriptions()
105 const row = rows.find((item) => item.endpoint === endpoint)
106 if (row) await unwrapApi(Apis.user.delete_push_subscription({ pathParams: { push_id: row.id } }))
107}
108
109/** Re-register an existing browser subscription after the server lost the row. */
110export async function syncExistingSubscription(): Promise<void> {
111 const subscription = await currentSubscription()
112 if (subscription) await upsertSubscription(subscription)
113}
114
115async function listSubscriptions() {
116 const page = await unwrapApi<{ items?: { id: string; endpoint: string }[] }>(
117 Apis.user.list_push_subscriptions({ params: { limit: 100 } }),
118 )
119 return page?.items ?? []
120}
121
122/**
123 * The generated client types `platform` as an open string union, so the value is
124 * pinned here against the backend enum (`web_push`); the other two platforms are
125 * reserved for the mobile apps and never produced by the browser.
126 */
127async function upsertSubscription(subscription: PushSubscription): Promise<void> {
128 const json = subscription.toJSON()
129 const platform: PushSubscriptionPlatform = 'WebPush'
130 await trackFetch('/api/v1/me/push-subscriptions', {
131 method: 'POST',
132 headers: { 'content-type': 'application/json' },
133 body: JSON.stringify({
134 endpoint: subscription.endpoint,
135 p256dh_key: json.keys?.p256dh ?? '',
136 auth_key: json.keys?.auth ?? '',
137 platform,
138 user_agent: navigator.userAgent,
139 }),
140 })
141}
Added view/app/src/domains/settings/model/user-settings.ts View view/app/src/domains/settings/model/user-settings.ts
@@ -0,0 +1,33 @@
1/**
2 * User-account row shapes for the settings modal, derived straight from the
3 * generated response envelopes. `Me` is re-derived here (one line) because a
4 * feature may not import types from a page slice.
5 */
6import type {
7 EnvelopeAvatarDto,
8 EnvelopeCountResp,
9 EnvelopeMeResp,
10 EnvelopePageDeviceDto,
11 EnvelopePageSessionDto,
12 EnvelopePageSshKeySummary,
13 EnvelopeRecoveryCodesResp,
14 EnvelopeTotpSetupResp,
15 EnvelopeTotpStatusResp,
16 EnvelopeUserPreferencesDto,
17 EnvelopeUserPublic,
18 EnvelopeVecSuspensionDto,
19} from '@app/services/api'
20
21export type Me = NonNullable<EnvelopeMeResp['data']>
22export type AvatarInfo = NonNullable<EnvelopeAvatarDto['data']>
23export type UserPublic = NonNullable<EnvelopeUserPublic['data']>
24export type CountResult = NonNullable<EnvelopeCountResp['data']>
25export type RecoveryCodes = NonNullable<EnvelopeRecoveryCodesResp['data']>
26export type TotpSetup = NonNullable<EnvelopeTotpSetupResp['data']>
27export type TotpStatus = NonNullable<EnvelopeTotpStatusResp['data']>
28export type DeviceRow = NonNullable<EnvelopePageDeviceDto['data']>['items'][number]
29export type SuspensionRow = NonNullable<EnvelopeVecSuspensionDto['data']>[number]
30export type SessionPage = NonNullable<EnvelopePageSessionDto['data']>
31export type SshKeyPage = NonNullable<EnvelopePageSshKeySummary['data']>
32export type SshKey = NonNullable<NonNullable<EnvelopePageSshKeySummary['data']>['items']>[number]
33export type UserPreferences = NonNullable<EnvelopeUserPreferencesDto['data']>
Added view/app/src/domains/settings/ui/sections/account-section.tsx View view/app/src/domains/settings/ui/sections/account-section.tsx
@@ -0,0 +1,167 @@
1import { LanguagePicker } from '@app/components'
2import { CopyButton, Field, fmtDate, inputCls, SaveBar, SectionTitle, useSave } from '@app/domains/shared'
3import { type Locale, useT } from '@app/i18n'
4import { Button } from '@app/ui'
5import { useRef, useState } from 'react'
6import * as api from '../../model/api'
7import type { Me } from '../../model/user-settings'
8
9export function AccountSection({ me, onMeChanged }: { me: Me; onMeChanged: () => void }) {
10 const { t } = useT()
11 const { busy, notice, error, run } = useSave()
12 const [displayName, setDisplayName] = useState(me.user.display_name)
13 const [username, setUsername] = useState(me.user.username)
14 const [language, setLanguage] = useState(me.user.language ?? '')
15 const [locale, setLocale] = useState(me.user.locale ?? '')
16 const [timezone, setTimezone] = useState(me.user.timezone ?? '')
17 const fileRef = useRef<HTMLInputElement>(null)
18
19 return (
20 <section>
21 <SectionTitle title={t('settings.account.title')} sub={t('settings.account.sub')} />
22 <div className="mb-6 flex items-center gap-4">
23 {me.user.avatar_url ? (
24 <img src={me.user.avatar_url} alt="" className="h-16 w-16 rounded-full object-cover" />
25 ) : (
26 <div className="flex h-16 w-16 items-center justify-center rounded-full bg-shell-tile text-xl font-bold text-shell-ink">
27 {(me.user.display_name || me.user.username).slice(0, 1).toUpperCase()}
28 </div>
29 )}
30 <div className="flex gap-2">
31 <input
32 ref={fileRef}
33 type="file"
34 accept="image/png,image/jpeg,image/webp,image/gif"
35 className="hidden"
36 onChange={(event) => {
37 const file = event.target.files?.[0]
38 if (file) {
39 void run(async () => {
40 await api.uploadAvatar(file)
41 onMeChanged()
42 }, t('settings.account.avatarUpdated'))
43 }
44 event.target.value = ''
45 }}
46 />
47 <Button
48 variant="secondary"
49 className="h-9 text-[13px]"
50 disabled={busy}
51 onClick={() => fileRef.current?.click()}
52 >
53 {t('settings.account.uploadAvatar')}
54 </Button>
55 <Button
56 variant="secondary"
57 className="h-9 text-[13px]"
58 disabled={busy}
59 onClick={() =>
60 void run(async () => {
61 await api.deleteAvatar()
62 onMeChanged()
63 }, t('settings.account.avatarRemoved'))
64 }
65 >
66 {t('settings.account.removeAvatar')}
67 </Button>
68 </div>
69 </div>
70 <Field label={t('settings.account.displayName')}>
71 <input
72 className={inputCls}
73 value={displayName}
74 onChange={(e) => setDisplayName(e.target.value)}
75 maxLength={64}
76 />
77 </Field>
78 <Field label={t('settings.account.username')} hint={t('settings.account.usernameHint')}>
79 <input
80 className={inputCls}
81 value={username}
82 onChange={(e) => setUsername(e.target.value.toLowerCase())}
83 maxLength={32}
84 />
85 </Field>
86 <div className="grid max-w-[700px] gap-3 sm:grid-cols-3">
87 <Field label={t('settings.account.language')}>
88 <LanguagePicker
89 className={`${inputCls} max-w-none`}
90 value={language}
91 onChange={(next: Locale | null) => setLanguage(next ?? '')}
92 />
93 </Field>
94 <Field label={t('settings.account.locale')}>
95 <input
96 className={`${inputCls} max-w-none`}
97 value={locale}
98 onChange={(e) => setLocale(e.target.value)}
99 placeholder="zh_CN"
100 />
101 </Field>
102 <Field label={t('settings.account.timezone')}>
103 <input
104 className={`${inputCls} max-w-none`}
105 value={timezone}
106 onChange={(e) => setTimezone(e.target.value)}
107 placeholder="Asia/Shanghai"
108 />
109 </Field>
110 </div>
111 <div className="mb-6">
112 <SaveBar
113 busy={busy}
114 notice={notice}
115 error={error}
116 onSave={() =>
117 void run(async () => {
118 if (username && username !== me.user.username) await api.updateUsername(username)
119 await api.updateProfile({
120 display_name: displayName,
121 language: language || null,
122 locale: locale || null,
123 timezone: timezone || null,
124 })
125 onMeChanged()
126 }, t('settings.account.saved'))
127 }
128 />
129 </div>
130 <dl className="mb-8 space-y-1.5 text-[12.5px] text-shell-ink-muted">
131 <div className="flex gap-2">
132 <dt>{t('settings.account.userId')}</dt>
133 <dd className="flex items-center gap-2 font-mono text-shell-ink">
134 {me.user.user_id} <CopyButton text={String(me.user.user_id)} />
135 </dd>
136 </div>
137 <div className="flex gap-2">
138 <dt>{t('settings.account.createdAt')}</dt>
139 <dd className="text-shell-ink">{fmtDate(me.user.created_at)}</dd>
140 </div>
141 <div className="flex gap-2">
142 <dt>{t('settings.account.status')}</dt>
143 <dd className="text-shell-ink">
144 {me.user.is_active ? t('settings.account.active') : t('settings.account.inactive')}
145 {me.user.is_restricted ? ` · ${t('settings.account.restricted')}` : ''}
146 </dd>
147 </div>
148 </dl>
149 <div className="rounded-lg border-2 border-[#f23f43]/40 p-4">
150 <h3 className="text-[14px] font-bold text-[#f23f43]">{t('settings.account.danger')}</h3>
151 <p className="mb-3 mt-1 text-[12.5px] text-shell-ink-muted">{t('settings.account.revokeNote')}</p>
152 <SaveBar
153 danger
154 label={t('settings.account.revokeAll')}
155 busy={busy}
156 notice={notice}
157 error={error}
158 onSave={() =>
159 void run(async () => {
160 await api.revokeAllSessions()
161 }, t('settings.account.revoked'))
162 }
163 />
164 </div>
165 </section>
166 )
167}
Added view/app/src/domains/settings/ui/sections/appearance-section.tsx View view/app/src/domains/settings/ui/sections/appearance-section.tsx
@@ -0,0 +1,249 @@
1import { normalizeBoardView } from '@app/domains/issues/model'
2import { Busy, Field, inputCls, SaveBar, SectionTitle, useSave } from '@app/domains/shared'
3import { type MessageKey, useT } from '@app/i18n'
4import { previewColors, setTheme } from '@app/lib'
5import { useThemeContext } from '@app/providers'
6import { useState } from 'react'
7import * as api from '../../model/api'
8import { usePreferences } from '../../model/preferences'
9
10const DIFF_STYLE_OPTIONS: ReadonlyArray<{ value: string; key: MessageKey; noteKey: MessageKey }> = [
11 { value: '', key: 'settings.appearance.diffDefault', noteKey: 'settings.appearance.diffDefaultNote' },
12 { value: 'unified', key: 'settings.appearance.diffUnified', noteKey: 'settings.appearance.diffUnifiedNote' },
13 { value: 'split', key: 'settings.appearance.diffSplit', noteKey: 'settings.appearance.diffSplitNote' },
14]
15
16/** `board_view_mode`: the layout a 问题集 opens in when its URL names none. */
17const BOARD_VIEW_OPTIONS: ReadonlyArray<{ value: string; key: MessageKey }> = [
18 { value: 'board', key: 'issue.board.viewBoard' },
19 { value: 'list', key: 'issue.board.viewList' },
20]
21
22/** Static illustration of the chosen layout — the setting is otherwise
23 * invisible until you open a commit. */
24function DiffStylePreview({ style }: { style: 'unified' | 'split' }) {
25 const { t } = useT()
26 const rows = [
27 { kind: 'ctx', no: 16, text: ' browsergym-related:' },
28 { kind: 'ctx', no: 17, text: ' patterns:' },
29 { kind: 'del', no: 18, text: ' - "browser-use"' },
30 { kind: 'add', no: 19, text: ' - "uv"' },
31 { kind: 'ctx', no: 20, text: ' search-tools:' },
32 ]
33 const sign = (kind: string) => (kind === 'add' ? '+' : kind === 'del' ? '−' : '')
34 const tone = (kind: string) =>
35 kind === 'add' ? 'bg-[rgba(35,165,90,0.12)]' : kind === 'del' ? 'bg-[rgba(242,63,67,0.10)]' : ''
36 const cell = (row: (typeof rows)[number] | null) => (
37 <td
38 className={`w-1/2 border-r border-shell-hairline px-2 py-0.5 align-top last:border-r-0 ${row ? tone(row.kind) : 'bg-shell-field/60'}`}
39 >
40 <span className="inline-block w-6 select-none text-right text-[10px] text-shell-ink-muted">
41 {row ? row.no : ''}
42 </span>
43 <span className="ml-1 inline-block w-3 select-none text-center text-[10px] text-shell-ink-muted">
44 {row ? sign(row.kind) : ''}
45 </span>
46 <span className="ml-1 whitespace-pre">{row ? row.text : ''}</span>
47 </td>
48 )
49 const splitRows: Array<{ left: (typeof rows)[number] | null; right: (typeof rows)[number] | null }> = []
50 for (let i = 0; i < rows.length; i += 1) {
51 const row = rows[i]
52 if (row.kind === 'del' && rows[i + 1]?.kind === 'add') {
53 splitRows.push({ left: row, right: rows[i + 1] })
54 i += 1
55 } else if (row.kind === 'add') {
56 splitRows.push({ left: null, right: row })
57 } else {
58 splitRows.push({ left: row, right: row })
59 }
60 }
61 return (
62 <div className="settings-card max-w-[620px] overflow-hidden">
63 <p className="border-b border-shell-hairline bg-shell-field px-3 py-1.5 font-mono text-[10px] uppercase tracking-[1.2px] text-shell-ink-muted">
64 {t('userSettings.appearance.example', {
65 style:
66 style === 'split' ? t('userSettings.appearance.splitExample') : t('userSettings.appearance.unifiedExample'),
67 })}
68 </p>
69 <table className="w-full border-collapse font-mono text-[11.5px] leading-5">
70 <tbody>
71 {style === 'unified'
72 ? rows.map((row, index) => (
73 <tr key={index}>
74 <td colSpan={2} className={`px-2 py-0.5 ${tone(row.kind)}`}>
75 <span className="inline-block w-6 select-none text-right text-[10px] text-shell-ink-muted">
76 {row.no}
77 </span>
78 <span className="ml-1 inline-block w-3 select-none text-center text-[10px] text-shell-ink-muted">
79 {row ? sign(row.kind) : ''}
80 </span>
81 <span className="ml-1 whitespace-pre">{row ? row.text : ''}</span>
82 </td>
83 </tr>
84 ))
85 : splitRows.map((row, index) => (
86 <tr key={index}>
87 {cell(row.left)}
88 {cell(row.right)}
89 </tr>
90 ))}
91 </tbody>
92 </table>
93 </div>
94 )
95}
96
97/** Light/dark preference → the class state, resolving `system` against the OS. */
98function applyMode(theme: string): void {
99 if (theme === 'light' || theme === 'dark') setTheme(theme)
100 else setTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
101}
102
103/**
104 * Palette picker. Choosing one repaints the app immediately and persists the
105 * generated stylesheet as base64 in localStorage (see `shared/lib/palettes`);
106 * the server only stores the light/dark preference.
107 */
108function PalettePicker() {
109 const { t } = useT()
110 const { paletteId, palette, mode, variant, encoded, palettes, setPalette, resetPalette } = useThemeContext()
111 return (
112 <div className="mb-6">
113 <span className="mb-2 block font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
114 {t('userSettings.appearance.themeColor')}
115 </span>
116 <div className="palette-grid" role="radiogroup" aria-label={t('userSettings.appearance.themeColor')}>
117 {palettes.map((item) => {
118 const active = item.id === paletteId
119 return (
120 <button
121 key={item.id}
122 type="button"
123 role="radio"
124 aria-checked={active}
125 onClick={() => setPalette(item.id)}
126 className={`palette-card${active ? ' is-active' : ''}`}
127 title={`${item.name} · ${item.origin}`}
128 >
129 <span className="palette-swatches" aria-hidden>
130 {previewColors(item.id, mode).map((color) => (
131 <i key={color} style={{ backgroundColor: color }} />
132 ))}
133 </span>
134 <span className="palette-name">{item.name}</span>
135 <span className="palette-origin">{item.origin}</span>
136 </button>
137 )
138 })}
139 </div>
140 <p className="palette-meta">
141 <span>
142 {t('userSettings.appearance.currentPalette', {
143 name: palette.name,
144 mode: variant === 'light' ? t('settings.appearance.themeLight') : t('settings.appearance.themeDark'),
145 })}
146 </span>
147 <code title={encoded}>{encoded.slice(0, 32)}…</code>
148 {paletteId !== 'graphite' ? (
149 <button type="button" className="palette-restore" onClick={resetPalette}>
150 {t('userSettings.appearance.resetDefault')}
151 </button>
152 ) : null}
153 </p>
154 </div>
155 )
156}
157
158export function AppearanceSection() {
159 const { t } = useT()
160 const prefs = usePreferences()
161 const { busy, notice, error, run } = useSave()
162 const [form, setForm] = useState<{ theme: string; diff: string; boardView: string } | null>(null)
163 const current =
164 form ??
165 (prefs.data
166 ? {
167 theme: prefs.data.theme_mode || 'system',
168 diff: prefs.data.diff_view_style ?? '',
169 boardView: normalizeBoardView(prefs.data.board_view_mode),
170 }
171 : null)
172 if (prefs.loading || !current)
173 return (
174 <>
175 <SectionTitle title={t('settings.appearance.title')} />
176 <Busy />
177 </>
178 )
179
180 const option = DIFF_STYLE_OPTIONS.find((item) => item.value === current.diff) ?? DIFF_STYLE_OPTIONS[0]
181
182 return (
183 <section>
184 <SectionTitle title={t('settings.appearance.title')} sub={t('settings.appearance.sub')} />
185 <PalettePicker />
186 <Field label={t('settings.appearance.themeMode')}>
187 <select
188 className={inputCls}
189 value={current.theme}
190 onChange={(e) => {
191 applyMode(e.target.value)
192 setForm({ ...current, theme: e.target.value })
193 }}
194 >
195 <option value="system">{t('userSettings.appearance.themeSystem')}</option>
196 <option value="light">{t('settings.appearance.themeLight')}</option>
197 <option value="dark">{t('settings.appearance.themeDark')}</option>
198 </select>
199 </Field>
200 <Field
201 label={t('settings.appearance.diffStyle')}
202 hint={`${t(option.noteKey)} · ${t('settings.appearance.diffHintSuffix')}`}
203 >
204 <select
205 className={inputCls}
206 value={current.diff}
207 onChange={(e) => setForm({ ...current, diff: e.target.value })}
208 >
209 {DIFF_STYLE_OPTIONS.map((item) => (
210 <option key={item.value || 'default'} value={item.value}>
211 {t(item.key)}
212 </option>
213 ))}
214 </select>
215 </Field>
216 <div className="mb-5">
217 <DiffStylePreview style={current.diff === 'split' ? 'split' : 'unified'} />
218 </div>
219 <Field label={t('settings.appearance.boardView')} hint={t('settings.appearance.boardViewHint')}>
220 <select
221 className={inputCls}
222 value={current.boardView}
223 onChange={(e) => setForm({ ...current, boardView: e.target.value })}
224 >
225 {BOARD_VIEW_OPTIONS.map((item) => (
226 <option key={item.value} value={item.value}>
227 {t(item.key)}
228 </option>
229 ))}
230 </select>
231 </Field>
232 <SaveBar
233 busy={busy}
234 notice={notice}
235 error={error}
236 onSave={() =>
237 void run(async () => {
238 await api.updatePreferences({
239 theme_mode: current.theme,
240 diff_view_style: current.diff || null,
241 board_view_mode: current.boardView,
242 })
243 applyMode(current.theme)
244 }, t('settings.appearance.saved'))
245 }
246 />
247 </section>
248 )
249}
Added view/app/src/domains/settings/ui/sections/notifications-section.tsx View view/app/src/domains/settings/ui/sections/notifications-section.tsx
@@ -0,0 +1,235 @@
1import { Alert } from '@app/components'
2import { Busy, Field, inputCls, SaveBar, SectionTitle, Toggle, useSave } from '@app/domains/shared'
3import { useT } from '@app/i18n'
4import { unwrapApi } from '@app/lib'
5import Apis from '@app/services/api'
6import { useCallback, useEffect, useState } from 'react'
7import * as api from '../../model/api'
8import { usePreferences } from '../../model/preferences'
9import {
10 currentSubscription,
11 disablePush,
12 enablePush,
13 PUSH_FLAG,
14 type PushPermission,
15 pushSupport,
16 syncExistingSubscription,
17} from '../../model/push'
18
19/** Page-level fetch of `/push/config`; `null` while loading. */
20function usePushConfig() {
21 const [config, setConfig] = useState<{ enabled: boolean; public_key: string } | null>(null)
22 useEffect(() => {
23 let cancelled = false
24 void (async () => {
25 try {
26 const value = await unwrapApi<{ enabled: boolean; public_key: string }>(Apis.push.push_config())
27 if (!cancelled) setConfig(value ?? { enabled: false, public_key: '' })
28 } catch {
29 // A deployment without push simply hides the block; failing the whole
30 // notifications section over an optional capability would be worse.
31 if (!cancelled) setConfig({ enabled: false, public_key: '' })
32 }
33 })()
34 return () => {
35 cancelled = true
36 }
37 }, [])
38 return config
39}
40
41/**
42 * Browser notification state.
43 *
44 * `permission` is the browser's answer; `subscribed` is whether a live
45 * subscription exists for this device. Both are needed: permission can be
46 * granted while this device was never subscribed (or was unsubscribed
47 * elsewhere), and only the pair tells the truth about the switch.
48 */
49function usePushDevice(config: { enabled: boolean; public_key: string } | null) {
50 // `pushSupport()` is a synchronous feature check, so it is read once as the
51 // initial state instead of being set from an effect (which would cost an
52 // extra render for a value that cannot change after load).
53 const [permission, setPermission] = useState<PushPermission>(() => pushSupport())
54 const [subscribed, setSubscribed] = useState(false)
55 const [busy, setBusy] = useState(false)
56 const [error, setError] = useState(false)
57
58 /** Re-read the browser's subscription state; used after every toggle. */
59 const refresh = useCallback(async () => {
60 try {
61 setSubscribed((await currentSubscription()) !== null)
62 } catch {
63 setSubscribed(false)
64 }
65 }, [])
66
67 useEffect(() => {
68 // The subscription lives in the browser, not in React, so reading it is a
69 // genuine external-system synchronisation. The read is inlined rather than
70 // routed through `refresh` so the linter can see the async boundary; the
71 // cancellation guard drops a state write that would outlive the mount.
72 //
73 // When a live subscription exists the server row is reasserted: the two can
74 // drift apart (a database reset, or a subscription created while the request
75 // failed), and the upsert is keyed on the endpoint so a healthy pair is
76 // simply rewritten. Without this, the switch would read "on" while nothing
77 // was ever deliverable.
78 let cancelled = false
79 void (async () => {
80 try {
81 const subscription = await currentSubscription()
82 if (cancelled) return
83 setSubscribed(subscription !== null)
84 if (subscription) await syncExistingSubscription()
85 } catch {
86 if (!cancelled) setSubscribed(false)
87 }
88 })()
89 return () => {
90 cancelled = true
91 }
92 }, [])
93
94 const toggle = useCallback(
95 async (next: boolean) => {
96 if (!config) return
97 setBusy(true)
98 setError(false)
99 try {
100 if (next) {
101 const result = await enablePush(config.public_key)
102 setPermission(result)
103 } else {
104 // Unsubscribing cannot change the browser's permission, so the state
105 // stays as it is; only the subscription flips.
106 await disablePush()
107 }
108 await refresh()
109 } catch {
110 setError(true)
111 } finally {
112 setBusy(false)
113 }
114 },
115 [config, refresh],
116 )
117
118 return { permission, subscribed, busy, error, toggle }
119}
120
121export function NotificationsSection() {
122 const { t } = useT()
123 const prefs = usePreferences()
124 const { busy, notice, error, run } = useSave()
125 const config = usePushConfig()
126 const pushDevice = usePushDevice(config)
127 const [form, setForm] = useState<{ digest: boolean; mute: string; pushFlags: number } | null>(null)
128 const current =
129 form ??
130 (prefs.data
131 ? {
132 digest: prefs.data.email_digest,
133 mute: prefs.data.mute_until?.slice(0, 16) ?? '',
134 pushFlags: prefs.data.push_flags ?? 0,
135 }
136 : null)
137 if (prefs.loading || !current) {
138 return (
139 <>
140 <SectionTitle title={t('settings.notifications.title')} />
141 <Busy />
142 </>
143 )
144 }
145
146 // `push_flags` is a *disable* mask: a set bit turns that category off, and the
147 // stored default of 0 keeps every category on for accounts that never touched
148 // this page.
149 const flagOn = (bit: number) => (current.pushFlags & bit) === 0
150 const setFlag = (bit: number, on: boolean) =>
151 setForm({ ...current, pushFlags: on ? current.pushFlags & ~bit : current.pushFlags | bit })
152
153 const pushConfigured = config?.enabled === true
154 const pushState = pushDevice.subscribed ? (
155 <p className="mb-4 text-[12.5px] text-shell-ink-muted">{t('settings.notifications.pushGranted')}</p>
156 ) : pushDevice.permission === 'denied' ? (
157 <Alert tone="error">{t('settings.notifications.pushDenied')}</Alert>
158 ) : pushDevice.permission === 'unsupported' ? (
159 <Alert tone="info">{t('settings.notifications.pushUnsupported')}</Alert>
160 ) : null
161
162 return (
163 <section>
164 <SectionTitle title={t('settings.notifications.title')} sub={t('settings.notifications.sub')} />
165
166 {pushConfigured && pushDevice.permission !== 'unsupported' ? (
167 <div className="mb-6">
168 <Toggle
169 checked={pushDevice.subscribed}
170 disabled={pushDevice.busy || pushDevice.permission === 'denied'}
171 onChange={(value) => void pushDevice.toggle(value)}
172 label={t('settings.notifications.pushEnabled')}
173 />
174 {pushState}
175 {pushDevice.error ? <Alert tone="error">{t('settings.notifications.pushFailed')}</Alert> : null}
176 {pushDevice.subscribed ? (
177 <>
178 <p className="mb-2 font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
179 {t('settings.notifications.pushTypes')}
180 </p>
181 <Toggle
182 checked={flagOn(PUSH_FLAG.mention)}
183 onChange={(value) => setFlag(PUSH_FLAG.mention, value)}
184 label={t('settings.notifications.pushMention')}
185 />
186 <Toggle
187 checked={flagOn(PUSH_FLAG.reply)}
188 onChange={(value) => setFlag(PUSH_FLAG.reply, value)}
189 label={t('settings.notifications.pushReply')}
190 />
191 <Toggle
192 checked={flagOn(PUSH_FLAG.dm)}
193 onChange={(value) => setFlag(PUSH_FLAG.dm, value)}
194 label={t('settings.notifications.pushDm')}
195 />
196 <Toggle
197 checked={flagOn(PUSH_FLAG.reminder)}
198 onChange={(value) => setFlag(PUSH_FLAG.reminder, value)}
199 label={t('settings.notifications.pushReminder')}
200 />
201 </>
202 ) : null}
203 </div>
204 ) : null}
205
206 <Toggle
207 checked={current.digest}
208 onChange={(value) => setForm({ ...current, digest: value })}
209 label={t('userSettings.notifications.digest')}
210 />
211 <Field label={t('userSettings.notifications.muteUntil')} hint={t('userSettings.notifications.muteHint')}>
212 <input
213 type="datetime-local"
214 className={inputCls}
215 value={current.mute}
216 onChange={(event) => setForm({ ...current, mute: event.target.value })}
217 />
218 </Field>
219 <SaveBar
220 busy={busy}
221 notice={notice}
222 error={error}
223 onSave={() =>
224 void run(async () => {
225 await api.updatePreferences({
226 email_digest: current.digest,
227 mute_until: current.mute ? new Date(current.mute).toISOString() : null,
228 push_flags: current.pushFlags,
229 })
230 }, t('settings.notifications.saved'))
231 }
232 />
233 </section>
234 )
235}
Added view/app/src/domains/settings/ui/sections/privacy-section.tsx View view/app/src/domains/settings/ui/sections/privacy-section.tsx
@@ -0,0 +1,83 @@
1import { Busy, Field, inputCls, SaveBar, SectionTitle, Toggle, useSave } from '@app/domains/shared'
2import { useT } from '@app/i18n'
3import { useState } from 'react'
4import * as api from '../../model/api'
5import { usePreferences } from '../../model/preferences'
6
7/** Values stay the API's own enum names; only the labels are localized. */
8const AUDIENCE_VALUES = ['Everyone', 'FollowingOnly', 'Nobody'] as const
9const AUDIENCE_KEYS = {
10 Everyone: 'label.audience.everyone',
11 FollowingOnly: 'label.audience.following',
12 Nobody: 'label.audience.nobody',
13} as const
14
15export function PrivacySection() {
16 const { t } = useT()
17 const prefs = usePreferences()
18 const { busy, notice, error, run } = useSave()
19 const [form, setForm] = useState<{ dms: string; mentions: string; online: boolean } | null>(null)
20 const current =
21 form ??
22 (prefs.data
23 ? {
24 dms: prefs.data.allow_dms_from,
25 mentions: prefs.data.allow_mentions_from,
26 online: prefs.data.show_online_status,
27 }
28 : null)
29 if (prefs.loading || !current)
30 return (
31 <>
32 <SectionTitle title={t('settings.privacy.title')} />
33 <Busy />
34 </>
35 )
36
37 return (
38 <section>
39 <SectionTitle title={t('settings.privacy.title')} sub={t('settings.privacy.sub')} />
40 <Field label={t('userSettings.privacy.dms')}>
41 <select className={inputCls} value={current.dms} onChange={(e) => setForm({ ...current, dms: e.target.value })}>
42 {AUDIENCE_VALUES.map((value) => (
43 <option key={value} value={value}>
44 {t(AUDIENCE_KEYS[value])}
45 </option>
46 ))}
47 </select>
48 </Field>
49 <Field label={t('userSettings.privacy.mentions')}>
50 <select
51 className={inputCls}
52 value={current.mentions}
53 onChange={(e) => setForm({ ...current, mentions: e.target.value })}
54 >
55 {AUDIENCE_VALUES.map((value) => (
56 <option key={value} value={value}>
57 {t(AUDIENCE_KEYS[value])}
58 </option>
59 ))}
60 </select>
61 </Field>
62 <Toggle
63 checked={current.online}
64 onChange={(v) => setForm({ ...current, online: v })}
65 label={t('userSettings.privacy.online')}
66 />
67 <SaveBar
68 busy={busy}
69 notice={notice}
70 error={error}
71 onSave={() =>
72 void run(async () => {
73 await api.updatePreferences({
74 allow_dms_from: current.dms as 'Everyone',
75 allow_mentions_from: current.mentions as 'Everyone',
76 show_online_status: current.online,
77 })
78 }, t('settings.privacy.saved'))
79 }
80 />
81 </section>
82 )
83}
Added view/app/src/domains/settings/ui/sections/profile-section.tsx View view/app/src/domains/settings/ui/sections/profile-section.tsx
@@ -0,0 +1,148 @@
1import { Field, inputCls, SaveBar, SectionTitle, Toggle, useSave } from '@app/domains/shared'
2import { type MessageKey, useT } from '@app/i18n'
3import type { ProfileVisibility } from '@app/services/api'
4import { useState } from 'react'
5import * as api from '../../model/api'
6import type { Me } from '../../model/user-settings'
7
8const VISIBILITY_OPTIONS: ReadonlyArray<{ value: string; key: MessageKey }> = [
9 { value: 'Public', key: 'label.visibility.public' },
10 { value: 'RegisteredOnly', key: 'label.visibility.registered' },
11 { value: 'MembersOnly', key: 'label.visibility.members' },
12 { value: 'Private', key: 'label.visibility.private' },
13]
14
15export function ProfileSection({ me, onMeChanged }: { me: Me; onMeChanged: () => void }) {
16 const { t } = useT()
17 const { busy, notice, error, run } = useSave()
18 const profile = me.profile
19 const [form, setForm] = useState({
20 bio: profile?.bio ?? '',
21 website: profile?.website ?? '',
22 company: profile?.company ?? '',
23 location: profile?.location ?? '',
24 pronouns: profile?.pronouns ?? '',
25 realName: profile?.real_name ?? '',
26 dateOfBirth: profile?.date_of_birth?.slice(0, 10) ?? '',
27 visibility: me.user.visibility ?? 'Public',
28 showEmail: profile?.show_email ?? false,
29 showLocation: profile?.show_location ?? false,
30 showRealName: profile?.show_real_name ?? false,
31 showActivity: profile?.show_activity ?? false,
32 })
33 const set = <K extends keyof typeof form>(key: K, value: (typeof form)[K]) =>
34 setForm((prev) => ({ ...prev, [key]: value }))
35
36 return (
37 <section>
38 <SectionTitle title={t('settings.profile.title')} sub={t('settings.profile.sub')} />
39 <Field label={t('userSettings.profile.bio')}>
40 <textarea
41 className="w-full max-w-[560px] resize-none rounded-md border border-shell-hairline bg-shell-field px-3 py-2 text-[13.5px] outline-none"
42 rows={3}
43 maxLength={500}
44 value={form.bio}
45 onChange={(e) => set('bio', e.target.value)}
46 />
47 </Field>
48 <div className="grid max-w-[700px] gap-3 sm:grid-cols-2">
49 <Field label={t('userSettings.profile.website')}>
50 <input
51 className={`${inputCls} max-w-none`}
52 value={form.website}
53 onChange={(e) => set('website', e.target.value)}
54 />
55 </Field>
56 <Field label={t('userSettings.profile.company')}>
57 <input
58 className={`${inputCls} max-w-none`}
59 value={form.company}
60 onChange={(e) => set('company', e.target.value)}
61 />
62 </Field>
63 <Field label={t('userSettings.profile.location')}>
64 <input
65 className={`${inputCls} max-w-none`}
66 value={form.location}
67 onChange={(e) => set('location', e.target.value)}
68 />
69 </Field>
70 <Field label={t('settings.profile.pronouns')}>
71 <input
72 className={`${inputCls} max-w-none`}
73 value={form.pronouns}
74 onChange={(e) => set('pronouns', e.target.value)}
75 />
76 </Field>
77 <Field label={t('settings.profile.realName')}>
78 <input
79 className={`${inputCls} max-w-none`}
80 value={form.realName}
81 onChange={(e) => set('realName', e.target.value)}
82 />
83 </Field>
84 <Field label={t('settings.profile.dateOfBirth')}>
85 <input
86 type="date"
87 className={`${inputCls} max-w-none`}
88 value={form.dateOfBirth}
89 onChange={(e) => set('dateOfBirth', e.target.value)}
90 />
91 </Field>
92 </div>
93 <Field label={t('userSettings.profile.visibility')}>
94 <select
95 className={inputCls}
96 value={form.visibility}
97 onChange={(e) => set('visibility', e.target.value as ProfileVisibility)}
98 >
99 {VISIBILITY_OPTIONS.map((option) => (
100 <option key={option.value} value={option.value}>
101 {t(option.key)}
102 </option>
103 ))}
104 </select>
105 </Field>
106 <Toggle checked={form.showEmail} onChange={(v) => set('showEmail', v)} label={t('settings.profile.showEmail')} />
107 <Toggle
108 checked={form.showLocation}
109 onChange={(v) => set('showLocation', v)}
110 label={t('settings.profile.showLocation')}
111 />
112 <Toggle
113 checked={form.showRealName}
114 onChange={(v) => set('showRealName', v)}
115 label={t('settings.profile.showRealName')}
116 />
117 <Toggle
118 checked={form.showActivity}
119 onChange={(v) => set('showActivity', v)}
120 label={t('settings.profile.showActivity')}
121 />
122 <SaveBar
123 busy={busy}
124 notice={notice}
125 error={error}
126 onSave={() =>
127 void run(async () => {
128 await api.updateProfile({
129 bio: form.bio || null,
130 website: form.website || null,
131 company: form.company || null,
132 location: form.location || null,
133 pronouns: form.pronouns || null,
134 real_name: form.realName || null,
135 date_of_birth: form.dateOfBirth || null,
136 visibility: form.visibility,
137 show_email: form.showEmail,
138 show_location: form.showLocation,
139 show_real_name: form.showRealName,
140 show_activity: form.showActivity,
141 })
142 onMeChanged()
143 }, t('settings.profile.saved'))
144 }
145 />
146 </section>
147 )
148}
Added view/app/src/domains/settings/ui/sections/security-section.tsx View view/app/src/domains/settings/ui/sections/security-section.tsx
@@ -0,0 +1,226 @@
1import { Busy, CopyButton, inputCls, SaveBar, SectionTitle, useSave } from '@app/domains/shared'
2import { useT } from '@app/i18n'
3import { ApiFailure, settingsKey, useAsync } from '@app/lib'
4import { Button } from '@app/ui'
5import { useState } from 'react'
6import * as api from '../../model/api'
7
8export function SecuritySection() {
9 const { t } = useT()
10 const { busy, notice, error, run } = useSave()
11 const [oldPassword, setOldPassword] = useState('')
12 const [newPassword, setNewPassword] = useState('')
13 const [confirmPassword, setConfirmPassword] = useState('')
14 const [statusTick, setStatusTick] = useState(0)
15 const status = useAsync(api.totpStatus, [statusTick], { cacheKey: settingsKey('totp') })
16 const [setup, setSetup] = useState<{ secret: string; otpauth_uri: string } | null>(null)
17 const [enableCode, setEnableCode] = useState('')
18 const [enablePassword, setEnablePassword] = useState('')
19 const [disableCode, setDisableCode] = useState('')
20 const [codes, setCodes] = useState<string[] | null>(null)
21 const [regenPassword, setRegenPassword] = useState('')
22
23 return (
24 <section>
25 <SectionTitle title={t('settings.security.title')} sub={t('userSettings.security.sub')} />
26 <h3 className="mb-3 text-[14px] font-bold">{t('userSettings.security.changePassword')}</h3>
27 <div className="mb-2 max-w-[440px] space-y-3">
28 <input
29 type="password"
30 className={inputCls}
31 placeholder={t('userSettings.security.currentPlaceholder')}
32 value={oldPassword}
33 onChange={(e) => setOldPassword(e.target.value)}
34 />
35 <input
36 type="password"
37 className={inputCls}
38 placeholder={t('userSettings.security.newPlaceholder')}
39 value={newPassword}
40 onChange={(e) => setNewPassword(e.target.value)}
41 />
42 <input
43 type="password"
44 className={inputCls}
45 placeholder={t('userSettings.security.confirmPlaceholder')}
46 value={confirmPassword}
47 onChange={(e) => setConfirmPassword(e.target.value)}
48 />
49 </div>
50 <div className="mb-8">
51 <SaveBar
52 busy={busy}
53 notice={notice}
54 error={error}
55 label={t('userSettings.security.update')}
56 onSave={() => {
57 if (!oldPassword || !newPassword)
58 return void run(async () => {
59 throw new ApiFailure(t('userSettings.security.fillBoth'), 400)
60 })
61 if (newPassword !== confirmPassword)
62 return void run(async () => {
63 throw new ApiFailure(t('userSettings.security.mismatch'), 400)
64 })
65 return void run(async () => {
66 await api.changePassword({ old_password: oldPassword, new_password: newPassword })
67 setOldPassword('')
68 setNewPassword('')
69 setConfirmPassword('')
70 }, t('userSettings.security.passwordUpdated'))
71 }}
72 />
73 </div>
74 <h3 className="mb-3 text-[14px] font-bold">{t('userSettings.security.totpHeading')}</h3>
75 {status.loading ? (
76 <Busy />
77 ) : (
78 <>
79 <p className="mb-3 text-[13px] text-shell-ink-muted">
80 {t('userSettings.security.status')}
81 <span
82 className={
83 status.data?.enabled ? 'ml-1 font-semibold text-[#23a55a]' : 'ml-1 font-semibold text-[#f23f43]'
84 }
85 >
86 {status.data?.enabled ? t('userSettings.security.enabled') : t('userSettings.security.notEnabled')}
87 </span>
88 </p>
89 {!status.data?.enabled && !setup ? (
90 <Button
91 variant="secondary"
92 className="h-9 text-[13px]"
93 onClick={() =>
94 void run(async () => setSetup(await api.totpSetup()), t('userSettings.security.setupHint'))
95 }
96 >
97 {t('userSettings.security.enable')}
98 </Button>
99 ) : null}
100 {setup ? (
101 <div className="settings-card mb-4 max-w-[560px] p-4">
102 <p className="mb-1 font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
103 {t('userSettings.security.secret')}
104 </p>
105 <p className="mb-3 flex items-center gap-2 font-mono text-[14px]">
106 {setup.secret} <CopyButton text={setup.secret} />
107 </p>
108 <p className="mb-1 font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
109 otpauth URI
110 </p>
111 <p className="mb-3 flex items-center gap-2 break-all font-mono text-[11.5px]">
112 {setup.otpauth_uri} <CopyButton text={setup.otpauth_uri} />
113 </p>
114 <div className="space-y-3">
115 <input
116 className={inputCls}
117 placeholder={t('userSettings.security.codePlaceholder')}
118 value={enableCode}
119 onChange={(e) => setEnableCode(e.target.value)}
120 />
121 <input
122 type="password"
123 className={inputCls}
124 placeholder={t('userSettings.security.passwordConfirmPlaceholder')}
125 value={enablePassword}
126 onChange={(e) => setEnablePassword(e.target.value)}
127 />
128 <SaveBar
129 busy={busy}
130 notice={notice}
131 error={error}
132 label={t('userSettings.security.verifyAndEnable')}
133 onSave={() =>
134 void run(async () => {
135 const result = await api.totpEnable(enableCode, enablePassword)
136 setCodes(result.codes)
137 setSetup(null)
138 setStatusTick((t) => t + 1)
139 }, t('userSettings.security.enabledNotice'))
140 }
141 />
142 </div>
143 </div>
144 ) : null}
145 {codes ? (
146 <div className="mb-4 max-w-[560px] rounded-lg border-2 border-[#f0b232]/50 bg-shell-field p-4">
147 <p className="mb-2 text-[12.5px] font-semibold text-warning-foreground">
148 {t('userSettings.security.codesOnce')}
149 </p>
150 <div className="grid grid-cols-2 gap-x-6 gap-y-1 font-mono text-[13px]">
151 {codes.map((code) => (
152 <span key={code}>{code}</span>
153 ))}
154 </div>
155 </div>
156 ) : null}
157 {status.data?.enabled ? (
158 <div className="settings-card max-w-[560px] space-y-3 p-4">
159 <div className="flex gap-2">
160 <Button
161 variant="secondary"
162 className="h-9 text-[13px]"
163 onClick={() =>
164 void run(
165 async () => setCodes((await api.recoveryCodes()).codes),
166 t('userSettings.security.codesLoaded'),
167 )
168 }
169 >
170 {t('userSettings.security.viewCodes')}
171 </Button>
172 <Button
173 variant="secondary"
174 className="h-9 text-[13px]"
175 onClick={() =>
176 void run(async () => {
177 const r = await api.regenerateRecoveryCodes(regenPassword)
178 setCodes(r.codes)
179 }, t('settings.security.codesRegenerated'))
180 }
181 >
182 {t('userSettings.security.regenerate')}
183 </Button>
184 </div>
185 <input
186 type="password"
187 className={inputCls}
188 placeholder={t('userSettings.security.regenPasswordPlaceholder')}
189 value={regenPassword}
190 onChange={(e) => setRegenPassword(e.target.value)}
191 />
192 <div className="flex gap-2">
193 <input
194 className={inputCls}
195 placeholder={t('userSettings.security.code6Placeholder')}
196 value={disableCode}
197 onChange={(e) => setDisableCode(e.target.value)}
198 />
199 <Button
200 className="h-10 bg-[#f23f43] text-[13px] hover:brightness-95"
201 disabled={busy}
202 onClick={() =>
203 void run(async () => {
204 await api.totpDisable(disableCode)
205 setStatusTick((t) => t + 1)
206 setCodes(null)
207 }, t('settings.security.totpDisabled'))
208 }
209 >
210 {t('userSettings.security.disable')}
211 </Button>
212 </div>
213 {codes ? (
214 <div className="grid grid-cols-2 gap-x-6 gap-y-1 font-mono text-[13px]">
215 {codes.map((code) => (
216 <span key={code}>{code}</span>
217 ))}
218 </div>
219 ) : null}
220 </div>
221 ) : null}
222 </>
223 )}
224 </section>
225 )
226}
Added view/app/src/domains/settings/ui/sections/sessions-section.tsx View view/app/src/domains/settings/ui/sections/sessions-section.tsx
@@ -0,0 +1,105 @@
1import { Busy, fmtDate, SaveBar, SectionTitle, useSave } from '@app/domains/shared'
2import { useT } from '@app/i18n'
3import { settingsKey, useAsync } from '@app/lib'
4import { Button } from '@app/ui'
5import { useState } from 'react'
6import * as api from '../../model/api'
7
8export function SessionsSection() {
9 const { t } = useT()
10 const [tick, setTick] = useState(0)
11 const data = useAsync(
12 async () => {
13 const [devices, sessions] = await Promise.all([api.listDevices(), api.listSessions()])
14 return { devices, sessions: sessions.items ?? [] }
15 },
16 [tick],
17 { cacheKey: settingsKey('sessions') },
18 )
19 const { busy, notice, error, run } = useSave()
20 const refresh = () => setTick((t) => t + 1)
21
22 return (
23 <section>
24 <SectionTitle title={t('userSettings.sessions.title')} sub={t('userSettings.sessions.sub')} />
25 {data.loading ? (
26 <Busy />
27 ) : (
28 <>
29 <h3 className="mb-3 text-[14px] font-bold">{t('userSettings.sessions.devices')}</h3>
30 <div className="mb-8 space-y-2">
31 {(data.data?.devices ?? []).length === 0 ? (
32 <p className="text-[13px] text-shell-ink-muted">{t('userSettings.sessions.noDevices')}</p>
33 ) : null}
34 {(data.data?.devices ?? []).map((device) => (
35 <div key={device.id} className="settings-card flex flex-wrap items-center gap-3 px-4 py-3">
36 <span className="min-w-[160px] text-[13.5px] font-semibold">
37 {device.device_name || device.device_type || t('userSettings.sessions.unknownDevice')}
38 </span>
39 <span className="text-[12px] text-shell-ink-muted">
40 {device.os ?? '—'} · {t('userSettings.sessions.recently', { when: fmtDate(device.last_seen_at) })}
41 </span>
42 </div>
43 ))}
44 </div>
45 <h3 className="mb-3 text-[14px] font-bold">{t('userSettings.sessions.sessions')}</h3>
46 <div className="mb-4 space-y-2">
47 {(data.data?.sessions ?? []).length === 0 ? (
48 <p className="text-[13px] text-shell-ink-muted">{t('userSettings.sessions.noSessions')}</p>
49 ) : null}
50 {(data.data?.sessions ?? []).map((session) => (
51 <div key={session.id} className="settings-card flex flex-wrap items-center gap-3 px-4 py-3">
52 <span className="text-[13.5px] font-semibold">
53 {session.kind === 'WebLogin'
54 ? t('userSettings.sessions.kindWeb')
55 : t('userSettings.sessions.kindKey')}
56 </span>
57 {session.is_current ? (
58 <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px]">
59 {t('userSettings.sessions.current')}
60 </span>
61 ) : null}
62 {session.is_revoked ? (
63 <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px]">
64 {t('userSettings.sessions.revoked')}
65 </span>
66 ) : null}
67 <span className="text-[12px] text-shell-ink-muted">
68 {t('userSettings.sessions.createdAt', { when: fmtDate(session.created_at) })} ·{' '}
69 {t('userSettings.sessions.recently', { when: fmtDate(session.last_seen_at) })}
70 </span>
71 {!session.is_current && !session.is_revoked ? (
72 <Button
73 variant="secondary"
74 className="ml-auto h-8 text-[12.5px]"
75 disabled={busy}
76 onClick={() =>
77 void run(async () => {
78 await api.revokeSession(session.id)
79 refresh()
80 }, t('settings.sessions.revoked'))
81 }
82 >
83 {t('userSettings.sessions.revoke')}
84 </Button>
85 ) : null}
86 </div>
87 ))}
88 </div>
89 <SaveBar
90 busy={busy}
91 notice={notice}
92 error={error}
93 label={t('userSettings.sessions.revokeAll')}
94 onSave={() =>
95 void run(async () => {
96 await api.revokeAllSessions()
97 refresh()
98 }, t('settings.account.revoked'))
99 }
100 />
101 </>
102 )}
103 </section>
104 )
105}
Added view/app/src/domains/settings/ui/sections/ssh-keys-section.tsx View view/app/src/domains/settings/ui/sections/ssh-keys-section.tsx
@@ -0,0 +1,190 @@
1import { Alert } from '@app/components'
2import { Busy, CopyButton, FormModal, fmtDate, SectionTitle, useSave } from '@app/domains/shared'
3import { t, useT } from '@app/i18n'
4import { ApiFailure, settingsKey, useAsync } from '@app/lib'
5import { Button } from '@app/ui'
6import { useState } from 'react'
7import * as api from '../../model/api'
8
9/** Public-key types the backend parser accepts (`ssh_key` crate). */
10const SSH_KEY_PREFIXES = ['ssh-rsa', 'ssh-ed25519', 'ecdsa-sha2-nistp256']
11
12/** Paste usually drags hard wraps and a trailing newline along — collapse all
13 * whitespace so `PublicKey::from_openssh` sees the single line it expects. */
14function normalizePublicKey(raw: string): string {
15 return raw.replace(/\s+/g, ' ').trim()
16}
17
18function looksLikePublicKey(content: string): boolean {
19 return SSH_KEY_PREFIXES.some((prefix) => content.startsWith(`${prefix} `))
20}
21
22/** The backend answers these in English; keep the dialog in one language. */
23function sshKeyError(err: unknown): ApiFailure {
24 const code = err instanceof ApiFailure ? err.code : 0
25 const raw = err instanceof ApiFailure ? err.message : ''
26 const mapped =
27 code === 409
28 ? t('userSettings.ssh.duplicate')
29 : code === 404
30 ? t('userSettings.ssh.deleted')
31 : /invalid ssh public key/i.test(raw)
32 ? t('userSettings.ssh.unparsable')
33 : /unsupported ssh key type/i.test(raw)
34 ? t('userSettings.ssh.unsupported')
35 : /name must be between/i.test(raw)
36 ? t('userSettings.ssh.nameLength')
37 : raw
38 return new ApiFailure(mapped || t('ui.actionFailed'), code)
39}
40
41function keyTypeLabel(type: string): string {
42 switch (type) {
43 case 'ssh_rsa':
44 return 'RSA'
45 case 'ssh_ed25519':
46 return 'Ed25519'
47 case 'ecdsa_sha2_nistp256':
48 return 'ECDSA P-256'
49 default:
50 return type
51 }
52}
53
54export function SshKeysSection() {
55 const { t } = useT()
56 const [tick, setTick] = useState(0)
57 const keys = useAsync(api.listSshKeys, [tick], { cacheKey: settingsKey('ssh-keys') })
58 const { busy, notice, error, run } = useSave()
59 const [adding, setAdding] = useState(false)
60 const [flash, setFlash] = useState<string | null>(null)
61 const [pendingDelete, setPendingDelete] = useState<string | null>(null)
62
63 const items = keys.data?.items ?? []
64 const total = keys.data?.total ?? items.length
65
66 return (
67 <section>
68 <SectionTitle title={t('userSettings.ssh.title')} sub={t('userSettings.ssh.sub')} />
69 <div className="mb-5 flex items-center gap-3">
70 <Button
71 onClick={() => {
72 setFlash(null)
73 setAdding(true)
74 }}
75 >
76 {t('userSettings.ssh.add')}
77 </Button>
78 {flash ? <Alert tone="success">{flash}</Alert> : null}
79 {notice ? <Alert tone="success">{notice}</Alert> : null}
80 {error ? <Alert tone="error">{error}</Alert> : null}
81 </div>
82 {keys.loading ? (
83 <Busy />
84 ) : (
85 <>
86 <h3 className="mb-3 text-[14px] font-bold">{t('userSettings.ssh.registered', { count: total })}</h3>
87 <div className="space-y-2">
88 {items.length === 0 ? (
89 <p className="text-[13px] text-shell-ink-muted">{t('userSettings.ssh.none')}</p>
90 ) : null}
91 {items.map((key) => (
92 <div key={key.id} className="settings-card flex flex-wrap items-center gap-3 px-4 py-3">
93 <span className="min-w-[120px] text-[13.5px] font-semibold">{key.name}</span>
94 <span className="rounded-full bg-shell-active px-2 py-0.5 font-mono text-[11px]">
95 {keyTypeLabel(key.algorithm)}
96 </span>
97 <span className="flex min-w-0 items-center gap-1.5 font-mono text-[12px] text-shell-ink-muted">
98 <span className="truncate">{key.fingerprint}</span>
99 <CopyButton text={key.fingerprint} />
100 </span>
101 <span className="text-[12px] text-shell-ink-muted">
102 {t('userSettings.ssh.addedAt', { when: fmtDate(key.created_at) })}
103 </span>
104 <Button
105 variant="secondary"
106 className="ml-auto h-8 text-[12.5px]"
107 disabled={busy}
108 onClick={() => {
109 if (pendingDelete !== key.id) {
110 setPendingDelete(key.id)
111 return
112 }
113 setPendingDelete(null)
114 void run(async () => {
115 try {
116 await api.deleteSshKey(key.id)
117 } catch (err) {
118 throw sshKeyError(err)
119 }
120 setTick((t) => t + 1)
121 }, t('userSettings.ssh.addedNotice'))
122 }}
123 >
124 {pendingDelete === key.id ? t('userSettings.ssh.confirmDelete') : t('common.action.delete')}
125 </Button>
126 </div>
127 ))}
128 </div>
129 </>
130 )}
131 <FormModal
132 title={t('userSettings.ssh.modalTitle')}
133 description={t('userSettings.ssh.modalDescription')}
134 open={adding}
135 wide
136 confirmLabel={t('userSettings.ssh.add')}
137 extra={
138 <div className="settings-card p-3">
139 <p className="mb-1 font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
140 {t('userSettings.ssh.generateHint')}
141 </p>
142 <p className="flex flex-wrap items-center gap-2 break-all font-mono text-[12px]">
143 ssh-keygen -t ed25519 -C "you@example.com"
144 <CopyButton text={'ssh-keygen -t ed25519 -C "you@example.com"'} />
145 </p>
146 <p className="mt-1.5 text-[11.5px] text-shell-ink-muted">
147 {t('userSettings.ssh.privateHint')}
148 <code className="font-mono">~/.ssh/id_ed25519.pub</code>
149 {t('userSettings.ssh.privateHintTail')}
150 </p>
151 </div>
152 }
153 fields={[
154 {
155 key: 'name',
156 kind: 'text',
157 label: t('userSettings.ssh.nameLabel'),
158 placeholder: t('userSettings.ssh.nameExample'),
159 maxLength: 64,
160 hint: t('userSettings.ssh.nameHint'),
161 },
162 {
163 key: 'content',
164 kind: 'textarea',
165 label: t('userSettings.ssh.contentLabel'),
166 rows: 4,
167 mono: true,
168 placeholder: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA… you@example.com',
169 hint: t('userSettings.ssh.contentHint'),
170 },
171 ]}
172 onClose={() => setAdding(false)}
173 onSubmit={async (values) => {
174 const name = String(values.name ?? '').trim()
175 const content = normalizePublicKey(String(values.content ?? ''))
176 if (!content) throw new ApiFailure(t('userSettings.ssh.pasteRequired'), 400)
177 if (!looksLikePublicKey(content)) throw new ApiFailure(t('userSettings.ssh.badFormat'), 400)
178 try {
179 await api.addSshKey(name, content)
180 } catch (err) {
181 throw sshKeyError(err)
182 }
183 setTick((t) => t + 1)
184 setFlash(t('userSettings.ssh.addedFlash', { name }))
185 // Returning void closes the dialog; the list already refetched.
186 }}
187 />
188 </section>
189 )
190}
Added view/app/src/domains/settings/ui/sections/status-section.tsx View view/app/src/domains/settings/ui/sections/status-section.tsx
@@ -0,0 +1,31 @@
1import { Busy, fmtDate, SectionTitle } from '@app/domains/shared'
2import { useT } from '@app/i18n'
3import { settingsKey, useAsync } from '@app/lib'
4import * as api from '../../model/api'
5
6export function StatusSection() {
7 const { t } = useT()
8 const suspensions = useAsync(api.listSuspensions, [], { cacheKey: settingsKey('suspensions') })
9 return (
10 <section>
11 <SectionTitle title={t('settings.status.title')} sub={t('userSettings.status.sub')} />
12 {suspensions.loading ? (
13 <Busy />
14 ) : (
15 <div className="space-y-2">
16 {(suspensions.data ?? []).length === 0 ? (
17 <p className="text-[13px] text-shell-ink-muted">{t('userSettings.status.clean')}</p>
18 ) : null}
19 {(suspensions.data ?? []).map((row) => (
20 <div key={row.id} className="settings-card flex flex-wrap items-center gap-3 px-4 py-3">
21 <span className="text-[13.5px] font-semibold">{row.suspension_type}</span>
22 <span className="text-[12px] text-shell-ink-muted">
23 {fmtDate(row.starts_at)} → {row.ends_at ? fmtDate(row.ends_at) : t('userSettings.status.permanent')}
24 </span>
25 </div>
26 ))}
27 </div>
28 )}
29 </section>
30 )
31}
Added view/app/src/domains/settings/ui/user-settings-modal.tsx View view/app/src/domains/settings/ui/user-settings-modal.tsx
@@ -0,0 +1,78 @@
1import { SettingsModalShell, type SettingsSection } from '@app/domains/shared'
2import { useT } from '@app/i18n'
3import { Activity, Bell, Eye, IdCard, MonitorSmartphone, Palette, ShieldCheck, Terminal, UserRound } from 'lucide-react'
4import { useState } from 'react'
5import type { Me } from '../model/user-settings'
6import { AccountSection } from './sections/account-section'
7import { AppearanceSection } from './sections/appearance-section'
8import { NotificationsSection } from './sections/notifications-section'
9import { PrivacySection } from './sections/privacy-section'
10import { ProfileSection } from './sections/profile-section'
11import { SecuritySection } from './sections/security-section'
12import { SessionsSection } from './sections/sessions-section'
13import { SshKeysSection } from './sections/ssh-keys-section'
14import { StatusSection } from './sections/status-section'
15
16export interface UserSettingsModalProps {
17 open: boolean
18 onClose: () => void
19 me: Me
20 /** Refetch `me` after edits that change identity/profile fields. */
21 onMeChanged: () => void
22}
23
24/** Discord-style full-screen settings overlay covering every user API. */
25export function UserSettingsModal({ open, onClose, me, onMeChanged }: UserSettingsModalProps) {
26 const { t } = useT()
27 const [section, setSection] = useState('account')
28
29 // Built per render so the section list follows the active language.
30 const sections: SettingsSection[] = [
31 { id: 'account', label: t('settings.section.account'), icon: UserRound, group: t('settings.group.account') },
32 { id: 'profile', label: t('settings.section.profile'), icon: IdCard, group: t('settings.group.account') },
33 { id: 'security', label: t('settings.section.security'), icon: ShieldCheck, group: t('settings.group.account') },
34 {
35 id: 'sessions',
36 label: t('settings.section.sessions'),
37 icon: MonitorSmartphone,
38 group: t('settings.group.account'),
39 },
40 { id: 'privacy', label: t('settings.section.privacy'), icon: Eye, group: t('settings.group.preferences') },
41 {
42 id: 'notifications',
43 label: t('settings.section.notifications'),
44 icon: Bell,
45 group: t('settings.group.preferences'),
46 },
47 {
48 id: 'appearance',
49 label: t('settings.section.appearance'),
50 icon: Palette,
51 group: t('settings.group.preferences'),
52 },
53 { id: 'sshKeys', label: t('settings.section.sshKeys'), icon: Terminal, group: t('settings.group.advanced') },
54 { id: 'status', label: t('settings.section.status'), icon: Activity, group: t('settings.group.advanced') },
55 ]
56
57 if (!open) return null
58
59 return (
60 <SettingsModalShell
61 title={t('settings.title')}
62 sections={sections}
63 active={section}
64 onSelect={setSection}
65 onClose={onClose}
66 >
67 {section === 'account' ? <AccountSection me={me} onMeChanged={onMeChanged} /> : null}
68 {section === 'profile' ? <ProfileSection me={me} onMeChanged={onMeChanged} /> : null}
69 {section === 'security' ? <SecuritySection /> : null}
70 {section === 'sessions' ? <SessionsSection /> : null}
71 {section === 'privacy' ? <PrivacySection /> : null}
72 {section === 'notifications' ? <NotificationsSection /> : null}
73 {section === 'appearance' ? <AppearanceSection /> : null}
74 {section === 'sshKeys' ? <SshKeysSection /> : null}
75 {section === 'status' ? <StatusSection /> : null}
76 </SettingsModalShell>
77 )
78}
Added view/app/src/domains/settings/index.ts View view/app/src/domains/settings/index.ts
@@ -0,0 +1,4 @@
1// Public API of features/user-settings — the full-screen user settings
2// overlay and its per-domain endpoints.
3// Consumers import from '@app/domains/settings', never from deeper files.
4export { UserSettingsModal, type UserSettingsModalProps } from './ui/user-settings-modal'