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
86 changes: 60 additions & 26 deletions src/components/VaultImportModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,9 @@ import {
summariseRows,
validateImportEntryAsync,
} from '../lib/vaultData';
import {
MIN_VAULT_PASSWORD_LENGTH,
validateVaultPassword,
VAULT_PASSWORD_HINT,
} from '../lib/passwordPolicy';
import { useAuth } from '../context/AuthContext';
import { useVault } from '../context/VaultContext';
import { authService as defaultAuthService } from '../lib/authService';

// VaultImportModal
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -91,7 +87,11 @@ function lineBounds(text, lineNo) {
return { start, end: start + lines[lineNo - 1].length };
}

export default function VaultImportModal({ open, onClose }) {
export default function VaultImportModal({
open,
onClose,
authService = defaultAuthService,
}) {
const vault = useVault();
const { user } = useAuth();

Expand Down Expand Up @@ -172,8 +172,25 @@ export default function VaultImportModal({ open, onClose }) {
// focused) instead of a silently-disabled button — clicking a disabled
// button leaves the user with no signal about which field is missing.
const canSave = !saving && !validating && summary.valid > 0;
// Password-field failures: empty is a local pre-flight failure;
// password_mismatch is the server-side verification result for the
// EMPTY first-write path.
// A mismatch is far more likely than a network blip — so we treat
// it as a password-field error rather than a generic banner.
const passwordError =
error === 'password_required' || error === 'password_too_short';
error === 'password_required' || error === 'password_mismatch';

// verifyAuthHash callback for VaultContext.save() on the EMPTY path.
// The vault password and the account password are the same secret;
// VaultContext derives an authHash from the typed password and asks
// us to confirm it against the server before encrypting under the
// matching vaultKey. authService.verifyPassword normalises a 401 to
// `{ code: 'invalid_credentials' }`, which VaultContext re-tags to
// `password_mismatch`. We pass it through unchanged.
const verifyAuthHash = useCallback(
(authHash) => authService.verifyPassword({ authHash }),
[authService]
);

// ESC closes, focus lands in the textarea on open, state resets on
// close. Done as a single effect keyed on `open` so the lifecycle
Expand Down Expand Up @@ -286,39 +303,56 @@ export default function VaultImportModal({ open, onClose }) {
try {
if (requiresPassword) {
// Empty input gets the explicit "please enter your password"
// copy; a non-empty but too-weak password gets the full policy
// hint. Both cases pull focus into the field so the user's
// next keystroke lands in the right place.
// copy. Non-empty values are checked by /auth/verify-password:
// this is current-password re-auth, not a new-password policy
// gate, so the server is authoritative.
if (password === '') {
setError('password_required');
if (passwordRef.current) passwordRef.current.focus();
return;
}
const policyError = validateVaultPassword(password);
if (policyError) {
setError(policyError.code);
if (passwordRef.current) passwordRef.current.focus();
return;
}
}
const nowMs = Date.now();
const newKeys = buildKeysFromValidRows(rows, nowMs);
const basePayload = vault.data || { version: 1, keys: [] };
const nextPayload = addKeys(basePayload, newKeys);
const saveOpts = requiresPassword
? { password, email: user && user.email }
? {
password,
email: user && user.email,
verifyAuthHash,
}
: undefined;
await vault.save(nextPayload, saveOpts);
setPassword('');
setPaste('');
onClose();
} catch (e) {
setError((e && e.code) || 'save_failed');
const code = (e && e.code) || 'save_failed';
setError(code);
// For the password-field error class (empty / mismatch)
// we pull focus straight into the input so the user's next
// keystroke lands there rather than getting lost in the
// banner. The empty-string check already does this for the
// local pre-flight failure; this branch covers the asynchronous
// server-verification result.
if (code === 'password_mismatch' && passwordRef.current) {
passwordRef.current.focus();
}
} finally {
setSaving(false);
}
},
[canSave, rows, vault, requiresPassword, password, user, onClose]
[
canSave,
rows,
vault,
requiresPassword,
password,
user,
onClose,
verifyAuthHash,
]
);

if (!open) return null;
Expand Down Expand Up @@ -485,7 +519,7 @@ export default function VaultImportModal({ open, onClose }) {
{requiresPassword ? (
<div className="auth-field">
<label className="auth-label" htmlFor="vault-import-password">
Password
Current password
</label>
<input
id="vault-import-password"
Expand All @@ -507,12 +541,12 @@ export default function VaultImportModal({ open, onClose }) {
aria-describedby={
passwordError ? 'vault-import-error' : undefined
}
minLength={MIN_VAULT_PASSWORD_LENGTH}
data-testid="vault-import-password"
/>
<span className="auth-hint">
{VAULT_PASSWORD_HINT} It stays on this device — the server never
sees it.
Use the password you signed in with — your account password
is also the key that encrypts your vault on this device.
It is never sent to the server.
</span>
</div>
) : null}
Expand All @@ -525,9 +559,9 @@ export default function VaultImportModal({ open, onClose }) {
data-testid="vault-import-error"
>
{error === 'password_required'
? 'Please enter your password.'
: error === 'password_too_short'
? VAULT_PASSWORD_HINT
? 'Please enter your current password.'
: error === 'password_mismatch'
? "That doesn't match your account password. Enter the password you used to sign in."
: error === 'vault_stale'
? 'Your vault changed in another tab. Close this dialog and try again.'
: error === 'network_error'
Expand Down
109 changes: 96 additions & 13 deletions src/components/VaultImportModal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ function descriptorFixtures() {
};
}

function mount({ vault, user, onClose = jest.fn() } = {}) {
function mount({ vault, user, onClose = jest.fn(), authService } = {}) {
const auth = {
user: user || {
id: 1,
Expand All @@ -64,8 +64,16 @@ function mount({ vault, user, onClose = jest.fn() } = {}) {
};
jest.spyOn(VaultCtx, 'useVault').mockReturnValue(vault);
jest.spyOn(AuthCtx, 'useAuth').mockReturnValue(auth);
const utils = render(<VaultImportModal open={true} onClose={onClose} />);
return { ...utils, onClose };
// Default authService stub: verifyPassword resolves on every call. The
// EMPTY-flow tests assert the callback shape, and the
// mismatch test injects a rejecting stub.
const svc = authService || {
verifyPassword: jest.fn().mockResolvedValue(true),
};
const utils = render(
<VaultImportModal open={true} onClose={onClose} authService={svc} />
);
return { ...utils, onClose, authService: svc };
}

afterEach(() => {
Expand Down Expand Up @@ -331,7 +339,7 @@ describe('VaultImportModal — save flow (EMPTY, first write)', () => {
await userEvent.click(screen.getByTestId('vault-import-save'));

const banner = await screen.findByTestId('vault-import-error');
expect(banner).toHaveTextContent(/please enter your password/i);
expect(banner).toHaveTextContent(/please enter your current password/i);

const pwInput = screen.getByTestId('vault-import-password');
expect(pwInput).toHaveClass('auth-input--error');
Expand All @@ -346,26 +354,29 @@ describe('VaultImportModal — save flow (EMPTY, first write)', () => {
expect(pwInput).not.toHaveClass('auth-input--error');
});

test('rejects short first-write passwords before vault.save', async () => {
test('does not locally policy-reject a non-empty current password', async () => {
const vault = emptyVault();
mount({ vault });
const { onClose } = mount({ vault });
pasteInto(screen.getByTestId('vault-import-paste'), VALID_WIF_1);
await userEvent.type(
screen.getByTestId('vault-import-password'),
'too-short'
'short'
);
await waitFor(() =>
expect(screen.getByTestId('vault-import-save')).not.toBeDisabled()
);

await userEvent.click(screen.getByTestId('vault-import-save'));
const error = await screen.findByTestId('vault-import-error');
expect(error).toHaveTextContent(/at least 8/i);
expect(error).toHaveTextContent(/3 of/i);
expect(vault.save).not.toHaveBeenCalled();
await waitFor(() => expect(vault.save).toHaveBeenCalledTimes(1));
expect(vault.save.mock.calls[0][1]).toMatchObject({
password: 'short',
email: 'user@example.com',
});
expect(screen.queryByTestId('vault-import-error')).toBeNull();
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
});

test('forwards {password, email} to vault.save on first write', async () => {
test('forwards {password, email, verifyAuthHash} to vault.save on first write', async () => {
const vault = emptyVault();
const { onClose } = mount({ vault });
pasteInto(screen.getByTestId('vault-import-paste'), `${VALID_WIF_1},MN 1`);
Expand All @@ -378,12 +389,84 @@ describe('VaultImportModal — save flow (EMPTY, first write)', () => {
);
await userEvent.click(screen.getByTestId('vault-import-save'));
await waitFor(() => expect(vault.save).toHaveBeenCalledTimes(1));
expect(vault.save.mock.calls[0][1]).toEqual({
const opts = vault.save.mock.calls[0][1];
expect(opts).toMatchObject({
password: 'My-secret-passphrase1',
email: 'user@example.com',
});
// The verifyAuthHash callback is the bridge that prevents password
// divergence on first write — it must be a function so VaultContext
// can hit POST /auth/verify-password before persisting the vault.
expect(typeof opts.verifyAuthHash).toBe('function');
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
});

test('verifyAuthHash callback delegates to authService.verifyPassword', async () => {
// We don't run the full VaultContext here — just confirm the
// callback the modal hands to vault.save calls into the injected
// authService with the exact authHash payload shape the backend
// expects. This protects the contract between the modal and the
// service layer without depending on real crypto.
const verifyPassword = jest.fn().mockResolvedValue(true);
const vault = emptyVault();
mount({ vault, authService: { verifyPassword } });
pasteInto(screen.getByTestId('vault-import-paste'), VALID_WIF_1);
await userEvent.type(
screen.getByTestId('vault-import-password'),
'My-secret-passphrase1'
);
await waitFor(() =>
expect(screen.getByTestId('vault-import-save')).not.toBeDisabled()
);
await userEvent.click(screen.getByTestId('vault-import-save'));
await waitFor(() => expect(vault.save).toHaveBeenCalledTimes(1));
const { verifyAuthHash } = vault.save.mock.calls[0][1];
const probe = 'a'.repeat(64);
await verifyAuthHash(probe);
expect(verifyPassword).toHaveBeenCalledWith({ authHash: probe });
});

test('surfaces password_mismatch with corrective copy and refocuses the field', async () => {
const vault = emptyVault({
// Mirror the real VaultContext behaviour: re-throw the typed
// password_mismatch error from the EMPTY save path.
save: jest.fn().mockRejectedValue(
Object.assign(new Error('password_mismatch'), {
code: 'password_mismatch',
})
),
});
const { onClose } = mount({ vault });
// The modal auto-focuses the paste textarea via setTimeout(0) on
// mount. Under microtask pressure (async typing + click + save
// rejection in the catch block), that setTimeout can fire AFTER
// the catch block focuses the password input, stealing focus
// back to the textarea. Wait for the autofocus to land before
// doing anything else so the catch-block focus call wins cleanly.
const textarea = screen.getByTestId('vault-import-paste');
await waitFor(() => expect(document.activeElement).toBe(textarea));

pasteInto(textarea, VALID_WIF_1);
const pwInput = screen.getByTestId('vault-import-password');
await userEvent.type(pwInput, 'Wrong-account-password1');
await waitFor(() =>
expect(screen.getByTestId('vault-import-save')).not.toBeDisabled()
);
await userEvent.click(screen.getByTestId('vault-import-save'));

const banner = await screen.findByTestId('vault-import-error');
expect(banner).toHaveTextContent(/account password/i);
expect(pwInput).toHaveClass('auth-input--error');
expect(pwInput).toHaveAttribute('aria-invalid', 'true');
await waitFor(() => expect(document.activeElement).toBe(pwInput));
expect(onClose).not.toHaveBeenCalled();

// Typing a correction clears the error state, matching the
// password_required UX.
await userEvent.type(pwInput, '!');
expect(screen.queryByTestId('vault-import-error')).toBeNull();
expect(pwInput).not.toHaveClass('auth-input--error');
});
});

describe('VaultImportModal — dismiss', () => {
Expand Down
Loading
Loading