diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index f93670d..9b5895d 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -766,6 +766,14 @@ export default function App() { ), }; } + if (Array.isArray(cipher.passwordHistory)) { + nextCipher.passwordHistory = await Promise.all( + cipher.passwordHistory.map(async (entry) => ({ + ...entry, + decPassword: await decryptField(entry?.password || '', itemEnc, itemMac), + })) + ); + } if (cipher.card) { nextCipher.card = { ...cipher.card, diff --git a/webapp/src/components/vault/VaultDetailView.tsx b/webapp/src/components/vault/VaultDetailView.tsx index 80b3884..114b205 100644 --- a/webapp/src/components/vault/VaultDetailView.tsx +++ b/webapp/src/components/vault/VaultDetailView.tsx @@ -1,5 +1,7 @@ -import { useState } from 'preact/hooks'; -import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2 } from 'lucide-preact'; +import { createPortal } from 'preact/compat'; +import { useMemo, useState } from 'preact/hooks'; +import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact'; +import { useDialogLifecycle } from '@/components/ConfirmDialog'; import type { Cipher } from '@/lib/types'; import { t } from '@/lib/i18n'; import { @@ -35,10 +37,60 @@ interface VaultDetailViewProps { onUnarchive: (cipher: Cipher) => void | Promise; } +function PasswordHistoryDialog(props: { + open: boolean; + entries: Array<{ password: string; lastUsedDate: string | null }>; + onClose: () => void; +}) { + useDialogLifecycle(props.open, props.onClose); + + if (!props.open || typeof document === 'undefined') return null; + return createPortal( +
event.target === event.currentTarget && props.onClose()}> +
+
+

{t('txt_password_history')}

