diff --git a/web/src/app/useInvitationNotices.ts b/web/src/app/useInvitationNotices.ts index b674ad1..29b7c77 100644 --- a/web/src/app/useInvitationNotices.ts +++ b/web/src/app/useInvitationNotices.ts @@ -1,27 +1,10 @@ import { useEffect, useRef } from 'react'; -import { accountApi, type AccountInvitation } from '@/core-bridge/account'; -import { errorMessage } from '@/core-bridge/errors'; -import { useChannels } from '@/store/channels'; +import { accountApi } from '@/core-bridge/account'; +import { acceptInvitation, invitationLabel } from '@/store/invitations'; import { useToast } from '@/store/toast'; -import { channelPath, navigate } from './navigation'; const POLL_MS = 8000; -export function invitationLabel(row: AccountInvitation): string { - return row.channelName || `空间 ${row.channel_id.slice(0, 8)}…`; -} - -async function accept(row: AccountInvitation): Promise { - try { - await accountApi.acceptInvitation(row.channel_id); - await useChannels.getState().refresh(); - useToast.getState().push({ kind: 'ok', text: `已加入 ${invitationLabel(row)}` }); - navigate(channelPath(row.channel_id)); - } catch (cause) { - useToast.getState().push({ kind: 'error', text: `加入失败:${errorMessage(cause)}` }); - } -} - /** 登录后轮询账号邀请,出现新邀请就弹通知,可直接接受 */ export function useInvitationNotices(username: string | null): void { const seen = useRef | null>(null); @@ -37,7 +20,7 @@ export function useInvitationNotices(username: string | null): void { const fresh = known ? rows.filter((row) => !known.has(row.channel_id)) : rows; for (const row of fresh) { useToast.getState().push({ kind: 'info', text: row.invitedBy ? `${row.invitedBy} 邀请你加入 ${invitationLabel(row)}` : `你收到了加入 ${invitationLabel(row)} 的邀请`, - action: { label: '接受', run: () => { void accept(row); } } }); + action: { label: '接受', run: () => { void acceptInvitation(row); } } }); } } catch { /* 下一轮再试,不打扰用户 */ } } diff --git a/web/src/features/home/AccountDevices.tsx b/web/src/features/home/AccountDevices.tsx deleted file mode 100644 index 59bf2a0..0000000 --- a/web/src/features/home/AccountDevices.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useEffect, useState } from 'react'; -import { accountApi, type AccountDevice } from '@/core-bridge/account'; -import { useAccount } from '@/store/account'; -import { Button } from '@/ui/Button'; -import styles from './AccountPanel.module.css'; - -export function AccountDevices() { - const [devices, setDevices] = useState([]); - const [names, setNames] = useState>({}); - const [error, setError] = useState(''); - const [busy, setBusy] = useState(false); - const refreshAccount = useAccount((s) => s.refresh); - async function refresh() { setDevices(await accountApi.devices()); } - useEffect(() => { void refresh().catch(() => setError('设备清单读取失败')); }, []); - async function change(device: AccountDevice, revoke: boolean) { - setBusy(true); setError(''); - try { - if (revoke) { - await accountApi.revokeDevice(device.id); await refreshAccount(); - } else await accountApi.renameDevice(device.id, names[device.id] ?? device.name); - await refresh(); - } catch (cause) { setError(cause instanceof Error ? cause.message : '设备更新失败'); } - finally { setBusy(false); } - } - return
-
我的设备 - -
-

同一账号可登录多台设备。移除设备只会撤销它的登录,不影响账号在各空间里的成员身份。

- {devices.map((device) =>
- -
- - -
-
)} - {error ?

{error}

: null} -
; -} diff --git a/web/src/features/home/AccountDock.tsx b/web/src/features/home/AccountDock.tsx index a5f4998..bd801d8 100644 --- a/web/src/features/home/AccountDock.tsx +++ b/web/src/features/home/AccountDock.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useAccount } from '@/store/account'; import { Button } from '@/ui/Button'; import { Sheet } from '@/ui/Sheet'; -import { AccountPanel } from './AccountPanel'; +import { AccountSettings } from './AccountSettings'; import styles from './AccountDock.module.css'; export function AccountDock() { @@ -24,6 +24,6 @@ export function AccountDock() { - - {remembered ? : null} - -

{remembered ? '已开启本机记住登录。' : '仅本次登录,刷新或关闭页面后需重新登录。'}

- - ) : ( + {account ? null : (
{ void submit(event); }}> {variant === 'auth' && !unlockUsername ?
- +
: null} {unlockUsername ?

已用Passkey确认账号,请输入账号密码解锁资源。

: null} + {creating ? : null} + {creating ?

密码至少 12 个字符。密码用于在本机加密你的资源保险库,忘记后无法找回,请妥善保管。

: null}
- + {variant !== 'auth' ? : null} {saved ? : null} {!register && !unlockUsername ? )} +
+ +
item.id === tab)?.label}> + {tab === 'profile' ? : null} + {tab === 'devices' ? : null} + {tab === 'security' ? : null} + {tab === 'invites' ? { void refreshInvitations(); }} /> : null} + {tab === 'sync' ? : null} +
+ ; +} diff --git a/web/src/features/home/DeviceSettings.tsx b/web/src/features/home/DeviceSettings.tsx new file mode 100644 index 0000000..edee36d --- /dev/null +++ b/web/src/features/home/DeviceSettings.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState, type KeyboardEvent } from 'react'; +import { accountApi, type AccountDevice } from '@/core-bridge/account'; +import { errorMessage } from '@/core-bridge/errors'; +import { formatDate, formatAgo } from '@/lib/format'; +import { useAccount } from '@/store/account'; +import { useToast } from '@/store/toast'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import styles from './AccountSettings.module.css'; + +/** last_seen 是 Unix 秒;一天内显示「N 分钟前」,更早显示日期 */ +function seenLabel(seconds: number): string { + const ts = seconds < 1e12 ? seconds * 1000 : seconds; + return Date.now() - ts < 24 * 3600_000 ? formatAgo(ts) : formatDate(ts); +} + +export function DeviceSettings() { + const refreshAccount = useAccount((s) => s.refresh); + const [devices, setDevices] = useState([]); + const [loaded, setLoaded] = useState(false); + const [editing, setEditing] = useState(); + const [draft, setDraft] = useState(''); + const [busy, setBusy] = useState(false); + const toast = useToast.getState().push; + + async function refresh() { + try { setDevices(await accountApi.devices()); } catch { toast({ kind: 'error', text: '设备清单读取失败' }); } + finally { setLoaded(true); } + } + useEffect(() => { void refresh(); }, []); + + async function run(action: () => Promise, ok: string) { + setBusy(true); + try { await action(); toast({ kind: 'ok', text: ok }); await refresh(); } + catch (cause) { toast({ kind: 'error', text: `设备更新失败:${errorMessage(cause)}` }); } + finally { setBusy(false); } + } + function begin(device: AccountDevice) { setEditing(device.id); setDraft(device.name); } + function save(device: AccountDevice) { + const name = draft.trim(); + setEditing(undefined); + if (!name || name === device.name) return; + void run(() => accountApi.renameDevice(device.id, name), '设备名称已更新'); + } + function keyDown(event: KeyboardEvent, device: AccountDevice) { + if (event.key === 'Enter') { event.preventDefault(); save(device); } + if (event.key === 'Escape') setEditing(undefined); + } + function remove(device: AccountDevice) { + if (!window.confirm(`移除设备「${device.name}」并撤销它的登录?`)) return; + void run(async () => { await accountApi.revokeDevice(device.id); await refreshAccount(); }, '设备已移除'); + } + + return
+

我的设备

+

同一账号可登录多台设备。移除设备只会撤销它的登录,不影响账号在各空间里的成员身份。

+
    + {devices.map((device) =>
  • + +
    + {editing === device.id + ? setDraft(event.target.value)} onBlur={() => save(device)} onKeyDown={(event) => keyDown(event, device)} /> + : {device.name}{device.current ? 本机 : null}} + {device.current ? '正在使用' : `最近使用 ${seenLabel(device.last_seen)}`} +
    +
  • )} +
