Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ The re-wrap ceremony. Given a held **MasterKey** and a new password, derives a n
The per-type editor module for one **VaultItem** discriminator (login, secure_note, payment_card, identity, totp, developer_credential, crypto_wallet). Lives under `components/vault/item-fields/<Type>Fields.tsx`. Owns its type-specific fields, validation, and any local reveal/mode-switch UI. Reads RHF methods through `useFormContext()` so the outer shell doesn't prop-drill.

**VaultItemFieldsRegistry**:
The `Record<VaultItem['type'], {schema, Component}>` map exported from `components/vault/item-fields/index.ts`. The outer **ItemForm** shell looks up the active type and renders the registered component inside a `FormProvider`. Schemas are imported from `@blindpass/vault` so the wire schema stays the source of truth.
The `Record<VaultItem['type'], {schema, Component}>` map exported from `components/vault/item-fields/index.ts`. The outer **ItemForm** shell looks up the active type and renders the registered component inside a `FormProvider`. Schemas are imported from `@blindpass/vault` so the wire schema stays the source of truth. Its read-only sibling is the **ItemDescriptor** map.

**ItemDescriptor**:
The per-type read-selection map `ITEM_DESCRIPTORS: { [K in VaultItem['type']]: { toSearchText, subtitle } }` in `components/vault/item-fields/descriptors.ts` — a pure, React-free module kept separate from the form registry so the hot list-filter path doesn't pull the field-editor component graph into its bundle. Owns "which fields matter for type X" for every non-form read: `getItemSearchText(item)` (the vault-list search haystack, `route.tsx`) and `getItemSubtitle(item)` (the **ItemCard** / trash / health subtitle, re-exported from `ItemCard.tsx` for path stability). The single audit point that replaced the four scattered copies of per-type field knowledge. _Invariant_: `toSearchText` emits no secret material (`password`, `number`, `cvv`, `secret`, `clientSecret`, `privateKey`, `passphrase`, `mnemonic`) — the search haystack is a plaintext index — and excludes `title` (generic; callers match it separately). Each entry is typed against `Extract<VaultItem, {type: K}>`, so field access needs no casts; the two dispatchers carry the one contained `as never` and return `''` for unknown types. The read-only detail renderer (`$itemId.tsx`) and the TypeFilter's `TYPE_OPTIONS` are not yet folded in — the obvious next descriptor fields.
_Avoid_: item metadata, type config, field map.

### Browser ceremony plumbing

Expand Down
35 changes: 4 additions & 31 deletions apps/web/src/components/vault/ItemCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import type { DecryptedItem } from '@/hooks/useVault';
import { useSyncBoundary } from '@/components/sync/SyncBoundary';
import { useTotpCode, formatTotpCode } from '@/hooks/useTotpCode';
import { ItemAvatar } from './ItemAvatar';
import { getItemSubtitle } from './item-fields/descriptors';

// Re-exported so existing importers of `@/components/vault/ItemCard` keep working.
export { getItemSubtitle };

function TotpRowTail({
item,
Expand Down Expand Up @@ -60,37 +64,6 @@ interface Props {
vaultLabel?: { color: string; name: string };
}

export function getItemSubtitle(item: { type: string; [key: string]: unknown }): string {
switch (item.type) {
case 'login':
return String(item.username ?? '');
case 'secure_note': {
const c = String(item.content ?? '');
return c.slice(0, 40) + (c.length > 40 ? '…' : '');
}
case 'payment_card':
return String(item.cardholderName ?? '');
case 'identity':
return `${item.firstName ?? ''} ${item.lastName ?? ''}`.trim();
case 'totp':
return [item.issuer, item.accountName].filter(Boolean).join(' · ') || 'Authenticator';
case 'developer_credential':
return item.credentialMode === 'ssh_key'
? [item.username, item.host].filter(Boolean).join(' @ ')
: [item.provider, item.environment].filter(Boolean).join(' · ');
case 'crypto_wallet': {
if (item.walletMode !== 'bip39') return '';
const wordCount = String(item.mnemonic ?? '')
.trim()
.split(/\s+/).length;
const primary = String(item.walletName ?? item.addressHint ?? `${wordCount}-word seed`);
return item.network ? `${item.network} · ${primary}` : primary;
}
default:
return '';
}
}

export const ItemCard = memo(function ItemCard({ item, isWeak, isReused, vaultLabel }: Props) {
const [copied, setCopied] = useState(false);
const { pendingItemIds } = useSyncBoundary();
Expand Down
Binary file not shown.
90 changes: 90 additions & 0 deletions apps/web/src/components/vault/item-fields/descriptors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { VaultItem } from '@blindpass/vault';

export type VaultItemType = VaultItem['type'];

type ItemOf<K extends VaultItemType> = Extract<VaultItem, { type: K }>;

/**
* Per-type field-selection for one VaultItem discriminator. The single audit
* point for "which fields matter for type X" outside of the form registry.
*
* Invariants (locked by descriptors.test.ts):
* - `toSearchText` MUST NOT emit secret material (password, number, cvv,
* secret, clientSecret, privateKey, passphrase, mnemonic). The search
* haystack is a plaintext index — leaking a secret here is a disclosure bug.
* - `toSearchText` returns raw case and excludes `title` (generic across all
* types); callers lowercase once and match title separately.
* - `subtitle` is display copy (separators, truncation, fallbacks) and is
* likewise secret-free.
*/
export interface ItemDescriptor<K extends VaultItemType> {
toSearchText: (item: ItemOf<K>) => string;
subtitle: (item: ItemOf<K>) => string;
}

type DescriptorMap = { [K in VaultItemType]: ItemDescriptor<K> };

const join = (parts: (string | undefined)[], sep = ' ') => parts.filter(Boolean).join(sep);

export const ITEM_DESCRIPTORS: DescriptorMap = {
login: {
toSearchText: (item) => join([item.username, item.url]),
subtitle: (item) => item.username,
},
secure_note: {
toSearchText: (item) => item.content,
subtitle: (item) => item.content.slice(0, 40) + (item.content.length > 40 ? '…' : ''),
},
payment_card: {
toSearchText: (item) => item.cardholderName,
subtitle: (item) => item.cardholderName,
},
identity: {
toSearchText: (item) => join([item.firstName, item.lastName]),
subtitle: (item) => `${item.firstName} ${item.lastName}`.trim(),
},
totp: {
toSearchText: (item) => join([item.issuer, item.accountName]),
subtitle: (item) => join([item.issuer, item.accountName], ' · ') || 'Authenticator',
},
developer_credential: {
toSearchText: (item) =>
join(
item.credentialMode === 'token'
? [item.provider, item.environment, item.keyId, item.baseUrl]
: item.credentialMode === 'client_secret_pair'
? [item.provider, item.environment, item.clientId, item.baseUrl]
: [item.username, item.host, item.algorithm, item.fingerprint],
),
subtitle: (item) =>
item.credentialMode === 'ssh_key'
? join([item.username, item.host], ' @ ')
: join([item.provider, item.environment], ' · '),
},
crypto_wallet: {
toSearchText: (item) => join([item.walletName, item.network, item.addressHint]),
subtitle: (item) => {
const wordCount = item.mnemonic.trim().split(/\s+/).length;
const primary = item.walletName ?? item.addressHint ?? `${wordCount}-word seed`;
return item.network ? `${item.network} · ${primary}` : primary;
},
},
};

type LooseItem = { type: string };

/**
* Space-joined, non-secret search haystack for an item. Excludes `title` (the
* caller matches that separately) and returns raw case. Unknown types yield ''
* so no legacy/future discriminator can throw here.
*/
export function getItemSearchText(item: LooseItem): string {
const descriptor = ITEM_DESCRIPTORS[item.type as VaultItemType];
return descriptor ? descriptor.toSearchText(item as never) : '';
}

/** Single-line display subtitle for an item. Secret-free; '' for unknown types. */
export function getItemSubtitle(item: LooseItem): string {
const descriptor = ITEM_DESCRIPTORS[item.type as VaultItemType];
return descriptor ? descriptor.subtitle(item as never) : '';
}
49 changes: 5 additions & 44 deletions apps/web/src/routes/_vault/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { Drawer } from 'vaul';
import { vaultColor } from '@/lib/vaultColor';
import { ItemCard } from '@/components/vault/ItemCard';
import { getItemSearchText } from '@/components/vault/item-fields/descriptors';
import { ItemCardSkeleton } from '@/components/vault/ItemCardSkeleton';
import { OnboardingEmpty } from '@/components/vault/OnboardingEmpty';
import { RecentlyViewedSection } from '@/components/vault/RecentlyViewedSection';
Expand Down Expand Up @@ -599,50 +600,10 @@ function VaultListPanel({
const result = typeFiltered;
if (!search.trim()) return result;
const q = search.toLowerCase();
return result.filter((item) => {
if (item.title.toLowerCase().includes(q)) return true;
switch (item.type) {
case 'login':
return (
(item.username as string).toLowerCase().includes(q) ||
((item.url as string | undefined)?.toLowerCase().includes(q) ?? false)
);
case 'secure_note':
return ((item.content as string) ?? '').toLowerCase().includes(q);
case 'payment_card':
return ((item.cardholderName as string) ?? '').toLowerCase().includes(q);
case 'identity':
return `${(item.firstName as string) ?? ''} ${(item.lastName as string) ?? ''}`
.toLowerCase()
.includes(q);
case 'totp':
return [item.issuer, item.accountName]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(q);
case 'developer_credential':
return (
item.credentialMode === 'token'
? [item.provider, item.environment, item.keyId, item.baseUrl]
: item.credentialMode === 'client_secret_pair'
? [item.provider, item.environment, item.clientId, item.baseUrl]
: [item.username, item.host, item.algorithm, item.fingerprint]
)
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(q);
case 'crypto_wallet':
return [item.walletName, item.network, item.addressHint]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(q);
default:
return false;
}
});
return result.filter(
(item) =>
item.title.toLowerCase().includes(q) || getItemSearchText(item).toLowerCase().includes(q),
);
}, [typeFiltered, search]);

const [recentTick, setRecentTick] = useState(0);
Expand Down