feat(client): slice 6 — minimal login per spec + high-risk approval dialog

Aligns the client with docs/product-package/{08,11}.md.

6a — Login surface trimmed to wireframe §1:
  - Brand wordmark + tagline "从一个想法,到可上线的软件产品"
  - Footer line "登录后,客户端会使用 Heicode 提供的模型。"
  - ProviderLoginCard now shows ONLY: sign-in target host (read-only),
    email + password, sign-in button. The local-network warning tag,
    raw baseUrl pill, promo paragraph, "or via browser" alt link, and
    the "RECOMMENDED" badge — all dropped per the wireframe's "登录目
    标只有 Heicode" intent.
  - Removed dead handleOAuth / shellOpen / isLocalBaseUrl helpers.

6b — High-risk approval dialog (08-client-guide.md §"高危审批体验"
+ 11-product-prototype-wireframes.md §9):
  - New zustand store stores/approvalStore.ts with a FIFO queue of
    ApprovalRequest items + decide(id, 'approve' | 'reject' |
    'postpone') action. Idempotent enqueue (dedupe by id).
  - New components/approval/ApprovalDialog.tsx renders queue[0] as a
    modal with the 6 spec fields (task / operation / target / role /
    impact / credential), a Heicode-suggestion sidebar, a risk-level
    pill, and 3 actions: 拒绝 / 稍后提醒 / 批准 N 分钟. Queue depth
    badge appears at the bottom when more requests are pending.
  - AppShell renders <ApprovalDialog /> alongside ToastContainer so it
    overlays any surface (sessions, settings, etc.).
  - Backend wiring pending — for now main.tsx calls
    installApprovalMock() which exposes window.__heicodeMockApproval()
    for DevTools-driven demos. Real backend hook lands when
    mcp-server / agent-manager publish the approval-stream contract.