+ {loaded && !devices.length ?

暂无设备。

: null} +
; +} diff --git a/web/src/features/home/Home.module.css b/web/src/features/home/Home.module.css index cc04a17..85318c1 100644 --- a/web/src/features/home/Home.module.css +++ b/web/src/features/home/Home.module.css @@ -41,43 +41,85 @@ .dashboard { display: grid; - grid-template-columns: minmax(0, 1fr); + grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); align-items: start; - gap: var(--space-6); - width: min(100%, 1480px); + row-gap: var(--space-6); + width: min(100%, 1180px); max-width: none; padding: 32px var(--space-6) 96px; } -.dashboard.hasShares { - grid-template-columns: minmax(0, 1.12fr) minmax(360px, 0.88fr); -} - .dashboardHead { grid-column: 1 / -1; padding: 0; text-align: left; } -.primaryColumn { +/* 便捷分享 / 空间:两个并列的分区,中间一条细线 */ +.zone { display: flex; flex-direction: column; - gap: var(--space-6); + gap: var(--space-4); min-width: 0; + padding-inline: var(--space-6); } -.dashboard:not(.hasShares) .primaryColumn { - width: 100%; - max-width: 1120px; - margin-inline: auto; +.zone:first-of-type { + padding-inline-start: 0; } -.secondaryColumn { - min-width: 0; - padding-inline-start: var(--space-4); +.zone + .zone { + padding-inline-end: 0; border-inline-start: 1px solid var(--line); } +.zoneHead { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.zoneIcon { + display: grid; + flex: 0 0 36px; + place-items: center; + width: 36px; + height: 36px; + border-radius: var(--radius-md); + background: var(--surface-2); + color: var(--text-dim); +} + +.zoneTitle { + margin: 0; + font-size: 18px; +} + +.zoneHint { + margin: 2px 0 0; + color: var(--text-dim); + font-size: 13px; +} + +.zoneBadge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + margin-inline-start: auto; + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + background: var(--ok-soft); + color: var(--ok); + font-size: 12px; + white-space: nowrap; +} + +.empty { + margin: 0; + color: var(--text-dim); + font-size: 13px; +} + .head { padding: var(--space-4) 0 var(--space-2); text-align: center; @@ -108,25 +150,6 @@ font-size: 14px; } -.channels { - display: flex; - flex-direction: column; - gap: var(--space-3); - padding-top: var(--space-4); - border-top: 1px solid var(--line); -} - -.sectionHead h2 { - margin: 0; - font-size: 18px; -} - -.sectionHead p { - margin: var(--space-1) 0 0; - color: var(--text-dim); - font-size: 13px; -} - .actions { display: flex; gap: var(--space-2); @@ -136,19 +159,11 @@ flex: 1; } -.note { - margin: var(--space-1) 0 0; - color: var(--text-dim); - font-size: 12px; - text-align: center; -} - .authMain { display: grid; - grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr); + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; - gap: clamp(40px, 7vw, 96px); - width: min(100%, 1120px); + width: min(100%, 1040px); max-width: none; min-height: 100%; padding: 48px 32px; @@ -157,25 +172,38 @@ .authIntro { display: flex; flex-direction: column; - align-items: flex-start; - gap: var(--space-4); - padding-inline-start: var(--space-4); + align-items: flex-end; + gap: var(--space-3); + padding-inline-end: clamp(32px, 7vw, 96px); + text-align: right; } .authLogo { margin: 0; - font-size: clamp(40px, 4vw, 56px); - font-weight: 650; - letter-spacing: -1.5px; - line-height: 1.1; + font-size: clamp(44px, 5vw, 64px); + font-weight: 600; + letter-spacing: -2px; + line-height: 1.05; } .authIntro p { - max-width: 34ch; + max-width: 28ch; margin: 0; color: var(--text-dim); - font-size: 16px; - line-height: 1.7; + font-size: 14px; + line-height: 1.8; +} + +/* 右侧表单与左侧品牌之间一条细线分隔 */ +.authMain > section { + min-height: 360px; + padding-inline-start: clamp(32px, 7vw, 96px); + border-inline-start: 1px solid var(--line); +} + +.authMain > section > * { + width: 100%; + max-width: 380px; } @media (width >= 900px) { @@ -187,10 +215,6 @@ padding-top: 32px; } - .authMain { - padding-top: 48px; - } - .head { padding-bottom: var(--space-4); } @@ -202,46 +226,46 @@ width: min(100%, 760px); } - .dashboard.hasShares { - grid-template-columns: minmax(0, 1fr); - } - .dashboardHead { grid-column: auto; } - .secondaryColumn { - padding: var(--space-4) 0 0; + .zone { + padding-inline: 0; + } + + .zone + .zone { + padding-top: var(--space-6); border-top: 1px solid var(--line); border-inline-start: 0; } } @media (width <= 760px) { + .authMain { + grid-template-columns: minmax(0, 1fr); + gap: var(--space-6); + width: min(100%, 520px); + padding: 72px var(--space-4) var(--space-6); + } + + .authIntro { + align-items: flex-start; + padding-inline-end: 0; + text-align: left; + } + + .authMain > section { + min-height: 0; + padding-inline-start: 0; + border-inline-start: 0; + } + .dashboard { gap: var(--space-4); padding: var(--space-6) var(--space-3) 88px; } - .authMain { - grid-template-columns: minmax(0, 1fr); - gap: var(--space-6); - width: min(100%, 560px); - padding: 72px var(--space-4) var(--space-6); - } - - .authIntro { - gap: var(--space-2); - padding-inline: var(--space-2); - } - - .authLogo { - font-size: 40px; - } - - .authIntro p { - font-size: 14px; - } } .error { diff --git a/web/src/features/home/InvitationInbox.tsx b/web/src/features/home/InvitationInbox.tsx index 8c0c92e..c763951 100644 --- a/web/src/features/home/InvitationInbox.tsx +++ b/web/src/features/home/InvitationInbox.tsx @@ -1,27 +1,33 @@ -import { useEffect, useState } from 'react'; -import { accountApi, type AccountInvitation } from '@/core-bridge/account'; -import { useChannels } from '@/store/channels'; +import { useState } from 'react'; +import type { AccountInvitation } from '@/core-bridge/account'; +import { acceptInvitation, invitationLabel } from '@/store/invitations'; +import { formatDate } from '@/lib/format'; import { Button } from '@/ui/Button'; -import { channelPath, navigate } from '@/app/navigation'; -import styles from './AccountPanel.module.css'; -export function InvitationInbox() { - const [invitations, setInvitations] = useState([]); - const [error, setError] = useState(''); +import { Icon } from '@/ui/Icon'; +import styles from './AccountSettings.module.css'; + +export function InvitationInbox({ invitations, onChange }: { invitations: AccountInvitation[]; onChange: () => void }) { const [busy, setBusy] = useState(); - useEffect(() => { void accountApi.invitations().then(setInvitations).catch(() => setError('读取邀请失败')); }, []); - async function accept(id: string) { - setBusy(id); setError(''); - try { await accountApi.acceptInvitation(id); await useChannels.getState().refresh(); navigate(channelPath(id)); } - catch (cause) { setError(cause instanceof Error ? cause.message : '加入失败'); } - finally { setBusy(undefined); } + + async function accept(row: AccountInvitation) { + setBusy(row.channel_id); + try { await acceptInvitation(row); } finally { setBusy(undefined); onChange(); } } - return
-
空间邀请 - -
- {invitations.length === 0 ?

暂无邀请。

: invitations.map((row) =>
- {row.channelName || `空间 ${row.channel_id.slice(0, 8)}…`}{row.invitedBy ? ` · 邀请人 ${row.invitedBy}` : ''} -
)} - {error ?

{error}

: null} -
; + + return
+

空间邀请

+

其他账号邀请你加入的空间。接受后会进入该空间,并按空间设置获得权限。

+
    + {invitations.map((row) =>
  • + +
    + {invitationLabel(row)} + {row.invitedBy ? `${row.invitedBy} 邀请 · ` : ''}{formatDate(row.created_at < 1e12 ? row.created_at * 1000 : row.created_at)} +
    + +
  • )} +
+ {!invitations.length ?

暂无待处理的邀请。

: null} +
+
; } diff --git a/web/src/features/home/PasskeySettings.tsx b/web/src/features/home/PasskeySettings.tsx deleted file mode 100644 index 3430d70..0000000 --- a/web/src/features/home/PasskeySettings.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useEffect, useState, type FormEvent } from 'react'; -import { bindPasskey, passkeyApi, passkeysAvailable } from '@/core-bridge/passkeys'; -import type { PasskeyInfoBody } from '@/core-bridge/generated/api'; -import { Button } from '@/ui/Button'; -import styles from './AccountPanel.module.css'; - -export function PasskeySettings() { - const [items, setItems] = useState([]); - const [name, setName] = useState('我的Passkey'); - const [password, setPassword] = useState(''); - const [busy, setBusy] = useState(false); - const [message, setMessage] = useState(''); - useEffect(() => { let active = true; void passkeyApi.list().then(value => { if (active) setItems(value); }).catch(() => { if (active) setMessage('Passkey列表读取失败'); }); return () => { active = false; }; }, []); - async function bind(event: FormEvent) { - event.preventDefault(); if (busy) return; - setBusy(true); setMessage(''); - try { await bindPasskey(password, name); setItems(await passkeyApi.list()); setMessage('Passkey已绑定。支持恢复的凭据可在其他设备上解锁账号。'); } - catch (error) { setMessage(error instanceof Error && /[\u4e00-\u9fff]/.test(error.message) ? error.message : 'Passkey操作已取消或不可用,请重试'); } - finally { setPassword(''); setBusy(false); } - } - async function remove(id: string) { - setBusy(true); setMessage(''); - try { await passkeyApi.remove(id); setItems(await passkeyApi.list()); setMessage('Passkey已撤销'); } - catch { setMessage('Passkey撤销失败'); } finally { setBusy(false); } - } - return
-

Passkey

-

可使用设备解锁或密码管理器登录。支持解密恢复的Passkey可在忘记密码时解锁资源;仅登录的Passkey仍需账号密码解密。

- {items.map(item =>
{item.name} · {item.can_recover ? '登录与解密恢复' : '仅登录'} -
)} - { void bind(event); }}> - - - - - {!passkeysAvailable() ?

当前环境不支持Passkey,请使用支持的浏览器和HTTPS或localhost地址。

: null} - {message ?

{message}

: null} -
; -} diff --git a/web/src/features/home/PersonalSettings.tsx b/web/src/features/home/PersonalSettings.tsx deleted file mode 100644 index 6b78e01..0000000 --- a/web/src/features/home/PersonalSettings.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect, useRef, useState, type FormEvent } from 'react'; -import { useAccount } from '@/store/account'; -import { Button } from '@/ui/Button'; -import styles from './AccountPanel.module.css'; - -export function PersonalSettings() { - const personal = useAccount((s) => s.personal); - const username = useAccount((s) => s.username); - const busy = useAccount((s) => s.busy); - const update = useAccount((s) => s.updatePersonal); - const fileInput = useRef(null); - const [name, setName] = useState(personal?.displayName ?? ''); - const [avatar, setAvatar] = useState(personal?.avatar); - const [message, setMessage] = useState(''); - useEffect(() => { setName(personal?.displayName ?? ''); setAvatar(personal?.avatar); }, [personal?.displayName, personal?.avatar]); - async function choose(file?: File) { - if (!file) return; - setMessage(''); - try { - if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > 250_000) throw new Error('请选择 250 KB 以内的 PNG、JPEG 或 WebP 图片'); - const bitmap = await createImageBitmap(file); - const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 4096 && bitmap.height <= 4096; - bitmap.close(); if (!valid) throw new Error('头像宽高不能超过 4096 像素'); - const value = await new Promise((resolve, reject) => { - const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(new Error('头像读取失败')); reader.readAsDataURL(file); - }); - setAvatar(value); - } catch (cause) { setMessage(cause instanceof Error ? cause.message : '头像读取失败'); } - } - async function submit(event: FormEvent) { - event.preventDefault(); setMessage(''); - try { await update(name, avatar); setMessage('个人资料已保存并同步。'); } - catch (cause) { setMessage(cause instanceof Error ? cause.message : '保存失败'); } - } - return
{ void submit(event); }} aria-label="个人资料"> - -
- 头像 - { void choose(e.target.files?.[0]); e.target.value = ''; }} /> -
- - PNG、JPEG 或 WebP,最大 250 KB -
-
-
{avatar ? 个人头像 : null} - - {avatar ? : null} -
-

