mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: enhance authentication and settings UI
This commit is contained in:
Generated
+10
@@ -10,6 +10,7 @@
|
|||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"lucide-preact": "^0.575.0",
|
||||||
"preact": "^10.28.4",
|
"preact": "^10.28.4",
|
||||||
"qrcode-generator": "^2.0.4",
|
"qrcode-generator": "^2.0.4",
|
||||||
"wouter": "^3.9.0"
|
"wouter": "^3.9.0"
|
||||||
@@ -2520,6 +2521,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"lucide-preact": "^0.575.0",
|
||||||
"preact": "^10.28.4",
|
"preact": "^10.28.4",
|
||||||
"qrcode-generator": "^2.0.4",
|
"qrcode-generator": "^2.0.4",
|
||||||
"wouter": "^3.9.0"
|
"wouter": "^3.9.0"
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>NodeWarden</title>
|
<title>NodeWarden</title>
|
||||||
<script type="module" crossorigin src="/assets/index-pVnF_d3f.js"></script>
|
<script type="module" crossorigin src="/assets/index-C-ko-NHm.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BL7fH__f.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BKQdQWYk.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -163,6 +163,26 @@ export async function handleAdminRevokeInvite(
|
|||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DELETE /api/admin/invites
|
||||||
|
export async function handleAdminDeleteAllInvites(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User
|
||||||
|
): Promise<Response> {
|
||||||
|
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
|
// PUT /api/admin/users/:id/status
|
||||||
export async function handleAdminSetUserStatus(
|
export async function handleAdminSetUserStatus(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import {
|
|||||||
handleAdminListUsers,
|
handleAdminListUsers,
|
||||||
handleAdminCreateInvite,
|
handleAdminCreateInvite,
|
||||||
handleAdminListInvites,
|
handleAdminListInvites,
|
||||||
|
handleAdminDeleteAllInvites,
|
||||||
handleAdminRevokeInvite,
|
handleAdminRevokeInvite,
|
||||||
handleAdminSetUserStatus,
|
handleAdminSetUserStatus,
|
||||||
handleAdminDeleteUser,
|
handleAdminDeleteUser,
|
||||||
@@ -591,6 +592,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
if (path === '/api/admin/invites') {
|
if (path === '/api/admin/invites') {
|
||||||
if (method === 'GET') return handleAdminListInvites(request, env, currentUser);
|
if (method === 'GET') return handleAdminListInvites(request, env, currentUser);
|
||||||
if (method === 'POST') return handleAdminCreateInvite(request, env, currentUser);
|
if (method === 'POST') return handleAdminCreateInvite(request, env, currentUser);
|
||||||
|
if (method === 'DELETE') return handleAdminDeleteAllInvites(request, env, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
|
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
|
||||||
|
|||||||
@@ -392,6 +392,11 @@ export class StorageService {
|
|||||||
return (result.meta.changes ?? 0) > 0;
|
return (result.meta.changes ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteAllInvites(): Promise<number> {
|
||||||
|
const result = await this.db.prepare('DELETE FROM invites').run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
async createAuditLog(log: AuditLog): Promise<void> {
|
async createAuditLog(log: AuditLog): Promise<void> {
|
||||||
await this.db
|
await this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
+57
-9
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { Link, Route, Switch, useLocation } from 'wouter';
|
import { Link, Route, Switch, useLocation } from 'wouter';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Lock, LogOut } from 'lucide-preact';
|
||||||
import AuthViews from '@/components/AuthViews';
|
import AuthViews from '@/components/AuthViews';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import ToastHost from '@/components/ToastHost';
|
import ToastHost from '@/components/ToastHost';
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
createCipher,
|
createCipher,
|
||||||
createAuthedFetch,
|
createAuthedFetch,
|
||||||
createInvite,
|
createInvite,
|
||||||
|
deleteAllInvites,
|
||||||
deleteCipher,
|
deleteCipher,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
deriveLoginHash,
|
deriveLoginHash,
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
getFolders,
|
getFolders,
|
||||||
getProfile,
|
getProfile,
|
||||||
getSetupStatus,
|
getSetupStatus,
|
||||||
|
getTotpStatus,
|
||||||
getWebConfig,
|
getWebConfig,
|
||||||
listAdminInvites,
|
listAdminInvites,
|
||||||
listAdminUsers,
|
listAdminUsers,
|
||||||
@@ -34,6 +37,7 @@ import {
|
|||||||
updateCipher,
|
updateCipher,
|
||||||
unlockVaultKey,
|
unlockVaultKey,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
|
verifyMasterPassword,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto';
|
import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto';
|
||||||
import type { AppPhase, Cipher, Folder, Profile, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
|
import type { AppPhase, Cipher, Folder, Profile, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
|
||||||
@@ -71,6 +75,7 @@ export default function App() {
|
|||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
|
showIcon?: boolean;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
@@ -266,17 +271,22 @@ export default function App() {
|
|||||||
navigate('/lock');
|
navigate('/lock');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function logoutNow() {
|
||||||
setConfirm({
|
|
||||||
title: 'Log Out',
|
|
||||||
message: 'Are you sure you want to log out?',
|
|
||||||
onConfirm: () => {
|
|
||||||
setConfirm(null);
|
setConfirm(null);
|
||||||
setSession(null);
|
setSession(null);
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setPhase(setupRegistered ? 'login' : 'register');
|
setPhase(setupRegistered ? 'login' : 'register');
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
setConfirm({
|
||||||
|
title: 'Log Out',
|
||||||
|
message: 'Are you sure you want to log out?',
|
||||||
|
showIcon: false,
|
||||||
|
onConfirm: () => {
|
||||||
|
logoutNow();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -301,6 +311,11 @@ export default function App() {
|
|||||||
queryFn: () => listAdminInvites(authedFetch),
|
queryFn: () => listAdminInvites(authedFetch),
|
||||||
enabled: phase === 'app' && profile?.role === 'admin',
|
enabled: phase === 'app' && profile?.role === 'admin',
|
||||||
});
|
});
|
||||||
|
const totpStatusQuery = useQuery({
|
||||||
|
queryKey: ['totp-status', session?.accessToken],
|
||||||
|
queryFn: () => getTotpStatus(authedFetch),
|
||||||
|
enabled: phase === 'app' && !!session?.accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.symEncKey || !session?.symMacKey) {
|
if (!session?.symEncKey || !session?.symMacKey) {
|
||||||
@@ -486,8 +501,10 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
|
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
|
||||||
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
|
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
|
||||||
|
if (profile?.id) localStorage.removeItem(`nodewarden.totp.secret.${profile.id}`);
|
||||||
setDisableTotpOpen(false);
|
setDisableTotpOpen(false);
|
||||||
setDisableTotpPassword('');
|
setDisableTotpPassword('');
|
||||||
|
await totpStatusQuery.refetch();
|
||||||
pushToast('success', 'TOTP disabled');
|
pushToast('success', 'TOTP disabled');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pushToast('error', error instanceof Error ? error.message : 'Disable TOTP failed');
|
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(() => {
|
useEffect(() => {
|
||||||
if (phase === 'app' && location === '/') navigate('/vault');
|
if (phase === 'app' && location === '/') navigate('/vault');
|
||||||
}, [phase, location, navigate]);
|
}, [phase, location, navigate]);
|
||||||
@@ -588,7 +610,7 @@ export default function App() {
|
|||||||
onSubmitUnlock={() => void handleUnlock()}
|
onSubmitUnlock={() => void handleUnlock()}
|
||||||
onGotoLogin={() => setPhase('login')}
|
onGotoLogin={() => setPhase('login')}
|
||||||
onGotoRegister={() => setPhase('register')}
|
onGotoRegister={() => setPhase('register')}
|
||||||
onLogout={handleLogout}
|
onLogout={logoutNow}
|
||||||
/>
|
/>
|
||||||
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
|
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
|
||||||
|
|
||||||
@@ -598,6 +620,7 @@ export default function App() {
|
|||||||
message="Password is already verified."
|
message="Password is already verified."
|
||||||
confirmText="Verify"
|
confirmText="Verify"
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
|
showIcon={false}
|
||||||
onConfirm={() => void handleTotpVerify()}
|
onConfirm={() => void handleTotpVerify()}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
@@ -637,10 +660,10 @@ export default function App() {
|
|||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
<span className="user-email">{profile?.email}</span>
|
<span className="user-email">{profile?.email}</span>
|
||||||
<button type="button" className="btn btn-secondary small" onClick={handleLock}>
|
<button type="button" className="btn btn-secondary small" onClick={handleLock}>
|
||||||
Lock
|
<Lock size={14} className="btn-icon" /> Lock
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-secondary small" onClick={handleLogout}>
|
<button type="button" className="btn btn-secondary small" onClick={handleLogout}>
|
||||||
Log Out
|
<LogOut size={14} className="btn-icon" /> Log Out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -651,27 +674,35 @@ export default function App() {
|
|||||||
ciphers={decryptedCiphers}
|
ciphers={decryptedCiphers}
|
||||||
folders={decryptedFolders}
|
folders={decryptedFolders}
|
||||||
loading={ciphersQuery.isFetching || foldersQuery.isFetching}
|
loading={ciphersQuery.isFetching || foldersQuery.isFetching}
|
||||||
|
emailForReprompt={profile?.email || session?.email || ''}
|
||||||
onRefresh={refreshVault}
|
onRefresh={refreshVault}
|
||||||
onCreate={createVaultItem}
|
onCreate={createVaultItem}
|
||||||
onUpdate={updateVaultItem}
|
onUpdate={updateVaultItem}
|
||||||
onDelete={deleteVaultItem}
|
onDelete={deleteVaultItem}
|
||||||
onBulkDelete={bulkDeleteVaultItems}
|
onBulkDelete={bulkDeleteVaultItems}
|
||||||
onBulkMove={bulkMoveVaultItems}
|
onBulkMove={bulkMoveVaultItems}
|
||||||
|
onVerifyMasterPassword={verifyMasterPasswordAction}
|
||||||
|
onNotify={pushToast}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/settings">
|
<Route path="/settings">
|
||||||
{profile && (
|
{profile && (
|
||||||
<SettingsPage
|
<SettingsPage
|
||||||
profile={profile}
|
profile={profile}
|
||||||
|
totpEnabled={!!totpStatusQuery.data?.enabled}
|
||||||
onSaveProfile={saveProfileAction}
|
onSaveProfile={saveProfileAction}
|
||||||
onChangePassword={changePasswordAction}
|
onChangePassword={changePasswordAction}
|
||||||
onEnableTotp={enableTotpAction}
|
onEnableTotp={async (secret, token) => {
|
||||||
|
await enableTotpAction(secret, token);
|
||||||
|
await totpStatusQuery.refetch();
|
||||||
|
}}
|
||||||
onOpenDisableTotp={() => setDisableTotpOpen(true)}
|
onOpenDisableTotp={() => setDisableTotpOpen(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/admin">
|
<Route path="/admin">
|
||||||
<AdminPage
|
<AdminPage
|
||||||
|
currentUserId={profile?.id || ''}
|
||||||
users={usersQuery.data || []}
|
users={usersQuery.data || []}
|
||||||
invites={invitesQuery.data || []}
|
invites={invitesQuery.data || []}
|
||||||
onRefresh={() => {
|
onRefresh={() => {
|
||||||
@@ -683,6 +714,21 @@ export default function App() {
|
|||||||
await invitesQuery.refetch();
|
await invitesQuery.refetch();
|
||||||
pushToast('success', 'Invite created');
|
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) => {
|
onToggleUserStatus={async (userId, status) => {
|
||||||
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
||||||
await usersQuery.refetch();
|
await usersQuery.refetch();
|
||||||
@@ -722,6 +768,7 @@ export default function App() {
|
|||||||
title={confirm?.title || ''}
|
title={confirm?.title || ''}
|
||||||
message={confirm?.message || ''}
|
message={confirm?.message || ''}
|
||||||
danger={confirm?.danger}
|
danger={confirm?.danger}
|
||||||
|
showIcon={confirm?.showIcon}
|
||||||
onConfirm={() => confirm?.onConfirm()}
|
onConfirm={() => confirm?.onConfirm()}
|
||||||
onCancel={() => setConfirm(null)}
|
onCancel={() => setConfirm(null)}
|
||||||
/>
|
/>
|
||||||
@@ -733,6 +780,7 @@ export default function App() {
|
|||||||
confirmText="Disable TOTP"
|
confirmText="Disable TOTP"
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
danger
|
danger
|
||||||
|
showIcon={false}
|
||||||
onConfirm={() => void disableTotpAction()}
|
onConfirm={() => void disableTotpAction()}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setDisableTotpOpen(false);
|
setDisableTotpOpen(false);
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
|
import { Clipboard, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
|
||||||
import type { AdminInvite, AdminUser } from '@/lib/types';
|
import type { AdminInvite, AdminUser } from '@/lib/types';
|
||||||
|
|
||||||
interface AdminPageProps {
|
interface AdminPageProps {
|
||||||
|
currentUserId: string;
|
||||||
users: AdminUser[];
|
users: AdminUser[];
|
||||||
invites: AdminInvite[];
|
invites: AdminInvite[];
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onCreateInvite: (hours: number) => Promise<void>;
|
onCreateInvite: (hours: number) => Promise<void>;
|
||||||
|
onDeleteAllInvites: () => Promise<void>;
|
||||||
onToggleUserStatus: (userId: string, currentStatus: string) => Promise<void>;
|
onToggleUserStatus: (userId: string, currentStatus: string) => Promise<void>;
|
||||||
onDeleteUser: (userId: string) => Promise<void>;
|
onDeleteUser: (userId: string) => Promise<void>;
|
||||||
onRevokeInvite: (code: string) => Promise<void>;
|
onRevokeInvite: (code: string) => Promise<void>;
|
||||||
@@ -13,64 +16,15 @@ interface AdminPageProps {
|
|||||||
|
|
||||||
export default function AdminPage(props: AdminPageProps) {
|
export default function AdminPage(props: AdminPageProps) {
|
||||||
const [inviteHours, setInviteHours] = useState(168);
|
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 (
|
return (
|
||||||
<div className="stack">
|
<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">
|
<section className="card">
|
||||||
<h3>Users</h3>
|
<h3>Users</h3>
|
||||||
<table className="table">
|
<table className="table">
|
||||||
@@ -95,12 +49,15 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
|
disabled={user.id === props.currentUserId}
|
||||||
onClick={() => void props.onToggleUserStatus(user.id, user.status)}
|
onClick={() => void props.onToggleUserStatus(user.id, user.status)}
|
||||||
>
|
>
|
||||||
|
{user.status === 'active' ? <UserX size={14} className="btn-icon" /> : <UserCheck size={14} className="btn-icon" />}
|
||||||
{user.status === 'active' ? 'Ban' : 'Unban'}
|
{user.status === 'active' ? 'Ban' : 'Unban'}
|
||||||
</button>
|
</button>
|
||||||
{user.role !== 'admin' && (
|
{user.role !== 'admin' && (
|
||||||
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteUser(user.id)}>
|
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteUser(user.id)}>
|
||||||
|
<Trash2 size={14} className="btn-icon" />
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -111,6 +68,78 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="section-head">
|
||||||
|
<h3>Invites</h3>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={props.onRefresh}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" /> Sync
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="invite-toolbar">
|
||||||
|
<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))}
|
||||||
|
/>
|
||||||
|
<span className="muted-inline">hours</span>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => void props.onCreateInvite(inviteHours)}>
|
||||||
|
Create Invite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteAllInvites()}>
|
||||||
|
<Trash2 size={14} className="btn-icon" /> Delete All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Code</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Expires At</th>
|
||||||
|
<th className="invite-actions-head">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pagedInvites.map((invite) => (
|
||||||
|
<tr key={invite.code}>
|
||||||
|
<td>{invite.code}</td>
|
||||||
|
<td>{invite.status}</td>
|
||||||
|
<td>{formatExpiresAt(invite.expiresAt)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="actions invite-row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => navigator.clipboard.writeText(invite.inviteLink || '')}
|
||||||
|
>
|
||||||
|
<Clipboard size={14} className="btn-icon" /> Copy Link
|
||||||
|
</button>
|
||||||
|
{invite.status === 'active' && (
|
||||||
|
<button type="button" className="btn btn-danger" onClick={() => void props.onRevokeInvite(invite.code)}>
|
||||||
|
<Trash2 size={14} className="btn-icon" /> Revoke
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={safePage <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<span className="muted-inline">{safePage} / {totalPages}</span>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={safePage >= totalPages} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
|
import { Eye, EyeOff } from 'lucide-preact';
|
||||||
|
|
||||||
interface LoginValues {
|
interface LoginValues {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -49,7 +50,7 @@ function PasswordField(props: {
|
|||||||
autoFocus={props.autoFocus}
|
autoFocus={props.autoFocus}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
|
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
|
||||||
{show ? 'Hide' : 'Show'}
|
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ interface ConfirmDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
showIcon?: boolean;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
@@ -17,7 +18,6 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="dialog-mask">
|
<div className="dialog-mask">
|
||||||
<div className="dialog-card">
|
<div className="dialog-card">
|
||||||
<div className="dialog-icon">!</div>
|
|
||||||
<h3 className="dialog-title">{props.title}</h3>
|
<h3 className="dialog-title">{props.title}</h3>
|
||||||
<div className="dialog-message">{props.message}</div>
|
<div className="dialog-message">{props.message}</div>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
|||||||
@@ -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 qrcode from 'qrcode-generator';
|
||||||
import type { Profile } from '@/lib/types';
|
import type { Profile } from '@/lib/types';
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
profile: Profile;
|
profile: Profile;
|
||||||
|
totpEnabled: boolean;
|
||||||
onSaveProfile: (name: string, email: string) => Promise<void>;
|
onSaveProfile: (name: string, email: string) => Promise<void>;
|
||||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||||
@@ -24,13 +26,23 @@ function buildOtpUri(email: string, secret: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage(props: SettingsPageProps) {
|
export default function SettingsPage(props: SettingsPageProps) {
|
||||||
|
const totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`;
|
||||||
const [name, setName] = useState(props.profile.name || '');
|
const [name, setName] = useState(props.profile.name || '');
|
||||||
const [email, setEmail] = useState(props.profile.email || '');
|
const [email, setEmail] = useState(props.profile.email || '');
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [newPassword2, setNewPassword2] = 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 [token, setToken] = useState('');
|
||||||
|
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!props.totpEnabled) {
|
||||||
|
setTotpLocked(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTotpLocked(true);
|
||||||
|
}, [props.totpEnabled]);
|
||||||
|
|
||||||
const qrSvg = useMemo(() => {
|
const qrSvg = useMemo(() => {
|
||||||
const qr = qrcode(0, 'M');
|
const qr = qrcode(0, 'M');
|
||||||
@@ -39,6 +51,12 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
return qr.createSvgTag({ scalable: true, margin: 0 });
|
return qr.createSvgTag({ scalable: true, margin: 0 });
|
||||||
}, [email, props.profile.email, secret]);
|
}, [email, props.profile.email, secret]);
|
||||||
|
|
||||||
|
async function enableTotp(): Promise<void> {
|
||||||
|
await props.onEnableTotp(secret, token);
|
||||||
|
localStorage.setItem(totpSecretStorageKey, secret);
|
||||||
|
setTotpLocked(true);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
@@ -95,31 +113,38 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h3>TOTP</h3>
|
<h3>TOTP</h3>
|
||||||
|
{totpLocked && <div className="status-ok">TOTP is enabled for this account.</div>}
|
||||||
<div className="totp-grid">
|
<div className="totp-grid">
|
||||||
<div className="totp-qr" dangerouslySetInnerHTML={{ __html: qrSvg }} />
|
<div className="totp-qr" dangerouslySetInnerHTML={{ __html: qrSvg }} />
|
||||||
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Authenticator Key</span>
|
<span>Authenticator Key</span>
|
||||||
<input className="input" value={secret} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
|
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Verification Code</span>
|
<span>Verification Code</span>
|
||||||
<input className="input" value={token} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
|
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
|
||||||
</label>
|
</label>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button type="button" className="btn btn-primary" onClick={() => void props.onEnableTotp(secret, token)}>
|
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
|
||||||
Enable TOTP
|
<ShieldCheck size={14} className="btn-icon" />
|
||||||
|
{totpLocked ? 'Enabled' : 'Enable TOTP'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => setSecret(randomBase32Secret(32))}>
|
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
Regenerate
|
Regenerate
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => navigator.clipboard.writeText(secret)}>
|
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => navigator.clipboard.writeText(secret)}>
|
||||||
|
<Clipboard size={14} className="btn-icon" />
|
||||||
Copy Secret
|
Copy Secret
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<button type="button" className="btn btn-danger" onClick={props.onOpenDisableTotp}>
|
<button type="button" className="btn btn-danger" onClick={props.onOpenDisableTotp}>
|
||||||
|
<ShieldOff size={14} className="btn-icon" />
|
||||||
Disable TOTP
|
Disable TOTP
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,18 +1,42 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import { calcTotpNow } from '@/lib/crypto';
|
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';
|
import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||||
|
|
||||||
interface VaultPageProps {
|
interface VaultPageProps {
|
||||||
ciphers: Cipher[];
|
ciphers: Cipher[];
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
emailForReprompt: string;
|
||||||
onRefresh: () => Promise<void>;
|
onRefresh: () => Promise<void>;
|
||||||
onCreate: (draft: VaultDraft) => Promise<void>;
|
onCreate: (draft: VaultDraft) => Promise<void>;
|
||||||
onUpdate: (cipher: Cipher, draft: VaultDraft) => Promise<void>;
|
onUpdate: (cipher: Cipher, draft: VaultDraft) => Promise<void>;
|
||||||
onDelete: (cipher: Cipher) => Promise<void>;
|
onDelete: (cipher: Cipher) => Promise<void>;
|
||||||
onBulkDelete: (ids: string[]) => Promise<void>;
|
onBulkDelete: (ids: string[]) => Promise<void>;
|
||||||
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
|
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
|
||||||
|
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||||
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TypeFilter = 'all' | 'favorite' | 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
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: 0, label: 'Text' },
|
||||||
{ value: 1, label: 'Hidden' },
|
{ value: 1, label: 'Hidden' },
|
||||||
{ value: 2, label: 'Boolean' },
|
{ value: 2, label: 'Boolean' },
|
||||||
{ value: 3, label: 'Linked' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function cipherTypeKey(type: number): TypeFilter {
|
function cipherTypeKey(type: number): TypeFilter {
|
||||||
@@ -54,13 +77,13 @@ function cipherTypeLabel(type: number): string {
|
|||||||
return 'Item';
|
return 'Item';
|
||||||
}
|
}
|
||||||
|
|
||||||
function typeIconText(type: number): string {
|
function TypeIcon({ type }: { type: number }) {
|
||||||
if (type === 1) return 'L';
|
if (type === 1) return <Globe size={18} />;
|
||||||
if (type === 3) return 'C';
|
if (type === 3) return <CreditCard size={18} />;
|
||||||
if (type === 4) return 'I';
|
if (type === 4) return <ShieldUser size={18} />;
|
||||||
if (type === 2) return 'N';
|
if (type === 2) return <StickyNote size={18} />;
|
||||||
if (type === 5) return 'S';
|
if (type === 5) return <KeyRound size={18} />;
|
||||||
return 'V';
|
return <FileKey2 size={18} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFieldType(value: number | string | null | undefined): CustomFieldType {
|
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 {
|
function fieldTypeLabel(type: CustomFieldType): string {
|
||||||
|
if (type === 3) return 'Linked';
|
||||||
const found = FIELD_TYPE_OPTIONS.find((x) => x.value === type);
|
const found = FIELD_TYPE_OPTIONS.find((x) => x.value === type);
|
||||||
return found ? found.label : 'Text';
|
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 {
|
function firstCipherUri(cipher: Cipher): string {
|
||||||
const uris = cipher.login?.uris || [];
|
const uris = cipher.login?.uris || [];
|
||||||
for (const uri of uris) {
|
for (const uri of uris) {
|
||||||
@@ -98,6 +127,7 @@ function hostFromUri(uri: string): string {
|
|||||||
function createEmptyDraft(type: number): VaultDraft {
|
function createEmptyDraft(type: number): VaultDraft {
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
|
favorite: false,
|
||||||
name: '',
|
name: '',
|
||||||
folderId: '',
|
folderId: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
@@ -140,6 +170,7 @@ function createEmptyDraft(type: number): VaultDraft {
|
|||||||
function draftFromCipher(cipher: Cipher): VaultDraft {
|
function draftFromCipher(cipher: Cipher): VaultDraft {
|
||||||
const draft = createEmptyDraft(Number(cipher.type || 1));
|
const draft = createEmptyDraft(Number(cipher.type || 1));
|
||||||
draft.id = cipher.id;
|
draft.id = cipher.id;
|
||||||
|
draft.favorite = !!cipher.favorite;
|
||||||
draft.name = cipher.decName || '';
|
draft.name = cipher.decName || '';
|
||||||
draft.folderId = cipher.folderId || '';
|
draft.folderId = cipher.folderId || '';
|
||||||
draft.notes = cipher.decNotes || '';
|
draft.notes = cipher.decNotes || '';
|
||||||
@@ -225,7 +256,11 @@ function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <span className="list-icon-fallback">{typeIconText(Number(cipher.type || 1))}</span>;
|
return (
|
||||||
|
<span className="list-icon-fallback">
|
||||||
|
<TypeIcon type={Number(cipher.type || 1)} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard(value: string): void {
|
function copyToClipboard(value: string): void {
|
||||||
@@ -263,7 +298,17 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [moveOpen, setMoveOpen] = useState(false);
|
const [moveOpen, setMoveOpen] = useState(false);
|
||||||
const [moveFolderId, setMoveFolderId] = useState('__none__');
|
const [moveFolderId, setMoveFolderId] = useState('__none__');
|
||||||
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
|
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
|
||||||
|
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [repromptOpen, setRepromptOpen] = useState(false);
|
||||||
|
const [repromptPassword, setRepromptPassword] = useState('');
|
||||||
|
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRepromptApprovedCipherId(null);
|
||||||
|
setRepromptPassword('');
|
||||||
|
setRepromptOpen(false);
|
||||||
|
}, [selectedCipherId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchComposing) return;
|
if (searchComposing) return;
|
||||||
@@ -376,6 +421,15 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
setDraft((prev) => (prev ? { ...prev, customFields: nextFields } : prev));
|
setDraft((prev) => (prev ? { ...prev, customFields: nextFields } : prev));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function patchDraftCustomField(index: number, patch: Partial<VaultDraftField>): 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 {
|
function updateDraftLoginUri(index: number, value: string): void {
|
||||||
setDraft((prev) => {
|
setDraft((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
@@ -459,6 +513,25 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function verifyReprompt(): Promise<void> {
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="vault-grid">
|
<div className="vault-grid">
|
||||||
@@ -527,21 +600,10 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
<section className="list-col">
|
<section className="list-col">
|
||||||
<div className="toolbar actions">
|
<div className="toolbar actions">
|
||||||
<button type="button" className="btn btn-secondary small" disabled={busy || props.loading} onClick={() => void syncVault()}>
|
<button type="button" className="btn btn-secondary small" disabled={busy || props.loading} onClick={() => void syncVault()}>
|
||||||
Sync
|
<RefreshCw size={14} className="btn-icon" /> Sync
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary small"
|
|
||||||
disabled={!selectedCount || busy}
|
|
||||||
onClick={() => {
|
|
||||||
setMoveFolderId('__none__');
|
|
||||||
setMoveOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Move
|
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-danger small" disabled={!selectedCount || busy} onClick={() => setBulkDeleteOpen(true)}>
|
<button type="button" className="btn btn-danger small" disabled={!selectedCount || busy} onClick={() => setBulkDeleteOpen(true)}>
|
||||||
Delete ({selectedCount})
|
<Trash2 size={14} className="btn-icon" /> Delete ({selectedCount})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -553,14 +615,11 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
setSelectedMap(map);
|
setSelectedMap(map);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Select All
|
<CheckCheck size={14} className="btn-icon" /> Select All
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => setSelectedMap({})}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
</button>
|
||||||
<div className="create-menu-wrap">
|
<div className="create-menu-wrap">
|
||||||
<button type="button" className="btn btn-primary small" onClick={() => setCreateMenuOpen((x) => !x)}>
|
<button type="button" className="btn btn-primary small" onClick={() => setCreateMenuOpen((x) => !x)}>
|
||||||
+ Add
|
<Plus size={14} className="btn-icon" /> Add
|
||||||
</button>
|
</button>
|
||||||
{createMenuOpen && (
|
{createMenuOpen && (
|
||||||
<div className="create-menu">
|
<div className="create-menu">
|
||||||
@@ -572,6 +631,24 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setMoveFolderId('__none__');
|
||||||
|
setMoveOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderInput size={14} className="btn-icon" /> Move
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => setSelectedMap({})}>
|
||||||
|
<X size={14} className="btn-icon" /> Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list-panel">
|
<div className="list-panel">
|
||||||
@@ -588,7 +665,14 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="row-main" onClick={() => setSelectedCipherId(cipher.id)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="row-main"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCipherId(cipher.id);
|
||||||
|
setRepromptApprovedCipherId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="list-icon-wrap">
|
<div className="list-icon-wrap">
|
||||||
<VaultListIcon cipher={cipher} />
|
<VaultListIcon cipher={cipher} />
|
||||||
</div>
|
</div>
|
||||||
@@ -607,7 +691,17 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
{isEditing && draft && (
|
{isEditing && draft && (
|
||||||
<>
|
<>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
<div className="section-head">
|
||||||
<h3 className="detail-title">{isCreating ? `New ${cipherTypeLabel(draft.type)}` : `Edit ${cipherTypeLabel(draft.type)}`}</h3>
|
<h3 className="detail-title">{isCreating ? `New ${cipherTypeLabel(draft.type)}` : `Edit ${cipherTypeLabel(draft.type)}`}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-secondary small ${draft.favorite ? 'star-on' : ''}`}
|
||||||
|
onClick={() => updateDraft({ favorite: !draft.favorite })}
|
||||||
|
>
|
||||||
|
{draft.favorite ? <Star size={14} className="btn-icon" /> : <StarOff size={14} className="btn-icon" />}
|
||||||
|
Favorite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="field-grid">
|
<div className="field-grid">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Type</span>
|
<span>Type</span>
|
||||||
@@ -666,11 +760,11 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<h4>Websites</h4>
|
<h4>Websites</h4>
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => updateDraft({ loginUris: [...draft.loginUris, ''] })}>
|
<button type="button" className="btn btn-secondary small" onClick={() => updateDraft({ loginUris: [...draft.loginUris, ''] })}>
|
||||||
+ Add Website
|
<Plus size={14} className="btn-icon" /> Add Website
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{draft.loginUris.map((uri, index) => (
|
{draft.loginUris.map((uri, index) => (
|
||||||
<div key={`uri-${index}`} className="uri-row">
|
<div key={`uri-${index}`} className="website-row">
|
||||||
<input className="input" value={uri} onInput={(e) => updateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} />
|
<input className="input" value={uri} onInput={(e) => updateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} />
|
||||||
{draft.loginUris.length > 1 && (
|
{draft.loginUris.length > 1 && (
|
||||||
<button
|
<button
|
||||||
@@ -774,18 +868,38 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<h4>Custom Fields</h4>
|
<h4>Custom Fields</h4>
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => setFieldModalOpen(true)}>
|
<button type="button" className="btn btn-secondary small" onClick={() => setFieldModalOpen(true)}>
|
||||||
+ Add Field
|
<Plus size={14} className="btn-icon" /> Add Field
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{draft.customFields.map((field, index) => (
|
{draft.customFields
|
||||||
<div key={`field-${index}`} className="uri-row">
|
.map((field, originalIndex) => ({ field, originalIndex }))
|
||||||
<input className="input" value={field.label} readOnly />
|
.filter((entry) => entry.field.type !== 3)
|
||||||
<input className="input" value={field.value} readOnly />
|
.map(({ field, originalIndex }) => (
|
||||||
<span className="field-type-pill">{fieldTypeLabel(field.type)}</span>
|
<div key={`field-${originalIndex}`} className="uri-row">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={field.label}
|
||||||
|
onInput={(e) => patchDraftCustomField(originalIndex, { label: (e.currentTarget as HTMLInputElement).value })}
|
||||||
|
/>
|
||||||
|
{field.type === 2 ? (
|
||||||
|
<label className="check-line cf-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={toBooleanFieldValue(field.value)}
|
||||||
|
onInput={(e) => patchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={field.value}
|
||||||
|
onInput={(e) => patchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary small"
|
className="btn btn-secondary small"
|
||||||
onClick={() => updateDraftCustomFields(draft.customFields.filter((_, i) => i !== index))}
|
onClick={() => updateDraftCustomFields(draft.customFields.filter((_, i) => i !== originalIndex))}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
@@ -813,6 +927,19 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEditing && selectedCipher && (
|
{!isEditing && selectedCipher && (
|
||||||
|
<>
|
||||||
|
{Number(selectedCipher.reprompt || 0) === 1 && repromptApprovedCipherId !== selectedCipher.id && (
|
||||||
|
<div className="card">
|
||||||
|
<h4>Master Password Reprompt</h4>
|
||||||
|
<div className="detail-sub">This item requires master password every time before viewing details.</div>
|
||||||
|
<div className="actions" style={{ marginTop: '10px' }}>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setRepromptOpen(true)}>
|
||||||
|
<Eye size={14} className="btn-icon" /> Unlock Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(Number(selectedCipher.reprompt || 0) !== 1 || repromptApprovedCipherId === selectedCipher.id) && (
|
||||||
<>
|
<>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="detail-title">{selectedCipher.decName || '(No Name)'}</h3>
|
<h3 className="detail-title">{selectedCipher.decName || '(No Name)'}</h3>
|
||||||
@@ -822,35 +949,42 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
{selectedCipher.login && (
|
{selectedCipher.login && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h4>Login Credentials</h4>
|
<h4>Login Credentials</h4>
|
||||||
<div className="kv-line">
|
<div className="kv-row">
|
||||||
<span>Username</span>
|
<span className="kv-label">Username</span>
|
||||||
<div className="actions">
|
<div className="kv-main">
|
||||||
<strong>{selectedCipher.login.decUsername || ''}</strong>
|
<strong>{selectedCipher.login.decUsername || ''}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(selectedCipher.login?.decUsername || '')}>
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(selectedCipher.login?.decUsername || '')}>
|
||||||
Copy
|
<Clipboard size={14} className="btn-icon" /> Copy
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="kv-line">
|
<div className="kv-row">
|
||||||
<span>Password</span>
|
<span className="kv-label">Password</span>
|
||||||
<div className="actions">
|
<div className="kv-main">
|
||||||
<strong>{showPassword ? selectedCipher.login.decPassword || '' : maskSecret(selectedCipher.login.decPassword || '')}</strong>
|
<strong>{showPassword ? selectedCipher.login.decPassword || '' : maskSecret(selectedCipher.login.decPassword || '')}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => setShowPassword((v) => !v)}>
|
<button type="button" className="btn btn-secondary small" onClick={() => setShowPassword((v) => !v)}>
|
||||||
|
{showPassword ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
|
||||||
{showPassword ? 'Hide' : 'Reveal'}
|
{showPassword ? 'Hide' : 'Reveal'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(selectedCipher.login?.decPassword || '')}>
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(selectedCipher.login?.decPassword || '')}>
|
||||||
Copy
|
<Clipboard size={14} className="btn-icon" /> Copy
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!!selectedCipher.login.decTotp && (
|
{!!selectedCipher.login.decTotp && (
|
||||||
<div className="kv-line">
|
<div className="kv-row">
|
||||||
<span>TOTP</span>
|
<span className="kv-label">TOTP</span>
|
||||||
<div className="actions">
|
<div className="kv-main">
|
||||||
<strong>{totpLive ? formatTotp(totpLive.code) : '------'}</strong>
|
<strong>{totpLive ? formatTotp(totpLive.code) : '------'}</strong>
|
||||||
<span className="detail-sub">Refresh in: {totpLive ? `${totpLive.remain}s` : '--'}</span>
|
<span className="detail-sub">Refresh in: {totpLive ? `${totpLive.remain}s` : '--'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(totpLive?.code || '')}>
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(totpLive?.code || '')}>
|
||||||
Copy
|
<Clipboard size={14} className="btn-icon" /> Copy
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -865,15 +999,17 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const value = uri.decUri || uri.uri || '';
|
const value = uri.decUri || uri.uri || '';
|
||||||
if (!value.trim()) return null;
|
if (!value.trim()) return null;
|
||||||
return (
|
return (
|
||||||
<div key={`view-uri-${index}`} className="kv-line">
|
<div key={`view-uri-${index}`} className="kv-row">
|
||||||
<span>Website</span>
|
<span className="kv-label">Website</span>
|
||||||
<div className="actions">
|
<div className="kv-main">
|
||||||
<strong>{value}</strong>
|
<strong>{value}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => openUri(value)}>
|
<button type="button" className="btn btn-secondary small" onClick={() => openUri(value)}>
|
||||||
Open
|
<ExternalLink size={14} className="btn-icon" /> Open
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(value)}>
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(value)}>
|
||||||
Copy
|
<Clipboard size={14} className="btn-icon" /> Copy
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -919,30 +1055,70 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
<div className="notes">{selectedCipher.decNotes || ''}</div>
|
<div className="notes">{selectedCipher.decNotes || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(selectedCipher.fields || []).length > 0 && (
|
{(selectedCipher.fields || []).some((x) => parseFieldType(x.type) !== 3) && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h4>Custom Fields</h4>
|
<h4>Custom Fields</h4>
|
||||||
{(selectedCipher.fields || []).map((field, index) => (
|
{(selectedCipher.fields || [])
|
||||||
<div key={`view-field-${index}`} className="kv-line">
|
.filter((x) => parseFieldType(x.type) !== 3)
|
||||||
<span>{field.decName || 'Field'}</span>
|
.map((field, index) => {
|
||||||
<strong>{field.decValue || ''}</strong>
|
const fieldType = parseFieldType(field.type);
|
||||||
|
const fieldName = field.decName || 'Field';
|
||||||
|
const rawValue = field.decValue || '';
|
||||||
|
const isHiddenVisible = !!hiddenFieldVisibleMap[index];
|
||||||
|
if (fieldType === 2) {
|
||||||
|
return (
|
||||||
|
<div key={`view-field-${index}`} className="kv-row">
|
||||||
|
<span className="kv-label">{fieldName}</span>
|
||||||
|
<div className="kv-main">
|
||||||
|
<label className="check-line cf-check view">
|
||||||
|
<input type="checkbox" checked={toBooleanFieldValue(rawValue)} disabled />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="kv-actions" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={`view-field-${index}`} className="kv-row">
|
||||||
|
<span className="kv-label">{fieldName}</span>
|
||||||
|
<div className="kv-main">
|
||||||
|
<strong>{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
|
{fieldType === 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
onClick={() => setHiddenFieldVisibleMap((prev) => ({ ...prev, [index]: !prev[index] }))}
|
||||||
|
>
|
||||||
|
{isHiddenVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
|
||||||
|
{isHiddenVisible ? 'Hide' : 'Reveal'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
|
||||||
|
<Clipboard size={14} className="btn-icon" /> Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="detail-actions">
|
<div className="detail-actions">
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button type="button" className="btn btn-secondary" onClick={startEdit}>
|
<button type="button" className="btn btn-secondary" onClick={startEdit}>
|
||||||
Edit
|
<Pencil size={14} className="btn-icon" /> Edit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-danger" onClick={() => setPendingDelete(selectedCipher)}>
|
<button type="button" className="btn btn-danger" onClick={() => setPendingDelete(selectedCipher)}>
|
||||||
Delete
|
<Trash2 size={14} className="btn-icon" /> Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isEditing && !selectedCipher && <div className="empty card">Select an item</div>}
|
{!isEditing && !selectedCipher && <div className="empty card">Select an item</div>}
|
||||||
</section>
|
</section>
|
||||||
@@ -965,7 +1141,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
{
|
{
|
||||||
type: fieldType,
|
type: fieldType,
|
||||||
label: fieldLabel.trim(),
|
label: fieldLabel.trim(),
|
||||||
value: fieldValue,
|
value: fieldType === 2 ? (toBooleanFieldValue(fieldValue) ? 'true' : 'false') : fieldValue,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
setFieldModalOpen(false);
|
setFieldModalOpen(false);
|
||||||
@@ -995,10 +1171,21 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
<span>Field Label</span>
|
<span>Field Label</span>
|
||||||
<input className="input" value={fieldLabel} onInput={(e) => setFieldLabel((e.currentTarget as HTMLInputElement).value)} />
|
<input className="input" value={fieldLabel} onInput={(e) => setFieldLabel((e.currentTarget as HTMLInputElement).value)} />
|
||||||
</label>
|
</label>
|
||||||
|
{fieldType === 2 ? (
|
||||||
|
<label className="check-line">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={toBooleanFieldValue(fieldValue)}
|
||||||
|
onInput={(e) => setFieldValue((e.currentTarget as HTMLInputElement).checked ? 'true' : 'false')}
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Field Value</span>
|
<span>Field Value</span>
|
||||||
<input className="input" value={fieldValue} onInput={(e) => setFieldValue((e.currentTarget as HTMLInputElement).value)} />
|
<input className="input" value={fieldValue} onInput={(e) => setFieldValue((e.currentTarget as HTMLInputElement).value)} />
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -1040,6 +1227,27 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={repromptOpen}
|
||||||
|
title="Unlock Item"
|
||||||
|
message="Enter master password to view this item."
|
||||||
|
confirmText="Unlock"
|
||||||
|
cancelText="Cancel"
|
||||||
|
showIcon={false}
|
||||||
|
onConfirm={() => void verifyReprompt()}
|
||||||
|
onCancel={() => {
|
||||||
|
setRepromptOpen(false);
|
||||||
|
setRepromptPassword('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>Master Password</span>
|
||||||
|
<input className="input" type="password" value={repromptPassword} onInput={(e) => setRepromptPassword((e.currentTarget as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+31
-1
@@ -307,6 +307,30 @@ export async function setTotp(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function verifyMasterPassword(
|
||||||
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
|
masterPasswordHash: string
|
||||||
|
): Promise<void> {
|
||||||
|
const resp = await authedFetch('/api/accounts/verify-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ masterPasswordHash }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await parseJson<TokenError>(resp);
|
||||||
|
throw new Error(body?.error_description || body?.error || 'Master password verify failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTotpStatus(
|
||||||
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>
|
||||||
|
): Promise<{ enabled: boolean }> {
|
||||||
|
const resp = await authedFetch('/api/accounts/totp');
|
||||||
|
if (!resp.ok) throw new Error('Failed to load TOTP status');
|
||||||
|
const body = (await parseJson<{ enabled?: boolean }>(resp)) || {};
|
||||||
|
return { enabled: !!body.enabled };
|
||||||
|
}
|
||||||
|
|
||||||
export async function listAdminUsers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<AdminUser[]> {
|
export async function listAdminUsers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<AdminUser[]> {
|
||||||
const resp = await authedFetch('/api/admin/users');
|
const resp = await authedFetch('/api/admin/users');
|
||||||
if (!resp.ok) throw new Error('Failed to load users');
|
if (!resp.ok) throw new Error('Failed to load users');
|
||||||
@@ -335,6 +359,11 @@ export async function revokeInvite(authedFetch: (input: string, init?: RequestIn
|
|||||||
if (!resp.ok) throw new Error('Revoke invite failed');
|
if (!resp.ok) throw new Error('Revoke invite failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteAllInvites(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<void> {
|
||||||
|
const resp = await authedFetch('/api/admin/invites', { method: 'DELETE' });
|
||||||
|
if (!resp.ok) throw new Error('Delete all invites failed');
|
||||||
|
}
|
||||||
|
|
||||||
export async function setUserStatus(
|
export async function setUserStatus(
|
||||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -424,6 +453,7 @@ export async function createCipher(
|
|||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
type,
|
type,
|
||||||
|
favorite: !!draft.favorite,
|
||||||
folderId: asNullable(draft.folderId),
|
folderId: asNullable(draft.folderId),
|
||||||
reprompt: draft.reprompt ? 1 : 0,
|
reprompt: draft.reprompt ? 1 : 0,
|
||||||
name: await encryptTextValue(draft.name, enc, mac),
|
name: await encryptTextValue(draft.name, enc, mac),
|
||||||
@@ -508,7 +538,7 @@ export async function updateCipher(
|
|||||||
type,
|
type,
|
||||||
key: keys.key,
|
key: keys.key,
|
||||||
folderId: asNullable(draft.folderId),
|
folderId: asNullable(draft.folderId),
|
||||||
favorite: !!cipher.favorite,
|
favorite: !!draft.favorite,
|
||||||
reprompt: draft.reprompt ? 1 : 0,
|
reprompt: draft.reprompt ? 1 : 0,
|
||||||
name: await encryptTextValue(draft.name, keys.enc, keys.mac),
|
name: await encryptTextValue(draft.name, keys.enc, keys.mac),
|
||||||
notes: await encryptTextValue(draft.notes, keys.enc, keys.mac),
|
notes: await encryptTextValue(draft.notes, keys.enc, keys.mac),
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export interface VaultDraftField {
|
|||||||
export interface VaultDraft {
|
export interface VaultDraft {
|
||||||
id?: string;
|
id?: string;
|
||||||
type: number;
|
type: number;
|
||||||
|
favorite: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
folderId: string;
|
folderId: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
|
|||||||
+134
-6
@@ -95,23 +95,33 @@ body,
|
|||||||
border-color: #2f5fd8;
|
border-color: #2f5fd8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input:disabled {
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.password-wrap {
|
.password-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-wrap .input {
|
.password-wrap .input {
|
||||||
padding-right: 88px;
|
padding-right: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eye-btn {
|
.eye-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 42px;
|
right: 10px;
|
||||||
bottom: 9px;
|
bottom: 9px;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@@ -122,6 +132,14 @@ body,
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.full {
|
.btn.full {
|
||||||
@@ -166,6 +184,13 @@ body,
|
|||||||
background: #fff1f2;
|
background: #fff1f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.or {
|
.or {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
@@ -219,7 +244,7 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-email {
|
.user-email {
|
||||||
font-size: 13px;
|
font-size: 18px;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +336,7 @@ body,
|
|||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +359,7 @@ body,
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -345,6 +371,7 @@ body,
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-icon {
|
.list-icon {
|
||||||
@@ -354,7 +381,14 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-icon-fallback {
|
.list-icon-fallback {
|
||||||
font-size: 20px;
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-icon-fallback svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-text {
|
.list-text {
|
||||||
@@ -400,6 +434,7 @@ body,
|
|||||||
.kv-line {
|
.kv-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
border-bottom: 1px solid #ecf0f5;
|
border-bottom: 1px solid #ecf0f5;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
@@ -413,8 +448,43 @@ body,
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kv-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 90px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid #ecf0f5;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-label {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.notes {
|
.notes {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
color: #334155;
|
color: #334155;
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
}
|
}
|
||||||
@@ -472,6 +542,12 @@ body,
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.muted-inline {
|
||||||
|
color: var(--muted);
|
||||||
|
align-self: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.create-menu-wrap {
|
.create-menu-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -505,11 +581,36 @@ body,
|
|||||||
|
|
||||||
.uri-row {
|
.uri-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto auto;
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.website-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-row .btn {
|
||||||
|
justify-self: start;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-check {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-check.view {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-check input[type='checkbox'] {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
.field-type-pill {
|
.field-type-pill {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
@@ -522,6 +623,10 @@ body,
|
|||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.star-on {
|
||||||
|
background: #eef4ff;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-actions {
|
.detail-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -535,6 +640,12 @@ body,
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-ok {
|
||||||
|
margin: 2px 0 10px 0;
|
||||||
|
color: #0f766e;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.kv-line strong {
|
.kv-line strong {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
@@ -568,6 +679,23 @@ body,
|
|||||||
width: 120px;
|
width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-row-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-actions-head {
|
||||||
|
text-align: right !important;
|
||||||
|
}
|
||||||
|
|
||||||
.dialog-mask {
|
.dialog-mask {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user