+ +
+
+ {props.entries.map((entry, index) => ( +
+
+ +
+
{entry.password}
+
{formatHistoryTime(entry.lastUsedDate)}
+
+ ))} +
+ +
+
, + document.body + ); +} + export default function VaultDetailView(props: VaultDetailViewProps) { const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : []; const [showSshPrivateKey, setShowSshPrivateKey] = useState(false); + const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false); const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt); + const passwordHistoryEntries = useMemo( + () => + (props.selectedCipher.passwordHistory || []) + .map((entry) => ({ + password: String(entry?.decPassword || entry?.password || ''), + lastUsedDate: entry?.lastUsedDate ?? null, + })) + .filter((entry) => entry.password.trim()), + [props.selectedCipher.passwordHistory] + ); const formatDownloadLabel = (attachmentId: string) => { const downloadKey = `${props.selectedCipher.id}:${attachmentId}`; if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download'); @@ -355,6 +407,14 @@ export default function VaultDetailView(props: VaultDetailViewProps) {

{t('txt_item_history')}

{t('txt_last_edited_value', { value: formatHistoryTime(props.selectedCipher.revisionDate) })}
{t('txt_created_value', { value: formatHistoryTime(props.selectedCipher.creationDate) })}
+ {!!props.selectedCipher.login?.passwordRevisionDate && ( +
{t('txt_password_updated_value', { value: formatHistoryTime(props.selectedCipher.login.passwordRevisionDate) })}
+ )} + {passwordHistoryEntries.length > 0 && ( + + )} )} @@ -379,6 +439,11 @@ export default function VaultDetailView(props: VaultDetailViewProps) { )} + setPasswordHistoryOpen(false)} + /> ); } diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 2e457cd..2054fa0 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -1,6 +1,7 @@ import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData } from '../crypto'; import type { Cipher, + CipherPasswordHistoryEntry, Folder, SessionState, VaultDraft, @@ -346,6 +347,61 @@ async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array) return encryptBw(new TextEncoder().encode(s), enc, mac); } +async function encryptPasswordHistory( + entries: CipherPasswordHistoryEntry[] | null | undefined, + enc: Uint8Array, + mac: Uint8Array +): Promise { + if (!Array.isArray(entries) || entries.length === 0) return null; + + const out: CipherPasswordHistoryEntry[] = []; + for (const entry of entries) { + const rawPassword = String(entry?.password || ''); + const plainPassword = entry?.decPassword ?? rawPassword; + const encryptedPassword = looksLikeCipherString(rawPassword) + ? rawPassword + : await encryptTextValue(plainPassword, enc, mac); + if (!encryptedPassword) continue; + out.push({ + password: encryptedPassword, + lastUsedDate: toIsoDateOrNow(entry?.lastUsedDate), + }); + } + + return out.length ? out : null; +} + +async function buildUpdatedPasswordHistory( + cipher: Cipher | null, + draft: VaultDraft, + enc: Uint8Array, + mac: Uint8Array +): Promise { + const existingHistory = Array.isArray(cipher?.passwordHistory) ? cipher.passwordHistory : []; + const currentPassword = String(cipher?.login?.decPassword || ''); + const nextPassword = String(draft.loginPassword || ''); + const passwordChanged = currentPassword !== nextPassword; + const history = await encryptPasswordHistory(existingHistory, enc, mac); + + if (!passwordChanged || !currentPassword.trim()) { + return history; + } + + const encryptedCurrentPassword = await encryptTextValue(currentPassword, enc, mac); + if (!encryptedCurrentPassword) { + return history; + } + + const nextEntries: CipherPasswordHistoryEntry[] = [ + { + password: encryptedCurrentPassword, + lastUsedDate: new Date().toISOString(), + }, + ...(history || []), + ]; + return nextEntries.slice(0, 5); +} + async function encryptCustomFields( fields: VaultDraftField[], enc: Uint8Array, @@ -473,6 +529,7 @@ async function buildCipherPayload( const userMac = base64ToBytes(session.symMacKey); const keys = await getCipherKeys(cipher, userEnc, userMac); const type = Number(draft.type || cipher?.type || 1); + const now = new Date().toISOString(); const payload: Record = { type, @@ -487,6 +544,7 @@ async function buildCipherPayload( secureNote: null, sshKey: null, fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac), + passwordHistory: await encryptPasswordHistory(cipher?.passwordHistory, keys.enc, keys.mac), }; if (cipher?.id) { @@ -495,6 +553,7 @@ async function buildCipherPayload( } if (type === 1) { + const passwordChanged = String(cipher?.login?.decPassword || '') !== String(draft.loginPassword || ''); const existingFido2 = cipher?.login && Array.isArray((cipher.login as any).fido2Credentials) ? (cipher.login as any).fido2Credentials @@ -508,9 +567,11 @@ async function buildCipherPayload( username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac), password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac), totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac), + passwordRevisionDate: passwordChanged ? now : existingLogin.passwordRevisionDate ?? null, fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac), uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac), }; + payload.passwordHistory = await buildUpdatedPasswordHistory(cipher, draft, keys.enc, keys.mac); } else if (type === 3) { payload.card = { cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac), diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index a330d71..bc7a1a0 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -462,6 +462,8 @@ const messages: Record> = { txt_item_created: "Item created", txt_item_deleted: "Item deleted", txt_item_history: "Item History", + txt_password_history: "Password History", + txt_password_updated_value: "Password updated: {value}", txt_item_name_is_required: "Item name is required.", txt_item_updated: "Item updated", txt_last_edited_value: "Last edited: {value}", @@ -1075,6 +1077,8 @@ const zhCNOverrides: Record = { txt_notes: '备注', txt_replace_device_name_with_note: '为这台设备设置自定义名称,不会改变系统识别到的设备类型。', txt_item_history: '项目历史', + txt_password_history: '密码历史记录', + txt_password_updated_value: '密码新于: {value}', txt_last_edited_value: '最后编辑:{value}', txt_created_value: '创建于:{value}', txt_username: '用户名', diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 8e0889f..ed4f8bd 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -148,6 +148,12 @@ export interface CipherField { decValue?: string; } +export interface CipherPasswordHistoryEntry { + password?: string | null; + lastUsedDate?: string | null; + decPassword?: string; +} + export interface Cipher { id: string; type: number; @@ -167,7 +173,7 @@ export interface Cipher { identity?: CipherIdentity | null; sshKey?: CipherSshKey | null; secureNote?: { type?: number | null } | null; - passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null; + passwordHistory?: CipherPasswordHistoryEntry[] | null; fields?: CipherField[] | null; decName?: string; decNotes?: string; diff --git a/webapp/src/styles.css b/webapp/src/styles.css index f07ad28..e115885 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -1561,6 +1561,22 @@ input[type='file'].input::file-selector-button:hover { margin-top: 8px; } +.password-history-link { + margin-top: 10px; + padding: 0; + border: none; + background: transparent; + color: var(--primary); + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.password-history-link:hover { + color: var(--primary-hover); + text-decoration: underline; +} + .kv-line { display: flex; justify-content: space-between; @@ -1591,6 +1607,81 @@ input[type='file'].input::file-selector-button:hover { border-bottom: none; } +.password-history-dialog { + width: min(560px, calc(100vw - 32px)); +} + +.password-history-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.password-history-head .dialog-title { + margin: 0; +} + +.password-history-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: none; + border-radius: 999px; + background: transparent; + color: var(--muted-strong); + cursor: pointer; +} + +.password-history-close:hover { + background: var(--panel-soft); + color: var(--text); +} + +.password-history-list { + display: grid; + gap: 12px; + margin: 10px 0 18px; +} + +.password-history-item { + position: relative; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel-soft); + padding: 16px 54px 14px 16px; + box-shadow: var(--shadow-sm); +} + +.password-history-value { + color: var(--primary); + font-size: 22px; + line-height: 1.15; + letter-spacing: 0.01em; + word-break: break-all; +} + +.password-history-time { + margin-top: 8px; + color: var(--muted); +} + +.password-history-copy { + position: absolute; + top: 12px; + right: 12px; +} + +.password-history-copy-btn { + min-width: 36px; + padding: 0; + width: 36px; + height: 36px; +} + .kv-label { color: #64748b; min-width: 0;