用户名是固定的账号身份。个人名称与头像由服务端保存并在设备间同步,设备名称单独管理。

- {message ?

{message}

: null} -
; -} diff --git a/web/src/features/home/ProfileSettings.tsx b/web/src/features/home/ProfileSettings.tsx new file mode 100644 index 0000000..52326ae --- /dev/null +++ b/web/src/features/home/ProfileSettings.tsx @@ -0,0 +1,72 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react'; +import { errorMessage } from '@/core-bridge/errors'; +import { useAccount } from '@/store/account'; +import { useToast } from '@/store/toast'; +import { Button } from '@/ui/Button'; +import styles from './AccountSettings.module.css'; + +const MAX_AVATAR_BYTES = 250_000; + +async function readAvatar(file: File): Promise { + if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > MAX_AVATAR_BYTES) throw new Error('请选择 250 KB 以内的 PNG、JPEG 或 WebP 图片'); + const bitmap = await createImageBitmap(file); + const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 4096 && bitmap.height <= 4096; + bitmap.close(); + if (!valid) throw new Error('头像宽高不能超过 4096 像素'); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(new Error('头像读取失败')); + reader.readAsDataURL(file); + }); +} + +export function ProfileSettings() { + const personal = useAccount((s) => s.personal); + const username = useAccount((s) => s.username) ?? ''; + const busy = useAccount((s) => s.busy); + const update = useAccount((s) => s.updatePersonal); + const fileInput = useRef(null); + const [name, setName] = useState(personal?.displayName ?? ''); + const [avatar, setAvatar] = useState(personal?.avatar); + useEffect(() => { setName(personal?.displayName ?? ''); setAvatar(personal?.avatar); }, [personal?.displayName, personal?.avatar]); + + const changed = name.trim() !== (personal?.displayName ?? '') || avatar !== personal?.avatar; + const shown = name.trim() || username; + + async function choose(file?: File) { + if (!file) return; + try { setAvatar(await readAvatar(file)); } + catch (cause) { useToast.getState().push({ kind: 'error', text: errorMessage(cause) }); } + } + async function submit(event: FormEvent) { + event.preventDefault(); + if (!changed || busy) return; + try { await update(name.trim(), avatar); useToast.getState().push({ kind: 'ok', text: '个人资料已保存并同步' }); } + catch (cause) { useToast.getState().push({ kind: 'error', text: `保存失败:${errorMessage(cause)}` }); } + } + + return
{ void submit(event); }} aria-label="个人资料"> +

