feat(ext/popup): Login add/edit form on typed-item API
Rewrites item-form.ts for the typed-item Item shape. Login is the only
editable type in Slice 6; other types fall through to coming-soon.
Form fields: title (required) + url + username + password (with gen
button backed by DEFAULT_PASSWORD_REQUEST) + totp (base32) + group +
notes. TOTP base32 is decoded via shared/base32 and wrapped as a
number[] into FieldValue-shape TotpConfig { secret, algorithm: sha1,
digits: 6, period_seconds: 30, kind: 'totp' }. Decode failure sets
state.error and aborts.
Save constructs a full Item envelope (id, title, type, tags, favorite,
group, notes, created, modified, trashed_at, core, sections,
attachments, field_history). On edit we preserve the existing item's
metadata but EXPLICITLY set trashed_at: undefined — carry-forward
from Slice 5 review M3, so an edit cannot accidentally preserve stale
trash state.
@ts-nocheck removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${escapeHtml(type.replace('_', ' '))}</div>
|
||||
<p class="muted">editing ${escapeHtml(type)} items is coming in a later slice.</p>
|
||||
<p class="muted" style="margin-top:8px;">use the CLI for now.</p>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', handler);
|
||||
navigate('list');
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
// --- Login add/edit ----------------------------------------------------
|
||||
|
||||
/// Encode TotpConfig secret bytes back to a base32 display string.
|
||||
function totpSecretToBase32(totp: TotpConfig | undefined): string {
|
||||
if (!totp) return '';
|
||||
return base32Encode(new Uint8Array(totp.secret));
|
||||
}
|
||||
|
||||
function renderLoginForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
|
||||
const state = getState();
|
||||
const existingCore = (existing?.core.type === 'login')
|
||||
? (existing.core as LoginCore & { type: 'login' })
|
||||
: null;
|
||||
|
||||
const title = existing?.title ?? '';
|
||||
const url = existingCore?.url ?? '';
|
||||
const username = existingCore?.username ?? '';
|
||||
const password = existingCore?.password ?? '';
|
||||
const totpStr = totpSecretToBase32(existingCore?.totp);
|
||||
const group = existing?.group ?? '';
|
||||
const notes = existing?.notes ?? '';
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new entry' : 'edit entry'}</div>
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new login' : 'edit login'}</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-name">name *</label>
|
||||
<input id="f-name" type="text" value="${escapeHtml(existing?.name ?? '')}" placeholder="GitHub">
|
||||
<label class="label" for="f-title">title *</label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-url">url</label>
|
||||
<input id="f-url" type="text" value="${escapeHtml(existing?.url ?? '')}" placeholder="https://github.com/login">
|
||||
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-username">username</label>
|
||||
<input id="f-username" type="text" value="${escapeHtml(existing?.username ?? '')}" placeholder="alice@example.com">
|
||||
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-password">password</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-password" type="password" value="${escapeHtml(existing?.password ?? '')}">
|
||||
<input id="f-password" type="password" value="${escapeHtml(password)}">
|
||||
<button class="btn" id="gen-btn" title="generate">gen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-totp">totp secret</label>
|
||||
<input id="f-totp" type="text" value="${escapeHtml(existing?.totp_secret ?? '')}" placeholder="JBSWY3DPEHPK3PXP">
|
||||
<label class="label" for="f-totp">totp secret (base32)</label>
|
||||
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-group">group</label>
|
||||
<input id="f-group" type="text" value="${escapeHtml(existing?.group ?? '')}" placeholder="work">
|
||||
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-notes">notes</label>
|
||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(existing?.notes ?? '')}</textarea>
|
||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="cancel-btn">cancel</button>
|
||||
@@ -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<void> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user