feat(ext/setup): second-factor container choice (image | key file)
This commit is contained in:
24
extension/src/setup/__tests__/setup-steps.test.ts
Normal file
24
extension/src/setup/__tests__/setup-steps.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { state, clearWizardState, renderVaultNew } from '../setup-steps';
|
||||
|
||||
describe('renderVaultNew — second-factor container choice', () => {
|
||||
afterEach(() => clearWizardState());
|
||||
|
||||
it('default (image) mode shows the stego note', () => {
|
||||
const html = renderVaultNew();
|
||||
expect(html).toContain('A 256-bit secret will be steganographically embedded');
|
||||
});
|
||||
|
||||
it('keyfile mode shows "Key File" and hides the carrier drop / stego note', () => {
|
||||
state.secondFactor = 'keyfile';
|
||||
const html = renderVaultNew();
|
||||
expect(html).toContain('Key File');
|
||||
expect(html).not.toContain('A 256-bit secret will be steganographically embedded');
|
||||
});
|
||||
|
||||
it('keyfile mode shows the key-file note copy', () => {
|
||||
state.secondFactor = 'keyfile';
|
||||
const html = renderVaultNew();
|
||||
expect(html).toContain('A 32-byte key file will be generated');
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ export interface WizardState {
|
||||
connectionTested: boolean;
|
||||
vaultProbe: VaultProbe | null;
|
||||
carrierImageBytes: Uint8Array | null;
|
||||
secondFactor: 'image' | 'keyfile';
|
||||
referenceImageBytesAttach: Uint8Array | null;
|
||||
passphrase: string;
|
||||
passphraseConfirm: string;
|
||||
@@ -61,7 +62,7 @@ export interface WizardState {
|
||||
|
||||
export const state: WizardState = {
|
||||
stepId: 'mode', mode: null, hostType: 'gitea', hostUrl: '', repoPath: '', apiToken: '',
|
||||
connectionTested: false, vaultProbe: null, carrierImageBytes: null, referenceImageBytesAttach: null,
|
||||
connectionTested: false, vaultProbe: null, carrierImageBytes: null, secondFactor: 'image', referenceImageBytesAttach: null,
|
||||
passphrase: '', passphraseConfirm: '', passphraseScore: -1, passphraseGuessesLog10: -1,
|
||||
passphraseVisible: false, confirmVisible: false, referenceImageBytes: null,
|
||||
creating: false, attaching: false, error: null, deviceName: '',
|
||||
@@ -375,7 +376,7 @@ function renderVaultAttach(): string {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderVaultNew(): string {
|
||||
export function renderVaultNew(): string {
|
||||
const score = state.passphraseScore;
|
||||
const hasScore = score >= 0;
|
||||
const meterClass = hasScore ? `s${score}` : '';
|
||||
@@ -397,6 +398,14 @@ function renderVaultNew(): string {
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>create vault</h3>
|
||||
<div class="form-group">
|
||||
<label class="label">second factor</label>
|
||||
<div class="toggle-group">
|
||||
<button class="${state.secondFactor === 'image' ? 'active' : ''}" data-factor="image">Reference Image</button>
|
||||
<button class="${state.secondFactor === 'keyfile' ? 'active' : ''}" data-factor="keyfile">Key File</button>
|
||||
</div>
|
||||
</div>
|
||||
${state.secondFactor === 'image' ? `
|
||||
<div class="form-group">
|
||||
<label class="label">carrier image (JPEG)</label>
|
||||
<div class="file-drop ${state.carrierImageBytes ? 'has-file' : ''}" id="file-drop">
|
||||
@@ -404,7 +413,10 @@ function renderVaultNew(): string {
|
||||
${state.carrierImageBytes ? '<p class="secondary">image loaded</p>' : '<p class="secondary">click to select a JPEG photo</p>'}
|
||||
</div>
|
||||
<p class="muted" style="margin-top:4px;">A 256-bit secret will be steganographically embedded in this image.</p>
|
||||
</div>
|
||||
</div>` : `
|
||||
<div class="form-group">
|
||||
<p class="muted" style="margin-top:4px;">A 32-byte key file will be generated for you to save — it is your second factor.</p>
|
||||
</div>`}
|
||||
<div class="pass-help">A long phrase of unrelated words is stronger than a short complex password. Your vault needs <strong>good</strong> (score ≥ 3) to continue.</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="passphrase">passphrase</label>
|
||||
@@ -492,20 +504,28 @@ function attachVaultAttach(ctx: StepContext): () => void {
|
||||
}
|
||||
|
||||
function attachVaultNew(ctx: StepContext): () => void {
|
||||
const fileDrop = document.getElementById('file-drop')!;
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
fileDrop.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
state.carrierImageBytes = new Uint8Array(reader.result as ArrayBuffer);
|
||||
state.error = null;
|
||||
document.querySelectorAll('[data-factor]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
state.secondFactor = (btn as HTMLElement).dataset.factor as 'image' | 'keyfile';
|
||||
ctx.rerender();
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
});
|
||||
if (state.secondFactor === 'image') {
|
||||
const fileDrop = document.getElementById('file-drop')!;
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
fileDrop.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
state.carrierImageBytes = new Uint8Array(reader.result as ArrayBuffer);
|
||||
state.error = null;
|
||||
ctx.rerender();
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
// Track passphrase changes inline (no full re-render) so the input keeps focus.
|
||||
// zxcvbn score is computed via the SW on a 150ms debounce — see scheduleRate.
|
||||
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
|
||||
@@ -778,6 +798,7 @@ export function clearWizardState(): void {
|
||||
state.connectionTested = false;
|
||||
state.vaultProbe = null;
|
||||
state.carrierImageBytes = null;
|
||||
state.secondFactor = 'image';
|
||||
state.referenceImageBytesAttach = null;
|
||||
state.passphrase = '';
|
||||
state.passphraseConfirm = '';
|
||||
|
||||
Reference in New Issue
Block a user