mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add cryptographic utilities and types for secure data handling
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user