mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add SSH key utilities and improve field decryption
This commit is contained in:
@@ -13,6 +13,7 @@ dist/
|
|||||||
build/
|
build/
|
||||||
public/
|
public/
|
||||||
public2/
|
public2/
|
||||||
|
public/index.html
|
||||||
|
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>NodeWarden</title>
|
|
||||||
<script type="module" crossorigin src="/assets/index-CfeJfWbB.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BNxoWS2-.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+68
-51
@@ -1,7 +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 { CircleHelp, LogOut, Plus, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact';
|
import { CircleHelp, LogOut, Settings as SettingsIcon, Shield, ShieldUser, Vault } 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';
|
||||||
@@ -11,6 +11,7 @@ import AdminPage from '@/components/AdminPage';
|
|||||||
import HelpPage from '@/components/HelpPage';
|
import HelpPage from '@/components/HelpPage';
|
||||||
import {
|
import {
|
||||||
changeMasterPassword,
|
changeMasterPassword,
|
||||||
|
createFolder,
|
||||||
createCipher,
|
createCipher,
|
||||||
createAuthedFetch,
|
createAuthedFetch,
|
||||||
createInvite,
|
createInvite,
|
||||||
@@ -291,13 +292,6 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleQuickAdd() {
|
|
||||||
navigate('/vault');
|
|
||||||
window.setTimeout(() => {
|
|
||||||
window.dispatchEvent(new Event('nodewarden:add-item'));
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ciphersQuery = useQuery({
|
const ciphersQuery = useQuery({
|
||||||
queryKey: ['ciphers', session?.accessToken],
|
queryKey: ['ciphers', session?.accessToken],
|
||||||
queryFn: () => getCiphers(authedFetch),
|
queryFn: () => getCiphers(authedFetch),
|
||||||
@@ -337,11 +331,24 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const encKey = base64ToBytes(session.symEncKey!);
|
const encKey = base64ToBytes(session.symEncKey!);
|
||||||
const macKey = base64ToBytes(session.symMacKey!);
|
const macKey = base64ToBytes(session.symMacKey!);
|
||||||
|
const decryptField = async (
|
||||||
|
value: string | null | undefined,
|
||||||
|
fieldEnc: Uint8Array = encKey,
|
||||||
|
fieldMac: Uint8Array = macKey
|
||||||
|
): Promise<string> => {
|
||||||
|
if (!value || typeof value !== 'string') return '';
|
||||||
|
try {
|
||||||
|
return await decryptStr(value, fieldEnc, fieldMac);
|
||||||
|
} catch {
|
||||||
|
// Backward-compatibility: some records may already be plain text.
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const folders = await Promise.all(
|
const folders = await Promise.all(
|
||||||
foldersQuery.data.map(async (folder) => ({
|
foldersQuery.data.map(async (folder) => ({
|
||||||
...folder,
|
...folder,
|
||||||
decName: await decryptStr(folder.name, encKey, macKey),
|
decName: await decryptField(folder.name, encKey, macKey),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -361,19 +368,19 @@ export default function App() {
|
|||||||
|
|
||||||
const nextCipher: Cipher = {
|
const nextCipher: Cipher = {
|
||||||
...cipher,
|
...cipher,
|
||||||
decName: await decryptStr(cipher.name || '', itemEnc, itemMac),
|
decName: await decryptField(cipher.name || '', itemEnc, itemMac),
|
||||||
decNotes: await decryptStr(cipher.notes || '', itemEnc, itemMac),
|
decNotes: await decryptField(cipher.notes || '', itemEnc, itemMac),
|
||||||
};
|
};
|
||||||
if (cipher.login) {
|
if (cipher.login) {
|
||||||
nextCipher.login = {
|
nextCipher.login = {
|
||||||
...cipher.login,
|
...cipher.login,
|
||||||
decUsername: await decryptStr(cipher.login.username || '', itemEnc, itemMac),
|
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
|
||||||
decPassword: await decryptStr(cipher.login.password || '', itemEnc, itemMac),
|
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac),
|
||||||
decTotp: await decryptStr(cipher.login.totp || '', itemEnc, itemMac),
|
decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac),
|
||||||
uris: await Promise.all(
|
uris: await Promise.all(
|
||||||
(cipher.login.uris || []).map(async (u) => ({
|
(cipher.login.uris || []).map(async (u) => ({
|
||||||
...u,
|
...u,
|
||||||
decUri: await decryptStr(u.uri || '', itemEnc, itemMac),
|
decUri: await decryptField(u.uri || '', itemEnc, itemMac),
|
||||||
}))
|
}))
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -381,51 +388,51 @@ export default function App() {
|
|||||||
if (cipher.card) {
|
if (cipher.card) {
|
||||||
nextCipher.card = {
|
nextCipher.card = {
|
||||||
...cipher.card,
|
...cipher.card,
|
||||||
decCardholderName: await decryptStr(cipher.card.cardholderName || '', itemEnc, itemMac),
|
decCardholderName: await decryptField(cipher.card.cardholderName || '', itemEnc, itemMac),
|
||||||
decNumber: await decryptStr(cipher.card.number || '', itemEnc, itemMac),
|
decNumber: await decryptField(cipher.card.number || '', itemEnc, itemMac),
|
||||||
decBrand: await decryptStr(cipher.card.brand || '', itemEnc, itemMac),
|
decBrand: await decryptField(cipher.card.brand || '', itemEnc, itemMac),
|
||||||
decExpMonth: await decryptStr(cipher.card.expMonth || '', itemEnc, itemMac),
|
decExpMonth: await decryptField(cipher.card.expMonth || '', itemEnc, itemMac),
|
||||||
decExpYear: await decryptStr(cipher.card.expYear || '', itemEnc, itemMac),
|
decExpYear: await decryptField(cipher.card.expYear || '', itemEnc, itemMac),
|
||||||
decCode: await decryptStr(cipher.card.code || '', itemEnc, itemMac),
|
decCode: await decryptField(cipher.card.code || '', itemEnc, itemMac),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (cipher.identity) {
|
if (cipher.identity) {
|
||||||
nextCipher.identity = {
|
nextCipher.identity = {
|
||||||
...cipher.identity,
|
...cipher.identity,
|
||||||
decTitle: await decryptStr(cipher.identity.title || '', itemEnc, itemMac),
|
decTitle: await decryptField(cipher.identity.title || '', itemEnc, itemMac),
|
||||||
decFirstName: await decryptStr(cipher.identity.firstName || '', itemEnc, itemMac),
|
decFirstName: await decryptField(cipher.identity.firstName || '', itemEnc, itemMac),
|
||||||
decMiddleName: await decryptStr(cipher.identity.middleName || '', itemEnc, itemMac),
|
decMiddleName: await decryptField(cipher.identity.middleName || '', itemEnc, itemMac),
|
||||||
decLastName: await decryptStr(cipher.identity.lastName || '', itemEnc, itemMac),
|
decLastName: await decryptField(cipher.identity.lastName || '', itemEnc, itemMac),
|
||||||
decUsername: await decryptStr(cipher.identity.username || '', itemEnc, itemMac),
|
decUsername: await decryptField(cipher.identity.username || '', itemEnc, itemMac),
|
||||||
decCompany: await decryptStr(cipher.identity.company || '', itemEnc, itemMac),
|
decCompany: await decryptField(cipher.identity.company || '', itemEnc, itemMac),
|
||||||
decSsn: await decryptStr(cipher.identity.ssn || '', itemEnc, itemMac),
|
decSsn: await decryptField(cipher.identity.ssn || '', itemEnc, itemMac),
|
||||||
decPassportNumber: await decryptStr(cipher.identity.passportNumber || '', itemEnc, itemMac),
|
decPassportNumber: await decryptField(cipher.identity.passportNumber || '', itemEnc, itemMac),
|
||||||
decLicenseNumber: await decryptStr(cipher.identity.licenseNumber || '', itemEnc, itemMac),
|
decLicenseNumber: await decryptField(cipher.identity.licenseNumber || '', itemEnc, itemMac),
|
||||||
decEmail: await decryptStr(cipher.identity.email || '', itemEnc, itemMac),
|
decEmail: await decryptField(cipher.identity.email || '', itemEnc, itemMac),
|
||||||
decPhone: await decryptStr(cipher.identity.phone || '', itemEnc, itemMac),
|
decPhone: await decryptField(cipher.identity.phone || '', itemEnc, itemMac),
|
||||||
decAddress1: await decryptStr(cipher.identity.address1 || '', itemEnc, itemMac),
|
decAddress1: await decryptField(cipher.identity.address1 || '', itemEnc, itemMac),
|
||||||
decAddress2: await decryptStr(cipher.identity.address2 || '', itemEnc, itemMac),
|
decAddress2: await decryptField(cipher.identity.address2 || '', itemEnc, itemMac),
|
||||||
decAddress3: await decryptStr(cipher.identity.address3 || '', itemEnc, itemMac),
|
decAddress3: await decryptField(cipher.identity.address3 || '', itemEnc, itemMac),
|
||||||
decCity: await decryptStr(cipher.identity.city || '', itemEnc, itemMac),
|
decCity: await decryptField(cipher.identity.city || '', itemEnc, itemMac),
|
||||||
decState: await decryptStr(cipher.identity.state || '', itemEnc, itemMac),
|
decState: await decryptField(cipher.identity.state || '', itemEnc, itemMac),
|
||||||
decPostalCode: await decryptStr(cipher.identity.postalCode || '', itemEnc, itemMac),
|
decPostalCode: await decryptField(cipher.identity.postalCode || '', itemEnc, itemMac),
|
||||||
decCountry: await decryptStr(cipher.identity.country || '', itemEnc, itemMac),
|
decCountry: await decryptField(cipher.identity.country || '', itemEnc, itemMac),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (cipher.sshKey) {
|
if (cipher.sshKey) {
|
||||||
nextCipher.sshKey = {
|
nextCipher.sshKey = {
|
||||||
...cipher.sshKey,
|
...cipher.sshKey,
|
||||||
decPrivateKey: await decryptStr(cipher.sshKey.privateKey || '', itemEnc, itemMac),
|
decPrivateKey: await decryptField(cipher.sshKey.privateKey || '', itemEnc, itemMac),
|
||||||
decPublicKey: await decryptStr(cipher.sshKey.publicKey || '', itemEnc, itemMac),
|
decPublicKey: await decryptField(cipher.sshKey.publicKey || '', itemEnc, itemMac),
|
||||||
decFingerprint: await decryptStr(cipher.sshKey.fingerprint || '', itemEnc, itemMac),
|
decFingerprint: await decryptField(cipher.sshKey.fingerprint || '', itemEnc, itemMac),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (cipher.fields) {
|
if (cipher.fields) {
|
||||||
nextCipher.fields = await Promise.all(
|
nextCipher.fields = await Promise.all(
|
||||||
cipher.fields.map(async (field) => ({
|
cipher.fields.map(async (field) => ({
|
||||||
...field,
|
...field,
|
||||||
decName: await decryptStr(field.name || '', itemEnc, itemMac),
|
decName: await decryptField(field.name || '', itemEnc, itemMac),
|
||||||
decValue: await decryptStr(field.value || '', itemEnc, itemMac),
|
decValue: await decryptField(field.value || '', itemEnc, itemMac),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -587,6 +594,22 @@ export default function App() {
|
|||||||
await verifyMasterPassword(authedFetch, derived.hash);
|
await verifyMasterPassword(authedFetch, derived.hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createFolderAction(name: string) {
|
||||||
|
const folderName = name.trim();
|
||||||
|
if (!folderName) {
|
||||||
|
pushToast('error', 'Folder name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createFolder(authedFetch, folderName);
|
||||||
|
await foldersQuery.refetch();
|
||||||
|
pushToast('success', 'Folder created');
|
||||||
|
} catch (error) {
|
||||||
|
pushToast('error', error instanceof Error ? error.message : 'Create folder failed');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase === 'app' && location === '/') navigate('/vault');
|
if (phase === 'app' && location === '/') navigate('/vault');
|
||||||
}, [phase, location, navigate]);
|
}, [phase, location, navigate]);
|
||||||
@@ -686,13 +709,6 @@ export default function App() {
|
|||||||
<CircleHelp size={16} />
|
<CircleHelp size={16} />
|
||||||
<span>Support Center</span>
|
<span>Support Center</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="side-spacer" />
|
|
||||||
<button type="button" className="btn btn-primary side-add-btn" onClick={handleQuickAdd}>
|
|
||||||
<Plus size={16} className="btn-icon" /> Add New Item
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-secondary side-lock-btn" onClick={handleLock}>
|
|
||||||
Lock
|
|
||||||
</button>
|
|
||||||
</aside>
|
</aside>
|
||||||
<main className="content">
|
<main className="content">
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -710,6 +726,7 @@ export default function App() {
|
|||||||
onBulkMove={bulkMoveVaultItems}
|
onBulkMove={bulkMoveVaultItems}
|
||||||
onVerifyMasterPassword={verifyMasterPasswordAction}
|
onVerifyMasterPassword={verifyMasterPasswordAction}
|
||||||
onNotify={pushToast}
|
onNotify={pushToast}
|
||||||
|
onCreateFolder={createFolderAction}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/settings">
|
<Route path="/settings">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, 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 { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
|
||||||
import {
|
import {
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileKey2,
|
FileKey2,
|
||||||
Folder as FolderIcon,
|
Folder as FolderIcon,
|
||||||
|
FolderPlus,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
FolderX,
|
FolderX,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
@@ -41,6 +43,7 @@ interface VaultPageProps {
|
|||||||
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
|
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
|
||||||
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
|
onCreateFolder: (name: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TypeFilter = 'all' | 'favorite' | 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
type TypeFilter = 'all' | 'favorite' | 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||||
@@ -58,6 +61,15 @@ const CREATE_TYPE_OPTIONS: TypeOption[] = [
|
|||||||
{ type: 5, label: 'SSH Key' },
|
{ type: 5, label: 'SSH Key' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function CreateTypeIcon({ type }: { type: number }) {
|
||||||
|
if (type === 1) return <Globe size={15} />;
|
||||||
|
if (type === 3) return <CreditCard size={15} />;
|
||||||
|
if (type === 4) return <ShieldUser size={15} />;
|
||||||
|
if (type === 2) return <StickyNote size={15} />;
|
||||||
|
if (type === 5) return <KeyRound size={15} />;
|
||||||
|
return <FileKey2 size={15} />;
|
||||||
|
}
|
||||||
|
|
||||||
const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }> = [
|
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' },
|
||||||
@@ -301,12 +313,17 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||||
const [moveOpen, setMoveOpen] = useState(false);
|
const [moveOpen, setMoveOpen] = useState(false);
|
||||||
const [moveFolderId, setMoveFolderId] = useState('__none__');
|
const [moveFolderId, setMoveFolderId] = useState('__none__');
|
||||||
|
const [createFolderOpen, setCreateFolderOpen] = useState(false);
|
||||||
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
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 [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [repromptOpen, setRepromptOpen] = useState(false);
|
const [repromptOpen, setRepromptOpen] = useState(false);
|
||||||
const [repromptPassword, setRepromptPassword] = useState('');
|
const [repromptPassword, setRepromptPassword] = useState('');
|
||||||
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
||||||
|
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const sshSeedTicketRef = useRef(0);
|
||||||
|
const sshFingerprintTicketRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onQuickAdd = () => {
|
const onQuickAdd = () => {
|
||||||
@@ -316,6 +333,25 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
return () => window.removeEventListener('nodewarden:add-item', onQuickAdd);
|
return () => window.removeEventListener('nodewarden:add-item', onQuickAdd);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPointerDown = (event: Event) => {
|
||||||
|
if (!createMenuOpen) return;
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (createMenuRef.current && target && !createMenuRef.current.contains(target)) {
|
||||||
|
setCreateMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setCreateMenuOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', onPointerDown);
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [createMenuOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRepromptApprovedCipherId(null);
|
setRepromptApprovedCipherId(null);
|
||||||
setRepromptPassword('');
|
setRepromptPassword('');
|
||||||
@@ -328,6 +364,11 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [searchInput, searchComposing]);
|
}, [searchInput, searchComposing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEditing || !draft || draft.type !== 5) return;
|
||||||
|
void recalculateSshFingerprint(draft.sshPublicKey);
|
||||||
|
}, [isEditing, draft?.id, draft?.type]);
|
||||||
|
|
||||||
const filteredCiphers = useMemo(() => {
|
const filteredCiphers = useMemo(() => {
|
||||||
return props.ciphers.filter((cipher) => {
|
return props.ciphers.filter((cipher) => {
|
||||||
if (!matchesTypeFilter(cipher, typeFilter)) return false;
|
if (!matchesTypeFilter(cipher, typeFilter)) return false;
|
||||||
@@ -407,6 +448,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
setSelectedCipherId('');
|
setSelectedCipherId('');
|
||||||
setShowPassword(false);
|
setShowPassword(false);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
|
if (type === 5) void seedSshDefaults();
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(): void {
|
function startEdit(): void {
|
||||||
@@ -429,6 +471,46 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function seedSshDefaults(force = false): Promise<void> {
|
||||||
|
const ticket = ++sshSeedTicketRef.current;
|
||||||
|
try {
|
||||||
|
const generated = await generateDefaultSshKeyMaterial();
|
||||||
|
if (ticket !== sshSeedTicketRef.current) return;
|
||||||
|
setDraft((prev) => {
|
||||||
|
if (!prev || prev.type !== 5) return prev;
|
||||||
|
if (!force && (prev.sshPrivateKey.trim() || prev.sshPublicKey.trim())) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
sshPrivateKey: generated.privateKey,
|
||||||
|
sshPublicKey: generated.publicKey,
|
||||||
|
sshFingerprint: generated.fingerprint,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Browser may not support Ed25519 generation; user can still paste keys manually.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recalculateSshFingerprint(publicKey: string): Promise<void> {
|
||||||
|
const ticket = ++sshFingerprintTicketRef.current;
|
||||||
|
const fingerprint = await computeSshFingerprint(publicKey);
|
||||||
|
if (ticket !== sshFingerprintTicketRef.current) return;
|
||||||
|
setDraft((prev) => {
|
||||||
|
if (!prev || prev.type !== 5) return prev;
|
||||||
|
if (prev.sshPublicKey !== publicKey) return prev;
|
||||||
|
if (prev.sshFingerprint === fingerprint) return prev;
|
||||||
|
return { ...prev, sshFingerprint: fingerprint };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSshPublicKey(nextValue: string): void {
|
||||||
|
setDraft((prev) => {
|
||||||
|
if (!prev || prev.type !== 5) return prev;
|
||||||
|
return { ...prev, sshPublicKey: nextValue };
|
||||||
|
});
|
||||||
|
void recalculateSshFingerprint(nextValue);
|
||||||
|
}
|
||||||
|
|
||||||
function updateDraftCustomFields(nextFields: VaultDraftField[]): void {
|
function updateDraftCustomFields(nextFields: VaultDraftField[]): void {
|
||||||
setDraft((prev) => (prev ? { ...prev, customFields: nextFields } : prev));
|
setDraft((prev) => (prev ? { ...prev, customFields: nextFields } : prev));
|
||||||
}
|
}
|
||||||
@@ -453,16 +535,24 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
|
|
||||||
async function saveDraft(): Promise<void> {
|
async function saveDraft(): Promise<void> {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
if (!draft.name.trim()) {
|
let nextDraft = draft;
|
||||||
|
if (nextDraft.type === 5) {
|
||||||
|
const computedFingerprint = await computeSshFingerprint(nextDraft.sshPublicKey);
|
||||||
|
if (computedFingerprint !== nextDraft.sshFingerprint) {
|
||||||
|
nextDraft = { ...nextDraft, sshFingerprint: computedFingerprint };
|
||||||
|
setDraft(nextDraft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!nextDraft.name.trim()) {
|
||||||
setLocalError('Item name is required.');
|
setLocalError('Item name is required.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
if (isCreating) {
|
if (isCreating) {
|
||||||
await props.onCreate(draft);
|
await props.onCreate(nextDraft);
|
||||||
} else if (selectedCipher) {
|
} else if (selectedCipher) {
|
||||||
await props.onUpdate(selectedCipher, draft);
|
await props.onUpdate(selectedCipher, nextDraft);
|
||||||
}
|
}
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
@@ -544,57 +634,62 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function confirmCreateFolder(): Promise<void> {
|
||||||
|
if (!newFolderName.trim()) {
|
||||||
|
props.onNotify('error', 'Folder name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await props.onCreateFolder(newFolderName);
|
||||||
|
setCreateFolderOpen(false);
|
||||||
|
setNewFolderName('');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="vault-grid">
|
<div className="vault-grid">
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="sidebar-block">
|
|
||||||
<div className="sidebar-title">Search</div>
|
|
||||||
<input
|
|
||||||
className="search-input"
|
|
||||||
placeholder="Search vault"
|
|
||||||
value={searchInput}
|
|
||||||
onInput={(e) => setSearchInput((e.currentTarget as HTMLInputElement).value)}
|
|
||||||
onCompositionStart={() => setSearchComposing(true)}
|
|
||||||
onCompositionEnd={(e) => {
|
|
||||||
setSearchComposing(false);
|
|
||||||
setSearchInput((e.currentTarget as HTMLInputElement).value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sidebar-block">
|
<div className="sidebar-block">
|
||||||
<div className="sidebar-title">Types</div>
|
<div className="sidebar-title">Types</div>
|
||||||
<button type="button" className={`tree-btn ${typeFilter === 'all' ? 'active' : ''}`} onClick={() => setTypeFilter('all')}>
|
<button type="button" className={`tree-btn ${typeFilter === 'all' ? 'active' : ''}`} onClick={() => setTypeFilter('all')}>
|
||||||
<LayoutGrid size={14} className="tree-icon" /> All Items
|
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">All Items</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={`tree-btn ${typeFilter === 'favorite' ? 'active' : ''}`} onClick={() => setTypeFilter('favorite')}>
|
<button type="button" className={`tree-btn ${typeFilter === 'favorite' ? 'active' : ''}`} onClick={() => setTypeFilter('favorite')}>
|
||||||
<Star size={14} className="tree-icon" /> Favorites
|
<Star size={14} className="tree-icon" /> <span className="tree-label">Favorites</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={`tree-btn ${typeFilter === 'login' ? 'active' : ''}`} onClick={() => setTypeFilter('login')}>
|
<button type="button" className={`tree-btn ${typeFilter === 'login' ? 'active' : ''}`} onClick={() => setTypeFilter('login')}>
|
||||||
<Globe size={14} className="tree-icon" /> Login
|
<Globe size={14} className="tree-icon" /> <span className="tree-label">Login</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={`tree-btn ${typeFilter === 'card' ? 'active' : ''}`} onClick={() => setTypeFilter('card')}>
|
<button type="button" className={`tree-btn ${typeFilter === 'card' ? 'active' : ''}`} onClick={() => setTypeFilter('card')}>
|
||||||
<CreditCard size={14} className="tree-icon" /> Card
|
<CreditCard size={14} className="tree-icon" /> <span className="tree-label">Card</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={`tree-btn ${typeFilter === 'identity' ? 'active' : ''}`} onClick={() => setTypeFilter('identity')}>
|
<button type="button" className={`tree-btn ${typeFilter === 'identity' ? 'active' : ''}`} onClick={() => setTypeFilter('identity')}>
|
||||||
<ShieldUser size={14} className="tree-icon" /> Identity
|
<ShieldUser size={14} className="tree-icon" /> <span className="tree-label">Identity</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={`tree-btn ${typeFilter === 'note' ? 'active' : ''}`} onClick={() => setTypeFilter('note')}>
|
<button type="button" className={`tree-btn ${typeFilter === 'note' ? 'active' : ''}`} onClick={() => setTypeFilter('note')}>
|
||||||
<StickyNote size={14} className="tree-icon" /> Note
|
<StickyNote size={14} className="tree-icon" /> <span className="tree-label">Note</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={`tree-btn ${typeFilter === 'ssh' ? 'active' : ''}`} onClick={() => setTypeFilter('ssh')}>
|
<button type="button" className={`tree-btn ${typeFilter === 'ssh' ? 'active' : ''}`} onClick={() => setTypeFilter('ssh')}>
|
||||||
<KeyRound size={14} className="tree-icon" /> SSH Key
|
<KeyRound size={14} className="tree-icon" /> <span className="tree-label">SSH Key</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-block">
|
<div className="sidebar-block">
|
||||||
<div className="sidebar-title">Folders</div>
|
<div className="sidebar-title-row">
|
||||||
|
<div className="sidebar-title">Folders</div>
|
||||||
|
<button type="button" className="folder-add-btn" onClick={() => setCreateFolderOpen(true)}>
|
||||||
|
<FolderPlus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button type="button" className={`tree-btn ${folderFilter === 'all' ? 'active' : ''}`} onClick={() => setFolderFilter('all')}>
|
<button type="button" className={`tree-btn ${folderFilter === 'all' ? 'active' : ''}`} onClick={() => setFolderFilter('all')}>
|
||||||
<FolderOpen size={14} className="tree-icon" /> All
|
<FolderOpen size={14} className="tree-icon" /> <span className="tree-label">All</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={`tree-btn ${folderFilter === 'none' ? 'active' : ''}`} onClick={() => setFolderFilter('none')}>
|
<button type="button" className={`tree-btn ${folderFilter === 'none' ? 'active' : ''}`} onClick={() => setFolderFilter('none')}>
|
||||||
<FolderX size={14} className="tree-icon" /> No Folder
|
<FolderX size={14} className="tree-icon" /> <span className="tree-label">No Folder</span>
|
||||||
</button>
|
</button>
|
||||||
{props.folders.map((folder) => (
|
{props.folders.map((folder) => (
|
||||||
<button
|
<button
|
||||||
@@ -603,19 +698,35 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
className={`tree-btn ${folderFilter === folder.id ? 'active' : ''}`}
|
className={`tree-btn ${folderFilter === folder.id ? 'active' : ''}`}
|
||||||
onClick={() => setFolderFilter(folder.id)}
|
onClick={() => setFolderFilter(folder.id)}
|
||||||
>
|
>
|
||||||
<FolderIcon size={14} className="tree-icon" /> {folder.decName || folder.name || folder.id}
|
<FolderIcon size={14} className="tree-icon" />
|
||||||
|
<span className="tree-label" title={folder.decName || folder.name || folder.id}>
|
||||||
|
{folder.decName || folder.name || folder.id}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section className="list-col">
|
<section className="list-col">
|
||||||
<div className="toolbar actions">
|
<div className="list-head">
|
||||||
|
<input
|
||||||
|
className="search-input"
|
||||||
|
placeholder="Search your secure vault..."
|
||||||
|
value={searchInput}
|
||||||
|
onInput={(e) => setSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
onCompositionStart={() => setSearchComposing(true)}
|
||||||
|
onCompositionEnd={(e) => {
|
||||||
|
setSearchComposing(false);
|
||||||
|
setSearchInput((e.currentTarget as HTMLInputElement).value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<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()}>
|
||||||
<RefreshCw size={14} className="btn-icon" /> Sync
|
<RefreshCw size={14} className="btn-icon" /> Sync Vault
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="toolbar actions">
|
||||||
<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)}>
|
||||||
<Trash2 size={14} className="btn-icon" /> Delete ({selectedCount})
|
<Trash2 size={14} className="btn-icon" /> Delete Selected
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -629,7 +740,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
>
|
>
|
||||||
<CheckCheck size={14} className="btn-icon" /> Select All
|
<CheckCheck size={14} className="btn-icon" /> Select All
|
||||||
</button>
|
</button>
|
||||||
<div className="create-menu-wrap">
|
<div className="create-menu-wrap" ref={createMenuRef}>
|
||||||
<button type="button" className="btn btn-primary small" onClick={() => setCreateMenuOpen((x) => !x)}>
|
<button type="button" className="btn btn-primary small" onClick={() => setCreateMenuOpen((x) => !x)}>
|
||||||
<Plus size={14} className="btn-icon" /> Add
|
<Plus size={14} className="btn-icon" /> Add
|
||||||
</button>
|
</button>
|
||||||
@@ -637,7 +748,8 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
<div className="create-menu">
|
<div className="create-menu">
|
||||||
{CREATE_TYPE_OPTIONS.map((option) => (
|
{CREATE_TYPE_OPTIONS.map((option) => (
|
||||||
<button key={option.type} type="button" className="create-menu-item" onClick={() => startCreate(option.type)}>
|
<button key={option.type} type="button" className="create-menu-item" onClick={() => startCreate(option.type)}>
|
||||||
{option.label}
|
<CreateTypeIcon type={option.type} />
|
||||||
|
<span>{option.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -689,8 +801,8 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
<VaultListIcon cipher={cipher} />
|
<VaultListIcon cipher={cipher} />
|
||||||
</div>
|
</div>
|
||||||
<div className="list-text">
|
<div className="list-text">
|
||||||
<span className="list-title">{cipher.decName || '(No Name)'}</span>
|
<span className="list-title" title={cipher.decName || '(No Name)'}>{cipher.decName || '(No Name)'}</span>
|
||||||
<span className="list-sub">{listSubtitle(cipher)}</span>
|
<span className="list-sub" title={listSubtitle(cipher)}>{listSubtitle(cipher)}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -721,7 +833,11 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
className="input"
|
className="input"
|
||||||
value={draft.type}
|
value={draft.type}
|
||||||
disabled={!isCreating}
|
disabled={!isCreating}
|
||||||
onInput={(e) => updateDraft({ type: Number((e.currentTarget as HTMLSelectElement).value) })}
|
onInput={(e) => {
|
||||||
|
const nextType = Number((e.currentTarget as HTMLSelectElement).value);
|
||||||
|
updateDraft({ type: nextType });
|
||||||
|
if (nextType === 5) void seedSshDefaults();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{CREATE_TYPE_OPTIONS.map((option) => (
|
{CREATE_TYPE_OPTIONS.map((option) => (
|
||||||
<option key={option.type} value={option.type}>
|
<option key={option.type} value={option.type}>
|
||||||
@@ -851,18 +967,23 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
)}
|
)}
|
||||||
{draft.type === 5 && (
|
{draft.type === 5 && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h4>SSH Key</h4>
|
<div className="section-head">
|
||||||
|
<h4>SSH Key</h4>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => void seedSshDefaults(true)}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" /> Regenerate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Private Key</span>
|
<span>Private Key</span>
|
||||||
<textarea className="input textarea" value={draft.sshPrivateKey} onInput={(e) => updateDraft({ sshPrivateKey: (e.currentTarget as HTMLTextAreaElement).value })} />
|
<textarea className="input textarea" value={draft.sshPrivateKey} onInput={(e) => updateDraft({ sshPrivateKey: (e.currentTarget as HTMLTextAreaElement).value })} />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Public Key</span>
|
<span>Public Key</span>
|
||||||
<textarea className="input textarea" value={draft.sshPublicKey} onInput={(e) => updateDraft({ sshPublicKey: (e.currentTarget as HTMLTextAreaElement).value })} />
|
<textarea className="input textarea" value={draft.sshPublicKey} onInput={(e) => updateSshPublicKey((e.currentTarget as HTMLTextAreaElement).value)} />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Fingerprint</span>
|
<span>Fingerprint</span>
|
||||||
<input className="input" value={draft.sshFingerprint} onInput={(e) => updateDraft({ sshFingerprint: (e.currentTarget as HTMLInputElement).value })} />
|
<input className="input input-readonly" value={draft.sshFingerprint} readOnly />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -964,7 +1085,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
<div className="kv-row">
|
<div className="kv-row">
|
||||||
<span className="kv-label">Username</span>
|
<span className="kv-label">Username</span>
|
||||||
<div className="kv-main">
|
<div className="kv-main">
|
||||||
<strong>{selectedCipher.login.decUsername || ''}</strong>
|
<strong className="value-ellipsis" title={selectedCipher.login.decUsername || ''}>{selectedCipher.login.decUsername || ''}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="kv-actions">
|
<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 || '')}>
|
||||||
@@ -1014,7 +1135,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
<div key={`view-uri-${index}`} className="kv-row">
|
<div key={`view-uri-${index}`} className="kv-row">
|
||||||
<span className="kv-label">Website</span>
|
<span className="kv-label">Website</span>
|
||||||
<div className="kv-main">
|
<div className="kv-main">
|
||||||
<strong>{value}</strong>
|
<strong className="value-ellipsis" title={value}>{value}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="kv-actions">
|
<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)}>
|
||||||
@@ -1078,24 +1199,30 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const rawValue = field.decValue || '';
|
const rawValue = field.decValue || '';
|
||||||
const isHiddenVisible = !!hiddenFieldVisibleMap[index];
|
const isHiddenVisible = !!hiddenFieldVisibleMap[index];
|
||||||
if (fieldType === 2) {
|
if (fieldType === 2) {
|
||||||
|
const checked = toBooleanFieldValue(rawValue);
|
||||||
return (
|
return (
|
||||||
<div key={`view-field-${index}`} className="kv-row">
|
<div key={`view-field-${index}`} className="kv-row custom-field-row">
|
||||||
<span className="kv-label">{fieldName}</span>
|
<span className="kv-label" title={fieldName}>{fieldName}</span>
|
||||||
<div className="kv-main">
|
<div className="kv-main boolean-main">
|
||||||
<label className="check-line cf-check view">
|
<label className="check-line cf-check view">
|
||||||
<input type="checkbox" checked={toBooleanFieldValue(rawValue)} disabled />
|
<input type="checkbox" checked={checked} disabled />
|
||||||
</label>
|
</label>
|
||||||
|
<span className="boolean-text value-ellipsis" title={checked ? 'Checked' : 'Unchecked'}>
|
||||||
|
{checked ? 'Checked' : 'Unchecked'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kv-actions" />
|
<div className="kv-actions" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div key={`view-field-${index}`} className="kv-row">
|
<div key={`view-field-${index}`} className="kv-row custom-field-row">
|
||||||
<span className="kv-label">{fieldName}</span>
|
<span className="kv-label" title={fieldName}>{fieldName}</span>
|
||||||
<div className="kv-main">
|
<div className="kv-main">
|
||||||
<strong>{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}</strong>
|
<strong className="value-ellipsis" title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}>
|
||||||
</div>
|
{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
<div className="kv-actions">
|
<div className="kv-actions">
|
||||||
{fieldType === 1 && (
|
{fieldType === 1 && (
|
||||||
<button
|
<button
|
||||||
@@ -1240,6 +1367,24 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
</label>
|
</label>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={createFolderOpen}
|
||||||
|
title="Create Folder"
|
||||||
|
message="Enter a folder name."
|
||||||
|
confirmText="Create"
|
||||||
|
cancelText="Cancel"
|
||||||
|
onConfirm={() => void confirmCreateFolder()}
|
||||||
|
onCancel={() => {
|
||||||
|
setCreateFolderOpen(false);
|
||||||
|
setNewFolderName('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>Folder Name</span>
|
||||||
|
<input className="input" value={newFolderName} onInput={(e) => setNewFolderName((e.currentTarget as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={repromptOpen}
|
open={repromptOpen}
|
||||||
title="Unlock Item"
|
title="Unlock Item"
|
||||||
@@ -1263,3 +1408,5 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -236,6 +236,18 @@ export async function getFolders(authedFetch: (input: string, init?: RequestInit
|
|||||||
return body?.data || [];
|
return body?.data || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createFolder(
|
||||||
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
|
name: string
|
||||||
|
): Promise<void> {
|
||||||
|
const resp = await authedFetch('/api/folders', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('Create folder failed');
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCiphers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Cipher[]> {
|
export async function getCiphers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Cipher[]> {
|
||||||
const resp = await authedFetch('/api/ciphers');
|
const resp = await authedFetch('/api/ciphers');
|
||||||
if (!resp.ok) throw new Error('Failed to load ciphers');
|
if (!resp.ok) throw new Error('Failed to load ciphers');
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
let binary = '';
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(base64: string): Uint8Array | null {
|
||||||
|
const normalized = base64.replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
if (!normalized) return null;
|
||||||
|
const padLength = (4 - (normalized.length % 4)) % 4;
|
||||||
|
const padded = normalized + '='.repeat(padLength);
|
||||||
|
try {
|
||||||
|
const binary = atob(padded);
|
||||||
|
const out = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function concatBytes(...parts: Uint8Array[]): Uint8Array {
|
||||||
|
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
||||||
|
const out = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const part of parts) {
|
||||||
|
out.set(part, offset);
|
||||||
|
offset += part.length;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeSshString(value: Uint8Array): Uint8Array {
|
||||||
|
const out = new Uint8Array(4 + value.length);
|
||||||
|
const view = new DataView(out.buffer);
|
||||||
|
view.setUint32(0, value.length, false);
|
||||||
|
out.set(value, 4);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSshBlobFromPublicKey(publicKey: string): Uint8Array | null {
|
||||||
|
const text = String(publicKey || '').trim();
|
||||||
|
if (!text) return null;
|
||||||
|
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/^([A-Za-z0-9-]+)\s+([A-Za-z0-9+/=_-]+)(?:\s+.*)?$/);
|
||||||
|
if (!match) continue;
|
||||||
|
const keyType = match[1].toLowerCase();
|
||||||
|
if (!keyType.startsWith('ssh-') && !keyType.startsWith('ecdsa-')) continue;
|
||||||
|
return base64ToBytes(match[2]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function computeSshFingerprint(publicKey: string): Promise<string> {
|
||||||
|
const blob = extractSshBlobFromPublicKey(publicKey);
|
||||||
|
if (!blob) return '';
|
||||||
|
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', blob as unknown as BufferSource));
|
||||||
|
return `SHA256:${bytesToBase64(digest).replace(/=+$/g, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPem(tag: string, bytes: Uint8Array): string {
|
||||||
|
const b64 = bytesToBase64(bytes);
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (let i = 0; i < b64.length; i += 64) chunks.push(b64.slice(i, i + 64));
|
||||||
|
return `-----BEGIN ${tag}-----\n${chunks.join('\n')}\n-----END ${tag}-----`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEd25519RawPublicKey(spki: Uint8Array): Uint8Array | null {
|
||||||
|
const prefix = new Uint8Array([0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00]);
|
||||||
|
const hasPrefix = spki.length >= prefix.length + 32 && prefix.every((value, idx) => spki[idx] === value);
|
||||||
|
if (hasPrefix) return spki.slice(prefix.length, prefix.length + 32);
|
||||||
|
if (spki.length >= 32) return spki.slice(spki.length - 32);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateDefaultSshKeyMaterial(): Promise<{ privateKey: string; publicKey: string; fingerprint: string }> {
|
||||||
|
const keyPair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
||||||
|
const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey));
|
||||||
|
const spki = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
|
||||||
|
const rawPublic = extractEd25519RawPublicKey(spki);
|
||||||
|
if (!rawPublic) throw new Error('Cannot export Ed25519 public key');
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const sshBlob = concatBytes(encodeSshString(encoder.encode('ssh-ed25519')), encodeSshString(rawPublic));
|
||||||
|
const publicKey = `ssh-ed25519 ${bytesToBase64(sshBlob)}`;
|
||||||
|
const privateKey = toPem('PRIVATE KEY', pkcs8);
|
||||||
|
const fingerprint = await computeSshFingerprint(publicKey);
|
||||||
|
return { privateKey, publicKey, fingerprint };
|
||||||
|
}
|
||||||
+153
-17
@@ -95,6 +95,11 @@ body,
|
|||||||
border-color: #2f5fd8;
|
border-color: #2f5fd8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-readonly {
|
||||||
|
background: #eef2f7;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
.input:disabled {
|
.input:disabled {
|
||||||
background: #e2e8f0;
|
background: #e2e8f0;
|
||||||
border-color: #cbd5e1;
|
border-color: #cbd5e1;
|
||||||
@@ -217,7 +222,7 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
height: 64px;
|
height: 58px;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border-bottom: 1px solid #d9e0ea;
|
border-bottom: 1px solid #d9e0ea;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
@@ -231,8 +236,8 @@ body,
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
font-size: 32px;
|
font-size: 34px;
|
||||||
font-weight: 900;
|
font-weight: 800;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +251,14 @@ body,
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-actions .btn {
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.user-chip {
|
.user-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -264,13 +277,13 @@ body,
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 220px 1fr;
|
grid-template-columns: 200px 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-side {
|
.app-side {
|
||||||
border-right: 1px solid #d9e0ea;
|
border-right: 1px solid #d9e0ea;
|
||||||
background: #eef3f9;
|
background: #eef3f9;
|
||||||
padding: 14px 10px;
|
padding: 12px 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -286,6 +299,7 @@ body,
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-link:hover {
|
.side-link:hover {
|
||||||
@@ -309,20 +323,32 @@ body,
|
|||||||
|
|
||||||
.side-add-btn {
|
.side-add-btn {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-add-btn.btn-primary {
|
||||||
|
background: #1e4f95;
|
||||||
|
border-color: #1e4f95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-add-btn.btn-primary:hover {
|
||||||
|
background: #1b4888;
|
||||||
|
border-color: #1b4888;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 10px;
|
padding: 8px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vault-grid {
|
.vault-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 260px minmax(420px, 1fr) 400px;
|
grid-template-columns: 240px minmax(420px, 46%) minmax(520px, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
padding: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar,
|
.sidebar,
|
||||||
@@ -335,16 +361,19 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
padding: 8px;
|
padding: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-block {
|
.sidebar-block {
|
||||||
border: 1px solid #dbe2ed;
|
border: 1px solid #e1e6ef;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 8px;
|
||||||
background: #f9fbfe;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-title {
|
.sidebar-title {
|
||||||
@@ -354,6 +383,33 @@ body,
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title-row .sidebar-title {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-add-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #334155;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-add-btn:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -365,6 +421,7 @@ body,
|
|||||||
|
|
||||||
.tree-btn {
|
.tree-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -387,6 +444,13 @@ body,
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-label {
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.list-col {
|
.list-col {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -395,9 +459,34 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar.actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar .btn.small {
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-head .search-input {
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head .btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.list-panel {
|
.list-panel {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -434,6 +523,7 @@ body,
|
|||||||
|
|
||||||
.row-main {
|
.row-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -471,29 +561,39 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-text {
|
.list-text {
|
||||||
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-title {
|
.list-title {
|
||||||
display: block;
|
display: block;
|
||||||
color: #175ddc;
|
color: #175ddc;
|
||||||
font-size: 18px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-sub {
|
.list-sub {
|
||||||
display: block;
|
display: block;
|
||||||
color: #64748b;
|
color: #5f6f85;
|
||||||
margin-top: 4px;
|
margin-top: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-col {
|
.detail-col {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
padding: 14px 16px;
|
padding: 12px 14px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card h4 {
|
.card h4 {
|
||||||
@@ -503,6 +603,9 @@ body,
|
|||||||
|
|
||||||
.detail-title {
|
.detail-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-sub {
|
.detail-sub {
|
||||||
@@ -529,7 +632,7 @@ body,
|
|||||||
|
|
||||||
.kv-row {
|
.kv-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 90px minmax(0, 1fr) auto;
|
grid-template-columns: minmax(88px, 140px) minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
border-bottom: 1px solid #ecf0f5;
|
border-bottom: 1px solid #ecf0f5;
|
||||||
@@ -542,6 +645,10 @@ body,
|
|||||||
|
|
||||||
.kv-label {
|
.kv-label {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kv-main {
|
.kv-main {
|
||||||
@@ -552,12 +659,38 @@ body,
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kv-main > strong {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-ellipsis {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.kv-actions {
|
.kv-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-field-row {
|
||||||
|
grid-template-columns: minmax(110px, 220px) minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boolean-main {
|
||||||
|
min-width: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boolean-text {
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notes {
|
.notes {
|
||||||
@@ -652,6 +785,9 @@ body,
|
|||||||
padding: 11px 12px;
|
padding: 11px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-menu-item:hover {
|
.create-menu-item:hover {
|
||||||
|
|||||||
Reference in New Issue
Block a user