feat(auth): protect aggregate console with password login

This commit is contained in:
chick
2026-07-19 17:13:43 +08:00
parent 75682b8134
commit f2803896a8
16 changed files with 1165 additions and 11 deletions
+3 -8
View File
@@ -1,3 +1,4 @@
import { ConsoleAuthSettings } from './auth/console-auth-settings.js';
import { AuditPage, type AuditDataSource } from './audit/audit-page.js';
import { createAuditApiDataSource } from './audit/audit-api-data-source.js';
import type { ReactNode } from 'react';
@@ -250,13 +251,7 @@ function Page({
refreshSignal={fleetRefreshSignal}
/>
);
if (route.kind === 'settings-system')
return (
<section aria-labelledby="system-settings-title">
<h1 id="system-settings-title"></h1>
<p role="status"></p>
</section>
);
if (route.kind === 'settings-system') return <ConsoleAuthSettings />;
if (route.kind === 'not-found')
return (
<section>
@@ -500,7 +495,7 @@ export function AppShell({
{(
[
['fleet', '/fleet', '实例总览'],
['settings', '/settings/instances', '设置'],
['settings', '/settings/system', '设置'],
] as const
).map(([key, href, label]) => (
<li key={key}>
+138
View File
@@ -0,0 +1,138 @@
import { type FormEvent, useEffect, useState } from 'react';
import type { ConsoleAuthDataSource, ConsoleAuthStatus } from './console-auth.js';
import { createConsoleAuthApiDataSource } from './console-auth.js';
export function ConsoleAuthSettings({
dataSource,
}: {
readonly dataSource?: ConsoleAuthDataSource;
}) {
const [source] = useState(() => dataSource ?? createConsoleAuthApiDataSource());
const [status, setStatus] = useState<ConsoleAuthStatus>();
const [enabled, setEnabled] = useState(false);
const [password, setPassword] = useState('');
const [confirmation, setConfirmation] = useState('');
const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
useEffect(() => {
let active = true;
void source.status().then(
(next) => {
if (!active) return;
setStatus(next);
setEnabled(next.protectionEnabled);
},
() => {
if (active) setError('无法读取密码保护设置。');
},
);
return () => {
active = false;
};
}, [source]);
const save = async (event: FormEvent) => {
event.preventDefault();
if (!status || saving) return;
setNotice('');
setError('');
if (enabled && !status.configured) {
if (password.length < 8 || !/[A-Za-z]/u.test(password) || !/\d/u.test(password)) {
setError('密码至少 8 位,并同时包含字母和数字。');
return;
}
if (password !== confirmation) {
setError('两次输入的密码不一致。');
return;
}
}
setSaving(true);
try {
const input =
enabled && !status.configured ? { enabled: true, newPassword: password } : { enabled };
const next = await source.update(input);
setStatus(next);
setEnabled(next.protectionEnabled);
setPassword('');
setConfirmation('');
setNotice(next.protectionEnabled ? '密码保护已启用。' : '密码保护已关闭。');
} catch {
setError('保存失败,请确认当前登录状态后重试。');
} finally {
setSaving(false);
}
};
return (
<section className="settings-page" aria-labelledby="password-protection-title">
<header className="page-heading">
<p className="eyebrow">SYSTEM SETTINGS</p>
<h1 id="password-protection-title"></h1>
<p>访</p>
<p> HTTP使 HTTPS</p>
<p> SimAdmin 访</p>
</header>
{error && !status ? <p role="alert">{error}</p> : null}
{status ? (
<form className="settings-card auth-settings" onSubmit={(event) => void save(event)}>
<label className="toggle-row">
<span>
<strong></strong>
<small>访</small>
</span>
<input
type="checkbox"
checked={enabled}
onChange={(event) => {
setEnabled(event.target.checked);
setNotice('');
}}
/>
</label>
{enabled && !status.configured ? (
<div className="auth-password-fields">
<label htmlFor="new-console-password">访</label>
<input
id="new-console-password"
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<label htmlFor="confirm-console-password">访</label>
<input
id="confirm-console-password"
type="password"
autoComplete="new-password"
value={confirmation}
onChange={(event) => setConfirmation(event.target.value)}
/>
<small> 8 </small>
</div>
) : null}
{status.configured ? <p>访 API </p> : null}
{error ? <p role="alert">{error}</p> : null}
{notice ? <p role="status">{notice}</p> : null}
<button type="submit" disabled={saving}>
{saving ? '正在保存…' : '保存密码保护设置'}
</button>
{status.protectionEnabled && status.authenticated ? (
<button
type="button"
onClick={() => {
void source.logout().then(() => window.location.reload());
}}
>
退
</button>
) : null}
</form>
) : !error ? (
<p role="status"></p>
) : null}
</section>
);
}
+95
View File
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ConsoleAuthGate, type ConsoleAuthDataSource } from './console-auth.js';
afterEach(cleanup);
const status = {
configured: true,
protectionEnabled: true,
authenticated: false,
};
describe('aggregate-console authentication UI', () => {
it('gates the entire console with the single-SimAdmin-style password form', async () => {
const user = userEvent.setup();
const login = vi.fn().mockResolvedValue(undefined);
const dataSource: ConsoleAuthDataSource = {
status: vi.fn().mockResolvedValue(status),
login,
logout: vi.fn(),
update: vi.fn(),
};
render(
<ConsoleAuthGate dataSource={dataSource}>
<p>secret fleet</p>
</ConsoleAuthGate>,
);
expect(await screen.findByRole('heading', { name: '请输入访问密码' })).toBeTruthy();
expect(screen.queryByText('secret fleet')).toBeNull();
await user.type(screen.getByLabelText('访问密码'), 'StrongPass!9');
await user.click(screen.getByRole('button', { name: '进入管理台' }));
expect(login).toHaveBeenCalledWith('StrongPass!9');
expect(await screen.findByText('secret fleet')).toBeTruthy();
});
it('shows a generic login failure without echoing credentials', async () => {
const user = userEvent.setup();
const dataSource: ConsoleAuthDataSource = {
status: vi.fn().mockResolvedValue(status),
login: vi.fn().mockRejectedValue(new Error('internal password hash mismatch')),
logout: vi.fn(),
update: vi.fn(),
};
render(<ConsoleAuthGate dataSource={dataSource}>fleet</ConsoleAuthGate>);
await screen.findByRole('heading', { name: '请输入访问密码' });
await user.type(screen.getByLabelText('访问密码'), 'WrongPass!9');
await user.click(screen.getByRole('button', { name: '进入管理台' }));
expect((await screen.findByRole('alert')).textContent).toContain('密码错误,请重试');
expect(screen.getByRole('alert').textContent).not.toContain('WrongPass');
expect(screen.getByRole('alert').textContent).not.toContain('hash');
});
it('configures and toggles password protection under system settings', async () => {
const user = userEvent.setup();
const update = vi
.fn()
.mockResolvedValueOnce({ configured: true, protectionEnabled: true, authenticated: true })
.mockResolvedValueOnce({ configured: true, protectionEnabled: false, authenticated: true });
const dataSource: ConsoleAuthDataSource = {
status: vi.fn().mockResolvedValue({
configured: false,
protectionEnabled: false,
authenticated: true,
}),
login: vi.fn(),
logout: vi.fn(),
update,
};
render(<ConsoleAuthGate dataSource={dataSource}>fleet</ConsoleAuthGate>);
await screen.findByText('fleet');
const settings = await import('./console-auth-settings.js');
cleanup();
render(<settings.ConsoleAuthSettings dataSource={dataSource} />);
expect(await screen.findByRole('heading', { name: '密码保护' })).toBeTruthy();
const toggle = screen.getByRole('checkbox', { name: /启用密码保护/ });
expect((toggle as HTMLInputElement).checked).toBe(false);
await user.click(toggle);
await user.type(screen.getByLabelText('设置访问密码'), 'StrongPass!9');
await user.type(screen.getByLabelText('确认访问密码'), 'StrongPass!9');
await user.click(screen.getByRole('button', { name: '保存密码保护设置' }));
expect(update).toHaveBeenNthCalledWith(1, { enabled: true, newPassword: 'StrongPass!9' });
expect(await screen.findByText('密码保护已启用。')).toBeTruthy();
await user.click(toggle);
await user.click(screen.getByRole('button', { name: '保存密码保护设置' }));
expect(update).toHaveBeenNthCalledWith(2, { enabled: false });
});
});
+150
View File
@@ -0,0 +1,150 @@
import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from 'react';
export interface ConsoleAuthStatus {
readonly configured: boolean;
readonly protectionEnabled: boolean;
readonly authenticated: boolean;
}
export interface ConsoleAuthDataSource {
status(): Promise<ConsoleAuthStatus>;
login(password: string): Promise<void>;
logout(): Promise<void>;
update(input: {
readonly enabled: boolean;
readonly newPassword?: string;
}): Promise<ConsoleAuthStatus>;
}
async function responseProblem(response: Response): Promise<Error> {
try {
const body = (await response.json()) as { detail?: unknown };
if (typeof body.detail === 'string') return new Error(body.detail);
} catch {
// Keep the browser boundary deliberately generic.
}
return new Error(`Request failed (${response.status})`);
}
export function createConsoleAuthApiDataSource(
fetcher: typeof fetch = fetch,
): ConsoleAuthDataSource {
const request = async (path: string, init?: RequestInit): Promise<Response> => {
const response = await fetcher(path, {
credentials: 'same-origin',
...(init?.method ? { method: init.method } : {}),
...(init?.body ? { body: init.body, headers: { 'content-type': 'application/json' } } : {}),
});
if (!response.ok) throw await responseProblem(response);
return response;
};
return {
async status() {
const response = await request('/api/v1/auth/status');
return (await response.json()) as ConsoleAuthStatus;
},
async login(password) {
await request('/api/v1/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
});
},
async logout() {
await request('/api/v1/auth/logout', { method: 'POST' });
},
async update(input) {
const response = await request('/api/v1/auth/settings', {
method: 'PUT',
body: JSON.stringify(input),
});
return (await response.json()) as ConsoleAuthStatus;
},
};
}
export interface ConsoleAuthGateProps {
readonly children: ReactNode;
readonly dataSource?: ConsoleAuthDataSource;
}
export function ConsoleAuthGate({ children, dataSource }: ConsoleAuthGateProps) {
const defaultDataSource = useMemo(() => createConsoleAuthApiDataSource(), []);
const source = dataSource ?? defaultDataSource;
const [status, setStatus] = useState<ConsoleAuthStatus>();
const [failed, setFailed] = useState(false);
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [loginFailed, setLoginFailed] = useState(false);
useEffect(() => {
let active = true;
void source.status().then(
(next) => {
if (active) setStatus(next);
},
() => {
if (active) setFailed(true);
},
);
return () => {
active = false;
};
}, [source]);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!password || submitting) return;
setSubmitting(true);
setLoginFailed(false);
try {
await source.login(password);
setStatus({ configured: true, protectionEnabled: true, authenticated: true });
setPassword('');
} catch {
setLoginFailed(true);
} finally {
setSubmitting(false);
}
};
if (failed)
return (
<main className="auth-screen">
<section className="auth-card" role="alert">
访
</section>
</main>
);
if (!status)
return (
<main className="auth-screen">
<p role="status">访</p>
</main>
);
if (status.protectionEnabled && !status.authenticated)
return (
<main className="auth-screen">
<section className="auth-card" aria-labelledby="console-login-title">
<p className="eyebrow">MULTI SIMADMIN</p>
<h1 id="console-login-title">访</h1>
<p></p>
<form onSubmit={(event) => void submit(event)}>
<label htmlFor="console-password">访</label>
<input
id="console-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoFocus
/>
{loginFailed ? <p role="alert"></p> : null}
<button type="submit" disabled={!password || submitting}>
{submitting ? '正在验证…' : '进入管理台'}
</button>
</form>
</section>
</main>
);
return children;
}
+4 -1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ConsoleAuthGate } from './auth/console-auth.js';
import { AppShell } from './app-shell.js';
import './styles.css';
@@ -9,6 +10,8 @@ if (!root) throw new Error('Missing application root');
createRoot(root).render(
<StrictMode>
<AppShell pathname={window.location.pathname} />
<ConsoleAuthGate>
<AppShell pathname={window.location.pathname} />
</ConsoleAuthGate>
</StrictMode>,
);
+48
View File
@@ -1030,6 +1030,54 @@ main ul[aria-label='已配置实例'] {
grid-column: auto;
}
}
.auth-screen {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1.5rem;
background:
radial-gradient(circle at 15% 10%, rgba(242, 169, 59, 0.18), transparent 34%), var(--background);
}
.auth-card {
width: min(100%, 27rem);
padding: 2rem;
border: 1px solid var(--border);
border-radius: 1.5rem;
background: var(--surface-raised);
box-shadow: var(--shadow);
}
.auth-card form,
.auth-password-fields {
display: grid;
gap: 0.75rem;
margin-top: 1.25rem;
}
.auth-card input,
.auth-settings input[type='password'] {
width: 100%;
}
.auth-settings {
max-width: 45rem;
display: grid;
gap: 1.25rem;
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
.toggle-row span {
display: grid;
gap: 0.375rem;
}
.toggle-row input[type='checkbox'] {
width: 2.875rem;
height: 1.5rem;
flex: 0 0 auto;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,