Skip to content

Commit 9fcf4ee

Browse files
committed
Dark theme contrast fix and vault reset flow
- Fixed dark mode contrast: slate-800 card backgrounds, slate-50 text - Solid severity badges with white text (red/yellow/blue) - Readable audit log and attack mode entries in dark theme - VaultUnlockPanel: Forgot passphrase link with destructive reset - destroyVault() wipes IndexedDB and storage without requiring unlock - Confirmation dialog with permanent data loss warning - Post-reset redirects to create-vault flow - vault_destroyed audit event when reset from unlocked state - Storage destroy helpers with tests (18 passing) - Resume vault reset tests (6 passing)
1 parent 9d2c3a6 commit 9fcf4ee

9 files changed

Lines changed: 460 additions & 34 deletions

File tree

apps/web/src/app.css

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,49 @@ button {
857857
gap: 0.25rem;
858858
}
859859

860+
:root:not([data-theme='light']) .shell-button--danger {
861+
background: #be123c;
862+
border-color: #e11d48;
863+
color: #fff;
864+
}
865+
866+
:root:not([data-theme='light']) .audit-badge.success-badge {
867+
background: #047857;
868+
border-color: #059669;
869+
color: #f8fafc;
870+
}
871+
872+
:root:not([data-theme='light']) .audit-badge.danger-badge {
873+
background: #be123c;
874+
border-color: #e11d48;
875+
color: #fff1f2;
876+
}
877+
878+
:root:not([data-theme='light']) .audit-event {
879+
border-color: #334155;
880+
color: #f8fafc;
881+
}
882+
883+
:root:not([data-theme='light']) .audit-event--vault {
884+
background: #1d4ed8;
885+
}
886+
887+
:root:not([data-theme='light']) .audit-event--resume {
888+
background: #047857;
889+
}
890+
891+
:root:not([data-theme='light']) .audit-event--scan {
892+
background: #b45309;
893+
}
894+
895+
:root:not([data-theme='light']) .audit-event--attack {
896+
background: #be123c;
897+
}
898+
899+
:root:not([data-theme='light']) .audit-event--export {
900+
background: #334155;
901+
}
902+
860903
@media (min-width: 48rem) {
861904
.app-shell {
862905
display: grid;

apps/web/src/lib/components/ConfirmDialog.svelte

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@
9494
</div>
9595

9696
<div class="dialog-body" id="confirm-dialog-body">
97-
<p>{body}</p>
97+
<p class="dialog-body__text">{body}</p>
9898
</div>
9999

100100
<div class="dialog-actions">
@@ -113,3 +113,10 @@
113113
</div>
114114
{/if}
115115
</dialog>
116+
117+
<style>
118+
.dialog-body__text {
119+
margin: 0;
120+
white-space: pre-line;
121+
}
122+
</style>

apps/web/src/lib/components/VaultUnlockPanel.svelte

Lines changed: 107 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
<script lang="ts">
2+
import { goto } from '$app/navigation';
3+
import { onMount } from 'svelte';
4+
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
5+
import { destroyVault, hasExistingVault } from '$lib/resume-vault';
6+
27
let {
38
title = 'Unlock your resume vault',
49
copy = 'Enter your passphrase to decrypt resumes stored on this device.',
@@ -14,17 +19,56 @@
1419
} = $props();
1520
1621
let passphrase = $state('');
22+
let resetBusy = $state(false);
23+
let resetSuccess = $state('');
24+
let hasVault = $state<boolean | null>(null);
25+
let resetDialog = $state<{ showModal: () => Promise<void> } | null>(null);
26+
27+
const createTitle = 'Create your resume vault';
28+
const createCopy = 'Choose a passphrase to create a new encrypted vault on this device.';
29+
30+
let vaultKnown = $derived(hasVault !== null);
31+
let resolvedTitle = $derived(hasVault === false ? createTitle : title);
32+
let resolvedCopy = $derived(hasVault === false ? createCopy : copy);
1733
1834
async function submit() {
1935
await onUnlock(passphrase);
2036
passphrase = '';
2137
}
38+
39+
function openResetDialog() {
40+
void resetDialog?.showModal();
41+
}
42+
43+
async function confirmReset() {
44+
resetBusy = true;
45+
46+
try {
47+
await destroyVault();
48+
passphrase = '';
49+
hasVault = false;
50+
resetSuccess = 'Vault reset complete. You can now create a new vault with a new passphrase.';
51+
window.setTimeout(() => {
52+
void goto('/', { replaceState: true }).catch(() => {
53+
window.location.reload();
54+
});
55+
}, 1200);
56+
} finally {
57+
resetBusy = false;
58+
}
59+
}
60+
61+
onMount(() => {
62+
void hasExistingVault().then((existingVault) => {
63+
hasVault = existingVault;
64+
});
65+
});
2266
</script>
2367

2468
<section class="content-card vault-panel">
2569
<p class="section-kicker">Encrypted Storage</p>
26-
<h3>{title}</h3>
27-
<p>{copy}</p>
70+
<h3>{resolvedTitle}</h3>
71+
<p>{resolvedCopy}</p>
2872

2973
<form class="stack-form" onsubmit={(event) => {
3074
event.preventDefault();
@@ -47,13 +91,72 @@
4791
<p class="field-error" data-testid="vault-error">{error}</p>
4892
{/if}
4993

94+
{#if resetSuccess}
95+
<p class="vault-reset-success" data-testid="vault-reset-success" role="status">{resetSuccess}</p>
96+
{/if}
97+
5098
<button
5199
class="shell-button shell-button--primary"
52100
data-testid="unlock-vault"
53-
disabled={busy}
101+
disabled={busy || resetBusy || !vaultKnown}
54102
type="submit"
55103
>
56-
{#if busy}Unlocking…{:else}Unlock vault{/if}
104+
{#if !vaultKnown}
105+
Checking vault…
106+
{:else if busy}
107+
Unlocking…
108+
{:else if !hasVault}
109+
Create vault
110+
{:else}
111+
Unlock vault
112+
{/if}
57113
</button>
114+
115+
{#if hasVault === true}
116+
<button
117+
class="vault-reset-link"
118+
data-testid="forgot-passphrase"
119+
disabled={resetBusy}
120+
type="button"
121+
onclick={openResetDialog}
122+
>
123+
Forgot your passphrase?
124+
</button>
125+
{/if}
58126
</form>
59127
</section>
128+
129+
<ConfirmDialog
130+
bind:this={resetDialog}
131+
body={`If you forgot your passphrase, your encrypted data cannot be recovered. ShieldCV uses local encryption with no server backup. Resetting the vault will permanently delete all stored resumes, scan history, and audit logs. You will create a new vault with a new passphrase.\n\nThis action cannot be undone.`}
132+
busy={resetBusy}
133+
confirmTestId="confirm-reset-vault"
134+
destructiveLabel="Delete all data and reset"
135+
kicker="Reset vault"
136+
title="Reset Vault"
137+
onConfirm={confirmReset}
138+
/>
139+
140+
<style>
141+
.vault-reset-link {
142+
justify-self: start;
143+
border: 0;
144+
padding: 0;
145+
background: transparent;
146+
color: var(--accent);
147+
font: inherit;
148+
font-weight: 600;
149+
text-decoration: underline;
150+
text-underline-offset: 0.2em;
151+
}
152+
153+
.vault-reset-link:disabled {
154+
color: var(--text-muted);
155+
text-decoration: none;
156+
}
157+
158+
.vault-reset-success {
159+
margin: 0;
160+
color: var(--success);
161+
}
162+
</style>

apps/web/src/lib/demo-audit.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { initAudit } from '@shieldcv/audit';
22
import { EncryptedStore } from '@shieldcv/storage';
33

4-
const ATTACK_MODE_AUDIT_DATABASE_NAME = 'shieldcv-attack-mode-audit';
4+
export const ATTACK_MODE_AUDIT_DATABASE_NAME = 'shieldcv-attack-mode-audit';
55
const ATTACK_MODE_AUDIT_PASSPHRASE = 'shieldcv-attack-mode-demo';
6-
const ATTACK_MODE_AUDIT_FLAG = 'shieldcv.attackModeAuditReady';
6+
export const ATTACK_MODE_AUDIT_FLAG = 'shieldcv.attackModeAuditReady';
77

88
type AuditStoreBinding = Pick<EncryptedStore, 'get' | 'put' | 'list' | 'delete'>;
99

apps/web/src/lib/resume-vault.test.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,55 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
22
import { createBlankResume } from '@shieldcv/resume';
3+
import { appendEntry } from '@shieldcv/audit';
4+
import { EncryptedStore } from '@shieldcv/storage';
35
import {
46
assertResumeWithinSizeLimit,
7+
destroyVault,
8+
hasExistingVault,
59
MAX_RESUME_SIZE,
610
measureResumeSize,
711
ResumeSizeError,
812
} from './resume-vault';
913

14+
vi.mock('@shieldcv/audit', () => ({
15+
appendEntry: vi.fn(),
16+
getEntries: vi.fn(async () => []),
17+
initAudit: vi.fn(async () => undefined),
18+
}));
19+
20+
vi.mock('@shieldcv/storage', () => ({
21+
EncryptedStore: {
22+
open: vi.fn(),
23+
destroy: vi.fn(async () => undefined),
24+
hasSentinel: vi.fn(async () => true),
25+
},
26+
}));
27+
28+
vi.mock('$lib/demo-audit', () => ({
29+
ATTACK_MODE_AUDIT_DATABASE_NAME: 'shieldcv-attack-mode-audit',
30+
ATTACK_MODE_AUDIT_FLAG: 'shieldcv.attackModeAuditReady',
31+
}));
32+
1033
describe('resume vault size guard', () => {
34+
beforeEach(() => {
35+
vi.clearAllMocks();
36+
37+
Object.defineProperty(window, 'localStorage', {
38+
configurable: true,
39+
value: {
40+
clear: vi.fn(),
41+
removeItem: vi.fn(),
42+
},
43+
});
44+
45+
Object.defineProperty(window, 'sessionStorage', {
46+
configurable: true,
47+
value: {
48+
clear: vi.fn(),
49+
},
50+
});
51+
});
52+
1153
it('accepts resumes that fit within the configured size limit', () => {
1254
const resume = createBlankResume('resume-within-limit');
1355
resume.basics.summary = 'A concise summary.';
@@ -33,4 +75,40 @@ describe('resume vault size guard', () => {
3375
expect((error as ResumeSizeError).message).toContain('Input exceeds maximum size');
3476
}
3577
});
78+
79+
it('reports whether a vault already exists', async () => {
80+
vi.mocked(EncryptedStore.hasSentinel).mockResolvedValueOnce(false);
81+
82+
await expect(hasExistingVault()).resolves.toBe(false);
83+
expect(EncryptedStore.hasSentinel).toHaveBeenCalledWith('shieldcv-local-vault');
84+
});
85+
86+
it('resets storage without writing an audit event when the vault is locked', async () => {
87+
vi.mocked(EncryptedStore.open).mockResolvedValueOnce({
88+
close: vi.fn(),
89+
isUnlocked: false,
90+
} as never);
91+
92+
await destroyVault();
93+
94+
expect(appendEntry).not.toHaveBeenCalled();
95+
expect(EncryptedStore.destroy).toHaveBeenCalledWith('shieldcv-local-vault');
96+
expect(EncryptedStore.destroy).toHaveBeenCalledWith('shieldcv-attack-mode-audit');
97+
expect(window.localStorage.clear).toHaveBeenCalledOnce();
98+
expect(window.sessionStorage.clear).toHaveBeenCalledOnce();
99+
});
100+
101+
it('writes a vault_destroyed audit event when the vault is unlocked', async () => {
102+
vi.mocked(EncryptedStore.open).mockResolvedValueOnce({
103+
close: vi.fn(),
104+
isUnlocked: true,
105+
} as never);
106+
107+
await destroyVault();
108+
109+
expect(appendEntry).toHaveBeenCalledWith(
110+
'vault_destroyed',
111+
'Destroyed encrypted resume vault and cleared local data.'
112+
);
113+
});
36114
});

0 commit comments

Comments
 (0)