diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index d0bda2d..08b07e5 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -503,7 +503,16 @@ function folderName(id: string | null | undefined): string { setDraft((prev) => { if (!prev) return prev; const next = [...prev.loginUris]; - next[index] = value; + next[index] = { ...(next[index] || { uri: '', match: null }), uri: value }; + return { ...prev, loginUris: next }; + }); + } + + function updateDraftLoginUriMatch(index: number, value: number | null): void { + setDraft((prev) => { + if (!prev) return prev; + const next = [...prev.loginUris]; + next[index] = { ...(next[index] || { uri: '', match: null }), match: value }; return { ...prev, loginUris: next }; }); } @@ -885,6 +894,7 @@ function folderName(id: string | null | undefined): string { onSeedSshDefaults={(force) => void seedSshDefaults(force)} onUpdateSshPublicKey={updateSshPublicKey} onUpdateDraftLoginUri={updateDraftLoginUri} + onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch} onQueueAttachmentFiles={queueAttachmentFiles} onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval} onRemoveQueuedAttachment={removeQueuedAttachment} diff --git a/webapp/src/components/vault/VaultDetailView.tsx b/webapp/src/components/vault/VaultDetailView.tsx index 4ffe7c7..80b3884 100644 --- a/webapp/src/components/vault/VaultDetailView.tsx +++ b/webapp/src/components/vault/VaultDetailView.tsx @@ -269,29 +269,36 @@ export default function VaultDetailView(props: VaultDetailViewProps) { if (fieldType === 2) { const checked = toBooleanFieldValue(rawValue); return ( -
- {fieldName} -
- - - {checked ? t('txt_checked') : t('txt_unchecked')} - +
+
{fieldName}
+
+
+ +
+
+ +
-
); } return ( -
- {fieldName} -
- - {fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue} - -
-
+
+
{fieldName}
+
+
+ + {fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue} + +
+
{fieldType === 1 && ( +
); diff --git a/webapp/src/components/vault/VaultEditor.tsx b/webapp/src/components/vault/VaultEditor.tsx index ff20695..e4a7a80 100644 --- a/webapp/src/components/vault/VaultEditor.tsx +++ b/webapp/src/components/vault/VaultEditor.tsx @@ -2,7 +2,7 @@ import type { RefObject } from 'preact'; import { CheckCheck, Download, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact'; import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; import { t } from '@/lib/i18n'; -import { CREATE_TYPE_OPTIONS, cipherTypeLabel, formatAttachmentSize, toBooleanFieldValue } from '@/components/vault/vault-page-helpers'; +import { CREATE_TYPE_OPTIONS, cipherTypeLabel, createEmptyLoginUri, formatAttachmentSize, toBooleanFieldValue, WEBSITE_MATCH_OPTIONS } from '@/components/vault/vault-page-helpers'; interface VaultEditorProps { draft: VaultDraft; @@ -24,6 +24,7 @@ interface VaultEditorProps { onSeedSshDefaults: (force?: boolean) => void; onUpdateSshPublicKey: (value: string) => void; onUpdateDraftLoginUri: (index: number, value: string) => void; + onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void; onQueueAttachmentFiles: (list: FileList | null) => void; onToggleExistingAttachmentRemoval: (attachmentId: string) => void; onRemoveQueuedAttachment: (index: number) => void; @@ -119,13 +120,27 @@ export default function VaultEditor(props: VaultEditorProps) {

{t('txt_websites')}

-
- {props.draft.loginUris.map((uri, index) => ( + {props.draft.loginUris.map((uriEntry, index) => (
- props.onUpdateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} /> + props.onUpdateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} /> + {props.draft.loginUris.length > 1 && ( +
+ +
+
+ {field.type === 2 ? ( + + ) : ( + props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })} /> + )} +
+ +
))}
diff --git a/webapp/src/components/vault/vault-page-helpers.tsx b/webapp/src/components/vault/vault-page-helpers.tsx index 3399ef4..cdc67a3 100644 --- a/webapp/src/components/vault/vault-page-helpers.tsx +++ b/webapp/src/components/vault/vault-page-helpers.tsx @@ -9,7 +9,7 @@ import { } from 'lucide-preact'; import { copyTextToClipboard } from '@/lib/clipboard'; import { t } from '@/lib/i18n'; -import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField } from '@/lib/types'; +import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types'; export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh'; export type VaultSortMode = 'edited' | 'created' | 'name'; @@ -51,6 +51,16 @@ export const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string } { value: 2, label: t('txt_boolean') }, ]; +export const WEBSITE_MATCH_OPTIONS: Array<{ value: number | null; label: string }> = [ + { value: null, label: t('txt_uri_match_default_base_domain') }, + { value: 0, label: t('txt_uri_match_base_domain') }, + { value: 1, label: t('txt_uri_match_host') }, + { value: 3, label: t('txt_uri_match_exact') }, + { value: 5, label: t('txt_uri_match_never') }, + { value: 2, label: t('txt_uri_match_starts_with') }, + { value: 4, label: t('txt_uri_match_regular_expression') }, +]; + export const TOTP_PERIOD_SECONDS = 30; export const TOTP_RING_RADIUS = 14; export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; @@ -154,6 +164,15 @@ export function websiteIconUrl(host: string): string { return `/icons/${encodeURIComponent(host)}/icon.png`; } +export function createEmptyLoginUri(): VaultDraftLoginUri { + return { uri: '', match: null }; +} + +export function websiteMatchLabel(value: number | null | undefined): string { + const normalized = typeof value === 'number' && Number.isFinite(value) ? value : null; + return WEBSITE_MATCH_OPTIONS.find((option) => option.value === normalized)?.label || t('txt_uri_match_default_base_domain'); +} + function valueOrFallback(value: string | null | undefined): string { return String(value || ''); } @@ -245,7 +264,7 @@ export function createEmptyDraft(type: number): VaultDraft { loginUsername: '', loginPassword: '', loginTotp: '', - loginUris: [''], + loginUris: [createEmptyLoginUri()], loginFido2Credentials: [], cardholderName: '', cardNumber: '', @@ -291,11 +310,14 @@ export function draftFromCipher(cipher: Cipher): VaultDraft { draft.loginUsername = cipher.login.decUsername || ''; draft.loginPassword = cipher.login.decPassword || ''; draft.loginTotp = cipher.login.decTotp || ''; - draft.loginUris = (cipher.login.uris || []).map((x) => x.decUri || x.uri || ''); + draft.loginUris = (cipher.login.uris || []).map((x) => ({ + uri: x.decUri || x.uri || '', + match: x.match ?? null, + })); draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials) ? cipher.login.fido2Credentials.map((credential) => ({ ...credential })) : []; - if (!draft.loginUris.length) draft.loginUris = ['']; + if (!draft.loginUris.length) draft.loginUris = [createEmptyLoginUri()]; } if (cipher.card) { draft.cardholderName = cipher.card.decCardholderName || ''; diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 856c8f1..6ce7ecd 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -367,12 +367,19 @@ async function encryptCustomFields( return out; } -async function encryptUris(uris: string[], enc: Uint8Array, mac: Uint8Array): Promise> { - const out: Array<{ uri: string | null; match: null }> = []; - for (const uri of uris || []) { - const trimmed = String(uri || '').trim(); +async function encryptUris( + uris: VaultDraft['loginUris'], + enc: Uint8Array, + mac: Uint8Array +): Promise> { + const out: Array<{ uri: string | null; match: number | null }> = []; + for (const entry of uris || []) { + const trimmed = String(entry?.uri || '').trim(); if (!trimmed) continue; - out.push({ uri: await encryptTextValue(trimmed, enc, mac), match: null }); + out.push({ + uri: await encryptTextValue(trimmed, enc, mac), + match: typeof entry?.match === 'number' && Number.isFinite(entry.match) ? entry.match : null, + }); } return out; } diff --git a/webapp/src/lib/app-support.ts b/webapp/src/lib/app-support.ts index 157aede..0ad5a0d 100644 --- a/webapp/src/lib/app-support.ts +++ b/webapp/src/lib/app-support.ts @@ -97,7 +97,7 @@ export function buildEmptyImportDraft(type: number): VaultDraft { loginUsername: '', loginPassword: '', loginTotp: '', - loginUris: [''], + loginUris: [{ uri: '', match: null }], loginFido2Credentials: [], cardholderName: '', cardNumber: '', @@ -167,9 +167,17 @@ export function importCipherToDraft(cipher: Record, folderId: s : []; const urisRaw = Array.isArray(login.uris) ? login.uris : []; const uris = urisRaw - .map((u) => asText((u as Record)?.uri).trim()) - .filter((u) => !!u); - draft.loginUris = uris.length ? uris : ['']; + .map((u) => { + const row = (u || {}) as Record; + const uri = asText(row.uri).trim(); + const matchRaw = row.match; + return { + uri, + match: typeof matchRaw === 'number' && Number.isFinite(matchRaw) ? matchRaw : null, + }; + }) + .filter((u) => !!u.uri); + draft.loginUris = uris.length ? uris : [{ uri: '', match: null }]; } else if (type === 3) { const card = (cipher.card || {}) as Record; draft.cardholderName = asText(card.cardholderName); diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index ad0a5f4..2b7c71e 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -629,6 +629,13 @@ const messages: Record> = { txt_user_deleted: "User deleted", txt_user_status_updated: "User status updated", txt_username: "Username", + txt_uri_match_default_base_domain: "Default (Base Domain)", + txt_uri_match_base_domain: "Base Domain", + txt_uri_match_host: "Host", + txt_uri_match_exact: "Exact", + txt_uri_match_never: "Never", + txt_uri_match_starts_with: "Starts With", + txt_uri_match_regular_expression: "Regular Expression", txt_users: "Users", txt_vault_synced: "Vault synced", txt_verification_code: "Verification Code", @@ -908,6 +915,13 @@ const zhCNOverrides: Record = { txt_last_edited_value: '最后编辑:{value}', txt_created_value: '创建于:{value}', txt_username: '用户名', + txt_uri_match_default_base_domain: '默认(基础域名)', + txt_uri_match_base_domain: '基础域名', + txt_uri_match_host: '主机', + txt_uri_match_exact: '精确', + txt_uri_match_never: '从不', + txt_uri_match_starts_with: '开始于', + txt_uri_match_regular_expression: '正则表达式', txt_website: '网站', txt_websites: '网站', txt_open: '打开', diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 223b9b6..ffc6b83 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -28,9 +28,15 @@ export interface Folder { export interface CipherLoginUri { uri?: string | null; + match?: number | null; decUri?: string; } +export interface VaultDraftLoginUri { + uri: string; + match: number | null; +} + export interface CipherAttachment { id?: string; url?: string | null; @@ -221,7 +227,7 @@ export interface VaultDraft { loginUsername: string; loginPassword: string; loginTotp: string; - loginUris: string[]; + loginUris: VaultDraftLoginUri[]; loginFido2Credentials: Array>; cardholderName: string; cardNumber: string; diff --git a/webapp/src/styles.css b/webapp/src/styles.css index b783cbf..4611153 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -1427,10 +1427,6 @@ input[type='file'].input::file-selector-button:hover { padding: 8px 0 2px; } -.custom-field-row { - grid-template-columns: minmax(110px, 220px) minmax(0, 1fr) auto; -} - .boolean-main { min-width: 0; gap: 8px; @@ -1440,6 +1436,61 @@ input[type='file'].input::file-selector-button:hover { min-width: 0; } +.custom-field-card { + display: grid; + gap: 8px; + padding: 10px 0; + border-bottom: 1px solid #ecf0f5; +} + +.custom-field-card:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.custom-field-label { + display: block; + color: #64748b; + font-size: 12px; + font-weight: 700; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.custom-field-body { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; +} + +.custom-field-value { + min-width: 0; +} + +.custom-field-value > .input { + width: 100%; +} + +.custom-field-check { + margin-bottom: 0; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.custom-field-check span { + color: #334155; + font-size: 14px; + font-weight: 600; +} + +.custom-field-remove { + white-space: nowrap; +} + .notes { white-space: pre-wrap; overflow-wrap: anywhere; @@ -2218,11 +2269,24 @@ input[type='file'].input::file-selector-button:hover { .website-row { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) minmax(130px, 160px) auto; gap: 8px; margin-bottom: 8px; } +.website-match-select { + height: 48px; + font-size: 13px; + line-height: 1.2; + padding-top: 10px; + padding-bottom: 10px; + padding-right: 38px; +} + +.website-match-select option { + font-size: 13px; +} + .website-row .btn { justify-self: start; width: auto;