mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat(ciphers): add bulk restore and permanent delete functionality for ciphers
style: enhance list count display in VaultPage and styles fix(i18n): add translations for bulk restore and permanent delete messages
This commit is contained in:
@@ -26,6 +26,8 @@ import {
|
||||
deleteCipherAttachment,
|
||||
deleteFolder,
|
||||
bulkDeleteCiphers,
|
||||
bulkPermanentDeleteCiphers,
|
||||
bulkRestoreCiphers,
|
||||
bulkDeleteSends,
|
||||
createCipher,
|
||||
createAuthedFetch,
|
||||
@@ -1345,6 +1347,28 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkRestoreVaultItems(ids: string[]) {
|
||||
try {
|
||||
await bulkRestoreCiphers(authedFetch, ids);
|
||||
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
||||
pushToast('success', t('txt_restored_selected_items'));
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkPermanentDeleteVaultItems(ids: string[]) {
|
||||
try {
|
||||
await bulkPermanentDeleteCiphers(authedFetch, ids);
|
||||
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
||||
pushToast('success', t('txt_deleted_selected_items_permanently'));
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDeleteFoldersAction(ids: string[]) {
|
||||
const folderIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!folderIds.length) return;
|
||||
@@ -2056,6 +2080,8 @@ export default function App() {
|
||||
onUpdate={updateVaultItem}
|
||||
onDelete={deleteVaultItem}
|
||||
onBulkDelete={bulkDeleteVaultItems}
|
||||
onBulkPermanentDelete={bulkPermanentDeleteVaultItems}
|
||||
onBulkRestore={bulkRestoreVaultItems}
|
||||
onBulkMove={bulkMoveVaultItems}
|
||||
onVerifyMasterPassword={verifyMasterPasswordAction}
|
||||
onNotify={pushToast}
|
||||
|
||||
@@ -46,6 +46,8 @@ interface VaultPageProps {
|
||||
onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
|
||||
onDelete: (cipher: Cipher) => Promise<void>;
|
||||
onBulkDelete: (ids: string[]) => Promise<void>;
|
||||
onBulkPermanentDelete: (ids: string[]) => Promise<void>;
|
||||
onBulkRestore: (ids: string[]) => Promise<void>;
|
||||
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
|
||||
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
@@ -679,6 +681,7 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
() => Object.values(selectedMap).reduce((sum, v) => sum + (v ? 1 : 0), 0),
|
||||
[selectedMap]
|
||||
);
|
||||
const totalCipherCount = filteredCiphers.length;
|
||||
|
||||
function folderName(id: string | null | undefined): string {
|
||||
if (!id) return t('txt_no_folder');
|
||||
@@ -877,7 +880,11 @@ function folderName(id: string | null | undefined): string {
|
||||
if (!ids.length) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await props.onBulkDelete(ids);
|
||||
if (sidebarFilter.kind === 'trash') {
|
||||
await props.onBulkPermanentDelete(ids);
|
||||
} else {
|
||||
await props.onBulkDelete(ids);
|
||||
}
|
||||
setSelectedMap({});
|
||||
setBulkDeleteOpen(false);
|
||||
} finally {
|
||||
@@ -958,6 +965,20 @@ function folderName(id: string | null | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBulkRestore(): Promise<void> {
|
||||
const ids = Object.entries(selectedMap)
|
||||
.filter(([, selected]) => selected)
|
||||
.map(([id]) => id);
|
||||
if (!ids.length) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await props.onBulkRestore(ids);
|
||||
setSelectedMap({});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteAllFolders(): Promise<void> {
|
||||
if (!props.folders.length) return;
|
||||
setBusy(true);
|
||||
@@ -1111,13 +1132,16 @@ function folderName(id: string | null | undefined): string {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="list-count" title={t('txt_total_items_count', { count: totalCipherCount })}>
|
||||
{t('txt_total_items_count', { count: totalCipherCount })}
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={busy || props.loading} onClick={() => void syncVault()}>
|
||||
<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" /> {t('txt_delete_selected')}
|
||||
<Trash2 size={14} className="btn-icon" /> {sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1152,7 +1176,12 @@ function folderName(id: string | null | undefined): string {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
{selectedCount > 0 && sidebarFilter.kind === 'trash' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={() => void confirmBulkRestore()}>
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
||||
</button>
|
||||
)}
|
||||
{selectedCount > 0 && sidebarFilter.kind !== 'trash' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
@@ -1969,8 +1998,12 @@ function folderName(id: string | null | undefined): string {
|
||||
|
||||
<ConfirmDialog
|
||||
open={bulkDeleteOpen}
|
||||
title={t('txt_delete_selected_items')}
|
||||
message={t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: selectedCount })}
|
||||
title={sidebarFilter.kind === 'trash' ? t('txt_delete_selected_items_permanently') : t('txt_delete_selected_items')}
|
||||
message={
|
||||
sidebarFilter.kind === 'trash'
|
||||
? t('txt_are_you_sure_you_want_to_delete_count_selected_items_permanently', { count: selectedCount })
|
||||
: t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: selectedCount })
|
||||
}
|
||||
danger
|
||||
onConfirm={() => void confirmBulkDelete()}
|
||||
onCancel={() => setBulkDeleteOpen(false)}
|
||||
|
||||
+164
-47
@@ -22,9 +22,19 @@ import type {
|
||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
|
||||
const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
|
||||
const BULK_API_CHUNK_SIZE = 200;
|
||||
|
||||
type SessionSetter = (next: SessionState | null) => void;
|
||||
|
||||
function chunkArray<T>(items: T[], size: number): T[][] {
|
||||
if (items.length <= size) return [items];
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
chunks.push(items.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function loadSession(): SessionState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
@@ -396,12 +406,15 @@ export async function bulkDeleteFolders(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||
ids: string[]
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch('/api/folders/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Bulk delete folders failed');
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
|
||||
const resp = await authedFetch('/api/folders/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: chunk }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Bulk delete folders failed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFolder(
|
||||
@@ -450,31 +463,96 @@ export async function importCiphers(
|
||||
): Promise<ImportedCipherMapEntry[] | null> {
|
||||
const returnCipherMap = !!options?.returnCipherMap;
|
||||
const url = returnCipherMap ? '/api/ciphers/import?returnCipherMap=1' : '/api/ciphers/import';
|
||||
const resp = await authedFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Import failed'));
|
||||
if (!returnCipherMap) return null;
|
||||
const body =
|
||||
(await parseJson<{
|
||||
cipherMap?: Array<{ index?: number; sourceId?: string | null; id?: string }>;
|
||||
}>(resp)) || {};
|
||||
if (!Array.isArray(body.cipherMap)) return [];
|
||||
const out: ImportedCipherMapEntry[] = [];
|
||||
for (const row of body.cipherMap) {
|
||||
const index = Number(row?.index);
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!Number.isFinite(index) || !id) continue;
|
||||
const sourceRaw = String(row?.sourceId || '').trim();
|
||||
out.push({
|
||||
index,
|
||||
id,
|
||||
sourceId: sourceRaw || null,
|
||||
const totalItems = (payload.folders?.length || 0) + (payload.ciphers?.length || 0);
|
||||
const responses: ImportedCipherMapEntry[] = [];
|
||||
const folderChunkSize = Math.min(BULK_API_CHUNK_SIZE, Math.max(0, BULK_API_CHUNK_SIZE - 1));
|
||||
|
||||
if (totalItems <= BULK_API_CHUNK_SIZE || payload.folders.length > folderChunkSize) {
|
||||
const resp = await authedFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Import failed'));
|
||||
if (!returnCipherMap) return null;
|
||||
const body =
|
||||
(await parseJson<{
|
||||
cipherMap?: Array<{ index?: number; sourceId?: string | null; id?: string }>;
|
||||
}>(resp)) || {};
|
||||
if (!Array.isArray(body.cipherMap)) return [];
|
||||
for (const row of body.cipherMap) {
|
||||
const index = Number(row?.index);
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!Number.isFinite(index) || !id) continue;
|
||||
const sourceRaw = String(row?.sourceId || '').trim();
|
||||
responses.push({
|
||||
index,
|
||||
id,
|
||||
sourceId: sourceRaw || null,
|
||||
});
|
||||
}
|
||||
return responses;
|
||||
}
|
||||
return out;
|
||||
|
||||
const folders = payload.folders || [];
|
||||
const relationshipsByCipher = new Map<number, number | null>();
|
||||
for (const relation of payload.folderRelationships || []) {
|
||||
relationshipsByCipher.set(Number(relation.key), Number(relation.value));
|
||||
}
|
||||
|
||||
for (const cipherChunkStart of Array.from({ length: Math.ceil(payload.ciphers.length / BULK_API_CHUNK_SIZE) }, (_, i) => i * BULK_API_CHUNK_SIZE)) {
|
||||
const cipherChunk = payload.ciphers.slice(cipherChunkStart, cipherChunkStart + BULK_API_CHUNK_SIZE);
|
||||
const usedFolderIndices = Array.from(
|
||||
new Set(
|
||||
cipherChunk
|
||||
.map((_, localIndex) => relationshipsByCipher.get(cipherChunkStart + localIndex))
|
||||
.filter((value): value is number => Number.isFinite(value as number) && (value as number) >= 0)
|
||||
)
|
||||
);
|
||||
const folderIndexMap = new Map<number, number>();
|
||||
const chunkFolders = usedFolderIndices.map((folderIndex, localIndex) => {
|
||||
folderIndexMap.set(folderIndex, localIndex);
|
||||
return folders[folderIndex];
|
||||
});
|
||||
const chunkRelationships = cipherChunk
|
||||
.map((_, localIndex) => {
|
||||
const originalCipherIndex = cipherChunkStart + localIndex;
|
||||
const originalFolderIndex = relationshipsByCipher.get(originalCipherIndex);
|
||||
if (!Number.isFinite(originalFolderIndex as number)) return null;
|
||||
const localFolderIndex = folderIndexMap.get(Number(originalFolderIndex));
|
||||
if (!Number.isFinite(localFolderIndex as number)) return null;
|
||||
return { key: localIndex, value: Number(localFolderIndex) };
|
||||
})
|
||||
.filter((value): value is { key: number; value: number } => !!value);
|
||||
|
||||
const resp = await authedFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ciphers: cipherChunk,
|
||||
folders: chunkFolders,
|
||||
folderRelationships: chunkRelationships,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Import failed'));
|
||||
if (!returnCipherMap) continue;
|
||||
const body =
|
||||
(await parseJson<{
|
||||
cipherMap?: Array<{ index?: number; sourceId?: string | null; id?: string }>;
|
||||
}>(resp)) || {};
|
||||
for (const row of body.cipherMap || []) {
|
||||
const localIndex = Number(row?.index);
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!Number.isFinite(localIndex) || !id) continue;
|
||||
const sourceRaw = String(row?.sourceId || '').trim();
|
||||
responses.push({
|
||||
index: cipherChunkStart + localIndex,
|
||||
id,
|
||||
sourceId: sourceRaw || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return returnCipherMap ? responses : null;
|
||||
}
|
||||
|
||||
export interface AttachmentDownloadInfo {
|
||||
@@ -1170,12 +1248,45 @@ export async function bulkDeleteCiphers(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||
ids: string[]
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch('/api/ciphers/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Bulk delete failed');
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
|
||||
const resp = await authedFetch('/api/ciphers/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: chunk }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Bulk delete failed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkPermanentDeleteCiphers(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||
ids: string[]
|
||||
): Promise<void> {
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
|
||||
const resp = await authedFetch('/api/ciphers/delete-permanent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: chunk }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Bulk permanent delete failed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkRestoreCiphers(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||
ids: string[]
|
||||
): Promise<void> {
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
|
||||
const resp = await authedFetch('/api/ciphers/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: chunk }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Bulk restore failed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkMoveCiphers(
|
||||
@@ -1183,12 +1294,15 @@ export async function bulkMoveCiphers(
|
||||
ids: string[],
|
||||
folderId: string | null
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch('/api/ciphers/move', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids, folderId }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Bulk move failed');
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
|
||||
const resp = await authedFetch('/api/ciphers/move', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: chunk, folderId }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Bulk move failed');
|
||||
}
|
||||
}
|
||||
|
||||
function toIsoDateFromDays(value: string, required: boolean): string | null {
|
||||
@@ -1416,12 +1530,15 @@ export async function bulkDeleteSends(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||
ids: string[]
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch('/api/sends/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Bulk delete sends failed');
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
|
||||
const resp = await authedFetch('/api/sends/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: chunk }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Bulk delete sends failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function buildPublicSendAccessPayload(password?: string, keyPart?: string | null): Promise<Record<string, unknown>> {
|
||||
|
||||
@@ -50,6 +50,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
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_count_selected_items_permanently: "Are you sure you want to permanently 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",
|
||||
@@ -61,6 +62,8 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_boolean: "Boolean",
|
||||
txt_brand: "Brand",
|
||||
txt_bulk_delete_failed: "Bulk delete failed",
|
||||
txt_bulk_permanent_delete_failed: "Bulk permanent delete failed",
|
||||
txt_bulk_restore_failed: "Bulk restore failed",
|
||||
txt_bulk_delete_sends_failed: "Bulk delete sends failed",
|
||||
txt_bulk_move_failed: "Bulk move failed",
|
||||
txt_cancel: "Cancel",
|
||||
@@ -106,12 +109,16 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_delete_all_invites: "Delete all invites",
|
||||
txt_delete_item: "Delete Item",
|
||||
txt_delete_item_failed: "Delete item failed",
|
||||
txt_delete_permanently: "Delete Permanently",
|
||||
txt_delete_selected: "Delete Selected",
|
||||
txt_delete_selected_items: "Delete Selected Items",
|
||||
txt_delete_selected_items_permanently: "Delete Selected Items Permanently",
|
||||
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_items_permanently: "Permanently deleted selected items",
|
||||
txt_restored_selected_items: "Restored selected items",
|
||||
txt_deleted_selected_sends: "Deleted selected sends",
|
||||
txt_deletion_date: "Deletion Date",
|
||||
txt_deletion_days: "Deletion Days",
|
||||
@@ -302,6 +309,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_remove_device_name_and_clear_its_2fa_trust: "Remove device \"{name}\" and clear its 2FA trust?",
|
||||
txt_remove_device_and_sign_out_name: "Remove device \"{name}\", clear its trust, and sign it out?",
|
||||
txt_reveal: "Reveal",
|
||||
txt_restore: "Restore",
|
||||
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?",
|
||||
@@ -361,6 +369,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_totp_disabled: "TOTP disabled",
|
||||
txt_totp_enabled: "TOTP enabled",
|
||||
txt_totp_is_enabled_for_this_account: "TOTP is enabled for this account.",
|
||||
txt_total_items_count: "{count} items",
|
||||
txt_totp_secret: "TOTP Secret",
|
||||
txt_totp_verify_failed: "TOTP verify failed",
|
||||
txt_passkey: "Passkey",
|
||||
@@ -507,6 +516,7 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_open: '打开',
|
||||
txt_hide: '隐藏',
|
||||
txt_reveal: '显示',
|
||||
txt_restore: '恢复',
|
||||
txt_favorite: '收藏',
|
||||
txt_field: '字段',
|
||||
txt_field_type: '字段类型',
|
||||
@@ -640,10 +650,13 @@ const zhCNOverrides: Record<string, string> = {
|
||||
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_count_selected_items_permanently: '确认永久删除所选的 {count} 个项目?',
|
||||
txt_are_you_sure_you_want_to_delete_this_item: '确认删除此项目?',
|
||||
txt_authenticator_key: '验证器密钥',
|
||||
txt_brand: '品牌',
|
||||
txt_bulk_delete_failed: '批量删除失败',
|
||||
txt_bulk_permanent_delete_failed: '批量永久删除失败',
|
||||
txt_bulk_restore_failed: '批量恢复失败',
|
||||
txt_bulk_delete_sends_failed: '批量删除发送失败',
|
||||
txt_bulk_move_failed: '批量移动失败',
|
||||
txt_cardholder_name: '持卡人姓名',
|
||||
@@ -664,10 +677,13 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_delete_all_invite_codes_active_inactive: '删除所有邀请码(有效/无效)?',
|
||||
txt_delete_all_invites: '删除所有邀请码',
|
||||
txt_delete_item_failed: '删除项目失败',
|
||||
txt_delete_permanently: '永久删除',
|
||||
txt_delete_send_failed: '删除发送失败',
|
||||
txt_delete_this_user_and_all_user_data: '删除此用户及其所有数据?',
|
||||
txt_delete_user: '删除用户',
|
||||
txt_deleted_selected_items: '已删除所选项目',
|
||||
txt_deleted_selected_items_permanently: '已永久删除所选项目',
|
||||
txt_restored_selected_items: '已恢复所选项目',
|
||||
txt_deleted_selected_sends: '已删除所选发送',
|
||||
txt_device_authorization_revoked: '设备信任已撤销',
|
||||
txt_device_removed: '设备已移除',
|
||||
@@ -766,6 +782,7 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_totp_disabled: 'TOTP 已禁用',
|
||||
txt_totp_enabled: 'TOTP 已启用',
|
||||
txt_totp_is_enabled_for_this_account: '此账户已启用 TOTP。',
|
||||
txt_total_items_count: '共 {count} 项',
|
||||
txt_totp_verify_failed: 'TOTP 验证失败',
|
||||
txt_trust_this_device_for_30_days: '信任此设备 30 天',
|
||||
txt_type_type: '类型 {type}',
|
||||
|
||||
@@ -733,6 +733,13 @@ input[type='file'].input::file-selector-button:hover {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list-count {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list-icon-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -2195,6 +2202,12 @@ input[type='file'].input::file-selector-button:hover {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-count {
|
||||
order: 10;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.list-head .search-input {
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
|
||||
Reference in New Issue
Block a user