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 1810e0aa7a
commit bacf27b936
14 changed files with 1343 additions and 491 deletions
+99 -98
View File
@@ -56,6 +56,7 @@ import {
verifyMasterPassword,
} from '@/lib/api';
import { base64ToBytes, decryptBw, decryptStr, hkdf } from '@/lib/crypto';
import { t } from '@/lib/i18n';
import type { AppPhase, AuthorizedDevice, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
interface PendingTotp {
@@ -201,12 +202,12 @@ export default function App() {
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
navigate('/vault');
}
pushToast('success', 'Login success');
pushToast('success', t('txt_login_success'));
}
async function handleLogin() {
if (!loginValues.email || !loginValues.password) {
pushToast('error', 'Please input email and password');
pushToast('error', t('txt_please_input_email_and_password'));
return;
}
try {
@@ -227,16 +228,16 @@ export default function App() {
setRememberDevice(true);
return;
}
pushToast('error', tokenError.error_description || tokenError.error || 'Login failed');
pushToast('error', tokenError.error_description || tokenError.error || t('txt_login_failed'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Login failed');
pushToast('error', error instanceof Error ? error.message : t('txt_login_failed'));
}
}
async function handleTotpVerify() {
if (!pendingTotp) return;
if (!totpCode.trim()) {
pushToast('error', 'Please input TOTP code');
pushToast('error', t('txt_please_input_totp_code'));
return;
}
const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, {
@@ -248,7 +249,7 @@ export default function App() {
return;
}
const tokenError = token as { error_description?: string; error?: string };
pushToast('error', tokenError.error_description || tokenError.error || 'TOTP verify failed');
pushToast('error', tokenError.error_description || tokenError.error || t('txt_totp_verify_failed'));
}
async function handleRecoverTwoFactorSubmit() {
@@ -256,7 +257,7 @@ export default function App() {
const password = recoverValues.password;
const recoveryCode = recoverValues.recoveryCode.trim();
if (!email || !password || !recoveryCode) {
pushToast('error', 'Email, password and recovery code are required');
pushToast('error', t('txt_email_password_and_recovery_code_are_required'));
return;
}
try {
@@ -266,30 +267,30 @@ export default function App() {
if ('access_token' in token && token.access_token) {
await finalizeLogin(token.access_token, token.refresh_token, email, derived.masterKey);
if (recovered.newRecoveryCode) {
pushToast('success', `2FA recovered. New recovery code: ${recovered.newRecoveryCode}`);
pushToast('success', t('txt_text_2fa_recovered_new_recovery_code_code', { code: recovered.newRecoveryCode }));
} else {
pushToast('success', '2FA recovered');
pushToast('success', t('txt_text_2fa_recovered'));
}
return;
}
pushToast('error', 'Recovered but auto-login failed, please sign in.');
pushToast('error', t('txt_recovered_but_auto_login_failed_please_sign_in'));
navigate('/login');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Recover 2FA failed');
pushToast('error', error instanceof Error ? error.message : t('txt_recover_2fa_failed'));
}
}
async function handleRegister() {
if (!registerValues.email || !registerValues.password) {
pushToast('error', 'Please input email and password');
pushToast('error', t('txt_please_input_email_and_password'));
return;
}
if (registerValues.password.length < 12) {
pushToast('error', 'Master password must be at least 12 chars');
pushToast('error', t('txt_master_password_must_be_at_least_12_chars'));
return;
}
if (registerValues.password !== registerValues.password2) {
pushToast('error', 'Passwords do not match');
pushToast('error', t('txt_passwords_do_not_match'));
return;
}
const resp = await registerAccount({
@@ -305,13 +306,13 @@ export default function App() {
}
setLoginValues({ email: registerValues.email.toLowerCase(), password: '' });
setPhase('login');
pushToast('success', 'Registration succeeded. Please sign in.');
pushToast('success', t('txt_registration_succeeded_please_sign_in'));
}
async function handleUnlock() {
if (!session || !profile) return;
if (!unlockPassword) {
pushToast('error', 'Please input master password');
pushToast('error', t('txt_please_input_master_password'));
return;
}
try {
@@ -321,9 +322,9 @@ export default function App() {
setUnlockPassword('');
setPhase('app');
if (location === '/' || location === '/lock') navigate('/vault');
pushToast('success', 'Unlocked');
pushToast('success', t('txt_unlocked'));
} catch {
pushToast('error', 'Unlock failed. Master password is incorrect.');
pushToast('error', t('txt_unlock_failed_master_password_is_incorrect'));
}
}
@@ -348,8 +349,8 @@ export default function App() {
function handleLogout() {
setConfirm({
title: 'Log Out',
message: 'Are you sure you want to log out?',
title: t('txt_log_out'),
message: t('txt_are_you_sure_you_want_to_log_out'),
showIcon: false,
onConfirm: () => {
logoutNow();
@@ -542,7 +543,7 @@ export default function App() {
nextSend.decText = '';
}
} catch {
nextSend.decName = '(Decrypt failed)';
nextSend.decName = t('txt_decrypt_failed');
}
return nextSend;
})
@@ -554,7 +555,7 @@ export default function App() {
setDecryptedSends(sends);
} catch (error) {
if (!active) return;
pushToast('error', error instanceof Error ? error.message : 'Decrypt failed');
pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2'));
}
})();
@@ -567,24 +568,24 @@ export default function App() {
try {
const updated = await updateProfile(authedFetch, { name: name.trim(), email: email.trim().toLowerCase() });
setProfile(updated);
pushToast('success', 'Profile updated');
pushToast('success', t('txt_profile_updated'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Save profile failed');
pushToast('error', error instanceof Error ? error.message : t('txt_save_profile_failed'));
}
}
async function changePasswordAction(currentPassword: string, nextPassword: string, nextPassword2: string) {
if (!profile) return;
if (!currentPassword || !nextPassword) {
pushToast('error', 'Current/new password is required');
pushToast('error', t('txt_current_new_password_is_required'));
return;
}
if (nextPassword.length < 12) {
pushToast('error', 'New password must be at least 12 chars');
pushToast('error', t('txt_new_password_must_be_at_least_12_chars'));
return;
}
if (nextPassword !== nextPassword2) {
pushToast('error', 'New passwords do not match');
pushToast('error', t('txt_new_passwords_do_not_match'));
return;
}
try {
@@ -596,29 +597,29 @@ export default function App() {
profileKey: profile.key,
});
handleLogout();
pushToast('success', 'Master password changed. Please login again.');
pushToast('success', t('txt_master_password_changed_please_login_again'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Change password failed');
pushToast('error', error instanceof Error ? error.message : t('txt_change_password_failed'));
}
}
async function enableTotpAction(secret: string, token: string) {
if (!secret.trim() || !token.trim()) {
pushToast('error', 'Secret and code are required');
pushToast('error', t('txt_secret_and_code_are_required'));
return;
}
try {
await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() });
pushToast('success', 'TOTP enabled');
pushToast('success', t('txt_totp_enabled'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Enable TOTP failed');
pushToast('error', error instanceof Error ? error.message : t('txt_enable_totp_failed'));
}
}
async function disableTotpAction() {
if (!profile) return;
if (!disableTotpPassword) {
pushToast('error', 'Please input master password');
pushToast('error', t('txt_please_input_master_password'));
return;
}
try {
@@ -628,15 +629,15 @@ export default function App() {
setDisableTotpOpen(false);
setDisableTotpPassword('');
await totpStatusQuery.refetch();
pushToast('success', 'TOTP disabled');
pushToast('success', t('txt_totp_disabled'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Disable TOTP failed');
pushToast('error', error instanceof Error ? error.message : t('txt_disable_totp_failed'));
}
}
async function refreshVault() {
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch(), sendsQuery.refetch()]);
pushToast('success', 'Vault synced');
pushToast('success', t('txt_vault_synced'));
}
async function refreshAuthorizedDevices() {
@@ -646,19 +647,19 @@ export default function App() {
async function revokeDeviceTrustAction(device: AuthorizedDevice) {
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
await authorizedDevicesQuery.refetch();
pushToast('success', 'Device authorization revoked');
pushToast('success', t('txt_device_authorization_revoked'));
}
async function revokeAllDeviceTrustAction() {
await revokeAllAuthorizedDeviceTrust(authedFetch);
await authorizedDevicesQuery.refetch();
pushToast('success', 'All device authorizations revoked');
pushToast('success', t('txt_all_device_authorizations_revoked'));
}
async function removeDeviceAction(device: AuthorizedDevice) {
await deleteAuthorizedDevice(authedFetch, device.identifier);
await authorizedDevicesQuery.refetch();
pushToast('success', 'Device removed');
pushToast('success', t('txt_device_removed'));
}
async function createVaultItem(draft: VaultDraft) {
@@ -666,9 +667,9 @@ export default function App() {
try {
await createCipher(authedFetch, session, draft);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Item created');
pushToast('success', t('txt_item_created'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Create item failed');
pushToast('error', error instanceof Error ? error.message : t('txt_create_item_failed'));
throw error;
}
}
@@ -678,9 +679,9 @@ export default function App() {
try {
await updateCipher(authedFetch, session, cipher, draft);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Item updated');
pushToast('success', t('txt_item_updated'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Update item failed');
pushToast('error', error instanceof Error ? error.message : t('txt_update_item_failed'));
throw error;
}
}
@@ -689,9 +690,9 @@ export default function App() {
try {
await deleteCipher(authedFetch, cipher.id);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Item deleted');
pushToast('success', t('txt_item_deleted'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Delete item failed');
pushToast('error', error instanceof Error ? error.message : t('txt_delete_item_failed'));
throw error;
}
}
@@ -702,9 +703,9 @@ export default function App() {
await deleteCipher(authedFetch, id);
}
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Deleted selected items');
pushToast('success', t('txt_deleted_selected_items'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Bulk delete failed');
pushToast('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed'));
throw error;
}
}
@@ -713,20 +714,20 @@ export default function App() {
try {
await bulkMoveCiphers(authedFetch, ids, folderId);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Moved selected items');
pushToast('success', t('txt_moved_selected_items'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Bulk move failed');
pushToast('error', error instanceof Error ? error.message : t('txt_bulk_move_failed'));
throw error;
}
}
async function getRecoveryCodeAction(masterPassword: string): Promise<string> {
if (!profile) throw new Error('Profile unavailable');
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error('Master password is required');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
const code = await getTotpRecoveryCode(authedFetch, derived.hash);
if (!code) throw new Error('Recovery code is empty');
if (!code) throw new Error(t('txt_recovery_code_is_empty'));
return code;
}
@@ -740,9 +741,9 @@ export default function App() {
const shareUrl = buildPublicSendUrl(window.location.origin, created.accessId, keyPart);
await navigator.clipboard.writeText(shareUrl);
}
pushToast('success', 'Send created');
pushToast('success', t('txt_send_created'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Create send failed');
pushToast('error', error instanceof Error ? error.message : t('txt_create_send_failed'));
throw error;
}
}
@@ -757,9 +758,9 @@ export default function App() {
const shareUrl = buildPublicSendUrl(window.location.origin, updated.accessId, keyPart);
await navigator.clipboard.writeText(shareUrl);
}
pushToast('success', 'Send updated');
pushToast('success', t('txt_send_updated'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Update send failed');
pushToast('error', error instanceof Error ? error.message : t('txt_update_send_failed'));
throw error;
}
}
@@ -768,9 +769,9 @@ export default function App() {
try {
await deleteSend(authedFetch, send.id);
await sendsQuery.refetch();
pushToast('success', 'Send deleted');
pushToast('success', t('txt_send_deleted'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Delete send failed');
pushToast('error', error instanceof Error ? error.message : t('txt_delete_send_failed'));
throw error;
}
}
@@ -781,9 +782,9 @@ export default function App() {
await deleteSend(authedFetch, id);
}
await sendsQuery.refetch();
pushToast('success', 'Deleted selected sends');
pushToast('success', t('txt_deleted_selected_sends'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Bulk delete sends failed');
pushToast('error', error instanceof Error ? error.message : t('txt_bulk_delete_sends_failed'));
throw error;
}
}
@@ -796,15 +797,15 @@ export default function App() {
async function createFolderAction(name: string) {
const folderName = name.trim();
if (!folderName) {
pushToast('error', 'Folder name is required');
pushToast('error', t('txt_folder_name_is_required'));
return;
}
try {
await createFolder(authedFetch, folderName);
await foldersQuery.refetch();
pushToast('success', 'Folder created');
pushToast('success', t('txt_folder_created'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Create folder failed');
pushToast('error', error instanceof Error ? error.message : t('txt_create_folder_failed'));
throw error;
}
}
@@ -849,7 +850,7 @@ export default function App() {
if (phase === 'loading') {
return (
<>
<div className="loading-screen">Loading NodeWarden...</div>
<div className="loading-screen">{t('txt_loading_nodewarden')}</div>
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
</>
);
@@ -878,10 +879,10 @@ export default function App() {
<ConfirmDialog
open={!!pendingTotp}
title="Two-step verification"
message="Password is already verified."
confirmText="Verify"
cancelText="Cancel"
title={t('txt_two_step_verification')}
message={t('txt_password_is_already_verified')}
confirmText={t('txt_verify')}
cancelText={t('txt_cancel')}
showIcon={false}
onConfirm={() => void handleTotpVerify()}
onCancel={() => {
@@ -902,18 +903,18 @@ export default function App() {
navigate('/recover-2fa');
}}
>
Use Recovery Code
{t('txt_use_recovery_code')}
</button>
</div>
)}
>
<label className="field">
<span>TOTP Code</span>
<span>{t('txt_totp_code')}</span>
<input className="input" value={totpCode} onInput={(e) => setTotpCode((e.currentTarget as HTMLInputElement).value)} />
</label>
<label className="check-line" style={{ marginBottom: 0 }}>
<input type="checkbox" checked={rememberDevice} onChange={(e) => setRememberDevice((e.currentTarget as HTMLInputElement).checked)} />
<span>Trust this device for 30 days</span>
<span>{t('txt_trust_this_device_for_30_days')}</span>
</label>
</ConfirmDialog>
</>
@@ -935,7 +936,7 @@ export default function App() {
<span>{profile?.email}</span>
</div>
<button type="button" className="btn btn-secondary small" onClick={handleLogout}>
<LogOut size={14} className="btn-icon" /> Sign Out
<LogOut size={14} className="btn-icon" /> {t('txt_sign_out')}
</button>
</div>
</header>
@@ -944,29 +945,29 @@ export default function App() {
<aside className="app-side">
<Link href="/vault" className={`side-link ${location === '/vault' ? 'active' : ''}`}>
<Vault size={16} />
<span>My Vault</span>
<span>{t('nav_my_vault')}</span>
</Link>
<Link href="/sends" className={`side-link ${location === '/sends' ? 'active' : ''}`}>
<SendIcon size={16} />
<span>Sends</span>
<span>{t('nav_sends')}</span>
</Link>
{profile?.role === 'admin' && (
<Link href="/admin" className={`side-link ${location === '/admin' ? 'active' : ''}`}>
<ShieldUser size={16} />
<span>Admin Panel</span>
<span>{t('nav_admin_panel')}</span>
</Link>
)}
<Link href="/settings" className={`side-link ${location === '/settings' ? 'active' : ''}`}>
<SettingsIcon size={16} />
<span>System Settings</span>
<span>{t('nav_account_settings')}</span>
</Link>
<Link href="/security/devices" className={`side-link ${location === '/security/devices' ? 'active' : ''}`}>
<Shield size={16} />
<span>Account Security</span>
<span>{t('nav_device_management')}</span>
</Link>
<Link href="/help" className={`side-link ${location === '/help' ? 'active' : ''}`}>
<CircleHelp size={16} />
<span>Support Center</span>
<span>{t('nav_support_center')}</span>
</Link>
</aside>
<main className="content">
@@ -1024,8 +1025,8 @@ export default function App() {
onRefresh={() => void refreshAuthorizedDevices()}
onRevokeTrust={(device) => {
setConfirm({
title: 'Revoke device authorization',
message: `Revoke 30-day TOTP trust for "${device.name}"?`,
title: t('txt_revoke_device_authorization'),
message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }),
danger: true,
onConfirm: () => {
setConfirm(null);
@@ -1035,8 +1036,8 @@ export default function App() {
}}
onRemoveDevice={(device) => {
setConfirm({
title: 'Remove device',
message: `Remove device "${device.name}" and clear its 2FA trust?`,
title: t('txt_remove_device'),
message: t('txt_remove_device_name_and_clear_its_2fa_trust', { name: device.name }),
danger: true,
onConfirm: () => {
setConfirm(null);
@@ -1046,8 +1047,8 @@ export default function App() {
}}
onRevokeAll={() => {
setConfirm({
title: 'Revoke all trusted devices',
message: 'Revoke 30-day TOTP trust from all devices?',
title: t('txt_revoke_all_trusted_devices'),
message: t('txt_revoke_30_day_totp_trust_from_all_devices'),
danger: true,
onConfirm: () => {
setConfirm(null);
@@ -1069,19 +1070,19 @@ export default function App() {
onCreateInvite={async (hours) => {
await createInvite(authedFetch, hours);
await invitesQuery.refetch();
pushToast('success', 'Invite created');
pushToast('success', t('txt_invite_created'));
}}
onDeleteAllInvites={async () => {
setConfirm({
title: 'Delete all invites',
message: 'Delete all invite codes (active/inactive)?',
title: t('txt_delete_all_invites'),
message: t('txt_delete_all_invite_codes_active_inactive'),
danger: true,
onConfirm: () => {
setConfirm(null);
void (async () => {
await deleteAllInvites(authedFetch);
await invitesQuery.refetch();
pushToast('success', 'All invites deleted');
pushToast('success', t('txt_all_invites_deleted'));
})();
},
});
@@ -1089,19 +1090,19 @@ export default function App() {
onToggleUserStatus={async (userId, status) => {
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
await usersQuery.refetch();
pushToast('success', 'User status updated');
pushToast('success', t('txt_user_status_updated'));
}}
onDeleteUser={async (userId) => {
setConfirm({
title: 'Delete user',
message: 'Delete this user and all user data?',
title: t('txt_delete_user'),
message: t('txt_delete_this_user_and_all_user_data'),
danger: true,
onConfirm: () => {
setConfirm(null);
void (async () => {
await deleteUser(authedFetch, userId);
await usersQuery.refetch();
pushToast('success', 'User deleted');
pushToast('success', t('txt_user_deleted'));
})();
},
});
@@ -1109,7 +1110,7 @@ export default function App() {
onRevokeInvite={async (code) => {
await revokeInvite(authedFetch, code);
await invitesQuery.refetch();
pushToast('success', 'Invite revoked');
pushToast('success', t('txt_invite_revoked'));
}}
/>
</Route>
@@ -1134,10 +1135,10 @@ export default function App() {
<ConfirmDialog
open={disableTotpOpen}
title="Disable TOTP"
message="Enter master password to disable two-step verification."
confirmText="Disable TOTP"
cancelText="Cancel"
title={t('txt_disable_totp')}
message={t('txt_enter_master_password_to_disable_two_step_verification')}
confirmText={t('txt_disable_totp')}
cancelText={t('txt_cancel')}
danger
showIcon={false}
onConfirm={() => void disableTotpAction()}
@@ -1147,7 +1148,7 @@ export default function App() {
}}
>
<label className="field">
<span>Master Password</span>
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
+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) {
+716
View File
@@ -0,0 +1,716 @@
type Locale = 'en' | 'zh-CN';
const LOCALE_STORAGE_KEY = 'nodewarden.locale';
const messages: Record<Locale, Record<string, string>> = {
en: {
nav_account_settings: "Account Settings",
nav_admin_panel: "Admin Panel",
nav_device_management: "Device Management",
nav_my_vault: "My Vault",
nav_sends: "Sends",
nav_support_center: "Support Center",
support_title: "Support Center",
support_under_construction: "Under construction.",
txt_access_count: "Access Count",
txt_accessed_count_times: "Accessed {count} times",
txt_actions: "Actions",
txt_add: "Add",
txt_add_field: "Add Field",
txt_add_website: "Add Website",
txt_added: "Added",
txt_additional_options: "Additional Options",
txt_address: "Address",
txt_address_1: "Address 1",
txt_address_2: "Address 2",
txt_address_3: "Address 3",
txt_all_device_authorizations_revoked: "All device authorizations revoked",
txt_all_invites_deleted: "All invites deleted",
txt_all_items: "All Items",
txt_all_sends: "All Sends",
txt_android: "Android",
txt_are_you_sure_you_want_to_delete_count_selected_items: "Are you sure you want to delete {count} selected items?",
txt_are_you_sure_you_want_to_delete_this_item: "Are you sure you want to delete this item?",
txt_are_you_sure_you_want_to_log_out: "Are you sure you want to log out?",
txt_authenticator_key: "Authenticator Key",
txt_authorized_devices: "Authorized Devices",
txt_auto_copy_link_after_save: "Auto copy link after save",
txt_autofill_options: "Autofill Options",
txt_back_to_login: "Back To Login",
txt_ban: "Ban",
txt_boolean: "Boolean",
txt_brand: "Brand",
txt_bulk_delete_failed: "Bulk delete failed",
txt_bulk_delete_sends_failed: "Bulk delete sends failed",
txt_bulk_move_failed: "Bulk move failed",
txt_cancel: "Cancel",
txt_card: "Card",
txt_card_details: "Card Details",
txt_cardholder_name: "Cardholder Name",
txt_change_master_password: "Change Master Password",
txt_change_password: "Change Password",
txt_change_password_failed: "Change password failed",
txt_checked: "Checked",
txt_choose_destination_folder: "Choose destination folder.",
txt_chrome_browser: "Chrome Browser",
txt_chrome_extension: "Chrome Extension",
txt_city_town: "City / Town",
txt_code: "Code",
txt_company: "Company",
txt_configure_custom_field_values: "Configure custom field values.",
txt_confirm: "Confirm",
txt_confirm_master_password: "Confirm Master Password",
txt_confirm_password: "Confirm Password",
txt_copy: "Copy",
txt_copy_code: "Copy Code",
txt_copy_link: "Copy Link",
txt_copy_secret: "Copy Secret",
txt_country: "Country",
txt_create: "Create",
txt_create_account: "Create Account",
txt_create_folder: "Create Folder",
txt_create_folder_failed: "Create folder failed",
txt_create_item_failed: "Create item failed",
txt_create_send_failed: "Create send failed",
txt_create_timed_invite: "Create Timed Invite",
txt_created_value: "Created: {value}",
txt_current_new_password_is_required: "Current/new password is required",
txt_current_password: "Current Password",
txt_custom_fields: "Custom Fields",
txt_decrypt_failed: "(Decrypt failed)",
txt_decrypt_failed_2: "Decrypt failed",
txt_delete: "Delete",
txt_delete_all: "Delete All",
txt_delete_all_invite_codes_active_inactive: "Delete all invite codes (active/inactive)?",
txt_delete_all_invites: "Delete all invites",
txt_delete_item: "Delete Item",
txt_delete_item_failed: "Delete item failed",
txt_delete_selected: "Delete Selected",
txt_delete_selected_items: "Delete Selected Items",
txt_delete_send_failed: "Delete send failed",
txt_delete_this_user_and_all_user_data: "Delete this user and all user data?",
txt_delete_user: "Delete user",
txt_deleted_selected_items: "Deleted selected items",
txt_deleted_selected_sends: "Deleted selected sends",
txt_deletion_date: "Deletion Date",
txt_deletion_days: "Deletion Days",
txt_device: "Device",
txt_device_authorization_revoked: "Device authorization revoked",
txt_device_management: "Device Management",
txt_device_removed: "Device removed",
txt_disable_this_send: "Disable this send",
txt_disable_totp: "Disable TOTP",
txt_disable_totp_failed: "Disable TOTP failed",
txt_download: "Download",
txt_download_failed: "Download failed",
txt_edge_browser: "Edge Browser",
txt_edge_extension: "Edge Extension",
txt_edit: "Edit",
txt_edit_send: "Edit Send",
txt_email: "Email",
txt_email_password_and_recovery_code_are_required: "Email, password and recovery code are required",
txt_enable_totp: "Enable TOTP",
txt_enable_totp_failed: "Enable TOTP failed",
txt_enabled: "Enabled",
txt_encrypted_file: "Encrypted File",
txt_encrypted_file_2: "Encrypted file",
txt_enter_a_folder_name: "Enter a folder name.",
txt_enter_master_password_to_disable_two_step_verification: "Enter master password to disable two-step verification.",
txt_enter_master_password_to_view_this_item: "Enter master password to view this item.",
txt_expiration_date: "Expiration Date",
txt_expiration_days_0_never: "Expiration Days (0 = never)",
txt_expires_at: "Expires At",
txt_expires_at_value: "Expires at: {value}",
txt_expiry: "Expiry",
txt_expiry_month: "Expiry Month",
txt_expiry_year: "Expiry Year",
txt_failed_to_open_send: "Failed to open send",
txt_favorite: "Favorite",
txt_favorites: "Favorites",
txt_field: "Field",
txt_field_label: "Field Label",
txt_field_label_is_required: "Field label is required.",
txt_field_type: "Field Type",
txt_field_value: "Field Value",
txt_file: "File",
txt_file_name: "File Name",
txt_file_send: "File Send",
txt_file_size: "File Size",
txt_fingerprint: "Fingerprint",
txt_firefox_browser: "Firefox Browser",
txt_firefox_extension: "Firefox Extension",
txt_first_name: "First Name",
txt_folder: "Folder",
txt_folder_created: "Folder created",
txt_folder_name: "Folder Name",
txt_folder_name_is_required: "Folder name is required",
txt_folders: "Folders",
txt_hidden: "Hidden",
txt_hide: "Hide",
txt_identity: "Identity",
txt_identity_details: "Identity Details",
txt_ie_browser: "IE Browser",
txt_invite_code_optional: "Invite Code (Optional)",
txt_invite_created: "Invite created",
txt_invite_revoked: "Invite revoked",
txt_invite_validity_hours: "Invite validity (hours)",
txt_invites: "Invites",
txt_ios: "iOS",
txt_item: "Item",
txt_item_created: "Item created",
txt_item_deleted: "Item deleted",
txt_item_history: "Item History",
txt_item_name_is_required: "Item name is required.",
txt_item_updated: "Item updated",
txt_last_edited_value: "Last edited: {value}",
txt_last_name: "Last Name",
txt_last_seen: "Last Seen",
txt_license_number: "License Number",
txt_link_copied: "Link copied",
txt_linked: "Linked",
txt_linux_desktop: "Linux Desktop",
txt_loading: "Loading...",
txt_loading_nodewarden: "Loading NodeWarden...",
txt_log_in: "Log In",
txt_log_out: "Log Out",
txt_login: "Login",
txt_login_credentials: "Login Credentials",
txt_login_failed: "Login failed",
txt_login_success: "Login success",
txt_macos_desktop: "macOS Desktop",
txt_manage_authorized_devices_and_30_day_totp_trusted_sessions: "Manage authorized devices and 30-day TOTP trusted sessions.",
txt_master_password: "Master Password",
txt_master_password_changed_please_login_again: "Master password changed. Please login again.",
txt_master_password_is_required: "Master password is required",
txt_master_password_is_required_2: "Master password is required.",
txt_master_password_must_be_at_least_12_chars: "Master password must be at least 12 chars",
txt_master_password_reprompt: "Master password reprompt",
txt_master_password_reprompt_2: "Master Password Reprompt",
txt_max_access_count: "Max Access Count",
txt_middle_name: "Middle Name",
txt_move: "Move",
txt_move_selected_items: "Move Selected Items",
txt_moved_selected_items: "Moved selected items",
txt_name: "Name",
txt_name_is_required: "Name is required",
txt_new_password: "New Password",
txt_new_password_must_be_at_least_12_chars: "New password must be at least 12 chars",
txt_new_passwords_do_not_match: "New passwords do not match",
txt_new_send: "New Send",
txt_next: "Next",
txt_no: "No",
txt_no_devices_found: "No devices found.",
txt_no_folder: "No Folder",
txt_no_items: "No items",
txt_no_name: "(No Name)",
txt_no_sends: "No sends",
txt_nodewarden_send: "NodeWarden Send",
txt_not_trusted: "Not trusted",
txt_note: "Note",
txt_notes: "Notes",
txt_number: "Number",
txt_open: "Open",
txt_opera_browser: "Opera Browser",
txt_opera_extension: "Opera Extension",
txt_or: "or",
txt_options: "Options",
txt_passport_number: "Passport Number",
txt_password: "Password",
txt_password_is_already_verified: "Password is already verified.",
txt_passwords_do_not_match: "Passwords do not match",
txt_phone: "Phone",
txt_please_input_email_and_password: "Please input email and password",
txt_please_input_master_password: "Please input master password",
txt_please_input_totp_code: "Please input TOTP code",
txt_please_select_a_file: "Please select a file",
txt_postal_code: "Postal Code",
txt_prev: "Prev",
txt_private_key: "Private Key",
txt_profile: "Profile",
txt_profile_unavailable: "Profile unavailable",
txt_profile_updated: "Profile updated",
txt_public_key: "Public Key",
txt_recover_2fa_failed: "Recover 2FA failed",
txt_recover_two_step_login: "Recover Two-step Login",
txt_recovered_but_auto_login_failed_please_sign_in: "Recovered but auto-login failed, please sign in.",
txt_recovery_code: "Recovery Code",
txt_recovery_code_copied: "Recovery code copied",
txt_recovery_code_is_empty: "Recovery code is empty",
txt_recovery_code_loaded: "Recovery code loaded",
txt_refresh: "Refresh",
txt_refresh_in_seconds_s: "Refresh in {seconds}s",
txt_regenerate: "Regenerate",
txt_registration_succeeded_please_sign_in: "Registration succeeded. Please sign in.",
txt_remove: "Remove",
txt_remove_device: "Remove device",
txt_remove_device_2: "Remove Device",
txt_remove_device_name_and_clear_its_2fa_trust: "Remove device \"{name}\" and clear its 2FA trust?",
txt_reveal: "Reveal",
txt_revoke: "Revoke",
txt_revoke_30_day_totp_trust_for_name: "Revoke 30-day TOTP trust for \"{name}\"?",
txt_revoke_30_day_totp_trust_from_all_devices: "Revoke 30-day TOTP trust from all devices?",
txt_revoke_all_trusted: "Revoke All Trusted",
txt_revoke_all_trusted_devices: "Revoke all trusted devices",
txt_revoke_device_authorization: "Revoke device authorization",
txt_revoke_trust: "Revoke Trust",
txt_role: "Role",
txt_save: "Save",
txt_save_profile: "Save Profile",
txt_save_profile_failed: "Save profile failed",
txt_search_sends: "Search sends...",
txt_search_your_secure_vault: "Search your secure vault...",
txt_secret_and_code_are_required: "Secret and code are required",
txt_secret_copied: "Secret copied",
txt_secure_note: "Secure Note",
txt_security_code: "Security Code",
txt_security_code_cvv: "Security Code (CVV)",
txt_select_all: "Select All",
txt_select_an_item: "Select an item",
txt_send_created: "Send created",
txt_send_deleted: "Send deleted",
txt_send_details: "Send Details",
txt_send_file: "send-file",
txt_send_unavailable: "Send unavailable.",
txt_send_updated: "Send updated",
txt_sign_out: "Sign Out",
txt_ssh_key: "SSH Key",
txt_ssn: "SSN",
txt_state_province: "State / Province",
txt_status: "Status",
txt_submit: "Submit",
txt_sync: "Sync",
txt_sync_vault: "Sync Vault",
txt_dash: "-",
txt_text: "Text",
txt_text_2fa_recovered: "2FA recovered",
txt_text_2fa_recovered_new_recovery_code_code: "2FA recovered. New recovery code: {code}",
txt_text_3: "------",
txt_text_is_required: "Text is required",
txt_text_send: "Text Send",
txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically: "This is a one-time code. After it is used, a new code is generated automatically.",
txt_this_item_requires_master_password_every_time_before_viewing_details: "This item requires master password every time before viewing details.",
txt_this_link_is_missing_decryption_key: "This link is missing decryption key.",
txt_this_send_is_password_protected: "This send is password protected.",
txt_title: "Title",
txt_totp: "TOTP",
txt_totp_code: "TOTP Code",
txt_totp_disabled: "TOTP disabled",
txt_totp_enabled: "TOTP enabled",
txt_totp_is_enabled_for_this_account: "TOTP is enabled for this account.",
txt_totp_secret: "TOTP Secret",
txt_totp_verify_failed: "TOTP verify failed",
txt_trash: "Trash",
txt_trust_this_device_for_30_days: "Trust this device for 30 days",
txt_trusted_until: "Trusted Until",
txt_two_step_verification: "Two-step verification",
txt_type: "Type",
txt_type_type: "Type {type}",
txt_unban: "Unban",
txt_unchecked: "Unchecked",
txt_unknown_device: "Unknown device",
txt_unlock: "Unlock",
txt_unlock_details: "Unlock Details",
txt_unlock_failed: "Unlock failed",
txt_unlock_failed_master_password_is_incorrect: "Unlock failed. Master password is incorrect.",
txt_unlock_item: "Unlock Item",
txt_unlock_send: "Unlock Send",
txt_unlock_vault: "Unlock Vault",
txt_unlocked: "Unlocked",
txt_update_item_failed: "Update item failed",
txt_update_send_failed: "Update send failed",
txt_use_recovery_code: "Use Recovery Code",
txt_use_your_one_time_recovery_code_to_disable_two_step_verification: "Use your one-time recovery code to disable two-step verification.",
txt_user_deleted: "User deleted",
txt_user_status_updated: "User status updated",
txt_username: "Username",
txt_users: "Users",
txt_vault_synced: "Vault synced",
txt_verification_code: "Verification Code",
txt_verify: "Verify",
txt_view_recovery_code: "View Recovery Code",
txt_web: "Web",
txt_website: "Website",
txt_websites: "Websites",
txt_windows_desktop: "Windows Desktop",
txt_yes: "Yes",
},
'zh-CN': {},
};
const zhCNOverrides: Record<string, string> = {
nav_my_vault: '我的保险库',
nav_sends: 'Send',
nav_admin_panel: '管理面板',
nav_account_settings: '账户设置',
nav_device_management: '设备管理',
nav_support_center: '支持中心',
support_title: '支持中心',
support_under_construction: '正在搭建中。',
txt_sign_out: '退出登录',
txt_log_in: '登录',
txt_log_out: '退出',
txt_create_account: '创建账户',
txt_back_to_login: '返回登录',
txt_unlock: '解锁',
txt_unlock_vault: '解锁保险库',
txt_master_password: '主密码',
txt_email: '邮箱',
txt_name: '名称',
txt_password: '密码',
txt_confirm_password: '确认密码',
txt_confirm_master_password: '确认主密码',
txt_submit: '提交',
txt_cancel: '取消',
txt_yes: '是',
txt_no: '否',
txt_loading: '加载中...',
txt_loading_nodewarden: '正在加载 NodeWarden...',
txt_search_sends: '搜索发送...',
txt_search_your_secure_vault: '搜索你的保险库...',
txt_refresh: '刷新',
txt_sync: '同步',
txt_sync_vault: '同步保险库',
txt_add: '新增',
txt_edit: '编辑',
txt_delete: '删除',
txt_save: '保存',
txt_confirm: '确认',
txt_move: '移动',
txt_copy: '复制',
txt_copy_link: '复制链接',
txt_select_all: '全选',
txt_delete_selected: '删除所选',
txt_all_items: '所有项目',
txt_favorites: '收藏',
txt_trash: '回收站',
txt_folder: '文件夹',
txt_folders: '文件夹',
txt_no_folder: '无文件夹',
txt_no_items: '没有项目',
txt_no_sends: '没有发送',
txt_select_an_item: '请选择一个项目',
txt_login: '登录',
txt_card: '银行卡',
txt_identity: '身份',
txt_note: '笔记',
txt_secure_note: '安全笔记',
txt_ssh_key: 'SSH 密钥',
txt_login_credentials: '登录信息',
txt_card_details: '银行卡详情',
txt_identity_details: '身份详情',
txt_autofill_options: '自动填充选项',
txt_additional_options: '附加选项',
txt_custom_fields: '自定义字段',
txt_notes: '备注',
txt_item_history: '项目历史',
txt_last_edited_value: '最后编辑:{value}',
txt_created_value: '创建于:{value}',
txt_username: '用户名',
txt_website: '网站',
txt_websites: '网站',
txt_open: '打开',
txt_hide: '隐藏',
txt_reveal: '显示',
txt_favorite: '收藏',
txt_field: '字段',
txt_field_type: '字段类型',
txt_field_label: '字段标签',
txt_field_value: '字段值',
txt_add_field: '添加字段',
txt_remove: '移除',
txt_enabled: '已启用',
txt_checked: '已勾选',
txt_unchecked: '未勾选',
txt_profile: '资料',
txt_save_profile: '保存资料',
txt_change_master_password: '修改主密码',
txt_current_password: '当前密码',
txt_new_password: '新密码',
txt_change_password: '修改密码',
txt_totp: 'TOTP',
txt_enable_totp: '启用 TOTP',
txt_disable_totp: '停用 TOTP',
txt_totp_code: 'TOTP 验证码',
txt_totp_secret: 'TOTP 密钥',
txt_verification_code: '验证码',
txt_recovery_code: '恢复代码',
txt_view_recovery_code: '查看恢复代码',
txt_copy_code: '复制代码',
txt_device_management: '设备管理',
txt_authorized_devices: '已授权设备',
txt_device: '设备',
txt_last_seen: '最后在线',
txt_trusted_until: '信任至',
txt_revoke_trust: '撤销信任',
txt_remove_device_2: '移除设备',
txt_not_trusted: '未信任',
txt_unknown_device: '未知设备',
txt_users: '用户',
txt_invites: '邀请码',
txt_ban: '封禁',
txt_unban: '解封',
txt_create_timed_invite: '创建时效邀请码',
txt_invite_validity_hours: '邀请码有效期(小时)',
txt_delete_all: '全部删除',
txt_prev: '上一页',
txt_next: '下一页',
txt_send_details: '发送详情',
txt_new_send: '新建发送',
txt_edit_send: '编辑发送',
txt_file_send: '文件发送',
txt_text_send: '文本发送',
txt_file: '文件',
txt_text: '文本',
txt_file_name: '文件名',
txt_file_size: '文件大小',
txt_access_count: '访问次数',
txt_deletion_date: '删除日期',
txt_expiration_date: '过期日期',
txt_deletion_days: '删除天数',
txt_expiration_days_0_never: '过期天数(0 表示不过期)',
txt_max_access_count: '最大访问次数',
txt_options: '选项',
txt_disable_this_send: '禁用此发送',
txt_auto_copy_link_after_save: '保存后自动复制链接',
txt_unlock_send: '解锁发送',
txt_nodewarden_send: 'NodeWarden 发送',
txt_send_unavailable: '发送不可用。',
txt_download: '下载',
txt_expires_at: '过期时间',
txt_expires_at_value: '过期于:{value}',
txt_dash: '-',
txt_or: '或',
txt_no_name: '(无名称)',
txt_are_you_sure_you_want_to_log_out: '确认要退出登录吗?',
txt_delete_item: '删除项目',
txt_delete_selected_items: '删除所选项目',
txt_move_selected_items: '移动所选项目',
txt_create_folder: '创建文件夹',
txt_folder_name: '文件夹名称',
txt_unlock_item: '解锁项目',
txt_use_recovery_code: '使用恢复代码',
txt_two_step_verification: '两步验证',
txt_recover_two_step_login: '恢复两步登录',
txt_title: '称谓',
txt_first_name: '名',
txt_middle_name: '中间名',
txt_last_name: '姓',
txt_company: '公司',
txt_ssn: '社保号',
txt_passport_number: '护照号',
txt_license_number: '证件号',
txt_private_key: '私钥',
txt_public_key: '公钥',
txt_fingerprint: '指纹',
txt_master_password_reprompt: '主密码二次确认',
txt_master_password_reprompt_2: '主密码二次确认',
txt_configure_custom_field_values: '配置自定义字段值。',
txt_hidden: '隐藏',
txt_boolean: '布尔',
txt_regenerate: '重新生成',
txt_copy_secret: '复制密钥',
txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically: '这是一次性恢复代码,使用后将自动生成新的恢复代码。',
txt_manage_authorized_devices_and_30_day_totp_trusted_sessions: '管理已授权设备和 30 天 TOTP 受信会话。',
txt_role: '角色',
txt_status: '状态',
txt_actions: '操作',
txt_type: '类型',
txt_revoke_all_trusted: '撤销全部受信任设备',
txt_revoke_all_trusted_devices: '撤销所有受信任设备',
txt_revoke_30_day_totp_trust_from_all_devices: '确认撤销所有设备的 30 天 TOTP 信任吗?',
txt_revoke_30_day_totp_trust_for_name: '确认撤销“{name}”的 30 天 TOTP 信任吗?',
txt_remove_device_name_and_clear_its_2fa_trust: '确认移除设备“{name}”并清除其 2FA 信任吗?',
txt_role_admin: '管理员',
txt_role_user: '用户',
txt_status_active: '正常',
txt_status_banned: '已封禁',
txt_status_inactive: '未激活',
txt_accessed_count_times: '已访问 {count} 次',
txt_add_website: '添加网站',
txt_added: '已添加',
txt_address: '地址',
txt_address_1: '地址 1',
txt_address_2: '地址 2',
txt_address_3: '地址 3',
txt_all_device_authorizations_revoked: '已撤销所有设备授权',
txt_all_invites_deleted: '已删除所有邀请码',
txt_all_sends: '所有发送',
txt_android: '安卓',
txt_are_you_sure_you_want_to_delete_count_selected_items: '确认删除所选的 {count} 个项目?',
txt_are_you_sure_you_want_to_delete_this_item: '确认删除此项目?',
txt_authenticator_key: '验证器密钥',
txt_brand: '品牌',
txt_bulk_delete_failed: '批量删除失败',
txt_bulk_delete_sends_failed: '批量删除发送失败',
txt_bulk_move_failed: '批量移动失败',
txt_cardholder_name: '持卡人姓名',
txt_change_password_failed: '修改密码失败',
txt_choose_destination_folder: '选择目标文件夹。',
txt_chrome_browser: 'Chrome 浏览器',
txt_chrome_extension: 'Chrome 扩展',
txt_city_town: '城市 / 城镇',
txt_code: '代码',
txt_country: '国家',
txt_create: '创建',
txt_create_folder_failed: '创建文件夹失败',
txt_create_item_failed: '创建项目失败',
txt_create_send_failed: '创建发送失败',
txt_current_new_password_is_required: '需要输入当前密码和新密码',
txt_decrypt_failed: '(解密失败)',
txt_decrypt_failed_2: '解密失败',
txt_delete_all_invite_codes_active_inactive: '删除所有邀请码(有效/无效)?',
txt_delete_all_invites: '删除所有邀请码',
txt_delete_item_failed: '删除项目失败',
txt_delete_send_failed: '删除发送失败',
txt_delete_this_user_and_all_user_data: '删除此用户及其所有数据?',
txt_delete_user: '删除用户',
txt_deleted_selected_items: '已删除所选项目',
txt_deleted_selected_sends: '已删除所选发送',
txt_device_authorization_revoked: '已撤销设备授权',
txt_device_removed: '设备已移除',
txt_disable_totp_failed: '禁用 TOTP 失败',
txt_download_failed: '下载失败',
txt_edge_browser: 'Edge 浏览器',
txt_edge_extension: 'Edge 扩展',
txt_email_password_and_recovery_code_are_required: '需要输入邮箱、密码和恢复代码',
txt_enable_totp_failed: '启用 TOTP 失败',
txt_encrypted_file: '加密文件',
txt_encrypted_file_2: '加密文件',
txt_enter_a_folder_name: '请输入文件夹名称',
txt_enter_master_password_to_disable_two_step_verification: '输入主密码以禁用两步验证',
txt_enter_master_password_to_view_this_item: '输入主密码以查看此项目',
txt_expiry: '有效期',
txt_expiry_month: '有效期月',
txt_expiry_year: '有效期年',
txt_failed_to_open_send: '打开发送失败',
txt_field_label_is_required: '字段标签不能为空',
txt_firefox_browser: 'Firefox 浏览器',
txt_firefox_extension: 'Firefox 扩展',
txt_folder_created: '文件夹已创建',
txt_folder_name_is_required: '文件夹名称不能为空',
txt_ie_browser: 'IE 浏览器',
txt_invite_code_optional: '邀请码(可选)',
txt_invite_created: '邀请码已创建',
txt_invite_revoked: '邀请码已撤销',
txt_ios: 'iOS',
txt_item: '项目',
txt_item_created: '项目已创建',
txt_item_deleted: '项目已删除',
txt_item_name_is_required: '项目名称不能为空',
txt_item_updated: '项目已更新',
txt_link_copied: '链接已复制',
txt_linked: '已关联',
txt_linux_desktop: 'Linux 桌面端',
txt_login_failed: '登录失败',
txt_login_success: '登录成功',
txt_macos_desktop: 'macOS 桌面端',
txt_master_password_changed_please_login_again: '主密码已修改,请重新登录',
txt_master_password_is_required: '主密码不能为空',
txt_master_password_is_required_2: '请输入主密码',
txt_master_password_must_be_at_least_12_chars: '主密码至少需要 12 个字符',
txt_moved_selected_items: '已移动所选项目',
txt_name_is_required: '名称不能为空',
txt_new_password_must_be_at_least_12_chars: '新密码至少需要 12 个字符',
txt_new_passwords_do_not_match: '两次输入的新密码不一致',
txt_no_devices_found: '未找到设备',
txt_number: '数字',
txt_opera_browser: 'Opera 浏览器',
txt_opera_extension: 'Opera 扩展',
txt_password_is_already_verified: '密码已验证',
txt_passwords_do_not_match: '两次输入的密码不一致',
txt_phone: '电话',
txt_please_input_email_and_password: '请输入邮箱和密码',
txt_please_input_master_password: '请输入主密码',
txt_please_input_totp_code: '请输入 TOTP 验证码',
txt_please_select_a_file: '请选择文件',
txt_postal_code: '邮政编码',
txt_profile_unavailable: '资料不可用',
txt_profile_updated: '资料已更新',
txt_recover_2fa_failed: '恢复 2FA 失败',
txt_recovered_but_auto_login_failed_please_sign_in: '已恢复,但自动登录失败,请手动登录',
txt_recovery_code_copied: '恢复代码已复制',
txt_recovery_code_is_empty: '恢复代码为空',
txt_recovery_code_loaded: '恢复代码已加载',
txt_refresh_in_seconds_s: '{seconds} 秒后刷新',
txt_registration_succeeded_please_sign_in: '注册成功,请登录',
txt_remove_device: '移除设备',
txt_revoke: '撤销',
txt_revoke_device_authorization: '撤销设备授权',
txt_save_profile_failed: '保存资料失败',
txt_secret_and_code_are_required: '密钥和代码不能为空',
txt_secret_copied: '密钥已复制',
txt_security_code: '安全码',
txt_security_code_cvv: '安全码 (CVV)',
txt_send_created: '发送已创建',
txt_send_deleted: '发送已删除',
txt_send_file: '发送文件',
txt_send_updated: '发送已更新',
txt_state_province: '省 / 州',
txt_text_2fa_recovered: '2FA 已恢复',
txt_text_2fa_recovered_new_recovery_code_code: '2FA 已恢复,新的恢复代码:{code}',
txt_text_3: '------',
txt_text_is_required: '文本不能为空',
txt_this_item_requires_master_password_every_time_before_viewing_details: '每次查看详情前均需输入主密码',
txt_this_link_is_missing_decryption_key: '此链接缺少解密密钥',
txt_this_send_is_password_protected: '此发送受密码保护',
txt_totp_disabled: 'TOTP 已禁用',
txt_totp_enabled: 'TOTP 已启用',
txt_totp_is_enabled_for_this_account: '此账户已启用 TOTP。',
txt_totp_verify_failed: 'TOTP 验证失败',
txt_trust_this_device_for_30_days: '信任此设备 30 天',
txt_type_type: '类型 {type}',
txt_unlock_details: '解锁详情',
txt_unlock_failed: '解锁失败',
txt_unlock_failed_master_password_is_incorrect: '解锁失败,主密码不正确。',
txt_unlocked: '已解锁',
txt_update_item_failed: '更新项目失败',
txt_update_send_failed: '更新发送失败',
txt_use_your_one_time_recovery_code_to_disable_two_step_verification: '使用一次性恢复代码禁用两步验证。',
txt_user_deleted: '用户已删除',
txt_user_status_updated: '用户状态已更新',
txt_vault_synced: '保险库已同步',
txt_verify: '验证',
txt_web: '网页',
txt_windows_desktop: 'Windows 桌面端',
};
messages['zh-CN'] = { ...messages.en, ...zhCNOverrides };
function resolveInitialLocale(): Locale {
try {
const saved = localStorage.getItem(LOCALE_STORAGE_KEY);
if (saved === 'en' || saved === 'zh-CN') return saved;
} catch {
// ignore storage errors
}
if (typeof navigator !== 'undefined') {
const langs = Array.isArray(navigator.languages) ? navigator.languages : [navigator.language];
for (const lang of langs) {
if (String(lang || '').toLowerCase().startsWith('zh')) return 'zh-CN';
}
}
return 'en';
}
let locale: Locale = resolveInitialLocale();
export type I18nParams = Record<string, string | number | null | undefined>;
export function t(key: string, params?: I18nParams): string {
const template = messages[locale][key] ?? key;
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? ''));
}
export function getLocale(): Locale {
return locale;
}
export function setLocale(next: Locale): void {
locale = next;
try {
localStorage.setItem(LOCALE_STORAGE_KEY, next);
} catch {
// ignore storage errors
}
}
+80 -5
View File
@@ -43,6 +43,7 @@ html {
display: grid;
place-items: center;
padding: 24px;
background: #e9edf3;
}
.public-send-page {
@@ -52,11 +53,11 @@ html {
}
.auth-card {
width: min(640px, 100%);
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: 0 10px 32px rgba(15, 23, 42, 0.08);
width: 100%;
background: #f5f7fb;
border: 1px solid #d5dce7;
border-radius: 16px;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
padding: 28px;
}
@@ -65,6 +66,67 @@ html {
text-align: center;
}
.standalone-shell {
width: min(640px, 100%);
display: grid;
gap: 12px;
}
.standalone-brand {
display: inline-flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.standalone-brand-outside {
justify-content: center;
width: 100%;
margin-bottom: 2px;
}
.standalone-brand-logo {
width: 56px;
height: 56px;
object-fit: contain;
flex-shrink: 0;
}
.standalone-brand-title {
font-size: 42px;
font-weight: 800;
line-height: 1;
color: #1e293b;
}
.standalone-title {
margin: 0 0 4px 0;
text-align: left;
font-size: 30px;
line-height: 1.15;
}
.standalone-muted {
text-align: left;
}
.standalone-footer {
width: 100%;
text-align: center;
font-size: 13px;
color: #64748b;
}
.standalone-footer a {
color: #1d4ed8;
font-weight: 700;
text-decoration: none;
}
.standalone-footer a:hover {
text-decoration: underline;
}
.muted {
margin: 0 0 16px 0;
text-align: center;
@@ -1247,4 +1309,17 @@ input[type='file'].input::file-selector-button:hover {
.settings-twofactor-grid {
grid-template-columns: 1fr;
}
.standalone-title {
font-size: 24px;
}
.standalone-brand-title {
font-size: 32px;
}
.standalone-footer {
font-size: 12px;
line-height: 1.4;
}
}