feat: add cryptographic utilities and types for secure data handling

This commit is contained in:
shuaiplus
2026-02-28 01:02:34 +08:00
committed by Shuai
parent 3494471cad
commit 0cf8028087
29 changed files with 5757 additions and 2786 deletions
+116
View File
@@ -0,0 +1,116 @@
import { useState } from 'preact/hooks';
import type { AdminInvite, AdminUser } from '@/lib/types';
interface AdminPageProps {
users: AdminUser[];
invites: AdminInvite[];
onRefresh: () => void;
onCreateInvite: (hours: number) => Promise<void>;
onToggleUserStatus: (userId: string, currentStatus: string) => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
onRevokeInvite: (code: string) => Promise<void>;
}
export default function AdminPage(props: AdminPageProps) {
const [inviteHours, setInviteHours] = useState(168);
return (
<div className="stack">
<section className="card">
<div className="section-head">
<h3>Invites</h3>
<button type="button" className="btn btn-secondary" onClick={props.onRefresh}>
Sync
</button>
</div>
<div className="actions">
<input
className="input small"
type="number"
value={inviteHours}
min={1}
max={720}
onInput={(e) => setInviteHours(Number((e.currentTarget as HTMLInputElement).value || 168))}
/>
<button type="button" className="btn btn-primary" onClick={() => void props.onCreateInvite(inviteHours)}>
Create Invite
</button>
</div>
<table className="table">
<thead>
<tr>
<th>Code</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{props.invites.map((invite) => (
<tr key={invite.code}>
<td>{invite.code}</td>
<td>{invite.status}</td>
<td>
<div className="actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => navigator.clipboard.writeText(invite.inviteLink || '')}
>
Copy Link
</button>
{invite.status === 'active' && (
<button type="button" className="btn btn-danger" onClick={() => void props.onRevokeInvite(invite.code)}>
Revoke
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
<section className="card">
<h3>Users</h3>
<table className="table">
<thead>
<tr>
<th>Email</th>
<th>Name</th>
<th>Role</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{props.users.map((user) => (
<tr key={user.id}>
<td>{user.email}</td>
<td>{user.name || '-'}</td>
<td>{user.role}</td>
<td>{user.status}</td>
<td>
<div className="actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => void props.onToggleUserStatus(user.id, user.status)}
>
{user.status === 'active' ? 'Ban' : 'Unban'}
</button>
{user.role !== 'admin' && (
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteUser(user.id)}>
Delete
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
</div>
);
}
+173
View File
@@ -0,0 +1,173 @@
import { useState } from 'preact/hooks';
interface LoginValues {
email: string;
password: string;
}
interface RegisterValues {
name: string;
email: string;
password: string;
password2: string;
inviteCode: string;
}
interface AuthViewsProps {
mode: 'login' | 'register' | 'locked';
loginValues: LoginValues;
registerValues: RegisterValues;
unlockPassword: string;
emailForLock: string;
onChangeLogin: (next: LoginValues) => void;
onChangeRegister: (next: RegisterValues) => void;
onChangeUnlock: (password: string) => void;
onSubmitLogin: () => void;
onSubmitRegister: () => void;
onSubmitUnlock: () => void;
onGotoLogin: () => void;
onGotoRegister: () => void;
onLogout: () => void;
}
function PasswordField(props: {
label: string;
value: string;
onInput: (v: string) => void;
autoFocus?: boolean;
}) {
const [show, setShow] = useState(false);
return (
<label className="field">
<span>{props.label}</span>
<div className="password-wrap">
<input
className="input"
type={show ? 'text' : 'password'}
value={props.value}
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
autoFocus={props.autoFocus}
/>
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
{show ? 'Hide' : 'Show'}
</button>
</div>
</label>
);
}
export default function AuthViews(props: AuthViewsProps) {
if (props.mode === 'locked') {
return (
<div className="auth-page">
<div className="auth-card">
<h1>Unlock Vault</h1>
<p className="muted">{props.emailForLock}</p>
<PasswordField
label="Master Password"
value={props.unlockPassword}
autoFocus
onInput={props.onChangeUnlock}
/>
<button type="button" className="btn btn-primary full" onClick={props.onSubmitUnlock}>
Unlock
</button>
<div className="or">or</div>
<button type="button" className="btn btn-secondary full" onClick={props.onLogout}>
Log Out
</button>
</div>
</div>
);
}
if (props.mode === 'register') {
return (
<div className="auth-page">
<div className="auth-card">
<h1>Create Account</h1>
<p className="muted">NodeWarden</p>
<label className="field">
<span>Name</span>
<input
className="input"
value={props.registerValues.name}
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, name: (e.currentTarget as HTMLInputElement).value })
}
/>
</label>
<label className="field">
<span>Email</span>
<input
className="input"
type="email"
value={props.registerValues.email}
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, email: (e.currentTarget as HTMLInputElement).value })
}
/>
</label>
<PasswordField
label="Master Password"
value={props.registerValues.password}
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password: v })}
/>
<PasswordField
label="Confirm Master Password"
value={props.registerValues.password2}
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password2: v })}
/>
<label className="field">
<span>Invite Code (Optional)</span>
<input
className="input"
value={props.registerValues.inviteCode}
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, inviteCode: (e.currentTarget as HTMLInputElement).value })
}
/>
</label>
<button type="button" className="btn btn-primary full" onClick={props.onSubmitRegister}>
Create Account
</button>
<div className="or">or</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoLogin}>
Back To Login
</button>
</div>
</div>
);
}
return (
<div className="auth-page">
<div className="auth-card">
<h1>Log In</h1>
<p className="muted">NodeWarden</p>
<label className="field">
<span>Email</span>
<input
className="input"
type="email"
value={props.loginValues.email}
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
/>
</label>
<PasswordField
label="Master Password"
value={props.loginValues.password}
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
autoFocus
/>
<button type="button" className="btn btn-primary full" onClick={props.onSubmitLogin}>
Log In
</button>
<div className="or">or</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister}>
Create Account
</button>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { ComponentChildren } from 'preact';
interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
children?: ComponentChildren;
}
export default function ConfirmDialog(props: ConfirmDialogProps) {
if (!props.open) return null;
return (
<div className="dialog-mask">
<div className="dialog-card">
<div className="dialog-icon">!</div>
<h3 className="dialog-title">{props.title}</h3>
<div className="dialog-message">{props.message}</div>
{props.children}
<button
type="button"
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
onClick={props.onConfirm}
>
{props.confirmText || 'Yes'}
</button>
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
{props.cancelText || 'No'}
</button>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
export default function HelpPage() {
return (
<div className="stack">
<section className="card">
<h3>Upstream Sync</h3>
<ul>
<li>Use fork + scheduled sync workflow.</li>
<li>Before merging, compare API routes and auth flow changes.</li>
<li>After merging, run migration tests in local dev before deploy.</li>
</ul>
</section>
<section className="card">
<h3>Common Errors</h3>
<ul>
<li>401 Unauthorized: token expired, log in again.</li>
<li>403 Account disabled: admin must unban your account.</li>
<li>403 Invite invalid: invite expired or revoked.</li>
<li>429 Too many requests: wait and retry.</li>
</ul>
</section>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useMemo, useState } from 'preact/hooks';
import qrcode from 'qrcode-generator';
import type { Profile } from '@/lib/types';
interface SettingsPageProps {
profile: Profile;
onSaveProfile: (name: string, email: string) => Promise<void>;
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void;
}
function randomBase32Secret(length: number): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const random = crypto.getRandomValues(new Uint8Array(length));
let out = '';
for (const x of random) out += alphabet[x % alphabet.length];
return out;
}
function buildOtpUri(email: string, secret: string): string {
const issuer = 'NodeWarden';
return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
}
export default function SettingsPage(props: SettingsPageProps) {
const [name, setName] = useState(props.profile.name || '');
const [email, setEmail] = useState(props.profile.email || '');
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newPassword2, setNewPassword2] = useState('');
const [secret, setSecret] = useState(randomBase32Secret(32));
const [token, setToken] = useState('');
const qrSvg = useMemo(() => {
const qr = qrcode(0, 'M');
qr.addData(buildOtpUri(email || props.profile.email, secret));
qr.make();
return qr.createSvgTag({ scalable: true, margin: 0 });
}, [email, props.profile.email, secret]);
return (
<div className="stack">
<section className="card">
<h3>Profile</h3>
<div className="field-grid">
<label className="field">
<span>Name</span>
<input className="input" value={name} onInput={(e) => setName((e.currentTarget as HTMLInputElement).value)} />
</label>
<label className="field">
<span>Email</span>
<input
className="input"
type="email"
value={email}
onInput={(e) => setEmail((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</div>
<button type="button" className="btn btn-primary" onClick={() => void props.onSaveProfile(name, email)}>
Save Profile
</button>
</section>
<section className="card">
<h3>Change Master Password</h3>
<label className="field">
<span>Current Password</span>
<input
className="input"
type="password"
value={currentPassword}
onInput={(e) => setCurrentPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="field-grid">
<label className="field">
<span>New Password</span>
<input className="input" type="password" value={newPassword} onInput={(e) => setNewPassword((e.currentTarget as HTMLInputElement).value)} />
</label>
<label className="field">
<span>Confirm Password</span>
<input className="input" type="password" value={newPassword2} onInput={(e) => setNewPassword2((e.currentTarget as HTMLInputElement).value)} />
</label>
</div>
<button
type="button"
className="btn btn-danger"
onClick={() => void props.onChangePassword(currentPassword, newPassword, newPassword2)}
>
Change Password
</button>
</section>
<section className="card">
<h3>TOTP</h3>
<div className="totp-grid">
<div className="totp-qr" dangerouslySetInnerHTML={{ __html: qrSvg }} />
<div>
<label className="field">
<span>Authenticator Key</span>
<input className="input" value={secret} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
</label>
<label className="field">
<span>Verification Code</span>
<input className="input" value={token} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
</label>
<div className="actions">
<button type="button" className="btn btn-primary" onClick={() => void props.onEnableTotp(secret, token)}>
Enable TOTP
</button>
<button type="button" className="btn btn-secondary" onClick={() => setSecret(randomBase32Secret(32))}>
Regenerate
</button>
<button type="button" className="btn btn-secondary" onClick={() => navigator.clipboard.writeText(secret)}>
Copy Secret
</button>
</div>
</div>
</div>
<button type="button" className="btn btn-danger" onClick={props.onOpenDisableTotp}>
Disable TOTP
</button>
</section>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ToastMessage } from '@/lib/types';
interface ToastHostProps {
toasts: ToastMessage[];
onClose: (id: string) => void;
}
export default function ToastHost({ toasts, onClose }: ToastHostProps) {
if (!toasts.length) return null;
return (
<ul className="toast-stack">
{toasts.map((toast) => (
<li key={toast.id} className={`toast-item ${toast.type}`}>
<div className="toast-text">{toast.text}</div>
<button type="button" className="toast-close" onClick={() => onClose(toast.id)}>
x
</button>
<div className="toast-progress" />
</li>
))}
</ul>
);
}
File diff suppressed because it is too large Load Diff