Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -1804,6 +1804,53 @@
box-shadow: 0 0 0 3px var(--accent-soft);
}

.password-input {
position: relative;
display: flex;
align-items: center;
}

.password-input .auth-input {
width: 100%;
padding-right: 52px;
}

.password-input__toggle {
position: absolute;
right: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border: 0;
border-radius: 12px;
background: transparent;
color: var(--muted);
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}

.password-input__toggle:hover,
.password-input__toggle:focus-visible {
background: rgba(30, 120, 255, 0.1);
color: var(--text);
}

.password-input__toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}

.password-input__toggle svg {
display: block;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}

/* Error variant — paired with aria-invalid on the input and the
#<form>-alert container described by aria-describedby. We keep the
red border visible even on :focus so the user isn't left to wonder
Expand Down
10 changes: 4 additions & 6 deletions src/components/ChangePasswordCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useCallback, useState } from 'react';

import { authService as defaultAuthService } from '../lib/authService';
import PasswordStrengthMeter from './PasswordStrengthMeter';
import PasswordInput from './PasswordInput';
import { useAuth } from '../context/AuthContext';
import { useVault } from '../context/VaultContext';
import {
Expand Down Expand Up @@ -308,10 +309,9 @@ export default function ChangePasswordCard({
<label className="auth-label" htmlFor="cp-old">
Current password
</label>
<input
<PasswordInput
id="cp-old"
className="auth-input"
type="password"
autoComplete="current-password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
Expand All @@ -323,10 +323,9 @@ export default function ChangePasswordCard({
<label className="auth-label" htmlFor="cp-new">
New password
</label>
<input
<PasswordInput
id="cp-new"
className="auth-input"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
Expand All @@ -346,10 +345,9 @@ export default function ChangePasswordCard({
<label className="auth-label" htmlFor="cp-confirm">
Confirm new password
</label>
<input
<PasswordInput
id="cp-confirm"
className="auth-input"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
Expand Down
4 changes: 2 additions & 2 deletions src/components/DeleteAccountCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useHistory } from 'react-router-dom';
import { authService as defaultAuthService } from '../lib/authService';
import { deriveLoginKeys } from '../lib/crypto/kdf';
import { useAuth } from '../context/AuthContext';
import PasswordInput from './PasswordInput';

// DeleteAccountCard
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -219,10 +220,9 @@ export default function DeleteAccountCard({
<label className="auth-label" htmlFor="del-password">
Current password
</label>
<input
<PasswordInput
id="del-password"
className="auth-input"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
Expand Down
4 changes: 2 additions & 2 deletions src/components/GovernanceOpsHero.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { computeOpsStats } from '../lib/governanceOps';
import { formatNumber } from '../lib/formatters';
import { useAuth } from '../context/AuthContext';
import { useVault } from '../context/VaultContext';
import PasswordInput from './PasswordInput';

// Error copy for the inline unlock form. Kept in sync with the same
// map in components/VaultStatusCard.js (the /account source of truth
Expand Down Expand Up @@ -403,10 +404,9 @@ function VaultLockedHero() {
<label className="auth-label" htmlFor="gov-hero-vault-password">
Password
</label>
<input
<PasswordInput
id="gov-hero-vault-password"
className="auth-input"
type="password"
autoComplete="current-password"
value={password}
onChange={function onChange(e) {
Expand Down
52 changes: 52 additions & 0 deletions src/components/PasswordInput.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React, { forwardRef, useState } from 'react';

const PasswordInput = forwardRef(function PasswordInput(
{ className = 'auth-input', ...props },
ref
) {
const [visible, setVisible] = useState(false);
const label = visible ? 'Hide password' : 'Show password';

return (
<div className="password-input">
<input
{...props}
ref={ref}
className={className}
type={visible ? 'text' : 'password'}
/>
<button
type="button"
className="password-input__toggle"
aria-label={label}
aria-pressed={visible}
title={label}
onClick={() => setVisible((v) => !v)}
>
<svg
aria-hidden="true"
focusable="false"
viewBox="0 0 24 24"
width="20"
height="20"
>
{visible ? (
<>
<path d="M3 3l18 18" />
<path d="M10.6 10.7a2 2 0 0 0 2.7 2.7" />
<path d="M9.9 5.2A9.6 9.6 0 0 1 12 5c5.3 0 8.5 4.7 9.3 6.2a1.5 1.5 0 0 1 0 1.6 17 17 0 0 1-2.2 3" />
<path d="M6.2 6.4a16.2 16.2 0 0 0-3.5 4.8 1.5 1.5 0 0 0 0 1.6C3.5 14.3 6.7 19 12 19a9.5 9.5 0 0 0 4-.9" />
</>
) : (
<>
<path d="M2.7 11.2C3.5 9.7 6.7 5 12 5s8.5 4.7 9.3 6.2a1.5 1.5 0 0 1 0 1.6C20.5 14.3 17.3 19 12 19s-8.5-4.7-9.3-6.2a1.5 1.5 0 0 1 0-1.6Z" />
<circle cx="12" cy="12" r="3" />
</>
)}
</svg>
</button>
</div>
);
});

export default PasswordInput;
23 changes: 23 additions & 0 deletions src/components/PasswordInput.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import PasswordInput from './PasswordInput';

test('keeps password hidden by default and reveals it on explicit toggle', async () => {
render(
<>
<label htmlFor="pw">Password</label>
<PasswordInput id="pw" value="hunter22a" onChange={() => {}} />
</>
);

const input = screen.getByLabelText('Password');
expect(input).toHaveAttribute('type', 'password');

await userEvent.click(screen.getByRole('button', { name: /show password/i }));
expect(input).toHaveAttribute('type', 'text');

await userEvent.click(screen.getByRole('button', { name: /hide password/i }));
expect(input).toHaveAttribute('type', 'password');
});
4 changes: 2 additions & 2 deletions src/components/TwoFactorCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { toString as qrToString } from 'qrcode';

import { authService as defaultAuthService } from '../lib/authService';
import { useAuth } from '../context/AuthContext';
import PasswordInput from './PasswordInput';

const ERROR_COPY = {
invalid_totp_code: "That authenticator code didn't work. Check the current code and try again.",
Expand Down Expand Up @@ -295,10 +296,9 @@ export default function TwoFactorCard({
<label className="auth-label" htmlFor="totp-current-password">
Current password
</label>
<input
<PasswordInput
id="totp-current-password"
className="auth-input"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
Expand Down
4 changes: 2 additions & 2 deletions src/components/VaultImportModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { useAuth } from '../context/AuthContext';
import { useVault } from '../context/VaultContext';
import { authService as defaultAuthService } from '../lib/authService';
import PasswordInput from './PasswordInput';

// VaultImportModal
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -521,10 +522,9 @@ export default function VaultImportModal({
<label className="auth-label" htmlFor="vault-import-password">
Current password
</label>
<input
<PasswordInput
id="vault-import-password"
ref={passwordRef}
type="password"
autoComplete="current-password"
className={`auth-input${
passwordError ? ' auth-input--error' : ''
Expand Down
4 changes: 2 additions & 2 deletions src/components/VaultStatusCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useState } from 'react';

import { useVault } from '../context/VaultContext';
import KeyManagerCard from './KeyManagerCard';
import PasswordInput from './PasswordInput';
import VaultImportModal from './VaultImportModal';

// VaultStatusCard
Expand Down Expand Up @@ -174,10 +175,9 @@ export default function VaultStatusCard({ user }) {
<label className="auth-label" htmlFor="vault-password">
Password
</label>
<input
<PasswordInput
id="vault-password"
className="auth-input"
type="password"
autoComplete="current-password"
value={password}
onChange={function onChange(e) {
Expand Down
41 changes: 40 additions & 1 deletion src/lib/passwordPolicy.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export const MIN_VAULT_PASSWORD_SCORE = 3;

export const VAULT_PASSWORD_HINT =
'Use at least 8 characters. Longer passphrases are best; weak or common passwords are rejected.';
export const PERSONAL_INFO_PASSWORD_HINT =
'Do not use your email address or account identifier in your password.';

export const PASSWORD_STRENGTH_LABELS = [
'Very weak',
Expand Down Expand Up @@ -34,8 +36,45 @@ function normalizeUserInputs(userInputs) {
return [...new Set(tokens.filter((token) => token.length >= 3))];
}

function personalInfoTokens(userInputs) {
const tokens = [];
for (const value of Array.isArray(userInputs) ? userInputs : []) {
const normalized = String(value || '').trim().toLowerCase();
if (!normalized) continue;

const [localPart] = normalized.split('@');
if (localPart) {
tokens.push(localPart);
tokens.push(...localPart.split(/[^a-z0-9]+/));
}
}

return [...new Set(tokens.filter((token) => token.length >= 4))];
}

function findPersonalInfoToken(password, userInputs) {
const normalizedPassword = String(password || '').toLowerCase();
if (!normalizedPassword) return null;
return (
personalInfoTokens(userInputs).find((token) =>
normalizedPassword.includes(token)
) || null
);
}

export function estimateVaultPasswordStrength(password, userInputs = []) {
return zxcvbn(String(password || ''), normalizeUserInputs(userInputs));
const result = zxcvbn(String(password || ''), normalizeUserInputs(userInputs));
const personalInfoToken = findPersonalInfoToken(password, userInputs);
if (!personalInfoToken) return result;
return {
...result,
score: Math.min(result.score, 1),
personalInfoToken,
feedback: {
warning: PERSONAL_INFO_PASSWORD_HINT,
suggestions: [],
},
};
}

export function passwordStrengthLabel(score) {
Expand Down
27 changes: 27 additions & 0 deletions src/lib/passwordPolicy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,30 @@ test('rejects passwords built from the email local part', () => {
validateVaultPassword('sentryoperator2026!', ['sentryoperator@example.com'])
).toMatchObject({ code: 'password_too_weak' });
});

test('caps strength for email-like personal-info variants even when long', () => {
const userInputs = ['doejohn@gmail.com'];
const result = estimateVaultPasswordStrength(
'doejohn@gmail.co123456789',
userInputs
);

expect(result.score).toBeLessThan(3);
expect(result.feedback.warning).toMatch(/email address|account identifier/i);
expect(
validateVaultPassword('doejohn@gmail.co123456789', userInputs)
).toMatchObject({
code: 'password_too_weak',
});
});

test('keeps personal-info passwords weak after adding extra numbers', () => {
const userInputs = ['doejohn@gmail.com'];

expect(validateVaultPassword('doejohn123!', userInputs)).toMatchObject({
code: 'password_too_weak',
});
expect(validateVaultPassword('doejohn123456789!', userInputs)).toMatchObject({
code: 'password_too_weak',
});
});
4 changes: 2 additions & 2 deletions src/pages/Login.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { Link, useHistory, useLocation } from 'react-router-dom';

import PageMeta from '../components/PageMeta';
import PasswordInput from '../components/PasswordInput';
import { useAuth } from '../context/AuthContext';
import { useVault } from '../context/VaultContext';
import { isValidEmailSyntax, normalizeEmail } from '../lib/crypto/normalize';
Expand Down Expand Up @@ -342,10 +343,9 @@ export default function Login() {
<label className="auth-label" htmlFor="login-password">
Password
</label>
<input
<PasswordInput
id="login-password"
className={inputClass(errorFields, 'password')}
type="password"
autoComplete="current-password"
value={password}
onChange={function onPasswordChange(e) {
Expand Down
Loading
Loading