fix(ui-settings-account): report clipboard failures without failing sign in

This commit is contained in:
lsdsjy committed 2026-09-21 11:32:33 +08:00
1 parent 31cce49fff
commit b6594005f7
8 files changed
+51 -16

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md
README.md: 12de952e0764db83bcd1963d07488c87625afa45
README.zh.md: 235a48fab8801f8082a841c9e863f385abfa0e91
README.md: 99053a5ffb5e85bb9754a1d2b00dffbadbaf51da
README.zh.md: aa89bce650374e5f2de15277cd53c0eb9cf8bc38
@@ -44,7 +44,7 @@ The Account section appears first in Settings only while signed in; it is hidden
The [credentials subsystem](../../../docs/subsystems/credentials.md) owns storage APIs; the [architecture](../../../docs/architecture.md) explains application composition.
Account login uses a dismissible dialog before the model onboarding credential editor. The dialog uses a compact, right-aligned action row with the primary action last. The sidebar displays the profile name, falling back to the server-masked phone number or email when the name is absent; it stays blank while the profile is loading. Dialog copy and the sidebar account label are not text-selectable; the copy-link button remains available and restores its label two seconds after each successful copy. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator. Browser login preopens a tab on the click and navigates it only when that attempt receives its authorization URL; blocked popups retain the copy-link route. The original tab retains the authorization window handle and closes it on failure, timeout, or cancellation. Login feedback stays in the original tab; callbacks never navigate to a second Web UI.
Account login uses a dismissible dialog before the model onboarding credential editor. The dialog uses a compact, right-aligned action row with the primary action last. The sidebar displays the profile name, falling back to the server-masked phone number or email when the name is absent; it stays blank while the profile is loading. Dialog copy and the sidebar account label are not text-selectable; the copy-link button remains available and restores its label two seconds after each successful copy. Clipboard failures show Copy failed on the link for two seconds without interrupting sign-in. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator. Browser login preopens a tab on the click and navigates it only when that attempt receives its authorization URL; blocked popups retain the copy-link route. The original tab retains the authorization window handle and closes it on failure, timeout, or cancellation. Login feedback stays in the original tab; callbacks never navigate to a second Web UI.
The balance card shows recharge funds and positive bonus credit in separate rows. Empty or nonpositive bonus wallets hide the bonus row and its divider; currencies retain their own amounts.
@@ -44,7 +44,7 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl
[凭证子系统](../../../docs/subsystems/credentials.zh.md)定义存储接口;[架构](../../../docs/architecture.zh.md)说明应用组合。
账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。侧边栏优先显示昵称,缺少昵称时显示服务端返回的脱敏手机号或邮箱;资料加载期间保持留空。 弹窗文字和侧边栏账号状态文字不可选中,复制登录链接按钮始终可用,并在每次复制成功两秒后恢复原文案。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。浏览器登录在点击时预先打开标签页,只有该次流程收到授权地址后才导航;弹窗被拦截时保留复制链接入口。原标签页保留授权窗口引用,并在失败、超时或取消时关闭它。登录反馈留在原标签页,回调不会导航到第二个 Web UI。
账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。侧边栏优先显示昵称,缺少昵称时显示服务端返回的脱敏手机号或邮箱;资料加载期间保持留空。 弹窗文字和侧边栏账号状态文字不可选中,复制登录链接按钮始终可用,并在每次复制成功两秒后恢复原文案。剪贴板失败时,链接原位显示“复制失败”两秒,不中断登录。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。浏览器登录在点击时预先打开标签页,只有该次流程收到授权地址后才导航;弹窗被拦截时保留复制链接入口。原标签页保留授权窗口引用,并在失败、超时或取消时关闭它。登录反馈留在原标签页,回调不会导航到第二个 Web UI。
余额卡片分行展示充值余额与正额赠金余额。赠送钱包为空或没有正余额时,隐藏赠金行及其分隔线;不同币种分别显示。
@@ -17,14 +17,14 @@ export function SignInDialog({ account, start, cancel, close, useApiKey, t }: {
}) {
const [busy, setBusy] = useState(false)
const [failed, setFailed] = useState(false)
const [copyCount, setCopyCount] = useState(0)
const [copyResult, setCopyResult] = useState<{ label: 'copiedLink' | 'copyFailed' } | null>(null)
const attempt = account.view?.attempt
useEffect(() => { setCopyCount(0) }, [attempt?.id, attempt?.authorizeUrl])
useEffect(() => { setCopyResult(null) }, [attempt?.id, attempt?.authorizeUrl])
useEffect(() => {
if (copyCount === 0) return
const timer = setTimeout(() => { setCopyCount(0) }, 2000)
if (copyResult === null) return
const timer = setTimeout(() => { setCopyResult(null) }, 2000)
return () => { clearTimeout(timer) }
}, [copyCount])
}, [copyResult])
const phase = attempt?.phase
const active = busy || phase === 'initializing' || phase === 'waiting-browser' || phase === 'exchanging' || phase === 'committing'
const expired = phase === 'expired'
@@ -41,6 +41,13 @@ export function SignInDialog({ account, start, cancel, close, useApiKey, t }: {
if (active && attempt) void run(async () => { await cancel(attempt.id); close() })
else close()
}
const copyLink = async () => {
if (!attempt?.authorizeUrl) return
try {
await navigator.clipboard.writeText(attempt.authorizeUrl)
setCopyResult({ label: 'copiedLink' })
} catch { setCopyResult({ label: 'copyFailed' }) }
}
const title = active ? t('browserTitle') : expired ? t('timeoutTitle') : error ? t('failureTitle') : t('loginTitle')
return <Modal open headless title={title} onClose={dismiss} className={css.dialog as string}>
<div className={css.content}>
@@ -52,11 +59,8 @@ export function SignInDialog({ account, start, cancel, close, useApiKey, t }: {
</div>
{active ? <p className={css.description}>
{t('browserPrompt')}<button type="button" className={css.link} disabled={!attempt?.authorizeUrl}
onClick={() => {
if (attempt?.authorizeUrl) void navigator.clipboard.writeText(attempt.authorizeUrl)
.then(() => { setCopyCount(count => count + 1) }, () => { setFailed(true) })
}}>
{t(copyCount > 0 ? 'copiedLink' : 'copyLink')}
onClick={() => { void copyLink() }}>
{t(copyResult?.label ?? 'copyLink')}
</button>{t('browserDescription')}
</p> : <p className={css.description}>
{expired ? t('timeoutDescription') : error ? t('failed') : t('loginDescription')}
@@ -2,7 +2,7 @@
export const en = {
close: 'Close', addApiKey: 'Add API Key', retry: 'Sign in again',
loginTitle: 'Start creating', loginDescription: 'Sign in to create, edit, and share your design projects. Everything is saved locally.',
browserTitle: 'Waiting for sign in', browserPrompt: 'Page did not open automatically? ', copyLink: 'Copy sign-in link', copiedLink: 'Link copied',
browserTitle: 'Waiting for sign in', browserPrompt: 'Page did not open automatically? ', copyLink: 'Copy sign-in link', copiedLink: 'Link copied', copyFailed: 'Copy failed',
browserDescription: ', then open it in your browser to finish signing in.',
timeoutTitle: 'Sign in timed out', timeoutDescription: 'Sign in again to continue.',
failureTitle: 'Could not sign in',
@@ -26,7 +26,7 @@ export type AccountKey = keyof typeof en
export const zh: Record<AccountKey, string> = {
close: '关闭', addApiKey: '添加 API Key', retry: '重新登录',
loginTitle: '开始你的创作', loginDescription: '登录后即可创建、编辑和分享你的设计项目,所有内容在本地保存。',
browserTitle: '等待登录', browserPrompt: '没有自动打开新页面?', copyLink: '复制登录链接', copiedLink: '链接已复制',
browserTitle: '等待登录', browserPrompt: '没有自动打开新页面?', copyLink: '复制登录链接', copiedLink: '链接已复制', copyFailed: '复制失败',
browserDescription: ',手动打开登录页完成登录。',
timeoutTitle: '登录已超时', timeoutDescription: '请重新登录后继续操作。',
failureTitle: '登录失败',
@@ -0,0 +1 @@
Waiting for sign inPage did not open automatically? Copy failed, then open it in your browser to finish signing in.Cancel
@@ -0,0 +1 @@
等待登录没有自动打开新页面?复制失败,手动打开登录页完成登录。取消
@@ -73,3 +73,32 @@ it.each([en, zh])('restores the copy label two seconds after the latest successf
else Reflect.deleteProperty(navigator, 'clipboard')
}
})
it.each([en, zh])('shows a temporary copy failure without interrupting sign-in', async (copy) => {
const clipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
vi.useFakeTimers()
try {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: {
writeText: vi.fn(async () => { throw new Error('Clipboard denied') }),
} })
const props = mount({ id, phase: 'waiting-browser', authorizeUrl: 'https://platform.deepseek.com/dsh/authorize?state=example' }, copy)
await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.copyLink })) })
expect(screen.getByRole('dialog', { name: copy.browserTitle })).toBeTruthy()
expect(screen.getByRole('button', { name: copy.copyFailed })).toBeTruthy()
await expect(`${screen.getByRole('dialog').textContent}\n`).toMatchFileSnapshot(`./expected/login-copy-failed-${copy === en ? 'en' : 'zh'}.txt`)
act(() => { vi.advanceTimersByTime(1000) })
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.copyFailed })) })
act(() => { vi.advanceTimersByTime(1999) })
expect(screen.getByRole('button', { name: copy.copyFailed })).toBeTruthy()
act(() => { vi.advanceTimersByTime(1) })
expect(screen.getByRole('button', { name: copy.copyLink })).toBeTruthy()
expect(props.cancel).not.toHaveBeenCalled()
expect(props.start).not.toHaveBeenCalled()
} finally {
cleanup()
vi.useRealTimers()
if (clipboard) Object.defineProperty(navigator, 'clipboard', clipboard)
else Reflect.deleteProperty(navigator, 'clipboard')
}
})