mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add password history feature with dialog and encryption handling
This commit is contained in:
@@ -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) {
|
if (cipher.card) {
|
||||||
nextCipher.card = {
|
nextCipher.card = {
|
||||||
...cipher.card,
|
...cipher.card,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { createPortal } from 'preact/compat';
|
||||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2 } from 'lucide-preact';
|
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 type { Cipher } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
@@ -35,10 +37,60 @@ interface VaultDetailViewProps {
|
|||||||
onUnarchive: (cipher: Cipher) => void | Promise<void>;
|
onUnarchive: (cipher: Cipher) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
<div className="dialog-mask open" onClick={(event) => event.target === event.currentTarget && props.onClose()}>
|
||||||
|
<section className="dialog-card password-history-dialog open" role="dialog" aria-modal="true" aria-label={t('txt_password_history')}>
|
||||||
|
<div className="password-history-head">
|
||||||
|
<h3 className="dialog-title">{t('txt_password_history')}</h3>
|
||||||
|
<button type="button" className="password-history-close" aria-label={t('txt_close')} onClick={props.onClose}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="password-history-list">
|
||||||
|
{props.entries.map((entry, index) => (
|
||||||
|
<div key={`password-history-${index}-${entry.lastUsedDate || 'none'}`} className="password-history-item">
|
||||||
|
<div className="password-history-copy">
|
||||||
|
<button type="button" className="btn btn-secondary small password-history-copy-btn" onClick={() => copyToClipboard(entry.password)}>
|
||||||
|
<Clipboard size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="password-history-value">{entry.password}</div>
|
||||||
|
<div className="password-history-time">{formatHistoryTime(entry.lastUsedDate)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary dialog-btn" onClick={props.onClose}>
|
||||||
|
{t('txt_close')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function VaultDetailView(props: VaultDetailViewProps) {
|
export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||||
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
||||||
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
|
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
|
||||||
|
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
|
||||||
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
|
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 formatDownloadLabel = (attachmentId: string) => {
|
||||||
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
||||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||||
@@ -355,6 +407,14 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
<h4>{t('txt_item_history')}</h4>
|
<h4>{t('txt_item_history')}</h4>
|
||||||
<div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(props.selectedCipher.revisionDate) })}</div>
|
<div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(props.selectedCipher.revisionDate) })}</div>
|
||||||
<div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(props.selectedCipher.creationDate) })}</div>
|
<div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(props.selectedCipher.creationDate) })}</div>
|
||||||
|
{!!props.selectedCipher.login?.passwordRevisionDate && (
|
||||||
|
<div className="detail-sub">{t('txt_password_updated_value', { value: formatHistoryTime(props.selectedCipher.login.passwordRevisionDate) })}</div>
|
||||||
|
)}
|
||||||
|
{passwordHistoryEntries.length > 0 && (
|
||||||
|
<button type="button" className="password-history-link" onClick={() => setPasswordHistoryOpen(true)}>
|
||||||
|
{t('txt_password_history')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -379,6 +439,11 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<PasswordHistoryDialog
|
||||||
|
open={passwordHistoryOpen}
|
||||||
|
entries={passwordHistoryEntries}
|
||||||
|
onClose={() => setPasswordHistoryOpen(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData } from '../crypto';
|
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData } from '../crypto';
|
||||||
import type {
|
import type {
|
||||||
Cipher,
|
Cipher,
|
||||||
|
CipherPasswordHistoryEntry,
|
||||||
Folder,
|
Folder,
|
||||||
SessionState,
|
SessionState,
|
||||||
VaultDraft,
|
VaultDraft,
|
||||||
@@ -346,6 +347,61 @@ async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array)
|
|||||||
return encryptBw(new TextEncoder().encode(s), enc, mac);
|
return encryptBw(new TextEncoder().encode(s), enc, mac);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function encryptPasswordHistory(
|
||||||
|
entries: CipherPasswordHistoryEntry[] | null | undefined,
|
||||||
|
enc: Uint8Array,
|
||||||
|
mac: Uint8Array
|
||||||
|
): Promise<CipherPasswordHistoryEntry[] | null> {
|
||||||
|
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<CipherPasswordHistoryEntry[] | null> {
|
||||||
|
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(
|
async function encryptCustomFields(
|
||||||
fields: VaultDraftField[],
|
fields: VaultDraftField[],
|
||||||
enc: Uint8Array,
|
enc: Uint8Array,
|
||||||
@@ -473,6 +529,7 @@ async function buildCipherPayload(
|
|||||||
const userMac = base64ToBytes(session.symMacKey);
|
const userMac = base64ToBytes(session.symMacKey);
|
||||||
const keys = await getCipherKeys(cipher, userEnc, userMac);
|
const keys = await getCipherKeys(cipher, userEnc, userMac);
|
||||||
const type = Number(draft.type || cipher?.type || 1);
|
const type = Number(draft.type || cipher?.type || 1);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
type,
|
type,
|
||||||
@@ -487,6 +544,7 @@ async function buildCipherPayload(
|
|||||||
secureNote: null,
|
secureNote: null,
|
||||||
sshKey: null,
|
sshKey: null,
|
||||||
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
|
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
|
||||||
|
passwordHistory: await encryptPasswordHistory(cipher?.passwordHistory, keys.enc, keys.mac),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cipher?.id) {
|
if (cipher?.id) {
|
||||||
@@ -495,6 +553,7 @@ async function buildCipherPayload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 1) {
|
if (type === 1) {
|
||||||
|
const passwordChanged = String(cipher?.login?.decPassword || '') !== String(draft.loginPassword || '');
|
||||||
const existingFido2 =
|
const existingFido2 =
|
||||||
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
|
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
|
||||||
? (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),
|
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
||||||
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
||||||
totp: await encryptTextValue(draft.loginTotp, 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),
|
fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac),
|
||||||
uris: await encryptUris(draft.loginUris || [], 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) {
|
} else if (type === 3) {
|
||||||
payload.card = {
|
payload.card = {
|
||||||
cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
|
cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
|
||||||
|
|||||||
@@ -462,6 +462,8 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_item_created: "Item created",
|
txt_item_created: "Item created",
|
||||||
txt_item_deleted: "Item deleted",
|
txt_item_deleted: "Item deleted",
|
||||||
txt_item_history: "Item History",
|
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_name_is_required: "Item name is required.",
|
||||||
txt_item_updated: "Item updated",
|
txt_item_updated: "Item updated",
|
||||||
txt_last_edited_value: "Last edited: {value}",
|
txt_last_edited_value: "Last edited: {value}",
|
||||||
@@ -1075,6 +1077,8 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_notes: '备注',
|
txt_notes: '备注',
|
||||||
txt_replace_device_name_with_note: '为这台设备设置自定义名称,不会改变系统识别到的设备类型。',
|
txt_replace_device_name_with_note: '为这台设备设置自定义名称,不会改变系统识别到的设备类型。',
|
||||||
txt_item_history: '项目历史',
|
txt_item_history: '项目历史',
|
||||||
|
txt_password_history: '密码历史记录',
|
||||||
|
txt_password_updated_value: '密码新于: {value}',
|
||||||
txt_last_edited_value: '最后编辑:{value}',
|
txt_last_edited_value: '最后编辑:{value}',
|
||||||
txt_created_value: '创建于:{value}',
|
txt_created_value: '创建于:{value}',
|
||||||
txt_username: '用户名',
|
txt_username: '用户名',
|
||||||
|
|||||||
@@ -148,6 +148,12 @@ export interface CipherField {
|
|||||||
decValue?: string;
|
decValue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CipherPasswordHistoryEntry {
|
||||||
|
password?: string | null;
|
||||||
|
lastUsedDate?: string | null;
|
||||||
|
decPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Cipher {
|
export interface Cipher {
|
||||||
id: string;
|
id: string;
|
||||||
type: number;
|
type: number;
|
||||||
@@ -167,7 +173,7 @@ export interface Cipher {
|
|||||||
identity?: CipherIdentity | null;
|
identity?: CipherIdentity | null;
|
||||||
sshKey?: CipherSshKey | null;
|
sshKey?: CipherSshKey | null;
|
||||||
secureNote?: { type?: number | null } | null;
|
secureNote?: { type?: number | null } | null;
|
||||||
passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null;
|
passwordHistory?: CipherPasswordHistoryEntry[] | null;
|
||||||
fields?: CipherField[] | null;
|
fields?: CipherField[] | null;
|
||||||
decName?: string;
|
decName?: string;
|
||||||
decNotes?: string;
|
decNotes?: string;
|
||||||
|
|||||||
@@ -1561,6 +1561,22 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
margin-top: 8px;
|
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 {
|
.kv-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -1591,6 +1607,81 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
border-bottom: none;
|
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 {
|
.kv-label {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user