feat: enhance login URI handling with match options and improve UI components

This commit is contained in:
shuaiplus
2026-03-26 21:59:50 +08:00
parent fe0bd80f43
commit 89308fc8a6
9 changed files with 221 additions and 59 deletions
+11 -1
View File
@@ -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}
+26 -18
View File
@@ -269,29 +269,36 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
if (fieldType === 2) {
const checked = toBooleanFieldValue(rawValue);
return (
<div key={`view-field-${index}`} className="kv-row custom-field-row">
<span className="kv-label" title={fieldName}>{fieldName}</span>
<div className="kv-main boolean-main">
<label className="check-line cf-check view">
<input type="checkbox" checked={checked} disabled />
</label>
<span className="boolean-text value-ellipsis" title={checked ? t('txt_checked') : t('txt_unchecked')}>
{checked ? t('txt_checked') : t('txt_unchecked')}
</span>
<div key={`view-field-${index}`} className="custom-field-card">
<div className="custom-field-label">{fieldName}</div>
<div className="custom-field-body">
<div className="custom-field-value">
<label className="check-line cf-check view custom-field-check">
<input type="checkbox" checked={checked} disabled />
<span className="boolean-text value-ellipsis" title={checked ? t('txt_checked') : t('txt_unchecked')}>
{checked ? t('txt_checked') : t('txt_unchecked')}
</span>
</label>
</div>
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
<div className="kv-actions" />
</div>
);
}
return (
<div key={`view-field-${index}`} className="kv-row custom-field-row">
<span className="kv-label" title={fieldName}>{fieldName}</span>
<div className="kv-main">
<strong className="value-ellipsis" title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}>
{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
</strong>
</div>
<div className="kv-actions">
<div key={`view-field-${index}`} className="custom-field-card">
<div className="custom-field-label" title={fieldName}>{fieldName}</div>
<div className="custom-field-body">
<div className="custom-field-value">
<strong className="value-ellipsis" title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}>
{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
</strong>
</div>
<div className="kv-actions">
{fieldType === 1 && (
<button type="button" className="btn btn-secondary small" onClick={() => props.onToggleHiddenField(index)}>
{isHiddenVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
@@ -301,6 +308,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
</div>
);
+44 -21
View File
@@ -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) {
</label>
<div className="section-head">
<h4>{t('txt_websites')}</h4>
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraft({ loginUris: [...props.draft.loginUris, ''] })}>
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraft({ loginUris: [...props.draft.loginUris, createEmptyLoginUri()] })}>
<Plus size={14} className="btn-icon" /> {t('txt_add_website')}
</button>
</div>
{props.draft.loginUris.map((uri, index) => (
{props.draft.loginUris.map((uriEntry, index) => (
<div key={`uri-${index}`} className="website-row">
<input className="input" value={uri} onInput={(e) => props.onUpdateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} />
<input className="input" value={uriEntry.uri} onInput={(e) => props.onUpdateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} />
<select
className="input website-match-select"
value={uriEntry.match == null ? '' : String(uriEntry.match)}
onInput={(e) => {
const raw = (e.currentTarget as HTMLSelectElement).value;
props.onUpdateDraftLoginUriMatch(index, raw === '' ? null : Number(raw));
}}
>
{WEBSITE_MATCH_OPTIONS.map((option) => (
<option key={`website-match-${String(option.value)}`} value={option.value == null ? '' : String(option.value)}>
{option.label}
</option>
))}
</select>
{props.draft.loginUris.length > 1 && (
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, i) => i !== index) })}>
<X size={14} className="btn-icon" />
@@ -322,23 +337,31 @@ export default function VaultEditor(props: VaultEditorProps) {
.map((field, originalIndex) => ({ field, originalIndex }))
.filter((entry) => entry.field.type !== 3)
.map(({ field, originalIndex }) => (
<div key={`field-${originalIndex}`} className="uri-row">
<input className="input" value={field.label} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { label: (e.currentTarget as HTMLInputElement).value })} />
{field.type === 2 ? (
<label className="check-line cf-check">
<input
type="checkbox"
checked={toBooleanFieldValue(field.value)}
onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })}
/>
</label>
) : (
<input className="input" value={field.value} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })} />
)}
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraftCustomFields(props.draft.customFields.filter((_, i) => i !== originalIndex))}>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
<div key={`field-${originalIndex}`} className="custom-field-card">
<label className="field custom-field-label">
<span>{t('txt_field_label')}</span>
<input className="input" value={field.label} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { label: (e.currentTarget as HTMLInputElement).value })} />
</label>
<div className="custom-field-body">
<div className="custom-field-value">
{field.type === 2 ? (
<label className="check-line cf-check custom-field-check">
<input
type="checkbox"
checked={toBooleanFieldValue(field.value)}
onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })}
/>
<span>{toBooleanFieldValue(field.value) ? t('txt_checked') : t('txt_unchecked')}</span>
</label>
) : (
<input className="input" value={field.value} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })} />
)}
</div>
<button type="button" className="btn btn-secondary small custom-field-remove" onClick={() => props.onUpdateDraftCustomFields(props.draft.customFields.filter((_, i) => i !== originalIndex))}>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
</div>
</div>
))}
</div>
@@ -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 || '';