feat: add PublicSendPage and SendsPage components for managing sends

This commit is contained in:
shuaiplus
2026-03-01 05:55:42 +08:00
committed by Shuai
parent be3b68956b
commit bb50617b16
16 changed files with 2792 additions and 28 deletions
+130 -5
View File
@@ -1,11 +1,13 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Link, Route, Switch, useLocation } from 'wouter';
import { useQuery } from '@tanstack/react-query';
import { CircleHelp, LogOut, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact';
import { CircleHelp, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact';
import AuthViews from '@/components/AuthViews';
import ConfirmDialog from '@/components/ConfirmDialog';
import ToastHost from '@/components/ToastHost';
import VaultPage from '@/components/VaultPage';
import SendsPage from '@/components/SendsPage';
import PublicSendPage from '@/components/PublicSendPage';
import SettingsPage from '@/components/SettingsPage';
import AdminPage from '@/components/AdminPage';
import HelpPage from '@/components/HelpPage';
@@ -15,8 +17,10 @@ import {
createCipher,
createAuthedFetch,
createInvite,
createSend,
deleteAllInvites,
deleteCipher,
deleteSend,
deleteUser,
deriveLoginHash,
bulkMoveCiphers,
@@ -24,6 +28,7 @@ import {
getFolders,
getProfile,
getSetupStatus,
getSends,
getTotpStatus,
getWebConfig,
listAdminInvites,
@@ -36,12 +41,14 @@ import {
setTotp,
setUserStatus,
updateCipher,
updateSend,
buildSendShareKey,
unlockVaultKey,
updateProfile,
verifyMasterPassword,
} from '@/lib/api';
import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto';
import type { AppPhase, Cipher, Folder, Profile, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
import type { AppPhase, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
interface PendingTotp {
email: string;
@@ -83,6 +90,7 @@ export default function App() {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const [decryptedFolders, setDecryptedFolders] = useState<Folder[]>([]);
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
function setSession(next: SessionState | null) {
setSessionState(next);
@@ -302,6 +310,11 @@ export default function App() {
queryFn: () => getFolders(authedFetch),
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
});
const sendsQuery = useQuery({
queryKey: ['sends', session?.accessToken],
queryFn: () => getSends(authedFetch),
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
});
const usersQuery = useQuery({
queryKey: ['admin-users', session?.accessToken],
queryFn: () => listAdminUsers(authedFetch),
@@ -322,9 +335,10 @@ export default function App() {
if (!session?.symEncKey || !session?.symMacKey) {
setDecryptedFolders([]);
setDecryptedCiphers([]);
setDecryptedSends([]);
return;
}
if (!foldersQuery.data || !ciphersQuery.data) return;
if (!foldersQuery.data || !ciphersQuery.data || !sendsQuery.data) return;
let active = true;
(async () => {
@@ -440,9 +454,36 @@ export default function App() {
})
);
const sends = await Promise.all(
sendsQuery.data.map(async (send) => {
const nextSend: Send = { ...send };
try {
if (send.key) {
const sendKeyRaw = await decryptBw(send.key, encKey, macKey);
const sendEnc = sendKeyRaw.slice(0, 32);
const sendMac = sendKeyRaw.slice(32, 64);
nextSend.decName = await decryptField(send.name || '', sendEnc, sendMac);
nextSend.decNotes = await decryptField(send.notes || '', sendEnc, sendMac);
nextSend.decText = await decryptField(send.text?.text || '', sendEnc, sendMac);
const shareKey = await buildSendShareKey(send.key, session.symEncKey!, session.symMacKey!);
nextSend.decShareKey = shareKey;
nextSend.shareUrl = `${window.location.origin}/send/${send.accessId}/${shareKey}`;
} else {
nextSend.decName = '';
nextSend.decNotes = '';
nextSend.decText = '';
}
} catch {
nextSend.decName = '(Decrypt failed)';
}
return nextSend;
})
);
if (!active) return;
setDecryptedFolders(folders);
setDecryptedCiphers(ciphers);
setDecryptedSends(sends);
} catch (error) {
if (!active) return;
pushToast('error', error instanceof Error ? error.message : 'Decrypt failed');
@@ -452,7 +493,7 @@ export default function App() {
return () => {
active = false;
};
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data]);
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data, sendsQuery.data]);
async function saveProfileAction(name: string, email: string) {
try {
@@ -526,7 +567,7 @@ export default function App() {
}
async function refreshVault() {
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch(), sendsQuery.refetch()]);
pushToast('success', 'Vault synced');
}
@@ -589,6 +630,64 @@ export default function App() {
}
}
async function createSendItem(draft: SendDraft, autoCopyLink: boolean) {
if (!session) return;
try {
const created = await createSend(authedFetch, session, draft);
await sendsQuery.refetch();
if (autoCopyLink && created.key && session.symEncKey && session.symMacKey) {
const keyPart = await buildSendShareKey(created.key, session.symEncKey, session.symMacKey);
const shareUrl = `${window.location.origin}/send/${created.accessId}/${keyPart}`;
await navigator.clipboard.writeText(shareUrl);
}
pushToast('success', 'Send created');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Create send failed');
throw error;
}
}
async function updateSendItem(send: Send, draft: SendDraft, autoCopyLink: boolean) {
if (!session) return;
try {
const updated = await updateSend(authedFetch, session, send, draft);
await sendsQuery.refetch();
if (autoCopyLink && updated.key && session.symEncKey && session.symMacKey) {
const keyPart = await buildSendShareKey(updated.key, session.symEncKey, session.symMacKey);
const shareUrl = `${window.location.origin}/send/${updated.accessId}/${keyPart}`;
await navigator.clipboard.writeText(shareUrl);
}
pushToast('success', 'Send updated');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Update send failed');
throw error;
}
}
async function deleteSendItem(send: Send) {
try {
await deleteSend(authedFetch, send.id);
await sendsQuery.refetch();
pushToast('success', 'Send deleted');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Delete send failed');
throw error;
}
}
async function bulkDeleteSendItems(ids: string[]) {
try {
for (const id of ids) {
await deleteSend(authedFetch, id);
}
await sendsQuery.refetch();
pushToast('success', 'Deleted selected sends');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Bulk delete sends failed');
throw error;
}
}
async function verifyMasterPasswordAction(email: string, password: string) {
const derived = await deriveLoginHash(email, password, defaultKdfIterations);
await verifyMasterPassword(authedFetch, derived.hash);
@@ -614,6 +713,16 @@ export default function App() {
if (phase === 'app' && location === '/') navigate('/vault');
}, [phase, location, navigate]);
const publicSendMatch = location.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i);
if (publicSendMatch) {
return (
<>
<PublicSendPage accessId={decodeURIComponent(publicSendMatch[1])} keyPart={publicSendMatch[2] ? decodeURIComponent(publicSendMatch[2]) : null} />
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
</>
);
}
if (phase === 'loading') {
return (
<>
@@ -695,6 +804,10 @@ export default function App() {
<Vault size={16} />
<span>My Vault</span>
</Link>
<Link href="/sends" className={`side-link ${location === '/sends' ? 'active' : ''}`}>
<SendIcon size={16} />
<span>Sends</span>
</Link>
{profile?.role === 'admin' && (
<Link href="/admin" className={`side-link ${location === '/admin' ? 'active' : ''}`}>
<ShieldUser size={16} />
@@ -712,6 +825,18 @@ export default function App() {
</aside>
<main className="content">
<Switch>
<Route path="/sends">
<SendsPage
sends={decryptedSends}
loading={sendsQuery.isFetching}
onRefresh={refreshVault}
onCreate={createSendItem}
onUpdate={updateSendItem}
onDelete={deleteSendItem}
onBulkDelete={bulkDeleteSendItems}
onNotify={pushToast}
/>
</Route>
<Route path="/vault">
<VaultPage
ciphers={decryptedCiphers}
+13 -11
View File
@@ -77,18 +77,20 @@ export default function AdminPage(props: AdminPageProps) {
</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>
<div className="actions invite-create-group">
<label className="field invite-hours-field">
<span></span>
<input
className="input small"
type="number"
value={inviteHours}
min={1}
max={720}
onInput={(e) => setInviteHours(Number((e.currentTarget as HTMLInputElement).value || 168))}
/>
</label>
<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()}>
+131
View File
@@ -0,0 +1,131 @@
import { useEffect, useState } from 'preact/hooks';
import { Download, Eye, Lock } from 'lucide-preact';
import { accessPublicSend, accessPublicSendFile, decryptPublicSend } from '@/lib/api';
interface PublicSendPageProps {
accessId: string;
keyPart: string | null;
}
export default function PublicSendPage(props: PublicSendPageProps) {
const [loading, setLoading] = useState(true);
const [password, setPassword] = useState('');
const [needPassword, setNeedPassword] = useState(false);
const [error, setError] = useState('');
const [sendData, setSendData] = useState<any>(null);
const [busy, setBusy] = useState(false);
async function loadSend(pass?: string): Promise<void> {
setBusy(true);
setError('');
try {
const data = await accessPublicSend(props.accessId, pass);
if (!props.keyPart) {
setError('This link is missing decryption key.');
setSendData(null);
return;
}
const decrypted = await decryptPublicSend(data, props.keyPart);
setSendData(decrypted);
setNeedPassword(false);
} catch (e) {
const err = e as Error & { status?: number };
if (err.status === 401) {
setNeedPassword(true);
setError('This send is password protected.');
} else {
setError(err.message || 'Failed to open send');
}
setSendData(null);
} finally {
setBusy(false);
setLoading(false);
}
}
async function downloadFile(): Promise<void> {
if (!sendData?.id || !sendData?.file?.id) return;
setBusy(true);
setError('');
try {
const url = await accessPublicSendFile(sendData.id, sendData.file.id, password || undefined);
const resp = await fetch(url);
if (!resp.ok) throw new Error('Download failed');
const blob = await resp.blob();
const obj = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = obj;
a.download = sendData.decFileName || sendData.file?.fileName || 'send-file';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(obj);
} catch (e) {
const err = e as Error;
setError(err.message || 'Download failed');
} finally {
setBusy(false);
}
}
useEffect(() => {
void loadSend();
}, [props.accessId, props.keyPart]);
return (
<div className="auth-page public-send-page">
<div className="auth-card">
<h1>NodeWarden Send</h1>
{loading && <p className="muted">Loading...</p>}
{!loading && needPassword && (
<>
<label className="field">
<span>Password</span>
<div className="password-wrap">
<input
className="input"
type="password"
value={password}
onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}
/>
</div>
</label>
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void loadSend(password)}>
<Lock size={14} className="btn-icon" /> Unlock Send
</button>
</>
)}
{!loading && sendData && (
<>
<h2 style={{ marginTop: '8px' }}>{sendData.decName || '(No Name)'}</h2>
{sendData.type === 0 ? (
<div className="card" style={{ marginTop: '10px' }}>
<div className="notes">{sendData.decText || ''}</div>
</div>
) : (
<div className="card" style={{ marginTop: '10px' }}>
<div className="kv-line">
<span>File</span>
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || 'Encrypted File'}</strong>
</div>
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void downloadFile()}>
<Download size={14} className="btn-icon" /> Download
</button>
</div>
)}
{!!sendData.expirationDate && <p className="muted">Expires at: {sendData.expirationDate}</p>}
</>
)}
{!loading && !sendData && !needPassword && !error && (
<p className="muted">
<Eye size={14} style={{ verticalAlign: 'text-bottom' }} /> Send unavailable.
</p>
)}
{!!error && <p className="local-error">{error}</p>}
</div>
</div>
);
}
+419
View File
@@ -0,0 +1,419 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Send as SendIcon, Trash2 } from 'lucide-preact';
import type { Send, SendDraft } from '@/lib/types';
interface SendsPageProps {
sends: Send[];
loading: boolean;
onRefresh: () => Promise<void>;
onCreate: (draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
onUpdate: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
onDelete: (send: Send) => Promise<void>;
onBulkDelete: (ids: string[]) => Promise<void>;
onNotify: (type: 'success' | 'error', text: string) => void;
}
type SendTypeFilter = 'all' | 'text' | 'file';
const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1';
function daysFromNow(iso: string | null | undefined, fallback: number): string {
if (!iso) return String(fallback);
const d = new Date(iso).getTime();
if (!Number.isFinite(d)) return String(fallback);
const diff = d - Date.now();
const days = Math.ceil(diff / (24 * 60 * 60 * 1000));
return String(Math.max(days, 0));
}
function buildDefaultDraft(): SendDraft {
return {
type: 'text',
name: '',
notes: '',
text: '',
file: null,
deletionDays: '7',
expirationDays: '0',
maxAccessCount: '',
password: '',
disabled: false,
};
}
function draftFromSend(send: Send): SendDraft {
return {
id: send.id,
type: Number(send.type) === 1 ? 'file' : 'text',
name: send.decName || '',
notes: send.decNotes || '',
text: send.decText || '',
file: null,
deletionDays: daysFromNow(send.deletionDate, 7),
expirationDays: daysFromNow(send.expirationDate, 0),
maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '',
password: '',
disabled: !!send.disabled,
};
}
export default function SendsPage(props: SendsPageProps) {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<SendTypeFilter>('all');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [busy, setBusy] = useState(false);
const [draft, setDraft] = useState<SendDraft | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
try {
return localStorage.getItem(AUTO_COPY_KEY) === '1';
} catch {
return false;
}
});
useEffect(() => {
try {
localStorage.setItem(AUTO_COPY_KEY, autoCopyLink ? '1' : '0');
} catch {
// ignore storage errors
}
}, [autoCopyLink]);
const filteredSends = useMemo(() => {
const q = search.trim().toLowerCase();
return props.sends.filter((send) => {
if (typeFilter === 'text' && Number(send.type) !== 0) return false;
if (typeFilter === 'file' && Number(send.type) !== 1) return false;
if (!q) return true;
const name = (send.decName || '').toLowerCase();
const text = (send.decText || '').toLowerCase();
return name.includes(q) || text.includes(q);
});
}, [props.sends, search, typeFilter]);
useEffect(() => {
if (!filteredSends.length) {
setSelectedId(null);
return;
}
if (!selectedId || !filteredSends.some((x) => x.id === selectedId)) {
setSelectedId(filteredSends[0].id);
setIsEditing(false);
setIsCreating(false);
setDraft(null);
}
}, [filteredSends, selectedId]);
const selectedSend = useMemo(
() => props.sends.find((x) => x.id === selectedId) || null,
[props.sends, selectedId]
);
const selectedIds = useMemo(() => Object.keys(selectedMap).filter((id) => selectedMap[id]), [selectedMap]);
const selectedCount = selectedIds.length;
async function saveDraft(): Promise<void> {
if (!draft) return;
if (!draft.name.trim()) {
props.onNotify('error', 'Name is required');
return;
}
if (draft.type === 'text' && !draft.text.trim()) {
props.onNotify('error', 'Text is required');
return;
}
if (draft.type === 'file' && isCreating && !draft.file) {
props.onNotify('error', 'Please select a file');
return;
}
setBusy(true);
try {
if (isCreating) {
await props.onCreate(draft, autoCopyLink);
setSelectedId(null);
} else if (selectedSend) {
await props.onUpdate(selectedSend, draft, autoCopyLink);
}
setIsEditing(false);
setIsCreating(false);
setDraft(null);
setShowPassword(false);
} finally {
setBusy(false);
}
}
async function removeSend(send: Send): Promise<void> {
setBusy(true);
try {
await props.onDelete(send);
if (selectedId === send.id) setSelectedId(null);
setIsEditing(false);
setDraft(null);
} finally {
setBusy(false);
}
}
async function removeSelected(): Promise<void> {
if (!selectedCount) return;
setBusy(true);
try {
await props.onBulkDelete(selectedIds);
setSelectedMap({});
} finally {
setBusy(false);
}
}
function copyAccessUrl(send: Send): void {
const url = send.shareUrl || `${window.location.origin}/send/${send.accessId}`;
void navigator.clipboard.writeText(url);
props.onNotify('success', 'Link copied');
}
return (
<div className="vault-grid">
<aside className="sidebar">
<div className="sidebar-block">
<div className="sidebar-title">All Sends</div>
<button type="button" className={`tree-btn ${typeFilter === 'all' ? 'active' : ''}`} onClick={() => setTypeFilter('all')}>
<LayoutGrid size={14} className="tree-icon" />
<span className="tree-label">All Sends</span>
</button>
</div>
<div className="sidebar-block">
<div className="sidebar-title">Type</div>
<button type="button" className={`tree-btn ${typeFilter === 'text' ? 'active' : ''}`} onClick={() => setTypeFilter('text')}>
<FileText size={14} className="tree-icon" />
<span className="tree-label">Text</span>
</button>
<button type="button" className={`tree-btn ${typeFilter === 'file' ? 'active' : ''}`} onClick={() => setTypeFilter('file')}>
<File size={14} className="tree-icon" />
<span className="tree-label">File</span>
</button>
</div>
</aside>
<section className="list-col">
<div className="list-head">
<input
className="search-input"
placeholder="Search sends..."
value={search}
onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}
/>
<button type="button" className="btn btn-secondary small" disabled={busy || props.loading} onClick={() => void props.onRefresh()}>
<RefreshCw size={14} className="btn-icon" /> Refresh
</button>
</div>
<div className="toolbar actions">
<button type="button" className="btn btn-danger small" disabled={!selectedCount || busy} onClick={() => void removeSelected()}>
<Trash2 size={14} className="btn-icon" /> Delete Selected
</button>
<button
type="button"
className="btn btn-secondary small"
disabled={!filteredSends.length}
onClick={() => {
const map: Record<string, boolean> = {};
for (const send of filteredSends) map[send.id] = true;
setSelectedMap(map);
}}
>
Select All
</button>
{!!selectedCount && (
<button type="button" className="btn btn-secondary small" onClick={() => setSelectedMap({})}>
Cancel
</button>
)}
<button
type="button"
className="btn btn-primary small"
disabled={busy}
onClick={() => {
setIsCreating(true);
setIsEditing(true);
setDraft(buildDefaultDraft());
setShowPassword(false);
}}
>
<Plus size={14} className="btn-icon" /> Add
</button>
</div>
<div className="list-panel">
{filteredSends.map((send) => (
<div key={send.id} className={`list-item ${selectedId === send.id ? 'active' : ''}`}>
<input
type="checkbox"
className="row-check"
checked={!!selectedMap[send.id]}
onInput={(e) =>
setSelectedMap((prev) => ({
...prev,
[send.id]: (e.currentTarget as HTMLInputElement).checked,
}))
}
/>
<button
type="button"
className="row-main"
onClick={() => {
setSelectedId(send.id);
setIsEditing(false);
setIsCreating(false);
setDraft(null);
}}
>
<div className="list-icon-wrap">
<span className="list-icon-fallback">
<SendIcon />
</span>
</div>
<div className="list-text">
<span className="list-title" title={send.decName || '(No Name)'}>{send.decName || '(No Name)'}</span>
<span className="list-sub">
{Number(send.type) === 1 ? 'File' : 'Text'} - Accessed {send.accessCount || 0} times
</span>
</div>
</button>
</div>
))}
{!filteredSends.length && <div className="empty">No sends</div>}
</div>
</section>
<section className="detail-col">
{isEditing && draft && (
<div className="card">
<h3 className="detail-title">{isCreating ? 'New Send' : 'Edit Send'}</h3>
<div className="field-grid">
<label className="field field-span-2">
<span>Name</span>
<input className="input" value={draft.name} onInput={(e) => setDraft({ ...draft, name: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field field-span-2">
<span>Type</span>
<div className="send-options">
<label>
<input
type="radio"
checked={draft.type === 'file'}
disabled={!isCreating}
onInput={() => setDraft({ ...draft, type: 'file' })}
/>
File
</label>
<label>
<input
type="radio"
checked={draft.type === 'text'}
disabled={!isCreating}
onInput={() => setDraft({ ...draft, type: 'text' })}
/>
Text
</label>
</div>
</label>
{draft.type === 'file' ? (
<label className="field field-span-2">
<span>File</span>
<input className="input" type="file" onInput={(e) => setDraft({ ...draft, file: (e.currentTarget as HTMLInputElement).files?.[0] || null })} />
</label>
) : (
<label className="field field-span-2">
<span>Text</span>
<textarea className="input textarea" rows={8} value={draft.text} onInput={(e) => setDraft({ ...draft, text: (e.currentTarget as HTMLTextAreaElement).value })} />
</label>
)}
<label className="field">
<span>Deletion Days</span>
<input className="input" type="number" min="1" max="31" value={draft.deletionDays} onInput={(e) => setDraft({ ...draft, deletionDays: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field">
<span>Expiration Days (0 = never)</span>
<input className="input" type="number" min="0" max="3650" value={draft.expirationDays} onInput={(e) => setDraft({ ...draft, expirationDays: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field">
<span>Max Access Count</span>
<input className="input" value={draft.maxAccessCount} onInput={(e) => setDraft({ ...draft, maxAccessCount: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field">
<span>Password</span>
<div className="password-wrap">
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</label>
<label className="field field-span-2">
<span>Notes</span>
<textarea className="input textarea" rows={5} value={draft.notes} onInput={(e) => setDraft({ ...draft, notes: (e.currentTarget as HTMLTextAreaElement).value })} />
</label>
<label className="field field-span-2">
<span>Options</span>
<div className="send-options">
<label><input type="checkbox" checked={draft.disabled} onInput={(e) => setDraft({ ...draft, disabled: (e.currentTarget as HTMLInputElement).checked })} /> Disable this send</label>
<label><input type="checkbox" checked={autoCopyLink} onInput={(e) => setAutoCopyLink((e.currentTarget as HTMLInputElement).checked)} /> Auto copy link after save</label>
</div>
</label>
</div>
<div className="detail-actions">
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>Save</button>
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={() => { setIsEditing(false); setIsCreating(false); setDraft(null); setShowPassword(false); }}>Cancel</button>
</div>
</div>
)}
{!isEditing && selectedSend && (
<>
<div className="card">
<h3 className="detail-title">{selectedSend.decName || '(No Name)'}</h3>
<div className="detail-sub">{Number(selectedSend.type) === 1 ? 'File Send' : 'Text Send'}</div>
</div>
<div className="card">
<h4>Send Details</h4>
<div className="kv-line"><span>Access Count</span><strong>{selectedSend.accessCount || 0}</strong></div>
<div className="kv-line"><span>Deletion Date</span><strong>{selectedSend.deletionDate || '-'}</strong></div>
<div className="kv-line"><span>Expiration Date</span><strong>{selectedSend.expirationDate || '-'}</strong></div>
</div>
<div className="card">
{Number(selectedSend.type) === 1 ? (
<>
<h4>File</h4>
<div className="kv-line"><span>File Name</span><strong>{selectedSend.file?.fileName || 'Encrypted file'}</strong></div>
<div className="kv-line"><span>File Size</span><strong>{selectedSend.file?.sizeName || '-'}</strong></div>
</>
) : (
<>
<h4>Text</h4>
<div className="notes">{selectedSend.decText || ''}</div>
</>
)}
</div>
<div className="detail-actions">
<div className="actions">
<button type="button" className="btn btn-secondary small" onClick={() => copyAccessUrl(selectedSend)}>
<Copy size={14} className="btn-icon" /> Copy Link
</button>
<button type="button" className="btn btn-secondary small" onClick={() => { setDraft(draftFromSend(selectedSend)); setIsCreating(false); setIsEditing(true); }}>
<Pencil size={14} className="btn-icon" /> Edit
</button>
</div>
<button type="button" className="btn btn-danger small detail-delete-btn" disabled={busy} onClick={() => void removeSend(selectedSend)}>
<Trash2 size={14} className="btn-icon" /> Delete
</button>
</div>
</>
)}
</section>
</div>
);
}
+271 -1
View File
@@ -1,4 +1,4 @@
import { base64ToBytes, bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from './crypto';
import { base64ToBytes, bytesToBase64, decryptBw, decryptStr, encryptBw, hkdfExpand, pbkdf2 } from './crypto';
import type {
AdminInvite,
AdminUser,
@@ -7,6 +7,8 @@ import type {
ListResponse,
Profile,
SessionState,
Send,
SendDraft,
SetupStatusResponse,
TokenError,
TokenSuccess,
@@ -255,6 +257,13 @@ export async function getCiphers(authedFetch: (input: string, init?: RequestInit
return body?.data || [];
}
export async function getSends(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Send[]> {
const resp = await authedFetch('/api/sends');
if (!resp.ok) throw new Error('Failed to load sends');
const body = await parseJson<ListResponse<Send>>(resp);
return body?.data || [];
}
export async function updateProfile(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
payload: { name: string; email: string }
@@ -637,3 +646,264 @@ export async function bulkMoveCiphers(
});
if (!resp.ok) throw new Error('Bulk move failed');
}
function toIsoDateFromDays(value: string, required: boolean): string | null {
const raw = String(value || '').trim();
if (!raw) {
if (required) throw new Error('Deletion days is required');
return null;
}
const n = Number(raw);
if (!Number.isFinite(n) || n < 0) {
if (required) throw new Error('Invalid deletion days');
throw new Error('Invalid expiration days');
}
if (!required && n === 0) return null;
const date = new Date(Date.now() + Math.floor(n) * 24 * 60 * 60 * 1000);
return date.toISOString();
}
function bytesToBase64Url(bytes: Uint8Array): string {
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function base64UrlToBytes(value: string): Uint8Array {
const raw = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = raw + '='.repeat((4 - (raw.length % 4)) % 4);
return base64ToBytes(padded);
}
async function parseErrorMessage(resp: Response, fallback: string): Promise<string> {
const body = await parseJson<TokenError>(resp);
return body?.error_description || body?.error || fallback;
}
function toSendKeyParts(sendKeyBytes: Uint8Array): { enc: Uint8Array; mac: Uint8Array } {
if (sendKeyBytes.length >= 64) {
return { enc: sendKeyBytes.slice(0, 32), mac: sendKeyBytes.slice(32, 64) };
}
const merged = new Uint8Array(64);
merged.set(sendKeyBytes.slice(0, 32), 0);
merged.set(sendKeyBytes.slice(0, 32), 32);
return { enc: merged.slice(0, 32), mac: merged.slice(32, 64) };
}
function parseMaxAccessCountRaw(value: string): number | null {
const raw = String(value || '').trim();
if (!raw) return null;
const n = Number(raw);
if (!Number.isFinite(n) || n < 0) throw new Error('Invalid max access count');
return Math.floor(n);
}
export async function createSend(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
draft: SendDraft
): Promise<Send> {
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
const userEnc = base64ToBytes(session.symEncKey);
const userMac = base64ToBytes(session.symMacKey);
const sendKeyRaw = crypto.getRandomValues(new Uint8Array(64));
const sendKeyForUser = await encryptBw(sendKeyRaw, userEnc, userMac);
const sendKey = toSendKeyParts(sendKeyRaw);
const nameCipher = await encryptTextValue(draft.name || '', sendKey.enc, sendKey.mac);
const notesCipher = await encryptTextValue(draft.notes || '', sendKey.enc, sendKey.mac);
const deletionIso = toIsoDateFromDays(draft.deletionDays, true)!;
const expirationIso = toIsoDateFromDays(draft.expirationDays, false);
const maxAccessCount = parseMaxAccessCountRaw(draft.maxAccessCount);
const password = String(draft.password || '');
if (draft.type === 'text') {
const text = String(draft.text || '').trim();
if (!text) throw new Error('Send text is required');
const textCipher = await encryptTextValue(text, sendKey.enc, sendKey.mac);
const payload = {
type: 0,
name: nameCipher,
notes: notesCipher,
key: sendKeyForUser,
text: {
text: textCipher,
hidden: false,
},
maxAccessCount,
password: password || null,
hideEmail: false,
disabled: !!draft.disabled,
deletionDate: deletionIso,
expirationDate: expirationIso,
};
const resp = await authedFetch('/api/sends', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Create send failed'));
const body = await parseJson<Send>(resp);
if (!body?.id) throw new Error('Create send failed');
return body;
}
if (!draft.file) throw new Error('File is required');
const fileResp = await authedFetch('/api/sends/file/v2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 1,
name: nameCipher,
notes: notesCipher,
key: sendKeyForUser,
file: {
fileName: draft.file.name,
},
fileLength: draft.file.size,
maxAccessCount,
password: password || null,
hideEmail: false,
disabled: !!draft.disabled,
deletionDate: deletionIso,
expirationDate: expirationIso,
}),
});
if (!fileResp.ok) throw new Error(await parseErrorMessage(fileResp, 'Create file send failed'));
const uploadInfo = await parseJson<{ url?: string }>(fileResp);
const uploadUrl = uploadInfo?.url;
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
const formData = new FormData();
formData.set('data', draft.file, draft.file.name);
const uploadResp = await authedFetch(uploadUrl, {
method: 'POST',
body: formData,
});
if (!uploadResp.ok) throw new Error(await parseErrorMessage(uploadResp, 'Upload send file failed'));
const fileBody = await parseJson<{ sendResponse?: Send }>(fileResp);
if (!fileBody?.sendResponse?.id) throw new Error('Create file send failed');
return fileBody.sendResponse;
}
export async function updateSend(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
send: Send,
draft: SendDraft
): Promise<Send> {
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
if (!send.key) throw new Error('Send key unavailable');
const userEnc = base64ToBytes(session.symEncKey);
const userMac = base64ToBytes(session.symMacKey);
const sendKeyRaw = await decryptBw(send.key, userEnc, userMac);
const sendKey = toSendKeyParts(sendKeyRaw);
const nameCipher = await encryptTextValue(draft.name || '', sendKey.enc, sendKey.mac);
const notesCipher = await encryptTextValue(draft.notes || '', sendKey.enc, sendKey.mac);
const deletionIso = toIsoDateFromDays(draft.deletionDays, true)!;
const expirationIso = toIsoDateFromDays(draft.expirationDays, false);
const maxAccessCount = parseMaxAccessCountRaw(draft.maxAccessCount);
if (draft.type === 'file' && draft.file) {
throw new Error('Updating file content is not supported yet');
}
const textCipher = await encryptTextValue(String(draft.text || ''), sendKey.enc, sendKey.mac);
const payload = {
id: send.id,
type: draft.type === 'file' ? 1 : 0,
name: nameCipher,
notes: notesCipher,
key: send.key,
text: {
text: textCipher,
hidden: false,
},
maxAccessCount,
password: String(draft.password || '') || null,
hideEmail: false,
disabled: !!draft.disabled,
deletionDate: deletionIso,
expirationDate: expirationIso,
};
const resp = await authedFetch(`/api/sends/${encodeURIComponent(send.id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Update send failed'));
const body = await parseJson<Send>(resp);
if (!body?.id) throw new Error('Update send failed');
return body;
}
export async function deleteSend(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
sendId: string
): Promise<void> {
const resp = await authedFetch(`/api/sends/${encodeURIComponent(sendId)}`, { method: 'DELETE' });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete send failed'));
}
export async function accessPublicSend(accessId: string, password?: string): Promise<any> {
const payload = password ? { password } : {};
const resp = await fetch(`/api/sends/access/${encodeURIComponent(accessId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const message = await parseErrorMessage(resp, 'Failed to access send');
const error = new Error(message) as Error & { status?: number };
error.status = resp.status;
throw error;
}
return (await parseJson<any>(resp)) || null;
}
export async function accessPublicSendFile(sendId: string, fileId: string, password?: string): Promise<string> {
const payload = password ? { password } : {};
const resp = await fetch(`/api/sends/${encodeURIComponent(sendId)}/access/file/${encodeURIComponent(fileId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const message = await parseErrorMessage(resp, 'Failed to access send file');
const error = new Error(message) as Error & { status?: number };
error.status = resp.status;
throw error;
}
const body = await parseJson<{ url?: string }>(resp);
if (!body?.url) throw new Error('Missing file URL');
return body.url;
}
export async function decryptPublicSend(accessData: any, urlSafeKey: string): Promise<any> {
const sendKeyRaw = base64UrlToBytes(urlSafeKey);
const sendKey = toSendKeyParts(sendKeyRaw);
const out: any = { ...accessData };
out.decName = await decryptStr(accessData?.name || '', sendKey.enc, sendKey.mac);
if (accessData?.text?.text) {
out.decText = await decryptStr(accessData.text.text, sendKey.enc, sendKey.mac);
}
if (accessData?.file?.fileName) {
try {
out.decFileName = await decryptStr(accessData.file.fileName, sendKey.enc, sendKey.mac);
} catch {
out.decFileName = String(accessData.file.fileName);
}
}
return out;
}
export function buildSendShareKey(sendKeyEncrypted: string, userEncB64: string, userMacB64: string): Promise<string> {
const userEnc = base64ToBytes(userEncB64);
const userMac = base64ToBytes(userMacB64);
return decryptBw(sendKeyEncrypted, userEnc, userMac).then((raw) => bytesToBase64Url(raw));
}
+46
View File
@@ -127,6 +127,52 @@ export interface Cipher {
decNotes?: string;
}
export interface SendTextData {
text?: string | null;
hidden?: boolean;
}
export interface Send {
id: string;
accessId: string;
type: number;
name?: string | null;
notes?: string | null;
text?: SendTextData | null;
key?: string | null;
maxAccessCount?: number | null;
accessCount?: number;
disabled?: boolean;
revisionDate?: string;
expirationDate?: string | null;
deletionDate?: string;
decName?: string;
decNotes?: string;
decText?: string;
decShareKey?: string;
shareUrl?: string;
file?: {
id?: string;
fileName?: string;
size?: string | number;
sizeName?: string;
} | null;
}
export interface SendDraft {
id?: string;
type: 'text' | 'file';
name: string;
notes: string;
text: string;
file: File | null;
deletionDays: string;
expirationDays: string;
maxAccessCount: string;
password: string;
disabled: boolean;
}
export type CustomFieldType = 0 | 1 | 2 | 3;
export interface VaultDraftField {
+90 -4
View File
@@ -42,6 +42,12 @@ body,
padding: 24px;
}
.public-send-page {
min-height: 100vh;
align-items: center;
justify-items: center;
}
.auth-card {
width: min(640px, 100%);
background: var(--panel);
@@ -85,6 +91,46 @@ body,
background: #fff;
}
select.input {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
padding-right: 42px;
background-image:
linear-gradient(45deg, transparent 50%, #365fa8 50%),
linear-gradient(135deg, #365fa8 50%, transparent 50%);
background-position:
calc(100% - 18px) calc(50% - 3px),
calc(100% - 12px) calc(50% - 3px);
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
}
input[type='file'].input {
height: auto;
min-height: 48px;
padding: 8px 10px;
font-size: 14px;
line-height: 1.4;
}
input[type='file'].input::file-selector-button {
height: 32px;
border: 1px solid #3f5b9e;
border-radius: 999px;
padding: 0 12px;
background: #eef4ff;
color: #1f4ea0;
font-weight: 700;
cursor: pointer;
margin-right: 10px;
}
input[type='file'].input::file-selector-button:hover {
background: #dfeaff;
border-color: #2f5fd8;
}
.textarea {
min-height: 110px;
height: auto;
@@ -115,6 +161,19 @@ body,
padding-right: 44px;
}
.password-toggle {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
border: none;
background: transparent;
color: #275ac2;
cursor: pointer;
display: grid;
place-items: center;
}
.eye-btn {
position: absolute;
right: 10px;
@@ -720,6 +779,10 @@ body,
gap: 10px;
}
.field-span-2 {
grid-column: 1 / -1;
}
.totp-grid {
display: grid;
grid-template-columns: 220px 1fr;
@@ -850,6 +913,22 @@ body,
margin: 12px 0;
}
.detail-delete-btn {
margin-left: auto;
}
.send-options {
display: grid;
gap: 8px;
color: #3a4a64;
}
.send-options label {
display: inline-flex;
align-items: center;
gap: 8px;
}
.local-error {
margin-top: 10px;
color: #b42318;
@@ -904,12 +983,19 @@ body,
margin-bottom: 10px;
}
.invite-row-actions {
justify-content: flex-end;
.invite-create-group {
align-items: flex-end;
}
.invite-actions-head {
text-align: right !important;
.invite-hours-field {
margin: 0;
}
.invite-hours-field > span {
margin-bottom: 6px;
color: #5f6f85;
font-size: 12px;
font-weight: 600;
}
.dialog-mask {