+
+
{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.onUpdateDraft({ loginUris: [...props.draft.loginUris, ''] })}>
+ props.onUpdateDraft({ loginUris: [...props.draft.loginUris, createEmptyLoginUri()] })}>
{t('txt_add_website')}
- {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 && (
props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, i) => i !== index) })}>
@@ -322,23 +337,31 @@ export default function VaultEditor(props: VaultEditorProps) {
.map((field, originalIndex) => ({ field, originalIndex }))
.filter((entry) => entry.field.type !== 3)
.map(({ field, originalIndex }) => (
-
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;