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
|
|
@ -40,8 +40,8 @@ pub fn build_app(state: AppState) -> Router {
|
|||
// Build protected routes (auth required)
|
||||
let protected_routes = Router::new()
|
||||
// User profile management
|
||||
.route("/api/users/me", get(handlers::get_profile))
|
||||
.route("/api/users/me", put(handlers::update_profile))
|
||||
.route("/api/users/me", get(handlers::get_account))
|
||||
.route("/api/users/me", put(handlers::update_account))
|
||||
.route("/api/users/me", delete(handlers::delete_account))
|
||||
.route("/api/users/me/change-password", post(handlers::change_password))
|
||||
// User settings
|
||||
|
|
@ -54,9 +54,12 @@ pub fn build_app(state: AppState) -> Router {
|
|||
.route("/api/shares/:id", delete(handlers::delete_share))
|
||||
// Permission checking
|
||||
.route("/api/permissions/check", post(handlers::check_permission))
|
||||
// Profile management (Phase 3c)
|
||||
.route("/api/profiles/me", get(handlers::get_my_profile))
|
||||
.route("/api/profiles/me", put(handlers::update_my_profile))
|
||||
// Profile management (Phase A2: multi-profile + per-profile DEKs)
|
||||
.route("/api/profiles", get(handlers::list_profiles))
|
||||
.route("/api/profiles", post(handlers::create_profile))
|
||||
.route("/api/profiles/:id", get(handlers::get_profile))
|
||||
.route("/api/profiles/:id", put(handlers::update_profile))
|
||||
.route("/api/profiles/:id", delete(handlers::delete_profile))
|
||||
// Session management (Phase 2.6)
|
||||
.route("/api/sessions", get(handlers::get_sessions))
|
||||
.route("/api/sessions/:id", delete(handlers::revoke_session))
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ use mongodb::bson::DateTime;
|
|||
pub struct AppointmentListQuery {
|
||||
/// Filter by status (upcoming/completed/cancelled). Optional.
|
||||
pub status: Option<String>,
|
||||
/// Filter by profile id. Optional.
|
||||
pub profile_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_appointment(
|
||||
|
|
@ -68,7 +70,11 @@ pub async fn list_appointments(
|
|||
let repo = AppointmentRepository::new(database.collection("appointments"));
|
||||
|
||||
match repo
|
||||
.find_by_user_filtered(&claims.sub, query.status.as_deref())
|
||||
.find_by_user_filtered(
|
||||
&claims.sub,
|
||||
query.status.as_deref(),
|
||||
query.profile_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(appointments) => {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,15 @@ pub struct RegisterRequest {
|
|||
/// Account X25519 identity private key, wrapped under the account DEK.
|
||||
pub identity_private_key_wrapped: Option<String>,
|
||||
pub identity_private_key_wrapped_iv: Option<String>,
|
||||
/// Default-profile display name (opaque, encrypted under the profile DEK)
|
||||
/// and the profile DEK wrapped under the account DEK. When present, the
|
||||
/// register flow auto-creates a "self" profile; when absent, no profile is
|
||||
/// created and the client is expected to POST /api/profiles on first run.
|
||||
pub default_profile_name_data: Option<String>,
|
||||
pub default_profile_name_iv: Option<String>,
|
||||
pub default_profile_name_auth_tag: Option<String>,
|
||||
pub default_wrapped_profile_dek: Option<String>,
|
||||
pub default_wrapped_profile_dek_iv: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
|
@ -145,18 +154,30 @@ pub async fn register(
|
|||
.await;
|
||||
}
|
||||
|
||||
// Auto-create a default profile for the new user. The profile_id is
|
||||
// deterministic (profile_<user_id>) and is the contract the frontend
|
||||
// uses for medication creation. Best-effort: registration still
|
||||
// succeeds if this fails (GET /profiles/me lazily creates one).
|
||||
// Auto-create the default "self" profile for the new user, but only
|
||||
// when the client provided a wrapped profile DEK. The profile_id is
|
||||
// deterministic (profile_<owner>). Best-effort: registration still
|
||||
// succeeds if this fails or if no wrapped DEK was provided (the
|
||||
// client creates the profile via POST /api/profiles on first run).
|
||||
if let (Some(wrapped_dek), Some(wrapped_dek_iv)) = (
|
||||
req.default_wrapped_profile_dek.as_ref(),
|
||||
req.default_wrapped_profile_dek_iv.as_ref(),
|
||||
) {
|
||||
let database = state.db.get_database();
|
||||
let profile_repo =
|
||||
crate::models::profile::ProfileRepository::new(database.collection("profiles"));
|
||||
let profile =
|
||||
crate::handlers::profile::build_default_profile(&id.to_string(), &req.username);
|
||||
let profile = crate::handlers::profile::build_default_profile(
|
||||
&id.to_string(),
|
||||
wrapped_dek,
|
||||
wrapped_dek_iv,
|
||||
req.default_profile_name_data.as_deref().unwrap_or(""),
|
||||
req.default_profile_name_iv.as_deref().unwrap_or(""),
|
||||
req.default_profile_name_auth_tag.as_deref().unwrap_or(""),
|
||||
);
|
||||
if let Err(e) = profile_repo.create(&profile).await {
|
||||
tracing::warn!("Failed to auto-create profile for {}: {}", id, e);
|
||||
}
|
||||
}
|
||||
|
||||
id
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use crate::config::AppState;
|
|||
use crate::models::health_stats::{HealthStatResponse, HealthStatistic};
|
||||
use crate::models::medication::EncryptedFieldWire;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
|
|
@ -14,9 +14,16 @@ use serde::Deserialize;
|
|||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateHealthStatRequest {
|
||||
pub encrypted_data: EncryptedFieldWire,
|
||||
/// Which profile this stat belongs to.
|
||||
pub profile_id: String,
|
||||
pub recorded_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct ListHealthStatsQuery {
|
||||
pub profile_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateHealthStatRequest {
|
||||
pub encrypted_data: EncryptedFieldWire,
|
||||
|
|
@ -41,6 +48,7 @@ pub async fn create_health_stat(
|
|||
let stat = HealthStatistic {
|
||||
id: None,
|
||||
user_id: claims.sub.clone(),
|
||||
profile_id: req.profile_id,
|
||||
encrypted_data: req.encrypted_data,
|
||||
recorded_at: req
|
||||
.recorded_at
|
||||
|
|
@ -70,6 +78,7 @@ pub async fn create_health_stat(
|
|||
pub async fn list_health_stats(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Query(query): Query<ListHealthStatsQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let repo = match state.health_stats_repo.as_ref() {
|
||||
Some(r) => r,
|
||||
|
|
@ -81,7 +90,10 @@ pub async fn list_health_stats(
|
|||
.into_response()
|
||||
}
|
||||
};
|
||||
match repo.find_by_user(&claims.sub).await {
|
||||
match repo
|
||||
.find_by_user_filtered(&claims.sub, query.profile_id.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(stats) => {
|
||||
let resp: Vec<HealthStatResponse> = stats.into_iter().map(Into::into).collect();
|
||||
(StatusCode::OK, Json(resp)).into_response()
|
||||
|
|
|
|||
|
|
@ -25,9 +25,9 @@ pub use medications::{
|
|||
log_dose, update_medication,
|
||||
};
|
||||
pub use permissions::check_permission;
|
||||
pub use profile::{get_my_profile, update_my_profile};
|
||||
pub use profile::{create_profile, delete_profile, get_profile, list_profiles, update_profile};
|
||||
pub use sessions::{get_sessions, revoke_all_sessions, revoke_session};
|
||||
pub use shares::{create_share, delete_share, list_shares, update_share};
|
||||
pub use users::{
|
||||
change_password, delete_account, get_profile, get_settings, update_profile, update_settings,
|
||||
change_password, delete_account, get_account, get_settings, update_account, update_settings,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use axum::{
|
||||
extract::{Extension, State},
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
|
|
@ -12,17 +12,21 @@ use crate::{
|
|||
models::profile::{Profile, ProfileRepository},
|
||||
};
|
||||
|
||||
/// The profile as exposed to clients. The display name is returned as an opaque
|
||||
/// client-encrypted blob (the server cannot read it).
|
||||
/// The profile as exposed to clients. Display name + wrapped profile DEK are
|
||||
/// returned as opaque client-encrypted blobs (the server cannot read either).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ProfileResponse {
|
||||
pub profile_id: String,
|
||||
pub user_id: String,
|
||||
/// Opaque client-encrypted display name.
|
||||
pub owner_account_id: String,
|
||||
pub name_data: String,
|
||||
pub name_iv: String,
|
||||
pub kind: String,
|
||||
pub relationship: String,
|
||||
pub role: String,
|
||||
pub permissions: Vec<String>,
|
||||
/// Profile DEK wrapped under the owner's account DEK (opaque).
|
||||
pub wrapped_profile_dek: String,
|
||||
pub wrapped_profile_dek_iv: String,
|
||||
pub created_at: DateTime,
|
||||
pub updated_at: DateTime,
|
||||
}
|
||||
|
|
@ -31,98 +35,212 @@ impl From<Profile> for ProfileResponse {
|
|||
fn from(p: Profile) -> Self {
|
||||
Self {
|
||||
profile_id: p.profile_id,
|
||||
user_id: p.user_id,
|
||||
owner_account_id: p.owner_account_id,
|
||||
name_data: p.name,
|
||||
name_iv: p.name_iv,
|
||||
kind: p.kind,
|
||||
relationship: p.relationship,
|
||||
role: p.role,
|
||||
permissions: p.permissions,
|
||||
wrapped_profile_dek: p.wrapped_profile_dek,
|
||||
wrapped_profile_dek_iv: p.wrapped_profile_dek_iv,
|
||||
created_at: p.created_at,
|
||||
updated_at: p.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the default "patient" profile for a freshly registered user. Called
|
||||
/// from `register`. The profile_id is deterministic: `profile_<user_id>` — this
|
||||
/// is the contract the frontend relies on for medication creation. The name is
|
||||
/// empty (not encrypted) because the server has no encryption key; the client
|
||||
/// sets it via PUT /profiles/me after deriving its key.
|
||||
pub fn build_default_profile(user_id: &str, _name: &str) -> Profile {
|
||||
/// Build a default "self" profile for a freshly registered user. Called from
|
||||
/// `register` when the client provides a wrapped profile DEK for the default
|
||||
/// profile. The server has no encryption key, so the name blob and wrapped
|
||||
/// profile DEK are taken verbatim from the caller (the client generated the
|
||||
/// profile DEK and wrapped it under the account DEK).
|
||||
pub fn build_default_profile(
|
||||
owner_account_id: &str,
|
||||
wrapped_profile_dek: &str,
|
||||
wrapped_profile_dek_iv: &str,
|
||||
name_data: &str,
|
||||
name_iv: &str,
|
||||
name_auth_tag: &str,
|
||||
) -> Profile {
|
||||
let now = DateTime::now();
|
||||
Profile {
|
||||
id: None,
|
||||
profile_id: format!("profile_{user_id}"),
|
||||
user_id: user_id.to_string(),
|
||||
// profile_<owner> keeps the deterministic-id contract the frontend
|
||||
// used pre-A2 for the self profile.
|
||||
profile_id: format!("profile_{owner_account_id}"),
|
||||
owner_account_id: owner_account_id.to_string(),
|
||||
user_id: owner_account_id.to_string(),
|
||||
family_id: None,
|
||||
name: String::new(),
|
||||
name_iv: String::new(),
|
||||
name_auth_tag: String::new(),
|
||||
name: name_data.to_string(),
|
||||
name_iv: name_iv.to_string(),
|
||||
name_auth_tag: name_auth_tag.to_string(),
|
||||
kind: "human".to_string(),
|
||||
relationship: "self".to_string(),
|
||||
role: "patient".to_string(),
|
||||
permissions: vec!["read:self".to_string(), "write:self".to_string()],
|
||||
wrapped_profile_dek: wrapped_profile_dek.to_string(),
|
||||
wrapped_profile_dek_iv: wrapped_profile_dek_iv.to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// PUT /profiles/me body — the client sends the encrypted name blob.
|
||||
fn profile_repo(state: &AppState) -> ProfileRepository {
|
||||
ProfileRepository::new(state.db.get_database().collection("profiles"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/profiles — list all profiles owned by the current account
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_profiles(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
) -> Result<Json<Vec<ProfileResponse>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let repo = profile_repo(&state);
|
||||
match repo.find_all_by_owner(&claims.sub).await {
|
||||
Ok(profiles) => {
|
||||
let resp: Vec<ProfileResponse> = profiles.into_iter().map(Into::into).collect();
|
||||
Ok(Json(resp))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Profile list failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/profiles — create a new profile (child, pet, ...).
|
||||
// The client generates the profile DEK, wraps it under the account DEK, and
|
||||
// sends the wrapped blob + the encrypted display name.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateProfileNameRequest {
|
||||
pub struct CreateProfileRequest {
|
||||
/// Optional explicit profile id; defaults to a fresh UUID if absent.
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(default = "default_kind_value")]
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub relationship: String,
|
||||
pub name_data: String,
|
||||
pub name_iv: String,
|
||||
#[serde(default)]
|
||||
pub name_auth_tag: String,
|
||||
/// Profile DEK wrapped under the account DEK (opaque ciphertext).
|
||||
pub wrapped_profile_dek: String,
|
||||
pub wrapped_profile_dek_iv: String,
|
||||
}
|
||||
|
||||
/// GET /api/profiles/me — the current user's profile. If for some reason the
|
||||
/// auto-created profile is missing, lazily create it.
|
||||
pub async fn get_my_profile(
|
||||
fn default_kind_value() -> String {
|
||||
"human".to_string()
|
||||
}
|
||||
|
||||
pub async fn create_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let database = state.db.get_database();
|
||||
let repo = ProfileRepository::new(database.collection("profiles"));
|
||||
Json(req): Json<CreateProfileRequest>,
|
||||
) -> Result<(StatusCode, Json<ProfileResponse>), (StatusCode, Json<serde_json::Value>)> {
|
||||
let now = DateTime::now();
|
||||
let profile = Profile {
|
||||
id: None,
|
||||
profile_id: req
|
||||
.profile_id
|
||||
.unwrap_or_else(|| format!("p_{}", uuid::Uuid::new_v4())),
|
||||
owner_account_id: claims.sub.clone(),
|
||||
user_id: claims.sub.clone(),
|
||||
family_id: None,
|
||||
name: req.name_data,
|
||||
name_iv: req.name_iv,
|
||||
name_auth_tag: req.name_auth_tag,
|
||||
kind: req.kind,
|
||||
relationship: req.relationship,
|
||||
role: "patient".to_string(),
|
||||
permissions: vec!["read:self".to_string(), "write:self".to_string()],
|
||||
wrapped_profile_dek: req.wrapped_profile_dek,
|
||||
wrapped_profile_dek_iv: req.wrapped_profile_dek_iv,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
let profile = match repo.find_by_user_id(&claims.sub).await {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => {
|
||||
// Lazily create if missing (e.g. users registered before this code shipped).
|
||||
let p = build_default_profile(&claims.sub, &claims.sub);
|
||||
if let Err(e) = repo.create(&p).await {
|
||||
tracing::error!("Failed to lazily create profile: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "failed to load profile" })),
|
||||
));
|
||||
}
|
||||
p
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Profile lookup failed: {}", e);
|
||||
let repo = profile_repo(&state);
|
||||
if let Err(e) = repo.create(&profile).await {
|
||||
tracing::error!("Profile create failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(ProfileResponse::from(profile)))
|
||||
Ok((StatusCode::CREATED, Json(ProfileResponse::from(profile))))
|
||||
}
|
||||
|
||||
/// PUT /api/profiles/me — update the profile's encrypted display-name blob.
|
||||
pub async fn update_my_profile(
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/profiles/:id — fetch one owned profile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Json(req): Json<UpdateProfileNameRequest>,
|
||||
Path(profile_id): Path<String>,
|
||||
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let database = state.db.get_database();
|
||||
let repo = ProfileRepository::new(database.collection("profiles"));
|
||||
|
||||
let repo = profile_repo(&state);
|
||||
match repo
|
||||
.update_encrypted_name(
|
||||
.find_by_profile_id_owned(&profile_id, &claims.sub)
|
||||
.await
|
||||
{
|
||||
Ok(Some(p)) => Ok(Json(ProfileResponse::from(p))),
|
||||
Ok(None) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "profile not found" })),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::error!("Profile lookup failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /api/profiles/:id — update an owned profile's mutable fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateProfileRequest {
|
||||
pub name_data: String,
|
||||
pub name_iv: String,
|
||||
#[serde(default)]
|
||||
pub name_auth_tag: String,
|
||||
#[serde(default = "default_kind_value")]
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub relationship: String,
|
||||
}
|
||||
|
||||
pub async fn update_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Path(profile_id): Path<String>,
|
||||
Json(req): Json<UpdateProfileRequest>,
|
||||
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let repo = profile_repo(&state);
|
||||
match repo
|
||||
.update_profile(
|
||||
&profile_id,
|
||||
&claims.sub,
|
||||
&req.name_data,
|
||||
&req.name_iv,
|
||||
&req.name_auth_tag,
|
||||
&req.kind,
|
||||
&req.relationship,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -140,3 +258,29 @@ pub async fn update_my_profile(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/profiles/:id — delete an owned profile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn delete_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Path(profile_id): Path<String>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
|
||||
let repo = profile_repo(&state);
|
||||
match repo.delete_profile(&profile_id, &claims.sub).await {
|
||||
Ok(true) => Ok(StatusCode::NO_CONTENT),
|
||||
Ok(false) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "profile not found" })),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::error!("Profile delete failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ pub struct UpdateProfileRequest {
|
|||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_profile(
|
||||
pub async fn get_account(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
) -> impl IntoResponse {
|
||||
|
|
@ -79,7 +79,7 @@ pub async fn get_profile(
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn update_profile(
|
||||
pub async fn update_account(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Json(req): Json<UpdateProfileRequest>,
|
||||
|
|
|
|||
|
|
@ -126,16 +126,21 @@ impl AppointmentRepository {
|
|||
Ok(appointment)
|
||||
}
|
||||
|
||||
/// List a user's appointments, optionally filtered by top-level `status`.
|
||||
/// List a user's appointments, optionally filtered by top-level `status`
|
||||
/// and/or `profileId`.
|
||||
pub async fn find_by_user_filtered(
|
||||
&self,
|
||||
user_id: &str,
|
||||
status: Option<&str>,
|
||||
profile_id: Option<&str>,
|
||||
) -> Result<Vec<Appointment>, Box<dyn std::error::Error>> {
|
||||
let mut filter = doc! { "userId": user_id };
|
||||
if let Some(s) = status {
|
||||
filter.insert("status", s);
|
||||
}
|
||||
if let Some(p) = profile_id {
|
||||
filter.insert("profileId", p);
|
||||
}
|
||||
let mut cursor = self.collection.find(filter, None).await?;
|
||||
let mut appointments = Vec::new();
|
||||
while let Some(appt) = cursor.next().await {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ pub struct HealthStatistic {
|
|||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<ObjectId>,
|
||||
pub user_id: String,
|
||||
/// Which profile this stat belongs to (plaintext, filterable).
|
||||
#[serde(rename = "profileId", default)]
|
||||
pub profile_id: String,
|
||||
/// Opaque client-encrypted blob (value, unit, type, notes inside).
|
||||
#[serde(rename = "encryptedData")]
|
||||
pub encrypted_data: EncryptedFieldWire,
|
||||
|
|
@ -27,6 +30,7 @@ pub struct HealthStatistic {
|
|||
pub struct HealthStatResponse {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub profile_id: String,
|
||||
pub encrypted_data: EncryptedFieldWire,
|
||||
pub recorded_at: String,
|
||||
}
|
||||
|
|
@ -36,6 +40,7 @@ impl From<HealthStatistic> for HealthStatResponse {
|
|||
HealthStatResponse {
|
||||
id: s.id.map(|o| o.to_hex()).unwrap_or_default(),
|
||||
user_id: s.user_id,
|
||||
profile_id: s.profile_id,
|
||||
encrypted_data: s.encrypted_data,
|
||||
recorded_at: s.recorded_at,
|
||||
}
|
||||
|
|
@ -70,6 +75,21 @@ impl HealthStatisticsRepository {
|
|||
cursor.try_collect().await
|
||||
}
|
||||
|
||||
/// All stats for a user, optionally filtered by profile_id at the Mongo
|
||||
/// level (real top-level document field).
|
||||
pub async fn find_by_user_filtered(
|
||||
&self,
|
||||
user_id: &str,
|
||||
profile_id: Option<&str>,
|
||||
) -> Result<Vec<HealthStatistic>, MongoError> {
|
||||
let mut filter = doc! { "user_id": user_id };
|
||||
if let Some(pid) = profile_id {
|
||||
filter.insert("profileId", pid);
|
||||
}
|
||||
let cursor = self.collection.find(filter, None).await?;
|
||||
cursor.try_collect().await
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: &ObjectId) -> Result<Option<HealthStatistic>, MongoError> {
|
||||
let filter = doc! { "_id": id };
|
||||
self.collection.find_one(filter, None).await
|
||||
|
|
|
|||
|
|
@ -1,35 +1,76 @@
|
|||
use futures::stream::StreamExt;
|
||||
use mongodb::{
|
||||
bson::{doc, oid::ObjectId, DateTime},
|
||||
Collection,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A profile (a "subject of care" — a person or pet whose health data is
|
||||
/// tracked). One account owns zero or more profiles. All health data
|
||||
/// (medications, appointments, health stats) is scoped to a profile and
|
||||
/// encrypted under that profile's DEK.
|
||||
///
|
||||
/// Zero-knowledge layers (both opaque to the server):
|
||||
/// - `name` / `wrapped_profile_dek` are AES-256-GCM ciphertext blobs the
|
||||
/// server stores verbatim and cannot read.
|
||||
/// - `name` is the profile's display name encrypted under the profile DEK.
|
||||
/// - `wrapped_profile_dek` is the profile DEK encrypted under the owner's
|
||||
/// account DEK. The client unwraps it at login/unlock (after unwrapping
|
||||
/// the account DEK) to get the profile DEK.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Profile {
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<ObjectId>,
|
||||
#[serde(rename = "profileId")]
|
||||
pub profile_id: String,
|
||||
/// Owner account id (Mongo ObjectId hex). Replaces the 1:1 `user_id`
|
||||
/// semantics from the pre-A2 single-profile model.
|
||||
#[serde(rename = "ownerAccountId")]
|
||||
pub owner_account_id: String,
|
||||
/// Kept populated = owner_account_id for backward compat with existing
|
||||
/// medication/appointment filters that key on `userId`. Same value; do
|
||||
/// not rely on it for ownership — use `owner_account_id`.
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: String,
|
||||
#[serde(rename = "familyId")]
|
||||
pub family_id: Option<String>,
|
||||
/// Display name — opaque, encrypted under the profile DEK.
|
||||
#[serde(rename = "name")]
|
||||
pub name: String,
|
||||
#[serde(rename = "nameIv")]
|
||||
pub name_iv: String,
|
||||
#[serde(rename = "nameAuthTag")]
|
||||
pub name_auth_tag: String,
|
||||
#[serde(rename = "role")]
|
||||
/// "human" | "pet" — what kind of subject this profile tracks.
|
||||
#[serde(rename = "kind", default = "default_kind")]
|
||||
pub kind: String,
|
||||
/// Freeform relationship to the owner: "self", "child", "spouse",
|
||||
/// "parent", "pet", ...
|
||||
#[serde(rename = "relationship", default)]
|
||||
pub relationship: String,
|
||||
#[serde(rename = "role", default = "default_role")]
|
||||
pub role: String,
|
||||
#[serde(rename = "permissions")]
|
||||
#[serde(rename = "permissions", default)]
|
||||
pub permissions: Vec<String>,
|
||||
/// Profile DEK wrapped under the owner's account DEK (opaque ciphertext).
|
||||
#[serde(rename = "wrappedProfileDek")]
|
||||
pub wrapped_profile_dek: String,
|
||||
#[serde(rename = "wrappedProfileDekIv")]
|
||||
pub wrapped_profile_dek_iv: String,
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: DateTime,
|
||||
#[serde(rename = "updatedAt")]
|
||||
pub updated_at: DateTime,
|
||||
}
|
||||
|
||||
fn default_kind() -> String {
|
||||
"human".to_string()
|
||||
}
|
||||
|
||||
fn default_role() -> String {
|
||||
"patient".to_string()
|
||||
}
|
||||
|
||||
pub struct ProfileRepository {
|
||||
collection: Collection<Profile>,
|
||||
}
|
||||
|
|
@ -44,6 +85,7 @@ impl ProfileRepository {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up a profile by its application-level profile id.
|
||||
pub async fn find_by_profile_id(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
|
|
@ -53,32 +95,77 @@ impl ProfileRepository {
|
|||
.await
|
||||
}
|
||||
|
||||
/// Look up a profile by its owning user id.
|
||||
pub async fn find_by_user_id(&self, user_id: &str) -> mongodb::error::Result<Option<Profile>> {
|
||||
/// Look up a profile by id AND owner — used for ownership-scoped access.
|
||||
/// Returns None if the profile doesn't exist or doesn't belong to `owner`.
|
||||
pub async fn find_by_profile_id_owned(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
owner: &str,
|
||||
) -> mongodb::error::Result<Option<Profile>> {
|
||||
self.collection
|
||||
.find_one(doc! { "userId": user_id }, None)
|
||||
.find_one(
|
||||
doc! { "profileId": profile_id, "ownerAccountId": owner },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update the profile's display name (opaque client-encrypted blob).
|
||||
pub async fn update_encrypted_name(
|
||||
/// All profiles owned by an account.
|
||||
pub async fn find_all_by_owner(&self, owner: &str) -> mongodb::error::Result<Vec<Profile>> {
|
||||
let mut cursor = self
|
||||
.collection
|
||||
.find(doc! { "ownerAccountId": owner }, None)
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(p) = cursor.next().await {
|
||||
out.push(p?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Replace a profile's mutable fields (display name blob, kind,
|
||||
/// relationship), keyed by (profile_id, owner). Returns the updated doc
|
||||
/// or None if the profile doesn't exist / isn't owned by `owner`.
|
||||
pub async fn update_profile(
|
||||
&self,
|
||||
user_id: &str,
|
||||
profile_id: &str,
|
||||
owner: &str,
|
||||
name_data: &str,
|
||||
name_iv: &str,
|
||||
name_auth_tag: &str,
|
||||
kind: &str,
|
||||
relationship: &str,
|
||||
) -> mongodb::error::Result<Option<Profile>> {
|
||||
self.collection
|
||||
.find_one_and_update(
|
||||
doc! { "userId": user_id },
|
||||
doc! { "profileId": profile_id, "ownerAccountId": owner },
|
||||
doc! { "$set": {
|
||||
"name": name_data,
|
||||
"nameIv": name_iv,
|
||||
"nameAuthTag": name_auth_tag,
|
||||
"kind": kind,
|
||||
"relationship": relationship,
|
||||
"updatedAt": DateTime::now()
|
||||
}},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete a profile keyed by (profile_id, owner). Returns true if a doc
|
||||
/// was deleted, false if it didn't exist or wasn't owned by `owner`.
|
||||
pub async fn delete_profile(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
owner: &str,
|
||||
) -> mongodb::error::Result<bool> {
|
||||
let res = self
|
||||
.collection
|
||||
.delete_one(
|
||||
doc! { "profileId": profile_id, "ownerAccountId": owner },
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(res.deleted_count > 0)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
255
backend/tests/profile_tests.rs
Normal file
255
backend/tests/profile_tests.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
//! Multi-profile integration tests (Phase A2).
|
||||
//!
|
||||
//! Exercises the /api/profiles CRUD endpoints: list, create, get, update,
|
||||
//! delete — all ownership-scoped. A second registered user must not be able
|
||||
//! to read or modify the first user's profiles.
|
||||
//!
|
||||
//! Requires a live MongoDB; skips gracefully otherwise (see common/mod.rs).
|
||||
|
||||
mod common;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
macro_rules! require_app {
|
||||
($app:expr) => {
|
||||
match $app {
|
||||
Some(x) => x,
|
||||
None => {
|
||||
eprintln!("[integration] skipped (MongoDB unavailable)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Register a user (sending no default-profile fields → no profile auto-created)
|
||||
/// and return the access token.
|
||||
async fn register(app: &axum::Router, email: &str, password: &str) -> String {
|
||||
let (status, body) = common::send_json(
|
||||
app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({ "email": email, "username": "tester", "password": password })),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "register precondition failed, body: {body}");
|
||||
body["token"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn unique_email() -> String {
|
||||
format!("test_{}@example.com", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Body for creating a profile with a fake wrapped DEK (the server stores it
|
||||
/// verbatim; it never inspects the contents).
|
||||
fn create_body(kind: &str, relationship: &str) -> Value {
|
||||
json!({
|
||||
"kind": kind,
|
||||
"relationship": relationship,
|
||||
"name_data": "base64-encrypted-name",
|
||||
"name_iv": "base64-iv",
|
||||
"wrapped_profile_dek": "base64-wrapped-profile-dek",
|
||||
"wrapped_profile_dek_iv": "base64-dek-iv",
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_starts_empty_without_default_profile() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let token = register(&app, &unique_email(), "supersecret").await;
|
||||
|
||||
let (status, body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await;
|
||||
assert_eq!(status, 200, "list should return 200, body: {body}");
|
||||
assert_eq!(
|
||||
body.as_array().unwrap().len(),
|
||||
0,
|
||||
"no default profile expected"
|
||||
);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_then_list_then_get_update_delete() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let token = register(&app, &unique_email(), "supersecret").await;
|
||||
|
||||
// Create a profile.
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/profiles",
|
||||
Some(create_body("human", "self")),
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "create should return 201, body: {body}");
|
||||
let profile_id = body["profile_id"].as_str().unwrap().to_string();
|
||||
assert_eq!(body["kind"], "human");
|
||||
assert_eq!(body["relationship"], "self");
|
||||
assert_eq!(body["wrapped_profile_dek"], "base64-wrapped-profile-dek");
|
||||
|
||||
// List now has one.
|
||||
let (_, list_body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await;
|
||||
assert_eq!(list_body.as_array().unwrap().len(), 1);
|
||||
|
||||
// Get by id.
|
||||
let (status, get_body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
None,
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
assert_eq!(get_body["profile_id"], profile_id);
|
||||
|
||||
// Update the display-name blob + relationship.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"PUT",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
Some(json!({
|
||||
"name_data": "new-name-blob",
|
||||
"name_iv": "new-iv",
|
||||
"kind": "pet",
|
||||
"relationship": "dog",
|
||||
})),
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "update should return 200");
|
||||
|
||||
// Delete.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"DELETE",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
None,
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 204, "delete should return 204");
|
||||
|
||||
// Get now 404s.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
None,
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn other_user_cannot_access_profile() {
|
||||
// Ownership isolation: user B must not GET/UPDATE/DELETE user A's profile.
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let token_a = register(&app, &unique_email(), "supersecret").await;
|
||||
let token_b = register(&app, &unique_email(), "supersecret").await;
|
||||
|
||||
// A creates a profile.
|
||||
let (_, body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/profiles",
|
||||
Some(create_body("human", "child")),
|
||||
Some(&token_a),
|
||||
)
|
||||
.await;
|
||||
let profile_id = body["profile_id"].as_str().unwrap().to_string();
|
||||
|
||||
// B cannot GET it (404, because ownership filter excludes it).
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
None,
|
||||
Some(&token_b),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "B must not read A's profile");
|
||||
|
||||
// B cannot UPDATE it.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"PUT",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
Some(json!({
|
||||
"name_data": "x", "name_iv": "y", "kind": "human", "relationship": "self"
|
||||
})),
|
||||
Some(&token_b),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "B must not update A's profile");
|
||||
|
||||
// B cannot DELETE it.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"DELETE",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
None,
|
||||
Some(&token_b),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "B must not delete A's profile");
|
||||
|
||||
// A still sees it intact.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
None,
|
||||
Some(&token_a),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "A's profile must be untouched");
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_with_default_profile_creates_self_profile() {
|
||||
// When register carries default_wrapped_profile_dek, the self profile is
|
||||
// auto-created and shows up in GET /api/profiles.
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let email = unique_email();
|
||||
|
||||
let (_, body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({
|
||||
"email": email,
|
||||
"username": "tester",
|
||||
"password": "supersecret",
|
||||
"default_profile_name_data": "name-blob",
|
||||
"default_profile_name_iv": "name-iv",
|
||||
"default_wrapped_profile_dek": "dek-blob",
|
||||
"default_wrapped_profile_dek_iv": "dek-iv",
|
||||
})),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let token = body["token"].as_str().unwrap().to_string();
|
||||
|
||||
let (_, list_body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await;
|
||||
let arr = list_body.as_array().unwrap();
|
||||
assert_eq!(arr.len(), 1, "default self profile should exist");
|
||||
assert_eq!(arr[0]["relationship"], "self");
|
||||
assert_eq!(arr[0]["kind"], "human");
|
||||
assert_eq!(arr[0]["wrapped_profile_dek"], "dek-blob");
|
||||
// Deterministic id contract: profile_<owner>.
|
||||
assert!(arr[0]["profile_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("profile_"));
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
|
@ -179,6 +179,7 @@ async fn health_stat_stored_as_ciphertext() {
|
|||
"POST",
|
||||
"/api/health-stats",
|
||||
Some(json!({
|
||||
"profile_id": "default",
|
||||
"encrypted_data": { "data": opaque_data, "iv": "aXY=" },
|
||||
"recorded_at": "2026-07-01T10:00:00Z",
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -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,42 +143,134 @@ 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>
|
||||
|
||||
{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">
|
||||
Account
|
||||
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>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Role
|
||||
</Typography>
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Chip size="small" label={profile?.role ?? 'patient'} variant="outlined" />
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete profile
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{profile?.profile_id && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Profile ID: {profile.profile_id}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
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) {
|
||||
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 {
|
||||
name = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key);
|
||||
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 = ''; }
|
||||
}
|
||||
const profile: Profile = {
|
||||
profiles.push({
|
||||
profile_id: wire.profile_id,
|
||||
user_id: wire.user_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,
|
||||
};
|
||||
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