|
|
|
|
@@ -235,7 +235,20 @@ function startTotpTicker(id: ItemId): void {
|
|
|
|
|
// Form (add / edit)
|
|
|
|
|
// ----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
|
|
|
|
|
export interface RenderFormOptions {
|
|
|
|
|
surface?: 'popup' | 'fullscreen';
|
|
|
|
|
/** When true, renderForm skips its own save/cancel buttons (caller provides them in a sticky bar). */
|
|
|
|
|
externalActions?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function renderForm(
|
|
|
|
|
app: HTMLElement,
|
|
|
|
|
mode: 'add' | 'edit',
|
|
|
|
|
existing: Item | null,
|
|
|
|
|
opts: RenderFormOptions = {}
|
|
|
|
|
): void {
|
|
|
|
|
const surface = opts.surface ?? 'popup';
|
|
|
|
|
const externalActions = opts.externalActions ?? false;
|
|
|
|
|
const state = getState();
|
|
|
|
|
const existingCore = (existing?.core.type === 'login')
|
|
|
|
|
? (existing.core as LoginCore & { type: 'login' })
|
|
|
|
|
@@ -254,58 +267,86 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|
|
|
|
: [];
|
|
|
|
|
let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? [];
|
|
|
|
|
|
|
|
|
|
const titleFieldHtml = `
|
|
|
|
|
<div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
|
|
|
|
|
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>`;
|
|
|
|
|
|
|
|
|
|
const urlFieldHtml = `
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
<label class="label" for="f-url">url</label>
|
|
|
|
|
<div class="inline-row">
|
|
|
|
|
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login">
|
|
|
|
|
<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">⤓</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="hostname-chip-row" class="hostname-chip-row" hidden></div>
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
const groupFieldHtml = `
|
|
|
|
|
<div class="form-group"><label class="label" for="f-group">group</label>
|
|
|
|
|
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div>`;
|
|
|
|
|
|
|
|
|
|
const usernameFieldHtml = `
|
|
|
|
|
<div class="form-group"><label class="label" for="f-username">username</label>
|
|
|
|
|
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com"></div>`;
|
|
|
|
|
|
|
|
|
|
const passwordFieldHtml = `
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
<label class="label" for="f-password">password</label>
|
|
|
|
|
<div class="inline-row">
|
|
|
|
|
<input id="f-password" type="password" value="${escapeHtml(password)}">
|
|
|
|
|
<button id="reveal-password-btn" class="glyph-btn" type="button" title="reveal">⊙</button>
|
|
|
|
|
<button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">↻</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="strength-bar-row" class="strength-bar-row" hidden>
|
|
|
|
|
<div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
|
|
|
|
|
<div class="strength-label"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
const totpFieldHtml = `
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
<label class="label" for="f-totp">totp secret (base32)</label>
|
|
|
|
|
<div class="inline-row">
|
|
|
|
|
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
|
|
|
|
|
<button id="totp-qr-btn" class="glyph-btn" type="button" title="paste / upload QR">◫</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="totp-preview-row" class="totp-preview" hidden>
|
|
|
|
|
<span class="totp-code">…</span>
|
|
|
|
|
<span class="totp-countdown">…</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="totp-qr-panel" class="totp-qr-panel" hidden>
|
|
|
|
|
<input id="totp-qr-file" type="file" accept="image/*" />
|
|
|
|
|
<div style="font-size:10px;color:var(--text-dim,#6b7888);margin-top:4px;">paste image, drop image, or pick a file</div>
|
|
|
|
|
<div id="totp-qr-error" class="totp-qr-error"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
const identityHtml = `
|
|
|
|
|
<div data-form-section="identity" class="${surface === 'fullscreen' ? 'glass form-col' : ''}">
|
|
|
|
|
${surface === 'fullscreen' ? '<div class="col-header">Identity</div>' : ''}
|
|
|
|
|
${titleFieldHtml}
|
|
|
|
|
${urlFieldHtml}
|
|
|
|
|
${groupFieldHtml}
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
const credentialsHtml = `
|
|
|
|
|
<div data-form-section="credentials" class="${surface === 'fullscreen' ? 'glass form-col' : ''}">
|
|
|
|
|
${surface === 'fullscreen' ? '<div class="col-header">Credentials</div>' : ''}
|
|
|
|
|
${usernameFieldHtml}
|
|
|
|
|
${passwordFieldHtml}
|
|
|
|
|
${totpFieldHtml}
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
const sectionsHtml = surface === 'fullscreen'
|
|
|
|
|
? `<div class="form-grid">${identityHtml}${credentialsHtml}</div>`
|
|
|
|
|
: `${identityHtml}${credentialsHtml}`;
|
|
|
|
|
|
|
|
|
|
app.innerHTML = `
|
|
|
|
|
<div class="pad">
|
|
|
|
|
${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })}
|
|
|
|
|
${surface === 'popup' ? renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' }) : ''}
|
|
|
|
|
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
|
|
|
|
|
|
|
|
|
<div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</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>
|
|
|
|
|
<div class="inline-row">
|
|
|
|
|
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login">
|
|
|
|
|
<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">⤓</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="hostname-chip-row" class="hostname-chip-row" hidden></div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="form-group"><label class="label" for="f-username">username</label>
|
|
|
|
|
<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(password)}">
|
|
|
|
|
<button id="reveal-password-btn" class="glyph-btn" type="button" title="reveal">⊙</button>
|
|
|
|
|
<button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">↻</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="strength-bar-row" class="strength-bar-row" hidden>
|
|
|
|
|
<div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
|
|
|
|
|
<div class="strength-label"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
<label class="label" for="f-totp">totp secret (base32)</label>
|
|
|
|
|
<div class="inline-row">
|
|
|
|
|
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
|
|
|
|
|
<button id="totp-qr-btn" class="glyph-btn" type="button" title="paste / upload QR">◫</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="totp-preview-row" class="totp-preview" hidden>
|
|
|
|
|
<span class="totp-code">…</span>
|
|
|
|
|
<span class="totp-countdown">…</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="totp-qr-panel" class="totp-qr-panel" hidden>
|
|
|
|
|
<input id="totp-qr-file" type="file" accept="image/*" />
|
|
|
|
|
<div style="font-size:10px;color:var(--text-dim,#6b7888);margin-top:4px;">paste image, drop image, or pick a file</div>
|
|
|
|
|
<div id="totp-qr-error" class="totp-qr-error"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="form-group"><label class="label" for="f-group">group</label>
|
|
|
|
|
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div>
|
|
|
|
|
${sectionsHtml}
|
|
|
|
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
<div class="notes-with-toggle">
|
|
|
|
|
@@ -317,10 +358,12 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|
|
|
|
|
|
|
|
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
|
|
|
|
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
|
|
|
|
|
${externalActions ? '' : `
|
|
|
|
|
<div class="form-actions">
|
|
|
|
|
<button class="btn" id="cancel-btn">cancel</button>
|
|
|
|
|
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
|
|
|
|
</div>
|
|
|
|
|
`}
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
@@ -433,7 +476,7 @@ function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; e
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise<void> {
|
|
|
|
|
export async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise<void> {
|
|
|
|
|
const state = getState();
|
|
|
|
|
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
|
|
|
|
const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value;
|
|
|
|
|
|