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
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue