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)
|
/// Typed-item add/edit form. Slice 6 ships full Login parity; other
|
||||||
/// Entry form — add or edit an entry.
|
/// 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 { 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 {
|
export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||||
const state = getState();
|
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 = `
|
app.innerHTML = `
|
||||||
<div class="pad">
|
<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>` : ''}
|
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="f-name">name *</label>
|
<label class="label" for="f-title">title *</label>
|
||||||
<input id="f-name" type="text" value="${escapeHtml(existing?.name ?? '')}" placeholder="GitHub">
|
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="f-url">url</label>
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="f-username">username</label>
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="f-password">password</label>
|
<label class="label" for="f-password">password</label>
|
||||||
<div class="inline-row">
|
<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>
|
<button class="btn" id="gen-btn" title="generate">gen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="f-totp">totp secret</label>
|
<label class="label" for="f-totp">totp secret (base32)</label>
|
||||||
<input id="f-totp" type="text" value="${escapeHtml(existing?.totp_secret ?? '')}" placeholder="JBSWY3DPEHPK3PXP">
|
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="f-group">group</label>
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="f-notes">notes</label>
|
<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>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn" id="cancel-btn">cancel</button>
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
@@ -52,92 +122,132 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
|
|||||||
|
|
||||||
// --- Generate password ---
|
// --- Generate password ---
|
||||||
document.getElementById('gen-btn')?.addEventListener('click', async () => {
|
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) {
|
if (resp.ok) {
|
||||||
const data = resp.data as { password: string };
|
const data = resp.data as { password: string };
|
||||||
const pwInput = document.getElementById('f-password') as HTMLInputElement;
|
const pwInput = document.getElementById('f-password') as HTMLInputElement;
|
||||||
pwInput.value = data.password;
|
pwInput.value = data.password;
|
||||||
pwInput.type = 'text'; // Show generated password.
|
pwInput.type = 'text'; // Show generated password.
|
||||||
|
} else {
|
||||||
|
setState({ error: resp.error });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Cancel ---
|
// --- Cancel ---
|
||||||
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
document.getElementById('cancel-btn')?.addEventListener('click', () => goBack(mode));
|
||||||
if (mode === 'edit' && state.selectedId && state.selectedEntry) {
|
|
||||||
navigate('detail');
|
|
||||||
} else {
|
|
||||||
navigate('list');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Save ---
|
// --- Save ---
|
||||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||||
const name = (document.getElementById('f-name') as HTMLInputElement).value.trim();
|
await saveLogin(mode, existing);
|
||||||
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 });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Escape to cancel ---
|
// --- Escape to cancel ---
|
||||||
const escHandler = (e: KeyboardEvent) => {
|
const escHandler = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
document.removeEventListener('keydown', escHandler);
|
document.removeEventListener('keydown', escHandler);
|
||||||
if (mode === 'edit' && state.selectedId && state.selectedEntry) {
|
goBack(mode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', escHandler);
|
||||||
|
|
||||||
|
// 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');
|
navigate('detail');
|
||||||
} else {
|
} else {
|
||||||
navigate('list');
|
navigate('list');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
document.addEventListener('keydown', escHandler);
|
|
||||||
|
|
||||||
// Focus the name field.
|
async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
|
||||||
(document.getElementById('f-name') as HTMLInputElement)?.focus();
|
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