个人资料

+
+ {avatar ? 个人头像 + : } +
+ { void choose(event.target.files?.[0]); event.target.value = ''; }} /> +
+ + {avatar ? : null} +
+ PNG、JPEG 或 WebP,最大 250 KB +
+
+ +
用户名 + {username} + 用户名是固定的账号身份,不能修改。个人名称与头像由服务端保存,在你的所有设备上同步。 +
+
+
; +} diff --git a/web/src/features/home/SecuritySettings.tsx b/web/src/features/home/SecuritySettings.tsx new file mode 100644 index 0000000..742c15b --- /dev/null +++ b/web/src/features/home/SecuritySettings.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { bindPasskey, passkeyApi, passkeysAvailable } from '@/core-bridge/passkeys'; +import type { PasskeyInfoBody } from '@/core-bridge/generated/api'; +import { formatDate } from '@/lib/format'; +import { useToast } from '@/store/toast'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import styles from './AccountSettings.module.css'; + +export function SecuritySettings() { + const [items, setItems] = useState([]); + const [adding, setAdding] = useState(false); + const [name, setName] = useState('我的 Passkey'); + const [password, setPassword] = useState(''); + const [busy, setBusy] = useState(false); + const available = passkeysAvailable(); + const toast = useToast.getState().push; + + useEffect(() => { + let active = true; + void passkeyApi.list().then((value) => { if (active) setItems(value); }).catch(() => toast({ kind: 'error', text: 'Passkey 列表读取失败' })); + return () => { active = false; }; + }, [toast]); + + async function bind(event: FormEvent) { + event.preventDefault(); + if (busy) return; + setBusy(true); + try { + await bindPasskey(password, name); + setItems(await passkeyApi.list()); + setAdding(false); + toast({ kind: 'ok', text: 'Passkey 已绑定' }); + } catch (error) { + toast({ kind: 'error', text: error instanceof Error && /[一-鿿]/.test(error.message) ? error.message : 'Passkey 操作已取消或不可用,请重试' }); + } finally { setPassword(''); setBusy(false); } + } + async function remove(item: PasskeyInfoBody) { + if (!window.confirm(`撤销 Passkey「${item.name}」?`)) return; + setBusy(true); + try { await passkeyApi.remove(item.id); setItems(await passkeyApi.list()); toast({ kind: 'ok', text: 'Passkey 已撤销' }); } + catch { toast({ kind: 'error', text: 'Passkey 撤销失败' }); } + finally { setBusy(false); } + } + + return
+

安全

+

Passkey

+

可用设备解锁或密码管理器登录。支持解密恢复的 Passkey 能在忘记密码时解锁资源;仅登录的 Passkey 仍需账号密码解密。忘记密码且没有可恢复的 Passkey 时,资源无法找回。

+
    + {items.map((item) =>
  • + +
    {item.name}{item.can_recover ? '登录与解密恢复' : '仅登录'} · 添加于 {formatDate(item.created_at * (item.created_at < 1e12 ? 1000 : 1))}
    +
  • )} +
+ {!items.length ?

还没有绑定 Passkey。

: null} + {adding ?
{ void bind(event); }}> + + +
+ + +
+
:
} + {!available ?

当前环境不支持 Passkey,请使用支持的浏览器,并通过 HTTPS 或 localhost 访问。

: null} +
; +} diff --git a/web/src/features/home/SyncSettings.tsx b/web/src/features/home/SyncSettings.tsx new file mode 100644 index 0000000..4172470 --- /dev/null +++ b/web/src/features/home/SyncSettings.tsx @@ -0,0 +1,43 @@ +import { navigate } from '@/app/navigation'; +import { errorMessage } from '@/core-bridge/errors'; +import { useAccount } from '@/store/account'; +import { useToast } from '@/store/toast'; +import { Button } from '@/ui/Button'; +import styles from './AccountSettings.module.css'; + +export function SyncSettings() { + const busy = useAccount((s) => s.busy); + const remembered = useAccount((s) => s.remembered); + const sync = useAccount((s) => s.sync); + const logout = useAccount((s) => s.logout); + const clearSavedLogin = useAccount((s) => s.clearSavedLogin); + const toast = useToast.getState().push; + + async function syncNow() { + try { await sync(); toast({ kind: 'ok', text: '资源保险库已同步' }); } + catch (cause) { toast({ kind: 'error', text: `同步失败:${errorMessage(cause)}` }); } + } + async function clearSaved() { + if (!window.confirm('清除本机保存的账号和密码?当前页面仍可使用,刷新后需要重新登录。')) return; + try { await clearSavedLogin(); toast({ kind: 'ok', text: '已清除本机保存的登录信息' }); } + catch (cause) { toast({ kind: 'error', text: errorMessage(cause) }); } + } + async function signOut() { + if (!window.confirm('退出登录?')) return; + await logout(); + navigate('/'); + } + + return
+

同步与登录

+
+
资源保险库空间与分享的访问凭据在本机加密后同步,换设备登录即可恢复。
+ +
+
+
本机记住登录{remembered ? '已开启:刷新页面后自动恢复登录。只在自己的设备上使用。' : '未开启:刷新或关闭页面后需要重新登录。'}
+ {remembered ? : null} +
+
+
; +} diff --git a/web/src/store/invitations.ts b/web/src/store/invitations.ts new file mode 100644 index 0000000..b8e8e03 --- /dev/null +++ b/web/src/store/invitations.ts @@ -0,0 +1,21 @@ +import { accountApi, type AccountInvitation } from '@/core-bridge/account'; +import { errorMessage } from '@/core-bridge/errors'; +import { channelPath, navigate } from '@/app/navigation'; +import { useChannels } from './channels'; +import { useToast } from './toast'; + +export function invitationLabel(row: AccountInvitation): string { + return row.channelName || `空间 ${row.channel_id.slice(0, 8)}…`; +} + +/** 接受邀请并进入空间;结果用 toast 反馈,不抛出 */ +export async function acceptInvitation(row: AccountInvitation): Promise { + try { + await accountApi.acceptInvitation(row.channel_id); + await useChannels.getState().refresh(); + useToast.getState().push({ kind: 'ok', text: `已加入 ${invitationLabel(row)}` }); + navigate(channelPath(row.channel_id)); + } catch (cause) { + useToast.getState().push({ kind: 'error', text: `加入失败:${errorMessage(cause)}` }); + } +}