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;
|
connectionTested: boolean;
|
||||||
vaultProbe: VaultProbe | null;
|
vaultProbe: VaultProbe | null;
|
||||||
carrierImageBytes: Uint8Array | null;
|
carrierImageBytes: Uint8Array | null;
|
||||||
|
secondFactor: 'image' | 'keyfile';
|
||||||
referenceImageBytesAttach: Uint8Array | null;
|
referenceImageBytesAttach: Uint8Array | null;
|
||||||
passphrase: string;
|
passphrase: string;
|
||||||
passphraseConfirm: string;
|
passphraseConfirm: string;
|
||||||
@@ -61,7 +62,7 @@ export interface WizardState {
|
|||||||
|
|
||||||
export const state: WizardState = {
|
export const state: WizardState = {
|
||||||
stepId: 'mode', mode: null, hostType: 'gitea', hostUrl: '', repoPath: '', apiToken: '',
|
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,
|
passphrase: '', passphraseConfirm: '', passphraseScore: -1, passphraseGuessesLog10: -1,
|
||||||
passphraseVisible: false, confirmVisible: false, referenceImageBytes: null,
|
passphraseVisible: false, confirmVisible: false, referenceImageBytes: null,
|
||||||
creating: false, attaching: false, error: null, deviceName: '',
|
creating: false, attaching: false, error: null, deviceName: '',
|
||||||
@@ -375,7 +376,7 @@ function renderVaultAttach(): string {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderVaultNew(): string {
|
export function renderVaultNew(): string {
|
||||||
const score = state.passphraseScore;
|
const score = state.passphraseScore;
|
||||||
const hasScore = score >= 0;
|
const hasScore = score >= 0;
|
||||||
const meterClass = hasScore ? `s${score}` : '';
|
const meterClass = hasScore ? `s${score}` : '';
|
||||||
@@ -397,6 +398,14 @@ function renderVaultNew(): string {
|
|||||||
return `
|
return `
|
||||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||||
<h3>create vault</h3>
|
<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">
|
<div class="form-group">
|
||||||
<label class="label">carrier image (JPEG)</label>
|
<label class="label">carrier image (JPEG)</label>
|
||||||
<div class="file-drop ${state.carrierImageBytes ? 'has-file' : ''}" id="file-drop">
|
<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>'}
|
${state.carrierImageBytes ? '<p class="secondary">image loaded</p>' : '<p class="secondary">click to select a JPEG photo</p>'}
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" style="margin-top:4px;">A 256-bit secret will be steganographically embedded in this image.</p>
|
<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="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">
|
<div class="form-group">
|
||||||
<label class="label" for="passphrase">passphrase</label>
|
<label class="label" for="passphrase">passphrase</label>
|
||||||
@@ -492,20 +504,28 @@ function attachVaultAttach(ctx: StepContext): () => void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function attachVaultNew(ctx: StepContext): () => void {
|
function attachVaultNew(ctx: StepContext): () => void {
|
||||||
const fileDrop = document.getElementById('file-drop')!;
|
document.querySelectorAll('[data-factor]').forEach((btn) => {
|
||||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
btn.addEventListener('click', () => {
|
||||||
fileDrop.addEventListener('click', () => fileInput.click());
|
state.secondFactor = (btn as HTMLElement).dataset.factor as 'image' | 'keyfile';
|
||||||
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();
|
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.
|
// 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.
|
// zxcvbn score is computed via the SW on a 150ms debounce — see scheduleRate.
|
||||||
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
|
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
|
||||||
@@ -778,6 +798,7 @@ export function clearWizardState(): void {
|
|||||||
state.connectionTested = false;
|
state.connectionTested = false;
|
||||||
state.vaultProbe = null;
|
state.vaultProbe = null;
|
||||||
state.carrierImageBytes = null;
|
state.carrierImageBytes = null;
|
||||||
|
state.secondFactor = 'image';
|
||||||
state.referenceImageBytesAttach = null;
|
state.referenceImageBytesAttach = null;
|
||||||
state.passphrase = '';
|
state.passphrase = '';
|
||||||
state.passphraseConfirm = '';
|
state.passphraseConfirm = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user