fix(ui-settings-account): report clipboard failures without failing sign in
This commit is contained in:
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')
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user