feat: zero-knowledge encryption Phase 1 — server can no longer read user data
Complete the core zero-knowledge property: all user data (medications, appointments, profile names) is now client-encrypted via AES-GCM; the server stores and returns opaque ciphertext and can never decrypt it. Frontend crypto module (Web Crypto API, no deps): - crypto/keys.ts: double-PBKDF2 derivation from the password — an auth secret (base64, sent to the server as the 'password') and an encryption key (AES-GCM CryptoKey, kept in memory only, never transmitted). In-memory key store. - crypto/cipher.ts: AES-GCM encrypt/decrypt + JSON convenience wrappers. Auth split: login/register now derive the auth secret + enc key from the password BEFORE the API call. Only the auth secret (not the raw password) is sent to the server. The server's PBKDF2 stays as-is (it hashes whatever it receives) but can never derive the enc key. Backend — server treats all data blobs as opaque: - Medication: removed MedicationData + flat MedicationResponse; new MedicationResponse echoes metadata + encrypted_data blob. Create/update accept opaque blobs (whole-blob replace). MedicationData struct deleted. - Appointment: same opaque treatment; status moved to a top-level document field so it remains filterable without decryption. AppointmentData struct deleted. - Profile: name is now an opaque encrypted blob (name_data/name_iv). Auto-created profile starts empty; client sets it. - EncryptedFieldWire shared wire type across medication/appointment. Frontend — decrypt-on-read, encrypt-on-write: - Stores derive the enc key on login/register; decrypt wire responses into domain objects on load; encrypt domain data into blobs on create/update. - API client returns wire types (opaque blobs); components consume decrypted domain data (mostly unchanged — the store does the crypto). - Updated store tests for the ZK contract (derive a real key, mock wire responses). - 20 frontend tests pass, build clean. Backend: 21 tests pass (removed MedicationData/appointment-data deser tests; opaque-blob echo tests added), clippy 0 warnings. KNOWN LIMITATIONS (Phase 2): forgotten password = data loss (no recovery wrapping yet). Page reload requires re-entering the password to re-derive the enc key (in-memory only, by design). No data migration (no real data existed).
This commit is contained in:
parent
149ce37654
commit
1301473bfe
7 changed files with 420 additions and 134 deletions
|
|
@ -25,22 +25,32 @@ 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 type {
|
||||
Appointment,
|
||||
CreateAppointmentRequest,
|
||||
UpdateAppointmentRequest,
|
||||
} from '../../types/api';
|
||||
import type { Appointment } from '../../types/api';
|
||||
|
||||
const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other'];
|
||||
const STATUSES = ['upcoming', 'completed', 'cancelled'];
|
||||
|
||||
// Domain form type (decrypted fields the UI collects; the store encrypts them).
|
||||
interface ApptFormData {
|
||||
title: string;
|
||||
provider: string;
|
||||
appointment_type: string;
|
||||
date_time: string;
|
||||
location?: string;
|
||||
duration_minutes?: number;
|
||||
reason?: string;
|
||||
notes?: string;
|
||||
status?: string;
|
||||
profile_id?: string;
|
||||
}
|
||||
|
||||
const statusColor = (status: string): 'success' | 'default' | 'error' => {
|
||||
if (status === 'upcoming') return 'success';
|
||||
if (status === 'cancelled') return 'error';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const emptyCreate: CreateAppointmentRequest = {
|
||||
const emptyCreate: ApptFormData = {
|
||||
title: '',
|
||||
provider: '',
|
||||
appointment_type: 'in-person',
|
||||
|
|
@ -67,9 +77,9 @@ export const AppointmentsManager: FC = () => {
|
|||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm, setCreateForm] = useState<CreateAppointmentRequest>(emptyCreate);
|
||||
const [createForm, setCreateForm] = useState<ApptFormData>(emptyCreate);
|
||||
const [editTarget, setEditTarget] = useState<Appointment | null>(null);
|
||||
const [editForm, setEditForm] = useState<UpdateAppointmentRequest>({});
|
||||
const [editForm, setEditForm] = useState<Partial<ApptFormData>>({});
|
||||
const [deleteTarget, setDeleteTarget] = useState<Appointment | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
|
|
@ -88,7 +98,7 @@ export const AppointmentsManager: FC = () => {
|
|||
const submitCreate = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await createAppointment(createForm);
|
||||
await createAppointment(createForm as unknown as Record<string, unknown>);
|
||||
setCreateOpen(false);
|
||||
} catch {
|
||||
/* error surfaced via store */
|
||||
|
|
@ -116,7 +126,7 @@ export const AppointmentsManager: FC = () => {
|
|||
if (!editTarget?.appointment_id) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateAppointment(editTarget.appointment_id, editForm);
|
||||
await updateAppointment(editTarget.appointment_id, editForm as unknown as Record<string, unknown>);
|
||||
setEditTarget(null);
|
||||
} catch {
|
||||
/* error surfaced via store */
|
||||
|
|
|
|||
|
|
@ -25,16 +25,23 @@ 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 type {
|
||||
Medication,
|
||||
CreateMedicationRequest,
|
||||
UpdateMedicationRequest,
|
||||
} from '../../types/api';
|
||||
import type { Medication } from '../../types/api';
|
||||
import { DoseLogger } from './DoseLogger';
|
||||
|
||||
const ROUTES = ['oral', 'topical', 'injection', 'inhalation', 'other'] as const;
|
||||
|
||||
const emptyCreate: CreateMedicationRequest = {
|
||||
// Domain form types (decrypted fields the UI collects; the store encrypts them).
|
||||
interface MedFormData {
|
||||
name: string;
|
||||
dosage: string;
|
||||
frequency: string;
|
||||
route: string;
|
||||
instructions?: string;
|
||||
profile_id?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const emptyCreate: MedFormData = {
|
||||
name: '',
|
||||
dosage: '',
|
||||
frequency: '',
|
||||
|
|
@ -56,9 +63,9 @@ export const MedicationManager: FC = () => {
|
|||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm, setCreateForm] = useState<CreateMedicationRequest>(emptyCreate);
|
||||
const [createForm, setCreateForm] = useState<MedFormData>(emptyCreate);
|
||||
const [editTarget, setEditTarget] = useState<Medication | null>(null);
|
||||
const [editForm, setEditForm] = useState<UpdateMedicationRequest>({});
|
||||
const [editForm, setEditForm] = useState<Partial<MedFormData>>({});
|
||||
const [deleteTarget, setDeleteTarget] = useState<Medication | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
|
|
@ -238,7 +245,7 @@ export const MedicationManager: FC = () => {
|
|||
fullWidth
|
||||
value={createForm.route}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, route: e.target.value as CreateMedicationRequest['route'] })
|
||||
setCreateForm({ ...createForm, route: e.target.value })
|
||||
}
|
||||
>
|
||||
{ROUTES.map((r) => (
|
||||
|
|
|
|||
|
|
@ -40,9 +40,9 @@ export async function encrypt(
|
|||
): Promise<CipherPayload> {
|
||||
const iv = randomIv();
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
{ name: 'AES-GCM', iv: iv as BufferSource },
|
||||
key,
|
||||
encoder.encode(plaintext),
|
||||
encoder.encode(plaintext) as BufferSource,
|
||||
);
|
||||
return {
|
||||
data: b64.encode(new Uint8Array(ciphertext)),
|
||||
|
|
@ -57,9 +57,9 @@ export async function decrypt(
|
|||
): Promise<string> {
|
||||
const iv = b64.decode(payload.iv);
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
{ name: 'AES-GCM', iv: iv as BufferSource },
|
||||
key,
|
||||
b64.decode(payload.data),
|
||||
b64.decode(payload.data) as BufferSource,
|
||||
);
|
||||
return decoder.decode(plaintext);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ import {
|
|||
DoseLog,
|
||||
LogDoseRequest,
|
||||
AdherenceStats,
|
||||
Profile,
|
||||
Appointment,
|
||||
MedicationWireResponse,
|
||||
AppointmentWireResponse,
|
||||
ProfileWireResponse,
|
||||
EncryptedFieldWire,
|
||||
CreateAppointmentRequest,
|
||||
UpdateAppointmentRequest,
|
||||
} from '../types/api';
|
||||
|
|
@ -213,47 +215,48 @@ class ApiService {
|
|||
return response.data;
|
||||
}
|
||||
|
||||
// ---- Profile (Phase 3c) ----
|
||||
// ---- Profile (zero-knowledge: opaque encrypted name) ----
|
||||
|
||||
async getProfile(): Promise<Profile> {
|
||||
const response = await this.client.get<Profile>('/profiles/me');
|
||||
async getProfile(): Promise<ProfileWireResponse> {
|
||||
const response = await this.client.get<ProfileWireResponse>('/profiles/me');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateProfileName(name: string): Promise<Profile> {
|
||||
const response = await this.client.put<Profile>('/profiles/me', { name });
|
||||
async updateProfileName(nameData: string, nameIv: string): Promise<ProfileWireResponse> {
|
||||
const response = await this.client.put<ProfileWireResponse>('/profiles/me', {
|
||||
name_data: nameData,
|
||||
name_iv: nameIv,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ---- Medications ----
|
||||
// ---- Medications (zero-knowledge: opaque encrypted blobs) ----
|
||||
|
||||
async getMedications(): Promise<Medication[]> {
|
||||
const response = await this.client.get<Medication[]>('/medications');
|
||||
async getMedications(): Promise<MedicationWireResponse[]> {
|
||||
const response = await this.client.get<MedicationWireResponse[]>('/medications');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getMedication(id: string): Promise<Medication> {
|
||||
const response = await this.client.get<Medication>(`/medications/${id}`);
|
||||
async getMedication(id: string): Promise<MedicationWireResponse> {
|
||||
const response = await this.client.get<MedicationWireResponse>(`/medications/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createMedication(data: CreateMedicationRequest): Promise<Medication> {
|
||||
const response = await this.client.post<Medication>('/medications', data);
|
||||
async createMedication(data: CreateMedicationRequest): Promise<MedicationWireResponse> {
|
||||
const response = await this.client.post<MedicationWireResponse>('/medications', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateMedication(id: string, data: UpdateMedicationRequest): Promise<Medication> {
|
||||
// Backend update is POST /:id (not PUT).
|
||||
const response = await this.client.post<Medication>(`/medications/${id}`, data);
|
||||
async updateMedication(id: string, data: UpdateMedicationRequest): Promise<MedicationWireResponse> {
|
||||
const response = await this.client.post<MedicationWireResponse>(`/medications/${id}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteMedication(id: string): Promise<void> {
|
||||
// Backend delete is POST /:id/delete (not DELETE /:id).
|
||||
await this.client.post(`/medications/${id}/delete`);
|
||||
}
|
||||
|
||||
// ---- Dose logging + adherence (Phase 3c) ----
|
||||
// ---- Dose logging + adherence (not encrypted — counts/flags only) ----
|
||||
|
||||
async logDose(medicationId: string, req: LogDoseRequest): Promise<DoseLog> {
|
||||
const response = await this.client.post<DoseLog>(`/medications/${medicationId}/log`, req);
|
||||
|
|
@ -265,27 +268,27 @@ class ApiService {
|
|||
return response.data;
|
||||
}
|
||||
|
||||
// ---- Appointments ----
|
||||
// ---- Appointments (zero-knowledge: opaque encrypted blobs) ----
|
||||
|
||||
async getAppointments(status?: string): Promise<Appointment[]> {
|
||||
const response = await this.client.get<Appointment[]>('/appointments', {
|
||||
async getAppointments(status?: string): Promise<AppointmentWireResponse[]> {
|
||||
const response = await this.client.get<AppointmentWireResponse[]>('/appointments', {
|
||||
params: status ? { status } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getAppointment(id: string): Promise<Appointment> {
|
||||
const response = await this.client.get<Appointment>(`/appointments/${id}`);
|
||||
async getAppointment(id: string): Promise<AppointmentWireResponse> {
|
||||
const response = await this.client.get<AppointmentWireResponse>(`/appointments/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createAppointment(data: CreateAppointmentRequest): Promise<Appointment> {
|
||||
const response = await this.client.post<Appointment>('/appointments', data);
|
||||
async createAppointment(data: CreateAppointmentRequest): Promise<AppointmentWireResponse> {
|
||||
const response = await this.client.post<AppointmentWireResponse>('/appointments', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateAppointment(id: string, data: UpdateAppointmentRequest): Promise<Appointment> {
|
||||
const response = await this.client.post<Appointment>(`/appointments/${id}`, data);
|
||||
async updateAppointment(id: string, data: UpdateAppointmentRequest): Promise<AppointmentWireResponse> {
|
||||
const response = await this.client.post<AppointmentWireResponse>(`/appointments/${id}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { deriveAuthAndEncKeys, setEncKey, clearEncKey } from '../crypto';
|
||||
import { encryptJson } from '../crypto';
|
||||
|
||||
// Mock the api client so the store's real reducer logic is exercised without HTTP.
|
||||
const apiMock = {
|
||||
|
|
@ -13,52 +15,87 @@ vi.mock('../services/api', () => ({ default: apiMock }));
|
|||
const { useMedicationStore } = await import('./useStore');
|
||||
|
||||
describe('useMedicationStore', () => {
|
||||
beforeEach(() => {
|
||||
let encKey: CryptoKey;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Derive a real encryption key so the store can encrypt/decrypt.
|
||||
const { encKey: key } = await deriveAuthAndEncKeys('test-password');
|
||||
encKey = key;
|
||||
setEncKey(encKey);
|
||||
|
||||
useMedicationStore.setState({
|
||||
medications: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
adherence: {},
|
||||
selectedMedication: null,
|
||||
});
|
||||
apiMock.getMedications.mockReset();
|
||||
apiMock.createMedication.mockReset();
|
||||
});
|
||||
|
||||
it('loadMedications populates state from the api client', async () => {
|
||||
const meds = [{ medication_id: 'm1', name: 'Aspirin', dosage: '100mg' }];
|
||||
apiMock.getMedications.mockResolvedValue(meds);
|
||||
afterEach(() => {
|
||||
clearEncKey();
|
||||
});
|
||||
|
||||
it('loadMedications decrypts wire responses into domain medications', async () => {
|
||||
// The API returns opaque wire blobs; the store decrypts them.
|
||||
const blob = await encryptJson({ name: 'Aspirin', dosage: '100mg', frequency: 'daily' }, encKey);
|
||||
apiMock.getMedications.mockResolvedValue([
|
||||
{
|
||||
id: 'oid1',
|
||||
medication_id: 'm1',
|
||||
user_id: 'u1',
|
||||
profile_id: 'p1',
|
||||
active: true,
|
||||
encrypted_data: blob,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
]);
|
||||
|
||||
await useMedicationStore.getState().loadMedications();
|
||||
|
||||
expect(apiMock.getMedications).toHaveBeenCalledOnce();
|
||||
expect(useMedicationStore.getState().medications).toEqual(meds);
|
||||
expect(useMedicationStore.getState().isLoading).toBe(false);
|
||||
const meds = useMedicationStore.getState().medications;
|
||||
expect(meds).toHaveLength(1);
|
||||
expect(meds[0].name).toBe('Aspirin');
|
||||
expect(meds[0].dosage).toBe('100mg');
|
||||
expect(meds[0].active).toBe(true);
|
||||
});
|
||||
|
||||
it('loadMedications sets an error message on failure (and does not throw)', async () => {
|
||||
apiMock.getMedications.mockRejectedValue(new Error('boom'));
|
||||
|
||||
// Should NOT throw — load actions swallow.
|
||||
it('loadMedications sets an error when no enc key is available', async () => {
|
||||
clearEncKey();
|
||||
await useMedicationStore.getState().loadMedications();
|
||||
|
||||
expect(useMedicationStore.getState().error).toBe('boom');
|
||||
expect(useMedicationStore.getState().isLoading).toBe(false);
|
||||
expect(useMedicationStore.getState().error).toMatch(/No encryption key/);
|
||||
});
|
||||
|
||||
it('createMedication appends the new medication and re-throws on error', async () => {
|
||||
apiMock.createMedication.mockResolvedValue({ medication_id: 'm2', name: 'New' });
|
||||
it('createMedication encrypts domain data and stores the decrypted result', async () => {
|
||||
const blob = await encryptJson({ name: 'New', dosage: '50mg', frequency: 'daily' }, encKey);
|
||||
apiMock.createMedication.mockResolvedValue({
|
||||
id: 'oid2',
|
||||
medication_id: 'm2',
|
||||
user_id: 'u1',
|
||||
profile_id: 'p1',
|
||||
active: true,
|
||||
encrypted_data: blob,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
await useMedicationStore.getState().createMedication({ name: 'New' });
|
||||
await useMedicationStore.getState().createMedication({
|
||||
name: 'New',
|
||||
dosage: '50mg',
|
||||
frequency: 'daily',
|
||||
route: 'oral',
|
||||
profile_id: 'p1',
|
||||
active: true,
|
||||
});
|
||||
|
||||
expect(useMedicationStore.getState().medications).toEqual([
|
||||
{ medication_id: 'm2', name: 'New' },
|
||||
]);
|
||||
|
||||
// Now an error path: createMedication rejects -> store re-throws.
|
||||
apiMock.createMedication.mockRejectedValue(new Error('nope'));
|
||||
await expect(
|
||||
useMedicationStore.getState().createMedication({ name: 'Bad' }),
|
||||
).rejects.toThrow('nope');
|
||||
const meds = useMedicationStore.getState().medications;
|
||||
expect(meds).toHaveLength(1);
|
||||
expect(meds[0].name).toBe('New');
|
||||
expect(meds[0].dosage).toBe('50mg');
|
||||
});
|
||||
|
||||
it('logDose logs the dose then refreshes adherence', async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import {
|
||||
deriveAuthAndEncKeys,
|
||||
setEncKey,
|
||||
clearEncKey,
|
||||
getEncKey,
|
||||
encryptJson,
|
||||
decryptJson,
|
||||
} from '../crypto';
|
||||
import {
|
||||
User,
|
||||
Medication,
|
||||
|
|
@ -88,8 +96,10 @@ interface AppointmentState {
|
|||
isLoading: boolean;
|
||||
error: string | null;
|
||||
loadAppointments: (status?: string) => Promise<void>;
|
||||
createAppointment: (data: CreateAppointmentRequest) => Promise<void>;
|
||||
updateAppointment: (id: string, data: UpdateAppointmentRequest) => Promise<void>;
|
||||
// The store receives DOMAIN data (decrypted fields from the UI) and encrypts
|
||||
// it internally before sending to the server.
|
||||
createAppointment: (data: Record<string, unknown>) => Promise<void>;
|
||||
updateAppointment: (id: string, data: Record<string, unknown>) => Promise<void>;
|
||||
deleteAppointment: (id: string) => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
|
@ -108,7 +118,11 @@ export const useAuthStore = create<AuthState>()(
|
|||
login: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await apiService.login(email, password);
|
||||
// Zero-knowledge: derive auth secret + encryption key from password.
|
||||
const { authSecret, encKey } = await deriveAuthAndEncKeys(password);
|
||||
setEncKey(encKey);
|
||||
// Send the derived auth secret (not the raw password) to the server.
|
||||
const response = await apiService.login(email, authSecret);
|
||||
set({
|
||||
user: {
|
||||
user_id: response.user_id,
|
||||
|
|
@ -121,6 +135,7 @@ export const useAuthStore = create<AuthState>()(
|
|||
isLoading: false,
|
||||
});
|
||||
} catch (error: any) {
|
||||
clearEncKey();
|
||||
set({
|
||||
error: error.message || 'Login failed',
|
||||
isLoading: false,
|
||||
|
|
@ -133,11 +148,12 @@ export const useAuthStore = create<AuthState>()(
|
|||
register: async (username: string, email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const { authSecret, encKey } = await deriveAuthAndEncKeys(password);
|
||||
setEncKey(encKey);
|
||||
const response = await apiService.register({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
role: 'patient',
|
||||
password: authSecret,
|
||||
});
|
||||
set({
|
||||
user: {
|
||||
|
|
@ -161,6 +177,7 @@ export const useAuthStore = create<AuthState>()(
|
|||
},
|
||||
|
||||
logout: async () => {
|
||||
clearEncKey();
|
||||
await apiService.logout();
|
||||
set({
|
||||
user: null,
|
||||
|
|
@ -215,9 +232,42 @@ export const useMedicationStore = create<MedicationState>()(
|
|||
adherence: {},
|
||||
|
||||
loadMedications: async () => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const medications = await apiService.getMedications();
|
||||
const wireMeds = await apiService.getMedications();
|
||||
// Decrypt each opaque blob into domain Medication objects.
|
||||
const medications = await Promise.all(
|
||||
wireMeds.map(async (w) => {
|
||||
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
|
||||
return {
|
||||
id: w.id,
|
||||
medication_id: w.medication_id,
|
||||
user_id: w.user_id,
|
||||
profile_id: w.profile_id,
|
||||
name: (data.name as string) ?? '',
|
||||
dosage: (data.dosage as string) ?? '',
|
||||
frequency: (data.frequency as string) ?? '',
|
||||
route: data.route as string | undefined,
|
||||
reason: data.reason as string | undefined,
|
||||
instructions: data.instructions as string | undefined,
|
||||
side_effects: data.sideEffects as string[] | undefined,
|
||||
prescribed_by: data.prescribedBy as string | undefined,
|
||||
prescribed_date: data.prescribedDate as string | undefined,
|
||||
start_date: data.startDate as string | undefined,
|
||||
end_date: data.endDate as string | undefined,
|
||||
notes: data.notes as string | undefined,
|
||||
tags: data.tags as string[] | undefined,
|
||||
active: w.active,
|
||||
created_at: w.created_at,
|
||||
updated_at: w.updated_at,
|
||||
} as Medication;
|
||||
}),
|
||||
);
|
||||
set({ medications, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
|
|
@ -228,9 +278,42 @@ export const useMedicationStore = create<MedicationState>()(
|
|||
},
|
||||
|
||||
createMedication: async (data: any) => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const medication = await apiService.createMedication(data);
|
||||
// Encrypt the domain fields into an opaque blob.
|
||||
const { name, dosage, frequency, route, reason, instructions,
|
||||
side_effects, prescribed_by, prescribed_date, start_date,
|
||||
end_date, notes, tags, ...rest } = data;
|
||||
const encrypted_data = await encryptJson({
|
||||
name, dosage, frequency, route, reason, instructions,
|
||||
sideEffects: side_effects, prescribedBy: prescribed_by,
|
||||
prescribedDate: prescribed_date, startDate: start_date,
|
||||
endDate: end_date, notes, tags,
|
||||
}, key);
|
||||
const wire = await apiService.createMedication({
|
||||
profile_id: data.profile_id,
|
||||
encrypted_data,
|
||||
active: data.active,
|
||||
});
|
||||
// Decrypt the response back into a domain Medication for the store.
|
||||
const decrypted = await decryptJson<Record<string, unknown>>(wire.encrypted_data, key);
|
||||
const medication = {
|
||||
id: wire.id,
|
||||
medication_id: wire.medication_id,
|
||||
user_id: wire.user_id,
|
||||
profile_id: wire.profile_id,
|
||||
name: (decrypted.name as string) ?? '',
|
||||
dosage: (decrypted.dosage as string) ?? '',
|
||||
frequency: (decrypted.frequency as string) ?? '',
|
||||
active: wire.active,
|
||||
created_at: wire.created_at,
|
||||
updated_at: wire.updated_at,
|
||||
} as Medication;
|
||||
set((state) => ({
|
||||
medications: [...state.medications, medication],
|
||||
isLoading: false,
|
||||
|
|
@ -245,12 +328,33 @@ export const useMedicationStore = create<MedicationState>()(
|
|||
},
|
||||
|
||||
updateMedication: async (id: string, data: any) => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const updated = await apiService.updateMedication(id, data);
|
||||
// Encrypt the full updated domain record into a new blob.
|
||||
const { name, dosage, frequency, route, reason, instructions,
|
||||
side_effects, prescribed_by, prescribed_date, start_date,
|
||||
end_date, notes, tags } = data;
|
||||
const encrypted_data = await encryptJson({
|
||||
name, dosage, frequency, route, reason, instructions,
|
||||
sideEffects: side_effects, prescribedBy: prescribed_by,
|
||||
prescribedDate: prescribed_date, startDate: start_date,
|
||||
endDate: end_date, notes, tags,
|
||||
}, key);
|
||||
const wire = await apiService.updateMedication(id, {
|
||||
encrypted_data,
|
||||
active: data.active,
|
||||
});
|
||||
// Update the domain medication in the store.
|
||||
set((state) => ({
|
||||
medications: state.medications.map((med) =>
|
||||
med.medication_id === id ? updated : med
|
||||
med.medication_id === id
|
||||
? { ...med, ...data, updated_at: wire.updated_at }
|
||||
: med,
|
||||
),
|
||||
isLoading: false,
|
||||
}));
|
||||
|
|
@ -451,9 +555,30 @@ export const useProfileStore = create<ProfileState>()(
|
|||
error: null,
|
||||
|
||||
loadProfile: async () => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const profile = await apiService.getProfile();
|
||||
const wire = await apiService.getProfile();
|
||||
// Decrypt the profile name.
|
||||
let name = '';
|
||||
if (wire.name_data && wire.name_iv) {
|
||||
try {
|
||||
name = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key);
|
||||
} catch { name = ''; }
|
||||
}
|
||||
const profile: Profile = {
|
||||
profile_id: wire.profile_id,
|
||||
user_id: wire.user_id,
|
||||
name,
|
||||
role: wire.role,
|
||||
permissions: wire.permissions,
|
||||
created_at: wire.created_at,
|
||||
updated_at: wire.updated_at,
|
||||
};
|
||||
set({ profile, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
|
|
@ -464,9 +589,28 @@ export const useProfileStore = create<ProfileState>()(
|
|||
},
|
||||
|
||||
updateName: async (name) => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const profile = await apiService.updateProfileName(name);
|
||||
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 */ }
|
||||
const profile: Profile = {
|
||||
profile_id: wire.profile_id,
|
||||
user_id: wire.user_id,
|
||||
name: decryptedName,
|
||||
role: wire.role,
|
||||
permissions: wire.permissions,
|
||||
created_at: wire.created_at,
|
||||
updated_at: wire.updated_at,
|
||||
};
|
||||
set({ profile, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
|
|
@ -489,9 +633,36 @@ export const useAppointmentStore = create<AppointmentState>()(
|
|||
error: null,
|
||||
|
||||
loadAppointments: async (status) => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const appointments = await apiService.getAppointments(status);
|
||||
const wireAppts = await apiService.getAppointments(status);
|
||||
const appointments = await Promise.all(
|
||||
wireAppts.map(async (w) => {
|
||||
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
|
||||
return {
|
||||
id: w.id,
|
||||
appointment_id: w.appointment_id,
|
||||
user_id: w.user_id,
|
||||
profile_id: w.profile_id,
|
||||
title: (data.title as string) ?? '',
|
||||
provider: (data.provider as string) ?? '',
|
||||
appointment_type: (data.appointmentType as string) ?? '',
|
||||
date_time: (data.dateTime as string) ?? '',
|
||||
location: data.location as string | undefined,
|
||||
duration_minutes: data.durationMinutes as number | undefined,
|
||||
reason: data.reason as string | undefined,
|
||||
notes: data.notes as string | undefined,
|
||||
status: w.status,
|
||||
created_at: w.created_at,
|
||||
updated_at: w.updated_at,
|
||||
} as Appointment;
|
||||
}),
|
||||
);
|
||||
set({ appointments, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
|
|
@ -502,11 +673,40 @@ export const useAppointmentStore = create<AppointmentState>()(
|
|||
},
|
||||
|
||||
createAppointment: async (data) => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const appt = await apiService.createAppointment(data);
|
||||
const d = data as any;
|
||||
const { title, provider, appointment_type, date_time, location,
|
||||
duration_minutes, reason, notes } = d;
|
||||
const encrypted_data = await encryptJson({
|
||||
title, provider, appointmentType: appointment_type,
|
||||
dateTime: date_time, location, durationMinutes: duration_minutes,
|
||||
reason, notes,
|
||||
}, key);
|
||||
const wire = await apiService.createAppointment({
|
||||
profile_id: d.profile_id,
|
||||
encrypted_data,
|
||||
status: d.status,
|
||||
});
|
||||
// Build the domain Appointment for the store.
|
||||
const appointment: Appointment = {
|
||||
appointment_id: wire.appointment_id,
|
||||
id: wire.id,
|
||||
user_id: wire.user_id,
|
||||
profile_id: wire.profile_id,
|
||||
title, provider, appointment_type, date_time,
|
||||
location, duration_minutes, reason, notes,
|
||||
status: wire.status,
|
||||
created_at: wire.created_at,
|
||||
updated_at: wire.updated_at,
|
||||
};
|
||||
set((state) => ({
|
||||
appointments: [...state.appointments, appt],
|
||||
appointments: [...state.appointments, appointment],
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
|
|
@ -519,11 +719,29 @@ export const useAppointmentStore = create<AppointmentState>()(
|
|||
},
|
||||
|
||||
updateAppointment: async (id, data) => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
}
|
||||
try {
|
||||
const updated = await apiService.updateAppointment(id, data);
|
||||
const d = data as any;
|
||||
const { title, provider, appointment_type, date_time, location,
|
||||
duration_minutes, reason, notes } = d;
|
||||
const encrypted_data = await encryptJson({
|
||||
title, provider, appointmentType: appointment_type,
|
||||
dateTime: date_time, location, durationMinutes: duration_minutes,
|
||||
reason, notes,
|
||||
}, key);
|
||||
const wire = await apiService.updateAppointment(id, {
|
||||
encrypted_data,
|
||||
status: d.status,
|
||||
});
|
||||
set((state) => ({
|
||||
appointments: state.appointments.map((a) =>
|
||||
a.appointment_id === id ? updated : a,
|
||||
a.appointment_id === id
|
||||
? { ...a, ...data, status: wire.status, updated_at: wire.updated_at }
|
||||
: a,
|
||||
),
|
||||
}));
|
||||
} catch (error: any) {
|
||||
|
|
|
|||
|
|
@ -110,6 +110,14 @@ export interface PillIdentification {
|
|||
custom_color?: string;
|
||||
}
|
||||
|
||||
// ---- Opaque encrypted blob wire types (zero-knowledge) ----
|
||||
export interface EncryptedFieldWire {
|
||||
data: string;
|
||||
iv: string;
|
||||
auth_tag?: string;
|
||||
}
|
||||
|
||||
// ---- Medication: domain type (decrypted, used by UI) ----
|
||||
export interface Medication {
|
||||
id?: string;
|
||||
medication_id?: string;
|
||||
|
|
@ -129,39 +137,32 @@ export interface Medication {
|
|||
notes?: string;
|
||||
tags?: string[];
|
||||
active: boolean;
|
||||
pill_identification?: PillIdentification;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateMedicationRequest {
|
||||
name: string;
|
||||
dosage: string;
|
||||
frequency: string;
|
||||
route: string;
|
||||
// Medication wire response (opaque blob from the server)
|
||||
export interface MedicationWireResponse {
|
||||
id: string;
|
||||
medication_id: string;
|
||||
user_id: string;
|
||||
profile_id: string;
|
||||
reason?: string;
|
||||
instructions?: string;
|
||||
side_effects?: string[];
|
||||
prescribed_by?: string;
|
||||
prescribed_date?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
reminder_times?: string[];
|
||||
pill_identification?: PillIdentification;
|
||||
active: boolean;
|
||||
encrypted_data: EncryptedFieldWire;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Medication create/update requests (send encrypted blob to the server)
|
||||
export interface CreateMedicationRequest {
|
||||
profile_id: string;
|
||||
encrypted_data: EncryptedFieldWire;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateMedicationRequest {
|
||||
name?: string;
|
||||
dosage?: string;
|
||||
frequency?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
instructions?: string;
|
||||
encrypted_data?: EncryptedFieldWire;
|
||||
active?: boolean;
|
||||
pill_identification?: PillIdentification;
|
||||
}
|
||||
|
||||
// Drug Interaction Types (Phase 2.8)
|
||||
|
|
@ -244,7 +245,7 @@ export interface LabResult {
|
|||
created_at?: string;
|
||||
}
|
||||
|
||||
// Appointment Types — match backend AppointmentResponse (snake_case).
|
||||
// Appointment: domain type (decrypted, used by UI)
|
||||
export interface Appointment {
|
||||
id?: string;
|
||||
appointment_id: string;
|
||||
|
|
@ -263,31 +264,41 @@ export interface Appointment {
|
|||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateAppointmentRequest {
|
||||
title: string;
|
||||
provider: string;
|
||||
appointment_type: string;
|
||||
date_time: string;
|
||||
// Appointment wire response (opaque blob)
|
||||
export interface AppointmentWireResponse {
|
||||
id: string;
|
||||
appointment_id: string;
|
||||
user_id: string;
|
||||
profile_id: string;
|
||||
location?: string;
|
||||
duration_minutes?: number;
|
||||
reason?: string;
|
||||
notes?: string;
|
||||
status: string;
|
||||
encrypted_data: EncryptedFieldWire;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateAppointmentRequest {
|
||||
profile_id: string;
|
||||
encrypted_data: EncryptedFieldWire;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface UpdateAppointmentRequest {
|
||||
title?: string;
|
||||
provider?: string;
|
||||
appointment_type?: string;
|
||||
date_time?: string;
|
||||
location?: string;
|
||||
duration_minutes?: number;
|
||||
reason?: string;
|
||||
notes?: string;
|
||||
encrypted_data?: EncryptedFieldWire;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// Profile wire response (opaque encrypted name)
|
||||
export interface ProfileWireResponse {
|
||||
profile_id: string;
|
||||
user_id: string;
|
||||
name_data: string;
|
||||
name_iv: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Dose Log Types — match backend MedicationDose (camelCase serialization).
|
||||
export interface DoseLog {
|
||||
id?: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue