mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add recovery code functionality and device management
This commit is contained in:
+169
-7
@@ -8,7 +8,9 @@ import ToastHost from '@/components/ToastHost';
|
||||
import VaultPage from '@/components/VaultPage';
|
||||
import SendsPage from '@/components/SendsPage';
|
||||
import PublicSendPage from '@/components/PublicSendPage';
|
||||
import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
|
||||
import SettingsPage from '@/components/SettingsPage';
|
||||
import SecurityDevicesPage from '@/components/SecurityDevicesPage';
|
||||
import AdminPage from '@/components/AdminPage';
|
||||
import HelpPage from '@/components/HelpPage';
|
||||
import {
|
||||
@@ -27,19 +29,25 @@ import {
|
||||
getCiphers,
|
||||
getFolders,
|
||||
getProfile,
|
||||
getAuthorizedDevices,
|
||||
getSetupStatus,
|
||||
getSends,
|
||||
getTotpStatus,
|
||||
getTotpRecoveryCode,
|
||||
getWebConfig,
|
||||
listAdminInvites,
|
||||
listAdminUsers,
|
||||
loadSession,
|
||||
loginWithPassword,
|
||||
registerAccount,
|
||||
recoverTwoFactor,
|
||||
revokeInvite,
|
||||
revokeAuthorizedDeviceTrust,
|
||||
revokeAllAuthorizedDeviceTrust,
|
||||
saveSession,
|
||||
setTotp,
|
||||
setUserStatus,
|
||||
deleteAuthorizedDevice,
|
||||
updateCipher,
|
||||
updateSend,
|
||||
buildSendShareKey,
|
||||
@@ -48,7 +56,7 @@ import {
|
||||
verifyMasterPassword,
|
||||
} from '@/lib/api';
|
||||
import { base64ToBytes, decryptBw, decryptStr, hkdf } from '@/lib/crypto';
|
||||
import type { AppPhase, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
|
||||
import type { AppPhase, AuthorizedDevice, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
|
||||
|
||||
interface PendingTotp {
|
||||
email: string;
|
||||
@@ -90,9 +98,11 @@ export default function App() {
|
||||
const [unlockPassword, setUnlockPassword] = useState('');
|
||||
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [rememberDevice, setRememberDevice] = useState(true);
|
||||
|
||||
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
|
||||
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
||||
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
||||
|
||||
const [confirm, setConfirm] = useState<{
|
||||
title: string;
|
||||
@@ -201,7 +211,7 @@ export default function App() {
|
||||
}
|
||||
try {
|
||||
const derived = await deriveLoginHash(loginValues.email, loginValues.password, defaultKdfIterations);
|
||||
const token = await loginWithPassword(loginValues.email, derived.hash);
|
||||
const token = await loginWithPassword(loginValues.email, derived.hash, { useRememberToken: true });
|
||||
if ('access_token' in token && token.access_token) {
|
||||
await finalizeLogin(token.access_token, token.refresh_token, loginValues.email.toLowerCase(), derived.masterKey);
|
||||
return;
|
||||
@@ -214,6 +224,7 @@ export default function App() {
|
||||
masterKey: derived.masterKey,
|
||||
});
|
||||
setTotpCode('');
|
||||
setRememberDevice(true);
|
||||
return;
|
||||
}
|
||||
pushToast('error', tokenError.error_description || tokenError.error || 'Login failed');
|
||||
@@ -228,7 +239,10 @@ export default function App() {
|
||||
pushToast('error', 'Please input TOTP code');
|
||||
return;
|
||||
}
|
||||
const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, totpCode.trim());
|
||||
const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, {
|
||||
totpCode: totpCode.trim(),
|
||||
rememberDevice,
|
||||
});
|
||||
if ('access_token' in token && token.access_token) {
|
||||
await finalizeLogin(token.access_token, token.refresh_token, pendingTotp.email, pendingTotp.masterKey);
|
||||
return;
|
||||
@@ -237,6 +251,34 @@ export default function App() {
|
||||
pushToast('error', tokenError.error_description || tokenError.error || 'TOTP verify failed');
|
||||
}
|
||||
|
||||
async function handleRecoverTwoFactorSubmit() {
|
||||
const email = recoverValues.email.trim().toLowerCase();
|
||||
const password = recoverValues.password;
|
||||
const recoveryCode = recoverValues.recoveryCode.trim();
|
||||
if (!email || !password || !recoveryCode) {
|
||||
pushToast('error', 'Email, password and recovery code are required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const derived = await deriveLoginHash(email, password, defaultKdfIterations);
|
||||
const recovered = await recoverTwoFactor(email, derived.hash, recoveryCode);
|
||||
const token = await loginWithPassword(email, derived.hash, { useRememberToken: false });
|
||||
if ('access_token' in token && token.access_token) {
|
||||
await finalizeLogin(token.access_token, token.refresh_token, email, derived.masterKey);
|
||||
if (recovered.newRecoveryCode) {
|
||||
pushToast('success', `2FA recovered. New recovery code: ${recovered.newRecoveryCode}`);
|
||||
} else {
|
||||
pushToast('success', '2FA recovered');
|
||||
}
|
||||
return;
|
||||
}
|
||||
pushToast('error', 'Recovered but auto-login failed, please sign in.');
|
||||
navigate('/login');
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : 'Recover 2FA failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
if (!registerValues.email || !registerValues.password) {
|
||||
pushToast('error', 'Please input email and password');
|
||||
@@ -345,6 +387,11 @@ export default function App() {
|
||||
queryFn: () => getTotpStatus(authedFetch),
|
||||
enabled: phase === 'app' && !!session?.accessToken,
|
||||
});
|
||||
const authorizedDevicesQuery = useQuery({
|
||||
queryKey: ['authorized-devices', session?.accessToken],
|
||||
queryFn: () => getAuthorizedDevices(authedFetch),
|
||||
enabled: phase === 'app' && !!session?.accessToken,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.symEncKey || !session?.symMacKey) {
|
||||
@@ -592,6 +639,28 @@ export default function App() {
|
||||
pushToast('success', 'Vault synced');
|
||||
}
|
||||
|
||||
async function refreshAuthorizedDevices() {
|
||||
await authorizedDevicesQuery.refetch();
|
||||
}
|
||||
|
||||
async function revokeDeviceTrustAction(device: AuthorizedDevice) {
|
||||
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
|
||||
await authorizedDevicesQuery.refetch();
|
||||
pushToast('success', 'Device authorization revoked');
|
||||
}
|
||||
|
||||
async function revokeAllDeviceTrustAction() {
|
||||
await revokeAllAuthorizedDeviceTrust(authedFetch);
|
||||
await authorizedDevicesQuery.refetch();
|
||||
pushToast('success', 'All device authorizations revoked');
|
||||
}
|
||||
|
||||
async function removeDeviceAction(device: AuthorizedDevice) {
|
||||
await deleteAuthorizedDevice(authedFetch, device.identifier);
|
||||
await authorizedDevicesQuery.refetch();
|
||||
pushToast('success', 'Device removed');
|
||||
}
|
||||
|
||||
async function createVaultItem(draft: VaultDraft) {
|
||||
if (!session) return;
|
||||
try {
|
||||
@@ -651,6 +720,16 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getRecoveryCodeAction(masterPassword: string): Promise<string> {
|
||||
if (!profile) throw new Error('Profile unavailable');
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error('Master password is required');
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const code = await getTotpRecoveryCode(authedFetch, derived.hash);
|
||||
if (!code) throw new Error('Recovery code is empty');
|
||||
return code;
|
||||
}
|
||||
|
||||
async function createSendItem(draft: SendDraft, autoCopyLink: boolean) {
|
||||
if (!session) return;
|
||||
try {
|
||||
@@ -732,8 +811,9 @@ export default function App() {
|
||||
|
||||
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
||||
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
|
||||
const effectiveLocation = hashPath.startsWith('/send/') ? hashPath : location;
|
||||
const effectiveLocation = hashPath.startsWith('/send/') || hashPath === '/recover-2fa' ? hashPath : location;
|
||||
const publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i);
|
||||
const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa';
|
||||
const isPublicSendRoute = !!publicSendMatch;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -749,6 +829,23 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isRecoverTwoFactorRoute && phase !== 'app') {
|
||||
return (
|
||||
<>
|
||||
<RecoverTwoFactorPage
|
||||
values={recoverValues}
|
||||
onChange={setRecoverValues}
|
||||
onSubmit={() => void handleRecoverTwoFactorSubmit()}
|
||||
onCancel={() => {
|
||||
setRecoverValues({ email: '', password: '', recoveryCode: '' });
|
||||
navigate('/login');
|
||||
}}
|
||||
/>
|
||||
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'loading') {
|
||||
return (
|
||||
<>
|
||||
@@ -790,12 +887,34 @@ export default function App() {
|
||||
onCancel={() => {
|
||||
setPendingTotp(null);
|
||||
setTotpCode('');
|
||||
setRememberDevice(true);
|
||||
}}
|
||||
afterActions={(
|
||||
<div className="dialog-extra">
|
||||
<div className="dialog-divider" />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary dialog-btn"
|
||||
onClick={() => {
|
||||
setPendingTotp(null);
|
||||
setTotpCode('');
|
||||
setRememberDevice(true);
|
||||
navigate('/recover-2fa');
|
||||
}}
|
||||
>
|
||||
Use Recovery Code
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<label className="field">
|
||||
<span>TOTP Code</span>
|
||||
<input className="input" value={totpCode} onInput={(e) => setTotpCode((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label className="check-line" style={{ marginBottom: 0 }}>
|
||||
<input type="checkbox" checked={rememberDevice} onChange={(e) => setRememberDevice((e.currentTarget as HTMLInputElement).checked)} />
|
||||
<span>Trust this device for 30 days</span>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
);
|
||||
@@ -815,9 +934,6 @@ export default function App() {
|
||||
<ShieldUser size={16} />
|
||||
<span>{profile?.email}</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => navigate('/settings')}>
|
||||
<Shield size={14} className="btn-icon" /> Account Security
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" onClick={handleLogout}>
|
||||
<LogOut size={14} className="btn-icon" /> Sign Out
|
||||
</button>
|
||||
@@ -844,6 +960,10 @@ export default function App() {
|
||||
<SettingsIcon size={16} />
|
||||
<span>System Settings</span>
|
||||
</Link>
|
||||
<Link href="/security/devices" className={`side-link ${location === '/security/devices' ? 'active' : ''}`}>
|
||||
<Shield size={16} />
|
||||
<span>Account Security</span>
|
||||
</Link>
|
||||
<Link href="/help" className={`side-link ${location === '/help' ? 'active' : ''}`}>
|
||||
<CircleHelp size={16} />
|
||||
<span>Support Center</span>
|
||||
@@ -892,9 +1012,51 @@ export default function App() {
|
||||
await totpStatusQuery.refetch();
|
||||
}}
|
||||
onOpenDisableTotp={() => setDisableTotpOpen(true)}
|
||||
onGetRecoveryCode={getRecoveryCodeAction}
|
||||
onNotify={pushToast}
|
||||
/>
|
||||
)}
|
||||
</Route>
|
||||
<Route path="/security/devices">
|
||||
<SecurityDevicesPage
|
||||
devices={authorizedDevicesQuery.data || []}
|
||||
loading={authorizedDevicesQuery.isFetching}
|
||||
onRefresh={() => void refreshAuthorizedDevices()}
|
||||
onRevokeTrust={(device) => {
|
||||
setConfirm({
|
||||
title: 'Revoke device authorization',
|
||||
message: `Revoke 30-day TOTP trust for "${device.name}"?`,
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
setConfirm(null);
|
||||
void revokeDeviceTrustAction(device);
|
||||
},
|
||||
});
|
||||
}}
|
||||
onRemoveDevice={(device) => {
|
||||
setConfirm({
|
||||
title: 'Remove device',
|
||||
message: `Remove device "${device.name}" and clear its 2FA trust?`,
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
setConfirm(null);
|
||||
void removeDeviceAction(device);
|
||||
},
|
||||
});
|
||||
}}
|
||||
onRevokeAll={() => {
|
||||
setConfirm({
|
||||
title: 'Revoke all trusted devices',
|
||||
message: 'Revoke 30-day TOTP trust from all devices?',
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
setConfirm(null);
|
||||
void revokeAllDeviceTrustAction();
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/admin">
|
||||
<AdminPage
|
||||
currentUserId={profile?.id || ''}
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ConfirmDialogProps {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
children?: ComponentChildren;
|
||||
afterActions?: ComponentChildren;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
@@ -31,6 +32,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
|
||||
{props.cancelText || 'No'}
|
||||
</button>
|
||||
{props.afterActions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { Eye, EyeOff } from 'lucide-preact';
|
||||
|
||||
interface RecoverTwoFactorPageProps {
|
||||
values: { email: string; password: string; recoveryCode: string };
|
||||
onChange: (next: { email: string; password: string; recoveryCode: string }) => void;
|
||||
onSubmit: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1>Recover Two-step Login</h1>
|
||||
<p className="muted">Sign in with your one-time recovery code to disable two-step verification.</p>
|
||||
|
||||
<label className="field">
|
||||
<span>Email</span>
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
value={props.values.email}
|
||||
onInput={(e) => props.onChange({ ...props.values, email: (e.currentTarget as HTMLInputElement).value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>Master Password</span>
|
||||
<div className="password-wrap">
|
||||
<input
|
||||
className="input"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={props.values.password}
|
||||
onInput={(e) => props.onChange({ ...props.values, password: (e.currentTarget as HTMLInputElement).value })}
|
||||
/>
|
||||
<button type="button" className="eye-btn" onClick={() => setShowPassword((v) => !v)}>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>Recovery Code</span>
|
||||
<input
|
||||
className="input"
|
||||
value={props.values.recoveryCode}
|
||||
onInput={(e) => props.onChange({ ...props.values, recoveryCode: (e.currentTarget as HTMLInputElement).value.toUpperCase() })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="field-grid">
|
||||
<button type="button" className="btn btn-primary" onClick={props.onSubmit}>
|
||||
Submit
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={props.onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Clock3, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
|
||||
import type { AuthorizedDevice } from '@/lib/types';
|
||||
|
||||
interface SecurityDevicesPageProps {
|
||||
devices: AuthorizedDevice[];
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
onRevokeTrust: (device: AuthorizedDevice) => void;
|
||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||
onRevokeAll: () => void;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function mapDeviceTypeName(type: number): string {
|
||||
switch (type) {
|
||||
case 0: return 'Android';
|
||||
case 1: return 'iOS';
|
||||
case 2: return 'Chrome Extension';
|
||||
case 3: return 'Firefox Extension';
|
||||
case 4: return 'Opera Extension';
|
||||
case 5: return 'Edge Extension';
|
||||
case 6: return 'Windows Desktop';
|
||||
case 7: return 'macOS Desktop';
|
||||
case 8: return 'Linux Desktop';
|
||||
case 9: return 'Chrome Browser';
|
||||
case 10: return 'Firefox Browser';
|
||||
case 11: return 'Opera Browser';
|
||||
case 12: return 'Edge Browser';
|
||||
case 13: return 'IE Browser';
|
||||
case 14: return 'Web';
|
||||
default: return `Type ${type}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h3 style={{ margin: 0 }}>Account Security</h3>
|
||||
<div className="muted-inline" style={{ marginTop: 4 }}>
|
||||
Manage authorized devices and 30-day TOTP trusted sessions.
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onRefresh}>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
Refresh
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
|
||||
<ShieldOff size={14} className="btn-icon" />
|
||||
Revoke All Trusted
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Authorized Devices</h3>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Type</th>
|
||||
<th>Added</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Trusted Until</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.devices.map((device) => (
|
||||
<tr key={device.identifier}>
|
||||
<td>
|
||||
<div>{device.name || 'Unknown device'}</div>
|
||||
<div className="muted-inline">{device.identifier}</div>
|
||||
</td>
|
||||
<td>{mapDeviceTypeName(device.type)}</td>
|
||||
<td>{formatDateTime(device.creationDate)}</td>
|
||||
<td>{formatDateTime(device.revisionDate)}</td>
|
||||
<td>
|
||||
{device.trusted ? (
|
||||
<div className="trusted-cell">
|
||||
<Clock3 size={13} />
|
||||
<span>{formatDateTime(device.trustedUntil)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="muted-inline">Not trusted</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={!device.trusted}
|
||||
onClick={() => props.onRevokeTrust(device)}
|
||||
>
|
||||
<ShieldOff size={14} className="btn-icon" />
|
||||
Revoke Trust
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger small" onClick={() => props.onRemoveDevice(device)}>
|
||||
<Trash2 size={14} className="btn-icon" />
|
||||
Remove Device
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!props.loading && props.devices.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6}>
|
||||
<div className="empty" style={{ minHeight: 80 }}>No devices found.</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ interface SettingsPageProps {
|
||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||
onOpenDisableTotp: () => void;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onNotify?: (type: 'success' | 'error', text: string) => void;
|
||||
}
|
||||
|
||||
function randomBase32Secret(length: number): string {
|
||||
@@ -35,6 +37,8 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32));
|
||||
const [token, setToken] = useState('');
|
||||
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
||||
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
|
||||
const [recoveryCode, setRecoveryCode] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.totpEnabled) {
|
||||
@@ -57,6 +61,12 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
setTotpLocked(true);
|
||||
}
|
||||
|
||||
async function loadRecoveryCode(): Promise<void> {
|
||||
const code = await props.onGetRecoveryCode(recoveryMasterPassword);
|
||||
setRecoveryCode(code);
|
||||
props.onNotify?.('success', 'Recovery code loaded');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
@@ -112,41 +122,90 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h3>TOTP</h3>
|
||||
{totpLocked && <div className="status-ok">TOTP is enabled for this account.</div>}
|
||||
<div className="totp-grid">
|
||||
<div className="totp-qr" dangerouslySetInnerHTML={{ __html: qrSvg }} />
|
||||
<div>
|
||||
<div>
|
||||
<label className="field">
|
||||
<span>Authenticator Key</span>
|
||||
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Verification Code</span>
|
||||
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
|
||||
<ShieldCheck size={14} className="btn-icon" />
|
||||
{totpLocked ? 'Enabled' : 'Enable TOTP'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
Regenerate
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => navigator.clipboard.writeText(secret)}>
|
||||
<Clipboard size={14} className="btn-icon" />
|
||||
Copy Secret
|
||||
</button>
|
||||
<div className="settings-twofactor-grid">
|
||||
<div className="settings-subcard">
|
||||
<h3>TOTP</h3>
|
||||
{totpLocked && <div className="status-ok">TOTP is enabled for this account.</div>}
|
||||
<div className="totp-grid">
|
||||
<div className="totp-qr" dangerouslySetInnerHTML={{ __html: qrSvg }} />
|
||||
<div>
|
||||
<div>
|
||||
<label className="field">
|
||||
<span>Authenticator Key</span>
|
||||
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Verification Code</span>
|
||||
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
|
||||
<ShieldCheck size={14} className="btn-icon" />
|
||||
{totpLocked ? 'Enabled' : 'Enable TOTP'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
Regenerate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={totpLocked}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(secret);
|
||||
props.onNotify?.('success', 'Secret copied');
|
||||
}}
|
||||
>
|
||||
<Clipboard size={14} className="btn-icon" />
|
||||
Copy Secret
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
|
||||
<ShieldOff size={14} className="btn-icon" />
|
||||
Disable TOTP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-subcard">
|
||||
<h3>Recovery Code</h3>
|
||||
<p className="muted-inline" style={{ marginBottom: 8 }}>
|
||||
This is a one-time code. After it is used, a new code is generated automatically.
|
||||
</p>
|
||||
<label className="field">
|
||||
<span>Master Password</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={recoveryMasterPassword}
|
||||
onInput={(e) => setRecoveryMasterPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void loadRecoveryCode()}>
|
||||
View Recovery Code
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={!recoveryCode}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(recoveryCode);
|
||||
props.onNotify?.('success', 'Recovery code copied');
|
||||
}}
|
||||
>
|
||||
Copy Code
|
||||
</button>
|
||||
</div>
|
||||
{recoveryCode && (
|
||||
<div className="card" style={{ marginTop: 10, marginBottom: 0 }}>
|
||||
<div style={{ fontWeight: 800, letterSpacing: '0.08em' }}>{recoveryCode}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-danger" onClick={props.onOpenDisableTotp}>
|
||||
<ShieldOff size={14} className="btn-icon" />
|
||||
Disable TOTP
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
+137
-3
@@ -1,5 +1,6 @@
|
||||
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, hkdfExpand, pbkdf2 } from './crypto';
|
||||
import type {
|
||||
AuthorizedDevice,
|
||||
AdminInvite,
|
||||
AdminUser,
|
||||
Cipher,
|
||||
@@ -18,6 +19,8 @@ import type {
|
||||
} from './types';
|
||||
|
||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
|
||||
const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
|
||||
|
||||
type SessionSetter = (next: SessionState | null) => void;
|
||||
|
||||
@@ -75,6 +78,42 @@ export interface PreloginResult {
|
||||
kdfIterations: number;
|
||||
}
|
||||
|
||||
function randomHex(length: number): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
||||
}
|
||||
|
||||
function getOrCreateDeviceIdentifier(): string {
|
||||
const current = (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
||||
if (current) return current;
|
||||
const next = `${randomHex(8)}-${randomHex(4)}-${randomHex(4)}-${randomHex(4)}-${randomHex(12)}`;
|
||||
localStorage.setItem(DEVICE_IDENTIFIER_KEY, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function guessDeviceName(): string {
|
||||
const ua = (typeof navigator !== 'undefined' ? navigator.userAgent : '').toLowerCase();
|
||||
const platform = (typeof navigator !== 'undefined' ? navigator.platform : '').trim();
|
||||
const browser = ua.includes('edg/') ? 'Edge' : ua.includes('chrome/') ? 'Chrome' : ua.includes('firefox/') ? 'Firefox' : ua.includes('safari/') ? 'Safari' : 'Browser';
|
||||
const os = ua.includes('windows') ? 'Windows' : ua.includes('mac os') ? 'macOS' : ua.includes('linux') ? 'Linux' : ua.includes('android') ? 'Android' : ua.includes('iphone') || ua.includes('ipad') ? 'iOS' : platform || 'Unknown OS';
|
||||
return `${browser} on ${os}`.slice(0, 128);
|
||||
}
|
||||
|
||||
function getRememberTwoFactorToken(): string | null {
|
||||
const token = (localStorage.getItem(TOTP_REMEMBER_TOKEN_KEY) || '').trim();
|
||||
return token || null;
|
||||
}
|
||||
|
||||
function saveRememberTwoFactorToken(token: string | undefined): void {
|
||||
const normalized = String(token || '').trim();
|
||||
if (!normalized) return;
|
||||
localStorage.setItem(TOTP_REMEMBER_TOKEN_KEY, normalized);
|
||||
}
|
||||
|
||||
function clearRememberTwoFactorToken(): void {
|
||||
localStorage.removeItem(TOTP_REMEMBER_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export async function deriveLoginHash(email: string, password: string, fallbackIterations: number): Promise<PreloginResult> {
|
||||
const pre = await fetch('/identity/accounts/prelogin', {
|
||||
method: 'POST',
|
||||
@@ -89,15 +128,34 @@ export async function deriveLoginHash(email: string, password: string, fallbackI
|
||||
return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations };
|
||||
}
|
||||
|
||||
export async function loginWithPassword(email: string, passwordHash: string, totpCode?: string): Promise<TokenSuccess | TokenError> {
|
||||
export async function loginWithPassword(
|
||||
email: string,
|
||||
passwordHash: string,
|
||||
options?: {
|
||||
totpCode?: string;
|
||||
rememberDevice?: boolean;
|
||||
useRememberToken?: boolean;
|
||||
}
|
||||
): Promise<TokenSuccess | TokenError> {
|
||||
const body = new URLSearchParams();
|
||||
body.set('grant_type', 'password');
|
||||
body.set('username', email.toLowerCase());
|
||||
body.set('password', passwordHash);
|
||||
body.set('scope', 'api offline_access');
|
||||
if (totpCode) {
|
||||
body.set('deviceIdentifier', getOrCreateDeviceIdentifier());
|
||||
body.set('deviceName', guessDeviceName());
|
||||
body.set('deviceType', '14');
|
||||
|
||||
const rememberedToken = options?.useRememberToken ? getRememberTwoFactorToken() : null;
|
||||
if (rememberedToken) {
|
||||
body.set('twoFactorProvider', '5');
|
||||
body.set('twoFactorToken', rememberedToken);
|
||||
} else if (options?.totpCode) {
|
||||
body.set('twoFactorProvider', '0');
|
||||
body.set('twoFactorToken', totpCode);
|
||||
body.set('twoFactorToken', options.totpCode);
|
||||
if (options.rememberDevice) {
|
||||
body.set('twoFactorRemember', '1');
|
||||
}
|
||||
}
|
||||
const resp = await fetch('/identity/connect/token', {
|
||||
method: 'POST',
|
||||
@@ -105,6 +163,12 @@ export async function loginWithPassword(email: string, passwordHash: string, tot
|
||||
body: body.toString(),
|
||||
});
|
||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||
if (resp.ok) {
|
||||
saveRememberTwoFactorToken((json as TokenSuccess).TwoFactorToken);
|
||||
} else if (rememberedToken) {
|
||||
// Remember-token login failed; force the next attempt to use real TOTP.
|
||||
clearRememberTwoFactorToken();
|
||||
}
|
||||
if (!resp.ok) return json;
|
||||
return json;
|
||||
}
|
||||
@@ -352,6 +416,76 @@ export async function getTotpStatus(
|
||||
return { enabled: !!body.enabled };
|
||||
}
|
||||
|
||||
export async function getTotpRecoveryCode(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||
masterPasswordHash: string
|
||||
): Promise<string> {
|
||||
const resp = await authedFetch('/api/accounts/totp/recovery-code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(body?.error_description || body?.error || 'Failed to get recovery code');
|
||||
}
|
||||
const body = (await parseJson<{ code?: string }>(resp)) || {};
|
||||
return String(body.code || '');
|
||||
}
|
||||
|
||||
export async function recoverTwoFactor(
|
||||
email: string,
|
||||
masterPasswordHash: string,
|
||||
recoveryCode: string
|
||||
): Promise<{ newRecoveryCode?: string }> {
|
||||
const resp = await fetch('/identity/accounts/recover-2fa', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: email.toLowerCase().trim(),
|
||||
masterPasswordHash,
|
||||
recoveryCode,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(body?.error_description || body?.error || 'Recover 2FA failed');
|
||||
}
|
||||
return (await parseJson<{ newRecoveryCode?: string }>(resp)) || {};
|
||||
}
|
||||
|
||||
export async function getAuthorizedDevices(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>
|
||||
): Promise<AuthorizedDevice[]> {
|
||||
const resp = await authedFetch('/api/devices/authorized');
|
||||
if (!resp.ok) throw new Error('Failed to load authorized devices');
|
||||
const body = await parseJson<ListResponse<AuthorizedDevice>>(resp);
|
||||
return body?.data || [];
|
||||
}
|
||||
|
||||
export async function revokeAuthorizedDeviceTrust(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||
deviceIdentifier: string
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch(`/api/devices/authorized/${encodeURIComponent(deviceIdentifier)}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error('Failed to revoke device authorization');
|
||||
}
|
||||
|
||||
export async function revokeAllAuthorizedDeviceTrust(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch('/api/devices/authorized', { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error('Failed to revoke all authorized devices');
|
||||
}
|
||||
|
||||
export async function deleteAuthorizedDevice(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||
deviceIdentifier: string
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch(`/api/devices/${encodeURIComponent(deviceIdentifier)}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error('Failed to remove device');
|
||||
}
|
||||
|
||||
export async function listAdminUsers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<AdminUser[]> {
|
||||
const resp = await authedFetch('/api/admin/users');
|
||||
if (!resp.ok) throw new Error('Failed to load users');
|
||||
|
||||
@@ -242,6 +242,7 @@ export interface WebConfigResponse {
|
||||
export interface TokenSuccess {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
TwoFactorToken?: string;
|
||||
}
|
||||
|
||||
export interface TokenError {
|
||||
@@ -270,3 +271,15 @@ export interface AdminInvite {
|
||||
status: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthorizedDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
identifier: string;
|
||||
type: number;
|
||||
creationDate: string | null;
|
||||
revisionDate: string | null;
|
||||
trusted: boolean;
|
||||
trustedTokenCount: number;
|
||||
trustedUntil: string | null;
|
||||
}
|
||||
|
||||
@@ -1050,6 +1050,12 @@ input[type='file'].input::file-selector-button:hover {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.trusted-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dialog-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1096,6 +1102,34 @@ input[type='file'].input::file-selector-button:hover {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dialog-extra {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dialog-divider {
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.settings-twofactor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-subcard {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.settings-subcard h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
@@ -1209,4 +1243,8 @@ input[type='file'].input::file-selector-button:hover {
|
||||
.uri-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-twofactor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user