mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat(i18n): add internationalization support with English and Chinese translations
This commit is contained in:
+99
-98
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user