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
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NodeWarden</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+756
View File
@@ -0,0 +1,756 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Link, Route, Switch, useLocation } from 'wouter';
import { useQuery } from '@tanstack/react-query';
import AuthViews from '@/components/AuthViews';
import ConfirmDialog from '@/components/ConfirmDialog';
import ToastHost from '@/components/ToastHost';
import VaultPage from '@/components/VaultPage';
import SettingsPage from '@/components/SettingsPage';
import AdminPage from '@/components/AdminPage';
import HelpPage from '@/components/HelpPage';
import {
changeMasterPassword,
createCipher,
createAuthedFetch,
createInvite,
deleteCipher,
deleteUser,
deriveLoginHash,
bulkMoveCiphers,
getCiphers,
getFolders,
getProfile,
getSetupStatus,
getWebConfig,
listAdminInvites,
listAdminUsers,
loadSession,
loginWithPassword,
registerAccount,
revokeInvite,
saveSession,
setTotp,
setUserStatus,
updateCipher,
unlockVaultKey,
updateProfile,
} from '@/lib/api';
import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto';
import type { AppPhase, Cipher, Folder, Profile, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
interface PendingTotp {
email: string;
passwordHash: string;
masterKey: Uint8Array;
}
export default function App() {
const [location, navigate] = useLocation();
const [phase, setPhase] = useState<AppPhase>('loading');
const [session, setSessionState] = useState<SessionState | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [defaultKdfIterations, setDefaultKdfIterations] = useState(600000);
const [setupRegistered, setSetupRegistered] = useState(true);
const [loginValues, setLoginValues] = useState({ email: '', password: '' });
const [registerValues, setRegisterValues] = useState({
name: '',
email: '',
password: '',
password2: '',
inviteCode: '',
});
const [unlockPassword, setUnlockPassword] = useState('');
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
const [totpCode, setTotpCode] = useState('');
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
const [disableTotpPassword, setDisableTotpPassword] = useState('');
const [confirm, setConfirm] = useState<{
title: string;
message: string;
danger?: boolean;
onConfirm: () => void;
} | null>(null);
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const [decryptedFolders, setDecryptedFolders] = useState<Folder[]>([]);
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
function setSession(next: SessionState | null) {
setSessionState(next);
saveSession(next);
}
function pushToast(type: ToastMessage['type'], text: string) {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
setToasts((prev) => [...prev.slice(-3), { id, type, text }]);
window.setTimeout(() => {
setToasts((prev) => prev.filter((x) => x.id !== id));
}, 4500);
}
const authedFetch = useMemo(
() =>
createAuthedFetch(
() => session,
(next) => {
setSession(next);
if (!next) {
setProfile(null);
setPhase(setupRegistered ? 'login' : 'register');
}
}
),
[session, setupRegistered]
);
useEffect(() => {
let mounted = true;
(async () => {
const [setup, config] = await Promise.all([getSetupStatus(), getWebConfig()]);
if (!mounted) return;
setSetupRegistered(setup.registered);
setDefaultKdfIterations(Number(config.defaultKdfIterations || 600000));
const loaded = loadSession();
if (!loaded) {
setPhase(setup.registered ? 'login' : 'register');
return;
}
setSession(loaded);
try {
const profileResp = await getProfile(
createAuthedFetch(
() => loaded,
(next) => {
if (!next) return;
setSession(next);
}
)
);
if (!mounted) return;
setProfile(profileResp);
setPhase('locked');
} catch {
setSession(null);
setPhase(setup.registered ? 'login' : 'register');
}
})();
return () => {
mounted = false;
};
}, []);
async function finalizeLogin(tokenAccess: string, tokenRefresh: string, email: string, masterKey: Uint8Array) {
const baseSession: SessionState = { accessToken: tokenAccess, refreshToken: tokenRefresh, email };
const tempFetch = createAuthedFetch(
() => baseSession,
() => {}
);
const profileResp = await getProfile(tempFetch);
const keys = await unlockVaultKey(profileResp.key, masterKey);
const nextSession = { ...baseSession, ...keys };
setSession(nextSession);
setProfile(profileResp);
setPendingTotp(null);
setTotpCode('');
setPhase('app');
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
navigate('/vault');
}
pushToast('success', 'Login success');
}
async function handleLogin() {
if (!loginValues.email || !loginValues.password) {
pushToast('error', 'Please input email and password');
return;
}
try {
const derived = await deriveLoginHash(loginValues.email, loginValues.password, defaultKdfIterations);
const token = await loginWithPassword(loginValues.email, derived.hash);
if ('access_token' in token && token.access_token) {
await finalizeLogin(token.access_token, token.refresh_token, loginValues.email.toLowerCase(), derived.masterKey);
return;
}
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
if (tokenError.TwoFactorProviders) {
setPendingTotp({
email: loginValues.email.toLowerCase(),
passwordHash: derived.hash,
masterKey: derived.masterKey,
});
setTotpCode('');
return;
}
pushToast('error', tokenError.error_description || tokenError.error || 'Login failed');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Login failed');
}
}
async function handleTotpVerify() {
if (!pendingTotp) return;
if (!totpCode.trim()) {
pushToast('error', 'Please input TOTP code');
return;
}
const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, totpCode.trim());
if ('access_token' in token && token.access_token) {
await finalizeLogin(token.access_token, token.refresh_token, pendingTotp.email, pendingTotp.masterKey);
return;
}
const tokenError = token as { error_description?: string; error?: string };
pushToast('error', tokenError.error_description || tokenError.error || 'TOTP verify failed');
}
async function handleRegister() {
if (!registerValues.email || !registerValues.password) {
pushToast('error', 'Please input email and password');
return;
}
if (registerValues.password.length < 12) {
pushToast('error', 'Master password must be at least 12 chars');
return;
}
if (registerValues.password !== registerValues.password2) {
pushToast('error', 'Passwords do not match');
return;
}
const resp = await registerAccount({
email: registerValues.email.toLowerCase(),
name: registerValues.name.trim(),
password: registerValues.password,
inviteCode: registerValues.inviteCode.trim(),
fallbackIterations: defaultKdfIterations,
});
if (!resp.ok) {
pushToast('error', resp.message);
return;
}
setLoginValues({ email: registerValues.email.toLowerCase(), password: '' });
setPhase('login');
pushToast('success', 'Registration succeeded. Please sign in.');
}
async function handleUnlock() {
if (!session || !profile) return;
if (!unlockPassword) {
pushToast('error', 'Please input master password');
return;
}
try {
const derived = await deriveLoginHash(profile.email || session.email, unlockPassword, defaultKdfIterations);
const keys = await unlockVaultKey(profile.key, derived.masterKey);
setSession({ ...session, ...keys });
setUnlockPassword('');
setPhase('app');
if (location === '/' || location === '/lock') navigate('/vault');
pushToast('success', 'Unlocked');
} catch {
pushToast('error', 'Unlock failed. Master password is incorrect.');
}
}
function handleLock() {
if (!session) return;
const nextSession = { ...session };
delete nextSession.symEncKey;
delete nextSession.symMacKey;
setSession(nextSession);
setPhase('locked');
navigate('/lock');
}
function handleLogout() {
setConfirm({
title: 'Log Out',
message: 'Are you sure you want to log out?',
onConfirm: () => {
setConfirm(null);
setSession(null);
setProfile(null);
setPendingTotp(null);
setPhase(setupRegistered ? 'login' : 'register');
navigate('/login');
},
});
}
const ciphersQuery = useQuery({
queryKey: ['ciphers', session?.accessToken],
queryFn: () => getCiphers(authedFetch),
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
});
const foldersQuery = useQuery({
queryKey: ['folders', session?.accessToken],
queryFn: () => getFolders(authedFetch),
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
});
const usersQuery = useQuery({
queryKey: ['admin-users', session?.accessToken],
queryFn: () => listAdminUsers(authedFetch),
enabled: phase === 'app' && profile?.role === 'admin',
});
const invitesQuery = useQuery({
queryKey: ['admin-invites', session?.accessToken],
queryFn: () => listAdminInvites(authedFetch),
enabled: phase === 'app' && profile?.role === 'admin',
});
useEffect(() => {
if (!session?.symEncKey || !session?.symMacKey) {
setDecryptedFolders([]);
setDecryptedCiphers([]);
return;
}
if (!foldersQuery.data || !ciphersQuery.data) return;
let active = true;
(async () => {
try {
const encKey = base64ToBytes(session.symEncKey!);
const macKey = base64ToBytes(session.symMacKey!);
const folders = await Promise.all(
foldersQuery.data.map(async (folder) => ({
...folder,
decName: await decryptStr(folder.name, encKey, macKey),
}))
);
const ciphers = await Promise.all(
ciphersQuery.data.map(async (cipher) => {
let itemEnc = encKey;
let itemMac = macKey;
if (cipher.key) {
try {
const itemKey = await decryptBw(cipher.key, encKey, macKey);
itemEnc = itemKey.slice(0, 32);
itemMac = itemKey.slice(32, 64);
} catch {
// keep user key when item key decrypt fails
}
}
const nextCipher: Cipher = {
...cipher,
decName: await decryptStr(cipher.name || '', itemEnc, itemMac),
decNotes: await decryptStr(cipher.notes || '', itemEnc, itemMac),
};
if (cipher.login) {
nextCipher.login = {
...cipher.login,
decUsername: await decryptStr(cipher.login.username || '', itemEnc, itemMac),
decPassword: await decryptStr(cipher.login.password || '', itemEnc, itemMac),
decTotp: await decryptStr(cipher.login.totp || '', itemEnc, itemMac),
uris: await Promise.all(
(cipher.login.uris || []).map(async (u) => ({
...u,
decUri: await decryptStr(u.uri || '', itemEnc, itemMac),
}))
),
};
}
if (cipher.card) {
nextCipher.card = {
...cipher.card,
decCardholderName: await decryptStr(cipher.card.cardholderName || '', itemEnc, itemMac),
decNumber: await decryptStr(cipher.card.number || '', itemEnc, itemMac),
decBrand: await decryptStr(cipher.card.brand || '', itemEnc, itemMac),
decExpMonth: await decryptStr(cipher.card.expMonth || '', itemEnc, itemMac),
decExpYear: await decryptStr(cipher.card.expYear || '', itemEnc, itemMac),
decCode: await decryptStr(cipher.card.code || '', itemEnc, itemMac),
};
}
if (cipher.identity) {
nextCipher.identity = {
...cipher.identity,
decTitle: await decryptStr(cipher.identity.title || '', itemEnc, itemMac),
decFirstName: await decryptStr(cipher.identity.firstName || '', itemEnc, itemMac),
decMiddleName: await decryptStr(cipher.identity.middleName || '', itemEnc, itemMac),
decLastName: await decryptStr(cipher.identity.lastName || '', itemEnc, itemMac),
decUsername: await decryptStr(cipher.identity.username || '', itemEnc, itemMac),
decCompany: await decryptStr(cipher.identity.company || '', itemEnc, itemMac),
decSsn: await decryptStr(cipher.identity.ssn || '', itemEnc, itemMac),
decPassportNumber: await decryptStr(cipher.identity.passportNumber || '', itemEnc, itemMac),
decLicenseNumber: await decryptStr(cipher.identity.licenseNumber || '', itemEnc, itemMac),
decEmail: await decryptStr(cipher.identity.email || '', itemEnc, itemMac),
decPhone: await decryptStr(cipher.identity.phone || '', itemEnc, itemMac),
decAddress1: await decryptStr(cipher.identity.address1 || '', itemEnc, itemMac),
decAddress2: await decryptStr(cipher.identity.address2 || '', itemEnc, itemMac),
decAddress3: await decryptStr(cipher.identity.address3 || '', itemEnc, itemMac),
decCity: await decryptStr(cipher.identity.city || '', itemEnc, itemMac),
decState: await decryptStr(cipher.identity.state || '', itemEnc, itemMac),
decPostalCode: await decryptStr(cipher.identity.postalCode || '', itemEnc, itemMac),
decCountry: await decryptStr(cipher.identity.country || '', itemEnc, itemMac),
};
}
if (cipher.sshKey) {
nextCipher.sshKey = {
...cipher.sshKey,
decPrivateKey: await decryptStr(cipher.sshKey.privateKey || '', itemEnc, itemMac),
decPublicKey: await decryptStr(cipher.sshKey.publicKey || '', itemEnc, itemMac),
decFingerprint: await decryptStr(cipher.sshKey.fingerprint || '', itemEnc, itemMac),
};
}
if (cipher.fields) {
nextCipher.fields = await Promise.all(
cipher.fields.map(async (field) => ({
...field,
decName: await decryptStr(field.name || '', itemEnc, itemMac),
decValue: await decryptStr(field.value || '', itemEnc, itemMac),
}))
);
}
return nextCipher;
})
);
if (!active) return;
setDecryptedFolders(folders);
setDecryptedCiphers(ciphers);
} catch (error) {
if (!active) return;
pushToast('error', error instanceof Error ? error.message : 'Decrypt failed');
}
})();
return () => {
active = false;
};
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data]);
async function saveProfileAction(name: string, email: string) {
try {
const updated = await updateProfile(authedFetch, { name: name.trim(), email: email.trim().toLowerCase() });
setProfile(updated);
pushToast('success', 'Profile updated');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Save profile failed');
}
}
async function changePasswordAction(currentPassword: string, nextPassword: string, nextPassword2: string) {
if (!profile) return;
if (!currentPassword || !nextPassword) {
pushToast('error', 'Current/new password is required');
return;
}
if (nextPassword.length < 12) {
pushToast('error', 'New password must be at least 12 chars');
return;
}
if (nextPassword !== nextPassword2) {
pushToast('error', 'New passwords do not match');
return;
}
try {
await changeMasterPassword(authedFetch, {
email: profile.email,
currentPassword,
newPassword: nextPassword,
currentIterations: defaultKdfIterations,
profileKey: profile.key,
});
handleLogout();
pushToast('success', 'Master password changed. Please login again.');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Change password failed');
}
}
async function enableTotpAction(secret: string, token: string) {
if (!secret.trim() || !token.trim()) {
pushToast('error', 'Secret and code are required');
return;
}
try {
await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() });
pushToast('success', 'TOTP enabled');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Enable TOTP failed');
}
}
async function disableTotpAction() {
if (!profile) return;
if (!disableTotpPassword) {
pushToast('error', 'Please input master password');
return;
}
try {
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
setDisableTotpOpen(false);
setDisableTotpPassword('');
pushToast('success', 'TOTP disabled');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Disable TOTP failed');
}
}
async function refreshVault() {
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Vault synced');
}
async function createVaultItem(draft: VaultDraft) {
if (!session) return;
try {
await createCipher(authedFetch, session, draft);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Item created');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Create item failed');
throw error;
}
}
async function updateVaultItem(cipher: Cipher, draft: VaultDraft) {
if (!session) return;
try {
await updateCipher(authedFetch, session, cipher, draft);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Item updated');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Update item failed');
throw error;
}
}
async function deleteVaultItem(cipher: Cipher) {
try {
await deleteCipher(authedFetch, cipher.id);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Item deleted');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Delete item failed');
throw error;
}
}
async function bulkDeleteVaultItems(ids: string[]) {
try {
for (const id of ids) {
await deleteCipher(authedFetch, id);
}
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Deleted selected items');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Bulk delete failed');
throw error;
}
}
async function bulkMoveVaultItems(ids: string[], folderId: string | null) {
try {
await bulkMoveCiphers(authedFetch, ids, folderId);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Moved selected items');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Bulk move failed');
throw error;
}
}
useEffect(() => {
if (phase === 'app' && location === '/') navigate('/vault');
}, [phase, location, navigate]);
if (phase === 'loading') {
return (
<>
<div className="loading-screen">Loading NodeWarden...</div>
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
</>
);
}
if (phase === 'register' || phase === 'login' || phase === 'locked') {
return (
<>
<AuthViews
mode={phase}
loginValues={loginValues}
registerValues={registerValues}
unlockPassword={unlockPassword}
emailForLock={profile?.email || session?.email || ''}
onChangeLogin={setLoginValues}
onChangeRegister={setRegisterValues}
onChangeUnlock={setUnlockPassword}
onSubmitLogin={() => void handleLogin()}
onSubmitRegister={() => void handleRegister()}
onSubmitUnlock={() => void handleUnlock()}
onGotoLogin={() => setPhase('login')}
onGotoRegister={() => setPhase('register')}
onLogout={handleLogout}
/>
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
<ConfirmDialog
open={!!pendingTotp}
title="Two-step verification"
message="Password is already verified."
confirmText="Verify"
cancelText="Cancel"
onConfirm={() => void handleTotpVerify()}
onCancel={() => {
setPendingTotp(null);
setTotpCode('');
}}
>
<label className="field">
<span>TOTP Code</span>
<input className="input" value={totpCode} onInput={(e) => setTotpCode((e.currentTarget as HTMLInputElement).value)} />
</label>
</ConfirmDialog>
</>
);
}
return (
<>
<div className="app-shell">
<header className="topbar">
<div className="brand">NodeWarden</div>
<nav className="nav">
<Link href="/vault" className={`nav-link ${location === '/vault' ? 'active' : ''}`}>
Vault
</Link>
<Link href="/settings" className={`nav-link ${location === '/settings' ? 'active' : ''}`}>
Settings
</Link>
{profile?.role === 'admin' && (
<Link href="/admin" className={`nav-link ${location === '/admin' ? 'active' : ''}`}>
Admin
</Link>
)}
<Link href="/help" className={`nav-link ${location === '/help' ? 'active' : ''}`}>
Help
</Link>
</nav>
<div className="topbar-actions">
<span className="user-email">{profile?.email}</span>
<button type="button" className="btn btn-secondary small" onClick={handleLock}>
Lock
</button>
<button type="button" className="btn btn-secondary small" onClick={handleLogout}>
Log Out
</button>
</div>
</header>
<main className="content">
<Switch>
<Route path="/vault">
<VaultPage
ciphers={decryptedCiphers}
folders={decryptedFolders}
loading={ciphersQuery.isFetching || foldersQuery.isFetching}
onRefresh={refreshVault}
onCreate={createVaultItem}
onUpdate={updateVaultItem}
onDelete={deleteVaultItem}
onBulkDelete={bulkDeleteVaultItems}
onBulkMove={bulkMoveVaultItems}
/>
</Route>
<Route path="/settings">
{profile && (
<SettingsPage
profile={profile}
onSaveProfile={saveProfileAction}
onChangePassword={changePasswordAction}
onEnableTotp={enableTotpAction}
onOpenDisableTotp={() => setDisableTotpOpen(true)}
/>
)}
</Route>
<Route path="/admin">
<AdminPage
users={usersQuery.data || []}
invites={invitesQuery.data || []}
onRefresh={() => {
void usersQuery.refetch();
void invitesQuery.refetch();
}}
onCreateInvite={async (hours) => {
await createInvite(authedFetch, hours);
await invitesQuery.refetch();
pushToast('success', 'Invite created');
}}
onToggleUserStatus={async (userId, status) => {
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
await usersQuery.refetch();
pushToast('success', 'User status updated');
}}
onDeleteUser={async (userId) => {
setConfirm({
title: 'Delete user',
message: 'Delete this user and all user data?',
danger: true,
onConfirm: () => {
setConfirm(null);
void (async () => {
await deleteUser(authedFetch, userId);
await usersQuery.refetch();
pushToast('success', 'User deleted');
})();
},
});
}}
onRevokeInvite={async (code) => {
await revokeInvite(authedFetch, code);
await invitesQuery.refetch();
pushToast('success', 'Invite revoked');
}}
/>
</Route>
<Route path="/help">
<HelpPage />
</Route>
</Switch>
</main>
</div>
<ConfirmDialog
open={!!confirm}
title={confirm?.title || ''}
message={confirm?.message || ''}
danger={confirm?.danger}
onConfirm={() => confirm?.onConfirm()}
onCancel={() => setConfirm(null)}
/>
<ConfirmDialog
open={disableTotpOpen}
title="Disable TOTP"
message="Enter master password to disable two-step verification."
confirmText="Disable TOTP"
cancelText="Cancel"
danger
onConfirm={() => void disableTotpAction()}
onCancel={() => {
setDisableTotpOpen(false);
setDisableTotpPassword('');
}}
>
<label className="field">
<span>Master Password</span>
<input
className="input"
type="password"
value={disableTotpPassword}
onInput={(e) => setDisableTotpPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
</>
);
}
+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
+597
View File
@@ -0,0 +1,597 @@
import { base64ToBytes, bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from './crypto';
import type {
AdminInvite,
AdminUser,
Cipher,
Folder,
ListResponse,
Profile,
SessionState,
SetupStatusResponse,
TokenError,
TokenSuccess,
VaultDraft,
VaultDraftField,
WebConfigResponse,
} from './types';
const SESSION_KEY = 'nodewarden.web.session.v4';
type SessionSetter = (next: SessionState | null) => void;
export function loadSession(): SessionState | null {
try {
const raw = localStorage.getItem(SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as SessionState;
if (!parsed.accessToken || !parsed.refreshToken) return null;
return parsed;
} catch {
return null;
}
}
export function saveSession(session: SessionState | null): void {
if (!session) {
localStorage.removeItem(SESSION_KEY);
return;
}
const persisted: SessionState = {
accessToken: session.accessToken,
refreshToken: session.refreshToken,
email: session.email,
symEncKey: session.symEncKey,
symMacKey: session.symMacKey,
};
localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
}
async function parseJson<T>(response: Response): Promise<T | null> {
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text) as T;
} catch {
return null;
}
}
export async function getSetupStatus(): Promise<SetupStatusResponse> {
const resp = await fetch('/setup/status');
const body = await parseJson<SetupStatusResponse>(resp);
return { registered: !!body?.registered };
}
export async function getWebConfig(): Promise<WebConfigResponse> {
const resp = await fetch('/api/web/config');
return (await parseJson<WebConfigResponse>(resp)) || {};
}
export interface PreloginResult {
hash: string;
masterKey: Uint8Array;
kdfIterations: number;
}
export async function deriveLoginHash(email: string, password: string, fallbackIterations: number): Promise<PreloginResult> {
const pre = await fetch('/identity/accounts/prelogin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.toLowerCase() }),
});
if (!pre.ok) throw new Error('prelogin failed');
const data = (await parseJson<{ kdfIterations?: number }>(pre)) || {};
const iterations = Number(data.kdfIterations || fallbackIterations);
const masterKey = await pbkdf2(password, email.toLowerCase(), iterations, 32);
const hash = await pbkdf2(masterKey, password, 1, 32);
return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations };
}
export async function loginWithPassword(email: string, passwordHash: string, totpCode?: string): 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('twoFactorProvider', '0');
body.set('twoFactorToken', totpCode);
}
const resp = await fetch('/identity/connect/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
if (!resp.ok) return json;
return json;
}
export async function refreshAccessToken(refreshToken: string): Promise<TokenSuccess | null> {
const body = new URLSearchParams();
body.set('grant_type', 'refresh_token');
body.set('refresh_token', refreshToken);
const resp = await fetch('/identity/connect/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!resp.ok) return null;
const json = await parseJson<TokenSuccess>(resp);
return json || null;
}
export async function registerAccount(args: {
email: string;
name: string;
password: string;
inviteCode?: string;
fallbackIterations: number;
}): Promise<{ ok: true } | { ok: false; message: string }> {
try {
const { email, name, password, inviteCode, fallbackIterations } = args;
const masterKey = await pbkdf2(password, email, fallbackIterations, 32);
const masterHash = await pbkdf2(masterKey, password, 1, 32);
const encKey = await hkdfExpand(masterKey, 'enc', 32);
const macKey = await hkdfExpand(masterKey, 'mac', 32);
const sym = crypto.getRandomValues(new Uint8Array(64));
const encryptedVaultKey = await encryptBw(sym, encKey, macKey);
const keyPair = await crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-1',
},
true,
['encrypt', 'decrypt']
);
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey));
const encryptedPrivateKey = await encryptBw(privateKey, sym.slice(0, 32), sym.slice(32, 64));
const resp = await fetch('/api/accounts/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email.toLowerCase(),
name,
masterPasswordHash: bytesToBase64(masterHash),
key: encryptedVaultKey,
kdf: 0,
kdfIterations: fallbackIterations,
inviteCode: inviteCode || undefined,
keys: {
publicKey: bytesToBase64(publicKey),
encryptedPrivateKey,
},
}),
});
if (!resp.ok) {
const json = await parseJson<TokenError>(resp);
return { ok: false, message: json?.error_description || json?.error || 'Register failed' };
}
return { ok: true };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : 'Register failed' };
}
}
export function createAuthedFetch(getSession: () => SessionState | null, setSession: SessionSetter) {
return async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> {
const session = getSession();
if (!session?.accessToken) throw new Error('Unauthorized');
const headers = new Headers(init.headers || {});
headers.set('Authorization', `Bearer ${session.accessToken}`);
let resp = await fetch(input, { ...init, headers });
if (resp.status !== 401 || !session.refreshToken) return resp;
const refreshed = await refreshAccessToken(session.refreshToken);
if (!refreshed?.access_token) {
setSession(null);
throw new Error('Session expired');
}
const nextSession: SessionState = {
...session,
accessToken: refreshed.access_token,
refreshToken: refreshed.refresh_token || session.refreshToken,
};
setSession(nextSession);
saveSession(nextSession);
const retryHeaders = new Headers(init.headers || {});
retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
resp = await fetch(input, { ...init, headers: retryHeaders });
return resp;
};
}
export async function getProfile(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Profile> {
const resp = await authedFetch('/api/accounts/profile');
if (!resp.ok) throw new Error('Failed to load profile');
const body = await parseJson<Profile>(resp);
if (!body) throw new Error('Invalid profile');
return body;
}
export async function unlockVaultKey(profileKey: string, masterKey: Uint8Array): Promise<{ symEncKey: string; symMacKey: string }> {
const encKey = await hkdfExpand(masterKey, 'enc', 32);
const macKey = await hkdfExpand(masterKey, 'mac', 32);
const keyBytes = await decryptBw(profileKey, encKey, macKey);
if (!keyBytes || keyBytes.length < 64) throw new Error('Invalid profile key');
return {
symEncKey: bytesToBase64(keyBytes.slice(0, 32)),
symMacKey: bytesToBase64(keyBytes.slice(32, 64)),
};
}
export async function getFolders(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Folder[]> {
const resp = await authedFetch('/api/folders');
if (!resp.ok) throw new Error('Failed to load folders');
const body = await parseJson<ListResponse<Folder>>(resp);
return body?.data || [];
}
export async function getCiphers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Cipher[]> {
const resp = await authedFetch('/api/ciphers');
if (!resp.ok) throw new Error('Failed to load ciphers');
const body = await parseJson<ListResponse<Cipher>>(resp);
return body?.data || [];
}
export async function updateProfile(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
payload: { name: string; email: string }
): Promise<Profile> {
const resp = await authedFetch('/api/accounts/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error('Save profile failed');
const body = await parseJson<Profile>(resp);
if (!body) throw new Error('Invalid profile');
return body;
}
export async function changeMasterPassword(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
args: {
email: string;
currentPassword: string;
newPassword: string;
currentIterations: number;
profileKey: string;
}
): Promise<void> {
const current = await deriveLoginHash(args.email, args.currentPassword, args.currentIterations);
const oldEnc = await hkdfExpand(current.masterKey, 'enc', 32);
const oldMac = await hkdfExpand(current.masterKey, 'mac', 32);
const userSym = await decryptBw(args.profileKey, oldEnc, oldMac);
const nextMasterKey = await pbkdf2(args.newPassword, args.email, current.kdfIterations, 32);
const nextHash = await pbkdf2(nextMasterKey, args.newPassword, 1, 32);
const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32);
const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32);
const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac);
const resp = await authedFetch('/api/accounts/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
currentPasswordHash: current.hash,
newMasterPasswordHash: bytesToBase64(nextHash),
newKey,
kdf: 0,
kdfIterations: current.kdfIterations,
}),
});
if (!resp.ok) throw new Error('Change master password failed');
}
export async function setTotp(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
payload: { enabled: boolean; token?: string; secret?: string; masterPasswordHash?: string }
): Promise<void> {
const resp = await authedFetch('/api/accounts/totp', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(body?.error_description || body?.error || 'TOTP update failed');
}
}
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');
const body = await parseJson<ListResponse<AdminUser>>(resp);
return body?.data || [];
}
export async function listAdminInvites(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<AdminInvite[]> {
const resp = await authedFetch('/api/admin/invites?includeInactive=true');
if (!resp.ok) throw new Error('Failed to load invites');
const body = await parseJson<ListResponse<AdminInvite>>(resp);
return body?.data || [];
}
export async function createInvite(authedFetch: (input: string, init?: RequestInit) => Promise<Response>, hours: number): Promise<void> {
const resp = await authedFetch('/api/admin/invites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expiresInHours: hours }),
});
if (!resp.ok) throw new Error('Create invite failed');
}
export async function revokeInvite(authedFetch: (input: string, init?: RequestInit) => Promise<Response>, code: string): Promise<void> {
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' });
if (!resp.ok) throw new Error('Revoke invite failed');
}
export async function setUserStatus(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
userId: string,
status: 'active' | 'banned'
): Promise<void> {
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
if (!resp.ok) throw new Error('Update user status failed');
}
export async function deleteUser(authedFetch: (input: string, init?: RequestInit) => Promise<Response>, userId: string): Promise<void> {
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' });
if (!resp.ok) throw new Error('Delete user failed');
}
function asNullable(v: string): string | null {
const s = String(v || '').trim();
return s ? s : null;
}
function parseFieldType(v: number | string): 0 | 1 | 2 | 3 {
if (typeof v === 'number') {
if (v === 1 || v === 2 || v === 3) return v;
return 0;
}
const s = String(v).trim().toLowerCase();
if (s === '1' || s === 'hidden') return 1;
if (s === '2' || s === 'boolean' || s === 'checkbox') return 2;
if (s === '3' || s === 'linked' || s === 'link') return 3;
return 0;
}
async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array): Promise<string | null> {
const s = String(value || '');
if (!s.trim()) return null;
return encryptBw(new TextEncoder().encode(s), enc, mac);
}
async function encryptCustomFields(fields: VaultDraftField[], enc: Uint8Array, mac: Uint8Array): Promise<Array<{ type: number; name: string | null; value: string | null }>> {
const out: Array<{ type: number; name: string | null; value: string | null }> = [];
for (const field of fields || []) {
const label = String(field.label || '').trim();
if (!label) continue;
out.push({
type: parseFieldType(field.type),
name: await encryptTextValue(label, enc, mac),
value: await encryptTextValue(String(field.value || ''), enc, mac),
});
}
return out;
}
async function encryptUris(uris: string[], enc: Uint8Array, mac: Uint8Array): Promise<Array<{ uri: string | null; match: null }>> {
const out: Array<{ uri: string | null; match: null }> = [];
for (const uri of uris || []) {
const trimmed = String(uri || '').trim();
if (!trimmed) continue;
out.push({ uri: await encryptTextValue(trimmed, enc, mac), match: null });
}
return out;
}
async function getCipherKeys(cipher: Cipher | null, userEnc: Uint8Array, userMac: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array; key: string | null }> {
if (cipher?.key) {
try {
const raw = await decryptBw(cipher.key, userEnc, userMac);
if (raw.length >= 64) return { enc: raw.slice(0, 32), mac: raw.slice(32, 64), key: cipher.key };
} catch {
// use user key
}
}
return { enc: userEnc, mac: userMac, key: null };
}
export async function createCipher(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
draft: VaultDraft
): Promise<void> {
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
const enc = base64ToBytes(session.symEncKey);
const mac = base64ToBytes(session.symMacKey);
const type = Number(draft.type || 1);
const payload: Record<string, unknown> = {
type,
folderId: asNullable(draft.folderId),
reprompt: draft.reprompt ? 1 : 0,
name: await encryptTextValue(draft.name, enc, mac),
notes: await encryptTextValue(draft.notes, enc, mac),
login: null,
card: null,
identity: null,
secureNote: null,
sshKey: null,
fields: await encryptCustomFields(draft.customFields || [], enc, mac),
};
if (type === 1) {
payload.login = {
username: await encryptTextValue(draft.loginUsername, enc, mac),
password: await encryptTextValue(draft.loginPassword, enc, mac),
totp: await encryptTextValue(draft.loginTotp, enc, mac),
uris: await encryptUris(draft.loginUris || [], enc, mac),
};
} else if (type === 3) {
payload.card = {
cardholderName: await encryptTextValue(draft.cardholderName, enc, mac),
number: await encryptTextValue(draft.cardNumber, enc, mac),
brand: await encryptTextValue(draft.cardBrand, enc, mac),
expMonth: await encryptTextValue(draft.cardExpMonth, enc, mac),
expYear: await encryptTextValue(draft.cardExpYear, enc, mac),
code: await encryptTextValue(draft.cardCode, enc, mac),
};
} else if (type === 4) {
payload.identity = {
title: await encryptTextValue(draft.identTitle, enc, mac),
firstName: await encryptTextValue(draft.identFirstName, enc, mac),
middleName: await encryptTextValue(draft.identMiddleName, enc, mac),
lastName: await encryptTextValue(draft.identLastName, enc, mac),
username: await encryptTextValue(draft.identUsername, enc, mac),
company: await encryptTextValue(draft.identCompany, enc, mac),
ssn: await encryptTextValue(draft.identSsn, enc, mac),
passportNumber: await encryptTextValue(draft.identPassportNumber, enc, mac),
licenseNumber: await encryptTextValue(draft.identLicenseNumber, enc, mac),
email: await encryptTextValue(draft.identEmail, enc, mac),
phone: await encryptTextValue(draft.identPhone, enc, mac),
address1: await encryptTextValue(draft.identAddress1, enc, mac),
address2: await encryptTextValue(draft.identAddress2, enc, mac),
address3: await encryptTextValue(draft.identAddress3, enc, mac),
city: await encryptTextValue(draft.identCity, enc, mac),
state: await encryptTextValue(draft.identState, enc, mac),
postalCode: await encryptTextValue(draft.identPostalCode, enc, mac),
country: await encryptTextValue(draft.identCountry, enc, mac),
};
} else if (type === 5) {
payload.sshKey = {
privateKey: await encryptTextValue(draft.sshPrivateKey, enc, mac),
publicKey: await encryptTextValue(draft.sshPublicKey, enc, mac),
fingerprint: await encryptTextValue(draft.sshFingerprint, enc, mac),
};
} else if (type === 2) {
payload.secureNote = { type: 0 };
}
const resp = await authedFetch('/api/ciphers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error('Create item failed');
}
export async function updateCipher(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
cipher: Cipher,
draft: VaultDraft
): Promise<void> {
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
const userEnc = base64ToBytes(session.symEncKey);
const userMac = base64ToBytes(session.symMacKey);
const keys = await getCipherKeys(cipher, userEnc, userMac);
const type = Number(draft.type || cipher.type || 1);
const payload: Record<string, unknown> = {
id: cipher.id,
type,
key: keys.key,
folderId: asNullable(draft.folderId),
favorite: !!cipher.favorite,
reprompt: draft.reprompt ? 1 : 0,
name: await encryptTextValue(draft.name, keys.enc, keys.mac),
notes: await encryptTextValue(draft.notes, keys.enc, keys.mac),
login: null,
card: null,
identity: null,
secureNote: null,
sshKey: null,
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
};
if (type === 1) {
payload.login = {
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
};
} else if (type === 3) {
payload.card = {
cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
number: await encryptTextValue(draft.cardNumber, keys.enc, keys.mac),
brand: await encryptTextValue(draft.cardBrand, keys.enc, keys.mac),
expMonth: await encryptTextValue(draft.cardExpMonth, keys.enc, keys.mac),
expYear: await encryptTextValue(draft.cardExpYear, keys.enc, keys.mac),
code: await encryptTextValue(draft.cardCode, keys.enc, keys.mac),
};
} else if (type === 4) {
payload.identity = {
title: await encryptTextValue(draft.identTitle, keys.enc, keys.mac),
firstName: await encryptTextValue(draft.identFirstName, keys.enc, keys.mac),
middleName: await encryptTextValue(draft.identMiddleName, keys.enc, keys.mac),
lastName: await encryptTextValue(draft.identLastName, keys.enc, keys.mac),
username: await encryptTextValue(draft.identUsername, keys.enc, keys.mac),
company: await encryptTextValue(draft.identCompany, keys.enc, keys.mac),
ssn: await encryptTextValue(draft.identSsn, keys.enc, keys.mac),
passportNumber: await encryptTextValue(draft.identPassportNumber, keys.enc, keys.mac),
licenseNumber: await encryptTextValue(draft.identLicenseNumber, keys.enc, keys.mac),
email: await encryptTextValue(draft.identEmail, keys.enc, keys.mac),
phone: await encryptTextValue(draft.identPhone, keys.enc, keys.mac),
address1: await encryptTextValue(draft.identAddress1, keys.enc, keys.mac),
address2: await encryptTextValue(draft.identAddress2, keys.enc, keys.mac),
address3: await encryptTextValue(draft.identAddress3, keys.enc, keys.mac),
city: await encryptTextValue(draft.identCity, keys.enc, keys.mac),
state: await encryptTextValue(draft.identState, keys.enc, keys.mac),
postalCode: await encryptTextValue(draft.identPostalCode, keys.enc, keys.mac),
country: await encryptTextValue(draft.identCountry, keys.enc, keys.mac),
};
} else if (type === 5) {
payload.sshKey = {
privateKey: await encryptTextValue(draft.sshPrivateKey, keys.enc, keys.mac),
publicKey: await encryptTextValue(draft.sshPublicKey, keys.enc, keys.mac),
fingerprint: await encryptTextValue(draft.sshFingerprint, keys.enc, keys.mac),
};
} else if (type === 2) {
payload.secureNote = { type: 0 };
}
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error('Update item failed');
}
export async function deleteCipher(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
cipherId: string
): Promise<void> {
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipherId)}`, { method: 'DELETE' });
if (!resp.ok) throw new Error('Delete item failed');
}
export async function bulkMoveCiphers(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
ids: string[],
folderId: string | null
): Promise<void> {
const resp = await authedFetch('/api/ciphers/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, folderId }),
});
if (!resp.ok) throw new Error('Bulk move failed');
}
+174
View File
@@ -0,0 +1,174 @@
export function bytesToBase64(bytes: Uint8Array): string {
let s = '';
for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
export function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
return out;
}
export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
const out = new Uint8Array(a.length + b.length);
out.set(a, 0);
out.set(b, a.length);
return out;
}
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
return new Uint8Array(bytes).buffer;
}
export async function pbkdf2(
passwordOrBytes: string | Uint8Array,
saltOrBytes: string | Uint8Array,
iterations: number,
keyLen: number
): Promise<Uint8Array> {
const pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes;
const saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes;
const key = await crypto.subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', hash: 'SHA-256', salt: toBufferSource(saltBytes), iterations },
key,
keyLen * 8
);
return new Uint8Array(bits);
}
export async function hkdfExpand(prk: Uint8Array, info: string, length: number): Promise<Uint8Array> {
const infoBytes = new TextEncoder().encode(info || '');
const key = await crypto.subtle.importKey('raw', toBufferSource(prk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const result = new Uint8Array(length);
let previous = new Uint8Array(0);
let offset = 0;
let counter = 1;
while (offset < length) {
const input = new Uint8Array(previous.length + infoBytes.length + 1);
input.set(previous, 0);
input.set(infoBytes, previous.length);
input[input.length - 1] = counter & 0xff;
previous = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(input)));
const copyLen = Math.min(previous.length, length - offset);
result.set(previous.slice(0, copyLen), offset);
offset += copyLen;
counter += 1;
}
return result;
}
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(dataBytes)));
}
async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-CBC' }, false, ['encrypt']);
return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
}
async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-CBC' }, false, ['decrypt']);
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
}
export async function encryptBw(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(16));
const cipher = await encryptAesCbc(data, encKey, iv);
const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
return `2.${bytesToBase64(iv)}|${bytesToBase64(cipher)}|${bytesToBase64(mac)}`;
}
function parseCipherString(s: string): { type: number; iv: Uint8Array; ct: Uint8Array; mac: Uint8Array | null } {
if (!s || typeof s !== 'string') throw new Error('invalid encrypted string');
const p = s.indexOf('.');
if (p <= 0) throw new Error('invalid encrypted string');
const type = Number(s.slice(0, p));
const body = s.slice(p + 1);
const parts = body.split('|');
if (type === 2 && parts.length === 3) {
return { type: 2, iv: base64ToBytes(parts[0]), ct: base64ToBytes(parts[1]), mac: base64ToBytes(parts[2]) };
}
if ((type === 0 || type === 1 || type === 4) && parts.length >= 2) {
return { type, iv: base64ToBytes(parts[0]), ct: base64ToBytes(parts[1]), mac: null };
}
throw new Error('unsupported enc type');
}
export async function decryptBw(cipherString: string, encKey: Uint8Array, macKey?: Uint8Array): Promise<Uint8Array> {
const parsed = parseCipherString(cipherString);
if (parsed.type === 2 && macKey && parsed.mac) {
const expected = await hmacSha256(macKey, concatBytes(parsed.iv, parsed.ct));
if (bytesToBase64(expected) !== bytesToBase64(parsed.mac)) throw new Error('MAC mismatch');
}
return decryptAesCbc(parsed.ct, encKey, parsed.iv);
}
export async function decryptStr(cipherString: string | null | undefined, encKey: Uint8Array, macKey?: Uint8Array): Promise<string> {
if (!cipherString || typeof cipherString !== 'string') return '';
const plain = await decryptBw(cipherString, encKey, macKey);
return new TextDecoder().decode(plain);
}
export function extractTotpSecret(raw: string): string {
if (!raw) return '';
const s = raw.trim();
if (!s) return '';
if (/^otpauth:\/\//i.test(s)) {
try {
const u = new URL(s);
return (u.searchParams.get('secret') || '').toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
} catch {
return '';
}
}
return s.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
}
function base32ToBytes(input: string): Uint8Array {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const clean = input.toUpperCase().replace(/[^A-Z2-7]/g, '');
let bits = 0;
let value = 0;
const out: number[] = [];
for (let i = 0; i < clean.length; i += 1) {
const idx = alphabet.indexOf(clean.charAt(i));
if (idx < 0) continue;
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return new Uint8Array(out);
}
export async function calcTotpNow(rawSecret: string): Promise<{ code: string; remain: number } | null> {
const secret = extractTotpSecret(rawSecret);
if (!secret) return null;
const keyBytes = base32ToBytes(secret);
if (!keyBytes.length) return null;
const step = 30;
const epoch = Math.floor(Date.now() / 1000);
const counter = Math.floor(epoch / step);
const remain = step - (epoch % step);
const message = new Uint8Array(8);
let c = counter;
for (let i = 7; i >= 0; i -= 1) {
message[i] = c & 0xff;
c = Math.floor(c / 256);
}
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
const hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(message)));
const offset = hs[hs.length - 1] & 0x0f;
const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff);
const code = (bin % 1000000).toString().padStart(6, '0');
return { code, remain };
}
+222
View File
@@ -0,0 +1,222 @@
export type AppPhase = 'loading' | 'register' | 'login' | 'locked' | 'app';
export interface SessionState {
accessToken: string;
refreshToken: string;
email: string;
symEncKey?: string;
symMacKey?: string;
}
export interface Profile {
id: string;
email: string;
name: string;
key: string;
role: 'admin' | 'user';
[k: string]: unknown;
}
export interface Folder {
id: string;
name: string;
decName?: string;
}
export interface CipherLoginUri {
uri?: string | null;
decUri?: string;
}
export interface CipherLogin {
username?: string | null;
password?: string | null;
totp?: string | null;
uris?: CipherLoginUri[] | null;
decUsername?: string;
decPassword?: string;
decTotp?: string;
}
export interface CipherCard {
cardholderName?: string | null;
number?: string | null;
brand?: string | null;
expMonth?: string | null;
expYear?: string | null;
code?: string | null;
decCardholderName?: string;
decNumber?: string;
decBrand?: string;
decExpMonth?: string;
decExpYear?: string;
decCode?: string;
}
export interface CipherIdentity {
title?: string | null;
firstName?: string | null;
middleName?: string | null;
lastName?: string | null;
username?: string | null;
company?: string | null;
ssn?: string | null;
passportNumber?: string | null;
licenseNumber?: string | null;
email?: string | null;
phone?: string | null;
address1?: string | null;
address2?: string | null;
address3?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
decTitle?: string;
decFirstName?: string;
decMiddleName?: string;
decLastName?: string;
decUsername?: string;
decCompany?: string;
decSsn?: string;
decPassportNumber?: string;
decLicenseNumber?: string;
decEmail?: string;
decPhone?: string;
decAddress1?: string;
decAddress2?: string;
decAddress3?: string;
decCity?: string;
decState?: string;
decPostalCode?: string;
decCountry?: string;
}
export interface CipherSshKey {
privateKey?: string | null;
publicKey?: string | null;
fingerprint?: string | null;
decPrivateKey?: string;
decPublicKey?: string;
decFingerprint?: string;
}
export interface CipherField {
type?: number | string | null;
name?: string | null;
value?: string | null;
decName?: string;
decValue?: string;
}
export interface Cipher {
id: string;
type: number;
folderId?: string | null;
favorite?: boolean;
reprompt?: number;
name?: string | null;
notes?: string | null;
key?: string | null;
login?: CipherLogin | null;
card?: CipherCard | null;
identity?: CipherIdentity | null;
sshKey?: CipherSshKey | null;
fields?: CipherField[] | null;
decName?: string;
decNotes?: string;
}
export type CustomFieldType = 0 | 1 | 2 | 3;
export interface VaultDraftField {
type: CustomFieldType;
label: string;
value: string;
}
export interface VaultDraft {
id?: string;
type: number;
name: string;
folderId: string;
notes: string;
reprompt: boolean;
loginUsername: string;
loginPassword: string;
loginTotp: string;
loginUris: string[];
cardholderName: string;
cardNumber: string;
cardBrand: string;
cardExpMonth: string;
cardExpYear: string;
cardCode: string;
identTitle: string;
identFirstName: string;
identMiddleName: string;
identLastName: string;
identUsername: string;
identCompany: string;
identSsn: string;
identPassportNumber: string;
identLicenseNumber: string;
identEmail: string;
identPhone: string;
identAddress1: string;
identAddress2: string;
identAddress3: string;
identCity: string;
identState: string;
identPostalCode: string;
identCountry: string;
sshPrivateKey: string;
sshPublicKey: string;
sshFingerprint: string;
customFields: VaultDraftField[];
}
export interface ListResponse<T> {
object: 'list';
data: T[];
}
export interface SetupStatusResponse {
registered: boolean;
}
export interface WebConfigResponse {
defaultKdfIterations?: number;
}
export interface TokenSuccess {
access_token: string;
refresh_token: string;
}
export interface TokenError {
error?: string;
error_description?: string;
TwoFactorProviders?: unknown;
}
export interface ToastMessage {
id: string;
type: 'success' | 'error';
text: string;
}
export interface AdminUser {
id: string;
email: string;
name?: string;
role: string;
status: string;
}
export interface AdminInvite {
code: string;
inviteLink?: string;
status: string;
expiresAt?: string;
}
+20
View File
@@ -0,0 +1,20 @@
import { render } from 'preact';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './styles.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
});
render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
document.getElementById('root')!
);
+700
View File
@@ -0,0 +1,700 @@
:root {
--bg: #f3f5f8;
--panel: #ffffff;
--line: #d7dde6;
--text: #0f172a;
--muted: #667085;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--danger: #e11d48;
--danger-hover: #be123c;
--radius: 12px;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
color: var(--text);
background: var(--bg);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
.loading-screen {
height: 100%;
display: grid;
place-items: center;
color: var(--muted);
font-size: 18px;
}
.auth-page {
min-height: 100%;
display: grid;
place-items: center;
padding: 24px;
}
.auth-card {
width: min(640px, 100%);
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: 0 10px 32px rgba(15, 23, 42, 0.08);
padding: 28px;
}
.auth-card h1 {
margin: 0 0 4px 0;
text-align: center;
}
.muted {
margin: 0 0 16px 0;
text-align: center;
color: var(--muted);
}
.field {
display: block;
margin-bottom: 14px;
}
.field > span {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
}
.input {
width: 100%;
height: 48px;
border: 1px solid #3f5b9e;
border-radius: 10px;
padding: 10px 12px;
font-size: 16px;
outline: none;
background: #fff;
}
.textarea {
min-height: 110px;
height: auto;
resize: vertical;
}
.input:focus {
border-color: #2f5fd8;
}
.password-wrap {
position: relative;
}
.password-wrap .input {
padding-right: 88px;
}
.eye-btn {
position: absolute;
right: 42px;
bottom: 9px;
width: 30px;
height: 30px;
border: none;
background: transparent;
cursor: pointer;
}
.btn {
height: 42px;
border: 1px solid transparent;
border-radius: 999px;
padding: 0 16px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.btn.full {
width: 100%;
height: 50px;
font-size: 22px;
}
.btn.small {
height: 34px;
font-size: 14px;
}
.btn-primary {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.btn-primary:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.btn-secondary {
background: #fff;
border-color: var(--primary);
color: var(--primary);
}
.btn-secondary:hover {
background: #eff5ff;
}
.btn-danger {
background: #fff;
border-color: var(--danger);
color: var(--danger);
}
.btn-danger:hover {
background: #fff1f2;
}
.or {
text-align: center;
margin: 10px 0;
color: #334155;
}
.app-shell {
height: 100%;
display: flex;
flex-direction: column;
}
.topbar {
height: 64px;
background: var(--primary);
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
}
.brand {
font-size: 20px;
font-weight: 800;
}
.nav {
display: flex;
gap: 8px;
}
.nav-link {
color: rgba(255, 255, 255, 0.85);
text-decoration: none;
padding: 8px 14px;
border-radius: 10px;
font-weight: 600;
}
.nav-link.active,
.nav-link:hover {
color: #fff;
background: rgba(255, 255, 255, 0.16);
}
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.user-email {
font-size: 13px;
opacity: 0.9;
}
.content {
flex: 1;
padding: 14px;
overflow: auto;
width: min(1540px, 100%);
margin: 0 auto;
}
.vault-grid {
display: grid;
grid-template-columns: 280px minmax(360px, 43%) 1fr;
gap: 12px;
height: calc(100vh - 64px - 28px);
}
.sidebar,
.list-panel,
.card {
background: #fff;
border: 1px solid var(--line);
border-radius: 12px;
}
.sidebar {
padding: 10px;
overflow: auto;
}
.sidebar-block {
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px;
margin-bottom: 10px;
}
.sidebar-title {
font-size: 13px;
font-weight: 700;
color: #475467;
margin-bottom: 8px;
}
.search-input {
width: 100%;
height: 42px;
border: 1px solid var(--primary);
border-radius: 10px;
padding: 0 12px;
}
.tree-btn {
width: 100%;
border: none;
background: transparent;
text-align: left;
border-radius: 8px;
padding: 8px 10px;
margin-bottom: 4px;
cursor: pointer;
}
.tree-btn.active {
background: #eef4ff;
color: #175ddc;
font-weight: 700;
}
.list-col {
display: flex;
flex-direction: column;
min-width: 0;
}
.toolbar {
margin-bottom: 8px;
}
.list-panel {
overflow: auto;
min-height: 0;
}
.list-item {
width: 100%;
background: #fff;
border-bottom: 1px solid var(--line);
padding: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.list-item:hover {
background: #f8fbff;
}
.list-item.active {
background: #edf4ff;
}
.row-check {
width: 16px;
height: 16px;
}
.row-main {
flex: 1;
border: none;
background: transparent;
padding: 0;
display: flex;
gap: 10px;
text-align: left;
cursor: pointer;
}
.list-icon-wrap {
width: 24px;
height: 24px;
display: grid;
place-items: center;
flex-shrink: 0;
}
.list-icon {
width: 24px;
height: 24px;
border-radius: 6px;
}
.list-icon-fallback {
font-size: 20px;
}
.list-text {
min-width: 0;
}
.list-title {
display: block;
color: #175ddc;
font-size: 18px;
font-weight: 700;
}
.list-sub {
display: block;
color: #64748b;
margin-top: 4px;
}
.detail-col {
overflow: auto;
}
.card {
padding: 14px 16px;
margin-bottom: 10px;
}
.card h4 {
margin-top: 0;
margin-bottom: 12px;
}
.detail-title {
margin: 0;
}
.detail-sub {
color: #667085;
margin-top: 8px;
}
.kv-line {
display: flex;
justify-content: space-between;
gap: 10px;
border-bottom: 1px solid #ecf0f5;
padding: 10px 0;
}
.kv-line:last-child {
border-bottom: none;
}
.kv-line > span {
color: #64748b;
}
.notes {
white-space: pre-wrap;
color: #334155;
min-height: 48px;
}
.empty {
color: #667085;
display: grid;
place-items: center;
min-height: 120px;
}
.stack {
display: grid;
gap: 12px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.totp-grid {
display: grid;
grid-template-columns: 220px 1fr;
gap: 14px;
margin-bottom: 14px;
}
.totp-qr {
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
display: grid;
place-items: center;
min-height: 220px;
padding: 8px;
}
.totp-qr svg {
width: 180px;
height: 180px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.create-menu-wrap {
position: relative;
}
.create-menu {
position: absolute;
left: 0;
top: calc(100% + 6px);
width: 220px;
background: #fff;
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
overflow: hidden;
z-index: 20;
}
.create-menu-item {
width: 100%;
border: none;
background: #fff;
text-align: left;
padding: 11px 12px;
cursor: pointer;
font-weight: 600;
}
.create-menu-item:hover {
background: #f1f5f9;
}
.uri-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto auto;
gap: 8px;
margin-bottom: 8px;
}
.field-type-pill {
align-self: center;
height: 34px;
line-height: 34px;
border-radius: 999px;
background: #eef4ff;
color: #175ddc;
font-size: 12px;
font-weight: 700;
padding: 0 10px;
}
.detail-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin: 12px 0;
}
.local-error {
margin-top: 10px;
color: #b42318;
font-weight: 600;
}
.kv-line strong {
overflow-wrap: anywhere;
}
.check-line {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
color: #334155;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
text-align: left;
border-bottom: 1px solid var(--line);
padding: 10px 8px;
font-size: 14px;
}
.table th {
color: #667085;
}
.input.small {
width: 120px;
}
.dialog-mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.5);
display: grid;
place-items: center;
z-index: 1200;
padding: 20px;
}
.dialog-card {
width: min(460px, 100%);
background: #fff;
border-radius: 20px;
border: 1px solid var(--line);
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.2);
padding: 20px;
text-align: center;
}
.dialog-card .field {
text-align: left;
}
.dialog-icon {
font-size: 34px;
color: #f59e0b;
}
.dialog-title {
margin: 6px 0;
font-size: 30px;
}
.dialog-message {
color: #475467;
margin-bottom: 10px;
}
.dialog-btn {
width: 100%;
height: 50px;
font-size: 20px;
margin-top: 8px;
}
.toast-stack {
position: fixed;
top: 16px;
right: 16px;
z-index: 1400;
width: min(420px, calc(100vw - 20px));
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 10px;
}
.toast-item {
position: relative;
border-radius: 10px;
border: 1px solid #bbdfc6;
background: #dff4e5;
color: #0f5132;
padding: 12px 14px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
overflow: hidden;
display: flex;
justify-content: space-between;
align-items: center;
}
.toast-item.error {
border-color: #f2b8c1;
background: #fde7eb;
color: #9f1239;
}
.toast-text {
font-weight: 700;
padding-right: 10px;
}
.toast-close {
border: none;
background: transparent;
cursor: pointer;
font-size: 20px;
color: inherit;
}
.toast-progress {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 3px;
background: rgba(15, 23, 42, 0.2);
animation: toast-life 4.5s linear forwards;
}
@keyframes toast-life {
from {
transform: scaleX(1);
transform-origin: left center;
}
to {
transform: scaleX(0);
transform-origin: left center;
}
}
@media (max-width: 1180px) {
.vault-grid {
grid-template-columns: 1fr;
height: auto;
}
.sidebar {
max-height: 280px;
}
.totp-grid,
.field-grid {
grid-template-columns: 1fr;
}
.uri-row {
grid-template-columns: 1fr;
}
}
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
declare module 'qrcode-generator' {
interface QrCode {
addData(data: string): void;
make(): void;
createSvgTag(options?: { scalable?: boolean; margin?: number }): string;
}
export default function qrcode(typeNumber: number, errorCorrectionLevel: 'L' | 'M' | 'Q' | 'H'): QrCode;
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"jsxImportSource": "preact",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"strict": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"types": ["vite/client"]
},
"include": ["src/**/*", "vite.config.ts"]
}
+35
View File
@@ -0,0 +1,35 @@
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import preact from '@preact/preset-vite';
import { defineConfig } from 'vite';
const rootDir = fileURLToPath(new URL('.', import.meta.url));
export default defineConfig({
root: rootDir,
plugins: [preact()],
resolve: {
alias: {
'@': path.resolve(rootDir, 'src'),
},
},
build: {
outDir: path.resolve(rootDir, '../public'),
emptyOutDir: false,
sourcemap: true,
},
server: {
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:8787',
'/identity': 'http://127.0.0.1:8787',
'/setup': 'http://127.0.0.1:8787',
'/icons': 'http://127.0.0.1:8787',
'/config': 'http://127.0.0.1:8787',
'/notifications': 'http://127.0.0.1:8787',
'/.well-known': 'http://127.0.0.1:8787',
'/favicon.ico': 'http://127.0.0.1:8787',
'/favicon.svg': 'http://127.0.0.1:8787',
},
},
});