mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
c0683016c3
- Updated the deployment script to build the web application before deploying. - Upgraded Wrangler dependency from 4.61.1 to 4.69.0. feat: add import item limit and request body size limit - Introduced a new limit for the maximum total items allowed in a single import (5000). - Set a hard body size limit for JSON API endpoints (25 MB). feat: validate KDF parameters during registration and password change - Added validation for KDF parameters to ensure compliance with Bitwarden's minimum requirements. - Enhanced error handling for invalid KDF parameters during user registration and password change. feat: clean up R2 files on user deletion - Implemented cleanup of R2 files associated with user attachments and sends before deleting user metadata. feat: verify folder ownership when creating or updating ciphers - Added checks to ensure that users cannot reference folders owned by other users when creating or updating ciphers. fix: handle corrupted cipher data gracefully - Improved error handling when retrieving ciphers from the database to avoid crashes due to corrupted data. feat: increment send access count atomically - Added a method to atomically increment the access count for sends and return whether the update was successful. fix: enforce request body size limits - Implemented checks to reject oversized request bodies for non-file upload paths. fix: update error handling for database initialization - Enhanced error logging for database initialization failures while providing a generic message to clients. feat: enhance security with Content Security Policy - Added a Content Security Policy to the web application to improve security against XSS attacks. fix: remove plaintext TOTP secret from localStorage - Updated the TOTP enabling process to remove the plaintext secret from localStorage after it is stored on the server. fix: ensure only PBKDF2 hash is sent for public send access - Modified the public send access payload to ensure only the PBKDF2 hash is sent, never the plaintext password.
195 lines
8.2 KiB
TypeScript
195 lines
8.2 KiB
TypeScript
import { useEffect, useMemo, useState } from 'preact/hooks';
|
|
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
|
|
import qrcode from 'qrcode-generator';
|
|
import type { Profile } from '@/lib/types';
|
|
import { t } from '@/lib/i18n';
|
|
|
|
interface SettingsPageProps {
|
|
profile: Profile;
|
|
totpEnabled: boolean;
|
|
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 {
|
|
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 totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`;
|
|
const [currentPassword, setCurrentPassword] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [newPassword2, setNewPassword2] = useState('');
|
|
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) {
|
|
setTotpLocked(false);
|
|
return;
|
|
}
|
|
setTotpLocked(true);
|
|
}, [props.totpEnabled]);
|
|
|
|
const qrDataUrl = useMemo(() => {
|
|
const qr = qrcode(0, 'M');
|
|
qr.addData(buildOtpUri(props.profile.email, secret));
|
|
qr.make();
|
|
const svg = qr.createSvgTag({ scalable: true, margin: 0 });
|
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
|
}, [props.profile.email, secret]);
|
|
|
|
async function enableTotp(): Promise<void> {
|
|
await props.onEnableTotp(secret, token);
|
|
// Secret is now stored on the server; remove plaintext copy from localStorage.
|
|
localStorage.removeItem(totpSecretStorageKey);
|
|
setTotpLocked(true);
|
|
}
|
|
|
|
async function loadRecoveryCode(): Promise<void> {
|
|
const code = await props.onGetRecoveryCode(recoveryMasterPassword);
|
|
setRecoveryCode(code);
|
|
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
|
}
|
|
|
|
return (
|
|
<div className="stack">
|
|
<section className="card">
|
|
<h3>{t('txt_change_master_password')}</h3>
|
|
<label className="field">
|
|
<span>{t('txt_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>{t('txt_new_password')}</span>
|
|
<input className="input" type="password" value={newPassword} onInput={(e) => setNewPassword((e.currentTarget as HTMLInputElement).value)} />
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('txt_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)}
|
|
>
|
|
<KeyRound size={14} className="btn-icon" />
|
|
{t('txt_change_password')}
|
|
</button>
|
|
</section>
|
|
|
|
<section className="card">
|
|
<div className="settings-twofactor-grid">
|
|
<div className="settings-subcard">
|
|
<h3>{t('txt_totp')}</h3>
|
|
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
|
|
<div className="totp-grid">
|
|
<div className="totp-qr">
|
|
<img src={qrDataUrl} alt="TOTP QR" />
|
|
</div>
|
|
<div>
|
|
<div>
|
|
<label className="field">
|
|
<span>{t('txt_authenticator_key')}</span>
|
|
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('txt_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 ? t('txt_enabled') : t('txt_enable_totp')}
|
|
</button>
|
|
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
|
|
<RefreshCw size={14} className="btn-icon" />
|
|
{t('txt_regenerate')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
disabled={totpLocked}
|
|
onClick={() => {
|
|
void navigator.clipboard.writeText(secret);
|
|
props.onNotify?.('success', t('txt_secret_copied'));
|
|
}}
|
|
>
|
|
<Clipboard size={14} className="btn-icon" />
|
|
{t('txt_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" />
|
|
{t('txt_disable_totp')}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="settings-subcard">
|
|
<h3>{t('txt_recovery_code')}</h3>
|
|
<p className="muted-inline" style={{ marginBottom: 8 }}>
|
|
{t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}
|
|
</p>
|
|
<label className="field">
|
|
<span>{t('txt_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()}>
|
|
<ShieldCheck size={14} className="btn-icon" />
|
|
{t('txt_view_recovery_code')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
disabled={!recoveryCode}
|
|
onClick={() => {
|
|
void navigator.clipboard.writeText(recoveryCode);
|
|
props.onNotify?.('success', t('txt_recovery_code_copied'));
|
|
}}
|
|
>
|
|
{t('txt_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>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|