feat(i18n): add internationalization support with English and Chinese translations

This commit is contained in:
shuaiplus
2026-03-01 10:28:21 +08:00
committed by Shuai
parent 8641df3cff
commit 9f14bca99a
14 changed files with 1343 additions and 491 deletions
+46 -27
View File
@@ -1,6 +1,7 @@
import { useState } from 'preact/hooks';
import { Clipboard, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
import type { AdminInvite, AdminUser } from '@/lib/types';
import { t } from '@/lib/i18n';
interface AdminPageProps {
currentUserId: string;
@@ -18,32 +19,47 @@ export default function AdminPage(props: AdminPageProps) {
const [inviteHours, setInviteHours] = useState(168);
const [page, setPage] = useState(1);
const pageSize = 20;
const formatExpiresAt = (x?: string) => (x ? new Date(x).toLocaleString() : '-');
const formatExpiresAt = (x?: string) => (x ? new Date(x).toLocaleString() : t('txt_dash'));
const totalPages = Math.max(1, Math.ceil(props.invites.length / pageSize));
const safePage = Math.min(page, totalPages);
const pagedInvites = props.invites.slice((safePage - 1) * pageSize, safePage * pageSize);
const roleText = (role: string) => {
const normalized = String(role || '').toLowerCase();
if (normalized === 'admin') return t('txt_role_admin');
if (normalized === 'user') return t('txt_role_user');
return role || '-';
};
const statusText = (status: string) => {
const normalized = String(status || '').toLowerCase();
if (normalized === 'active') return t('txt_status_active');
if (normalized === 'banned') return t('txt_status_banned');
if (normalized === 'inactive') return t('txt_status_inactive');
return status || '-';
};
return (
<div className="stack">
<section className="card">
<h3>Users</h3>
<h3>{t('txt_users')}</h3>
<table className="table">
<thead>
<tr>
<th>Email</th>
<th>Name</th>
<th>Role</th>
<th>Status</th>
<th>Actions</th>
<th>{t('txt_email')}</th>
<th>{t('txt_name')}</th>
<th>{t('txt_role')}</th>
<th>{t('txt_status')}</th>
<th>{t('txt_actions')}</th>
</tr>
</thead>
<tbody>
{props.users.map((user) => (
<tr key={user.id}>
<td>{user.email}</td>
<td>{user.name || '-'}</td>
<td>{user.role}</td>
<td>{user.status}</td>
<td>{user.name || t('txt_dash')}</td>
<td>{roleText(user.role)}</td>
<td>{statusText(user.status)}</td>
<td>
<div className="actions">
<button
@@ -53,12 +69,12 @@ export default function AdminPage(props: AdminPageProps) {
onClick={() => void props.onToggleUserStatus(user.id, user.status)}
>
{user.status === 'active' ? <UserX size={14} className="btn-icon" /> : <UserCheck size={14} className="btn-icon" />}
{user.status === 'active' ? 'Ban' : 'Unban'}
{user.status === 'active' ? t('txt_ban') : t('txt_unban')}
</button>
{user.role !== 'admin' && (
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteUser(user.id)}>
<Trash2 size={14} className="btn-icon" />
Delete
{t('txt_delete')}
</button>
)}
</div>
@@ -71,15 +87,15 @@ export default function AdminPage(props: AdminPageProps) {
<section className="card">
<div className="section-head">
<h3>Invites</h3>
<h3>{t('txt_invites')}</h3>
<button type="button" className="btn btn-secondary" onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" /> Sync
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
</button>
</div>
<div className="invite-toolbar">
<div className="actions invite-create-group">
<label className="field invite-hours-field">
<span></span>
<span>{t('txt_invite_validity_hours')}</span>
<input
className="input small"
type="number"
@@ -90,27 +106,28 @@ export default function AdminPage(props: AdminPageProps) {
/>
</label>
<button type="button" className="btn btn-primary" onClick={() => void props.onCreateInvite(inviteHours)}>
<Plus size={14} className="btn-icon" />
{t('txt_create_timed_invite')}
</button>
</div>
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteAllInvites()}>
<Trash2 size={14} className="btn-icon" /> Delete All
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')}
</button>
</div>
<table className="table">
<thead>
<tr>
<th>Code</th>
<th>Status</th>
<th>Expires At</th>
<th className="invite-actions-head">Actions</th>
<th>{t('txt_code')}</th>
<th>{t('txt_status')}</th>
<th>{t('txt_expires_at')}</th>
<th className="invite-actions-head">{t('txt_actions')}</th>
</tr>
</thead>
<tbody>
{pagedInvites.map((invite) => (
<tr key={invite.code}>
<td>{invite.code}</td>
<td>{invite.status}</td>
<td>{statusText(invite.status)}</td>
<td>{formatExpiresAt(invite.expiresAt)}</td>
<td>
<div className="actions invite-row-actions">
@@ -119,11 +136,11 @@ export default function AdminPage(props: AdminPageProps) {
className="btn btn-secondary"
onClick={() => navigator.clipboard.writeText(invite.inviteLink || '')}
>
<Clipboard size={14} className="btn-icon" /> Copy Link
<Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')}
</button>
{invite.status === 'active' && (
<button type="button" className="btn btn-danger" onClick={() => void props.onRevokeInvite(invite.code)}>
<Trash2 size={14} className="btn-icon" /> Revoke
<Trash2 size={14} className="btn-icon" /> {t('txt_revoke')}
</button>
)}
</div>
@@ -134,11 +151,13 @@ export default function AdminPage(props: AdminPageProps) {
</table>
<div className="actions">
<button type="button" className="btn btn-secondary small" disabled={safePage <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
Prev
<ChevronLeft size={14} className="btn-icon" />
{t('txt_prev')}
</button>
<span className="muted-inline">{safePage} / {totalPages}</span>
<button type="button" className="btn btn-secondary small" disabled={safePage >= totalPages} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}>
Next
{t('txt_next')}
<ChevronRight size={14} className="btn-icon" />
</button>
</div>
</section>
+33 -30
View File
@@ -1,5 +1,7 @@
import { useState } from 'preact/hooks';
import { Eye, EyeOff } from 'lucide-preact';
import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
interface LoginValues {
email: string;
@@ -61,23 +63,24 @@ export default function AuthViews(props: AuthViewsProps) {
if (props.mode === 'locked') {
return (
<div className="auth-page">
<div className="auth-card">
<h1>Unlock Vault</h1>
<p className="muted">{props.emailForLock}</p>
<StandalonePageFrame title={t('txt_unlock_vault')}>
<p className="muted standalone-muted">{props.emailForLock}</p>
<PasswordField
label="Master Password"
label={t('txt_master_password')}
value={props.unlockPassword}
autoFocus
onInput={props.onChangeUnlock}
/>
<button type="button" className="btn btn-primary full" onClick={props.onSubmitUnlock}>
Unlock
<Unlock size={16} className="btn-icon" />
{t('txt_unlock')}
</button>
<div className="or">or</div>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onLogout}>
Log Out
<LogOut size={16} className="btn-icon" />
{t('txt_log_out')}
</button>
</div>
</StandalonePageFrame>
</div>
);
}
@@ -85,11 +88,9 @@ export default function AuthViews(props: AuthViewsProps) {
if (props.mode === 'register') {
return (
<div className="auth-page">
<div className="auth-card">
<h1>Create Account</h1>
<p className="muted">NodeWarden</p>
<StandalonePageFrame title={t('txt_create_account')}>
<label className="field">
<span>Name</span>
<span>{t('txt_name')}</span>
<input
className="input"
value={props.registerValues.name}
@@ -99,7 +100,7 @@ export default function AuthViews(props: AuthViewsProps) {
/>
</label>
<label className="field">
<span>Email</span>
<span>{t('txt_email')}</span>
<input
className="input"
type="email"
@@ -110,17 +111,17 @@ export default function AuthViews(props: AuthViewsProps) {
/>
</label>
<PasswordField
label="Master Password"
label={t('txt_master_password')}
value={props.registerValues.password}
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password: v })}
/>
<PasswordField
label="Confirm Master Password"
label={t('txt_confirm_master_password')}
value={props.registerValues.password2}
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password2: v })}
/>
<label className="field">
<span>Invite Code (Optional)</span>
<span>{t('txt_invite_code_optional')}</span>
<input
className="input"
value={props.registerValues.inviteCode}
@@ -130,24 +131,24 @@ export default function AuthViews(props: AuthViewsProps) {
/>
</label>
<button type="button" className="btn btn-primary full" onClick={props.onSubmitRegister}>
Create Account
<UserPlus size={16} className="btn-icon" />
{t('txt_create_account')}
</button>
<div className="or">or</div>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoLogin}>
Back To Login
<ArrowLeft size={16} className="btn-icon" />
{t('txt_back_to_login')}
</button>
</div>
</StandalonePageFrame>
</div>
);
}
return (
<div className="auth-page">
<div className="auth-card">
<h1>Log In</h1>
<p className="muted">NodeWarden</p>
<StandalonePageFrame title={t('txt_log_in')}>
<label className="field">
<span>Email</span>
<span>{t('txt_email')}</span>
<input
className="input"
type="email"
@@ -156,19 +157,21 @@ export default function AuthViews(props: AuthViewsProps) {
/>
</label>
<PasswordField
label="Master Password"
label={t('txt_master_password')}
value={props.loginValues.password}
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
autoFocus
/>
<button type="button" className="btn btn-primary full" onClick={props.onSubmitLogin}>
Log In
<LogIn size={16} className="btn-icon" />
{t('txt_log_in')}
</button>
<div className="or">or</div>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister}>
Create Account
<UserPlus size={16} className="btn-icon" />
{t('txt_create_account')}
</button>
</div>
</StandalonePageFrame>
</div>
);
}
+3 -2
View File
@@ -1,4 +1,5 @@
import type { ComponentChildren } from 'preact';
import { t } from '@/lib/i18n';
interface ConfirmDialogProps {
open: boolean;
@@ -27,10 +28,10 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
onClick={props.onConfirm}
>
{props.confirmText || 'Yes'}
{props.confirmText || t('txt_yes')}
</button>
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
{props.cancelText || 'No'}
{props.cancelText || t('txt_no')}
</button>
{props.afterActions}
</div>
+10 -15
View File
@@ -1,22 +1,17 @@
import { Construction } from 'lucide-preact';
import { t } from '@/lib/i18n';
export default function HelpPage() {
return (
<div className="stack">
<section className="card">
<h3>Upstream Sync</h3>
<ul>
<li>Use fork + scheduled sync workflow.</li>
<li>Before merging, compare API routes and auth flow changes.</li>
<li>After merging, run migration tests in local dev before deploy.</li>
</ul>
</section>
<section className="card">
<h3>Common Errors</h3>
<ul>
<li>401 Unauthorized: token expired, log in again.</li>
<li>403 Account disabled: admin must unban your account.</li>
<li>403 Invite invalid: invite expired or revoked.</li>
<li>429 Too many requests: wait and retry.</li>
</ul>
<h3>{t('support_title')}</h3>
<div className="empty" style={{ minHeight: 180 }}>
<div style={{ textAlign: 'center' }}>
<Construction size={34} style={{ color: '#64748b', marginBottom: 8 }} />
<div>{t('support_under_construction')}</div>
</div>
</div>
</section>
</div>
);
+19 -18
View File
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'preact/hooks';
import { Download, Eye, Lock } from 'lucide-preact';
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
interface PublicSendPageProps {
accessId: string;
@@ -21,7 +23,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
try {
const data = await accessPublicSend(props.accessId, props.keyPart, pass);
if (!props.keyPart) {
setError('This link is missing decryption key.');
setError(t('txt_this_link_is_missing_decryption_key'));
setSendData(null);
return;
}
@@ -32,9 +34,9 @@ export default function PublicSendPage(props: PublicSendPageProps) {
const err = e as Error & { status?: number };
if (err.status === 401) {
setNeedPassword(true);
setError('This send is password protected.');
setError(t('txt_this_send_is_password_protected'));
} else {
setError(err.message || 'Failed to open send');
setError(err.message || t('txt_failed_to_open_send'));
}
setSendData(null);
} finally {
@@ -50,7 +52,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
try {
const url = await accessPublicSendFile(sendData.id, sendData.file.id, props.keyPart, password || undefined);
const resp = await fetch(url);
if (!resp.ok) throw new Error('Download failed');
if (!resp.ok) throw new Error(t('txt_download_failed'));
const encryptedBytes = await resp.arrayBuffer();
let blob: Blob;
if (props.keyPart) {
@@ -67,14 +69,14 @@ export default function PublicSendPage(props: PublicSendPageProps) {
const obj = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = obj;
a.download = sendData.decFileName || sendData.file?.fileName || 'send-file';
a.download = sendData.decFileName || sendData.file?.fileName || t('txt_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');
setError(err.message || t('txt_download_failed'));
} finally {
setBusy(false);
}
@@ -86,14 +88,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
return (
<div className="auth-page public-send-page">
<div className="auth-card">
<h1>NodeWarden Send</h1>
{loading && <p className="muted">Loading...</p>}
<StandalonePageFrame title={t('txt_nodewarden_send')}>
{loading && <p className="muted">{t('txt_loading')}</p>}
{!loading && needPassword && (
<>
<label className="field">
<span>Password</span>
<span>{t('txt_password')}</span>
<div className="password-wrap">
<input
className="input"
@@ -104,14 +105,14 @@ export default function PublicSendPage(props: PublicSendPageProps) {
</div>
</label>
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void loadSend(password)}>
<Lock size={14} className="btn-icon" /> Unlock Send
<Lock size={14} className="btn-icon" /> {t('txt_unlock_send')}
</button>
</>
)}
{!loading && sendData && (
<>
<h2 style={{ marginTop: '8px' }}>{sendData.decName || '(No Name)'}</h2>
<h2 style={{ marginTop: '8px' }}>{sendData.decName || t('txt_no_name')}</h2>
{sendData.type === 0 ? (
<div className="card" style={{ marginTop: '10px' }}>
<div className="notes">{sendData.decText || ''}</div>
@@ -119,25 +120,25 @@ export default function PublicSendPage(props: PublicSendPageProps) {
) : (
<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>
<span>{t('txt_file')}</span>
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong>
</div>
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void downloadFile()}>
<Download size={14} className="btn-icon" /> Download
<Download size={14} className="btn-icon" /> {t('txt_download')}
</button>
</div>
)}
{!!sendData.expirationDate && <p className="muted">Expires at: {sendData.expirationDate}</p>}
{!!sendData.expirationDate && <p className="muted">{t('txt_expires_at_value', { value: sendData.expirationDate })}</p>}
</>
)}
{!loading && !sendData && !needPassword && !error && (
<p className="muted">
<Eye size={14} style={{ verticalAlign: 'text-bottom' }} /> Send unavailable.
<Eye size={14} style={{ verticalAlign: 'text-bottom' }} /> {t('txt_send_unavailable')}
</p>
)}
{!!error && <p className="local-error">{error}</p>}
</div>
</StandalonePageFrame>
</div>
);
}
+13 -10
View File
@@ -1,5 +1,7 @@
import { useState } from 'preact/hooks';
import { Eye, EyeOff } from 'lucide-preact';
import { Eye, EyeOff, Send, X } from 'lucide-preact';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
interface RecoverTwoFactorPageProps {
values: { email: string; password: string; recoveryCode: string };
@@ -13,12 +15,11 @@ export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) {
return (
<div className="auth-page">
<div className="auth-card">
<h1>Recover Two-step Login</h1>
<p className="muted">Sign in with your one-time recovery code to disable two-step verification.</p>
<StandalonePageFrame title={t('txt_recover_two_step_login')}>
<p className="muted standalone-muted">{t('txt_use_your_one_time_recovery_code_to_disable_two_step_verification')}</p>
<label className="field">
<span>Email</span>
<span>{t('txt_email')}</span>
<input
className="input"
type="email"
@@ -28,7 +29,7 @@ export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) {
</label>
<label className="field">
<span>Master Password</span>
<span>{t('txt_master_password')}</span>
<div className="password-wrap">
<input
className="input"
@@ -43,7 +44,7 @@ export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) {
</label>
<label className="field">
<span>Recovery Code</span>
<span>{t('txt_recovery_code')}</span>
<input
className="input"
value={props.values.recoveryCode}
@@ -53,13 +54,15 @@ export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) {
<div className="field-grid">
<button type="button" className="btn btn-primary" onClick={props.onSubmit}>
Submit
<Send size={14} className="btn-icon" />
{t('txt_submit')}
</button>
<button type="button" className="btn btn-secondary" onClick={props.onCancel}>
Cancel
<X size={14} className="btn-icon" />
{t('txt_cancel')}
</button>
</div>
</div>
</StandalonePageFrame>
</div>
);
}
+35 -34
View File
@@ -1,5 +1,6 @@
import { Clock3, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
import type { AuthorizedDevice } from '@/lib/types';
import { t } from '@/lib/i18n';
interface SecurityDevicesPageProps {
devices: AuthorizedDevice[];
@@ -11,30 +12,30 @@ interface SecurityDevicesPageProps {
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return '-';
if (!value) return t('txt_dash');
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
if (Number.isNaN(date.getTime())) return t('txt_dash');
return date.toLocaleString();
}
function mapDeviceTypeName(type: number): string {
switch (type) {
case 0: return 'Android';
case 1: return 'iOS';
case 2: return 'Chrome Extension';
case 3: return 'Firefox Extension';
case 4: return 'Opera Extension';
case 5: return 'Edge Extension';
case 6: return 'Windows Desktop';
case 7: return 'macOS Desktop';
case 8: return 'Linux Desktop';
case 9: return 'Chrome Browser';
case 10: return 'Firefox Browser';
case 11: return 'Opera Browser';
case 12: return 'Edge Browser';
case 13: return 'IE Browser';
case 14: return 'Web';
default: return `Type ${type}`;
case 0: return t('txt_android');
case 1: return t('txt_ios');
case 2: return t('txt_chrome_extension');
case 3: return t('txt_firefox_extension');
case 4: return t('txt_opera_extension');
case 5: return t('txt_edge_extension');
case 6: return t('txt_windows_desktop');
case 7: return t('txt_macos_desktop');
case 8: return t('txt_linux_desktop');
case 9: return t('txt_chrome_browser');
case 10: return t('txt_firefox_browser');
case 11: return t('txt_opera_browser');
case 12: return t('txt_edge_browser');
case 13: return t('txt_ie_browser');
case 14: return t('txt_web');
default: return t('txt_type_type', { type });
}
}
@@ -44,42 +45,42 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<section className="card">
<div className="section-head">
<div>
<h3 style={{ margin: 0 }}>Account Security</h3>
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
<div className="muted-inline" style={{ marginTop: 4 }}>
Manage authorized devices and 30-day TOTP trusted sessions.
{t('txt_manage_authorized_devices_and_30_day_totp_trusted_sessions')}
</div>
</div>
<div className="actions">
<button type="button" className="btn btn-secondary small" onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
Refresh
{t('txt_refresh')}
</button>
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
<ShieldOff size={14} className="btn-icon" />
Revoke All Trusted
{t('txt_revoke_all_trusted')}
</button>
</div>
</div>
</section>
<section className="card">
<h3 style={{ marginTop: 0 }}>Authorized Devices</h3>
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3>
<table className="table">
<thead>
<tr>
<th>Device</th>
<th>Type</th>
<th>Added</th>
<th>Last Seen</th>
<th>Trusted Until</th>
<th>Actions</th>
<th>{t('txt_device')}</th>
<th>{t('txt_type')}</th>
<th>{t('txt_added')}</th>
<th>{t('txt_last_seen')}</th>
<th>{t('txt_trusted_until')}</th>
<th>{t('txt_actions')}</th>
</tr>
</thead>
<tbody>
{props.devices.map((device) => (
<tr key={device.identifier}>
<td>
<div>{device.name || 'Unknown device'}</div>
<div>{device.name || t('txt_unknown_device')}</div>
<div className="muted-inline">{device.identifier}</div>
</td>
<td>{mapDeviceTypeName(device.type)}</td>
@@ -92,7 +93,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<span>{formatDateTime(device.trustedUntil)}</span>
</div>
) : (
<span className="muted-inline">Not trusted</span>
<span className="muted-inline">{t('txt_not_trusted')}</span>
)}
</td>
<td>
@@ -104,11 +105,11 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
onClick={() => props.onRevokeTrust(device)}
>
<ShieldOff size={14} className="btn-icon" />
Revoke Trust
{t('txt_revoke_trust')}
</button>
<button type="button" className="btn btn-danger small" onClick={() => props.onRemoveDevice(device)}>
<Trash2 size={14} className="btn-icon" />
Remove Device
{t('txt_remove_device_2')}
</button>
</div>
</td>
@@ -117,7 +118,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
{!props.loading && props.devices.length === 0 && (
<tr>
<td colSpan={6}>
<div className="empty" style={{ minHeight: 80 }}>No devices found.</div>
<div className="empty" style={{ minHeight: 80 }}>{t('txt_no_devices_found')}</div>
</td>
</tr>
)}
+50 -49
View File
@@ -1,6 +1,7 @@
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';
import { t } from '@/lib/i18n';
interface SendsPageProps {
sends: Send[];
@@ -117,15 +118,15 @@ export default function SendsPage(props: SendsPageProps) {
async function saveDraft(): Promise<void> {
if (!draft) return;
if (!draft.name.trim()) {
props.onNotify('error', 'Name is required');
props.onNotify('error', t('txt_name_is_required'));
return;
}
if (draft.type === 'text' && !draft.text.trim()) {
props.onNotify('error', 'Text is required');
props.onNotify('error', t('txt_text_is_required'));
return;
}
if (draft.type === 'file' && isCreating && !draft.file) {
props.onNotify('error', 'Please select a file');
props.onNotify('error', t('txt_please_select_a_file'));
return;
}
setBusy(true);
@@ -171,28 +172,28 @@ export default function SendsPage(props: SendsPageProps) {
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');
props.onNotify('success', t('txt_link_copied'));
}
return (
<div className="vault-grid">
<aside className="sidebar">
<div className="sidebar-block">
<div className="sidebar-title">All Sends</div>
<div className="sidebar-title">{t('txt_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>
<span className="tree-label">{t('txt_all_sends')}</span>
</button>
</div>
<div className="sidebar-block">
<div className="sidebar-title">Type</div>
<div className="sidebar-title">{t('txt_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>
<span className="tree-label">{t('txt_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>
<span className="tree-label">{t('txt_file')}</span>
</button>
</div>
</aside>
@@ -201,17 +202,17 @@ export default function SendsPage(props: SendsPageProps) {
<div className="list-head">
<input
className="search-input"
placeholder="Search sends..."
placeholder={t('txt_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
<RefreshCw size={14} className="btn-icon" /> {t('txt_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
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_selected')}
</button>
<button
type="button"
@@ -223,11 +224,11 @@ export default function SendsPage(props: SendsPageProps) {
setSelectedMap(map);
}}
>
Select All
{t('txt_select_all')}
</button>
{!!selectedCount && (
<button type="button" className="btn btn-secondary small" onClick={() => setSelectedMap({})}>
Cancel
{t('txt_cancel')}
</button>
)}
<button
@@ -241,7 +242,7 @@ export default function SendsPage(props: SendsPageProps) {
setShowPassword(false);
}}
>
<Plus size={14} className="btn-icon" /> Add
<Plus size={14} className="btn-icon" /> {t('txt_add')}
</button>
</div>
<div className="list-panel">
@@ -274,29 +275,29 @@ export default function SendsPage(props: SendsPageProps) {
</span>
</div>
<div className="list-text">
<span className="list-title" title={send.decName || '(No Name)'}>{send.decName || '(No Name)'}</span>
<span className="list-title" title={send.decName || t('txt_no_name')}>{send.decName || t('txt_no_name')}</span>
<span className="list-sub">
{Number(send.type) === 1 ? 'File' : 'Text'} - Accessed {send.accessCount || 0} times
{Number(send.type) === 1 ? t('txt_file') : t('txt_text')} - {t('txt_accessed_count_times', { count: send.accessCount || 0 })}
</span>
</div>
</button>
</div>
))}
{!filteredSends.length && <div className="empty">No sends</div>}
{!filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>}
</div>
</section>
<section className="detail-col">
{isEditing && draft && (
<div className="card">
<h3 className="detail-title">{isCreating ? 'New Send' : 'Edit Send'}</h3>
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
<div className="field-grid">
<label className="field field-span-2">
<span>Name</span>
<span>{t('txt_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>
<span>{t('txt_type')}</span>
<div className="send-options">
<label>
<input
@@ -305,7 +306,7 @@ export default function SendsPage(props: SendsPageProps) {
disabled={!isCreating}
onInput={() => setDraft({ ...draft, type: 'file' })}
/>
File
{t('txt_file')}
</label>
<label>
<input
@@ -314,35 +315,35 @@ export default function SendsPage(props: SendsPageProps) {
disabled={!isCreating}
onInput={() => setDraft({ ...draft, type: 'text' })}
/>
Text
{t('txt_text')}
</label>
</div>
</label>
{draft.type === 'file' ? (
<label className="field field-span-2">
<span>File</span>
<span>{t('txt_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>
<span>{t('txt_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>
<span>{t('txt_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>
<span>{t('txt_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>
<span>{t('txt_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>
<span>{t('txt_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)}>
@@ -351,20 +352,20 @@ export default function SendsPage(props: SendsPageProps) {
</div>
</label>
<label className="field field-span-2">
<span>Notes</span>
<span>{t('txt_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>
<span>{t('txt_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>
<label><input type="checkbox" checked={draft.disabled} onInput={(e) => setDraft({ ...draft, disabled: (e.currentTarget as HTMLInputElement).checked })} /> {t('txt_disable_this_send')}</label>
<label><input type="checkbox" checked={autoCopyLink} onInput={(e) => setAutoCopyLink((e.currentTarget as HTMLInputElement).checked)} /> {t('txt_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>
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>{t('txt_save')}</button>
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={() => { setIsEditing(false); setIsCreating(false); setDraft(null); setShowPassword(false); }}>{t('txt_cancel')}</button>
</div>
</div>
)}
@@ -372,27 +373,27 @@ export default function SendsPage(props: SendsPageProps) {
{!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>
<h3 className="detail-title">{selectedSend.decName || t('txt_no_name')}</h3>
<div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_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>
<h4>{t('txt_send_details')}</h4>
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{selectedSend.expirationDate || t('txt_dash')}</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>{t('txt_file')}</h4>
<div className="kv-line"><span>{t('txt_file_name')}</span><strong>{selectedSend.file?.fileName || t('txt_encrypted_file_2')}</strong></div>
<div className="kv-line"><span>{t('txt_file_size')}</span><strong>{selectedSend.file?.sizeName || t('txt_dash')}</strong></div>
</>
) : (
<>
<h4>Text</h4>
<h4>{t('txt_text')}</h4>
<div className="notes">{selectedSend.decText || ''}</div>
</>
)}
@@ -400,7 +401,7 @@ export default function SendsPage(props: SendsPageProps) {
{!!(selectedSend.decNotes || '').trim() && (
<div className="card">
<h4>Notes</h4>
<h4>{t('txt_notes')}</h4>
<div className="notes">{selectedSend.decNotes || ''}</div>
</div>
)}
@@ -408,14 +409,14 @@ export default function SendsPage(props: SendsPageProps) {
<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
<Copy size={14} className="btn-icon" /> {t('txt_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
<Pencil size={14} className="btn-icon" /> {t('txt_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
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
</button>
</div>
</>
+30 -26
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Clipboard, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
import { Clipboard, KeyRound, RefreshCw, Save, ShieldCheck, ShieldOff } from 'lucide-preact';
import qrcode from 'qrcode-generator';
import type { Profile } from '@/lib/types';
import { t } from '@/lib/i18n';
interface SettingsPageProps {
profile: Profile;
@@ -64,20 +65,20 @@ export default function SettingsPage(props: SettingsPageProps) {
async function loadRecoveryCode(): Promise<void> {
const code = await props.onGetRecoveryCode(recoveryMasterPassword);
setRecoveryCode(code);
props.onNotify?.('success', 'Recovery code loaded');
props.onNotify?.('success', t('txt_recovery_code_loaded'));
}
return (
<div className="stack">
<section className="card">
<h3>Profile</h3>
<h3>{t('txt_profile')}</h3>
<div className="field-grid">
<label className="field">
<span>Name</span>
<span>{t('txt_name')}</span>
<input className="input" value={name} onInput={(e) => setName((e.currentTarget as HTMLInputElement).value)} />
</label>
<label className="field">
<span>Email</span>
<span>{t('txt_email')}</span>
<input
className="input"
type="email"
@@ -87,14 +88,15 @@ export default function SettingsPage(props: SettingsPageProps) {
</label>
</div>
<button type="button" className="btn btn-primary" onClick={() => void props.onSaveProfile(name, email)}>
Save Profile
<Save size={14} className="btn-icon" />
{t('txt_save_profile')}
</button>
</section>
<section className="card">
<h3>Change Master Password</h3>
<h3>{t('txt_change_master_password')}</h3>
<label className="field">
<span>Current Password</span>
<span>{t('txt_current_password')}</span>
<input
className="input"
type="password"
@@ -104,11 +106,11 @@ export default function SettingsPage(props: SettingsPageProps) {
</label>
<div className="field-grid">
<label className="field">
<span>New Password</span>
<span>{t('txt_new_password')}</span>
<input className="input" type="password" value={newPassword} onInput={(e) => setNewPassword((e.currentTarget as HTMLInputElement).value)} />
</label>
<label className="field">
<span>Confirm Password</span>
<span>{t('txt_confirm_password')}</span>
<input className="input" type="password" value={newPassword2} onInput={(e) => setNewPassword2((e.currentTarget as HTMLInputElement).value)} />
</label>
</div>
@@ -117,35 +119,36 @@ export default function SettingsPage(props: SettingsPageProps) {
className="btn btn-danger"
onClick={() => void props.onChangePassword(currentPassword, newPassword, newPassword2)}
>
Change Password
<KeyRound size={14} className="btn-icon" />
{t('txt_change_password')}
</button>
</section>
<section className="card">
<div className="settings-twofactor-grid">
<div className="settings-subcard">
<h3>TOTP</h3>
{totpLocked && <div className="status-ok">TOTP is enabled for this account.</div>}
<h3>{t('txt_totp')}</h3>
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
<div className="totp-grid">
<div className="totp-qr" dangerouslySetInnerHTML={{ __html: qrSvg }} />
<div>
<div>
<label className="field">
<span>Authenticator Key</span>
<span>{t('txt_authenticator_key')}</span>
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
</label>
<label className="field">
<span>Verification Code</span>
<span>{t('txt_verification_code')}</span>
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
</label>
<div className="actions">
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
<ShieldCheck size={14} className="btn-icon" />
{totpLocked ? 'Enabled' : 'Enable TOTP'}
{totpLocked ? t('txt_enabled') : t('txt_enable_totp')}
</button>
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
<RefreshCw size={14} className="btn-icon" />
Regenerate
{t('txt_regenerate')}
</button>
<button
type="button"
@@ -153,11 +156,11 @@ export default function SettingsPage(props: SettingsPageProps) {
disabled={totpLocked}
onClick={() => {
void navigator.clipboard.writeText(secret);
props.onNotify?.('success', 'Secret copied');
props.onNotify?.('success', t('txt_secret_copied'));
}}
>
<Clipboard size={14} className="btn-icon" />
Copy Secret
{t('txt_copy_secret')}
</button>
</div>
</div>
@@ -165,17 +168,17 @@ export default function SettingsPage(props: SettingsPageProps) {
</div>
<button type="button" className="btn btn-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
<ShieldOff size={14} className="btn-icon" />
Disable TOTP
{t('txt_disable_totp')}
</button>
</div>
<div className="settings-subcard">
<h3>Recovery Code</h3>
<h3>{t('txt_recovery_code')}</h3>
<p className="muted-inline" style={{ marginBottom: 8 }}>
This is a one-time code. After it is used, a new code is generated automatically.
{t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}
</p>
<label className="field">
<span>Master Password</span>
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
@@ -185,7 +188,8 @@ export default function SettingsPage(props: SettingsPageProps) {
</label>
<div className="actions">
<button type="button" className="btn btn-secondary" onClick={() => void loadRecoveryCode()}>
View Recovery Code
<ShieldCheck size={14} className="btn-icon" />
{t('txt_view_recovery_code')}
</button>
<button
type="button"
@@ -193,10 +197,10 @@ export default function SettingsPage(props: SettingsPageProps) {
disabled={!recoveryCode}
onClick={() => {
void navigator.clipboard.writeText(recoveryCode);
props.onNotify?.('success', 'Recovery code copied');
props.onNotify?.('success', t('txt_recovery_code_copied'));
}}
>
Copy Code
{t('txt_copy_code')}
</button>
</div>
{recoveryCode && (
@@ -0,0 +1,30 @@
import type { ComponentChildren } from 'preact';
interface StandalonePageFrameProps {
title: string;
children: ComponentChildren;
}
export default function StandalonePageFrame(props: StandalonePageFrameProps) {
return (
<div className="standalone-shell">
<div className="standalone-brand standalone-brand-outside">
<img src="/logo-64.png" alt="NodeWarden logo" className="standalone-brand-logo" />
<div>
<div className="standalone-brand-title">NodeWarden</div>
</div>
</div>
<div className="auth-card">
<h1 className="standalone-title">{props.title}</h1>
{props.children}
</div>
<div className="standalone-footer">
<a href="https://github.com/shuaiplus/NodeWarden" target="_blank" rel="noreferrer">NodeWarden Repository</a>
<span> | </span>
<a href="https://github.com/shuaiplus" target="_blank" rel="noreferrer">Author: @shuaiplus</a>
</div>
</div>
);
}
+179 -177
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import ConfirmDialog from '@/components/ConfirmDialog';
import { calcTotpNow } from '@/lib/crypto';
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
@@ -28,6 +28,7 @@ import {
X,
} from 'lucide-preact';
import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
import { t } from '@/lib/i18n';
interface VaultPageProps {
ciphers: Cipher[];
@@ -59,11 +60,11 @@ interface TypeOption {
}
const CREATE_TYPE_OPTIONS: TypeOption[] = [
{ type: 1, label: 'Login' },
{ type: 3, label: 'Card' },
{ type: 4, label: 'Identity' },
{ type: 2, label: 'Note' },
{ type: 5, label: 'SSH Key' },
{ type: 1, label: t('txt_login') },
{ type: 3, label: t('txt_card') },
{ type: 4, label: t('txt_identity') },
{ type: 2, label: t('txt_note') },
{ type: 5, label: t('txt_ssh_key') },
];
function CreateTypeIcon({ type }: { type: number }) {
@@ -76,9 +77,9 @@ function CreateTypeIcon({ type }: { type: number }) {
}
const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }> = [
{ value: 0, label: 'Text' },
{ value: 1, label: 'Hidden' },
{ value: 2, label: 'Boolean' },
{ value: 0, label: t('txt_text') },
{ value: 1, label: t('txt_hidden') },
{ value: 2, label: t('txt_boolean') },
];
function cipherTypeKey(type: number): TypeFilter {
@@ -90,12 +91,12 @@ function cipherTypeKey(type: number): TypeFilter {
}
function cipherTypeLabel(type: number): string {
if (type === 1) return 'Login';
if (type === 3) return 'Card';
if (type === 4) return 'Identity';
if (type === 2) return 'Secure Note';
if (type === 5) return 'SSH Key';
return 'Item';
if (type === 1) return t('txt_login');
if (type === 3) return t('txt_card');
if (type === 4) return t('txt_identity');
if (type === 2) return t('txt_secure_note');
if (type === 5) return t('txt_ssh_key');
return t('txt_item');
}
function TypeIcon({ type }: { type: number }) {
@@ -116,9 +117,9 @@ function parseFieldType(value: number | string | null | undefined): CustomFieldT
}
function fieldTypeLabel(type: CustomFieldType): string {
if (type === 3) return 'Linked';
if (type === 3) return t('txt_linked');
const found = FIELD_TYPE_OPTIONS.find((x) => x.value === type);
return found ? found.label : 'Text';
return found ? found.label : t('txt_text');
}
function toBooleanFieldValue(raw: string): boolean {
@@ -257,7 +258,7 @@ function formatTotp(code: string): string {
}
function formatHistoryTime(value: string | null | undefined): string {
if (!value) return '-';
if (!value) return t('txt_dash');
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return value;
return date.toLocaleString();
@@ -448,11 +449,11 @@ export default function VaultPage(props: VaultPageProps) {
[selectedMap]
);
function folderName(id: string | null | undefined): string {
if (!id) return 'No Folder';
const folder = props.folders.find((x) => x.id === id);
return folder?.decName || folder?.name || id;
}
function folderName(id: string | null | undefined): string {
if (!id) return t('txt_no_folder');
const folder = props.folders.find((x) => x.id === id);
return folder?.decName || folder?.name || id;
}
function listSubtitle(cipher: Cipher): string {
if (Number(cipher.type || 1) === 1) {
@@ -565,7 +566,7 @@ export default function VaultPage(props: VaultPageProps) {
}
}
if (!nextDraft.name.trim()) {
setLocalError('Item name is required.');
setLocalError(t('txt_item_name_is_required'));
return;
}
setBusy(true);
@@ -639,7 +640,7 @@ export default function VaultPage(props: VaultPageProps) {
async function verifyReprompt(): Promise<void> {
if (!selectedCipher) return;
if (!repromptPassword) {
props.onNotify('error', 'Master password is required.');
props.onNotify('error', t('txt_master_password_is_required_2'));
return;
}
setBusy(true);
@@ -649,7 +650,7 @@ export default function VaultPage(props: VaultPageProps) {
setRepromptOpen(false);
setRepromptPassword('');
} catch (error) {
props.onNotify('error', error instanceof Error ? error.message : 'Unlock failed');
props.onNotify('error', error instanceof Error ? error.message : t('txt_unlock_failed'));
} finally {
setBusy(false);
}
@@ -657,7 +658,7 @@ export default function VaultPage(props: VaultPageProps) {
async function confirmCreateFolder(): Promise<void> {
if (!newFolderName.trim()) {
props.onNotify('error', 'Folder name is required');
props.onNotify('error', t('txt_folder_name_is_required'));
return;
}
setBusy(true);
@@ -676,44 +677,44 @@ export default function VaultPage(props: VaultPageProps) {
<aside className="sidebar">
<div className="sidebar-block">
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'all' })}>
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">All Items</span>
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span>
</button>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'favorite' })}>
<Star size={14} className="tree-icon" /> <span className="tree-label">Favorites</span>
<Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span>
</button>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'trash' })}>
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">Trash</span>
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
</button>
</div>
<div className="sidebar-block">
<div className="sidebar-title">Type</div>
<div className="sidebar-title">{t('txt_type')}</div>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'login' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'login' })}>
<Globe size={14} className="tree-icon" /> <span className="tree-label">Login</span>
<Globe size={14} className="tree-icon" /> <span className="tree-label">{t('txt_login')}</span>
</button>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'card' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'card' })}>
<CreditCard size={14} className="tree-icon" /> <span className="tree-label">Card</span>
<CreditCard size={14} className="tree-icon" /> <span className="tree-label">{t('txt_card')}</span>
</button>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'identity' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'identity' })}>
<ShieldUser size={14} className="tree-icon" /> <span className="tree-label">Identity</span>
<ShieldUser size={14} className="tree-icon" /> <span className="tree-label">{t('txt_identity')}</span>
</button>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'note' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'note' })}>
<StickyNote size={14} className="tree-icon" /> <span className="tree-label">Note</span>
<StickyNote size={14} className="tree-icon" /> <span className="tree-label">{t('txt_note')}</span>
</button>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'ssh' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'ssh' })}>
<KeyRound size={14} className="tree-icon" /> <span className="tree-label">SSH Key</span>
<KeyRound size={14} className="tree-icon" /> <span className="tree-label">{t('txt_ssh_key')}</span>
</button>
</div>
<div className="sidebar-block">
<div className="sidebar-title-row">
<div className="sidebar-title">Folders</div>
<div className="sidebar-title">{t('txt_folders')}</div>
<button type="button" className="folder-add-btn" onClick={() => setCreateFolderOpen(true)}>
<FolderPlus size={14} />
</button>
</div>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'folder' && sidebarFilter.folderId === null ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'folder', folderId: null })}>
<FolderX size={14} className="tree-icon" /> <span className="tree-label">No Folder</span>
<FolderX size={14} className="tree-icon" /> <span className="tree-label">{t('txt_no_folder')}</span>
</button>
{props.folders.map((folder) => (
<button
@@ -735,7 +736,7 @@ export default function VaultPage(props: VaultPageProps) {
<div className="list-head">
<input
className="search-input"
placeholder="Search your secure vault..."
placeholder={t('txt_search_your_secure_vault')}
value={searchInput}
onInput={(e) => setSearchInput((e.currentTarget as HTMLInputElement).value)}
onCompositionStart={() => setSearchComposing(true)}
@@ -745,12 +746,12 @@ export default function VaultPage(props: VaultPageProps) {
}}
/>
<button type="button" className="btn btn-secondary small" disabled={busy || props.loading} onClick={() => void syncVault()}>
<RefreshCw size={14} className="btn-icon" /> Sync Vault
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
</button>
</div>
<div className="toolbar actions">
<button type="button" className="btn btn-danger small" disabled={!selectedCount || busy} onClick={() => setBulkDeleteOpen(true)}>
<Trash2 size={14} className="btn-icon" /> Delete Selected
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_selected')}
</button>
<button
type="button"
@@ -762,11 +763,11 @@ export default function VaultPage(props: VaultPageProps) {
setSelectedMap(map);
}}
>
<CheckCheck size={14} className="btn-icon" /> Select All
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
</button>
<div className="create-menu-wrap" ref={createMenuRef}>
<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" /> {t('txt_add')}
</button>
{createMenuOpen && (
<div className="create-menu">
@@ -789,12 +790,12 @@ export default function VaultPage(props: VaultPageProps) {
setMoveOpen(true);
}}
>
<FolderInput size={14} className="btn-icon" /> Move
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
</button>
)}
{selectedCount > 0 && (
<button type="button" className="btn btn-secondary small" onClick={() => setSelectedMap({})}>
<X size={14} className="btn-icon" /> Cancel
<X size={14} className="btn-icon" /> {t('txt_cancel')}
</button>
)}
</div>
@@ -825,13 +826,13 @@ export default function VaultPage(props: VaultPageProps) {
<VaultListIcon cipher={cipher} />
</div>
<div className="list-text">
<span className="list-title" title={cipher.decName || '(No Name)'}>{cipher.decName || '(No Name)'}</span>
<span className="list-title" title={cipher.decName || t('txt_no_name')}>{cipher.decName || t('txt_no_name')}</span>
<span className="list-sub" title={listSubtitle(cipher)}>{listSubtitle(cipher)}</span>
</div>
</button>
</div>
))}
{!filteredCiphers.length && <div className="empty">No items</div>}
{!filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
</div>
</section>
@@ -847,12 +848,12 @@ export default function VaultPage(props: VaultPageProps) {
onClick={() => updateDraft({ favorite: !draft.favorite })}
>
{draft.favorite ? <Star size={14} className="btn-icon" /> : <StarOff size={14} className="btn-icon" />}
Favorite
{t('txt_favorite')}
</button>
</div>
<div className="field-grid">
<label className="field">
<span>Type</span>
<span>{t('txt_type')}</span>
<select
className="input"
value={draft.type}
@@ -871,13 +872,13 @@ export default function VaultPage(props: VaultPageProps) {
</select>
</label>
<label className="field">
<span>Folder</span>
<span>{t('txt_folder')}</span>
<select
className="input"
value={draft.folderId}
onInput={(e) => updateDraft({ folderId: (e.currentTarget as HTMLSelectElement).value })}
>
<option value="">No Folder</option>
<option value="">{t('txt_no_folder')}</option>
{props.folders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.decName || folder.name || folder.id}
@@ -887,32 +888,32 @@ export default function VaultPage(props: VaultPageProps) {
</label>
</div>
<label className="field">
<span>Name</span>
<span>{t('txt_name')}</span>
<input className="input" value={draft.name} onInput={(e) => updateDraft({ name: (e.currentTarget as HTMLInputElement).value })} />
</label>
</div>
{draft.type === 1 && (
<div className="card">
<h4>Login Credentials</h4>
<h4>{t('txt_login_credentials')}</h4>
<div className="field-grid">
<label className="field">
<span>Username</span>
<span>{t('txt_username')}</span>
<input className="input" value={draft.loginUsername} onInput={(e) => updateDraft({ loginUsername: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field">
<span>Password</span>
<span>{t('txt_password')}</span>
<input className="input" value={draft.loginPassword} onInput={(e) => updateDraft({ loginPassword: (e.currentTarget as HTMLInputElement).value })} />
</label>
</div>
<label className="field">
<span>TOTP Secret</span>
<span>{t('txt_totp_secret')}</span>
<input className="input" value={draft.loginTotp} onInput={(e) => updateDraft({ loginTotp: (e.currentTarget as HTMLInputElement).value })} />
</label>
<div className="section-head">
<h4>Websites</h4>
<h4>{t('txt_websites')}</h4>
<button type="button" className="btn btn-secondary small" onClick={() => updateDraft({ loginUris: [...draft.loginUris, ''] })}>
<Plus size={14} className="btn-icon" /> Add Website
<Plus size={14} className="btn-icon" /> {t('txt_add_website')}
</button>
</div>
{draft.loginUris.map((uri, index) => (
@@ -924,7 +925,7 @@ export default function VaultPage(props: VaultPageProps) {
className="btn btn-secondary small"
onClick={() => updateDraft({ loginUris: draft.loginUris.filter((_, i) => i !== index) })}
>
Remove
{t('txt_remove')}
</button>
)}
</div>
@@ -934,30 +935,30 @@ export default function VaultPage(props: VaultPageProps) {
{draft.type === 3 && (
<div className="card">
<h4>Card Details</h4>
<h4>{t('txt_card_details')}</h4>
<div className="field-grid">
<label className="field">
<span>Cardholder Name</span>
<span>{t('txt_cardholder_name')}</span>
<input className="input" value={draft.cardholderName} onInput={(e) => updateDraft({ cardholderName: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field">
<span>Number</span>
<span>{t('txt_number')}</span>
<input className="input" value={draft.cardNumber} onInput={(e) => updateDraft({ cardNumber: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field">
<span>Brand</span>
<span>{t('txt_brand')}</span>
<input className="input" value={draft.cardBrand} onInput={(e) => updateDraft({ cardBrand: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field">
<span>Security Code (CVV)</span>
<span>{t('txt_security_code_cvv')}</span>
<input className="input" value={draft.cardCode} onInput={(e) => updateDraft({ cardCode: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field">
<span>Expiry Month</span>
<span>{t('txt_expiry_month')}</span>
<input className="input" value={draft.cardExpMonth} onInput={(e) => updateDraft({ cardExpMonth: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field">
<span>Expiry Year</span>
<span>{t('txt_expiry_year')}</span>
<input className="input" value={draft.cardExpYear} onInput={(e) => updateDraft({ cardExpYear: (e.currentTarget as HTMLInputElement).value })} />
</label>
</div>
@@ -966,66 +967,66 @@ export default function VaultPage(props: VaultPageProps) {
{draft.type === 4 && (
<div className="card">
<h4>Identity Details</h4>
<h4>{t('txt_identity_details')}</h4>
<div className="field-grid">
<label className="field"><span>Title</span><input className="input" value={draft.identTitle} onInput={(e) => updateDraft({ identTitle: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>First Name</span><input className="input" value={draft.identFirstName} onInput={(e) => updateDraft({ identFirstName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>Middle Name</span><input className="input" value={draft.identMiddleName} onInput={(e) => updateDraft({ identMiddleName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>Last Name</span><input className="input" value={draft.identLastName} onInput={(e) => updateDraft({ identLastName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>Username</span><input className="input" value={draft.identUsername} onInput={(e) => updateDraft({ identUsername: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>Company</span><input className="input" value={draft.identCompany} onInput={(e) => updateDraft({ identCompany: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>SSN</span><input className="input" value={draft.identSsn} onInput={(e) => updateDraft({ identSsn: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>Passport Number</span><input className="input" value={draft.identPassportNumber} onInput={(e) => updateDraft({ identPassportNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>License Number</span><input className="input" value={draft.identLicenseNumber} onInput={(e) => updateDraft({ identLicenseNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>Email</span><input className="input" value={draft.identEmail} onInput={(e) => updateDraft({ identEmail: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>Phone</span><input className="input" value={draft.identPhone} onInput={(e) => updateDraft({ identPhone: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>Address 1</span><input className="input" value={draft.identAddress1} onInput={(e) => updateDraft({ identAddress1: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>Address 2</span><input className="input" value={draft.identAddress2} onInput={(e) => updateDraft({ identAddress2: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>Address 3</span><input className="input" value={draft.identAddress3} onInput={(e) => updateDraft({ identAddress3: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>City / Town</span><input className="input" value={draft.identCity} onInput={(e) => updateDraft({ identCity: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>State / Province</span><input className="input" value={draft.identState} onInput={(e) => updateDraft({ identState: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>Postal Code</span><input className="input" value={draft.identPostalCode} onInput={(e) => updateDraft({ identPostalCode: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>Country</span><input className="input" value={draft.identCountry} onInput={(e) => updateDraft({ identCountry: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_title')}</span><input className="input" value={draft.identTitle} onInput={(e) => updateDraft({ identTitle: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_first_name')}</span><input className="input" value={draft.identFirstName} onInput={(e) => updateDraft({ identFirstName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_middle_name')}</span><input className="input" value={draft.identMiddleName} onInput={(e) => updateDraft({ identMiddleName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_last_name')}</span><input className="input" value={draft.identLastName} onInput={(e) => updateDraft({ identLastName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_username')}</span><input className="input" value={draft.identUsername} onInput={(e) => updateDraft({ identUsername: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_company')}</span><input className="input" value={draft.identCompany} onInput={(e) => updateDraft({ identCompany: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_ssn')}</span><input className="input" value={draft.identSsn} onInput={(e) => updateDraft({ identSsn: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_passport_number')}</span><input className="input" value={draft.identPassportNumber} onInput={(e) => updateDraft({ identPassportNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_license_number')}</span><input className="input" value={draft.identLicenseNumber} onInput={(e) => updateDraft({ identLicenseNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_email')}</span><input className="input" value={draft.identEmail} onInput={(e) => updateDraft({ identEmail: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_phone')}</span><input className="input" value={draft.identPhone} onInput={(e) => updateDraft({ identPhone: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_address_1')}</span><input className="input" value={draft.identAddress1} onInput={(e) => updateDraft({ identAddress1: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_address_2')}</span><input className="input" value={draft.identAddress2} onInput={(e) => updateDraft({ identAddress2: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_address_3')}</span><input className="input" value={draft.identAddress3} onInput={(e) => updateDraft({ identAddress3: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_city_town')}</span><input className="input" value={draft.identCity} onInput={(e) => updateDraft({ identCity: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_state_province')}</span><input className="input" value={draft.identState} onInput={(e) => updateDraft({ identState: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_postal_code')}</span><input className="input" value={draft.identPostalCode} onInput={(e) => updateDraft({ identPostalCode: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_country')}</span><input className="input" value={draft.identCountry} onInput={(e) => updateDraft({ identCountry: (e.currentTarget as HTMLInputElement).value })} /></label>
</div>
</div>
)}
{draft.type === 5 && (
<div className="card">
<div className="section-head">
<h4>SSH Key</h4>
<h4>{t('txt_ssh_key')}</h4>
<button type="button" className="btn btn-secondary small" onClick={() => void seedSshDefaults(true)}>
<RefreshCw size={14} className="btn-icon" /> Regenerate
<RefreshCw size={14} className="btn-icon" /> {t('txt_regenerate')}
</button>
</div>
<label className="field">
<span>Private Key</span>
<span>{t('txt_private_key')}</span>
<textarea className="input textarea" value={draft.sshPrivateKey} onInput={(e) => updateDraft({ sshPrivateKey: (e.currentTarget as HTMLTextAreaElement).value })} />
</label>
<label className="field">
<span>Public Key</span>
<span>{t('txt_public_key')}</span>
<textarea className="input textarea" value={draft.sshPublicKey} onInput={(e) => updateSshPublicKey((e.currentTarget as HTMLTextAreaElement).value)} />
</label>
<label className="field">
<span>Fingerprint</span>
<span>{t('txt_fingerprint')}</span>
<input className="input input-readonly" value={draft.sshFingerprint} readOnly />
</label>
</div>
)}
<div className="card">
<h4>Additional Options</h4>
<h4>{t('txt_additional_options')}</h4>
<label className="field">
<span>Notes</span>
<span>{t('txt_notes')}</span>
<textarea className="input textarea" value={draft.notes} onInput={(e) => updateDraft({ notes: (e.currentTarget as HTMLTextAreaElement).value })} />
</label>
<label className="check-line">
<input type="checkbox" checked={draft.reprompt} onInput={(e) => updateDraft({ reprompt: (e.currentTarget as HTMLInputElement).checked })} />
Master password reprompt
{t('txt_master_password_reprompt')}
</label>
<div className="section-head">
<h4>Custom Fields</h4>
<h4>{t('txt_custom_fields')}</h4>
<button type="button" className="btn btn-secondary small" onClick={() => setFieldModalOpen(true)}>
<Plus size={14} className="btn-icon" /> Add Field
<Plus size={14} className="btn-icon" /> {t('txt_add_field')}
</button>
</div>
{draft.customFields
@@ -1058,7 +1059,7 @@ export default function VaultPage(props: VaultPageProps) {
className="btn btn-secondary small"
onClick={() => updateDraftCustomFields(draft.customFields.filter((_, i) => i !== originalIndex))}
>
Remove
{t('txt_remove')}
</button>
</div>
))}
@@ -1067,15 +1068,15 @@ export default function VaultPage(props: VaultPageProps) {
<div className="detail-actions">
<div className="actions">
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void saveDraft()}>
Confirm
{t('txt_confirm')}
</button>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={cancelEdit}>
Cancel
{t('txt_cancel')}
</button>
</div>
{!isCreating && selectedCipher && (
<button type="button" className="btn btn-danger" disabled={busy} onClick={() => setPendingDelete(selectedCipher)}>
Delete
{t('txt_delete')}
</button>
)}
</div>
@@ -1087,11 +1088,11 @@ export default function VaultPage(props: VaultPageProps) {
<>
{Number(selectedCipher.reprompt || 0) === 1 && repromptApprovedCipherId !== selectedCipher.id && (
<div className="card">
<h4>Master Password Reprompt</h4>
<div className="detail-sub">This item requires master password every time before viewing details.</div>
<h4>{t('txt_master_password_reprompt_2')}</h4>
<div className="detail-sub">{t('txt_this_item_requires_master_password_every_time_before_viewing_details')}</div>
<div className="actions" style={{ marginTop: '10px' }}>
<button type="button" className="btn btn-primary" onClick={() => setRepromptOpen(true)}>
<Eye size={14} className="btn-icon" /> Unlock Details
<Eye size={14} className="btn-icon" /> {t('txt_unlock_details')}
</button>
</div>
</div>
@@ -1099,49 +1100,49 @@ export default function VaultPage(props: VaultPageProps) {
{(Number(selectedCipher.reprompt || 0) !== 1 || repromptApprovedCipherId === selectedCipher.id) && (
<>
<div className="card">
<h3 className="detail-title">{selectedCipher.decName || '(No Name)'}</h3>
<h3 className="detail-title">{selectedCipher.decName || t('txt_no_name')}</h3>
<div className="detail-sub">{folderName(selectedCipher.folderId)}</div>
</div>
{selectedCipher.login && (
<div className="card">
<h4>Login Credentials</h4>
<h4>{t('txt_login_credentials')}</h4>
<div className="kv-row">
<span className="kv-label">Username</span>
<span className="kv-label">{t('txt_username')}</span>
<div className="kv-main">
<strong className="value-ellipsis" title={selectedCipher.login.decUsername || ''}>{selectedCipher.login.decUsername || ''}</strong>
</div>
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(selectedCipher.login?.decUsername || '')}>
<Clipboard size={14} className="btn-icon" /> Copy
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
<div className="kv-row">
<span className="kv-label">Password</span>
<span className="kv-label">{t('txt_password')}</span>
<div className="kv-main">
<strong>{showPassword ? selectedCipher.login.decPassword || '' : maskSecret(selectedCipher.login.decPassword || '')}</strong>
</div>
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => setShowPassword((v) => !v)}>
{showPassword ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
{showPassword ? 'Hide' : 'Reveal'}
{showPassword ? t('txt_hide') : t('txt_reveal')}
</button>
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(selectedCipher.login?.decPassword || '')}>
<Clipboard size={14} className="btn-icon" /> Copy
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
{!!selectedCipher.login.decTotp && (
<div className="kv-row">
<span className="kv-label">TOTP</span>
<span className="kv-label">{t('txt_totp')}</span>
<div className="kv-main">
<div className="totp-inline">
<strong>{totpLive ? formatTotp(totpLive.code) : '------'}</strong>
<strong>{totpLive ? formatTotp(totpLive.code) : t('txt_text_3')}</strong>
<div
className="totp-timer"
title={`Refresh in ${totpLive ? totpLive.remain : 0}s`}
aria-label={`Refresh in ${totpLive ? totpLive.remain : 0}s`}
title={t('txt_refresh_in_seconds_s', { seconds: totpLive ? totpLive.remain : 0 })}
aria-label={t('txt_refresh_in_seconds_s', { seconds: totpLive ? totpLive.remain : 0 })}
>
<svg viewBox="0 0 36 36" className="totp-ring" role="presentation" aria-hidden="true">
<circle className="totp-ring-track" cx="18" cy="18" r={TOTP_RING_RADIUS} />
@@ -1166,7 +1167,7 @@ export default function VaultPage(props: VaultPageProps) {
</div>
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(totpLive?.code || '')}>
<Clipboard size={14} className="btn-icon" /> Copy
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
@@ -1176,22 +1177,22 @@ export default function VaultPage(props: VaultPageProps) {
{(selectedCipher.login?.uris || []).length > 0 && (
<div className="card">
<h4>Autofill Options</h4>
<h4>{t('txt_autofill_options')}</h4>
{(selectedCipher.login?.uris || []).map((uri, index) => {
const value = uri.decUri || uri.uri || '';
if (!value.trim()) return null;
return (
<div key={`view-uri-${index}`} className="kv-row">
<span className="kv-label">Website</span>
<span className="kv-label">{t('txt_website')}</span>
<div className="kv-main">
<strong className="value-ellipsis" title={value}>{value}</strong>
</div>
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => openUri(value)}>
<ExternalLink size={14} className="btn-icon" /> Open
<ExternalLink size={14} className="btn-icon" /> {t('txt_open')}
</button>
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(value)}>
<Clipboard size={14} className="btn-icon" /> Copy
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
@@ -1202,51 +1203,51 @@ export default function VaultPage(props: VaultPageProps) {
{selectedCipher.card && (
<div className="card">
<h4>Card Details</h4>
<div className="kv-line"><span>Cardholder Name</span><strong>{selectedCipher.card.decCardholderName || ''}</strong></div>
<div className="kv-line"><span>Number</span><strong>{selectedCipher.card.decNumber || ''}</strong></div>
<div className="kv-line"><span>Brand</span><strong>{selectedCipher.card.decBrand || ''}</strong></div>
<div className="kv-line"><span>Expiry</span><strong>{`${selectedCipher.card.decExpMonth || ''}/${selectedCipher.card.decExpYear || ''}`}</strong></div>
<div className="kv-line"><span>Security Code</span><strong>{selectedCipher.card.decCode || ''}</strong></div>
<h4>{t('txt_card_details')}</h4>
<div className="kv-line"><span>{t('txt_cardholder_name')}</span><strong>{selectedCipher.card.decCardholderName || ''}</strong></div>
<div className="kv-line"><span>{t('txt_number')}</span><strong>{selectedCipher.card.decNumber || ''}</strong></div>
<div className="kv-line"><span>{t('txt_brand')}</span><strong>{selectedCipher.card.decBrand || ''}</strong></div>
<div className="kv-line"><span>{t('txt_expiry')}</span><strong>{`${selectedCipher.card.decExpMonth || ''}/${selectedCipher.card.decExpYear || ''}`}</strong></div>
<div className="kv-line"><span>{t('txt_security_code')}</span><strong>{selectedCipher.card.decCode || ''}</strong></div>
</div>
)}
{selectedCipher.identity && (
<div className="card">
<h4>Identity Details</h4>
<div className="kv-line"><span>Name</span><strong>{`${selectedCipher.identity.decFirstName || ''} ${selectedCipher.identity.decLastName || ''}`.trim()}</strong></div>
<div className="kv-line"><span>Username</span><strong>{selectedCipher.identity.decUsername || ''}</strong></div>
<div className="kv-line"><span>Email</span><strong>{selectedCipher.identity.decEmail || ''}</strong></div>
<div className="kv-line"><span>Phone</span><strong>{selectedCipher.identity.decPhone || ''}</strong></div>
<div className="kv-line"><span>Company</span><strong>{selectedCipher.identity.decCompany || ''}</strong></div>
<div className="kv-line"><span>Address</span><strong>{[selectedCipher.identity.decAddress1, selectedCipher.identity.decAddress2, selectedCipher.identity.decAddress3, selectedCipher.identity.decCity, selectedCipher.identity.decState, selectedCipher.identity.decPostalCode, selectedCipher.identity.decCountry].filter(Boolean).join(', ')}</strong></div>
<h4>{t('txt_identity_details')}</h4>
<div className="kv-line"><span>{t('txt_name')}</span><strong>{`${selectedCipher.identity.decFirstName || ''} ${selectedCipher.identity.decLastName || ''}`.trim()}</strong></div>
<div className="kv-line"><span>{t('txt_username')}</span><strong>{selectedCipher.identity.decUsername || ''}</strong></div>
<div className="kv-line"><span>{t('txt_email')}</span><strong>{selectedCipher.identity.decEmail || ''}</strong></div>
<div className="kv-line"><span>{t('txt_phone')}</span><strong>{selectedCipher.identity.decPhone || ''}</strong></div>
<div className="kv-line"><span>{t('txt_company')}</span><strong>{selectedCipher.identity.decCompany || ''}</strong></div>
<div className="kv-line"><span>{t('txt_address')}</span><strong>{[selectedCipher.identity.decAddress1, selectedCipher.identity.decAddress2, selectedCipher.identity.decAddress3, selectedCipher.identity.decCity, selectedCipher.identity.decState, selectedCipher.identity.decPostalCode, selectedCipher.identity.decCountry].filter(Boolean).join(', ')}</strong></div>
</div>
)}
{selectedCipher.sshKey && (
<div className="card">
<h4>SSH Key</h4>
<div className="kv-line"><span>Private Key</span><strong>{maskSecret(selectedCipher.sshKey.decPrivateKey || '')}</strong></div>
<div className="kv-line"><span>Public Key</span><strong>{selectedCipher.sshKey.decPublicKey || ''}</strong></div>
<div className="kv-line"><span>Fingerprint</span><strong>{selectedCipher.sshKey.decFingerprint || ''}</strong></div>
<h4>{t('txt_ssh_key')}</h4>
<div className="kv-line"><span>{t('txt_private_key')}</span><strong>{maskSecret(selectedCipher.sshKey.decPrivateKey || '')}</strong></div>
<div className="kv-line"><span>{t('txt_public_key')}</span><strong>{selectedCipher.sshKey.decPublicKey || ''}</strong></div>
<div className="kv-line"><span>{t('txt_fingerprint')}</span><strong>{selectedCipher.sshKey.decFingerprint || ''}</strong></div>
</div>
)}
{!!(selectedCipher.decNotes || '').trim() && (
<div className="card">
<h4>Notes</h4>
<h4>{t('txt_notes')}</h4>
<div className="notes">{selectedCipher.decNotes || ''}</div>
</div>
)}
{(selectedCipher.fields || []).some((x) => parseFieldType(x.type) !== 3) && (
<div className="card">
<h4>Custom Fields</h4>
<h4>{t('txt_custom_fields')}</h4>
{(selectedCipher.fields || [])
.filter((x) => parseFieldType(x.type) !== 3)
.map((field, index) => {
const fieldType = parseFieldType(field.type);
const fieldName = field.decName || 'Field';
const fieldName = field.decName || t('txt_field');
const rawValue = field.decValue || '';
const isHiddenVisible = !!hiddenFieldVisibleMap[index];
if (fieldType === 2) {
@@ -1258,8 +1259,8 @@ export default function VaultPage(props: VaultPageProps) {
<label className="check-line cf-check view">
<input type="checkbox" checked={checked} disabled />
</label>
<span className="boolean-text value-ellipsis" title={checked ? 'Checked' : 'Unchecked'}>
{checked ? 'Checked' : 'Unchecked'}
<span className="boolean-text value-ellipsis" title={checked ? t('txt_checked') : t('txt_unchecked')}>
{checked ? t('txt_checked') : t('txt_unchecked')}
</span>
</div>
<div className="kv-actions" />
@@ -1282,11 +1283,11 @@ export default function VaultPage(props: VaultPageProps) {
onClick={() => setHiddenFieldVisibleMap((prev) => ({ ...prev, [index]: !prev[index] }))}
>
{isHiddenVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
{isHiddenVisible ? 'Hide' : 'Reveal'}
{isHiddenVisible ? t('txt_hide') : t('txt_reveal')}
</button>
)}
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
<Clipboard size={14} className="btn-icon" /> Copy
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
@@ -1297,20 +1298,20 @@ export default function VaultPage(props: VaultPageProps) {
{(selectedCipher.creationDate || selectedCipher.revisionDate) && (
<div className="card">
<h4></h4>
<div className="detail-sub">: {formatHistoryTime(selectedCipher.revisionDate)}</div>
<div className="detail-sub">: {formatHistoryTime(selectedCipher.creationDate)}</div>
<h4>{t('txt_item_history')}</h4>
<div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(selectedCipher.revisionDate) })}</div>
<div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(selectedCipher.creationDate) })}</div>
</div>
)}
<div className="detail-actions">
<div className="actions">
<button type="button" className="btn btn-secondary" onClick={startEdit}>
<Pencil size={14} className="btn-icon" /> Edit
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
</button>
</div>
<button type="button" className="btn btn-danger" onClick={() => setPendingDelete(selectedCipher)}>
<Trash2 size={14} className="btn-icon" /> Delete
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
</button>
</div>
</>
@@ -1318,20 +1319,20 @@ export default function VaultPage(props: VaultPageProps) {
</>
)}
{!isEditing && !selectedCipher && <div className="empty card">Select an item</div>}
{!isEditing && !selectedCipher && <div className="empty card">{t('txt_select_an_item')}</div>}
</section>
</div>
<ConfirmDialog
open={fieldModalOpen}
title="Add Field"
message="Configure custom field values."
confirmText="Add"
cancelText="Cancel"
title={t('txt_add_field')}
message={t('txt_configure_custom_field_values')}
confirmText={t('txt_add')}
cancelText={t('txt_cancel')}
onConfirm={() => {
if (!draft) return;
if (!fieldLabel.trim()) {
setLocalError('Field label is required.');
setLocalError(t('txt_field_label_is_required'));
return;
}
updateDraftCustomFields([
@@ -1356,7 +1357,7 @@ export default function VaultPage(props: VaultPageProps) {
}}
>
<label className="field">
<span>Field Type</span>
<span>{t('txt_field_type')}</span>
<select className="input" value={fieldType} onInput={(e) => setFieldType(Number((e.currentTarget as HTMLSelectElement).value) as CustomFieldType)}>
{FIELD_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
@@ -1366,7 +1367,7 @@ export default function VaultPage(props: VaultPageProps) {
</select>
</label>
<label className="field">
<span>Field Label</span>
<span>{t('txt_field_label')}</span>
<input className="input" value={fieldLabel} onInput={(e) => setFieldLabel((e.currentTarget as HTMLInputElement).value)} />
</label>
{fieldType === 2 ? (
@@ -1376,11 +1377,11 @@ export default function VaultPage(props: VaultPageProps) {
checked={toBooleanFieldValue(fieldValue)}
onInput={(e) => setFieldValue((e.currentTarget as HTMLInputElement).checked ? 'true' : 'false')}
/>
Enabled
{t('txt_enabled')}
</label>
) : (
<label className="field">
<span>Field Value</span>
<span>{t('txt_field_value')}</span>
<input className="input" value={fieldValue} onInput={(e) => setFieldValue((e.currentTarget as HTMLInputElement).value)} />
</label>
)}
@@ -1388,8 +1389,8 @@ export default function VaultPage(props: VaultPageProps) {
<ConfirmDialog
open={!!pendingDelete}
title="Delete Item"
message="Are you sure you want to delete this item?"
title={t('txt_delete_item')}
message={t('txt_are_you_sure_you_want_to_delete_this_item')}
danger
onConfirm={() => void deleteSelected()}
onCancel={() => setPendingDelete(null)}
@@ -1397,8 +1398,8 @@ export default function VaultPage(props: VaultPageProps) {
<ConfirmDialog
open={bulkDeleteOpen}
title="Delete Selected Items"
message={`Are you sure you want to delete ${selectedCount} selected items?`}
title={t('txt_delete_selected_items')}
message={t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: selectedCount })}
danger
onConfirm={() => void confirmBulkDelete()}
onCancel={() => setBulkDeleteOpen(false)}
@@ -1406,17 +1407,17 @@ export default function VaultPage(props: VaultPageProps) {
<ConfirmDialog
open={moveOpen}
title="Move Selected Items"
message="Choose destination folder."
confirmText="Move"
cancelText="Cancel"
title={t('txt_move_selected_items')}
message={t('txt_choose_destination_folder')}
confirmText={t('txt_move')}
cancelText={t('txt_cancel')}
onConfirm={() => void confirmBulkMove()}
onCancel={() => setMoveOpen(false)}
>
<label className="field">
<span>Folder</span>
<span>{t('txt_folder')}</span>
<select className="input" value={moveFolderId} onInput={(e) => setMoveFolderId((e.currentTarget as HTMLSelectElement).value)}>
<option value="__none__">No Folder</option>
<option value="__none__">{t('txt_no_folder')}</option>
{props.folders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.decName || folder.name || folder.id}
@@ -1428,10 +1429,10 @@ export default function VaultPage(props: VaultPageProps) {
<ConfirmDialog
open={createFolderOpen}
title="Create Folder"
message="Enter a folder name."
confirmText="Create"
cancelText="Cancel"
title={t('txt_create_folder')}
message={t('txt_enter_a_folder_name')}
confirmText={t('txt_create')}
cancelText={t('txt_cancel')}
onConfirm={() => void confirmCreateFolder()}
onCancel={() => {
setCreateFolderOpen(false);
@@ -1439,17 +1440,17 @@ export default function VaultPage(props: VaultPageProps) {
}}
>
<label className="field">
<span>Folder Name</span>
<span>{t('txt_folder_name')}</span>
<input className="input" value={newFolderName} onInput={(e) => setNewFolderName((e.currentTarget as HTMLInputElement).value)} />
</label>
</ConfirmDialog>
<ConfirmDialog
open={repromptOpen}
title="Unlock Item"
message="Enter master password to view this item."
confirmText="Unlock"
cancelText="Cancel"
title={t('txt_unlock_item')}
message={t('txt_enter_master_password_to_view_this_item')}
confirmText={t('txt_unlock')}
cancelText={t('txt_cancel')}
showIcon={false}
onConfirm={() => void verifyReprompt()}
onCancel={() => {
@@ -1458,7 +1459,7 @@ export default function VaultPage(props: VaultPageProps) {
}}
>
<label className="field">
<span>Master Password</span>
<span>{t('txt_master_password')}</span>
<input className="input" type="password" value={repromptPassword} onInput={(e) => setRepromptPassword((e.currentTarget as HTMLInputElement).value)} />
</label>
</ConfirmDialog>
@@ -1469,3 +1470,4 @@ export default function VaultPage(props: VaultPageProps) {