feat: refactor vault component helpers to use dedicated functions for options retrieval

This commit is contained in:
shuaiplus
2026-04-29 15:28:23 +08:00
parent 85147e1569
commit 9c5fbda374
8 changed files with 93 additions and 48 deletions
+3 -2
View File
@@ -1,6 +1,6 @@
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import type { CustomFieldType, Folder } from '@/lib/types'; import type { CustomFieldType, Folder } from '@/lib/types';
import { FIELD_TYPE_OPTIONS, toBooleanFieldValue } from '@/components/vault/vault-page-helpers'; import { getFieldTypeOptions, toBooleanFieldValue } from '@/components/vault/vault-page-helpers';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
interface VaultDialogsProps { interface VaultDialogsProps {
@@ -61,6 +61,7 @@ interface VaultDialogsProps {
} }
export default function VaultDialogs(props: VaultDialogsProps) { export default function VaultDialogs(props: VaultDialogsProps) {
const fieldTypeOptions = getFieldTypeOptions();
return ( return (
<> <>
<ConfirmDialog <ConfirmDialog
@@ -75,7 +76,7 @@ export default function VaultDialogs(props: VaultDialogsProps) {
<label className="field"> <label className="field">
<span>{t('txt_field_type')}</span> <span>{t('txt_field_type')}</span>
<select className="input" value={props.fieldType} onInput={(e) => props.onFieldTypeChange(Number((e.currentTarget as HTMLSelectElement).value) as CustomFieldType)}> <select className="input" value={props.fieldType} onInput={(e) => props.onFieldTypeChange(Number((e.currentTarget as HTMLSelectElement).value) as CustomFieldType)}>
{FIELD_TYPE_OPTIONS.map((option) => ( {fieldTypeOptions.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
{option.label} {option.label}
</option> </option>
+6 -4
View File
@@ -21,13 +21,13 @@ import { CSS } from '@dnd-kit/utilities';
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 { import {
CREATE_TYPE_OPTIONS,
cipherTypeLabel, cipherTypeLabel,
createEmptyLoginUri, createEmptyLoginUri,
formatAttachmentSize, formatAttachmentSize,
formatHistoryTime, formatHistoryTime,
getCreateTypeOptions,
getWebsiteMatchOptions,
toBooleanFieldValue, toBooleanFieldValue,
WEBSITE_MATCH_OPTIONS,
} from '@/components/vault/vault-page-helpers'; } from '@/components/vault/vault-page-helpers';
interface VaultEditorProps { interface VaultEditorProps {
@@ -77,6 +77,7 @@ interface SortableWebsiteRowProps {
} }
function SortableWebsiteRow(props: SortableWebsiteRowProps) { function SortableWebsiteRow(props: SortableWebsiteRowProps) {
const websiteMatchOptions = getWebsiteMatchOptions();
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.id, id: props.id,
}); });
@@ -117,7 +118,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
props.onUpdateMatch(props.index, raw === '' ? null : Number(raw)); props.onUpdateMatch(props.index, raw === '' ? null : Number(raw));
}} }}
> >
{WEBSITE_MATCH_OPTIONS.map((option) => ( {websiteMatchOptions.map((option) => (
<option key={`website-match-${String(option.value)}`} value={option.value == null ? '' : String(option.value)}> <option key={`website-match-${String(option.value)}`} value={option.value == null ? '' : String(option.value)}>
{option.label} {option.label}
</option> </option>
@@ -134,6 +135,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
} }
export default function VaultEditor(props: VaultEditorProps) { export default function VaultEditor(props: VaultEditorProps) {
const createTypeOptions = getCreateTypeOptions();
const uriIdSeedRef = useRef(0); const uriIdSeedRef = useRef(0);
const [uriItemIds, setUriItemIds] = useState<string[]>([]); const [uriItemIds, setUriItemIds] = useState<string[]>([]);
const [activeUriId, setActiveUriId] = useState<string | null>(null); const [activeUriId, setActiveUriId] = useState<string | null>(null);
@@ -232,7 +234,7 @@ export default function VaultEditor(props: VaultEditorProps) {
if (nextType === 5) props.onSeedSshDefaults(); if (nextType === 5) props.onSeedSshDefaults();
}} }}
> >
{CREATE_TYPE_OPTIONS.map((option) => ( {createTypeOptions.map((option) => (
<option key={option.type} value={option.type}> <option key={option.type} value={option.type}>
{option.label} {option.label}
</option> </option>
@@ -6,9 +6,9 @@ import LoadingState from '@/components/LoadingState';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
CREATE_TYPE_OPTIONS,
CreateTypeIcon, CreateTypeIcon,
VAULT_SORT_OPTIONS, getCreateTypeOptions,
getVaultSortOptions,
VaultListIcon, VaultListIcon,
type SidebarFilter, type SidebarFilter,
type VaultSortMode, type VaultSortMode,
@@ -106,6 +106,8 @@ const CipherListItem = memo(function CipherListItem(props: CipherListItemProps)
}); });
export default function VaultListPanel(props: VaultListPanelProps) { export default function VaultListPanel(props: VaultListPanelProps) {
const createTypeOptions = getCreateTypeOptions();
const vaultSortOptions = getVaultSortOptions();
const createMenu = ( const createMenu = (
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}> <div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
<button <button
@@ -119,7 +121,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
</button> </button>
{props.createMenuOpen && ( {props.createMenuOpen && (
<div className="create-menu"> <div className="create-menu">
{CREATE_TYPE_OPTIONS.map((option) => ( {createTypeOptions.map((option) => (
<button key={option.type} type="button" className="create-menu-item" onClick={() => props.onStartCreate(option.type)}> <button key={option.type} type="button" className="create-menu-item" onClick={() => props.onStartCreate(option.type)}>
<CreateTypeIcon type={option.type} /> <CreateTypeIcon type={option.type} />
<span>{option.label}</span> <span>{option.label}</span>
@@ -171,7 +173,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
</button> </button>
{props.sortMenuOpen && ( {props.sortMenuOpen && (
<div className="sort-menu"> <div className="sort-menu">
{VAULT_SORT_OPTIONS.map((option) => ( {vaultSortOptions.map((option) => (
<button <button
key={option.value} key={option.value}
type="button" type="button"
+3 -2
View File
@@ -21,7 +21,7 @@ import {
} from 'lucide-preact'; } from 'lucide-preact';
import type { Folder } from '@/lib/types'; import type { Folder } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { FOLDER_SORT_OPTIONS, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers'; import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers';
interface VaultSidebarProps { interface VaultSidebarProps {
folders: Folder[]; folders: Folder[];
@@ -43,6 +43,7 @@ interface VaultSidebarProps {
} }
export default function VaultSidebar(props: VaultSidebarProps) { export default function VaultSidebar(props: VaultSidebarProps) {
const folderSortOptions = getFolderSortOptions();
const nameCollator = useMemo( const nameCollator = useMemo(
() => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }), () => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
[] []
@@ -143,7 +144,7 @@ export default function VaultSidebar(props: VaultSidebarProps) {
</button> </button>
{props.folderSortMenuOpen && ( {props.folderSortMenuOpen && (
<div className="sort-menu"> <div className="sort-menu">
{FOLDER_SORT_OPTIONS.map((option) => ( {folderSortOptions.map((option) => (
<button <button
key={option.value} key={option.value}
type="button" type="button"
@@ -28,37 +28,47 @@ interface TypeOption {
label: string; label: string;
} }
export const CREATE_TYPE_OPTIONS: TypeOption[] = [ export function getCreateTypeOptions(): TypeOption[] {
return [
{ type: 1, label: t('txt_login') }, { type: 1, label: t('txt_login') },
{ type: 3, label: t('txt_card') }, { type: 3, label: t('txt_card') },
{ type: 4, label: t('txt_identity') }, { type: 4, label: t('txt_identity') },
{ type: 2, label: t('txt_note') }, { type: 2, label: t('txt_note') },
{ type: 5, label: t('txt_ssh_key') }, { type: 5, label: t('txt_ssh_key') },
]; ];
}
export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1'; export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
export const FOLDER_SORT_STORAGE_KEY = 'nodewarden.folder-sort.v1'; export const FOLDER_SORT_STORAGE_KEY = 'nodewarden.folder-sort.v1';
export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)'; export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
export const VAULT_LIST_ROW_HEIGHT = 74; export const VAULT_LIST_ROW_HEIGHT = 74;
export const VAULT_LIST_OVERSCAN = 10; export const VAULT_LIST_OVERSCAN = 10;
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [ export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> {
return [
{ value: 'edited', label: t('txt_sort_last_edited') }, { value: 'edited', label: t('txt_sort_last_edited') },
{ value: 'created', label: t('txt_sort_created') }, { value: 'created', label: t('txt_sort_created') },
{ value: 'name', label: t('txt_sort_name') }, { value: 'name', label: t('txt_sort_name') },
]; ];
export const FOLDER_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [ }
{ value: 'edited', label: t('txt_sort_last_edited') },
{ value: 'created', label: t('txt_sort_created') },
{ value: 'name', label: t('txt_sort_name') },
];
export const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }> = [ export function getFolderSortOptions(): Array<{ value: VaultSortMode; label: string }> {
return [
{ value: 'edited', label: t('txt_sort_last_edited') },
{ value: 'created', label: t('txt_sort_created') },
{ value: 'name', label: t('txt_sort_name') },
];
}
export function getFieldTypeOptions(): Array<{ value: CustomFieldType; label: string }> {
return [
{ value: 0, label: t('txt_text') }, { value: 0, label: t('txt_text') },
{ value: 1, label: t('txt_hidden') }, { value: 1, label: t('txt_hidden') },
{ value: 2, label: t('txt_boolean') }, { value: 2, label: t('txt_boolean') },
]; ];
}
export const WEBSITE_MATCH_OPTIONS: Array<{ value: number | null; label: string }> = [ export function getWebsiteMatchOptions(): Array<{ value: number | null; label: string }> {
return [
{ value: null, label: t('txt_uri_match_default_base_domain') }, { value: null, label: t('txt_uri_match_default_base_domain') },
{ value: 0, label: t('txt_uri_match_base_domain') }, { value: 0, label: t('txt_uri_match_base_domain') },
{ value: 1, label: t('txt_uri_match_host') }, { value: 1, label: t('txt_uri_match_host') },
@@ -66,7 +76,8 @@ export const WEBSITE_MATCH_OPTIONS: Array<{ value: number | null; label: string
{ value: 5, label: t('txt_uri_match_never') }, { value: 5, label: t('txt_uri_match_never') },
{ value: 2, label: t('txt_uri_match_starts_with') }, { value: 2, label: t('txt_uri_match_starts_with') },
{ value: 4, label: t('txt_uri_match_regular_expression') }, { 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;
@@ -156,7 +167,7 @@ export function createEmptyLoginUri(): VaultDraftLoginUri {
export function websiteMatchLabel(value: number | null | undefined): string { export function websiteMatchLabel(value: number | null | undefined): string {
const normalized = typeof value === 'number' && Number.isFinite(value) ? value : null; 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'); return getWebsiteMatchOptions().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 {
+22 -1
View File
@@ -134,7 +134,28 @@ export function loadProfileSnapshot(email?: string | null): Profile | null {
export function saveProfileSnapshot(profile: Profile | null): void { export function saveProfileSnapshot(profile: Profile | null): void {
if (!profile) return; if (!profile) return;
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(stripProfileSecrets(profile))); const nextSnapshot = stripProfileSecrets(profile);
try {
const rawExisting = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
if (rawExisting) {
const existing = stripProfileSecrets(JSON.parse(rawExisting) as Profile);
if (
existing
&& existing.email === nextSnapshot?.email
&& existing.role === 'admin'
&& nextSnapshot?.role !== 'admin'
) {
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify({
...nextSnapshot,
role: 'admin',
}));
return;
}
}
} catch {
// Fall back to writing the normalized snapshot below.
}
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(nextSnapshot));
} }
export function clearProfileSnapshot(): void { export function clearProfileSnapshot(): void {
+6 -3
View File
@@ -144,7 +144,7 @@ function decodeAccessTokenClaims(accessToken: string): AccessTokenClaims {
} }
} }
function buildTransientProfile(token: TokenSuccess, email: string): Profile { function buildTransientProfile(token: TokenSuccess, email: string, fallbackProfile: Profile | null = null): Profile {
const claims = decodeAccessTokenClaims(token.access_token); const claims = decodeAccessTokenClaims(token.access_token);
const normalizedEmail = String(claims.email || email || '').trim().toLowerCase(); const normalizedEmail = String(claims.email || email || '').trim().toLowerCase();
const accountKeys = token.accountKeys ?? token.AccountKeys ?? null; const accountKeys = token.accountKeys ?? token.AccountKeys ?? null;
@@ -154,9 +154,11 @@ function buildTransientProfile(token: TokenSuccess, email: string): Profile {
name: String(claims.name || normalizedEmail || ''), name: String(claims.name || normalizedEmail || ''),
key: String(token.Key || ''), key: String(token.Key || ''),
privateKey: token.PrivateKey ?? null, privateKey: token.PrivateKey ?? null,
role: 'user', role: fallbackProfile?.role === 'admin' ? 'admin' : 'user',
premium: !!claims.premium, premium: !!claims.premium,
accountKeys, accountKeys,
masterPasswordHint: fallbackProfile?.masterPasswordHint ?? null,
publicKey: fallbackProfile?.publicKey ?? null,
object: 'profile', object: 'profile',
}; };
} }
@@ -256,6 +258,7 @@ export async function completeLogin(
masterKey: Uint8Array masterKey: Uint8Array
): Promise<CompletedLogin> { ): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase(); const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
const baseSession: SessionState = { const baseSession: SessionState = {
accessToken: token.access_token, accessToken: token.access_token,
refreshToken: token.refresh_token, refreshToken: token.refresh_token,
@@ -266,7 +269,7 @@ export async function completeLogin(
() => baseSession, () => baseSession,
() => {} () => {}
); );
const profile = buildTransientProfile(token, normalizedEmail); const profile = buildTransientProfile(token, normalizedEmail, fallbackProfile);
if (!profile.key) { if (!profile.key) {
throw new Error('Missing profile key'); throw new Error('Missing profile key');
} }
+4
View File
@@ -249,6 +249,9 @@
.list-panel { .list-panel {
@apply min-h-0 overflow-auto p-2; @apply min-h-0 overflow-auto p-2;
/* Virtualized rows update top/bottom spacers while scrolling. Chrome's scroll anchoring
can fight those updates and produce subtle up/down jitter, so disable it here. */
overflow-anchor: none;
} }
.list-item { .list-item {
@@ -256,6 +259,7 @@
background: rgba(249, 251, 255, 0.9); background: rgba(249, 251, 255, 0.9);
border-color: var(--line); border-color: var(--line);
contain: paint; contain: paint;
overflow-anchor: none;
} }
.list-item::before { .list-item::before {