i18n: added login.tagline / login.signInTarget /
login.footer.heicodeProvidesModels and a full approval.* set
(zh + en, ~17 keys per locale).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 11:29:20 +08:00
co-authored by Claude Opus 4.7
parent 4827682de5
commit f08119fd33
8 changed files with 370 additions and 105 deletions
@@ -0,0 +1,198 @@
// desktop/src/components/approval/ApprovalDialog.tsx
//
// High-risk approval dialog rendered by AppShell whenever
// useApprovalStore().queue is non-empty. The first item in the queue is
// shown; user picks 拒绝 / 稍后提醒 / 批准 to drain it.
//
// Per docs/product-package/11-product-prototype-wireframes.md §9 the
// dialog must surface: task name, operation, target resource, risk level,
// short-lived credential note, Heicode's suggestion, and three actions.
import { useTranslation } from '../../i18n'
import {
useApprovalStore,
type ApprovalRequest,
type ApprovalRiskLevel,
} from '../../stores/approvalStore'
const RISK_TONE: Record<ApprovalRiskLevel, string> = {
low: 'border-[var(--color-success)]/30 bg-[var(--color-success)]/10 text-[var(--color-success)]',
medium:
'border-[var(--color-warning)]/30 bg-[var(--color-warning)]/10 text-[var(--color-warning)]',
high: 'border-[var(--color-error)]/30 bg-[var(--color-error-container)] text-[var(--color-error)]',
critical:
'border-[var(--color-error)]/50 bg-[var(--color-error)] text-white',
}
export function ApprovalDialog() {
const t = useTranslation()
const queue = useApprovalStore((s) => s.queue)
const decide = useApprovalStore((s) => s.decide)
const current = queue[0]
if (!current) return null
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-[var(--color-overlay-scrim)] px-4 py-8"
role="alertdialog"
aria-modal="true"
aria-labelledby="heicode-approval-title"
>
<div className="w-full max-w-xl overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] shadow-[var(--shadow-dropdown)]">
<header className="flex items-center justify-between border-b border-[var(--color-border-separator)] px-6 py-4">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[20px] text-[var(--color-warning)]"
aria-hidden
>
shield_lock
</span>
<h2
id="heicode-approval-title"
className="text-base font-semibold text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{t('approval.title')}
</h2>
</div>
<span
className={`rounded-full border px-2.5 py-0.5 text-[10px] font-medium uppercase tracking-wider ${RISK_TONE[current.risk_level]}`}
>
{t(`approval.risk.${current.risk_level}`)}
</span>
</header>
<dl className="grid gap-3 px-6 py-5 text-sm">
<Row label={t('approval.field.task')} value={current.task_name} />
<Row label={t('approval.field.operation')} value={current.operation} mono />
<Row label={t('approval.field.targetResource')} value={current.target_resource} mono />
{current.requesting_role ? (
<Row label={t('approval.field.requestingRole')} value={current.requesting_role} mono />
) : null}
{current.impact_summary ? (
<Row label={t('approval.field.impact')} value={current.impact_summary} />
) : null}
<Row
label={t('approval.field.credential')}
value={
current.derives_short_lived_credential !== false
? t('approval.credential.derived', {
minutes: String(current.ttl_minutes ?? 15),
})
: t('approval.credential.notRequired')
}
/>
</dl>
{current.heicode_suggestion ? (
<aside className="mx-6 mb-5 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">
{t('approval.suggestion.label')}
</div>
<p className="text-sm leading-relaxed text-[var(--color-text-secondary)]">
{current.heicode_suggestion}
</p>
</aside>
) : null}
<footer className="flex items-center justify-end gap-2 border-t border-[var(--color-border-separator)] px-6 py-4">
<button
type="button"
onClick={() => decide(current.id, 'reject')}
className="rounded-[var(--radius-md)] border border-[var(--color-error)]/30 px-3 py-1.5 text-sm text-[var(--color-error)] transition-colors hover:bg-[var(--color-error)]/10"
>
{t('approval.action.reject')}
</button>
<button
type="button"
onClick={() => decide(current.id, 'postpone')}
className="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 text-sm text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))] hover:text-[var(--color-text-primary)]"
>
{t('approval.action.postpone')}
</button>
<button
type="button"
onClick={() => decide(current.id, 'approve')}
className="rounded-[var(--radius-md)] border border-[var(--color-primary)]/40 bg-[var(--color-primary)] px-3 py-1.5 text-sm font-medium text-[var(--color-on-primary)] transition-colors hover:bg-[var(--color-primary-fixed-dim)]"
>
{t('approval.action.approve', {
minutes: String(current.ttl_minutes ?? 15),
})}
</button>
</footer>
{queue.length > 1 ? (
<div className="border-t border-[var(--color-border-separator)] bg-[var(--color-surface-container-low)] px-6 py-2 text-center text-[10px] text-[var(--color-text-tertiary)]">
{t('approval.queue.more', { count: String(queue.length - 1) })}
</div>
) : null}
</div>
</div>
)
}
function Row({
label,
value,
mono,
}: {
label: string
value: string
mono?: boolean
}) {
return (
<div className="grid grid-cols-[110px_1fr] gap-3">
<dt className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)] pt-1">
{label}
</dt>
<dd
className={`break-words text-[var(--color-text-primary)] ${mono ? 'font-mono text-[12px]' : ''}`}
>
{value}
</dd>
</div>
)
}
type ApprovalRequestInput = Omit<ApprovalRequest, 'id' | 'enqueued_at'> & {
id?: string
enqueued_at?: number
}
declare global {
interface Window {
/**
* Dev / mock entry point — push a fake approval into the queue from
* DevTools console:
* window.__heicodeMockApproval({ task_name: '...', operation: '...', ... })
*/
__heicodeMockApproval?: (input?: ApprovalRequestInput) => void
}
}
/**
* Wire the global mock entry point. Call this once at app startup.
*/
export function installApprovalMock() {
if (typeof window === 'undefined') return
window.__heicodeMockApproval = (input) => {
const req: Omit<ApprovalRequest, 'enqueued_at'> = {
id: input?.id ?? `mock-${Date.now()}`,
task_name: input?.task_name ?? '小团队任务管理 SaaS',
operation: input?.operation ?? '部署到生产环境',
target_resource: input?.target_resource ?? 'aks-prod',
requesting_role: input?.requesting_role ?? 'ops',
risk_level: input?.risk_level ?? 'high',
impact_summary:
input?.impact_summary ??
'会更新对外服务,影响所有线上用户。回滚需要重新触发部署流程。',
heicode_suggestion:
input?.heicode_suggestion ?? '先完成测试环境验证,再批准生产部署。',
derives_short_lived_credential:
input?.derives_short_lived_credential ?? true,
ttl_minutes: input?.ttl_minutes ?? 15,
}
useApprovalStore.getState().enqueue(req)
}
}
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Sidebar } from './Sidebar'
import { ContentRouter } from './ContentRouter'
import { ToastContainer } from '../shared/Toast'
import { ApprovalDialog } from '../approval/ApprovalDialog'
import { UpdateChecker } from '../shared/UpdateChecker'
import { HeicodeLoginPage } from '../login/HeicodeLoginPage'
import { useSettingsStore } from '../../stores/settingsStore'
@@ -124,6 +125,7 @@ export function AppShell() {
</main>
<ToastContainer />
<UpdateChecker />
<ApprovalDialog />
</div>
)
}
@@ -62,12 +62,15 @@ export function HeicodeLoginPage() {
{/* Content */}
<div className="relative flex flex-1 flex-col items-center justify-center overflow-auto px-6 py-10">
<div className="mb-12 text-center">
<div className="mb-10 text-center">
<h1 className="text-5xl font-bold tracking-[0.18em] uppercase text-[var(--color-brand)]">
Heicode
</h1>
<p className="mt-4 text-sm text-[var(--color-text-secondary)]">
{t('login.subtitle')}
<p
className="mt-5 text-sm text-[var(--color-text-secondary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{t('login.tagline')}
</p>
</div>
@@ -84,7 +87,7 @@ export function HeicodeLoginPage() {
) : null}
{hasFetched && providers.length > 0 ? (
<div className="grid w-full max-w-md grid-cols-1 gap-6">
<div className="grid w-full max-w-md grid-cols-1 gap-5">
{providers.map((provider) => (
<ProviderLoginCard key={provider.id} provider={provider} />
))}
@@ -92,10 +95,14 @@ export function HeicodeLoginPage() {
) : null}
{error ? (
<div className="mt-6 max-w-xl rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error-container)] px-4 py-3 text-sm text-[var(--color-error)]">
<div className="mt-6 max-w-md rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error-container)] px-4 py-3 text-sm text-[var(--color-error)]">
{error}
</div>
) : null}
<p className="mt-10 max-w-md text-center text-xs leading-relaxed text-[var(--color-text-tertiary)]">
{t('login.footer.heicodeProvidesModels')}
</p>
</div>
</div>
)
@@ -1,11 +1,9 @@
// desktop/src/components/login/ProviderLoginCard.tsx
//
// 登录卡片:
// 主路径 — 邮箱+密码直登(路径 B,符合原设计文档)
// 次路径 — 浏览器跳转 OAuth(保留作为快捷免密登录入口)
// Minimal email + password sign-in card per
// docs/product-package/11-product-prototype-wireframes.md §1.
import { useState } from 'react'
import { open as shellOpen } from '@tauri-apps/plugin-shell'
import type { HeicodeLoginProviderInfo } from '../../api/heicodeAuth'
import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore'
import { useTranslation } from '../../i18n'
@@ -14,113 +12,60 @@ type Props = {
provider: HeicodeLoginProviderInfo
}
function isLocalBaseUrl(rawUrl: string): boolean {
if (!rawUrl) return false
let host: string
try {
const url = new URL(rawUrl)
if (url.protocol === 'http:') return true
host = url.hostname
} catch {
return false
}
if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0') return true
if (!host.includes('.')) return true
if (host.startsWith('10.')) return true
if (host.startsWith('192.168.')) return true
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true
return false
}
export function ProviderLoginCard({ provider }: Props) {
const t = useTranslation()
const {
loginWithCredentials,
startOAuth,
startOAuthPolling,
isLoggingIn,
} = useHeicodeAuthStore()
const { loginWithCredentials, isLoggingIn } = useHeicodeAuthStore()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [localError, setLocalError] = useState<string | null>(null)
const [busy, setBusy] = useState<'creds' | 'oauth' | null>(null)
const [busy, setBusy] = useState<boolean>(false)
const handleCredentialsLogin = async (e: React.FormEvent) => {
e.preventDefault()
if (busy !== null || isLoggingIn) return
if (busy || isLoggingIn) return
setLocalError(null)
setBusy('creds')
setBusy(true)
try {
await loginWithCredentials({
providerId: provider.id,
email: email.trim(),
password,
})
// 登录成功后 store.status.loggedIn=true,AppShell 自动切到主界面
// After success store.status.loggedIn=true → AppShell switches surface.
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err))
} finally {
setBusy(null)
setBusy(false)
}
}
const handleOAuth = async () => {
if (!provider.oauthEnabled) return
setLocalError(null)
setBusy('oauth')
try {
const { authorizeUrl } = await startOAuth(provider.id)
try {
await shellOpen(authorizeUrl)
} catch {
setLocalError(t('login.errors.openBrowser'))
}
startOAuthPolling()
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err))
} finally {
setBusy(null)
}
}
const local = isLocalBaseUrl(provider.baseUrl)
const formDisabled = busy !== null || isLoggingIn
const formDisabled = busy || isLoggingIn
const submitEnabled = email.trim().length >= 3 && password.length >= 1 && !formDisabled
// Per docs/product-package/11-product-prototype-wireframes.md §1, the login
// surface is intentionally minimal: brand wordmark + sign-in target host +
// single sign-in action. We surface the host as a small, low-contrast label
// (so the user knows where the credentials are going) but drop the raw
// baseUrl pill, the promo text, the local-network warning tag, and the
// "or via browser" alt link that earlier slices added.
const signInHost = (() => {
try {
return new URL(provider.baseUrl).host
} catch {
return provider.baseUrl
}
})()
return (
<div className="relative flex flex-col gap-5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] p-7 shadow-[var(--shadow-dropdown)] backdrop-blur-sm transition-colors duration-200 hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))]">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold tracking-wide text-[var(--color-text-primary)]">
{provider.name}
</h3>
<span className="rounded-full border border-[var(--color-success)]/25 bg-[var(--color-success)]/10 px-2.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[var(--color-success)]">
{t('login.tags.recommended')}
</span>
</div>
<div
className="flex flex-wrap items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)]"
data-testid={`heicode-login-card-baseurl-${provider.id}`}
>
<span className="opacity-70">{t('login.baseUrl.label')}</span>
<code className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-text-secondary)] break-all">
{provider.baseUrl}
</code>
{local ? (
<span
className="rounded-full border border-[var(--color-warning)]/25 bg-[var(--color-warning)]/10 px-2 py-0.5 text-[10px] uppercase tracking-wider text-[var(--color-warning)]"
title={t('login.baseUrl.localHint')}
>
{t('login.baseUrl.localTag')}
</span>
) : null}
</div>
{provider.promoText ? (
<p className="text-sm leading-relaxed text-[var(--color-text-secondary)]">
{provider.promoText}
<div className="text-center">
<p className="text-[10px] font-medium uppercase tracking-[0.2em] text-[var(--color-text-tertiary)]">
{t('login.signInTarget')}
</p>
) : null}
<p className="mt-1 font-mono text-xs text-[var(--color-text-secondary)] break-all">
{signInHost}
</p>
</div>
{/* ─── Primary: Email + Password ───────────────────────── */}
<form onSubmit={handleCredentialsLogin} className="flex flex-col gap-3">
@@ -160,24 +105,10 @@ export function ProviderLoginCard({ provider }: Props) {
disabled={!submitEnabled}
className="rounded-[var(--radius-md)] border border-[var(--color-primary)]/40 bg-[var(--color-primary)] px-4 py-2.5 text-sm font-medium text-[var(--color-on-primary)] shadow-[var(--shadow-button-primary)] transition-all duration-200 hover:bg-[var(--color-primary-fixed-dim)] hover:shadow-[0_8px_24px_rgba(197,165,114,0.28)] disabled:cursor-not-allowed disabled:border-[var(--color-border)] disabled:bg-[var(--color-surface-container-high)] disabled:text-[var(--color-text-tertiary)] disabled:shadow-none"
>
{busy === 'creds' ? t('login.creds.submitting') : t('login.creds.submit')}
{busy ? t('login.creds.submitting') : t('login.creds.submit')}
</button>
</form>
{/* ─── Secondary: Browser OAuth (保留快捷入口) ─────────── */}
{provider.oauthEnabled ? (
<div className="flex items-center justify-center pt-1">
<button
type="button"
onClick={handleOAuth}
disabled={formDisabled}
className="text-xs text-[var(--color-text-tertiary)] underline-offset-4 transition-colors hover:text-[var(--color-text-primary)] hover:underline disabled:cursor-not-allowed disabled:opacity-50"
>
{busy === 'oauth' ? t('login.oauth.opening') : t('login.oauth.altLink')}
</button>
</div>
) : null}
{localError ? (
<div className="rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error-container)] px-3 py-2 text-xs text-[var(--color-error)]">
{localError}
+23
View File
@@ -966,6 +966,29 @@ export const en = {
// confirm dialogs
// ─── HeiCode Login ──────────────────────────────────────
'login.subtitle': 'Sign in to start using Heicode',
'login.tagline': 'From an idea to a shippable product',
'login.signInTarget': 'Sign-in target',
'login.footer.heicodeProvidesModels': 'Once signed in, the client uses models provided by Heicode.',
// ─── High-risk approval dialog ──────────────────────────
'approval.title': 'Heicode high-risk approval',
'approval.field.task': 'Task',
'approval.field.operation': 'Operation',
'approval.field.targetResource': 'Target',
'approval.field.requestingRole': 'Requesting role',
'approval.field.impact': 'Expected impact',
'approval.field.credential': 'Credential',
'approval.credential.derived': 'Will mint a {minutes}-minute short-lived credential',
'approval.credential.notRequired': 'No new credential will be minted',
'approval.suggestion.label': 'Heicode suggests',
'approval.risk.low': 'Low',
'approval.risk.medium': 'Medium',
'approval.risk.high': 'High',
'approval.risk.critical': 'Critical',
'approval.action.reject': 'Reject',
'approval.action.postpone': 'Remind me later',
'approval.action.approve': 'Approve for {minutes} min',
'approval.queue.more': '{count} more pending approvals queued',
'login.footnote': 'Heicode talks directly to TaijiAICloud; your API key never leaves this machine.',
'login.tags.recommended': 'Recommended',
'login.tags.comingSoon': 'Coming soon',
+23
View File
@@ -968,6 +968,29 @@ export const zh: Record<TranslationKey, string> = {
// confirm dialogs
// ─── HeiCode 登录 ──────────────────────────────────────
'login.subtitle': '登录以开始使用 Heicode',
'login.tagline': '从一个想法,到可上线的软件产品',
'login.signInTarget': '登录地址',
'login.footer.heicodeProvidesModels': '登录后,客户端会使用 Heicode 提供的模型。',
// ─── 高危审批弹窗 ───────────────────────────────────────
'approval.title': 'Heicode 高危操作审批',
'approval.field.task': '任务',
'approval.field.operation': '请求',
'approval.field.targetResource': '资源',
'approval.field.requestingRole': '请求角色',
'approval.field.impact': '预计影响',
'approval.field.credential': '凭证',
'approval.credential.derived': '将申请 {minutes} 分钟短期凭证',
'approval.credential.notRequired': '不会派生新凭证',
'approval.suggestion.label': 'Heicode 建议',
'approval.risk.low': '低风险',
'approval.risk.medium': '中风险',
'approval.risk.high': '高风险',
'approval.risk.critical': '严重',
'approval.action.reject': '拒绝',
'approval.action.postpone': '稍后提醒',
'approval.action.approve': '批准 {minutes} 分钟',
'approval.queue.more': '另有 {count} 个高危请求等待处理',
'login.footnote': 'Heicode 直接连 TaijiAICloud,API Key 仅保存在你这台机器上。',
'login.tags.recommended': '推荐',
'login.tags.comingSoon': '即将开放',
+2
View File
@@ -3,8 +3,10 @@ import ReactDOM from 'react-dom/client'
import { App } from './App'
import './theme/globals.css'
import { initializeTheme } from './stores/uiStore'
import { installApprovalMock } from './components/approval/ApprovalDialog'
initializeTheme()
installApprovalMock()
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
@@ -0,0 +1,79 @@
// desktop/src/stores/approvalStore.ts
//
// High-risk approval queue. Per
// docs/product-package/08-client-guide.md §"高危审批体验" and
// docs/product-package/11-product-prototype-wireframes.md §9, the client is
// the **only** surface where a human approves dangerous Agnet actions
// (production deploys, credential access, large model spend, etc.).
//
// Slice 6b ships the surface with a mock data path: backend wiring will
// arrive when mcp-server / agent-manager publish the approval-stream API.
// In the meantime callers (or the developer console) can push approval
// payloads onto this queue via `window.__heicodeMockApproval(req)`.
import { create } from 'zustand'
export type ApprovalRiskLevel = 'low' | 'medium' | 'high' | 'critical'
export type ApprovalRequest = {
/** Stable id provided by upstream (or generated locally for mock). */
id: string
/** User-facing task name, e.g. "小团队任务管理 SaaS". */
task_name: string
/** What the agent wants to do, e.g. "部署到生产环境". */
operation: string
/** Target resource handle, e.g. "aks-prod" / "github.com/foo/bar@main". */
target_resource: string
/** Role requesting the action, e.g. "ops" / "backend". */
requesting_role?: string
/** Risk classification — drives the dialog tone + button copy. */
risk_level: ApprovalRiskLevel
/** Short human-readable description of side effects. */
impact_summary?: string
/** Heicode's recommendation, e.g. "先完成测试环境验证". */
heicode_suggestion?: string
/** Will approval mint a short-lived credential? Default true for high. */
derives_short_lived_credential?: boolean
/** TTL of derived credential / approval, in minutes. Default 15. */
ttl_minutes?: number
/** Unix epoch ms when the request entered the queue. */
enqueued_at: number
}
export type ApprovalDecision = 'approve' | 'reject' | 'postpone'
type ApprovalState = {
/** FIFO queue of pending approvals. The dialog displays queue[0]. */
queue: ApprovalRequest[]
/** History of recent decisions, capped at 50, newest first. */
recent: Array<{ id: string; decision: ApprovalDecision; at: number }>
enqueue: (req: Omit<ApprovalRequest, 'enqueued_at'> & { enqueued_at?: number }) => void
decide: (id: string, decision: ApprovalDecision) => void
/** Clear the queue (used by logout / for tests). */
clear: () => void
}
export const useApprovalStore = create<ApprovalState>((set) => ({
queue: [],
recent: [],
enqueue: (req) => {
const full: ApprovalRequest = {
...req,
enqueued_at: req.enqueued_at ?? Date.now(),
}
set((s) => ({
// Idempotency: ignore duplicates by id (upstream may retry).
queue: s.queue.some((r) => r.id === full.id) ? s.queue : [...s.queue, full],
}))
},
decide: (id, decision) =>
set((s) => ({
queue: s.queue.filter((r) => r.id !== id),
recent: [{ id, decision, at: Date.now() }, ...s.recent].slice(0, 50),
})),
clear: () => set({ queue: [] }),
}))