diff --git a/package-lock.json b/package-lock.json index 82ea3ce..f4b93c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "LGPL-3.0", "dependencies": { "@tanstack/react-query": "^5.90.21", + "lucide-preact": "^0.575.0", "preact": "^10.28.4", "qrcode-generator": "^2.0.4", "wouter": "^3.9.0" @@ -2520,6 +2521,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-preact": { + "version": "0.575.0", + "resolved": "https://registry.npmmirror.com/lucide-preact/-/lucide-preact-0.575.0.tgz", + "integrity": "sha512-W8JZyQEkYv6DlbRrEgmZxVWFKL3zjoyEkFOOSxiX2VEU6Gou8cOqXZ5IAGmqAL4KiPx1tWgGT9awNjAH7MFknw==", + "license": "ISC", + "peerDependencies": { + "preact": "^10.27.2" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", diff --git a/package.json b/package.json index f36b490..0da4e35 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.90.21", + "lucide-preact": "^0.575.0", "preact": "^10.28.4", "qrcode-generator": "^2.0.4", "wouter": "^3.9.0" diff --git a/public/index.html b/public/index.html index be3c367..2fad72b 100644 --- a/public/index.html +++ b/public/index.html @@ -4,8 +4,8 @@ NodeWarden - - + +
diff --git a/src/handlers/admin.ts b/src/handlers/admin.ts index 85880ed..a9a2479 100644 --- a/src/handlers/admin.ts +++ b/src/handlers/admin.ts @@ -163,6 +163,26 @@ export async function handleAdminRevokeInvite( return new Response(null, { status: 204 }); } +// DELETE /api/admin/invites +export async function handleAdminDeleteAllInvites( + request: Request, + env: Env, + actorUser: User +): Promise { + void request; + if (!isAdmin(actorUser)) { + return errorResponse('Forbidden', 403); + } + + const storage = new StorageService(env.DB); + const deleted = await storage.deleteAllInvites(); + await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, { + deleted, + }); + + return jsonResponse({ deleted }, 200); +} + // PUT /api/admin/users/:id/status export async function handleAdminSetUserStatus( request: Request, diff --git a/src/router.ts b/src/router.ts index ec7f498..8223b45 100644 --- a/src/router.ts +++ b/src/router.ts @@ -66,6 +66,7 @@ import { handleAdminListUsers, handleAdminCreateInvite, handleAdminListInvites, + handleAdminDeleteAllInvites, handleAdminRevokeInvite, handleAdminSetUserStatus, handleAdminDeleteUser, @@ -591,6 +592,7 @@ export async function handleRequest(request: Request, env: Env): Promise 0; } + async deleteAllInvites(): Promise { + const result = await this.db.prepare('DELETE FROM invites').run(); + return Number(result.meta.changes ?? 0); + } + async createAuditLog(log: AuditLog): Promise { await this.db .prepare( diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 502094e..f902340 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; import { Link, Route, Switch, useLocation } from 'wouter'; import { useQuery } from '@tanstack/react-query'; +import { Lock, LogOut } from 'lucide-preact'; import AuthViews from '@/components/AuthViews'; import ConfirmDialog from '@/components/ConfirmDialog'; import ToastHost from '@/components/ToastHost'; @@ -13,6 +14,7 @@ import { createCipher, createAuthedFetch, createInvite, + deleteAllInvites, deleteCipher, deleteUser, deriveLoginHash, @@ -21,6 +23,7 @@ import { getFolders, getProfile, getSetupStatus, + getTotpStatus, getWebConfig, listAdminInvites, listAdminUsers, @@ -34,6 +37,7 @@ import { updateCipher, unlockVaultKey, updateProfile, + verifyMasterPassword, } from '@/lib/api'; import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto'; import type { AppPhase, Cipher, Folder, Profile, SessionState, ToastMessage, VaultDraft } from '@/lib/types'; @@ -71,6 +75,7 @@ export default function App() { title: string; message: string; danger?: boolean; + showIcon?: boolean; onConfirm: () => void; } | null>(null); @@ -266,17 +271,22 @@ export default function App() { navigate('/lock'); } + function logoutNow() { + setConfirm(null); + setSession(null); + setProfile(null); + setPendingTotp(null); + setPhase(setupRegistered ? 'login' : 'register'); + navigate('/login'); + } + function handleLogout() { setConfirm({ title: 'Log Out', message: 'Are you sure you want to log out?', + showIcon: false, onConfirm: () => { - setConfirm(null); - setSession(null); - setProfile(null); - setPendingTotp(null); - setPhase(setupRegistered ? 'login' : 'register'); - navigate('/login'); + logoutNow(); }, }); } @@ -301,6 +311,11 @@ export default function App() { queryFn: () => listAdminInvites(authedFetch), enabled: phase === 'app' && profile?.role === 'admin', }); + const totpStatusQuery = useQuery({ + queryKey: ['totp-status', session?.accessToken], + queryFn: () => getTotpStatus(authedFetch), + enabled: phase === 'app' && !!session?.accessToken, + }); useEffect(() => { if (!session?.symEncKey || !session?.symMacKey) { @@ -486,8 +501,10 @@ export default function App() { try { const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations); await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash }); + if (profile?.id) localStorage.removeItem(`nodewarden.totp.secret.${profile.id}`); setDisableTotpOpen(false); setDisableTotpPassword(''); + await totpStatusQuery.refetch(); pushToast('success', 'TOTP disabled'); } catch (error) { pushToast('error', error instanceof Error ? error.message : 'Disable TOTP failed'); @@ -558,6 +575,11 @@ export default function App() { } } + async function verifyMasterPasswordAction(email: string, password: string) { + const derived = await deriveLoginHash(email, password, defaultKdfIterations); + await verifyMasterPassword(authedFetch, derived.hash); + } + useEffect(() => { if (phase === 'app' && location === '/') navigate('/vault'); }, [phase, location, navigate]); @@ -588,7 +610,7 @@ export default function App() { onSubmitUnlock={() => void handleUnlock()} onGotoLogin={() => setPhase('login')} onGotoRegister={() => setPhase('register')} - onLogout={handleLogout} + onLogout={logoutNow} /> setToasts((prev) => prev.filter((x) => x.id !== id))} /> @@ -598,6 +620,7 @@ export default function App() { message="Password is already verified." confirmText="Verify" cancelText="Cancel" + showIcon={false} onConfirm={() => void handleTotpVerify()} onCancel={() => { setPendingTotp(null); @@ -637,10 +660,10 @@ export default function App() {
{profile?.email}
@@ -651,27 +674,35 @@ export default function App() { ciphers={decryptedCiphers} folders={decryptedFolders} loading={ciphersQuery.isFetching || foldersQuery.isFetching} + emailForReprompt={profile?.email || session?.email || ''} onRefresh={refreshVault} onCreate={createVaultItem} onUpdate={updateVaultItem} onDelete={deleteVaultItem} onBulkDelete={bulkDeleteVaultItems} onBulkMove={bulkMoveVaultItems} + onVerifyMasterPassword={verifyMasterPasswordAction} + onNotify={pushToast} /> {profile && ( { + await enableTotpAction(secret, token); + await totpStatusQuery.refetch(); + }} onOpenDisableTotp={() => setDisableTotpOpen(true)} /> )} { @@ -683,6 +714,21 @@ export default function App() { await invitesQuery.refetch(); pushToast('success', 'Invite created'); }} + onDeleteAllInvites={async () => { + setConfirm({ + title: 'Delete all invites', + message: 'Delete all invite codes (active/inactive)?', + danger: true, + onConfirm: () => { + setConfirm(null); + void (async () => { + await deleteAllInvites(authedFetch); + await invitesQuery.refetch(); + pushToast('success', 'All invites deleted'); + })(); + }, + }); + }} onToggleUserStatus={async (userId, status) => { await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active'); await usersQuery.refetch(); @@ -722,6 +768,7 @@ export default function App() { title={confirm?.title || ''} message={confirm?.message || ''} danger={confirm?.danger} + showIcon={confirm?.showIcon} onConfirm={() => confirm?.onConfirm()} onCancel={() => setConfirm(null)} /> @@ -733,6 +780,7 @@ export default function App() { confirmText="Disable TOTP" cancelText="Cancel" danger + showIcon={false} onConfirm={() => void disableTotpAction()} onCancel={() => { setDisableTotpOpen(false); diff --git a/webapp/src/components/AdminPage.tsx b/webapp/src/components/AdminPage.tsx index 3284e5a..e895c2f 100644 --- a/webapp/src/components/AdminPage.tsx +++ b/webapp/src/components/AdminPage.tsx @@ -1,11 +1,14 @@ import { useState } from 'preact/hooks'; +import { Clipboard, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact'; import type { AdminInvite, AdminUser } from '@/lib/types'; interface AdminPageProps { + currentUserId: string; users: AdminUser[]; invites: AdminInvite[]; onRefresh: () => void; onCreateInvite: (hours: number) => Promise; + onDeleteAllInvites: () => Promise; onToggleUserStatus: (userId: string, currentStatus: string) => Promise; onDeleteUser: (userId: string) => Promise; onRevokeInvite: (code: string) => Promise; @@ -13,64 +16,15 @@ interface AdminPageProps { export default function AdminPage(props: AdminPageProps) { const [inviteHours, setInviteHours] = useState(168); + const [page, setPage] = useState(1); + const pageSize = 20; + const formatExpiresAt = (x?: string) => (x ? new Date(x).toLocaleString() : '-'); + const totalPages = Math.max(1, Math.ceil(props.invites.length / pageSize)); + const safePage = Math.min(page, totalPages); + const pagedInvites = props.invites.slice((safePage - 1) * pageSize, safePage * pageSize); return (
-
-
-

Invites

- -
-
- setInviteHours(Number((e.currentTarget as HTMLInputElement).value || 168))} - /> - -
- - - - - - - - - - {props.invites.map((invite) => ( - - - - - - ))} - -
CodeStatusActions
{invite.code}{invite.status} -
- - {invite.status === 'active' && ( - - )} -
-
-
-

Users

@@ -95,12 +49,15 @@ export default function AdminPage(props: AdminPageProps) { {user.role !== 'admin' && ( )} @@ -111,6 +68,78 @@ export default function AdminPage(props: AdminPageProps) {
+ +
+
+

Invites

+ +
+
+
+ setInviteHours(Number((e.currentTarget as HTMLInputElement).value || 168))} + /> + hours + +
+ +
+ + + + + + + + + + + {pagedInvites.map((invite) => ( + + + + + + + ))} + +
CodeStatusExpires AtActions
{invite.code}{invite.status}{formatExpiresAt(invite.expiresAt)} +
+ + {invite.status === 'active' && ( + + )} +
+
+
+ + {safePage} / {totalPages} + +
+
); } diff --git a/webapp/src/components/AuthViews.tsx b/webapp/src/components/AuthViews.tsx index 900dfbb..238c433 100644 --- a/webapp/src/components/AuthViews.tsx +++ b/webapp/src/components/AuthViews.tsx @@ -1,4 +1,5 @@ import { useState } from 'preact/hooks'; +import { Eye, EyeOff } from 'lucide-preact'; interface LoginValues { email: string; @@ -49,7 +50,7 @@ function PasswordField(props: { autoFocus={props.autoFocus} /> diff --git a/webapp/src/components/ConfirmDialog.tsx b/webapp/src/components/ConfirmDialog.tsx index e179007..8e8b709 100644 --- a/webapp/src/components/ConfirmDialog.tsx +++ b/webapp/src/components/ConfirmDialog.tsx @@ -4,6 +4,7 @@ interface ConfirmDialogProps { open: boolean; title: string; message: string; + showIcon?: boolean; confirmText?: string; cancelText?: string; danger?: boolean; @@ -17,7 +18,6 @@ export default function ConfirmDialog(props: ConfirmDialogProps) { return (
-
!

{props.title}

{props.message}
{props.children} diff --git a/webapp/src/components/SettingsPage.tsx b/webapp/src/components/SettingsPage.tsx index e2670df..4b2c2f7 100644 --- a/webapp/src/components/SettingsPage.tsx +++ b/webapp/src/components/SettingsPage.tsx @@ -1,9 +1,11 @@ -import { useMemo, useState } from 'preact/hooks'; +import { useEffect, useMemo, useState } from 'preact/hooks'; +import { Clipboard, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact'; import qrcode from 'qrcode-generator'; import type { Profile } from '@/lib/types'; interface SettingsPageProps { profile: Profile; + totpEnabled: boolean; onSaveProfile: (name: string, email: string) => Promise; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise; onEnableTotp: (secret: string, token: string) => Promise; @@ -24,13 +26,23 @@ function buildOtpUri(email: string, secret: string): string { } export default function SettingsPage(props: SettingsPageProps) { + const totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`; 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 [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32)); const [token, setToken] = useState(''); + const [totpLocked, setTotpLocked] = useState(props.totpEnabled); + + useEffect(() => { + if (!props.totpEnabled) { + setTotpLocked(false); + return; + } + setTotpLocked(true); + }, [props.totpEnabled]); const qrSvg = useMemo(() => { const qr = qrcode(0, 'M'); @@ -39,6 +51,12 @@ export default function SettingsPage(props: SettingsPageProps) { return qr.createSvgTag({ scalable: true, margin: 0 }); }, [email, props.profile.email, secret]); + async function enableTotp(): Promise { + await props.onEnableTotp(secret, token); + localStorage.setItem(totpSecretStorageKey, secret); + setTotpLocked(true); + } + return (
@@ -95,31 +113,38 @@ export default function SettingsPage(props: SettingsPageProps) {

TOTP

+ {totpLocked &&
TOTP is enabled for this account.
}
- - -
- - - +
+ + +
+ + + +
diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index 426817a..0bf87cf 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -1,18 +1,42 @@ -import { useEffect, useMemo, useState } from 'preact/hooks'; +import { useEffect, useMemo, useState } from 'preact/hooks'; import ConfirmDialog from '@/components/ConfirmDialog'; import { calcTotpNow } from '@/lib/crypto'; +import { + CheckCheck, + Clipboard, + CreditCard, + Eye, + EyeOff, + ExternalLink, + FileKey2, + FolderInput, + Globe, + KeyRound, + Pencil, + Plus, + RefreshCw, + ShieldUser, + Star, + StarOff, + StickyNote, + Trash2, + X, +} from 'lucide-preact'; import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; interface VaultPageProps { ciphers: Cipher[]; folders: Folder[]; loading: boolean; + emailForReprompt: string; onRefresh: () => Promise; onCreate: (draft: VaultDraft) => Promise; onUpdate: (cipher: Cipher, draft: VaultDraft) => Promise; onDelete: (cipher: Cipher) => Promise; onBulkDelete: (ids: string[]) => Promise; onBulkMove: (ids: string[], folderId: string | null) => Promise; + onVerifyMasterPassword: (email: string, password: string) => Promise; + onNotify: (type: 'success' | 'error', text: string) => void; } type TypeFilter = 'all' | 'favorite' | 'login' | 'card' | 'identity' | 'note' | 'ssh'; @@ -34,7 +58,6 @@ const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }> = [ { value: 0, label: 'Text' }, { value: 1, label: 'Hidden' }, { value: 2, label: 'Boolean' }, - { value: 3, label: 'Linked' }, ]; function cipherTypeKey(type: number): TypeFilter { @@ -54,13 +77,13 @@ function cipherTypeLabel(type: number): string { return 'Item'; } -function typeIconText(type: number): string { - if (type === 1) return 'L'; - if (type === 3) return 'C'; - if (type === 4) return 'I'; - if (type === 2) return 'N'; - if (type === 5) return 'S'; - return 'V'; +function TypeIcon({ type }: { type: number }) { + if (type === 1) return ; + if (type === 3) return ; + if (type === 4) return ; + if (type === 2) return ; + if (type === 5) return ; + return ; } function parseFieldType(value: number | string | null | undefined): CustomFieldType { @@ -72,10 +95,16 @@ function parseFieldType(value: number | string | null | undefined): CustomFieldT } function fieldTypeLabel(type: CustomFieldType): string { + if (type === 3) return 'Linked'; const found = FIELD_TYPE_OPTIONS.find((x) => x.value === type); return found ? found.label : 'Text'; } +function toBooleanFieldValue(raw: string): boolean { + const v = String(raw || '').trim().toLowerCase(); + return v === '1' || v === 'true' || v === 'yes' || v === 'on'; +} + function firstCipherUri(cipher: Cipher): string { const uris = cipher.login?.uris || []; for (const uri of uris) { @@ -98,6 +127,7 @@ function hostFromUri(uri: string): string { function createEmptyDraft(type: number): VaultDraft { return { type, + favorite: false, name: '', folderId: '', notes: '', @@ -140,6 +170,7 @@ function createEmptyDraft(type: number): VaultDraft { function draftFromCipher(cipher: Cipher): VaultDraft { const draft = createEmptyDraft(Number(cipher.type || 1)); draft.id = cipher.id; + draft.favorite = !!cipher.favorite; draft.name = cipher.decName || ''; draft.folderId = cipher.folderId || ''; draft.notes = cipher.decNotes || ''; @@ -225,7 +256,11 @@ function VaultListIcon({ cipher }: { cipher: Cipher }) { /> ); } - return {typeIconText(Number(cipher.type || 1))}; + return ( + + + + ); } function copyToClipboard(value: string): void { @@ -263,7 +298,17 @@ export default function VaultPage(props: VaultPageProps) { const [moveOpen, setMoveOpen] = useState(false); const [moveFolderId, setMoveFolderId] = useState('__none__'); const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null); + const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState>({}); const [busy, setBusy] = useState(false); + const [repromptOpen, setRepromptOpen] = useState(false); + const [repromptPassword, setRepromptPassword] = useState(''); + const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState(null); + + useEffect(() => { + setRepromptApprovedCipherId(null); + setRepromptPassword(''); + setRepromptOpen(false); + }, [selectedCipherId]); useEffect(() => { if (searchComposing) return; @@ -376,6 +421,15 @@ export default function VaultPage(props: VaultPageProps) { setDraft((prev) => (prev ? { ...prev, customFields: nextFields } : prev)); } + function patchDraftCustomField(index: number, patch: Partial): void { + setDraft((prev) => { + if (!prev) return prev; + const next = [...prev.customFields]; + next[index] = { ...next[index], ...patch }; + return { ...prev, customFields: next }; + }); + } + function updateDraftLoginUri(index: number, value: string): void { setDraft((prev) => { if (!prev) return prev; @@ -459,6 +513,25 @@ export default function VaultPage(props: VaultPageProps) { } } + async function verifyReprompt(): Promise { + if (!selectedCipher) return; + if (!repromptPassword) { + props.onNotify('error', 'Master password is required.'); + return; + } + setBusy(true); + try { + await props.onVerifyMasterPassword(props.emailForReprompt, repromptPassword); + setRepromptApprovedCipherId(selectedCipher.id); + setRepromptOpen(false); + setRepromptPassword(''); + } catch (error) { + props.onNotify('error', error instanceof Error ? error.message : 'Unlock failed'); + } finally { + setBusy(false); + } + } + return ( <>
@@ -527,21 +600,10 @@ export default function VaultPage(props: VaultPageProps) {
- -
{createMenuOpen && (
@@ -572,6 +631,24 @@ export default function VaultPage(props: VaultPageProps) {
)}
+ {selectedCount > 0 && ( + + )} + {selectedCount > 0 && ( + + )}
@@ -588,7 +665,14 @@ export default function VaultPage(props: VaultPageProps) { })) } /> - +