mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: refactor vault component helpers to use dedicated functions for options retrieval
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user