feat(ext/setup): polished passphrase entry UX
Setup wizard step 3 now has self-explanatory passphrase feedback: - Strength meter: 5 segments with smooth color transitions (very-weak/weak/fair/good/strong). Tier 4 gets a subtle glow. - Nuanced label (lowercase, tracked): "very weak" / "weak" / "fair" / "good" / "strong" — color-matched to each tier. - Entropy readout line: "~10^N guesses — <time to crack>" with tiered shorthand (trivial / minutes-on-GPU / hours-to-days / years-on-consumer / beyond consumer / uncrackable). - Live char counter in the strength row. - Eye toggle buttons on both passphrase fields. Flip type="password" <-> type="text" without re-render, preserving focus + cursor. - Live match indicator (✓ / ✗) between the confirm field and its eye toggle. Updates per keystroke. - Create button gate widened: now requires score >= 3 AND confirm field filled AND confirm matches. Disabled button carries a tooltip saying which condition failed. - Contextual help box above the passphrase field explaining the "long phrase > complex password" idea + the score >= 3 threshold. All live-update paths (counter, label, entropy, match indicator, button gate) go through updateStrengthUi() which targets the DOM directly — no full re-render, so focus/cursor survive every keystroke. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,21 +51,21 @@
|
|||||||
.strength-bar {
|
.strength-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
margin-top: 6px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.strength-bar .seg {
|
.strength-bar .seg {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 4px;
|
height: 5px;
|
||||||
background: #21262d;
|
background: #21262d;
|
||||||
border-radius: 2px;
|
border-radius: 3px;
|
||||||
transition: background 0.2s;
|
transition: background 0.25s ease, box-shadow 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* zxcvbn score-driven colors. Higher-scored bars light up earlier bars too. */
|
/* zxcvbn score-driven colors. Higher-scored bars light up earlier bars too. */
|
||||||
.strength-bar.s0 .seg.i0 { background: #f85149; }
|
.strength-bar.s0 .seg.i0 { background: #f85149; }
|
||||||
.strength-bar.s1 .seg.i0,
|
.strength-bar.s1 .seg.i0,
|
||||||
.strength-bar.s1 .seg.i1 { background: #db6d28; }
|
.strength-bar.s1 .seg.i1 { background: #f08d49; }
|
||||||
.strength-bar.s2 .seg.i0,
|
.strength-bar.s2 .seg.i0,
|
||||||
.strength-bar.s2 .seg.i1,
|
.strength-bar.s2 .seg.i1,
|
||||||
.strength-bar.s2 .seg.i2 { background: #d29922; }
|
.strength-bar.s2 .seg.i2 { background: #d29922; }
|
||||||
@@ -73,14 +73,101 @@
|
|||||||
.strength-bar.s3 .seg.i1,
|
.strength-bar.s3 .seg.i1,
|
||||||
.strength-bar.s3 .seg.i2,
|
.strength-bar.s3 .seg.i2,
|
||||||
.strength-bar.s3 .seg.i3 { background: #3fb950; }
|
.strength-bar.s3 .seg.i3 { background: #3fb950; }
|
||||||
.strength-bar.s4 .seg { background: #3fb950; }
|
.strength-bar.s4 .seg {
|
||||||
|
background: #56d364;
|
||||||
|
box-shadow: 0 0 4px rgba(86, 211, 100, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strength-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
.strength-label {
|
.strength-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
margin-top: 3px;
|
margin: 0;
|
||||||
|
text-transform: lowercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
.strength-label.s-very-weak { color: #f85149; }
|
||||||
|
.strength-label.s-weak { color: #f08d49; }
|
||||||
|
.strength-label.s-fair { color: #d29922; }
|
||||||
|
.strength-label.s-good { color: #3fb950; }
|
||||||
|
.strength-label.s-strong { color: #56d364; font-weight: 600; }
|
||||||
|
|
||||||
|
.char-counter {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #6e7681;
|
||||||
|
margin: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entropy-line {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #8b949e;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-family: "SF Mono", "JetBrains Mono", monospace;
|
||||||
|
min-height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass-help {
|
||||||
|
background: #0d1117;
|
||||||
|
border: 1px solid #21262d;
|
||||||
|
border-left: 2px solid #1f6feb;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8b949e;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.pass-help strong { color: #c9d1d9; }
|
||||||
|
|
||||||
|
.passphrase-field {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.passphrase-field input {
|
||||||
|
padding-right: 76px; /* room for match indicator + eye button */
|
||||||
|
}
|
||||||
|
.eye-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #8b949e;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
text-transform: lowercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.eye-btn:hover { color: #c9d1d9; border-color: #484f58; }
|
||||||
|
|
||||||
|
.match-indicator {
|
||||||
|
position: absolute;
|
||||||
|
right: 50px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: color 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.match-indicator.ok { color: #3fb950; }
|
||||||
|
.match-indicator.bad { color: #f85149; }
|
||||||
|
|
||||||
|
/* Primary button explicitly dims when disabled so the gate is obvious. */
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.strength-label.weak { color: #f85149; }
|
|
||||||
.strength-label.strong { color: #3fb950; }
|
|
||||||
|
|
||||||
.success-box {
|
.success-box {
|
||||||
background: #0d1b0e;
|
background: #0d1b0e;
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ interface WizardState {
|
|||||||
passphraseConfirm: string;
|
passphraseConfirm: string;
|
||||||
// zxcvbn meter state — -1 means "not yet scored" (empty passphrase).
|
// zxcvbn meter state — -1 means "not yet scored" (empty passphrase).
|
||||||
passphraseScore: number;
|
passphraseScore: number;
|
||||||
|
passphraseGuessesLog10: number; // -1 before first rating
|
||||||
|
passphraseVisible: boolean;
|
||||||
|
confirmVisible: boolean;
|
||||||
referenceImageBytes: Uint8Array | null;
|
referenceImageBytes: Uint8Array | null;
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -56,6 +59,9 @@ const state: WizardState = {
|
|||||||
passphrase: '',
|
passphrase: '',
|
||||||
passphraseConfirm: '',
|
passphraseConfirm: '',
|
||||||
passphraseScore: -1,
|
passphraseScore: -1,
|
||||||
|
passphraseGuessesLog10: -1,
|
||||||
|
passphraseVisible: false,
|
||||||
|
confirmVisible: false,
|
||||||
referenceImageBytes: null,
|
referenceImageBytes: null,
|
||||||
creating: false,
|
creating: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -73,9 +79,11 @@ function escapeHtml(s: string): string {
|
|||||||
.replace(/"/g, '"');
|
.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Call the SW to score a passphrase with zxcvbn. Returns a score in [0, 4]
|
interface Strength { score: number; guessesLog10: number }
|
||||||
/// per the zxcvbn convention, or -1 if the message round-trip failed.
|
|
||||||
function ratePassphrase(passphrase: string): Promise<number> {
|
/// Call the SW to score a passphrase with zxcvbn. Returns score in [0, 4]
|
||||||
|
/// and guesses_log10, or -1 on both if the round-trip failed.
|
||||||
|
function ratePassphrase(passphrase: string): Promise<Strength> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
try {
|
try {
|
||||||
chrome.runtime.sendMessage(
|
chrome.runtime.sendMessage(
|
||||||
@@ -84,20 +92,23 @@ function ratePassphrase(passphrase: string): Promise<number> {
|
|||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn('[relicario setup] rate_passphrase lastError:', chrome.runtime.lastError);
|
console.warn('[relicario setup] rate_passphrase lastError:', chrome.runtime.lastError);
|
||||||
resolve(-1); return;
|
resolve({ score: -1, guessesLog10: -1 }); return;
|
||||||
}
|
}
|
||||||
if (!response?.ok) {
|
if (!response?.ok) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn('[relicario setup] rate_passphrase rejected by SW:', response);
|
console.warn('[relicario setup] rate_passphrase rejected by SW:', response);
|
||||||
resolve(-1); return;
|
resolve({ score: -1, guessesLog10: -1 }); return;
|
||||||
}
|
}
|
||||||
resolve(response.data?.score ?? -1);
|
resolve({
|
||||||
|
score: response.data?.score ?? -1,
|
||||||
|
guessesLog10: response.data?.guesses_log10 ?? -1,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn('[relicario setup] rate_passphrase threw:', err);
|
console.warn('[relicario setup] rate_passphrase threw:', err);
|
||||||
resolve(-1);
|
resolve({ score: -1, guessesLog10: -1 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -105,36 +116,101 @@ function ratePassphrase(passphrase: string): Promise<number> {
|
|||||||
/// 150ms debounce around the rate_passphrase call so we don't hammer the SW
|
/// 150ms debounce around the rate_passphrase call so we don't hammer the SW
|
||||||
/// on every keystroke. The last invocation wins.
|
/// on every keystroke. The last invocation wins.
|
||||||
let rateDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let rateDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
function scheduleRate(passphrase: string, onScore: (score: number) => void): void {
|
function scheduleRate(passphrase: string, onResult: (s: Strength) => void): void {
|
||||||
if (rateDebounceTimer !== null) clearTimeout(rateDebounceTimer);
|
if (rateDebounceTimer !== null) clearTimeout(rateDebounceTimer);
|
||||||
rateDebounceTimer = setTimeout(async () => {
|
rateDebounceTimer = setTimeout(async () => {
|
||||||
rateDebounceTimer = null;
|
rateDebounceTimer = null;
|
||||||
if (!passphrase) { onScore(-1); return; }
|
if (!passphrase) { onResult({ score: -1, guessesLog10: -1 }); return; }
|
||||||
onScore(await ratePassphrase(passphrase));
|
onResult(await ratePassphrase(passphrase));
|
||||||
}, 150);
|
}, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STRENGTH_LABELS: Record<number, { text: string; cls: string }> = {
|
||||||
|
0: { text: 'very weak', cls: 's-very-weak' },
|
||||||
|
1: { text: 'weak', cls: 's-weak' },
|
||||||
|
2: { text: 'fair', cls: 's-fair' },
|
||||||
|
3: { text: 'good', cls: 's-good' },
|
||||||
|
4: { text: 'strong', cls: 's-strong' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Render the entropy readout as "~10^N guesses to crack" or a friendlier
|
||||||
|
/// shorthand for large values. Returns empty string when no data.
|
||||||
|
function entropyText(guessesLog10: number): string {
|
||||||
|
if (guessesLog10 < 0) return '';
|
||||||
|
const rounded = Math.round(guessesLog10);
|
||||||
|
if (rounded < 6) return `~10^${rounded} guesses — trivially crackable`;
|
||||||
|
if (rounded < 9) return `~10^${rounded} guesses — minutes on a single GPU`;
|
||||||
|
if (rounded < 12) return `~10^${rounded} guesses — hours to days on a GPU`;
|
||||||
|
if (rounded < 15) return `~10^${rounded} guesses — years on consumer hardware`;
|
||||||
|
if (rounded < 20) return `~10^${rounded} guesses — beyond consumer-hardware reach`;
|
||||||
|
return `~10^${rounded} guesses — effectively uncrackable`;
|
||||||
|
}
|
||||||
|
|
||||||
/// Update just the meter DOM without a full re-render (so the input keeps
|
/// Update just the meter DOM without a full re-render (so the input keeps
|
||||||
/// focus and the user's cursor position is preserved).
|
/// focus and the user's cursor position is preserved). Also updates the
|
||||||
|
/// char counter and confirm-match indicator live.
|
||||||
function updateStrengthUi(): void {
|
function updateStrengthUi(): void {
|
||||||
const bar = document.getElementById('strength-bar');
|
const bar = document.getElementById('strength-bar');
|
||||||
const label = document.getElementById('strength-label');
|
const label = document.getElementById('strength-label');
|
||||||
|
const entropy = document.getElementById('entropy-line');
|
||||||
|
const counter = document.getElementById('passphrase-counter');
|
||||||
|
const matchInd = document.getElementById('match-indicator');
|
||||||
const create = document.getElementById('create-btn') as HTMLButtonElement | null;
|
const create = document.getElementById('create-btn') as HTMLButtonElement | null;
|
||||||
|
|
||||||
const score = state.passphraseScore;
|
const score = state.passphraseScore;
|
||||||
|
const guessesLog10 = state.passphraseGuessesLog10;
|
||||||
|
|
||||||
if (bar) bar.className = `strength-bar${score >= 0 ? ` s${score}` : ''}`;
|
if (bar) bar.className = `strength-bar${score >= 0 ? ` s${score}` : ''}`;
|
||||||
|
|
||||||
if (label) {
|
if (label) {
|
||||||
if (score < 0) {
|
if (score < 0) {
|
||||||
label.className = 'strength-label';
|
label.className = 'strength-label';
|
||||||
label.innerHTML = ' ';
|
label.innerHTML = ' ';
|
||||||
} else if (score >= 3) {
|
|
||||||
label.className = 'strength-label strong';
|
|
||||||
label.textContent = 'Strong enough';
|
|
||||||
} else {
|
} else {
|
||||||
label.className = 'strength-label weak';
|
const meta = STRENGTH_LABELS[score] ?? STRENGTH_LABELS[0];
|
||||||
label.textContent = 'Too weak';
|
label.className = `strength-label ${meta.cls}`;
|
||||||
|
label.textContent = meta.text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (create) create.disabled = state.creating || score < 3;
|
|
||||||
|
if (entropy) {
|
||||||
|
const txt = entropyText(guessesLog10);
|
||||||
|
entropy.textContent = txt;
|
||||||
|
entropy.style.visibility = txt ? 'visible' : 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counter) {
|
||||||
|
const n = state.passphrase.length;
|
||||||
|
counter.textContent = n === 0 ? '' : `${n} character${n === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchInd) {
|
||||||
|
const p = state.passphrase;
|
||||||
|
const c = state.passphraseConfirm;
|
||||||
|
if (!p || !c) {
|
||||||
|
matchInd.className = 'match-indicator';
|
||||||
|
matchInd.textContent = '';
|
||||||
|
} else if (p === c) {
|
||||||
|
matchInd.className = 'match-indicator ok';
|
||||||
|
matchInd.textContent = '✓';
|
||||||
|
} else {
|
||||||
|
matchInd.className = 'match-indicator bad';
|
||||||
|
matchInd.textContent = '✗';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchOk = !state.passphraseConfirm || state.passphrase === state.passphraseConfirm;
|
||||||
|
if (create) {
|
||||||
|
const disabled = state.creating || score < 3 || !state.passphraseConfirm || !matchOk;
|
||||||
|
create.disabled = disabled;
|
||||||
|
create.title = disabled
|
||||||
|
? (score < 3
|
||||||
|
? 'passphrase must score "good" or better'
|
||||||
|
: !state.passphraseConfirm ? 'confirm your passphrase'
|
||||||
|
: !matchOk ? 'passphrases do not match'
|
||||||
|
: '')
|
||||||
|
: '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Render ---
|
// --- Render ---
|
||||||
@@ -320,17 +396,34 @@ function attachStep2(): void {
|
|||||||
|
|
||||||
function renderStep3(): string {
|
function renderStep3(): string {
|
||||||
const score = state.passphraseScore;
|
const score = state.passphraseScore;
|
||||||
|
const guessesLog10 = state.passphraseGuessesLog10;
|
||||||
const hasScore = score >= 0;
|
const hasScore = score >= 0;
|
||||||
const meterClass = hasScore ? `s${score}` : '';
|
const meterClass = hasScore ? `s${score}` : '';
|
||||||
const labelClass = hasScore ? (score >= 3 ? 'strong' : 'weak') : '';
|
const labelMeta = hasScore ? STRENGTH_LABELS[score] : null;
|
||||||
const labelText = !hasScore
|
const labelClass = labelMeta?.cls ?? '';
|
||||||
? ' '
|
const labelText = labelMeta?.text ?? ' ';
|
||||||
: (score >= 3 ? 'Strong enough' : 'Too weak');
|
const entropy = entropyText(guessesLog10);
|
||||||
const gateDisabled = state.creating || score < 3;
|
|
||||||
|
const p = state.passphrase;
|
||||||
|
const c = state.passphraseConfirm;
|
||||||
|
const matchState = !p || !c ? '' : p === c ? 'ok' : 'bad';
|
||||||
|
const matchGlyph = matchState === 'ok' ? '✓' : matchState === 'bad' ? '✗' : '';
|
||||||
|
|
||||||
|
const pType = state.passphraseVisible ? 'text' : 'password';
|
||||||
|
const cType = state.confirmVisible ? 'text' : 'password';
|
||||||
|
const pToggle = state.passphraseVisible ? 'hide' : 'show';
|
||||||
|
const cToggle = state.confirmVisible ? 'hide' : 'show';
|
||||||
|
|
||||||
|
const matchOk = !c || p === c;
|
||||||
|
const gateDisabled = state.creating || score < 3 || !c || !matchOk;
|
||||||
|
|
||||||
|
const nChars = p.length;
|
||||||
|
const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="wizard-step">
|
<div class="wizard-step">
|
||||||
<h3>create vault</h3>
|
<h3>create vault</h3>
|
||||||
|
|
||||||
<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">
|
||||||
@@ -341,9 +434,18 @@ function renderStep3(): string {
|
|||||||
</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="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>
|
||||||
<input id="passphrase" type="password" value="${escapeHtml(state.passphrase)}" placeholder="enter a strong passphrase" autocomplete="new-password">
|
<div class="passphrase-field">
|
||||||
|
<input id="passphrase" type="${pType}" value="${escapeHtml(p)}" placeholder="enter a strong passphrase" autocomplete="new-password">
|
||||||
|
<button type="button" class="eye-btn" id="eye-btn" aria-label="toggle passphrase visibility">${pToggle}</button>
|
||||||
|
</div>
|
||||||
<div class="strength-bar ${meterClass}" id="strength-bar" aria-hidden="true">
|
<div class="strength-bar ${meterClass}" id="strength-bar" aria-hidden="true">
|
||||||
<div class="seg i0"></div>
|
<div class="seg i0"></div>
|
||||||
<div class="seg i1"></div>
|
<div class="seg i1"></div>
|
||||||
@@ -351,12 +453,22 @@ function renderStep3(): string {
|
|||||||
<div class="seg i3"></div>
|
<div class="seg i3"></div>
|
||||||
<div class="seg i4"></div>
|
<div class="seg i4"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="strength-label ${labelClass}" id="strength-label">${labelText}</p>
|
<div class="strength-row">
|
||||||
|
<p class="strength-label ${labelClass}" id="strength-label">${labelText}</p>
|
||||||
|
<p class="char-counter" id="passphrase-counter">${escapeHtml(counterText)}</p>
|
||||||
|
</div>
|
||||||
|
<p class="entropy-line" id="entropy-line" style="visibility:${entropy ? 'visible' : 'hidden'};">${escapeHtml(entropy || ' ')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="passphrase-confirm">confirm passphrase</label>
|
<label class="label" for="passphrase-confirm">confirm passphrase</label>
|
||||||
<input id="passphrase-confirm" type="password" value="${escapeHtml(state.passphraseConfirm)}" placeholder="re-enter passphrase" autocomplete="new-password">
|
<div class="passphrase-field">
|
||||||
|
<input id="passphrase-confirm" type="${cType}" value="${escapeHtml(c)}" placeholder="re-enter passphrase" autocomplete="new-password">
|
||||||
|
<span class="match-indicator ${matchState}" id="match-indicator">${matchGlyph}</span>
|
||||||
|
<button type="button" class="eye-btn" id="confirm-eye-btn" aria-label="toggle confirm visibility">${cToggle}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn" id="back-btn">back</button>
|
<button class="btn" id="back-btn">back</button>
|
||||||
<button class="btn btn-primary" id="create-btn" ${gateDisabled ? 'disabled' : ''}>
|
<button class="btn btn-primary" id="create-btn" ${gateDisabled ? 'disabled' : ''}>
|
||||||
@@ -387,16 +499,41 @@ function attachStep3(): void {
|
|||||||
|
|
||||||
// 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.
|
||||||
document.getElementById('passphrase')?.addEventListener('input', (e) => {
|
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
|
||||||
|
passInput?.addEventListener('input', (e) => {
|
||||||
state.passphrase = (e.target as HTMLInputElement).value;
|
state.passphrase = (e.target as HTMLInputElement).value;
|
||||||
scheduleRate(state.passphrase, (score) => {
|
// Update char counter + match indicator + button gate immediately on every keystroke.
|
||||||
state.passphraseScore = score;
|
updateStrengthUi();
|
||||||
|
// Score updates on the 150ms debounce to avoid SW hammering.
|
||||||
|
scheduleRate(state.passphrase, (s) => {
|
||||||
|
state.passphraseScore = s.score;
|
||||||
|
state.passphraseGuessesLog10 = s.guessesLog10;
|
||||||
updateStrengthUi();
|
updateStrengthUi();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => {
|
const confirmInput = document.getElementById('passphrase-confirm') as HTMLInputElement | null;
|
||||||
|
confirmInput?.addEventListener('input', (e) => {
|
||||||
state.passphraseConfirm = (e.target as HTMLInputElement).value;
|
state.passphraseConfirm = (e.target as HTMLInputElement).value;
|
||||||
|
updateStrengthUi();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Eye toggles — flip the input type and label without a full re-render so
|
||||||
|
// focus + cursor position survive the click.
|
||||||
|
document.getElementById('eye-btn')?.addEventListener('click', () => {
|
||||||
|
state.passphraseVisible = !state.passphraseVisible;
|
||||||
|
if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password';
|
||||||
|
const btn = document.getElementById('eye-btn');
|
||||||
|
if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show';
|
||||||
|
passInput?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('confirm-eye-btn')?.addEventListener('click', () => {
|
||||||
|
state.confirmVisible = !state.confirmVisible;
|
||||||
|
if (confirmInput) confirmInput.type = state.confirmVisible ? 'text' : 'password';
|
||||||
|
const btn = document.getElementById('confirm-eye-btn');
|
||||||
|
if (btn) btn.textContent = state.confirmVisible ? 'hide' : 'show';
|
||||||
|
confirmInput?.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('back-btn')?.addEventListener('click', () => {
|
document.getElementById('back-btn')?.addEventListener('click', () => {
|
||||||
@@ -423,7 +560,9 @@ function attachStep3(): void {
|
|||||||
// Re-rate synchronously in case the button was clicked before the
|
// Re-rate synchronously in case the button was clicked before the
|
||||||
// debounced rater fired. Defence in depth — the button is already
|
// debounced rater fired. Defence in depth — the button is already
|
||||||
// disabled in the UI when score < 3 (audit H3).
|
// disabled in the UI when score < 3 (audit H3).
|
||||||
state.passphraseScore = await ratePassphrase(state.passphrase);
|
const strength = await ratePassphrase(state.passphrase);
|
||||||
|
state.passphraseScore = strength.score;
|
||||||
|
state.passphraseGuessesLog10 = strength.guessesLog10;
|
||||||
if (state.passphraseScore < 3) {
|
if (state.passphraseScore < 3) {
|
||||||
state.error = 'Passphrase is too weak (zxcvbn score must be ≥ 3).';
|
state.error = 'Passphrase is too weak (zxcvbn score must be ≥ 3).';
|
||||||
render();
|
render();
|
||||||
|
|||||||
Reference in New Issue
Block a user