feat: per-profile DEKs + multi-profile (Phase A2, #3)
Implements the 3-tier key model from the multi-person sharing ADR: each account owns multiple profiles (a person or pet — a 'subject of care'), and each profile has its own random AES-256-GCM DEK. All health data is now encrypted under the active profile's DEK, not the account-wide DEK. The account DEK wraps each profile DEK; the server stores only opaque wrapped blobs. Per the ADR: DB wipe, no migration (no real user data). This unblocks Phase B (sharing) — there is now a per-profile key to wrap to a recipient's X25519 public key. Backend: - Profile model: owner_account_id, kind (human/pet), relationship, wrapped_profile_dek + iv. ProfileRepository gains find_all_by_owner, find_by_profile_id_owned, update_profile, delete_profile — all ownership-scoped. - Profile handlers: GET/POST /api/profiles, GET/PUT/DELETE /api/profiles/:id. Removed /api/profiles/me. Renamed users.rs get_profile/update_profile (the /api/users/me handlers) to get_account/update_account to resolve a name collision. - Register accepts default_profile_* fields and auto-creates the self profile when the client provides a wrapped profile DEK. - HealthStatistic + Appointment gain profile_id and ?profile_id= filtering (health stats previously had no profile binding). - New profile_tests.rs: multi-profile CRUD + ownership isolation + register-with-default-profile. Fixed the zk health-stat test to send the now-required profile_id. Frontend: - crypto/keys.ts: generateProfileDek, wrapProfileDek, unwrapProfileDek + in-memory per-profile DEK store with an active-profile concept. - useProfileStore rewritten: holds profiles[], activeProfileId; loadProfiles unwraps each profile DEK; create/update/delete. All 11 encrypt/decrypt sites switched from getEncKey() to getActiveProfileDek(). load actions pass ?profile_id= so only the active profile's rows come back. - ProfileEditor rewritten for the new store (edit active profile, create/delete). New ProfileSwitcher in the Dashboard AppBar. - MedicationManager / AppointmentsManager use the active profile id instead of the hardcoded profile_<user_id>. - 2 new crypto tests for per-profile DEK isolation; updated store + component tests for the active-profile-DEK model. Verification: backend cargo build/clippy/fmt green, tests compile (integration tests run in CI — Mongo is fixed there). Frontend tsc clean, 30/30 tests pass. Closes nothing yet (Phase B/C/D remain). Refs #3.
This commit is contained in:
parent
9807434c5f
commit
eb2c2aa546
25 changed files with 1322 additions and 206 deletions
|
|
@ -24,7 +24,7 @@ import EditIcon from '@mui/icons-material/Edit';
|
|||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { format } from 'date-fns';
|
||||
import { useAppointmentStore, useAuthStore } from '../../store/useStore';
|
||||
import { useAppointmentStore, useProfileStore } from '../../store/useStore';
|
||||
import type { Appointment } from '../../types/api';
|
||||
|
||||
const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other'];
|
||||
|
|
@ -74,7 +74,7 @@ export const AppointmentsManager: FC = () => {
|
|||
deleteAppointment,
|
||||
clearError,
|
||||
} = useAppointmentStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const activeProfileId = useProfileStore((s) => s.activeProfileId);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm, setCreateForm] = useState<ApptFormData>(emptyCreate);
|
||||
|
|
@ -84,13 +84,13 @@ export const AppointmentsManager: FC = () => {
|
|||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadAppointments();
|
||||
}, [loadAppointments]);
|
||||
if (activeProfileId) loadAppointments();
|
||||
}, [loadAppointments, activeProfileId]);
|
||||
|
||||
const openCreate = () => {
|
||||
setCreateForm({
|
||||
...emptyCreate,
|
||||
profile_id: `profile_${user?.user_id ?? 'default'}`,
|
||||
profile_id: activeProfileId ?? 'default',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ describe('MedicationManager', () => {
|
|||
resetMockStore();
|
||||
setMockStore({
|
||||
useAuthStore: { user: { user_id: 'u1', username: 'tester' }, profile: {} },
|
||||
// The load effect is gated on activeProfileId (Phase A2); provide one.
|
||||
useProfileStore: { activeProfileId: 'profile_u1' },
|
||||
});
|
||||
baseActions.loadMedications.mockClear();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { useMedicationStore, useAuthStore } from '../../store/useStore';
|
||||
import { useMedicationStore, useProfileStore } from '../../store/useStore';
|
||||
import type { Medication } from '../../types/api';
|
||||
import { DoseLogger } from './DoseLogger';
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ export const MedicationManager: FC = () => {
|
|||
deleteMedication,
|
||||
clearError,
|
||||
} = useMedicationStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const activeProfileId = useProfileStore((s) => s.activeProfileId);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm, setCreateForm] = useState<MedFormData>(emptyCreate);
|
||||
|
|
@ -69,17 +69,16 @@ export const MedicationManager: FC = () => {
|
|||
const [deleteTarget, setDeleteTarget] = useState<Medication | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Reload medications when the active profile changes — each profile's data
|
||||
// is encrypted under its own DEK and filtered server-side by profile_id.
|
||||
useEffect(() => {
|
||||
loadMedications();
|
||||
}, [loadMedications]);
|
||||
if (activeProfileId) loadMedications();
|
||||
}, [loadMedications, activeProfileId]);
|
||||
|
||||
const openCreate = () => {
|
||||
// profile_id is deterministic: profile_<user_id>. The backend auto-creates
|
||||
// this profile on register, so the id always resolves to a real profile.
|
||||
const profileId = user?.profile_id ?? `profile_${user?.user_id ?? 'default'}`;
|
||||
setCreateForm({
|
||||
...emptyCreate,
|
||||
profile_id: profileId,
|
||||
profile_id: activeProfileId ?? 'default',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,34 +7,61 @@ import {
|
|||
CircularProgress,
|
||||
Alert,
|
||||
Chip,
|
||||
MenuItem,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useProfileStore, useAuthStore } from '../../store/useStore';
|
||||
|
||||
export const ProfileEditor: FC = () => {
|
||||
const { profile, isLoading, error, loadProfile, updateName, clearError } =
|
||||
useProfileStore();
|
||||
const {
|
||||
profiles,
|
||||
activeProfileId,
|
||||
isLoading,
|
||||
error,
|
||||
loadProfiles,
|
||||
updateProfile,
|
||||
createProfile,
|
||||
deleteProfile,
|
||||
clearError,
|
||||
} = useProfileStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const active = profiles.find((p) => p.profile_id === activeProfileId) ?? null;
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [kind, setKind] = useState('human');
|
||||
const [relationship, setRelationship] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, [loadProfile]);
|
||||
// Create-profile dialog state
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newKind, setNewKind] = useState('human');
|
||||
const [newRel, setNewRel] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (profile?.name) setName(profile.name);
|
||||
}, [profile]);
|
||||
loadProfiles();
|
||||
}, [loadProfiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
setName(active.name);
|
||||
setKind(active.kind || 'human');
|
||||
setRelationship(active.relationship || '');
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!active) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateName(name);
|
||||
await updateProfile(active.profile_id, { name, kind, relationship });
|
||||
setEditing(false);
|
||||
} catch {
|
||||
/* error surfaced via store */
|
||||
|
|
@ -43,11 +70,43 @@ export const ProfileEditor: FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await createProfile({ name: newName, kind: newKind, relationship: newRel });
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
setNewKind('human');
|
||||
setNewRel('');
|
||||
} catch {
|
||||
/* error surfaced via store */
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!active) return;
|
||||
if (profiles.length <= 1) {
|
||||
alert('You must have at least one profile. Create another before deleting this one.');
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Delete the "${active.name}" profile? Its encrypted data cannot be recovered.`)) return;
|
||||
try {
|
||||
await deleteProfile(active.profile_id);
|
||||
} catch {
|
||||
/* error surfaced via store */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Profile
|
||||
</Typography>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="h6">Profiles</Typography>
|
||||
<Button startIcon={<AddIcon />} onClick={() => setCreating(true)}>
|
||||
Add profile
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={clearError}>
|
||||
|
|
@ -55,11 +114,11 @@ export const ProfileEditor: FC = () => {
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
{isLoading && !profile ? (
|
||||
{isLoading && profiles.length === 0 ? (
|
||||
<Box display="flex" justifyContent="center" py={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
) : active ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
|
|
@ -84,39 +143,131 @@ export const ProfileEditor: FC = () => {
|
|||
>
|
||||
{saving ? <CircularProgress size={24} /> : 'Save'}
|
||||
</Button>
|
||||
<Button onClick={() => { setEditing(false); setName(profile?.name ?? ''); }}>
|
||||
<Button onClick={() => { setEditing(false); setName(active.name); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Typography variant="h6">{profile?.name ?? user?.username ?? '—'}</Typography>
|
||||
<Typography variant="h6">{active.name || user?.username || '—'}</Typography>
|
||||
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Account
|
||||
</Typography>
|
||||
<Typography variant="body2">{user?.email}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Role
|
||||
</Typography>
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Chip size="small" label={profile?.role ?? 'patient'} variant="outlined" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{profile?.profile_id && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Profile ID: {profile.profile_id}
|
||||
</Typography>
|
||||
{editing && (
|
||||
<>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Kind</Typography>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
fullWidth
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
<MenuItem value="human">Human</MenuItem>
|
||||
<MenuItem value="pet">Pet</MenuItem>
|
||||
</TextField>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Relationship
|
||||
</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
value={relationship}
|
||||
onChange={(e) => setRelationship(e.target.value)}
|
||||
placeholder="self, child, spouse, parent, pet, ..."
|
||||
sx={{ mt: 0.5 }}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!editing && (
|
||||
<>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Kind / relationship</Typography>
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Chip size="small" label={active.kind || 'human'} variant="outlined" sx={{ mr: 1 }} />
|
||||
{active.relationship && (
|
||||
<Chip size="small" label={active.relationship} variant="outlined" />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Account</Typography>
|
||||
<Typography variant="body2">{user?.email}</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Profile ID: {active.profile_id}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete profile
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
No profiles yet. Create one to start tracking health data.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{creating && (
|
||||
<Card sx={{ mt: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ mb: 2 }}>New profile</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Display name"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label="Kind"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={newKind}
|
||||
onChange={(e) => setNewKind(e.target.value)}
|
||||
>
|
||||
<MenuItem value="human">Human</MenuItem>
|
||||
<MenuItem value="pet">Pet</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Relationship"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={newRel}
|
||||
onChange={(e) => setNewRel(e.target.value)}
|
||||
placeholder="child, spouse, parent, pet, ..."
|
||||
/>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleCreate}
|
||||
disabled={saving || !newName.trim()}
|
||||
>
|
||||
{saving ? <CircularProgress size={24} /> : 'Create'}
|
||||
</Button>
|
||||
<Button onClick={() => setCreating(false)}>Cancel</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
49
web/normogen-web/src/components/profile/ProfileSwitcher.tsx
Normal file
49
web/normogen-web/src/components/profile/ProfileSwitcher.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useEffect, type FC } from 'react';
|
||||
import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
|
||||
import { useProfileStore } from '../../store/useStore';
|
||||
|
||||
/** A compact dropdown for switching the active profile (the subject of care
|
||||
* whose data the dashboard shows). Sits in the AppBar. Triggers `loadProfiles`
|
||||
* on mount so the list + per-profile DEKs are ready. */
|
||||
export const ProfileSwitcher: FC = () => {
|
||||
const { profiles, activeProfileId, isLoading, loadProfiles, setActiveProfile } =
|
||||
useProfileStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadProfiles();
|
||||
}, [loadProfiles]);
|
||||
|
||||
if (profiles.length === 0) {
|
||||
return isLoading ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CircularProgress size={18} sx={{ color: 'common.white' }} />
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ opacity: 0.7, mr: 2 }}>
|
||||
No profile
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
size="small"
|
||||
value={activeProfileId ?? ''}
|
||||
onChange={(e) => setActiveProfile(e.target.value)}
|
||||
sx={{
|
||||
mr: 2,
|
||||
color: 'common.white',
|
||||
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
|
||||
'.MuiSvgIcon-root': { color: 'common.white' },
|
||||
}}
|
||||
>
|
||||
{profiles.map((p) => (
|
||||
<MenuItem key={p.profile_id} value={p.profile_id}>
|
||||
{p.name || p.relationship || p.profile_id}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSwitcher;
|
||||
|
|
@ -6,6 +6,8 @@ import {
|
|||
rewrapDek,
|
||||
encryptJson,
|
||||
decryptJson,
|
||||
encrypt,
|
||||
decrypt,
|
||||
setEncKey,
|
||||
clearEncKey,
|
||||
hasEncKey,
|
||||
|
|
@ -15,6 +17,12 @@ import {
|
|||
setIdentityPrivate,
|
||||
clearIdentityPrivate,
|
||||
hasIdentityPrivate,
|
||||
generateProfileDek,
|
||||
wrapProfileDek,
|
||||
unwrapProfileDek,
|
||||
setProfileDek,
|
||||
getProfileDek,
|
||||
clearProfileDeks,
|
||||
} from './index';
|
||||
|
||||
/** X25519 in Web Crypto is a recent addition (Node 16+, modern browsers). Some
|
||||
|
|
@ -224,3 +232,53 @@ describe('account X25519 identity keypair (Phase A1)', () => {
|
|||
expect(aHex).not.toBe(bHex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-profile DEK isolation (Phase A2)', () => {
|
||||
const password = 'my-secret-password';
|
||||
|
||||
it('two profiles get distinct DEKs, each wrapped under the account DEK', async () => {
|
||||
// One account DEK (unlocked from the password).
|
||||
const setup = await setupEncryption(password);
|
||||
const accountDek = setup.dek;
|
||||
|
||||
// Two profiles, each with its own random DEK.
|
||||
const childDek = await generateProfileDek();
|
||||
const petDek = await generateProfileDek();
|
||||
|
||||
// Each profile DEK is wrapped under the account DEK for storage.
|
||||
const childWrapped = await wrapProfileDek(childDek, accountDek);
|
||||
const petWrapped = await wrapProfileDek(petDek, accountDek);
|
||||
|
||||
// Both wrapped blobs decrypt cleanly under the account DEK.
|
||||
const childUnwrapped = await unwrapProfileDek(childWrapped, accountDek);
|
||||
const petUnwrapped = await unwrapProfileDek(petWrapped, accountDek);
|
||||
expect(childUnwrapped).toBeDefined();
|
||||
expect(petUnwrapped).toBeDefined();
|
||||
|
||||
// Data encrypted under the child profile's DEK cannot be decrypted by the
|
||||
// pet profile's DEK (and vice versa) — this is the per-profile isolation
|
||||
// guarantee that makes the parent/child and pet cases safe.
|
||||
const childData = await encrypt('child-medication', childDek);
|
||||
await expect(decrypt(childData, petDek)).rejects.toThrow();
|
||||
const petData = await encrypt('pet-weight', petDek);
|
||||
await expect(decrypt(petData, childDek)).rejects.toThrow();
|
||||
|
||||
// ...but each decrypts with its own key.
|
||||
expect(await decrypt(childData, childDek)).toBe('child-medication');
|
||||
expect(await decrypt(petData, petDek)).toBe('pet-weight');
|
||||
});
|
||||
|
||||
it('the in-memory profile-DEK store maps profile ids to DEKs', async () => {
|
||||
const childDek = await generateProfileDek();
|
||||
const petDek = await generateProfileDek();
|
||||
setProfileDek('profile_child', childDek);
|
||||
setProfileDek('profile_pet', petDek);
|
||||
// Each id resolves to the DEK that was stored under it; unknown ids resolve to null.
|
||||
expect(getProfileDek('profile_child')).toBe(childDek);
|
||||
expect(getProfileDek('profile_pet')).toBe(petDek);
|
||||
expect(getProfileDek('profile_unknown')).toBeNull();
|
||||
// Clearing drops everything.
|
||||
clearProfileDeks();
|
||||
expect(getProfileDek('profile_child')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ export {
|
|||
generateIdentityKeyPair,
|
||||
wrapIdentityPrivateKey,
|
||||
unwrapIdentityPrivateKey,
|
||||
generateProfileDek,
|
||||
wrapProfileDek,
|
||||
unwrapProfileDek,
|
||||
setEncKey,
|
||||
getEncKey,
|
||||
clearEncKey,
|
||||
|
|
@ -19,6 +22,12 @@ export {
|
|||
getIdentityPrivate,
|
||||
clearIdentityPrivate,
|
||||
hasIdentityPrivate,
|
||||
setProfileDek,
|
||||
getProfileDek,
|
||||
setActiveProfileId,
|
||||
getActiveProfileId,
|
||||
getActiveProfileDek,
|
||||
clearProfileDeks,
|
||||
AUTH_SALT,
|
||||
PASSWORD_KEK_SALT,
|
||||
RECOVERY_KEK_SALT,
|
||||
|
|
|
|||
|
|
@ -114,6 +114,39 @@ export async function rewrapDek(
|
|||
return wrapDek(dek, newKek);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-profile DEK wrap/unwrap (Phase A2).
|
||||
//
|
||||
// Each profile owns a random AES-256-GCM DEK that encrypts all of that
|
||||
// profile's data. The profile DEK is wrapped under the *account* DEK (not a
|
||||
// KEK derived from a secret) and stored on the server. At login/unlock the
|
||||
// client unwraps each profile DEK using the account DEK. See
|
||||
// docs/adr/multi-person-sharing.md §1.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Generate a fresh random profile DEK (a random AES-256-GCM key). */
|
||||
export async function generateProfileDek(): Promise<CryptoKey> {
|
||||
return generateDek();
|
||||
}
|
||||
|
||||
/** Wrap a profile DEK under the account DEK. The account DEK is a CryptoKey
|
||||
* usable for AES-GCM encrypt/decrypt, so it serves directly as the wrap key. */
|
||||
export async function wrapProfileDek(
|
||||
profileDek: CryptoKey,
|
||||
accountDek: CryptoKey,
|
||||
): Promise<CipherPayload> {
|
||||
return wrapDek(profileDek, accountDek);
|
||||
}
|
||||
|
||||
/** Unwrap a profile DEK from its account-wrapped form. Inverse of
|
||||
* wrapProfileDek. Throws on tamper / wrong account DEK. */
|
||||
export async function unwrapProfileDek(
|
||||
payload: CipherPayload,
|
||||
accountDek: CryptoKey,
|
||||
): Promise<CryptoKey> {
|
||||
return unwrapDek(payload, accountDek);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// High-level flows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -287,6 +320,43 @@ export function hasIdentityPrivate(): boolean {
|
|||
return currentIdentityPrivate !== null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory per-profile DEK store (Phase A2). One DEK per profile, plus a
|
||||
// notion of the "active" profile whose DEK encrypts/decrypts the data the UI
|
||||
// is currently showing. Same lifecycle as the account DEK: rebuilt from the
|
||||
// server-stored wrapped forms on unlock, cleared on logout.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const profileDeks: Map<string, CryptoKey> = new Map();
|
||||
let activeProfileId: string | null = null;
|
||||
|
||||
export function setProfileDek(profileId: string, dek: CryptoKey): void {
|
||||
profileDeks.set(profileId, dek);
|
||||
}
|
||||
|
||||
export function getProfileDek(profileId: string): CryptoKey | null {
|
||||
return profileDeks.get(profileId) ?? null;
|
||||
}
|
||||
|
||||
export function setActiveProfileId(profileId: string): void {
|
||||
activeProfileId = profileId;
|
||||
}
|
||||
|
||||
export function getActiveProfileId(): string | null {
|
||||
return activeProfileId;
|
||||
}
|
||||
|
||||
/** The active profile's DEK — the key all encrypt/decrypt call sites use. */
|
||||
export function getActiveProfileDek(): CryptoKey | null {
|
||||
if (activeProfileId === null) return null;
|
||||
return profileDeks.get(activeProfileId) ?? null;
|
||||
}
|
||||
|
||||
export function clearProfileDeks(): void {
|
||||
profileDeks.clear();
|
||||
activeProfileId = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward compat: the old deriveAuthAndEncKeys (Phase 1 direct-from-password
|
||||
// enc key). Kept for tests; new code uses setupEncryption/unlockWithPassword.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { MedicationManager } from '../components/medication/MedicationManager';
|
|||
import { HealthStats } from '../components/health/HealthStats';
|
||||
import { InteractionsChecker } from '../components/interactions/InteractionsChecker';
|
||||
import { ProfileEditor } from '../components/profile/ProfileEditor';
|
||||
import { ProfileSwitcher } from '../components/profile/ProfileSwitcher';
|
||||
import { AppointmentsManager } from '../components/appointments/AppointmentsManager';
|
||||
|
||||
type TabIndex = 0 | 1 | 2 | 3 | 4;
|
||||
|
|
@ -35,6 +36,7 @@ export const Dashboard: FC = () => {
|
|||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
Normogen
|
||||
</Typography>
|
||||
<ProfileSwitcher />
|
||||
{user && (
|
||||
<Typography variant="body2" sx={{ mr: 2, opacity: 0.85 }}>
|
||||
{user.username}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import {
|
|||
MedicationWireResponse,
|
||||
AppointmentWireResponse,
|
||||
ProfileWireResponse,
|
||||
CreateProfileRequest,
|
||||
UpdateProfileRequest,
|
||||
HealthStatWireResponse,
|
||||
UpdateHealthStatRequest,
|
||||
EncryptedFieldWire,
|
||||
|
|
@ -232,25 +234,37 @@ class ApiService {
|
|||
});
|
||||
}
|
||||
|
||||
// ---- Profile (zero-knowledge: opaque encrypted name) ----
|
||||
// ---- Profiles (Phase A2: multi-profile, per-profile DEKs) ----
|
||||
|
||||
async getProfile(): Promise<ProfileWireResponse> {
|
||||
const response = await this.client.get<ProfileWireResponse>('/profiles/me');
|
||||
async listProfiles(): Promise<ProfileWireResponse[]> {
|
||||
const response = await this.client.get<ProfileWireResponse[]>('/profiles');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateProfileName(nameData: string, nameIv: string): Promise<ProfileWireResponse> {
|
||||
const response = await this.client.put<ProfileWireResponse>('/profiles/me', {
|
||||
name_data: nameData,
|
||||
name_iv: nameIv,
|
||||
});
|
||||
async createProfile(req: CreateProfileRequest): Promise<ProfileWireResponse> {
|
||||
const response = await this.client.post<ProfileWireResponse>('/profiles', req);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getProfile(profileId: string): Promise<ProfileWireResponse> {
|
||||
const response = await this.client.get<ProfileWireResponse>(`/profiles/${profileId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateProfile(profileId: string, req: UpdateProfileRequest): Promise<ProfileWireResponse> {
|
||||
const response = await this.client.put<ProfileWireResponse>(`/profiles/${profileId}`, req);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteProfile(profileId: string): Promise<void> {
|
||||
await this.client.delete(`/profiles/${profileId}`);
|
||||
}
|
||||
|
||||
// ---- Medications (zero-knowledge: opaque encrypted blobs) ----
|
||||
|
||||
async getMedications(): Promise<MedicationWireResponse[]> {
|
||||
const response = await this.client.get<MedicationWireResponse[]>('/medications');
|
||||
async getMedications(profileId?: string): Promise<MedicationWireResponse[]> {
|
||||
const params = profileId ? { profile_id: profileId } : undefined;
|
||||
const response = await this.client.get<MedicationWireResponse[]>('/medications', { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
|
@ -287,9 +301,12 @@ class ApiService {
|
|||
|
||||
// ---- Appointments (zero-knowledge: opaque encrypted blobs) ----
|
||||
|
||||
async getAppointments(status?: string): Promise<AppointmentWireResponse[]> {
|
||||
async getAppointments(status?: string, profileId?: string): Promise<AppointmentWireResponse[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (status) params.status = status;
|
||||
if (profileId) params.profile_id = profileId;
|
||||
const response = await this.client.get<AppointmentWireResponse[]>('/appointments', {
|
||||
params: status ? { status } : undefined,
|
||||
params: Object.keys(params).length ? params : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
|
@ -332,8 +349,9 @@ class ApiService {
|
|||
|
||||
// ---- Health Statistics (zero-knowledge: opaque encrypted blobs) ----
|
||||
|
||||
async getHealthStats(): Promise<HealthStatWireResponse[]> {
|
||||
const response = await this.client.get<HealthStatWireResponse[]>('/health-stats');
|
||||
async getHealthStats(profileId?: string): Promise<HealthStatWireResponse[]> {
|
||||
const params = profileId ? { profile_id: profileId } : undefined;
|
||||
const response = await this.client.get<HealthStatWireResponse[]>('/health-stats', { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import {
|
|||
deriveAuthAndEncKeys,
|
||||
setEncKey,
|
||||
clearEncKey,
|
||||
setActiveProfileId,
|
||||
setProfileDek,
|
||||
clearProfileDeks,
|
||||
encryptJson,
|
||||
setupEncryption,
|
||||
unlockWithPassword,
|
||||
|
|
@ -22,6 +25,10 @@ vi.mock('../services/api', () => ({ default: apiMock }));
|
|||
// Import the store AFTER the mock is registered.
|
||||
const { useMedicationStore } = await import('./useStore');
|
||||
|
||||
// The active profile under test — the store encrypts/decrypts with the active
|
||||
// profile's DEK, so tests must set both the profile id and its DEK.
|
||||
const TEST_PROFILE_ID = 'profile_test';
|
||||
|
||||
describe('useMedicationStore', () => {
|
||||
let encKey: CryptoKey;
|
||||
|
||||
|
|
@ -30,6 +37,10 @@ describe('useMedicationStore', () => {
|
|||
const { encKey: key } = await deriveAuthAndEncKeys('test-password');
|
||||
encKey = key;
|
||||
setEncKey(encKey);
|
||||
// Phase A2: the store uses the active profile's DEK, not the account DEK.
|
||||
// Register the test key as the active profile's DEK.
|
||||
setProfileDek(TEST_PROFILE_ID, encKey);
|
||||
setActiveProfileId(TEST_PROFILE_ID);
|
||||
|
||||
useMedicationStore.setState({
|
||||
medications: [],
|
||||
|
|
@ -44,6 +55,7 @@ describe('useMedicationStore', () => {
|
|||
|
||||
afterEach(() => {
|
||||
clearEncKey();
|
||||
clearProfileDeks();
|
||||
});
|
||||
|
||||
it('loadMedications decrypts wire responses into domain medications', async () => {
|
||||
|
|
@ -74,6 +86,7 @@ describe('useMedicationStore', () => {
|
|||
|
||||
it('loadMedications sets an error when no enc key is available', async () => {
|
||||
clearEncKey();
|
||||
clearProfileDeks();
|
||||
await useMedicationStore.getState().loadMedications();
|
||||
expect(useMedicationStore.getState().error).toMatch(/No encryption key/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,11 +9,22 @@ import {
|
|||
generateIdentityKeyPair,
|
||||
wrapIdentityPrivateKey,
|
||||
unwrapIdentityPrivateKey,
|
||||
generateProfileDek,
|
||||
wrapProfileDek,
|
||||
unwrapProfileDek,
|
||||
encrypt as encryptRaw,
|
||||
decrypt as decryptRaw,
|
||||
setEncKey,
|
||||
clearEncKey,
|
||||
clearIdentityPrivate,
|
||||
clearProfileDeks,
|
||||
setIdentityPrivate,
|
||||
getEncKey,
|
||||
getActiveProfileDek,
|
||||
getActiveProfileId,
|
||||
setActiveProfileId,
|
||||
setProfileDek,
|
||||
getProfileDek,
|
||||
encryptJson,
|
||||
decryptJson,
|
||||
type CipherPayload,
|
||||
|
|
@ -106,11 +117,26 @@ interface InteractionState {
|
|||
}
|
||||
|
||||
interface ProfileState {
|
||||
profile: Profile | null;
|
||||
profiles: Profile[];
|
||||
activeProfileId: string | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
loadProfile: () => Promise<void>;
|
||||
updateName: (name: string) => Promise<void>;
|
||||
/** Fetch all owned profiles and unwrap each per-profile DEK under the account
|
||||
* DEK into the in-memory crypto store. Sets the first profile active. */
|
||||
loadProfiles: () => Promise<void>;
|
||||
/** Switch the active profile (whose DEK encrypts/decrypts the UI's data). */
|
||||
setActiveProfile: (profileId: string) => void;
|
||||
createProfile: (input: {
|
||||
name: string;
|
||||
kind?: string;
|
||||
relationship?: string;
|
||||
}) => Promise<void>;
|
||||
updateProfile: (profileId: string, input: {
|
||||
name: string;
|
||||
kind?: string;
|
||||
relationship?: string;
|
||||
}) => Promise<void>;
|
||||
deleteProfile: (profileId: string) => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -225,6 +251,14 @@ export const useAuthStore = create<AuthState>()(
|
|||
const wrappedPrivate = await wrapIdentityPrivateKey(privateKey, setup.dek);
|
||||
setIdentityPrivate(privateKey);
|
||||
|
||||
// Phase A2: generate the default "self" profile's DEK, wrap it
|
||||
// under the account DEK, and encrypt the display name (the chosen
|
||||
// username) under the profile DEK. The server stores both opaque
|
||||
// blobs verbatim and auto-creates the self profile.
|
||||
const defaultProfileDek = await generateProfileDek();
|
||||
const defaultWrapped = await wrapProfileDek(defaultProfileDek, setup.dek);
|
||||
const defaultNameBlob = await encryptRaw(username, defaultProfileDek);
|
||||
|
||||
const response = await apiService.register({
|
||||
username,
|
||||
email,
|
||||
|
|
@ -237,7 +271,16 @@ export const useAuthStore = create<AuthState>()(
|
|||
identity_public_key: publicKey,
|
||||
identity_private_key_wrapped: wrappedPrivate.data,
|
||||
identity_private_key_wrapped_iv: wrappedPrivate.iv,
|
||||
default_profile_name_data: defaultNameBlob.data,
|
||||
default_profile_name_iv: defaultNameBlob.iv,
|
||||
default_wrapped_profile_dek: defaultWrapped.data,
|
||||
default_wrapped_profile_dek_iv: defaultWrapped.iv,
|
||||
});
|
||||
// Seed the in-memory profile-DEK store with the self profile and
|
||||
// make it the active profile.
|
||||
const selfProfileId = `profile_${response.user_id}`;
|
||||
setProfileDek(selfProfileId, defaultProfileDek);
|
||||
setActiveProfileId(selfProfileId);
|
||||
set({
|
||||
user: {
|
||||
user_id: response.user_id,
|
||||
|
|
@ -305,6 +348,7 @@ export const useAuthStore = create<AuthState>()(
|
|||
logout: async () => {
|
||||
clearEncKey();
|
||||
clearIdentityPrivate();
|
||||
clearProfileDeks();
|
||||
await apiService.logout();
|
||||
set({
|
||||
user: null,
|
||||
|
|
@ -369,14 +413,14 @@ export const useMedicationStore = create<MedicationState>()(
|
|||
adherence: {},
|
||||
|
||||
loadMedications: async () => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const wireMeds = await apiService.getMedications();
|
||||
const wireMeds = await apiService.getMedications(getActiveProfileId() ?? undefined);
|
||||
// Decrypt each opaque blob into domain Medication objects.
|
||||
const medications = await Promise.all(
|
||||
wireMeds.map(async (w) => {
|
||||
|
|
@ -416,7 +460,7 @@ export const useMedicationStore = create<MedicationState>()(
|
|||
},
|
||||
|
||||
createMedication: async (data: any) => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
@ -466,7 +510,7 @@ export const useMedicationStore = create<MedicationState>()(
|
|||
},
|
||||
|
||||
updateMedication: async (id: string, data: any) => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
@ -565,14 +609,14 @@ export const useHealthStore = create<HealthState>()(
|
|||
error: null,
|
||||
|
||||
loadStats: async () => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const wireStats = await apiService.getHealthStats();
|
||||
const wireStats = await apiService.getHealthStats(getActiveProfileId() ?? undefined);
|
||||
const stats = await Promise.all(
|
||||
wireStats.map(async (w) => {
|
||||
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
|
||||
|
|
@ -599,7 +643,7 @@ export const useHealthStore = create<HealthState>()(
|
|||
},
|
||||
|
||||
createStat: async (data: any) => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
@ -607,6 +651,7 @@ export const useHealthStore = create<HealthState>()(
|
|||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const d = data as any;
|
||||
const profileId = getActiveProfileId();
|
||||
const encrypted_data = await encryptJson({
|
||||
stat_type: d.stat_type,
|
||||
value: d.value,
|
||||
|
|
@ -614,6 +659,7 @@ export const useHealthStore = create<HealthState>()(
|
|||
notes: d.notes,
|
||||
}, key);
|
||||
const wire = await apiService.createHealthStat({
|
||||
profile_id: profileId ?? '',
|
||||
encrypted_data,
|
||||
recorded_at: d.measured_at,
|
||||
});
|
||||
|
|
@ -640,7 +686,7 @@ export const useHealthStore = create<HealthState>()(
|
|||
},
|
||||
|
||||
updateStat: async (id: string, data: any) => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
@ -752,71 +798,158 @@ export const useInteractionStore = create<InteractionState>()(
|
|||
}))
|
||||
);
|
||||
|
||||
// Profile Store (Phase 3c)
|
||||
// Profile Store (Phase A2: multi-profile + per-profile DEKs)
|
||||
export const useProfileStore = create<ProfileState>()(
|
||||
devtools((set, get) => ({
|
||||
profile: null,
|
||||
profiles: [],
|
||||
activeProfileId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
loadProfile: async () => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
loadProfiles: async () => {
|
||||
const accountDek = getEncKey();
|
||||
if (!accountDek) {
|
||||
set({ error: 'No account key — log in to decrypt profiles', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const wire = await apiService.getProfile();
|
||||
// Decrypt the profile name.
|
||||
let name = '';
|
||||
if (wire.name_data && wire.name_iv) {
|
||||
try {
|
||||
name = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key);
|
||||
} catch { name = ''; }
|
||||
const wireList = await apiService.listProfiles();
|
||||
// Unwrap each profile's DEK under the account DEK, and decrypt each
|
||||
// profile's display name under that profile's DEK.
|
||||
const profiles: Profile[] = [];
|
||||
for (const wire of wireList) {
|
||||
let profileDek = getProfileDek(wire.profile_id);
|
||||
if (!profileDek) {
|
||||
try {
|
||||
profileDek = await unwrapProfileDek(
|
||||
{ data: wire.wrapped_profile_dek, iv: wire.wrapped_profile_dek_iv },
|
||||
accountDek,
|
||||
);
|
||||
setProfileDek(wire.profile_id, profileDek);
|
||||
} catch {
|
||||
// Could not unwrap this profile's DEK — skip it; the UI will
|
||||
// show the profiles it could decrypt.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let name = '';
|
||||
if (wire.name_data && wire.name_iv && profileDek) {
|
||||
try {
|
||||
name = await decryptRaw({ data: wire.name_data, iv: wire.name_iv }, profileDek);
|
||||
} catch { name = ''; }
|
||||
}
|
||||
profiles.push({
|
||||
profile_id: wire.profile_id,
|
||||
owner_account_id: wire.owner_account_id,
|
||||
name,
|
||||
kind: wire.kind,
|
||||
relationship: wire.relationship,
|
||||
role: wire.role,
|
||||
permissions: wire.permissions,
|
||||
created_at: wire.created_at,
|
||||
updated_at: wire.updated_at,
|
||||
});
|
||||
}
|
||||
const profile: Profile = {
|
||||
profile_id: wire.profile_id,
|
||||
user_id: wire.user_id,
|
||||
name,
|
||||
role: wire.role,
|
||||
permissions: wire.permissions,
|
||||
created_at: wire.created_at,
|
||||
updated_at: wire.updated_at,
|
||||
};
|
||||
set({ profile, isLoading: false });
|
||||
// Set the first profile active if none is selected (or if the active
|
||||
// one is no longer present).
|
||||
const currentActive = getActiveProfileId();
|
||||
const stillOwned = profiles.some((p) => p.profile_id === currentActive);
|
||||
const newActive = stillOwned ? currentActive : (profiles[0]?.profile_id ?? null);
|
||||
if (newActive) setActiveProfileId(newActive);
|
||||
set({ profiles, activeProfileId: newActive, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to load profile',
|
||||
error: error.message || 'Failed to load profiles',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateName: async (name) => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
setActiveProfile: (profileId) => {
|
||||
setActiveProfileId(profileId);
|
||||
set({ activeProfileId: profileId });
|
||||
},
|
||||
|
||||
createProfile: async ({ name, kind = 'human', relationship = '' }) => {
|
||||
const accountDek = getEncKey();
|
||||
if (!accountDek) {
|
||||
set({ error: 'No account key — cannot create profile' });
|
||||
throw new Error('No account key');
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const blob = await encryptJson(name, key);
|
||||
const wire = await apiService.updateProfileName(blob.data, blob.iv);
|
||||
let decryptedName = name;
|
||||
try {
|
||||
decryptedName = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key);
|
||||
} catch { /* keep the input name */ }
|
||||
// Generate a fresh profile DEK, wrap it under the account DEK, and
|
||||
// encrypt the display name under the new profile DEK.
|
||||
const profileDek = await generateProfileDek();
|
||||
const wrapped = await wrapProfileDek(profileDek, accountDek);
|
||||
const nameBlob = await encryptRaw(name, profileDek);
|
||||
const wire = await apiService.createProfile({
|
||||
kind,
|
||||
relationship,
|
||||
name_data: nameBlob.data,
|
||||
name_iv: nameBlob.iv,
|
||||
wrapped_profile_dek: wrapped.data,
|
||||
wrapped_profile_dek_iv: wrapped.iv,
|
||||
});
|
||||
// Cache the DEK in memory (we already have it) and set this profile
|
||||
// active — the user just created it.
|
||||
setProfileDek(wire.profile_id, profileDek);
|
||||
setActiveProfileId(wire.profile_id);
|
||||
const profile: Profile = {
|
||||
profile_id: wire.profile_id,
|
||||
user_id: wire.user_id,
|
||||
name: decryptedName,
|
||||
owner_account_id: wire.owner_account_id,
|
||||
name,
|
||||
kind: wire.kind,
|
||||
relationship: wire.relationship,
|
||||
role: wire.role,
|
||||
permissions: wire.permissions,
|
||||
created_at: wire.created_at,
|
||||
updated_at: wire.updated_at,
|
||||
};
|
||||
set({ profile, isLoading: false });
|
||||
set((state) => ({
|
||||
profiles: [...state.profiles, profile],
|
||||
activeProfileId: wire.profile_id,
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to create profile',
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateProfile: async (profileId, { name, kind, relationship }) => {
|
||||
const profileDek = getProfileDek(profileId);
|
||||
if (!profileDek) {
|
||||
set({ error: 'No profile key — switch to or unlock this profile first' });
|
||||
throw new Error('No profile key');
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const nameBlob = await encryptRaw(name, profileDek);
|
||||
const wire = await apiService.updateProfile(profileId, {
|
||||
name_data: nameBlob.data,
|
||||
name_iv: nameBlob.iv,
|
||||
kind: kind ?? 'human',
|
||||
relationship: relationship ?? '',
|
||||
});
|
||||
set((state) => ({
|
||||
profiles: state.profiles.map((p) =>
|
||||
p.profile_id === profileId
|
||||
? {
|
||||
...p,
|
||||
name,
|
||||
kind: wire.kind,
|
||||
relationship: wire.relationship,
|
||||
updated_at: wire.updated_at,
|
||||
}
|
||||
: p,
|
||||
),
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to update profile',
|
||||
|
|
@ -826,6 +959,27 @@ export const useProfileStore = create<ProfileState>()(
|
|||
}
|
||||
},
|
||||
|
||||
deleteProfile: async (profileId) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await apiService.deleteProfile(profileId);
|
||||
const remaining = get().profiles.filter((p) => p.profile_id !== profileId);
|
||||
// If we deleted the active profile, fall back to the first remaining.
|
||||
let newActive = getActiveProfileId();
|
||||
if (newActive === profileId) {
|
||||
newActive = remaining[0]?.profile_id ?? null;
|
||||
if (newActive) setActiveProfileId(newActive);
|
||||
}
|
||||
set({ profiles: remaining, activeProfileId: newActive, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to delete profile',
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
})),
|
||||
);
|
||||
|
|
@ -838,14 +992,14 @@ export const useAppointmentStore = create<AppointmentState>()(
|
|||
error: null,
|
||||
|
||||
loadAppointments: async (status) => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const wireAppts = await apiService.getAppointments(status);
|
||||
const wireAppts = await apiService.getAppointments(status, getActiveProfileId() ?? undefined);
|
||||
const appointments = await Promise.all(
|
||||
wireAppts.map(async (w) => {
|
||||
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
|
||||
|
|
@ -878,7 +1032,7 @@ export const useAppointmentStore = create<AppointmentState>()(
|
|||
},
|
||||
|
||||
createAppointment: async (data) => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
@ -924,7 +1078,7 @@ export const useAppointmentStore = create<AppointmentState>()(
|
|||
},
|
||||
|
||||
updateAppointment: async (id, data) => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
|
|||
|
|
@ -71,6 +71,13 @@ export interface RegisterRequest {
|
|||
identity_public_key?: string;
|
||||
identity_private_key_wrapped?: string;
|
||||
identity_private_key_wrapped_iv?: string;
|
||||
/** Default "self" profile (Phase A2). Encrypted name blob + profile DEK
|
||||
* wrapped under the account DEK. */
|
||||
default_profile_name_data?: string;
|
||||
default_profile_name_iv?: string;
|
||||
default_profile_name_auth_tag?: string;
|
||||
default_wrapped_profile_dek?: string;
|
||||
default_wrapped_profile_dek_iv?: string;
|
||||
}
|
||||
|
||||
// Medication Types
|
||||
|
|
@ -240,11 +247,13 @@ export interface HealthStat {
|
|||
export interface HealthStatWireResponse {
|
||||
id: string;
|
||||
user_id: string;
|
||||
profile_id: string;
|
||||
encrypted_data: EncryptedFieldWire;
|
||||
recorded_at: string;
|
||||
}
|
||||
|
||||
export interface CreateHealthStatRequest {
|
||||
profile_id: string;
|
||||
encrypted_data: EncryptedFieldWire;
|
||||
recorded_at?: string;
|
||||
}
|
||||
|
|
@ -324,18 +333,44 @@ export interface UpdateAppointmentRequest {
|
|||
status?: string;
|
||||
}
|
||||
|
||||
// Profile wire response (opaque encrypted name)
|
||||
// Profile wire response (opaque encrypted name + wrapped profile DEK)
|
||||
export interface ProfileWireResponse {
|
||||
profile_id: string;
|
||||
user_id: string;
|
||||
owner_account_id: string;
|
||||
name_data: string;
|
||||
name_iv: string;
|
||||
kind: string;
|
||||
relationship: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
/** Profile DEK wrapped under the account DEK (opaque ciphertext). */
|
||||
wrapped_profile_dek: string;
|
||||
wrapped_profile_dek_iv: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Request body for POST /profiles
|
||||
export interface CreateProfileRequest {
|
||||
profile_id?: string;
|
||||
kind?: string;
|
||||
relationship?: string;
|
||||
name_data: string;
|
||||
name_iv: string;
|
||||
name_auth_tag?: string;
|
||||
wrapped_profile_dek: string;
|
||||
wrapped_profile_dek_iv: string;
|
||||
}
|
||||
|
||||
// Request body for PUT /profiles/:id
|
||||
export interface UpdateProfileRequest {
|
||||
name_data: string;
|
||||
name_iv: string;
|
||||
name_auth_tag?: string;
|
||||
kind?: string;
|
||||
relationship?: string;
|
||||
}
|
||||
|
||||
// Dose Log Types — match backend MedicationDose (camelCase serialization).
|
||||
export interface DoseLog {
|
||||
id?: string;
|
||||
|
|
@ -368,8 +403,10 @@ export interface AdherenceStats {
|
|||
// Profile Types — match backend ProfileResponse.
|
||||
export interface Profile {
|
||||
profile_id: string;
|
||||
user_id: string;
|
||||
owner_account_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
relationship: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
created_at: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue