diff --git a/extension/src/popup/components/item-form.ts b/extension/src/popup/components/item-form.ts
index 16fd9a9..b58dde1 100644
--- a/extension/src/popup/components/item-form.ts
+++ b/extension/src/popup/components/item-form.ts
@@ -1,47 +1,117 @@
-// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires)
-/// Entry form — add or edit an entry.
+/// Typed-item add/edit form. Slice 6 ships full Login parity; other
+/// types show a coming-soon placeholder (use the CLI for now).
+///
+/// Carry-forward from Slice 5 review M3: on edit, trashed_at is
+/// explicitly reset to undefined so stale trash state cannot survive an
+/// edit. (The capture path already uses spread + fetched item; this
+/// popup flow uses state.selectedItem.)
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
-import type { Entry, ManifestEntry } from '../../shared/types';
+import type {
+ Item, ItemId, ItemType, ManifestEntry, LoginCore, TotpConfig,
+} from '../../shared/types';
+import { DEFAULT_PASSWORD_REQUEST } from '../../shared/types';
+import { base32Decode, base32Encode } from '../../shared/base32';
+
+// Which types support add/edit in Slice 6.
+function isEditableType(t: ItemType): boolean {
+ return t === 'login';
+}
export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
const state = getState();
- const existing = mode === 'edit' ? state.selectedEntry : null;
+ const existing = mode === 'edit' ? state.selectedItem : null;
+
+ // Determine the type we're editing/creating. Add defaults to login.
+ const type: ItemType = existing?.type ?? 'login';
+
+ if (!isEditableType(type)) {
+ renderComingSoon(app, type);
+ return;
+ }
+
+ renderLoginForm(app, mode, existing);
+}
+
+// --- Coming-soon -------------------------------------------------------
+
+function renderComingSoon(app: HTMLElement, type: ItemType): void {
+ app.innerHTML = `
+
-
${mode === 'add' ? 'new entry' : 'edit entry'}
+
${mode === 'add' ? 'new login' : 'edit login'}
${state.error ? `
${escapeHtml(state.error)}
` : ''}
-
-
+
+
-
+
-
+
-
-
+
+
-
+
-
+
@@ -52,92 +122,132 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
// --- Generate password ---
document.getElementById('gen-btn')?.addEventListener('click', async () => {
- const resp = await sendMessage({ type: 'generate_password', length: 24 });
+ const resp = await sendMessage({ type: 'generate_password', request: DEFAULT_PASSWORD_REQUEST });
if (resp.ok) {
const data = resp.data as { password: string };
const pwInput = document.getElementById('f-password') as HTMLInputElement;
pwInput.value = data.password;
pwInput.type = 'text'; // Show generated password.
+ } else {
+ setState({ error: resp.error });
}
});
// --- Cancel ---
- document.getElementById('cancel-btn')?.addEventListener('click', () => {
- if (mode === 'edit' && state.selectedId && state.selectedEntry) {
- navigate('detail');
- } else {
- navigate('list');
- }
- });
+ document.getElementById('cancel-btn')?.addEventListener('click', () => goBack(mode));
// --- Save ---
document.getElementById('save-btn')?.addEventListener('click', async () => {
- const name = (document.getElementById('f-name') as HTMLInputElement).value.trim();
- const url = (document.getElementById('f-url') as HTMLInputElement).value.trim() || undefined;
- const username = (document.getElementById('f-username') as HTMLInputElement).value.trim() || undefined;
- const password = (document.getElementById('f-password') as HTMLInputElement).value;
- const totp_secret = (document.getElementById('f-totp') as HTMLInputElement).value.trim() || undefined;
- const group = (document.getElementById('f-group') as HTMLInputElement).value.trim() || undefined;
- const notes = (document.getElementById('f-notes') as HTMLTextAreaElement).value.trim() || undefined;
-
- if (!name) {
- setState({ error: 'Name is required' });
- return;
- }
- if (!password) {
- setState({ error: 'Password is required' });
- return;
- }
-
- const now = new Date().toISOString();
- const entry: Entry = {
- name,
- url,
- username,
- password,
- notes,
- totp_secret,
- group,
- created_at: existing?.created_at ?? now,
- updated_at: now,
- };
-
- setState({ loading: true, error: null });
-
- let resp;
- if (mode === 'add') {
- resp = await sendMessage({ type: 'add_entry', entry });
- } else {
- resp = await sendMessage({ type: 'update_entry', id: state.selectedId!, entry });
- }
-
- if (resp.ok) {
- // Refresh entries and go to list.
- const listResp = await sendMessage({ type: 'list_entries' });
- if (listResp.ok) {
- const data = listResp.data as { entries: Array<[string, ManifestEntry]> };
- navigate('list', { entries: data.entries, selectedId: null, selectedEntry: null });
- } else {
- navigate('list');
- }
- } else {
- setState({ loading: false, error: resp.error });
- }
+ await saveLogin(mode, existing);
});
// --- Escape to cancel ---
const escHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
document.removeEventListener('keydown', escHandler);
- if (mode === 'edit' && state.selectedId && state.selectedEntry) {
- navigate('detail');
- } else {
- navigate('list');
- }
+ goBack(mode);
}
};
document.addEventListener('keydown', escHandler);
- // Focus the name field.
- (document.getElementById('f-name') as HTMLInputElement)?.focus();
+ // Focus the title field.
+ (document.getElementById('f-title') as HTMLInputElement | null)?.focus();
+}
+
+function goBack(mode: 'add' | 'edit'): void {
+ const s = getState();
+ if (mode === 'edit' && s.selectedId && s.selectedItem) {
+ navigate('detail');
+ } else {
+ navigate('list');
+ }
+}
+
+async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise {
+ const state = getState();
+
+ const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
+ const url = (document.getElementById('f-url') as HTMLInputElement).value.trim();
+ const username = (document.getElementById('f-username') as HTMLInputElement).value.trim();
+ const password = (document.getElementById('f-password') as HTMLInputElement).value;
+ const totpStr = (document.getElementById('f-totp') as HTMLInputElement).value.trim();
+ const group = (document.getElementById('f-group') as HTMLInputElement).value.trim();
+ const notes = (document.getElementById('f-notes') as HTMLTextAreaElement).value;
+
+ if (!title) {
+ setState({ error: 'Title is required' });
+ return;
+ }
+
+ let totp: TotpConfig | undefined;
+ if (totpStr) {
+ try {
+ const bytes = base32Decode(totpStr);
+ totp = {
+ secret: Array.from(bytes),
+ algorithm: 'sha1',
+ digits: 6,
+ period_seconds: 30,
+ kind: 'totp',
+ };
+ } catch (err) {
+ setState({ error: `Invalid TOTP secret: ${err instanceof Error ? err.message : String(err)}` });
+ return;
+ }
+ }
+
+ const now = Math.floor(Date.now() / 1000);
+ const core: LoginCore & { type: 'login' } = {
+ type: 'login',
+ username: username || undefined,
+ password: password || undefined,
+ url: url || undefined,
+ totp,
+ };
+
+ // Build the Item. On edit we preserve id/created/tags/favorite/sections/
+ // attachments/field_history from the existing item, but we EXPLICITLY
+ // set trashed_at: undefined — never preserve stale trash state through
+ // an edit (carry-forward from Slice 5 review M3).
+ const item: Item = {
+ id: existing?.id ?? '', // SW fills in for add_item.
+ title,
+ type: 'login',
+ tags: existing?.tags ?? [],
+ favorite: existing?.favorite ?? false,
+ group: group || undefined,
+ notes: notes || undefined,
+ created: existing?.created ?? now,
+ modified: now,
+ trashed_at: undefined,
+ core,
+ sections: existing?.sections ?? [],
+ attachments: existing?.attachments ?? [],
+ field_history: existing?.field_history ?? {},
+ };
+
+ setState({ loading: true, error: null });
+
+ let resp;
+ if (mode === 'add') {
+ resp = await sendMessage({ type: 'add_item', item });
+ } else {
+ if (!state.selectedId) {
+ setState({ loading: false, error: 'Missing item id' });
+ return;
+ }
+ resp = await sendMessage({ type: 'update_item', id: state.selectedId, item });
+ }
+
+ if (resp.ok) {
+ const listResp = await sendMessage({ type: 'list_items' });
+ if (listResp.ok) {
+ const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
+ navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
+ } else {
+ navigate('list');
+ }
+ } else {
+ setState({ loading: false, error: resp.error });
+ }
}