fix(web): surface provider credential status

This commit is contained in:
Yichen Jiang
2026-08-06 16:44:04 +08:00
parent 099b903ac6
commit f2050bfd1e
13 changed files with 153 additions and 25 deletions

View File

@@ -38,6 +38,13 @@
color: var(--dsw-alias-state-warn-label);
}
.savedNotice {
margin: 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-success-primary);
}
.rows {
list-style: none;
/* Extra air between the title/intro block and the first provider card. */
@@ -65,6 +72,13 @@
gap: 10px;
}
.rowIdentity {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.rowName {
font-size: 14px;
line-height: 22px;
@@ -72,6 +86,23 @@
color: var(--dsw-alias-label-primary);
}
.credentialDot {
box-sizing: border-box;
display: inline-block;
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
}
.credentialDotConfigured {
background: var(--dsw-alias-state-success-primary);
}
.credentialDotMissing {
background: var(--dsw-alias-state-error-primary);
}
.rowActions {
display: inline-flex;
align-items: center;

View File

@@ -1,10 +1,11 @@
/**
* Models settings section: the provider rows joined from the configurable
* directory, settings namespaces, and credential states, with one editor
* card at a time. A whole-section provider without a configured key (the
* unconfigured DeepSeek posture) renders as its open setup card instead of a
* row; the add flow is a card carrying the dormant-provider select. Every
* mutation writes through the wire, while a provider removal first requires
* card at a time. Rows expose only confirmed API-key state through accessible
* solid configured or missing dots. A whole-section provider without a
* configured key (the unconfigured DeepSeek posture) renders as its open setup
* card instead of a row; the add flow is a card carrying the dormant-provider
* select. Every mutation writes through the wire, while a provider removal first requires
* confirmation; the page re-renders from pushed invalidations or the
* post-apply reload.
*/
@@ -149,11 +150,15 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined)
const [deleting, setDeleting] = useState(false)
const [deleteFailure, setDeleteFailure] = useState<string | undefined>(undefined)
const [savedTarget, setSavedTarget] = useState<ProviderIdentity | undefined>(undefined)
const closeEditor = (changed: boolean): void => {
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
setEditing(undefined)
setAdding(false)
if (changed) void controller.load()
if (changed) {
setSavedTarget(target)
void controller.load()
}
}
const closeDelete = (): void => {
@@ -202,6 +207,13 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
<h2 className={styles['title']}>{t('title')}</h2>
<p className={styles['intro']}>{t('intro')}</p>
{!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
{savedTarget === undefined
? null
: (
<p className={styles['savedNotice']} role="status" aria-live="polite">
{providerCopy(t('savedProvider'), savedTarget)}
</p>
)}
<ul className={styles['rows']}>
{configured.map((row) => {
const target = targetOf(row)
@@ -221,22 +233,51 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
api={api}
t={t}
readOnly={!state.writable}
onClose={closeEditor}
onClose={(changed) => { closeEditor(changed, target) }}
/>
</li>
)
}
const open = !adding && editing?.provider === row.entry.provider
const credentialConfigured = row.literalApiKeyConfigured || row.credential?.configured === true
const credentialMissing = !credentialConfigured
&& row.apiKeyEnv !== undefined
&& row.credential?.configured === false
return (
<li key={row.entry.provider} className={styles['rowCard']}>
<div className={styles['rowHead']}>
<span className={styles['rowName']}>{row.entry.displayName}</span>
<span className={styles['rowIdentity']}>
<span className={styles['rowName']}>{row.entry.displayName}</span>
{credentialConfigured
? (
<span
className={`${styles['credentialDot']} ${styles['credentialDotConfigured']}`}
role="img"
aria-label={t('credentialConfigured')}
title={t('credentialConfigured')}
/>
)
: credentialMissing
? (
<span
className={`${styles['credentialDot']} ${styles['credentialDotMissing']}`}
role="img"
aria-label={t('credentialMissing')}
title={t('credentialMissing')}
/>
)
: null}
</span>
<span className={styles['rowActions']}>
<button
type="button"
className={styles['secondaryButton']}
aria-label={providerCopy(t('editProvider'), target)}
onClick={() => { setAdding(false); setEditing(open ? undefined : target) }}
onClick={() => {
setSavedTarget(undefined)
setAdding(false)
setEditing(open ? undefined : target)
}}
>
{t('edit')}
</button>
@@ -247,7 +288,11 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
className={styles['dangerButton']}
aria-label={providerCopy(t('removeProvider'), target)}
disabled={!state.writable}
onClick={() => { setDeleteFailure(undefined); setDeleteTarget(target) }}
onClick={() => {
setSavedTarget(undefined)
setDeleteFailure(undefined)
setDeleteTarget(target)
}}
>
{t('remove')}
</button>
@@ -265,7 +310,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
api={api}
t={t}
readOnly={!state.writable}
onClose={closeEditor}
onClose={(changed) => { closeEditor(changed, target) }}
/>
)
: null}
@@ -305,7 +350,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
api={api}
t={t}
readOnly={!state.writable}
onClose={closeEditor}
onClose={(changed) => { closeEditor(changed, addTarget) }}
/>
</div>
)
@@ -318,6 +363,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const first = addable[0]
/* v8 ignore next -- the button is disabled while nothing is addable */
if (first === undefined) return
setSavedTarget(undefined)
setAdding(true)
setEditing(targetOf(first))
}}

View File

@@ -20,6 +20,9 @@ export const en = {
cancel: 'Cancel',
apply: 'Apply',
applying: 'Applying…',
savedProvider: 'Saved {provider}.',
credentialConfigured: 'API key configured',
credentialMissing: 'API key missing',
readOnly: 'The settings document is read-only in this deployment.',
loadFailed: 'Loading the provider directory failed',
conflict: 'Someone else changed these settings while this card was open. Close it and reopen to edit the current values.',
@@ -85,6 +88,9 @@ export const zh: typeof en = {
cancel: '取消',
apply: '保存',
applying: '保存中…',
savedProvider: '已保存 {provider}。',
credentialConfigured: 'API 密钥已配置',
credentialMissing: 'API 密钥缺失',
readOnly: '当前部署的设置文档为只读。',
loadFailed: '加载提供方目录失败',
conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。',