feat(ext/popup): Totp view + form (countdown ring, Steam toggle)
Detail view renders a signature block with a large monospace rotating code and a thin SVG countdown ring that sweeps via CSS transition. The ticker polls get_totp every second and is stopped on teardown (back/edit/trash/Escape/e/d/t). Form has a two-button kind toggle (TOTP / Steam Guard) that re-renders in place while preserving entered values. TOTP uses digits=6 kind='totp'; Steam uses digits=5 kind='steam'. Both default to algorithm='sha1' period_seconds=30. Keyboard shortcuts on detail: Escape=back, e=edit, d=trash, t=copy-code. Guarded against stealing keystrokes from editable targets. Wires totp.renderDetail / totp.renderForm into both dispatchers and calls totp.teardown() alongside the other types so tickers can't leak across views. Closes T8 of the extension 1C-β1 plan (5/5 typed-item modules in place; only T9 picker and T10 acceptance remain).
This commit is contained in:
@@ -9,6 +9,7 @@ import * as secureNote from './types/secure-note';
|
||||
import * as identity from './types/identity';
|
||||
import * as card from './types/card';
|
||||
import * as key from './types/key';
|
||||
import * as totp from './types/totp';
|
||||
|
||||
export async function renderItemDetail(app: HTMLElement): Promise<void> {
|
||||
// Tear down any tickers/handlers from a previous detail render before
|
||||
@@ -19,6 +20,7 @@ export async function renderItemDetail(app: HTMLElement): Promise<void> {
|
||||
identity.teardown();
|
||||
card.teardown();
|
||||
key.teardown();
|
||||
totp.teardown();
|
||||
|
||||
const item = getState().selectedItem;
|
||||
if (!item) { navigate('list'); return; }
|
||||
@@ -29,7 +31,7 @@ export async function renderItemDetail(app: HTMLElement): Promise<void> {
|
||||
case 'identity': return identity.renderDetail(app, item);
|
||||
case 'card': return card.renderDetail(app, item);
|
||||
case 'key': return key.renderDetail(app, item);
|
||||
case 'totp':
|
||||
case 'totp': return totp.renderDetail(app, item);
|
||||
case 'document': return renderComingSoon(app, item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as secureNote from './types/secure-note';
|
||||
import * as identity from './types/identity';
|
||||
import * as card from './types/card';
|
||||
import * as key from './types/key';
|
||||
import * as totp from './types/totp';
|
||||
|
||||
export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||
login.teardown(); // detail-view's ticker/listener don't leak into form
|
||||
@@ -15,6 +16,7 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||
identity.teardown();
|
||||
card.teardown();
|
||||
key.teardown();
|
||||
totp.teardown();
|
||||
const state = getState();
|
||||
const existing = mode === 'edit' ? state.selectedItem : null;
|
||||
const type: ItemType = existing?.type ?? state.newType ?? 'login';
|
||||
@@ -25,7 +27,7 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||
case 'identity': return identity.renderForm(app, mode, existing);
|
||||
case 'card': return card.renderForm(app, mode, existing);
|
||||
case 'key': return key.renderForm(app, mode, existing);
|
||||
case 'totp':
|
||||
case 'totp': return totp.renderForm(app, mode, existing);
|
||||
case 'document': return renderComingSoon(app, type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../popup', async () => {
|
||||
const navigate = vi.fn();
|
||||
const setState = vi.fn();
|
||||
const sendMessage = vi.fn();
|
||||
const getState = vi.fn(() => ({
|
||||
view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
|
||||
searchQuery: '', activeGroup: null, error: null, loading: false,
|
||||
capturedTabId: null, capturedUrl: '', newType: 'totp',
|
||||
}));
|
||||
const escapeHtml = (s: string) => s
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
return { navigate, setState, sendMessage, getState, escapeHtml };
|
||||
});
|
||||
|
||||
import { renderForm } from '../totp';
|
||||
import { sendMessage } from '../../../popup';
|
||||
import { base32Decode } from '../../../../shared/base32';
|
||||
|
||||
describe('Totp save shape', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
vi.mocked(sendMessage).mockReset();
|
||||
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
|
||||
});
|
||||
|
||||
it('TOTP kind: secret round-trips via base32, defaults applied', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = 'GitHub';
|
||||
(document.getElementById('f-secret') as HTMLInputElement).value = 'JBSWY3DPEHPK3PXP';
|
||||
(document.getElementById('f-issuer') as HTMLInputElement).value = 'GitHub';
|
||||
(document.getElementById('f-label') as HTMLInputElement).value = 'alice';
|
||||
|
||||
document.getElementById('save-btn')!.click();
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
|
||||
const calls = vi.mocked(sendMessage).mock.calls;
|
||||
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
|
||||
const msg = addCall![0] as { type: 'add_item'; item: any };
|
||||
expect(msg.item.type).toBe('totp');
|
||||
expect(msg.item.core).toMatchObject({
|
||||
type: 'totp',
|
||||
issuer: 'GitHub',
|
||||
label: 'alice',
|
||||
config: {
|
||||
secret: Array.from(base32Decode('JBSWY3DPEHPK3PXP')),
|
||||
algorithm: 'sha1',
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: 'totp',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Steam kind: digits set to 5, kind set to steam', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = 'Steam';
|
||||
(document.getElementById('f-secret') as HTMLInputElement).value = 'JBSWY3DPEHPK3PXP';
|
||||
(document.getElementById('kind-steam') as HTMLButtonElement).click();
|
||||
// After the click, the form re-renders; re-query the secret field and re-populate.
|
||||
(document.getElementById('f-secret') as HTMLInputElement).value = 'JBSWY3DPEHPK3PXP';
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = 'Steam';
|
||||
|
||||
document.getElementById('save-btn')!.click();
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
|
||||
const calls = vi.mocked(sendMessage).mock.calls;
|
||||
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
|
||||
const msg = addCall![0] as { type: 'add_item'; item: any };
|
||||
expect(msg.item.core.config).toMatchObject({
|
||||
digits: 5,
|
||||
kind: 'steam',
|
||||
algorithm: 'sha1',
|
||||
period_seconds: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects empty secret', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = 'no secret';
|
||||
document.getElementById('save-btn')!.click();
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
|
||||
const calls = vi.mocked(sendMessage).mock.calls;
|
||||
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
|
||||
expect(addCall).toBeUndefined();
|
||||
});
|
||||
});
|
||||
332
extension/src/popup/components/types/totp.ts
Normal file
332
extension/src/popup/components/types/totp.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/// Totp standalone item type. Detail view shows the rotating code in a
|
||||
/// signature block with a thin SVG countdown ring; form has a kind toggle
|
||||
/// (TOTP vs Steam Guard) and a single secret input.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||
import type { Item, ItemId, ManifestEntry, TotpKind } from '../../../shared/types';
|
||||
import { base32Decode, base32Encode } from '../../../shared/base32';
|
||||
import {
|
||||
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers,
|
||||
} from '../fields';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Module-scope lifecycle state
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
let totpTickerId: ReturnType<typeof setInterval> | null = null;
|
||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
function stopTotpTicker(): void {
|
||||
if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; }
|
||||
}
|
||||
|
||||
/// Called by the dispatcher before each render. Stops the countdown ticker
|
||||
/// AND removes the detail-view's keyboard handler so they don't leak.
|
||||
export function teardown(): void {
|
||||
stopTotpTicker();
|
||||
if (activeKeyHandler) {
|
||||
document.removeEventListener('keydown', activeKeyHandler);
|
||||
activeKeyHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Detail view
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
|
||||
if (item.core.type !== 'totp') return;
|
||||
const c = item.core;
|
||||
const secretB32 = base32Encode(new Uint8Array(c.config.secret));
|
||||
const isSteam = c.config.kind === 'steam';
|
||||
|
||||
const headerLine = c.issuer
|
||||
? `${escapeHtml(c.issuer)}${c.label ? ` · ${escapeHtml(c.label)}` : ''}`
|
||||
: escapeHtml(item.title);
|
||||
|
||||
// Countdown ring SVG. Stroke-dashoffset animates per tick (CSS transition
|
||||
// gives the smooth sweep between seconds).
|
||||
const ringSvg = `
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" style="display:block;">
|
||||
<circle cx="16" cy="16" r="14" stroke="#30363d" stroke-width="2" fill="none"/>
|
||||
<circle id="totp-ring-arc" cx="16" cy="16" r="14" stroke="#58a6ff" stroke-width="2" fill="none"
|
||||
stroke-linecap="round" stroke-dasharray="87.96"
|
||||
transform="rotate(-90 16 16)" style="transition:stroke-dashoffset 1s linear;"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const sigInner = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<div style="font-size:11px;color:#8b949e;letter-spacing:0.04em;">${headerLine}</div>
|
||||
<div id="totp-code" style="font-family:monospace;font-size:28px;letter-spacing:0.12em;color:#c9d1d9;margin-top:4px;">${isSteam ? '·····' : '······'}</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
|
||||
${ringSvg}
|
||||
<span id="totp-countdown" style="font-size:10px;color:#8b949e;font-variant-numeric:tabular-nums;">…</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div style="margin-bottom:12px;">
|
||||
<div class="detail-title" style="margin-bottom:8px;">${escapeHtml(item.title)}</div>
|
||||
${renderSignatureBlock({ accent: 'blue', children: sigInner })}
|
||||
</div>
|
||||
${c.issuer ? renderRow({ label: 'issuer', value: c.issuer }) : ''}
|
||||
${c.label ? renderRow({ label: 'label', value: c.label }) : ''}
|
||||
${renderRow({ label: 'kind', value: isSteam ? 'Steam Guard' : 'TOTP' })}
|
||||
${renderConcealedRow({ id: 'totp-secret', label: 'secret', value: secretB32, monospace: true })}
|
||||
<div class="form-actions" style="margin-top:14px;">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn" id="edit-btn">edit</button>
|
||||
<button class="btn danger" id="trash-btn">trash</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
wireFieldHandlers(app);
|
||||
|
||||
// Start the ticker — re-fetches code + countdown every second from the SW.
|
||||
startTotpTicker(item.id, c.config.period_seconds || 30);
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => {
|
||||
teardown();
|
||||
navigate('list');
|
||||
});
|
||||
document.getElementById('edit-btn')?.addEventListener('click', () => {
|
||||
teardown();
|
||||
navigate('edit');
|
||||
});
|
||||
document.getElementById('trash-btn')?.addEventListener('click', async () => {
|
||||
if (!confirm(`Move "${item.title}" to trash?`)) return;
|
||||
teardown();
|
||||
const resp = await sendMessage({ type: 'delete_item', id: item.id });
|
||||
if (!resp.ok) { setState({ error: resp.error }); return; }
|
||||
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');
|
||||
});
|
||||
|
||||
const handler = async (e: KeyboardEvent) => {
|
||||
// Don't steal printable keystrokes from editable fields.
|
||||
const t = e.target;
|
||||
if (t instanceof HTMLElement) {
|
||||
const tag = t.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
|
||||
}
|
||||
switch (e.key) {
|
||||
case 'Escape': teardown(); navigate('list'); break;
|
||||
case 'e': teardown(); navigate('edit'); break;
|
||||
case 't': {
|
||||
// Copy the currently displayed rotating code.
|
||||
const codeEl = document.getElementById('totp-code');
|
||||
const code = codeEl?.textContent?.trim();
|
||||
if (code && code !== '……' && code !== '·····' && code !== '······') {
|
||||
try { await navigator.clipboard.writeText(code); } catch { /* swallow */ }
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'd':
|
||||
e.preventDefault();
|
||||
if (confirm(`Move "${item.title}" to trash?`)) {
|
||||
teardown();
|
||||
const resp = await sendMessage({ type: 'delete_item', id: item.id });
|
||||
if (!resp.ok) { setState({ error: resp.error }); return; }
|
||||
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');
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
activeKeyHandler = handler;
|
||||
document.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Countdown ticker
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function startTotpTicker(id: ItemId, period: number): void {
|
||||
stopTotpTicker();
|
||||
const circumference = 2 * Math.PI * 14;
|
||||
const tick = async () => {
|
||||
const r = await sendMessage({ type: 'get_totp', id });
|
||||
if (!r.ok) return;
|
||||
const { code, expires_at } = r.data as { code: string; expires_at: number };
|
||||
const codeEl = document.getElementById('totp-code');
|
||||
const cdEl = document.getElementById('totp-countdown');
|
||||
const ring = document.getElementById('totp-ring-arc') as SVGCircleElement | null;
|
||||
if (codeEl) codeEl.textContent = code;
|
||||
const remaining = Math.max(0, expires_at - Math.floor(Date.now() / 1000));
|
||||
if (cdEl) cdEl.textContent = `${remaining}s`;
|
||||
if (ring) {
|
||||
const offset = circumference * (1 - remaining / period);
|
||||
ring.style.strokeDashoffset = String(offset);
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
totpTickerId = setInterval(() => void tick(), 1000);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Form (add / edit) with TOTP/Steam kind toggle
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
let formKind: TotpKind = 'totp';
|
||||
|
||||
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
|
||||
const state = getState();
|
||||
const title = existing?.title ?? '';
|
||||
const c = (existing?.core.type === 'totp') ? existing.core : null;
|
||||
formKind = c?.config.kind === 'steam' ? 'steam' : 'totp';
|
||||
const secretB32 = c?.config.secret ? base32Encode(new Uint8Array(c.config.secret)) : '';
|
||||
|
||||
const renderInner = (): string => `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new totp' : 'edit totp'}</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group"><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">kind</label>
|
||||
<div class="inline-row">
|
||||
<button type="button" id="kind-totp" class="btn ${formKind === 'totp' ? 'btn-primary' : ''}" style="flex:1;">TOTP</button>
|
||||
<button type="button" id="kind-steam" class="btn ${formKind === 'steam' ? 'btn-primary' : ''}" style="flex:1;">Steam Guard</button>
|
||||
</div>
|
||||
<p class="muted" style="font-size:11px;margin-top:4px;" id="kind-blurb">${formKind === 'steam' ? 'Steam Mobile Authenticator (5-char alphanumeric)' : 'Standard time-based codes (6 digits)'}</p>
|
||||
</div>
|
||||
<div class="form-group"><label class="label" for="f-secret">secret (base32) *</label>
|
||||
<input id="f-secret" type="text" value="${escapeHtml(secretB32)}" placeholder="JBSWY3DPEHPK3PXP"></div>
|
||||
<div class="form-group"><label class="label" for="f-issuer">issuer</label>
|
||||
<input id="f-issuer" type="text" value="${escapeHtml(c?.issuer ?? '')}" placeholder="GitHub"></div>
|
||||
<div class="form-group"><label class="label" for="f-label">label</label>
|
||||
<input id="f-label" type="text" value="${escapeHtml(c?.label ?? '')}" placeholder="alice@github.com"></div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="cancel-btn">cancel</button>
|
||||
<button class="btn btn-primary" id="save-btn">save</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.innerHTML = renderInner();
|
||||
|
||||
// In-place re-render on kind toggle. Preserves current input values so
|
||||
// the user doesn't lose what they've typed.
|
||||
const reRender = (): void => {
|
||||
const titleVal = (document.getElementById('f-title') as HTMLInputElement).value;
|
||||
const secretVal = (document.getElementById('f-secret') as HTMLInputElement).value;
|
||||
const issuerVal = (document.getElementById('f-issuer') as HTMLInputElement).value;
|
||||
const labelVal = (document.getElementById('f-label') as HTMLInputElement).value;
|
||||
app.innerHTML = renderInner();
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = titleVal;
|
||||
(document.getElementById('f-secret') as HTMLInputElement).value = secretVal;
|
||||
(document.getElementById('f-issuer') as HTMLInputElement).value = issuerVal;
|
||||
(document.getElementById('f-label') as HTMLInputElement).value = labelVal;
|
||||
wireKindToggle();
|
||||
wireFormButtons(mode, existing);
|
||||
};
|
||||
|
||||
const wireKindToggle = (): void => {
|
||||
document.getElementById('kind-totp')?.addEventListener('click', () => {
|
||||
formKind = 'totp';
|
||||
reRender();
|
||||
});
|
||||
document.getElementById('kind-steam')?.addEventListener('click', () => {
|
||||
formKind = 'steam';
|
||||
reRender();
|
||||
});
|
||||
};
|
||||
|
||||
wireKindToggle();
|
||||
wireFormButtons(mode, existing);
|
||||
|
||||
const escHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||
}
|
||||
|
||||
function wireFormButtons(mode: 'add' | 'edit', existing: Item | null): void {
|
||||
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
});
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
await saveTotp(mode, existing);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveTotp(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
|
||||
const state = getState();
|
||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||
if (!title) { setState({ error: 'Title is required' }); return; }
|
||||
|
||||
const secretStr = (document.getElementById('f-secret') as HTMLInputElement).value.trim();
|
||||
if (!secretStr) { setState({ error: 'Secret is required' }); return; }
|
||||
|
||||
let secretBytes: Uint8Array;
|
||||
try {
|
||||
secretBytes = base32Decode(secretStr);
|
||||
} catch (err) {
|
||||
setState({ error: `Invalid TOTP secret: ${err instanceof Error ? err.message : String(err)}` });
|
||||
return;
|
||||
}
|
||||
if (secretBytes.length === 0) { setState({ error: 'Secret decoded to zero bytes' }); return; }
|
||||
|
||||
const get = (id: string) => (document.getElementById(id) as HTMLInputElement).value.trim();
|
||||
|
||||
const isSteam = formKind === 'steam';
|
||||
const core = {
|
||||
type: 'totp' as const,
|
||||
config: {
|
||||
secret: Array.from(secretBytes),
|
||||
algorithm: 'sha1' as const,
|
||||
digits: isSteam ? 5 : 6,
|
||||
period_seconds: 30,
|
||||
kind: (isSteam ? 'steam' : 'totp') as TotpKind,
|
||||
},
|
||||
issuer: get('f-issuer') || undefined,
|
||||
label: get('f-label') || undefined,
|
||||
};
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const item: Item = {
|
||||
id: existing?.id ?? '',
|
||||
title, type: 'totp',
|
||||
tags: existing?.tags ?? [],
|
||||
favorite: existing?.favorite ?? false,
|
||||
group: existing?.group, notes: existing?.notes,
|
||||
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 });
|
||||
const resp = mode === 'add'
|
||||
? await sendMessage({ type: 'add_item', item })
|
||||
: 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