diff --git a/.sisyphus/evidence/task-T8-protected-nav.md b/.sisyphus/evidence/task-T8-protected-nav.md new file mode 100644 index 0000000..898bd0a --- /dev/null +++ b/.sisyphus/evidence/task-T8-protected-nav.md @@ -0,0 +1,18 @@ +# Task T8 Protected Navigation + +- QA app: `http://127.0.0.1:4175` +- Backend: `http://127.0.0.1:8002` +- Mock OIDC issuer: `http://127.0.0.1:9100` +- Backend DB: `.sisyphus/evidence/task-T8-qa.sqlite` + +Authenticated shell and protected route checks executed with Playwright: + +- `/` -> title `Dashboard - innercontext`, heading `Dashboard`, shell user `Playwright User`, role `Użytkownik`, logout visible `true` +- `/products` -> title `Produkty — innercontext`, heading `Produkty`, shell user `Playwright User`, role `Użytkownik`, logout visible `true` +- `/profile` -> title `Profil — innercontext`, heading `Profil`, shell user `Playwright User`, role `Użytkownik`, logout visible `true` +- `/routines` -> title `Rutyny — innercontext`, heading `Rutyny`, shell user `Playwright User`, role `Użytkownik`, logout visible `true` + +Logout endpoint check executed with Playwright request API: + +- `GET /auth/logout` -> `303` +- Location -> `http://127.0.0.1:9100/logout?client_id=innercontext-web&post_logout_redirect_uri=http%3A%2F%2F127.0.0.1%3A4175%2F` diff --git a/.sisyphus/evidence/task-T8-signed-out-network.txt b/.sisyphus/evidence/task-T8-signed-out-network.txt new file mode 100644 index 0000000..9128fdf --- /dev/null +++ b/.sisyphus/evidence/task-T8-signed-out-network.txt @@ -0,0 +1,10 @@ +Playwright unauthenticated request check + +request: GET http://127.0.0.1:4175/products +cookies: none +maxRedirects: 0 + +status: 303 +location: /auth/login?returnTo=%2Fproducts + +result: protected page redirects to the login flow before returning page content. diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 651301a..8eda3a9 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -10,6 +10,11 @@ "nav_appName": "innercontext", "nav_appSubtitle": "personal health & skincare", + "auth_signedInAs": "Signed in as", + "auth_roleAdmin": "Admin", + "auth_roleMember": "Member", + "auth_logout": "Log out", + "common_save": "Save", "common_cancel": "Cancel", "common_add": "Add", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 33fbc31..c086a79 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -10,6 +10,11 @@ "nav_appName": "innercontext", "nav_appSubtitle": "zdrowie & pielęgnacja", + "auth_signedInAs": "Zalogowano jako", + "auth_roleAdmin": "Administrator", + "auth_roleMember": "Użytkownik", + "auth_logout": "Wyloguj", + "common_save": "Zapisz", "common_cancel": "Anuluj", "common_add": "Dodaj", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7a0327f..f9bd236 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -20,43 +20,87 @@ import type { UserProfile, } from "./types"; -// ─── Core fetch helpers ────────────────────────────────────────────────────── +export interface ApiClientOptions { + fetch?: typeof globalThis.fetch; + accessToken?: string; +} -async function request(path: string, init: RequestInit = {}): Promise { - // Server-side uses PUBLIC_API_BASE (e.g. http://localhost:8000). - // Browser-side uses /api so nginx proxies the request on the correct host. - const base = browser ? "/api" : PUBLIC_API_BASE; - const url = `${base}${path}`; - const res = await fetch(url, { - headers: { "Content-Type": "application/json", ...init.headers }, +function resolveBase(options: ApiClientOptions): string { + if (browser && !options.accessToken) { + return "/api"; + } + + return PUBLIC_API_BASE; +} + +function buildHeaders( + initHeaders: HeadersInit | undefined, + body: BodyInit | null | undefined, + accessToken?: string, +): Headers { + const headers = new Headers(initHeaders); + + if (!(body instanceof FormData) && body !== undefined && body !== null) { + headers.set("Content-Type", headers.get("Content-Type") ?? "application/json"); + } + + if (accessToken && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${accessToken}`); + } + + return headers; +} + +async function request( + path: string, + init: RequestInit = {}, + options: ApiClientOptions = {}, +): Promise { + const url = `${resolveBase(options)}${path}`; + const requestFn = options.fetch ?? fetch; + const res = await requestFn(url, { + headers: buildHeaders(init.headers, init.body, options.accessToken), ...init, }); + if (!res.ok) { const detail = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(detail?.detail ?? res.statusText); } + if (res.status === 204) return undefined as T; return res.json(); } -export const api = { - get: (path: string) => request(path), - post: (path: string, body: unknown) => - request(path, { method: "POST", body: JSON.stringify(body) }), - patch: (path: string, body: unknown) => - request(path, { method: "PATCH", body: JSON.stringify(body) }), - del: (path: string) => request(path, { method: "DELETE" }), -}; +export function createApiClient(options: ApiClientOptions = {}) { + return { + get: (path: string) => request(path, {}, options), + post: (path: string, body: unknown) => + request(path, { method: "POST", body: JSON.stringify(body) }, options), + postForm: (path: string, body: FormData) => + request(path, { method: "POST", body }, options), + patch: (path: string, body: unknown) => + request(path, { method: "PATCH", body: JSON.stringify(body) }, options), + del: (path: string) => request(path, { method: "DELETE" }, options), + }; +} -// ─── Profile ───────────────────────────────────────────────────────────────── +export type ApiClient = ReturnType; -export const getProfile = (): Promise => api.get("/profile"); +export const api = createApiClient(); + +function resolveClient(options?: ApiClientOptions): ApiClient { + return options ? createApiClient(options) : api; +} + +export const getProfile = ( + options?: ApiClientOptions, +): Promise => resolveClient(options).get("/profile"); export const updateProfile = ( body: { birth_date?: string; sex_at_birth?: "male" | "female" | "intersex" }, -): Promise => api.patch("/profile", body); - -// ─── Products ──────────────────────────────────────────────────────────────── + options?: ApiClientOptions, +): Promise => resolveClient(options).patch("/profile", body); export interface ProductListParams { category?: string; @@ -68,62 +112,87 @@ export interface ProductListParams { export function getProducts( params: ProductListParams = {}, + options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.category) q.set("category", params.category); if (params.brand) q.set("brand", params.brand); if (params.targets) params.targets.forEach((t) => q.append("targets", t)); - if (params.is_medication != null) + if (params.is_medication != null) { q.set("is_medication", String(params.is_medication)); + } if (params.is_tool != null) q.set("is_tool", String(params.is_tool)); const qs = q.toString(); - return api.get(`/products${qs ? `?${qs}` : ""}`); + return resolveClient(options).get(`/products${qs ? `?${qs}` : ""}`); } export function getProductSummaries( params: ProductListParams = {}, + options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.category) q.set("category", params.category); if (params.brand) q.set("brand", params.brand); if (params.targets) params.targets.forEach((t) => q.append("targets", t)); - if (params.is_medication != null) + if (params.is_medication != null) { q.set("is_medication", String(params.is_medication)); + } if (params.is_tool != null) q.set("is_tool", String(params.is_tool)); const qs = q.toString(); - return api.get(`/products/summary${qs ? `?${qs}` : ""}`); + return resolveClient(options).get(`/products/summary${qs ? `?${qs}` : ""}`); } -export const getProduct = (id: string): Promise => - api.get(`/products/${id}`); +export const getProduct = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).get(`/products/${id}`); + export const createProduct = ( body: Record, -): Promise => api.post("/products", body); + options?: ApiClientOptions, +): Promise => resolveClient(options).post("/products", body); + export const updateProduct = ( id: string, body: Record, -): Promise => api.patch(`/products/${id}`, body); -export const deleteProduct = (id: string): Promise => - api.del(`/products/${id}`); + options?: ApiClientOptions, +): Promise => resolveClient(options).patch(`/products/${id}`, body); + +export const deleteProduct = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).del(`/products/${id}`); + +export const getInventory = ( + productId: string, + options?: ApiClientOptions, +): Promise => + resolveClient(options).get(`/products/${productId}/inventory`); -export const getInventory = (productId: string): Promise => - api.get(`/products/${productId}/inventory`); export const createInventory = ( productId: string, body: Record, + options?: ApiClientOptions, ): Promise => - api.post(`/products/${productId}/inventory`, body); + resolveClient(options).post(`/products/${productId}/inventory`, body); + export const updateInventory = ( id: string, body: Record, -): Promise => api.patch(`/inventory/${id}`, body); -export const deleteInventory = (id: string): Promise => - api.del(`/inventory/${id}`); + options?: ApiClientOptions, +): Promise => + resolveClient(options).patch(`/inventory/${id}`, body); -export const parseProductText = (text: string): Promise => - api.post("/products/parse-text", { text }); +export const deleteInventory = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).del(`/inventory/${id}`); -// ─── Routines ──────────────────────────────────────────────────────────────── +export const parseProductText = ( + text: string, + options?: ApiClientOptions, +): Promise => + resolveClient(options).post("/products/parse-text", { text }); export interface RoutineListParams { from_date?: string; @@ -133,68 +202,103 @@ export interface RoutineListParams { export function getRoutines( params: RoutineListParams = {}, + options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.from_date) q.set("from_date", params.from_date); if (params.to_date) q.set("to_date", params.to_date); if (params.part_of_day) q.set("part_of_day", params.part_of_day); const qs = q.toString(); - return api.get(`/routines${qs ? `?${qs}` : ""}`); + return resolveClient(options).get(`/routines${qs ? `?${qs}` : ""}`); } -export const getRoutine = (id: string): Promise => - api.get(`/routines/${id}`); +export const getRoutine = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).get(`/routines/${id}`); + export const createRoutine = ( body: Record, -): Promise => api.post("/routines", body); + options?: ApiClientOptions, +): Promise => resolveClient(options).post("/routines", body); + export const updateRoutine = ( id: string, body: Record, -): Promise => api.patch(`/routines/${id}`, body); -export const deleteRoutine = (id: string): Promise => - api.del(`/routines/${id}`); + options?: ApiClientOptions, +): Promise => resolveClient(options).patch(`/routines/${id}`, body); + +export const deleteRoutine = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).del(`/routines/${id}`); export const addRoutineStep = ( routineId: string, body: Record, -): Promise => api.post(`/routines/${routineId}/steps`, body); + options?: ApiClientOptions, +): Promise => + resolveClient(options).post(`/routines/${routineId}/steps`, body); + export const updateRoutineStep = ( stepId: string, body: Record, -): Promise => api.patch(`/routines/steps/${stepId}`, body); -export const deleteRoutineStep = (stepId: string): Promise => - api.del(`/routines/steps/${stepId}`); + options?: ApiClientOptions, +): Promise => + resolveClient(options).patch(`/routines/steps/${stepId}`, body); -export const suggestRoutine = (body: { - routine_date: string; - part_of_day: PartOfDay; - notes?: string; - include_minoxidil_beard?: boolean; - leaving_home?: boolean; -}): Promise => api.post("/routines/suggest", body); +export const deleteRoutineStep = ( + stepId: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).del(`/routines/steps/${stepId}`); -export const suggestBatch = (body: { - from_date: string; - to_date: string; - notes?: string; - include_minoxidil_beard?: boolean; - minimize_products?: boolean; -}): Promise => api.post("/routines/suggest-batch", body); +export const suggestRoutine = ( + body: { + routine_date: string; + part_of_day: PartOfDay; + notes?: string; + include_minoxidil_beard?: boolean; + leaving_home?: boolean; + }, + options?: ApiClientOptions, +): Promise => + resolveClient(options).post("/routines/suggest", body); + +export const suggestBatch = ( + body: { + from_date: string; + to_date: string; + notes?: string; + include_minoxidil_beard?: boolean; + minimize_products?: boolean; + }, + options?: ApiClientOptions, +): Promise => + resolveClient(options).post("/routines/suggest-batch", body); + +export const getGroomingSchedule = ( + options?: ApiClientOptions, +): Promise => + resolveClient(options).get("/routines/grooming-schedule"); -export const getGroomingSchedule = (): Promise => - api.get("/routines/grooming-schedule"); export const createGroomingScheduleEntry = ( body: Record, -): Promise => api.post("/routines/grooming-schedule", body); + options?: ApiClientOptions, +): Promise => + resolveClient(options).post("/routines/grooming-schedule", body); + export const updateGroomingScheduleEntry = ( id: string, body: Record, + options?: ApiClientOptions, ): Promise => - api.patch(`/routines/grooming-schedule/${id}`, body); -export const deleteGroomingScheduleEntry = (id: string): Promise => - api.del(`/routines/grooming-schedule/${id}`); + resolveClient(options).patch(`/routines/grooming-schedule/${id}`, body); -// ─── Health – Medications ──────────────────────────────────────────────────── +export const deleteGroomingScheduleEntry = ( + id: string, + options?: ApiClientOptions, +): Promise => + resolveClient(options).del(`/routines/grooming-schedule/${id}`); export interface MedicationListParams { kind?: string; @@ -203,37 +307,51 @@ export interface MedicationListParams { export function getMedications( params: MedicationListParams = {}, + options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.kind) q.set("kind", params.kind); if (params.product_name) q.set("product_name", params.product_name); const qs = q.toString(); - return api.get(`/health/medications${qs ? `?${qs}` : ""}`); + return resolveClient(options).get(`/health/medications${qs ? `?${qs}` : ""}`); } -export const getMedication = (id: string): Promise => - api.get(`/health/medications/${id}`); +export const getMedication = ( + id: string, + options?: ApiClientOptions, +): Promise => + resolveClient(options).get(`/health/medications/${id}`); + export const createMedication = ( body: Record, -): Promise => api.post("/health/medications", body); + options?: ApiClientOptions, +): Promise => + resolveClient(options).post("/health/medications", body); + export const updateMedication = ( id: string, body: Record, -): Promise => api.patch(`/health/medications/${id}`, body); -export const deleteMedication = (id: string): Promise => - api.del(`/health/medications/${id}`); + options?: ApiClientOptions, +): Promise => + resolveClient(options).patch(`/health/medications/${id}`, body); + +export const deleteMedication = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).del(`/health/medications/${id}`); export const getMedicationUsages = ( medicationId: string, + options?: ApiClientOptions, ): Promise => - api.get(`/health/medications/${medicationId}/usages`); + resolveClient(options).get(`/health/medications/${medicationId}/usages`); + export const createMedicationUsage = ( medicationId: string, body: Record, + options?: ApiClientOptions, ): Promise => - api.post(`/health/medications/${medicationId}/usages`, body); - -// ─── Health – Lab results ──────────────────────────────────────────────────── + resolveClient(options).post(`/health/medications/${medicationId}/usages`, body); export interface LabResultListParams { q?: string; @@ -250,6 +368,7 @@ export interface LabResultListParams { export function getLabResults( params: LabResultListParams = {}, + options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.q) q.set("q", params.q); @@ -258,29 +377,43 @@ export function getLabResults( if (params.flags?.length) { for (const flag of params.flags) q.append("flags", flag); } - if (params.without_flag != null) q.set("without_flag", String(params.without_flag)); + if (params.without_flag != null) { + q.set("without_flag", String(params.without_flag)); + } if (params.from_date) q.set("from_date", params.from_date); if (params.to_date) q.set("to_date", params.to_date); - if (params.latest_only != null) q.set("latest_only", String(params.latest_only)); + if (params.latest_only != null) { + q.set("latest_only", String(params.latest_only)); + } if (params.limit != null) q.set("limit", String(params.limit)); if (params.offset != null) q.set("offset", String(params.offset)); const qs = q.toString(); - return api.get(`/health/lab-results${qs ? `?${qs}` : ""}`); + return resolveClient(options).get(`/health/lab-results${qs ? `?${qs}` : ""}`); } -export const getLabResult = (id: string): Promise => - api.get(`/health/lab-results/${id}`); +export const getLabResult = ( + id: string, + options?: ApiClientOptions, +): Promise => + resolveClient(options).get(`/health/lab-results/${id}`); + export const createLabResult = ( body: Record, -): Promise => api.post("/health/lab-results", body); + options?: ApiClientOptions, +): Promise => + resolveClient(options).post("/health/lab-results", body); + export const updateLabResult = ( id: string, body: Record, -): Promise => api.patch(`/health/lab-results/${id}`, body); -export const deleteLabResult = (id: string): Promise => - api.del(`/health/lab-results/${id}`); + options?: ApiClientOptions, +): Promise => + resolveClient(options).patch(`/health/lab-results/${id}`, body); -// ─── Skin ──────────────────────────────────────────────────────────────────── +export const deleteLabResult = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).del(`/health/lab-results/${id}`); export interface SnapshotListParams { from_date?: string; @@ -290,40 +423,47 @@ export interface SnapshotListParams { export function getSkinSnapshots( params: SnapshotListParams = {}, + options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.from_date) q.set("from_date", params.from_date); if (params.to_date) q.set("to_date", params.to_date); if (params.overall_state) q.set("overall_state", params.overall_state); const qs = q.toString(); - return api.get(`/skincare${qs ? `?${qs}` : ""}`); + return resolveClient(options).get(`/skincare${qs ? `?${qs}` : ""}`); } -export const getSkinSnapshot = (id: string): Promise => - api.get(`/skincare/${id}`); +export const getSkinSnapshot = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).get(`/skincare/${id}`); + export const createSkinSnapshot = ( body: Record, -): Promise => api.post("/skincare", body); + options?: ApiClientOptions, +): Promise => resolveClient(options).post("/skincare", body); + export const updateSkinSnapshot = ( id: string, body: Record, -): Promise => api.patch(`/skincare/${id}`, body); -export const deleteSkinSnapshot = (id: string): Promise => - api.del(`/skincare/${id}`); + options?: ApiClientOptions, +): Promise => + resolveClient(options).patch(`/skincare/${id}`, body); + +export const deleteSkinSnapshot = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).del(`/skincare/${id}`); export async function analyzeSkinPhotos( - files: File[], + files: File[] | FormData, + options?: ApiClientOptions, ): Promise { - const body = new FormData(); - for (const file of files) body.append("photos", file); - const base = browser ? "/api" : PUBLIC_API_BASE; - const res = await fetch(`${base}/skincare/analyze-photos`, { - method: "POST", - body, - }); - if (!res.ok) { - const detail = await res.json().catch(() => ({ detail: res.statusText })); - throw new Error(detail?.detail ?? res.statusText); + const body = files instanceof FormData ? files : new FormData(); + + if (!(files instanceof FormData)) { + for (const file of files) body.append("photos", file); } - return res.json(); + + return resolveClient(options).postForm("/skincare/analyze-photos", body); } diff --git a/frontend/src/lib/components/ProductForm.svelte b/frontend/src/lib/components/ProductForm.svelte index 23dc172..fbf5bda 100644 --- a/frontend/src/lib/components/ProductForm.svelte +++ b/frontend/src/lib/components/ProductForm.svelte @@ -1,4 +1,4 @@ -