feat:cash-assets-batch-draft-review-and-login-memory

This commit is contained in:
陈煜 committed 2026-10-05 12:29:07 +08:00
1 parent ce8609116b
commit cfbba73791
16 files changed
+872 -46

No files matched your search

+21 -4
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { api } from './api';
import { AgentDrafts } from './AgentDrafts';
import { toolLabel, statusLabel, permissionLabel } from './agent-display';
import { showToast, useToast } from './Toast';
@@ -116,7 +117,8 @@ function codexSetupPrompt(url: string, level: string, hiddenRead = false, hidden
}
const connectionExpiry = (g: Connection) => (g.clientId ? g.refreshExpiresAt : g.expiresAt);
export function AgentConnections() {
export function AgentConnections({ changed }: { changed?: () => void }) {
const [review, setReview] = useState<string | null>(null);
const [data, setData] = useState<Management | null>(null),
[view, setView] = useState('connect'),
[busy, setBusy] = useState(false),
@@ -479,6 +481,9 @@ export function AgentConnections() {
{data && view === 'operations' && (
<section className="agent-card">
<h3>待确认草稿</h3>
<button className="primary" disabled={busy} onClick={() => setReview('all')}>
审阅全部草稿
</button>
{!pending.length && <p className="agent-empty">没有等待确认的修改。</p>}
{pending.map((o) => (
<div className="agent-row" key={o.id}>
@@ -486,9 +491,9 @@ export function AgentConnections() {
<strong>{toolLabel(o.tool)}</strong>
<p className="muted">有效至 {new Date(o.expiresAt).toLocaleTimeString()}</p>
</div>
<a className="agent-review-link" href={'/agent/operation?agent_operation=' + o.id}>
<button className="agent-review-link" onClick={() => setReview(o.id)}>
审阅草稿
</a>
</button>
</div>
))}
<details className="agent-details">
@@ -507,7 +512,9 @@ export function AgentConnections() {
· {new Date(o.createdAt).toLocaleString()}
</p>
</div>
<a href={'/agent/operation?agent_operation=' + o.id}>查看详情</a>
<button className="text" onClick={() => setReview(o.id)}>
查看详情
</button>
</div>
))}
</details>
@@ -584,6 +591,16 @@ export function AgentConnections() {
</section>
</>
)}
{review !== null && (
<AgentDrafts
initialId={review === 'all' ? undefined : review}
close={() => setReview(null)}
changed={() => {
void load();
changed?.();
}}
/>
)}
</section>
);
}
+318
View File
@@ -0,0 +1,318 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { api } from './api';
import { AgentOperation, Fields, type Preview } from './AgentReview';
import { toolLabel, statusLabel } from './agent-display';
import { showToast } from './Toast';
export function ReviewDialog({
children,
close,
busy = false,
}: {
children: ReactNode;
close: () => void;
busy?: boolean;
}) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
const overflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
ref.current?.focus();
return () => {
document.body.style.overflow = overflow;
previous?.focus();
};
}, []);
return createPortal(
<div
className="veil"
onClick={(e) => {
if (e.target === e.currentTarget && !busy) close();
}}
>
<section
ref={ref}
tabIndex={-1}
className="modal draft-dialog"
role="dialog"
aria-modal="true"
aria-label="审阅草稿"
onKeyDown={(e) => {
if (e.key === 'Escape' && !busy) {
e.preventDefault();
close();
}
if (e.key === 'Tab') {
const nodes = Array.from(
ref.current?.querySelectorAll<HTMLElement>(
'button:not(:disabled),input:not(:disabled),a[href],select:not(:disabled),summary',
) || [],
);
const first = nodes[0],
last = nodes[nodes.length - 1];
if (!first) {
e.preventDefault();
return;
}
if (
e.shiftKey &&
(document.activeElement === first || document.activeElement === ref.current)
) {
e.preventDefault();
last.focus();
} else if (
!e.shiftKey &&
(document.activeElement === last || document.activeElement === ref.current)
) {
e.preventDefault();
first.focus();
}
}
}}
>
<header>
<h2>审阅草稿</h2>
<button className="icon" aria-label="关闭" disabled={busy} onClick={close}>
×
</button>
</header>
{children}
</section>
</div>,
document.body,
);
}
type Draft = Preview & { error?: string };
export function AgentDrafts({
initialId,
close,
changed,
}: {
initialId?: string;
close: () => void;
changed: () => void;
}) {
const [items, setItems] = useState<Draft[]>([]);
const [selected, setSelected] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [detail, setDetail] = useState(false);
const [now, setNow] = useState(Date.now());
async function load(initial = false) {
setLoading(true);
setError('');
try {
const all: Draft[] = [];
let cursor: string | null = null;
do {
const page: { items: Draft[]; nextCursor: string | null } = await api(
'/agent/drafts' + (cursor ? '?cursor=' + encodeURIComponent(cursor) : ''),
);
all.push(...page.items);
cursor = page.nextCursor;
} while (cursor);
setItems(all);
setSelected(initial && initialId ? [initialId] : []);
setDetail(!!initialId && !all.some((o) => o.operationId === initialId));
} catch (e) {
setError(e instanceof Error ? e.message : '草稿加载失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void load(true);
}, [initialId]);
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
const valid = items.filter(
(o) => o.status === 'pending' && !o.error && new Date(o.expiresAt).getTime() > now,
);
const eligible = selected.filter((id) => valid.some((o) => o.operationId === id));
async function decide(approve: boolean) {
if (busy || !eligible.length || eligible.length > 100) return;
setBusy(true);
setError('');
try {
await api('/agent/operations/confirm-batch', 'POST', {
approve,
operationIds: eligible,
});
showToast(approve ? '所选草稿已全部执行' : '所选草稿已取消', 'success');
changed();
await load();
} catch (e) {
const message = e instanceof Error ? e.message : '提交失败';
setError(message + '。本批修改未执行,请刷新后重新审阅。');
showToast(message, 'error');
} finally {
setBusy(false);
}
}
async function cancelUnavailable(id: string) {
if (busy) return;
setBusy(true);
try {
await api('/agent/operations/' + id, 'POST', { approve: false });
changed();
await load();
} catch (e) {
setError(e instanceof Error ? e.message : '取消失败');
} finally {
setBusy(false);
}
}
return (
<ReviewDialog close={close} busy={busy}>
{loading ? (
<p>正在读取全部草稿…</p>
) : detail ? (
<AgentOperation id={initialId || null} changed={changed} />
) : (
<>
<p className="muted">核对每项修改后选择。一次最多同意 100 项,任一失败整批回滚。</p>
<div className="draft-toolbar">
<button
className="secondary"
disabled={busy || !valid.length}
onClick={() => setSelected(valid.slice(0, 100).map((o) => o.operationId))}
>
{valid.length > 100 ? '选择前 100 项' : '全选'}
</button>
<button className="secondary" disabled={busy} onClick={() => setSelected([])}>
取消全选
</button>
<button className="secondary" disabled={busy} onClick={() => void load()}>
刷新草稿
</button>
<span role="status">
已选择 {eligible.length} / {items.length} 项
</span>
</div>
{!items.length && <p>没有等待确认的修改。</p>}
<div className="draft-list">
{items.map((o) => {
const available = valid.some((v) => v.operationId === o.operationId);
const p = o.impact?.parameters || {},
refs = o.impact?.references || {};
const ref = refs[String(p.id || p.sourceId || '')];
const absolute = ['balance_record', 'history_update', 'position_create'].includes(
o.tool,
);
return (
<article className="draft-card" key={o.operationId}>
<label className="check-line">
<input
type="checkbox"
disabled={busy || !available}
checked={eligible.includes(o.operationId)}
onChange={(e) =>
setSelected((ids) =>
e.target.checked
? [...ids, o.operationId]
: ids.filter((id) => id !== o.operationId),
)
}
/>
<strong>{toolLabel(o.tool)}</strong>
<span>
{o.connectionName} · {statusLabel(available ? o.status : 'expired')}
</span>
</label>
{o.error ? (
<div>
<p className="danger-text">{o.error}</p>
<button
className="secondary"
disabled={busy}
onClick={() => void cancelUnavailable(o.operationId)}
>
取消草稿
</button>
</div>
) : (
<>
{o.destructive && (
<p className="agent-warning">删除记录并重算余额,请仔细核对。</p>
)}
{o.impact.current && (
<details open>
<summary>当前记录</summary>
<Fields
data={o.impact.current}
references={refs}
currency={ref?.currency}
liability={absolute && ref?.side === 'liability'}
/>
</details>
)}
<Fields
data={
o.tool === 'position_create'
? Object.fromEntries(
Object.entries(p).filter(
([key, value]) =>
!['hidden', 'archived', 'notes'].includes(key) ||
(value !== false && value !== '' && value !== null),
),
)
: p
}
references={refs}
currency={ref?.currency}
liability={
absolute && (ref?.side === 'liability' || p.side === 'liability')
}
/>
{o.tool === 'position_create' && (
<details className="agent-details">
<summary>查看全部字段</summary>
<Fields data={p} references={refs} liability={p.side === 'liability'} />
</details>
)}
<small className="muted">
有效至 {new Date(o.expiresAt).toLocaleString()}
</small>
</>
)}
</article>
);
})}
</div>
<div className="draft-footer">
<button className="secondary" disabled={busy} onClick={close}>
稍后处理
</button>
<button
className="secondary"
disabled={busy || !eligible.length || eligible.length > 100}
onClick={() => void decide(false)}
>
取消所选草稿
</button>
<button
className="primary"
disabled={busy || !eligible.length || eligible.length > 100}
onClick={() => void decide(true)}
>
{busy ? '正在执行…' : '同意所选 ' + eligible.length + ' 项'}
</button>
</div>
</>
)}
{error && (
<p role="alert" className="danger-text">
{error}
<button className="secondary" disabled={busy} onClick={() => void load()}>
重新加载
</button>
</p>
)}
</ReviewDialog>
);
}
+4 -3
View File
@@ -16,7 +16,7 @@ type Consent = {
redirectUri: string;
resource: string;
};
type Preview = {
export type Preview = {
operationId: string;
tool: string;
status: string;
@@ -222,7 +222,7 @@ export function AgentAuthorization({ id }: { id: string | null }) {
</section>
);
}
function Fields({
export function Fields({
data,
references,
currency,
@@ -283,7 +283,7 @@ function Fields({
</dl>
);
}
export function AgentOperation({ id }: { id: string | null }) {
export function AgentOperation({ id, changed }: { id: string | null; changed?: () => void }) {
const [preview, setPreview] = useState<Preview | null>(null),
[error, setError] = useState(''),
[busy, setBusy] = useState(false),
@@ -318,6 +318,7 @@ export function AgentOperation({ id }: { id: string | null }) {
try {
await api('/agent/operations/' + preview.operationId, 'POST', { approve });
setPreview(await api<Preview>('/agent/operations/' + preview.operationId));
changed?.();
showToast(approve ? '修改已完成,Agent 可以查询结果' : '草稿已取消', 'success');
} catch (e) {
showToast(e instanceof Error ? e.message : '操作失败', 'error');
+87 -35
View File
@@ -55,7 +55,9 @@ import { orderedGroups, mergeGroupOrder } from './group-order';
import { Calendar } from './Calendar';
import { SchedulePanel } from './SchedulePanel';
import { AgentConnections } from './AgentConnections';
import { AgentAuthorization, AgentOperation } from './AgentReview';
import { AgentAuthorization } from './AgentReview';
import { AgentDrafts } from './AgentDrafts';
import { savedLogin, rememberLogin } from './login-preferences';
import { PositionDeletion, deletionLabel } from './PositionDeletion';
import { IconPicker } from './IconPicker';
import { MetalPanel } from './MetalPanel';
@@ -93,6 +95,7 @@ const categories: Record<string, [string, string][]> = {
['other', '其他'],
],
asset: [
['cash', '现金'],
['property', '房产'],
['vehicle', '车辆'],
['gold', '贵金属'],
@@ -175,10 +178,7 @@ export default function App() {
[boot, setBoot] = useState(true),
[register, setRegister] = useState(false),
[page, setPage] = useState(
new URLSearchParams(location.search).has('agent_authorization') ||
new URLSearchParams(location.search).has('agent_operation')
? 'agent-review'
: 'overview',
new URLSearchParams(location.search).has('agent_authorization') ? 'agent-review' : 'overview',
),
[positions, setPositions] = useState<Position[]>([]),
[overview, setOverview] = useState<Overview | null>(null),
@@ -212,6 +212,11 @@ export default function App() {
const [hideBooked, setHideBooked] = useState(false);
const [unpaidOnly, setUnpaidOnly] = useState(false);
const [assetQuickMode, setAssetQuickMode] = useState(false);
const [assetValueMode, setAssetValueMode] = useState(false);
const [remember, setRemember] = useState(() => savedLogin().remember);
const [draftId, setDraftId] = useState(() =>
new URLSearchParams(location.search).get('agent_operation'),
);
const [businessDay, setBusinessDay] = useState(today());
const [repaymentMonth, setRepaymentMonth] = useState(today().slice(0, 7));
const quickGeneration = useRef(0);
@@ -829,9 +834,9 @@ export default function App() {
password: f.get('password'),
});
if (session !== sessionGeneration.current) return;
const agentReturn =
new URLSearchParams(location.search).has('agent_authorization') ||
new URLSearchParams(location.search).has('agent_operation');
if (!register)
void rememberLogin(String(f.get('username')), String(f.get('password')), remember);
const agentReturn = new URLSearchParams(location.search).has('agent_authorization');
setPage(agentReturn ? 'agent-review' : 'overview');
if (agentReturn) setSettingsSection('agent');
setSelected(null);
@@ -911,10 +916,11 @@ export default function App() {
{register ? tr('建立属于你的私人资产空间') : tr('登录以查看你的资产与负债')}
</p>
<form onSubmit={auth}>
<form onSubmit={auth} autoComplete="on">
<Field label={tr('账号')}>
<input
name="username"
defaultValue={savedLogin().username}
autoComplete="username"
required
minLength={3}
@@ -933,6 +939,19 @@ export default function App() {
placeholder={tr('至少 10 个字符,最多 72 字节')}
/>
</Field>
{!register && (
<>
<label className="check-line">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
/>
{tr('记住账号密码')}
</label>
<p className="muted">{tr('密码由浏览器密码管理器保存,下次登录可自动填充。')}</p>
</>
)}
<button className="primary full" disabled={busy}>
{busy ? tr('正在处理…') : register ? tr('注册并进入') : tr('登录')}
<ArrowUpRight size={18} />
@@ -1018,12 +1037,7 @@ export default function App() {
}
}
const agentParams = new URLSearchParams(location.search);
if (
agentParams.has('agent_authorization') ||
agentParams.has('agent_operation') ||
location.pathname === '/agent/authorize' ||
location.pathname === '/agent/operation'
) {
if (agentParams.has('agent_authorization') || location.pathname === '/agent/authorize') {
return (
<main className="agent-review-page">
<header className="agent-review-header">
@@ -1033,11 +1047,7 @@ export default function App() {
退出登录
</button>
</header>
{agentParams.has('agent_authorization') || location.pathname === '/agent/authorize' ? (
<AgentAuthorization id={agentParams.get('agent_authorization')} />
) : (
<AgentOperation id={agentParams.get('agent_operation')} />
)}
<AgentAuthorization id={agentParams.get('agent_authorization')} />
</main>
);
}
@@ -1733,11 +1743,30 @@ export default function App() {
className={assetQuickMode ? 'primary' : 'secondary'}
disabled={busy}
aria-pressed={assetQuickMode}
onClick={() => setAssetQuickMode(!assetQuickMode)}
onClick={() => {
setAssetQuickMode(!assetQuickMode);
setAssetValueMode(false);
}}
>
{tr(assetQuickMode ? '结束统计设置' : '快速设置计入总资产')}
</button>
<button
className={assetValueMode ? 'primary' : 'secondary'}
disabled={busy}
aria-pressed={assetValueMode}
onClick={() => {
setAssetValueMode(!assetValueMode);
setAssetQuickMode(false);
}}
>
{tr(assetValueMode ? '结束快速估值' : '快速设置估值')}
</button>
</div>
{assetValueMode && (
<p className="muted">
{tr('点击资产卡片填写最新估值;贵金属按报价自动估值。')}
</p>
)}
{assetQuickMode && (
<p className="muted">{tr('点击资产卡片,切换是否计入总资产。')}</p>
)}
@@ -1816,6 +1845,9 @@ export default function App() {
}
disabled={
busy ||
(page === 'asset' &&
assetValueMode &&
(p.archived || p.category === 'gold')) ||
(!!quickTransfer && p.archived) ||
(quickMode &&
quickMarkMode &&
@@ -1824,19 +1856,26 @@ export default function App() {
}
key={p.id}
onClick={() =>
page === 'asset' && assetQuickMode
? void act(() => toggleAssetInclusion(p), tr('统计设置已保存'))
: quickMode && quickBookedMode && page === 'account'
? void act(() => markQuickBooked(p), tr('记账标记已保存'))
: quickMode && quickMarkMode && page === 'account'
? p.kind === 'account' && p.side === 'liability' && !p.archived
? void act(() => markQuickRepayment(p), tr('还款标记已保存'))
: undefined
: quickTransfer !== null && page === 'account' && quickMode
? chooseQuickAccount(p)
: page === 'account' && quickMode && !p.archived
? setModal({ kind: 'revision', p, quick: true })
: setSelected(p.id)
page === 'asset' && assetValueMode
? setModal({ kind: 'revision', p })
: page === 'asset' && assetQuickMode
? void act(() => toggleAssetInclusion(p), tr('统计设置已保存'))
: quickMode && quickBookedMode && page === 'account'
? void act(() => markQuickBooked(p), tr('记账标记已保存'))
: quickMode && quickMarkMode && page === 'account'
? p.kind === 'account' &&
p.side === 'liability' &&
!p.archived
? void act(
() => markQuickRepayment(p),
tr('还款标记已保存'),
)
: undefined
: quickTransfer !== null && page === 'account' && quickMode
? chooseQuickAccount(p)
: page === 'account' && quickMode && !p.archived
? setModal({ kind: 'revision', p, quick: true })
: setSelected(p.id)
}
>
<div>
@@ -2119,7 +2158,7 @@ export default function App() {
<IconLibrary />
</section>
)}
{settingsSection === 'agent' && <AgentConnections />}
{settingsSection === 'agent' && <AgentConnections changed={() => void load()} />}
{settingsSection === 'security' && (
<section className="panel">
<h2>{tr('账号与密码')}</h2>
@@ -3357,6 +3396,19 @@ export default function App() {
)}
</Modal>
)}
{draftId && (
<AgentDrafts
initialId={draftId}
close={() => {
setDraftId(null);
const url = new URL(location.href);
url.searchParams.delete('agent_operation');
if (url.pathname === '/agent/operation') url.pathname = '/';
history.replaceState(null, '', url.pathname + url.search + url.hash);
}}
changed={() => void load()}
/>
)}
</div>,
);
}
+6 -1
View File
@@ -617,5 +617,10 @@
"已隐藏全部折线,点击上方名称可重新显示。": "All series are hidden. Click a name above to show it again.",
"点击名称切换折线;悬停或点击图表查看当天数据,方向键切换日期。": "Click a name to toggle a series. Hover or tap the chart to inspect a date; use arrow keys to move between dates.",
"当天趋势数据": "Data for selected date",
"当天估值或汇率不完整,无法显示完整总额。": "Valuations or exchange rates are incomplete for this date; full totals are unavailable."
"当天估值或汇率不完整,无法显示完整总额。": "Valuations or exchange rates are incomplete for this date; full totals are unavailable.",
"记住账号密码": "Remember account and password",
"密码由浏览器密码管理器保存,下次登录可自动填充。": "Your browser password manager saves the password for autofill next time.",
"快速设置估值": "Quick valuation",
"结束快速估值": "Finish quick valuation",
"点击资产卡片填写最新估值;贵金属按报价自动估值。": "Select an asset to enter its latest value; precious metals use automatic quotes."
}
+6 -1
View File
@@ -617,5 +617,10 @@
"已隐藏全部折线,点击上方名称可重新显示。": "已隱藏全部折線,點擊上方名稱可重新顯示。",
"点击名称切换折线;悬停或点击图表查看当天数据,方向键切换日期。": "點擊名稱切換折線;懸停或點擊圖表查看當天數據,方向鍵切換日期。",
"当天趋势数据": "當天趨勢數據",
"当天估值或汇率不完整,无法显示完整总额。": "當天估值或匯率不完整,無法顯示完整總額。"
"当天估值或汇率不完整,无法显示完整总额。": "當天估值或匯率不完整,無法顯示完整總額。",
"记住账号密码": "記住帳號密碼",
"密码由浏览器密码管理器保存,下次登录可自动填充。": "密碼由瀏覽器密碼管理器保存,下次登入可自動填入。",
"快速设置估值": "快速設定估值",
"结束快速估值": "結束快速估值",
"点击资产卡片填写最新估值;贵金属按报价自动估值。": "點擊資產卡片填寫最新估值;貴金屬按報價自動估值。"
}
+33
View File
@@ -0,0 +1,33 @@
export function savedLogin(): { username: string; remember: boolean } {
try {
const value = JSON.parse(localStorage.getItem('worthpath.login') || '{}');
return {
username: typeof value.username === 'string' ? value.username : '',
remember: value.remember === true,
};
} catch {
return { username: '', remember: false };
}
}
export async function rememberLogin(username: string, password: string, remember: boolean) {
try {
if (remember)
localStorage.setItem('worthpath.login', JSON.stringify({ username, remember: true }));
else localStorage.removeItem('worthpath.login');
} catch {
/* Login remains usable when local storage is unavailable. */
}
if (!remember) return;
const Password = (
window as unknown as {
PasswordCredential?: new (data: { id: string; password: string; name: string }) => Credential;
}
).PasswordCredential;
if (Password && navigator.credentials && window.isSecureContext) {
try {
await navigator.credentials.store(new Password({ id: username, password, name: username }));
} catch {
/* The browser can still offer its normal password save prompt. */
}
}
}
+75
View File
@@ -2492,3 +2492,78 @@ textarea,
transform: none;
max-width: calc(100% - 24px);
}
.draft-dialog {
width: min(960px, calc(100vw - 32px));
max-height: 90dvh;
overflow-y: auto;
}
.draft-toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
padding: 12px 0;
}
.draft-list {
display: grid;
gap: 12px;
}
.draft-card {
padding: 16px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
}
.draft-card > .check-line {
flex-wrap: wrap;
align-items: center;
}
.draft-card > .check-line span {
font-size: 12px;
color: #64748b;
}
.draft-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
position: sticky;
bottom: -24px;
background: #fff;
padding: 16px 0;
border-top: 1px solid #e2e8f0;
margin-top: 16px;
}
@media (max-width: 600px) {
.draft-dialog {
width: calc(100vw - 20px);
padding: 14px;
}
.draft-footer {
bottom: -14px;
}
.draft-card {
padding: 12px;
}
}
.draft-card .agent-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 24px;
margin: 8px 0;
}
.draft-card .agent-field {
grid-template-columns: 90px minmax(0, 1fr);
gap: 8px;
padding: 8px 0;
font-size: 14px;
}
.draft-card .agent-field-section {
grid-column: 1 / -1;
}
@media (max-width: 600px) {
.draft-card .agent-fields {
grid-template-columns: minmax(0, 1fr);
}
}
+59
View File
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { savedLogin, rememberLogin } from '../src/login-preferences';
test('remembered login keeps passwords out of application storage and delegates to the browser', async (t) => {
const values = new Map<string, string>();
const stored: any[] = [];
const old = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['localStorage', 'window', 'navigator'])
old.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
t.after(() => {
for (const [key, descriptor] of old) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
const storage = {
getItem: (key: string) => values.get(key) || null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
};
class BrowserPassword {
constructor(data: unknown) {
Object.assign(this, data);
}
}
Object.defineProperty(globalThis, 'localStorage', { value: storage, configurable: true });
Object.defineProperty(globalThis, 'window', {
value: { isSecureContext: true, PasswordCredential: BrowserPassword },
configurable: true,
});
Object.defineProperty(globalThis, 'navigator', {
value: {
credentials: {
store: async (credential: unknown) => {
stored.push(credential);
},
},
},
configurable: true,
});
await rememberLogin('fixture account', 'synthetic-password-for-test', true);
assert.deepEqual(savedLogin(), { username: 'fixture account', remember: true });
assert.equal(
Array.from(values.values()).some((v) => v.includes('synthetic-password-for-test')),
false,
);
assert.equal(stored.length, 1);
assert.equal(stored[0].id, 'fixture account');
await rememberLogin('fixture account', 'synthetic-password-for-test', false);
assert.equal(stored.length, 1);
assert.deepEqual(savedLogin(), { username: '', remember: false });
// Browser support and storage failure cannot prevent a normal login.
(window as any).isSecureContext = false;
await rememberLogin('http fixture', 'synthetic-password-for-test', true);
assert.equal(stored.length, 1);
values.set('worthpath.login', 'bad json');
assert.deepEqual(savedLogin(), { username: '', remember: false });
});