mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: enhance login URI handling with match options and improve UI components
This commit is contained in:
@@ -503,7 +503,16 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setDraft((prev) => {
|
setDraft((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
const next = [...prev.loginUris];
|
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 };
|
return { ...prev, loginUris: next };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -885,6 +894,7 @@ function folderName(id: string | null | undefined): string {
|
|||||||
onSeedSshDefaults={(force) => void seedSshDefaults(force)}
|
onSeedSshDefaults={(force) => void seedSshDefaults(force)}
|
||||||
onUpdateSshPublicKey={updateSshPublicKey}
|
onUpdateSshPublicKey={updateSshPublicKey}
|
||||||
onUpdateDraftLoginUri={updateDraftLoginUri}
|
onUpdateDraftLoginUri={updateDraftLoginUri}
|
||||||
|
onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
|
||||||
onQueueAttachmentFiles={queueAttachmentFiles}
|
onQueueAttachmentFiles={queueAttachmentFiles}
|
||||||
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
|
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
|
||||||
onRemoveQueuedAttachment={removeQueuedAttachment}
|
onRemoveQueuedAttachment={removeQueuedAttachment}
|
||||||
|
|||||||
@@ -269,29 +269,36 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
if (fieldType === 2) {
|
if (fieldType === 2) {
|
||||||
const checked = toBooleanFieldValue(rawValue);
|
const checked = toBooleanFieldValue(rawValue);
|
||||||
return (
|
return (
|
||||||
<div key={`view-field-${index}`} className="kv-row custom-field-row">
|
<div key={`view-field-${index}`} className="custom-field-card">
|
||||||
<span className="kv-label" title={fieldName}>{fieldName}</span>
|
<div className="custom-field-label">{fieldName}</div>
|
||||||
<div className="kv-main boolean-main">
|
<div className="custom-field-body">
|
||||||
<label className="check-line cf-check view">
|
<div className="custom-field-value">
|
||||||
<input type="checkbox" checked={checked} disabled />
|
<label className="check-line cf-check view custom-field-check">
|
||||||
</label>
|
<input type="checkbox" checked={checked} disabled />
|
||||||
<span className="boolean-text value-ellipsis" title={checked ? t('txt_checked') : t('txt_unchecked')}>
|
<span className="boolean-text value-ellipsis" title={checked ? t('txt_checked') : t('txt_unchecked')}>
|
||||||
{checked ? t('txt_checked') : t('txt_unchecked')}
|
{checked ? t('txt_checked') : t('txt_unchecked')}
|
||||||
</span>
|
</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>
|
||||||
<div className="kv-actions" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div key={`view-field-${index}`} className="kv-row custom-field-row">
|
<div key={`view-field-${index}`} className="custom-field-card">
|
||||||
<span className="kv-label" title={fieldName}>{fieldName}</span>
|
<div className="custom-field-label" title={fieldName}>{fieldName}</div>
|
||||||
<div className="kv-main">
|
<div className="custom-field-body">
|
||||||
<strong className="value-ellipsis" title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}>
|
<div className="custom-field-value">
|
||||||
{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
|
<strong className="value-ellipsis" title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}>
|
||||||
</strong>
|
{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
|
||||||
</div>
|
</strong>
|
||||||
<div className="kv-actions">
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
{fieldType === 1 && (
|
{fieldType === 1 && (
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onToggleHiddenField(index)}>
|
<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" />}
|
{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)}>
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
|
||||||
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { RefObject } from 'preact';
|
|||||||
import { CheckCheck, Download, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-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 type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
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 {
|
interface VaultEditorProps {
|
||||||
draft: VaultDraft;
|
draft: VaultDraft;
|
||||||
@@ -24,6 +24,7 @@ interface VaultEditorProps {
|
|||||||
onSeedSshDefaults: (force?: boolean) => void;
|
onSeedSshDefaults: (force?: boolean) => void;
|
||||||
onUpdateSshPublicKey: (value: string) => void;
|
onUpdateSshPublicKey: (value: string) => void;
|
||||||
onUpdateDraftLoginUri: (index: number, value: string) => void;
|
onUpdateDraftLoginUri: (index: number, value: string) => void;
|
||||||
|
onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void;
|
||||||
onQueueAttachmentFiles: (list: FileList | null) => void;
|
onQueueAttachmentFiles: (list: FileList | null) => void;
|
||||||
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
|
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
|
||||||
onRemoveQueuedAttachment: (index: number) => void;
|
onRemoveQueuedAttachment: (index: number) => void;
|
||||||
@@ -119,13 +120,27 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
</label>
|
</label>
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<h4>{t('txt_websites')}</h4>
|
<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')}
|
<Plus size={14} className="btn-icon" /> {t('txt_add_website')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{props.draft.loginUris.map((uri, index) => (
|
{props.draft.loginUris.map((uriEntry, index) => (
|
||||||
<div key={`uri-${index}`} className="website-row">
|
<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 && (
|
{props.draft.loginUris.length > 1 && (
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, i) => i !== index) })}>
|
<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" />
|
<X size={14} className="btn-icon" />
|
||||||
@@ -322,23 +337,31 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
.map((field, originalIndex) => ({ field, originalIndex }))
|
.map((field, originalIndex) => ({ field, originalIndex }))
|
||||||
.filter((entry) => entry.field.type !== 3)
|
.filter((entry) => entry.field.type !== 3)
|
||||||
.map(({ field, originalIndex }) => (
|
.map(({ field, originalIndex }) => (
|
||||||
<div key={`field-${originalIndex}`} className="uri-row">
|
<div key={`field-${originalIndex}`} className="custom-field-card">
|
||||||
<input className="input" value={field.label} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { label: (e.currentTarget as HTMLInputElement).value })} />
|
<label className="field custom-field-label">
|
||||||
{field.type === 2 ? (
|
<span>{t('txt_field_label')}</span>
|
||||||
<label className="check-line cf-check">
|
<input className="input" value={field.label} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { label: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
<input
|
</label>
|
||||||
type="checkbox"
|
<div className="custom-field-body">
|
||||||
checked={toBooleanFieldValue(field.value)}
|
<div className="custom-field-value">
|
||||||
onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })}
|
{field.type === 2 ? (
|
||||||
/>
|
<label className="check-line cf-check custom-field-check">
|
||||||
</label>
|
<input
|
||||||
) : (
|
type="checkbox"
|
||||||
<input className="input" value={field.value} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })} />
|
checked={toBooleanFieldValue(field.value)}
|
||||||
)}
|
onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })}
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraftCustomFields(props.draft.customFields.filter((_, i) => i !== originalIndex))}>
|
/>
|
||||||
<X size={14} className="btn-icon" />
|
<span>{toBooleanFieldValue(field.value) ? t('txt_checked') : t('txt_unchecked')}</span>
|
||||||
{t('txt_remove')}
|
</label>
|
||||||
</button>
|
) : (
|
||||||
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from 'lucide-preact';
|
} from 'lucide-preact';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { t } from '@/lib/i18n';
|
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 TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||||
export type VaultSortMode = 'edited' | 'created' | 'name';
|
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') },
|
{ 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_PERIOD_SECONDS = 30;
|
||||||
export const TOTP_RING_RADIUS = 14;
|
export const TOTP_RING_RADIUS = 14;
|
||||||
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
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`;
|
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 {
|
function valueOrFallback(value: string | null | undefined): string {
|
||||||
return String(value || '');
|
return String(value || '');
|
||||||
}
|
}
|
||||||
@@ -245,7 +264,7 @@ export function createEmptyDraft(type: number): VaultDraft {
|
|||||||
loginUsername: '',
|
loginUsername: '',
|
||||||
loginPassword: '',
|
loginPassword: '',
|
||||||
loginTotp: '',
|
loginTotp: '',
|
||||||
loginUris: [''],
|
loginUris: [createEmptyLoginUri()],
|
||||||
loginFido2Credentials: [],
|
loginFido2Credentials: [],
|
||||||
cardholderName: '',
|
cardholderName: '',
|
||||||
cardNumber: '',
|
cardNumber: '',
|
||||||
@@ -291,11 +310,14 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
|
|||||||
draft.loginUsername = cipher.login.decUsername || '';
|
draft.loginUsername = cipher.login.decUsername || '';
|
||||||
draft.loginPassword = cipher.login.decPassword || '';
|
draft.loginPassword = cipher.login.decPassword || '';
|
||||||
draft.loginTotp = cipher.login.decTotp || '';
|
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)
|
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
|
||||||
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
||||||
: [];
|
: [];
|
||||||
if (!draft.loginUris.length) draft.loginUris = [''];
|
if (!draft.loginUris.length) draft.loginUris = [createEmptyLoginUri()];
|
||||||
}
|
}
|
||||||
if (cipher.card) {
|
if (cipher.card) {
|
||||||
draft.cardholderName = cipher.card.decCardholderName || '';
|
draft.cardholderName = cipher.card.decCardholderName || '';
|
||||||
|
|||||||
@@ -367,12 +367,19 @@ async function encryptCustomFields(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function encryptUris(uris: string[], enc: Uint8Array, mac: Uint8Array): Promise<Array<{ uri: string | null; match: null }>> {
|
async function encryptUris(
|
||||||
const out: Array<{ uri: string | null; match: null }> = [];
|
uris: VaultDraft['loginUris'],
|
||||||
for (const uri of uris || []) {
|
enc: Uint8Array,
|
||||||
const trimmed = String(uri || '').trim();
|
mac: Uint8Array
|
||||||
|
): Promise<Array<{ uri: string | null; match: number | null }>> {
|
||||||
|
const out: Array<{ uri: string | null; match: number | null }> = [];
|
||||||
|
for (const entry of uris || []) {
|
||||||
|
const trimmed = String(entry?.uri || '').trim();
|
||||||
if (!trimmed) continue;
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function buildEmptyImportDraft(type: number): VaultDraft {
|
|||||||
loginUsername: '',
|
loginUsername: '',
|
||||||
loginPassword: '',
|
loginPassword: '',
|
||||||
loginTotp: '',
|
loginTotp: '',
|
||||||
loginUris: [''],
|
loginUris: [{ uri: '', match: null }],
|
||||||
loginFido2Credentials: [],
|
loginFido2Credentials: [],
|
||||||
cardholderName: '',
|
cardholderName: '',
|
||||||
cardNumber: '',
|
cardNumber: '',
|
||||||
@@ -167,9 +167,17 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
|
|||||||
: [];
|
: [];
|
||||||
const urisRaw = Array.isArray(login.uris) ? login.uris : [];
|
const urisRaw = Array.isArray(login.uris) ? login.uris : [];
|
||||||
const uris = urisRaw
|
const uris = urisRaw
|
||||||
.map((u) => asText((u as Record<string, unknown>)?.uri).trim())
|
.map((u) => {
|
||||||
.filter((u) => !!u);
|
const row = (u || {}) as Record<string, unknown>;
|
||||||
draft.loginUris = uris.length ? uris : [''];
|
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) {
|
} else if (type === 3) {
|
||||||
const card = (cipher.card || {}) as Record<string, unknown>;
|
const card = (cipher.card || {}) as Record<string, unknown>;
|
||||||
draft.cardholderName = asText(card.cardholderName);
|
draft.cardholderName = asText(card.cardholderName);
|
||||||
|
|||||||
@@ -629,6 +629,13 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_user_deleted: "User deleted",
|
txt_user_deleted: "User deleted",
|
||||||
txt_user_status_updated: "User status updated",
|
txt_user_status_updated: "User status updated",
|
||||||
txt_username: "Username",
|
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_users: "Users",
|
||||||
txt_vault_synced: "Vault synced",
|
txt_vault_synced: "Vault synced",
|
||||||
txt_verification_code: "Verification Code",
|
txt_verification_code: "Verification Code",
|
||||||
@@ -908,6 +915,13 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_last_edited_value: '最后编辑:{value}',
|
txt_last_edited_value: '最后编辑:{value}',
|
||||||
txt_created_value: '创建于:{value}',
|
txt_created_value: '创建于:{value}',
|
||||||
txt_username: '用户名',
|
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_website: '网站',
|
||||||
txt_websites: '网站',
|
txt_websites: '网站',
|
||||||
txt_open: '打开',
|
txt_open: '打开',
|
||||||
|
|||||||
@@ -28,9 +28,15 @@ export interface Folder {
|
|||||||
|
|
||||||
export interface CipherLoginUri {
|
export interface CipherLoginUri {
|
||||||
uri?: string | null;
|
uri?: string | null;
|
||||||
|
match?: number | null;
|
||||||
decUri?: string;
|
decUri?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VaultDraftLoginUri {
|
||||||
|
uri: string;
|
||||||
|
match: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CipherAttachment {
|
export interface CipherAttachment {
|
||||||
id?: string;
|
id?: string;
|
||||||
url?: string | null;
|
url?: string | null;
|
||||||
@@ -221,7 +227,7 @@ export interface VaultDraft {
|
|||||||
loginUsername: string;
|
loginUsername: string;
|
||||||
loginPassword: string;
|
loginPassword: string;
|
||||||
loginTotp: string;
|
loginTotp: string;
|
||||||
loginUris: string[];
|
loginUris: VaultDraftLoginUri[];
|
||||||
loginFido2Credentials: Array<Record<string, unknown>>;
|
loginFido2Credentials: Array<Record<string, unknown>>;
|
||||||
cardholderName: string;
|
cardholderName: string;
|
||||||
cardNumber: string;
|
cardNumber: string;
|
||||||
|
|||||||
+69
-5
@@ -1427,10 +1427,6 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
padding: 8px 0 2px;
|
padding: 8px 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-field-row {
|
|
||||||
grid-template-columns: minmax(110px, 220px) minmax(0, 1fr) auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.boolean-main {
|
.boolean-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1440,6 +1436,61 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
min-width: 0;
|
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 {
|
.notes {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
@@ -2218,11 +2269,24 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
|
|
||||||
.website-row {
|
.website-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) minmax(130px, 160px) auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 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 {
|
.website-row .btn {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user