mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Merge branch 'fix/v1474-auth' into release/v1.47.4
# Conflicts: # src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
commit
212ff9ee55
33 files changed
+1177
-151
No files matched your search
@@ -61,6 +61,9 @@
|
||||
"signIn": "Anmelden"
|
||||
},
|
||||
"apiErrors": {
|
||||
"http": {
|
||||
"rate_limited": "Zu viele Versuche. Warte ein paar Minuten und versuche es erneut."
|
||||
},
|
||||
"measurement": {
|
||||
"create": {
|
||||
"invalid": "Prüfe die Messdaten und versuche es erneut."
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
"signIn": "Sign in"
|
||||
},
|
||||
"apiErrors": {
|
||||
"http": {
|
||||
"rate_limited": "Too many attempts. Wait a few minutes and try again."
|
||||
},
|
||||
"measurement": {
|
||||
"create": {
|
||||
"invalid": "Check the measurement details and try again."
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
"signIn": "Iniciar sesión"
|
||||
},
|
||||
"apiErrors": {
|
||||
"http": {
|
||||
"rate_limited": "Demasiados intentos. Espera unos minutos y vuelve a intentarlo."
|
||||
},
|
||||
"measurement": {
|
||||
"create": {
|
||||
"invalid": "Comprueba los datos de la medición e inténtalo de nuevo."
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
"signIn": "Se connecter"
|
||||
},
|
||||
"apiErrors": {
|
||||
"http": {
|
||||
"rate_limited": "Trop de tentatives. Attendez quelques minutes puis réessayez."
|
||||
},
|
||||
"measurement": {
|
||||
"create": {
|
||||
"invalid": "Vérifiez les données de la mesure et réessayez."
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
"signIn": "Accedi"
|
||||
},
|
||||
"apiErrors": {
|
||||
"http": {
|
||||
"rate_limited": "Troppi tentativi. Attendi qualche minuto e riprova."
|
||||
},
|
||||
"measurement": {
|
||||
"create": {
|
||||
"invalid": "Controlla i dati della misurazione e riprova."
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
"signIn": "로그인"
|
||||
},
|
||||
"apiErrors": {
|
||||
"http": {
|
||||
"rate_limited": "시도 횟수가 너무 많습니다. 몇 분 후 다시 시도하세요."
|
||||
},
|
||||
"measurement": {
|
||||
"create": {
|
||||
"invalid": "측정 내용을 확인하고 다시 시도해 주세요."
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
"signIn": "Aanmelden"
|
||||
},
|
||||
"apiErrors": {
|
||||
"http": {
|
||||
"rate_limited": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw."
|
||||
},
|
||||
"measurement": {
|
||||
"create": {
|
||||
"invalid": "Controleer de gegevens van de meting en probeer het opnieuw."
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
"signIn": "Zaloguj się"
|
||||
},
|
||||
"apiErrors": {
|
||||
"http": {
|
||||
"rate_limited": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie."
|
||||
},
|
||||
"measurement": {
|
||||
"create": {
|
||||
"invalid": "Sprawdź dane pomiaru i spróbuj ponownie."
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
"signIn": "Entrar"
|
||||
},
|
||||
"apiErrors": {
|
||||
"http": {
|
||||
"rate_limited": "Muitas tentativas. Aguarde alguns minutos e tente novamente."
|
||||
},
|
||||
"measurement": {
|
||||
"create": {
|
||||
"invalid": "Verifique os dados da medição e tente novamente."
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
"signIn": "Вход"
|
||||
},
|
||||
"apiErrors": {
|
||||
"http": {
|
||||
"rate_limited": "Слишком много попыток. Подождите несколько минут и попробуйте снова."
|
||||
},
|
||||
"measurement": {
|
||||
"create": {
|
||||
"invalid": "Проверьте данные измерения и попробуйте снова."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 84,
|
||||
"total": 94047,
|
||||
"total": 94006,
|
||||
"entries": {
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
@@ -12,7 +12,7 @@
|
||||
"components/insights/derived/vitals-dashboard.tsx": 1033,
|
||||
"components/insights/health-score-card.tsx": 1023,
|
||||
"components/insights/insights-tab-strip.tsx": 1087,
|
||||
"components/measurements/measurement-form.tsx": 815,
|
||||
"components/measurements/measurement-form.tsx": 812,
|
||||
"components/medications/detail/medication-detail-tabs.tsx": 1157,
|
||||
"components/medications/medication-table.tsx": 853,
|
||||
"components/medications/wizard/wizard-payload.ts": 1414,
|
||||
@@ -82,7 +82,7 @@
|
||||
"lib/search/search-records.ts": 996,
|
||||
"lib/sharing/grants.ts": 806,
|
||||
"lib/support/report.ts": 903,
|
||||
"lib/telegram-webhook-handlers.ts": 1341,
|
||||
"lib/telegram-webhook-handlers.ts": 1335,
|
||||
"lib/validations/inbound-documents.ts": 1018,
|
||||
"lib/withings/sync-sleep.ts": 884,
|
||||
"lib/withings/sync.ts": 933,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 344,
|
||||
"total": 111495,
|
||||
"count": 343,
|
||||
"total": 111120,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
|
||||
@@ -17,7 +17,7 @@
|
||||
"app/api/devices/route.ts :: apiHandler(arg)": 226,
|
||||
"app/api/encounters/[id]/route.ts :: apiHandler(arg) #2": 269,
|
||||
"app/api/environment/route.ts :: apiHandler(arg)": 201,
|
||||
"app/api/export/health-record/route.ts :: apiHandler(arg)": 241,
|
||||
"app/api/export/health-record/route.ts :: apiHandler(arg)": 240,
|
||||
"app/api/ingest/medication/route.ts :: apiHandler(arg)": 259,
|
||||
"app/api/insights/cards/route.ts :: apiHandler(arg)": 211,
|
||||
"app/api/integrations/status/route.ts :: apiHandler(arg)": 476,
|
||||
@@ -38,8 +38,8 @@
|
||||
"app/api/record-settings/[family]/route.ts :: apiHandler(arg) #2": 262,
|
||||
"app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 248,
|
||||
"app/api/withings/callback/route.ts :: apiHandler(arg)": 247,
|
||||
"app/auth/login/page.tsx :: LoginPage": 569,
|
||||
"app/auth/register/page.tsx :: RegisterPage": 285,
|
||||
"app/auth/login/page.tsx :: LoginPage": 561,
|
||||
"app/auth/register/page.tsx :: RegisterPage": 283,
|
||||
"app/coach/conversations/page.tsx :: CoachConversationsBody": 249,
|
||||
"app/coach/plans/page.tsx :: CoachPlansBody": 216,
|
||||
"app/insights/catalog/page.tsx :: MetricCatalogPage": 204,
|
||||
@@ -64,7 +64,7 @@
|
||||
"components/admin/user-management-section.tsx :: UserManagementSection": 622,
|
||||
"components/admin/web-push-vapid-section.tsx :: WebPushVapidSection": 217,
|
||||
"components/auth/claim-profile.tsx :: ClaimProfile": 429,
|
||||
"components/auth/mfa-login-step.tsx :: MfaLoginStep": 217,
|
||||
"components/auth/mfa-login-step.tsx :: MfaLoginStep": 215,
|
||||
"components/charts/compliance-heatmap.tsx :: ComplianceHeatmap": 325,
|
||||
"components/charts/health-chart.tsx :: HealthChart": 470,
|
||||
"components/charts/medication-compliance-chart.tsx :: MedicationComplianceChart": 543,
|
||||
@@ -73,7 +73,7 @@
|
||||
"components/charts/trend-card.tsx :: TrendCard": 365,
|
||||
"components/clinician/clinician-view.tsx :: ClinicianView": 223,
|
||||
"components/command-palette/command-palette.tsx :: PaletteBody": 333,
|
||||
"components/custom-metrics/custom-metric-chart.tsx :: CustomMetricChart": 231,
|
||||
"components/custom-metrics/custom-metric-chart.tsx :: CustomMetricChart": 228,
|
||||
"components/custom-metrics/custom-metric-detail.tsx :: CustomMetricDetail": 277,
|
||||
"components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 214,
|
||||
"components/custom-metrics/custom-metric-history-list.tsx :: CustomMetricHistoryList": 302,
|
||||
@@ -138,7 +138,7 @@
|
||||
"components/labs/lab-history-list.tsx :: LabHistoryList": 534,
|
||||
"components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359,
|
||||
"components/labs/ocr-row-editor.tsx :: OcrRowEditor": 378,
|
||||
"components/layout/auth-shell.tsx :: AuthShell": 518,
|
||||
"components/layout/auth-shell.tsx :: AuthShell": 483,
|
||||
"components/layout/bottom-nav.tsx :: BottomNav": 262,
|
||||
"components/layout/capture-picker.tsx :: CapturePicker": 235,
|
||||
"components/layout/sidebar-nav.tsx :: SidebarNav": 356,
|
||||
@@ -146,7 +146,7 @@
|
||||
"components/links/entity-link-picker.tsx :: EntityLinkPicker": 392,
|
||||
"components/measurement-reminders/vorsorge-dashboard-card.tsx :: VorsorgeDashboardCard": 215,
|
||||
"components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 352,
|
||||
"components/measurements/measurement-form.tsx :: MeasurementForm": 500,
|
||||
"components/measurements/measurement-form.tsx :: MeasurementForm": 493,
|
||||
"components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361,
|
||||
"components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 767,
|
||||
"components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 223,
|
||||
@@ -161,7 +161,7 @@
|
||||
"components/medications/medication-table.tsx :: MedicationTableRowItem": 377,
|
||||
"components/medications/scheduling/natural-language-extractor.tsx :: NaturalLanguageExtractor": 214,
|
||||
"components/medications/scheduling/schedule-history-timeline.tsx :: EraDialog": 219,
|
||||
"components/medications/scheduling/schedule-history-timeline.tsx :: ScheduleHistoryTimeline": 256,
|
||||
"components/medications/scheduling/schedule-history-timeline.tsx :: ScheduleHistoryTimeline": 254,
|
||||
"components/medications/sections/api-tokens-row.tsx :: ApiTokensRow": 296,
|
||||
"components/medications/sections/composition-section.tsx :: CompositionEditor": 362,
|
||||
"components/medications/sections/inventory-dialogs.tsx :: AddInventoryDialog": 238,
|
||||
@@ -232,7 +232,7 @@
|
||||
"components/timeline/life-event-form.tsx :: LifeEventForm": 281,
|
||||
"components/timeline/timeline-chart.tsx :: ChartBody": 236,
|
||||
"components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246,
|
||||
"components/timeline/timeline-view.tsx :: TimelineView": 486,
|
||||
"components/timeline/timeline-view.tsx :: TimelineView": 478,
|
||||
"components/ui/responsive-sheet.tsx :: ResponsiveSheet": 239,
|
||||
"components/vaccinations/catalog-picker.tsx :: CatalogPicker": 221,
|
||||
"components/vaccinations/custom-vaccine-sheet.tsx :: CustomVaccineSheet": 246,
|
||||
@@ -284,8 +284,8 @@
|
||||
"lib/google-health/data-points.ts :: forEachDataPointPage > walk": 223,
|
||||
"lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 452,
|
||||
"lib/illness/correlation.ts :: computeIllnessCorrelation": 233,
|
||||
"lib/import/labs-csv.ts :: importLabsCsv": 307,
|
||||
"lib/import/labs-csv.ts :: importLabsCsv > flush": 229,
|
||||
"lib/import/labs-csv.ts :: importLabsCsv": 305,
|
||||
"lib/import/labs-csv.ts :: importLabsCsv > flush": 227,
|
||||
"lib/insights/comprehensive-aggregator.ts :: buildFromRollups": 217,
|
||||
"lib/insights/comprehensive-generate.ts :: generateComprehensiveInsight": 876,
|
||||
"lib/insights/features.ts :: extractFeatures": 796,
|
||||
@@ -329,7 +329,7 @@
|
||||
"lib/medications/list-read.ts :: buildMedicationsList": 308,
|
||||
"lib/medications/scheduling/dose-history.ts :: reconstructDoseHistory": 218,
|
||||
"lib/multipart/stream-to-disk.ts :: streamMultipartToDisk": 272,
|
||||
"lib/notifications/dispatcher.ts :: dispatchNotification": 378,
|
||||
"lib/notifications/dispatcher.ts :: dispatchNotification": 368,
|
||||
"lib/notifications/senders/apns.ts :: sendViaApns": 299,
|
||||
"lib/notifications/senders/web-push.ts :: sendViaWebPush": 255,
|
||||
"lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239,
|
||||
@@ -339,12 +339,12 @@
|
||||
"lib/support/report.ts :: collectSupportReportFacts": 332,
|
||||
"lib/targets/build-response.ts :: buildTargetsResponse": 330,
|
||||
"lib/targets/vitals-builder.ts :: buildVitalTargets": 324,
|
||||
"lib/telegram-webhook-handlers.ts :: handleCallback": 599,
|
||||
"lib/telegram-webhook-handlers.ts :: handleCallback": 590,
|
||||
"lib/telegram-webhook-handlers.ts :: handleTextMessage": 337,
|
||||
"lib/withings/sync-activity.ts :: syncUserActivity": 377,
|
||||
"lib/withings/sync-ecg.ts :: syncUserEcg": 202,
|
||||
"lib/withings/sync-sleep.ts :: syncUserSleep": 414,
|
||||
"lib/withings/sync.ts :: syncUserMeasurements": 337,
|
||||
"proxy.ts :: proxy": 471
|
||||
"proxy.ts :: proxy": 467
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2885,
|
||||
"total": 9198,
|
||||
"total": 9193,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -136,7 +136,7 @@
|
||||
"app/api/auth/codex/device-start/route.ts": 1,
|
||||
"app/api/auth/login/__tests__/native-token.test.ts": 2,
|
||||
"app/api/auth/login/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/login/route.ts": 5,
|
||||
"app/api/auth/login/route.ts": 4,
|
||||
"app/api/auth/me/coach-prefs/route.ts": 7,
|
||||
"app/api/auth/me/cycle-prefs/route.ts": 1,
|
||||
"app/api/auth/me/devices/[id]/route.ts": 3,
|
||||
@@ -556,7 +556,7 @@
|
||||
"app/api/workouts/route.ts": 2,
|
||||
"app/auth/claim/page.tsx": 1,
|
||||
"app/auth/login/page.tsx": 5,
|
||||
"app/auth/register/page.tsx": 4,
|
||||
"app/auth/register/page.tsx": 3,
|
||||
"app/c/[token]/__tests__/page.test.tsx": 2,
|
||||
"app/c/[token]/d/[id]/__tests__/route.test.ts": 1,
|
||||
"app/c/[token]/d/[id]/route.ts": 1,
|
||||
@@ -1094,7 +1094,7 @@
|
||||
"components/layout/__tests__/shared-record-chrome.test.tsx": 1,
|
||||
"components/layout/__tests__/sidebar-nav.test.tsx": 3,
|
||||
"components/layout/account-switcher-menu.tsx": 1,
|
||||
"components/layout/auth-shell.tsx": 14,
|
||||
"components/layout/auth-shell.tsx": 13,
|
||||
"components/layout/bottom-nav.tsx": 8,
|
||||
"components/layout/capture-picker.tsx": 5,
|
||||
"components/layout/nav-model.ts": 17,
|
||||
@@ -2885,7 +2885,7 @@
|
||||
"lib/withings/sync.ts": 6,
|
||||
"lib/withings/webhook-handler.ts": 3,
|
||||
"lib/workouts/list-read.ts": 1,
|
||||
"proxy.ts": 27,
|
||||
"proxy.ts": 25,
|
||||
"types/analytics.ts": 8,
|
||||
"types/backups.ts": 1
|
||||
}
|
||||
|
||||
@@ -161,6 +161,25 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
|
||||
expect(proxy(makeRequest("/api/search", "DELETE")).status).toBe(403);
|
||||
});
|
||||
|
||||
it("lets a visitor sign out, report page timings and pick a language", () => {
|
||||
// Each touches only this visitor: their own session, a log line, their
|
||||
// own language cookie (the locale route leaves the shared account's row
|
||||
// alone under DEMO_MODE). Refused, sign-out stayed signed in and every
|
||||
// page load sent the refused locale write and the timings again.
|
||||
for (const [path, method] of [
|
||||
["/api/auth/logout", "POST"],
|
||||
["/api/internal/web-vitals", "POST"],
|
||||
["/api/auth/me/locale", "PUT"],
|
||||
] as const) {
|
||||
expect(
|
||||
proxy(makeRequest(path, method)).status,
|
||||
`${method} ${path} is blocked in the demo`,
|
||||
).not.toBe(403);
|
||||
}
|
||||
// The rest of the account's settings stay closed.
|
||||
expect(proxy(makeRequest("/api/auth/me/timezone", "PUT")).status).toBe(403);
|
||||
});
|
||||
|
||||
it("still blocks a health-data mutation (POST /api/measurements)", () => {
|
||||
const res = proxy(makeRequest("/api/measurements", "POST"));
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
@@ -87,7 +87,7 @@ describe("proxy early exits carry the baseline security headers", () => {
|
||||
|
||||
it("unauthenticated page redirect to /auth/login", () => {
|
||||
const res = proxy(makeRequest("/dashboard"));
|
||||
expect(res.headers.get("location")).toMatch(/\/auth\/login$/);
|
||||
expect(res.headers.get("location")).toMatch(/\/auth\/login(\?|$)/);
|
||||
expectBaselineHeaders(res);
|
||||
});
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("proxy.ts onboarding redirect (v1.4.22 C4)", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
const anonymous = proxy(makeRequest("/insights"));
|
||||
expect(anonymous.headers.get("location")).toMatch(/\/auth\/login$/);
|
||||
expect(anonymous.headers.get("location")).toMatch(/\/auth\/login(\?|$)/);
|
||||
});
|
||||
|
||||
it("passes through when hl_onboarding cookie is absent (completed users)", () => {
|
||||
@@ -123,7 +123,7 @@ describe("proxy.ts onboarding redirect (v1.4.22 C4)", () => {
|
||||
// were still in place it would short-circuit through public-path
|
||||
// handling and 200 instead.
|
||||
expect([307, 308]).toContain(res.status);
|
||||
expect(res.headers.get("location")).toMatch(/\/auth\/login$/);
|
||||
expect(res.headers.get("location")).toMatch(/\/auth\/login(\?|$)/);
|
||||
});
|
||||
|
||||
it("still treats subroutes /onboarding/<x> as the onboarding surface", () => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* The edge sends a visitor without a session to the sign-in page with the
|
||||
* page they asked for in `next`, so a home-screen shortcut or a notification
|
||||
* tap still arrives there after signing in. The sign-in page re-checks the
|
||||
* value (`sanitizeSameOriginPath`); the proxy only ever puts in the
|
||||
* request's own path and query.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
|
||||
function signInRedirect(pathAndQuery: string): URL {
|
||||
const res = proxy(new NextRequest(`http://localhost${pathAndQuery}`));
|
||||
expect([307, 308]).toContain(res.status);
|
||||
return new URL(res.headers.get("location")!, "http://localhost");
|
||||
}
|
||||
|
||||
describe("proxy.ts sign-in redirect", () => {
|
||||
it.each([
|
||||
"/measurements?add=WEIGHT&source=shortcut",
|
||||
"/mood?add=1",
|
||||
"/medications",
|
||||
"/?source=pwa",
|
||||
])("carries %s to the sign-in page", (target) => {
|
||||
const location = signInRedirect(target);
|
||||
expect(location.pathname).toBe("/auth/login");
|
||||
expect(location.searchParams.get("next")).toBe(target);
|
||||
});
|
||||
|
||||
it("sends the bare start page to a bare sign-in page", () => {
|
||||
const location = signInRedirect("/");
|
||||
expect(location.pathname).toBe("/auth/login");
|
||||
expect(location.search).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -184,3 +184,30 @@ describe("POST /api/auth/login — OIDC_ONLY server-side enforcement", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/login — per-address limit", () => {
|
||||
const original = process.env.DEMO_MODE;
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = original;
|
||||
});
|
||||
|
||||
function limitAsked(): unknown {
|
||||
const call = vi
|
||||
.mocked(checkAuthSurfaceRateLimit)
|
||||
.mock.calls.find((c) => c[1] === "auth:login");
|
||||
return call?.[2];
|
||||
}
|
||||
|
||||
it("allows five attempts per address on an ordinary instance", async () => {
|
||||
delete process.env.DEMO_MODE;
|
||||
await POST(makeRequest());
|
||||
expect(limitAsked()).toBe(5);
|
||||
});
|
||||
|
||||
it("allows thirty on a demo, whose one account everybody shares", async () => {
|
||||
process.env.DEMO_MODE = "true";
|
||||
await POST(makeRequest());
|
||||
expect(limitAsked()).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -38,13 +38,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.43 — `checkAuthSurfaceRateLimit` swaps to a tighter
|
||||
// global bucket when the trust chain is misconfigured; otherwise it
|
||||
// is byte-equivalent to the previous per-IP `auth:login:{ip}` key.
|
||||
// Per IP, or one tighter shared bucket when the trust chain is
|
||||
// misconfigured. A demo publishes its one account, and a class or an
|
||||
// office behind one address tries it together: 5 locked them out.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:login",
|
||||
5,
|
||||
process.env.DEMO_MODE === "true" ? 30 : 5,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The language write on a demo.
|
||||
*
|
||||
* A demo is one published account every visitor signs into, so a visitor's
|
||||
* language must not become the account's: the route answers as usual and
|
||||
* sets the visitor's own cookie, but leaves the shared row alone.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return { ...actual, requireActorAuth: vi.fn() };
|
||||
});
|
||||
vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() } } }));
|
||||
vi.mock("next/headers", () => ({ cookies: vi.fn(async () => ({})) }));
|
||||
vi.mock("@/lib/i18n/locale-cookie", () => ({ setLocaleCookie: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { requireActorAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { setLocaleCookie } from "@/lib/i18n/locale-cookie";
|
||||
|
||||
const ORIGINAL_DEMO_MODE = process.env.DEMO_MODE;
|
||||
|
||||
function put(locale: string) {
|
||||
return PUT(
|
||||
new Request("http://localhost/api/auth/me/locale", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ locale }),
|
||||
}) as never,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireActorAuth).mockResolvedValue({
|
||||
user: { id: "demo-account", locale: "en" },
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_DEMO_MODE === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = ORIGINAL_DEMO_MODE;
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/locale", () => {
|
||||
it("stores the language on the account", async () => {
|
||||
delete process.env.DEMO_MODE;
|
||||
const res = await put("de");
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "demo-account" },
|
||||
data: { locale: "de" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a demo visitor's language in their own cookie only", async () => {
|
||||
process.env.DEMO_MODE = "true";
|
||||
const res = await put("de");
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(setLocaleCookie).toHaveBeenCalledWith(expect.anything(), "de");
|
||||
});
|
||||
});
|
||||
@@ -66,8 +66,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
// Idempotent: skip the write (and the annotation noise) when the column
|
||||
// already matches — the mount-time backfill fires on every page load.
|
||||
if (user.locale !== locale) {
|
||||
// already matches — the mount-time backfill fires on every page load. A
|
||||
// demo is one account every visitor shares, so there the choice stays in
|
||||
// this visitor's cookie and never reaches the row the next visitor reads.
|
||||
if (user.locale !== locale && process.env.DEMO_MODE !== "true") {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { locale },
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* The sign-in page, driven by its buttons.
|
||||
*
|
||||
* Single sign-on leaves the page for the identity provider and the session
|
||||
* starts on the way back, so the previous account's offline copies have to
|
||||
* be gone before the page is left. A sign-in that stays in the app replaces
|
||||
* the sign-in page in the history, so Back does not show the form again.
|
||||
* A refusal is said in the reader's language, never in the server's words.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { page } from "vitest/browser";
|
||||
|
||||
import {
|
||||
stubFetch,
|
||||
type FetchStub,
|
||||
} from "../../../../../tests/browser/fetch-stub";
|
||||
import { renderWithApp } from "../../../../../tests/browser/render";
|
||||
import { recordHistory } from "../../../../../tests/browser/url";
|
||||
|
||||
const clearOffline = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const loadDocument = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/pwa/query-persister", () => ({
|
||||
clearOfflineCachesForSessionEnd: clearOffline,
|
||||
}));
|
||||
vi.mock("@/lib/navigation/load-document", () => ({ loadDocument }));
|
||||
|
||||
import { MfaLoginStep } from "@/components/auth/mfa-login-step";
|
||||
|
||||
import LoginPage from "../page";
|
||||
|
||||
let api: FetchStub;
|
||||
|
||||
beforeEach(() => {
|
||||
api = stubFetch()
|
||||
.on("GET", "/api/auth/registration-status", {
|
||||
data: { registrationEnabled: false, sessionCookieSecure: false },
|
||||
})
|
||||
.on("GET", "/api/auth/oidc/status", {
|
||||
data: { enabled: true, buttonLabel: "Example ID", only: false },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearOffline.mockClear();
|
||||
loadDocument.mockClear();
|
||||
});
|
||||
|
||||
describe("sign-in page, single sign-on", () => {
|
||||
it("clears the offline copies before leaving for the identity provider", async () => {
|
||||
const { screen } = await renderWithApp(<LoginPage />, { user: null });
|
||||
await screen.getByRole("button", { name: "Example ID" }).click();
|
||||
|
||||
await expect.poll(() => loadDocument.mock.calls.length).toBe(1);
|
||||
expect(loadDocument.mock.calls[0][0]).toMatch(
|
||||
/^\/api\/auth\/oidc\/login\?/,
|
||||
);
|
||||
expect(clearOffline).toHaveBeenCalledTimes(1);
|
||||
expect(clearOffline.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
loadDocument.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sign-in page, password", () => {
|
||||
it("replaces itself with the page asked for", async () => {
|
||||
api.on("POST", "/api/auth/login", { data: { id: "signed-in" } });
|
||||
window.history.replaceState(null, "", "/auth/login?next=%2Fmood%3Fadd%3D1");
|
||||
const history = recordHistory();
|
||||
const { screen } = await renderWithApp(<LoginPage />, { user: null });
|
||||
|
||||
await screen.getByTestId("login-use-password").click();
|
||||
await screen.getByTestId("login-email").fill("someone");
|
||||
await page
|
||||
.elementLocator(document.querySelector("#password")!)
|
||||
.fill("a long enough passphrase");
|
||||
await screen.getByTestId("login-password-submit").click();
|
||||
|
||||
await expect.poll(() => window.location.pathname).toBe("/mood");
|
||||
expect(window.location.search).toBe("?add=1");
|
||||
expect(history.pushes()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sign-in refusals", () => {
|
||||
async function signInWithPassword() {
|
||||
const { screen } = await renderWithApp(<LoginPage />, { user: null });
|
||||
await screen.getByTestId("login-use-password").click();
|
||||
await screen.getByTestId("login-email").fill("someone");
|
||||
await page
|
||||
.elementLocator(document.querySelector("#password")!)
|
||||
.fill("a long enough passphrase");
|
||||
await screen.getByTestId("login-password-submit").click();
|
||||
return screen;
|
||||
}
|
||||
|
||||
it("says the sign-in failed instead of the server's words", async () => {
|
||||
api.on("POST", "/api/auth/login", {
|
||||
status: 401,
|
||||
error: "Invalid credentials",
|
||||
meta: { errorCode: "http.unauthenticated" },
|
||||
});
|
||||
const screen = await signInWithPassword();
|
||||
await expect.element(screen.getByText("Login failed")).toBeVisible();
|
||||
expect(document.body.textContent).not.toContain("Invalid credentials");
|
||||
});
|
||||
|
||||
it("says to wait when the limit is reached", async () => {
|
||||
api.on("POST", "/api/auth/login", {
|
||||
status: 429,
|
||||
error: "Too many login attempts. Please try again later.",
|
||||
meta: { errorCode: "http.rate_limited" },
|
||||
});
|
||||
const screen = await signInWithPassword();
|
||||
await expect
|
||||
.element(
|
||||
screen.getByText(
|
||||
"Too many attempts. Wait a few minutes and try again.",
|
||||
),
|
||||
)
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it("does the same for the second factor", async () => {
|
||||
api.on("POST", "/api/auth/mfa/verify", {
|
||||
status: 401,
|
||||
error: "Invalid code",
|
||||
meta: { errorCode: "http.unauthenticated" },
|
||||
});
|
||||
const { screen } = await renderWithApp(
|
||||
<MfaLoginStep
|
||||
mfaTicket="ticket"
|
||||
methods={["totp"]}
|
||||
onSuccess={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
{ user: null },
|
||||
);
|
||||
await screen.getByTestId("mfa-code-input").fill("123456");
|
||||
await screen.getByTestId("mfa-code-submit").click();
|
||||
await expect
|
||||
.element(screen.getByText("Verification failed. Please try again."))
|
||||
.toBeVisible();
|
||||
expect(document.body.textContent).not.toContain("Invalid code");
|
||||
});
|
||||
});
|
||||
+11
-18
@@ -25,6 +25,7 @@ import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
|
||||
import { sanitizeSameOriginPath } from "@/lib/url-safety";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import {
|
||||
InsecureTransportBanner,
|
||||
@@ -176,12 +177,14 @@ export default function LoginPage() {
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
function handleOidcLogin() {
|
||||
async function handleOidcLogin() {
|
||||
const params = new URLSearchParams({ next: getRedirectTarget() });
|
||||
if (clientTransport) {
|
||||
params.set("client_proto", clientTransport.protocol);
|
||||
params.set("client_host", clientTransport.host);
|
||||
}
|
||||
// The session starts on the way back; the last account's copies go first.
|
||||
await clearOfflineCachesForSessionEnd();
|
||||
loadDocument(`/api/auth/oidc/login?${params}`);
|
||||
}
|
||||
|
||||
@@ -226,21 +229,15 @@ export default function LoginPage() {
|
||||
// stale `["auth","me"]`, so the shell refetches it fresh on mount and the
|
||||
// former explicit `invalidateQueries({ queryKey: auth() })` is redundant.
|
||||
queryClient.clear();
|
||||
// Belt-and-suspenders to match the session-END wipe (logout / 401-expiry):
|
||||
// also drop the OFFLINE layers — the IndexedDB query snapshot and the SW
|
||||
// data/page caches — so a previous account's persisted dashboard never
|
||||
// lingers on disk for the new session. The restore is already per-account
|
||||
// bound, but a fresh login on a shared browser should leave nothing of the
|
||||
// prior account behind. Order is clear → prefetch → navigate: the wipe is
|
||||
// started here (best-effort, like logout) before the prefetch warms THIS
|
||||
// user's data, and the persister's 1 s debounce gives the single IDB delete
|
||||
// ample headroom to land before the new snapshot is written.
|
||||
// The offline layers too, as at every session end. Started before the
|
||||
// prefetch; the persister's 1 s debounce lets the delete land before the
|
||||
// new account's snapshot is written.
|
||||
void clearOfflineCachesForSessionEnd();
|
||||
const target = getRedirectTarget();
|
||||
if (target === "/" && isDashboardSnapshotEnabled()) {
|
||||
prefetchDashboardSnapshot(queryClient);
|
||||
}
|
||||
router.push(target);
|
||||
router.replace(target);
|
||||
}
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
@@ -269,7 +266,7 @@ export default function LoginPage() {
|
||||
// Route rejections carry the envelope error verbatim; everything
|
||||
// else (WebAuthn ceremony failures) keeps the descriptive mapping.
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message || t("auth.loginFailed"));
|
||||
setError(localizedApiError(err, t, "auth.loginFailed"));
|
||||
} else {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setError(t(key, params));
|
||||
@@ -313,11 +310,7 @@ export default function LoginPage() {
|
||||
navigateAfterLogin();
|
||||
} catch (err) {
|
||||
if (noteRefusal(err)) return;
|
||||
setError(
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: t("auth.loginFailed"),
|
||||
);
|
||||
setError(localizedApiError(err, t, "auth.loginFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -464,7 +457,7 @@ export default function LoginPage() {
|
||||
<InsecureTransportBanner verdict={verdict} />
|
||||
{oidcStatus?.enabled && (
|
||||
<Button
|
||||
onClick={handleOidcLogin}
|
||||
onClick={() => void handleOidcLogin()}
|
||||
variant={oidcStatus.only ? "default" : "outline"}
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* The registration page, driven by its form.
|
||||
*
|
||||
* A new account starts from an empty cache, like every other sign-in: the
|
||||
* client cache outlives the navigation, and nothing read before the account
|
||||
* existed belongs to it. The app replaces the page in the history, so Back
|
||||
* does not show the form again.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { page } from "vitest/browser";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { stubFetch } from "../../../../../tests/browser/fetch-stub";
|
||||
import { renderWithApp } from "../../../../../tests/browser/render";
|
||||
import { recordHistory } from "../../../../../tests/browser/url";
|
||||
import RegisterPage from "../page";
|
||||
|
||||
async function fill(selector: string, value: string): Promise<void> {
|
||||
await page.elementLocator(document.querySelector(selector)!).fill(value);
|
||||
}
|
||||
|
||||
describe("registration page", () => {
|
||||
it("starts the new account from an empty cache", async () => {
|
||||
const api = stubFetch()
|
||||
.on("GET", "/api/auth/me", { status: 401, error: "Unauthorized" })
|
||||
.on("POST", "/api/auth/register", { data: { id: "new-account" } });
|
||||
window.history.replaceState(null, "", "/auth/register");
|
||||
const history = recordHistory();
|
||||
const { screen, queryClient } = await renderWithApp(<RegisterPage />, {
|
||||
user: null,
|
||||
});
|
||||
// Left over from whoever used this browser before.
|
||||
queryClient.setQueryData(queryKeys.dashboardSnapshot(), { stale: true });
|
||||
|
||||
await fill("#email", "new@example.com");
|
||||
await fill("#username", "newcomer");
|
||||
await fill("#password", "a long enough passphrase");
|
||||
await screen.getByRole("button", { name: "Sign up" }).click();
|
||||
|
||||
await expect.poll(() => window.location.pathname).toBe("/");
|
||||
expect(api.calls("POST", "/api/auth/register")).toHaveLength(1);
|
||||
expect(history.pushes()).toEqual([]);
|
||||
expect(queryClient.getQueryData(queryKeys.dashboardSnapshot())).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registration page, already signed in", () => {
|
||||
it("keeps the page and says so when the server refuses the sign-out", async () => {
|
||||
const api = stubFetch().on("POST", "/api/auth/logout", {
|
||||
status: 403,
|
||||
error: "Demo mode: modifications are disabled",
|
||||
meta: { demo: true, errorCode: "demo.readOnly" },
|
||||
});
|
||||
window.history.replaceState(null, "", "/auth/register?invite=hlv_x");
|
||||
const { screen, queryClient } = await renderWithApp(<RegisterPage />, {
|
||||
user: { username: "signed-in" },
|
||||
});
|
||||
|
||||
await screen.getByTestId("register-sign-out").click();
|
||||
|
||||
await expect
|
||||
.element(screen.getByText("Could not sign out. You are still signed in."))
|
||||
.toBeVisible();
|
||||
expect(api.calls("POST", "/api/auth/logout")).toHaveLength(1);
|
||||
// Nothing was wiped and nobody was sent anywhere: the session is alive.
|
||||
expect(queryClient.getQueryData(queryKeys.authMe())).toBeDefined();
|
||||
expect(window.location.search).toBe("?invite=hlv_x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("registration refusals", () => {
|
||||
async function register(
|
||||
response: Parameters<ReturnType<typeof stubFetch>["on"]>[2],
|
||||
) {
|
||||
stubFetch()
|
||||
.on("GET", "/api/auth/me", { status: 401, error: "Unauthorized" })
|
||||
.on("POST", "/api/auth/register", response);
|
||||
const { screen } = await renderWithApp(<RegisterPage />, { user: null });
|
||||
await fill("#email", "new@example.com");
|
||||
await fill("#username", "newcomer");
|
||||
await fill("#password", "a long enough passphrase");
|
||||
await screen.getByRole("button", { name: "Sign up" }).click();
|
||||
return screen;
|
||||
}
|
||||
|
||||
it("says the registration failed instead of the server's words", async () => {
|
||||
const screen = await register({
|
||||
status: 409,
|
||||
error: "Username or email already taken",
|
||||
meta: { errorCode: "http.conflict" },
|
||||
});
|
||||
await expect.element(screen.getByText("Registration failed")).toBeVisible();
|
||||
expect(document.body.textContent).not.toContain("already taken");
|
||||
});
|
||||
|
||||
it("shows the password feedback, which the server words for the reader", async () => {
|
||||
const screen = await register({
|
||||
status: 422,
|
||||
error: "Füge ein oder zwei Wörter hinzu.",
|
||||
meta: { errorCode: "http.invalid" },
|
||||
});
|
||||
await expect
|
||||
.element(screen.getByText("Füge ein oder zwei Wörter hinzu."))
|
||||
.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,8 @@ import { Label } from "@/components/ui/label";
|
||||
import { PasswordStrength } from "@/components/ui/password-strength";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { detectBrowserTimezone } from "@/lib/tz/format";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
|
||||
import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
|
||||
|
||||
export default function RegisterPage() {
|
||||
@@ -60,11 +60,8 @@ export default function RegisterPage() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// v1.4.25 — ship the browser-detected timezone with the
|
||||
// signup payload. Server validates against the IANA list and
|
||||
// falls back to the admin server-default if the value is
|
||||
// bogus, so a tampered or proxied request can never poison
|
||||
// the user's stored zone.
|
||||
// The server checks the zone against the IANA list and falls back to
|
||||
// its default, so a tampered value never reaches the account.
|
||||
const timezone = detectBrowserTimezone();
|
||||
await apiPost("/api/auth/register", {
|
||||
email,
|
||||
@@ -74,13 +71,14 @@ export default function RegisterPage() {
|
||||
...(inviteToken ? { inviteToken } : {}),
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
||||
router.push("/");
|
||||
queryClient.clear(); // nothing cached before belongs to the new account
|
||||
router.replace("/");
|
||||
} catch (err) {
|
||||
// A 422 says what to fix (the password feedback comes localized).
|
||||
setError(
|
||||
err instanceof ApiError && err.message
|
||||
err instanceof ApiError && err.status === 422 && err.message
|
||||
? err.message
|
||||
: t("auth.registerFailed"),
|
||||
: localizedApiError(err, t, "auth.registerFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -91,7 +89,7 @@ export default function RegisterPage() {
|
||||
setError(null);
|
||||
setSigningOut(true);
|
||||
try {
|
||||
await apiFetchRaw("/api/auth/logout", { method: "POST" });
|
||||
await apiFetch("/api/auth/logout", { method: "POST" });
|
||||
// Same session-end wipe the shell and the menu do: the QueryClient
|
||||
// outlives the navigation on this SPA and the health-data families are
|
||||
// not user-scoped, so nothing may survive into the next account.
|
||||
@@ -106,7 +104,7 @@ export default function RegisterPage() {
|
||||
);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError(t("common.networkError"));
|
||||
setError(t("auth.signOutFailed"));
|
||||
} finally {
|
||||
setSigningOut(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Taking over a managed profile, driven by the claim page's buttons.
|
||||
*
|
||||
* Both ways in start a session for a different account than whatever this
|
||||
* browser held before: the password claim stays in the single-page app,
|
||||
* starts from an empty cache and replaces the claim page in the history; the
|
||||
* single-sign-on claim leaves for the identity provider and clears the
|
||||
* offline copies before it goes.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { page } from "vitest/browser";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import {
|
||||
stubFetch,
|
||||
type FetchStub,
|
||||
} from "../../../../tests/browser/fetch-stub";
|
||||
import { renderWithApp } from "../../../../tests/browser/render";
|
||||
import { recordHistory } from "../../../../tests/browser/url";
|
||||
|
||||
const clearOffline = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const loadDocument = vi.hoisted(() => vi.fn());
|
||||
const showErrorToast = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/pwa/query-persister", () => ({
|
||||
clearOfflineCachesForSessionEnd: clearOffline,
|
||||
}));
|
||||
vi.mock("@/lib/navigation/load-document", () => ({ loadDocument }));
|
||||
vi.mock("@/lib/errors/error-toast", () => ({ showErrorToast }));
|
||||
|
||||
import { ClaimProfile } from "../claim-profile";
|
||||
|
||||
const TOKEN = `hlp_${"c".repeat(64)}`;
|
||||
|
||||
let api: FetchStub;
|
||||
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, "", "/auth/claim");
|
||||
api = stubFetch()
|
||||
.on("GET", "/api/auth/me", { status: 401, error: "Unauthorized" })
|
||||
.on("POST", "/api/auth/claim/preview", {
|
||||
data: {
|
||||
displayName: "Robin",
|
||||
expiresAt: "2030-01-01T00:00:00.000Z",
|
||||
guardians: [],
|
||||
methods: ["password", "sso"],
|
||||
ssoProviderName: "Example ID",
|
||||
},
|
||||
})
|
||||
.on("POST", "/api/auth/claim", {
|
||||
data: { userId: "claimed", username: "robin" },
|
||||
})
|
||||
.on("POST", "/api/auth/claim/sso", {
|
||||
data: { redirectTo: "/api/auth/oidc/login?claim=1" },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearOffline.mockClear();
|
||||
loadDocument.mockClear();
|
||||
showErrorToast.mockClear();
|
||||
});
|
||||
|
||||
async function fill(selector: string, value: string): Promise<void> {
|
||||
await expect.poll(() => document.querySelector(selector)).toBeTruthy();
|
||||
await page.elementLocator(document.querySelector(selector)!).fill(value);
|
||||
}
|
||||
|
||||
async function click(slot: string): Promise<void> {
|
||||
await page
|
||||
.elementLocator(document.querySelector(`[data-slot="${slot}"]`)!)
|
||||
.click();
|
||||
}
|
||||
|
||||
describe("ClaimProfile", () => {
|
||||
it("starts the claimed account from an empty cache", async () => {
|
||||
const history = recordHistory();
|
||||
const { queryClient } = await renderWithApp(
|
||||
<ClaimProfile token={TOKEN} />,
|
||||
{
|
||||
user: null,
|
||||
},
|
||||
);
|
||||
queryClient.setQueryData(queryKeys.dashboardSnapshot(), { stale: true });
|
||||
|
||||
await fill("#claim-username", "robin");
|
||||
await fill("#claim-email", "robin@example.com");
|
||||
await fill("#claim-password", "a long enough passphrase");
|
||||
await click("claim-submit");
|
||||
|
||||
await expect
|
||||
.poll(() => window.location.pathname)
|
||||
.toBe("/onboarding/handover");
|
||||
expect(api.calls("POST", "/api/auth/claim")).toHaveLength(1);
|
||||
expect(history.pushes()).toEqual([]);
|
||||
expect(queryClient.getQueryData(queryKeys.dashboardSnapshot())).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the offline copies before leaving for the identity provider", async () => {
|
||||
await renderWithApp(<ClaimProfile token={TOKEN} />, { user: null });
|
||||
|
||||
await fill("#claim-username", "robin");
|
||||
await click("claim-sso-continue");
|
||||
|
||||
await expect.poll(() => loadDocument.mock.calls.length).toBe(1);
|
||||
expect(loadDocument.mock.calls[0][0]).toMatch(
|
||||
/^\/api\/auth\/oidc\/login\?claim=1/,
|
||||
);
|
||||
expect(clearOffline).toHaveBeenCalledTimes(1);
|
||||
expect(clearOffline.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
loadDocument.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaimProfile, already signed in", () => {
|
||||
it("keeps the session's page and says so when the server refuses the sign-out", async () => {
|
||||
api.on("POST", "/api/auth/logout", {
|
||||
status: 403,
|
||||
error: "Demo mode: modifications are disabled",
|
||||
meta: { demo: true, errorCode: "demo.readOnly" },
|
||||
});
|
||||
const { queryClient } = await renderWithApp(
|
||||
<ClaimProfile token={TOKEN} />,
|
||||
{
|
||||
user: { username: "signed-in" },
|
||||
},
|
||||
);
|
||||
|
||||
await click("claim-sign-out");
|
||||
|
||||
await expect.poll(() => showErrorToast.mock.calls.length).toBe(1);
|
||||
expect(showErrorToast.mock.calls[0][0]).toBe(
|
||||
"Could not sign out. You are still signed in.",
|
||||
);
|
||||
expect(clearOffline).not.toHaveBeenCalled();
|
||||
expect(queryClient.getQueryData(queryKeys.authMe())).toBeDefined();
|
||||
expect(window.location.search).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -14,15 +14,16 @@ import { Label } from "@/components/ui/label";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { PasswordStrength } from "@/components/ui/password-strength";
|
||||
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
|
||||
import { ApiError, apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { ApiError, apiFetch } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { loadDocument } from "@/lib/navigation/load-document";
|
||||
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
|
||||
import {
|
||||
useClaimProfile,
|
||||
useClaimProfileSso,
|
||||
useProfileClaimPreview,
|
||||
} from "@/lib/queries/use-profile-claim";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.42 (#959) — the person a managed profile describes takes it over as
|
||||
@@ -78,7 +79,7 @@ export function ClaimProfile({
|
||||
async function signOut() {
|
||||
setSigningOut(true);
|
||||
try {
|
||||
await apiFetchRaw("/api/auth/logout", { method: "POST" });
|
||||
await apiFetch("/api/auth/logout", { method: "POST" });
|
||||
clearCachesForSessionEnd(queryClient);
|
||||
// Back to this page with the link intact: the token only rides the URL.
|
||||
router.replace(
|
||||
@@ -87,6 +88,8 @@ export function ClaimProfile({
|
||||
: "/auth/claim",
|
||||
);
|
||||
router.refresh();
|
||||
} catch (failure) {
|
||||
showErrorToast(t("auth.signOutFailed"), { cause: failure });
|
||||
} finally {
|
||||
setSigningOut(false);
|
||||
}
|
||||
@@ -97,14 +100,15 @@ export function ClaimProfile({
|
||||
claimSso.mutate(
|
||||
{ token, username },
|
||||
{
|
||||
onSuccess: ({ redirectTo }) => {
|
||||
// Only the path and query of the server's answer, so a response
|
||||
// could never send the browser off this origin.
|
||||
onSuccess: async ({ redirectTo }) => {
|
||||
// Path and query only, so an answer can never leave this origin.
|
||||
const next = new URL(redirectTo, window.location.origin);
|
||||
if (clientTransport) {
|
||||
next.searchParams.set("client_proto", clientTransport.protocol);
|
||||
next.searchParams.set("client_host", clientTransport.host);
|
||||
}
|
||||
// The session starts on the way back; the last account's copies go first.
|
||||
await clearOfflineCachesForSessionEnd();
|
||||
loadDocument(`${next.pathname}${next.search}`);
|
||||
},
|
||||
},
|
||||
@@ -117,12 +121,9 @@ export function ClaimProfile({
|
||||
claim.mutate(
|
||||
{ token, username, email, password },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
queryClient.removeQueries({
|
||||
queryKey: queryKeys.profileClaimPreview(),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
||||
router.push("/onboarding/handover");
|
||||
onSuccess: () => {
|
||||
queryClient.clear(); // as after any sign-in
|
||||
router.replace("/onboarding/handover");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { currentClientTransport } from "@/lib/auth/client-transport";
|
||||
|
||||
/** The server's refusal for a page whose sign-in cookie cannot stick. */
|
||||
@@ -73,9 +74,7 @@ export function MfaLoginStep({
|
||||
err instanceof ApiError &&
|
||||
err.meta?.errorCode === INSECURE_TRANSPORT_CODE
|
||||
? t("auth.insecureTransport.title")
|
||||
: err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: t("auth.mfa.verifyFailed"),
|
||||
: localizedApiError(err, t, "auth.mfa.verifyFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -102,7 +101,7 @@ export function MfaLoginStep({
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message || t("auth.mfa.verifyFailed"));
|
||||
setError(localizedApiError(err, t, "auth.mfa.verifyFailed"));
|
||||
} else {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setError(t(key, params));
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The app shell's scroll position across navigation.
|
||||
*
|
||||
* `<main>` is the shell's one scroller and stays mounted across routes, and
|
||||
* the browser restores only the document's offset on Back. So the shell
|
||||
* keeps the offsets itself: a new page starts at the top, Back returns to
|
||||
* where the list was left.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { URL_STATE_EVENT } from "@/lib/url-state";
|
||||
|
||||
import { renderWithApp } from "../../../../tests/browser/render";
|
||||
import { stubFetch } from "../../../../tests/browser/fetch-stub";
|
||||
|
||||
// Chrome that is not the subject.
|
||||
vi.mock("../sidebar-nav", () => ({ SidebarNav: () => null }));
|
||||
vi.mock("../bottom-nav", () => ({ BottomNav: () => null }));
|
||||
vi.mock("../top-bar", () => ({ TopBar: () => null }));
|
||||
vi.mock("../offline-banner", () => ({ OfflineBanner: () => null }));
|
||||
vi.mock("../shared-record-banner", () => ({ SharedRecordBanner: () => null }));
|
||||
vi.mock("@/components/gamification/achievement-unlock-notifier", () => ({
|
||||
AchievementUnlockNotifier: () => null,
|
||||
}));
|
||||
vi.mock("@/components/insights/layout-coach-fab", () => ({
|
||||
LayoutCoachFab: () => null,
|
||||
}));
|
||||
vi.mock("@/components/insights/layout-coach-mount", () => ({
|
||||
LayoutCoachMount: () => null,
|
||||
}));
|
||||
vi.mock("@/components/onboarding/tour-launcher", () => ({
|
||||
TourLauncher: () => null,
|
||||
}));
|
||||
vi.mock("@/components/day/day-layer", () => ({ DayLayerMount: () => null }));
|
||||
vi.mock("@/components/command-palette/command-palette.lazy", () => ({
|
||||
CommandPaletteMount: () => null,
|
||||
}));
|
||||
vi.mock("../global-shortcuts", () => ({ GlobalShortcuts: () => null }));
|
||||
|
||||
import { AuthShell } from "../auth-shell";
|
||||
|
||||
/**
|
||||
* A long list on `/labs`, a short detail page anywhere else. The list is
|
||||
* short while it loads (a skeleton), and each page mounts afresh.
|
||||
*/
|
||||
function Page() {
|
||||
const pathname = usePathname();
|
||||
return <PageBody key={pathname} pathname={pathname} />;
|
||||
}
|
||||
|
||||
function PageBody({ pathname }: { pathname: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setLoaded(true), 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
const long = pathname === "/labs" && loaded;
|
||||
return (
|
||||
<div
|
||||
data-slot="page"
|
||||
data-path={pathname}
|
||||
data-loaded={loaded || undefined}
|
||||
style={{ height: long ? 4000 : 300 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function main(): HTMLElement {
|
||||
return document.getElementById("main-content")!;
|
||||
}
|
||||
|
||||
async function nextFrames(): Promise<void> {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
/** A link tap: the router pushes the entry and renders the next page. */
|
||||
async function openPage(path: string): Promise<void> {
|
||||
window.history.pushState(null, "", path);
|
||||
window.dispatchEvent(new Event(URL_STATE_EVENT));
|
||||
await expect
|
||||
.poll(() => document.querySelector(`[data-path="${path}"]`))
|
||||
.toBeTruthy();
|
||||
}
|
||||
|
||||
describe("AuthShell scroll position", () => {
|
||||
it("opens a new page at the top and returns to the list's offset on Back, once the list has loaded", async () => {
|
||||
stubFetch();
|
||||
window.history.replaceState(null, "", "/labs");
|
||||
await renderWithApp(
|
||||
<AuthShell>
|
||||
<Page />
|
||||
</AuthShell>,
|
||||
{ user: { id: "signed-in" } },
|
||||
);
|
||||
await expect
|
||||
.poll(() => document.querySelector("[data-loaded]"))
|
||||
.toBeTruthy();
|
||||
|
||||
main().scrollTop = 1500;
|
||||
await nextFrames();
|
||||
expect(main().scrollTop).toBe(1500);
|
||||
|
||||
await openPage("/labs/biomarker-1");
|
||||
await expect.poll(() => main().scrollTop).toBe(0);
|
||||
|
||||
window.history.back();
|
||||
await expect
|
||||
.poll(() => document.querySelector('[data-path="/labs"]'))
|
||||
.toBeTruthy();
|
||||
await expect.poll(() => main().scrollTop).toBe(1500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* What the shell does once the server says there is no session.
|
||||
*
|
||||
* On an `/auth/*` page it clears the previous account's offline copies, once,
|
||||
* so a session that ended without a sign-out (an expired cookie the edge sent
|
||||
* straight to the sign-in page) leaves nothing on the device for the next
|
||||
* account. It never clears on an answer that is not a verdict.
|
||||
*
|
||||
* On a protected page it sends the visitor to the sign-in page, carrying the
|
||||
* page along when the session ended on its own, and not after a deliberate
|
||||
* sign-out: the next person on the device starts from a bare sign-in page.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
import { authUser, renderWithApp } from "../../../../tests/browser/render";
|
||||
import { stubFetch } from "../../../../tests/browser/fetch-stub";
|
||||
|
||||
const clearOffline = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock("@/lib/pwa/query-persister", () => ({
|
||||
clearOfflineCachesForSessionEnd: clearOffline,
|
||||
clearOfflineCachesForRecordSwitch: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
// Chrome that is not the subject.
|
||||
vi.mock("../sidebar-nav", () => ({ SidebarNav: () => null }));
|
||||
vi.mock("../bottom-nav", () => ({ BottomNav: () => null }));
|
||||
vi.mock("../top-bar", () => ({ TopBar: () => null }));
|
||||
vi.mock("../offline-banner", () => ({ OfflineBanner: () => null }));
|
||||
vi.mock("../shared-record-banner", () => ({ SharedRecordBanner: () => null }));
|
||||
vi.mock("@/components/gamification/achievement-unlock-notifier", () => ({
|
||||
AchievementUnlockNotifier: () => null,
|
||||
}));
|
||||
vi.mock("@/components/insights/layout-coach-fab", () => ({
|
||||
LayoutCoachFab: () => null,
|
||||
}));
|
||||
vi.mock("@/components/insights/layout-coach-mount", () => ({
|
||||
LayoutCoachMount: () => null,
|
||||
}));
|
||||
vi.mock("@/components/onboarding/tour-launcher", () => ({
|
||||
TourLauncher: () => null,
|
||||
}));
|
||||
vi.mock("@/components/day/day-layer", () => ({ DayLayerMount: () => null }));
|
||||
vi.mock("@/components/command-palette/command-palette.lazy", () => ({
|
||||
CommandPaletteMount: () => null,
|
||||
}));
|
||||
vi.mock("../global-shortcuts", () => ({ GlobalShortcuts: () => null }));
|
||||
|
||||
import { clearCachesForSessionEnd } from "@/hooks/use-auth";
|
||||
|
||||
import { AuthShell } from "../auth-shell";
|
||||
|
||||
const ME = "/api/auth/me";
|
||||
|
||||
function openAt(path: string): void {
|
||||
window.history.replaceState(null, "", path);
|
||||
}
|
||||
|
||||
/** The tree `renderWithApp` mounts, for rendering it again. */
|
||||
function shellTree(queryClient: QueryClient) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<AuthShell>
|
||||
<div data-slot="page" />
|
||||
</AuthShell>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function mountShell(user: Parameters<typeof authUser>[0] | null) {
|
||||
return renderWithApp(
|
||||
<AuthShell>
|
||||
<div data-slot="page" />
|
||||
</AuthShell>,
|
||||
{ user },
|
||||
);
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearOffline.mockClear();
|
||||
});
|
||||
|
||||
describe("AuthShell on a sign-in page", () => {
|
||||
it("clears the offline copies once the server says there is no session", async () => {
|
||||
const fetchStub = stubFetch().on("GET", ME, {
|
||||
status: 401,
|
||||
error: "Unauthorized",
|
||||
});
|
||||
openAt("/auth/login");
|
||||
const { queryClient } = await mountShell(null);
|
||||
|
||||
await expect.poll(() => clearOffline.mock.calls.length).toBe(1);
|
||||
|
||||
// A second answer of the same kind does not clear again.
|
||||
await queryClient.refetchQueries();
|
||||
await settle();
|
||||
expect(fetchStub.calls("GET", ME).length).toBeGreaterThan(1);
|
||||
expect(clearOffline).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does the same on the profile take-over page", async () => {
|
||||
stubFetch().on("GET", ME, { status: 401, error: "Unauthorized" });
|
||||
openAt("/auth/claim");
|
||||
await mountShell(null);
|
||||
await expect.poll(() => clearOffline.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("leaves them alone for a signed-in visitor", async () => {
|
||||
stubFetch();
|
||||
openAt("/auth/register");
|
||||
await mountShell({ id: "signed-in" });
|
||||
await settle();
|
||||
expect(clearOffline).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves them alone when the answer carries no verdict", async () => {
|
||||
// An offline launch or a 5xx says nothing about the session; clearing on
|
||||
// it is what used to destroy the offline copies of a signed-in device.
|
||||
const fetchStub = stubFetch().on("GET", ME, {
|
||||
status: 503,
|
||||
error: "Unavailable",
|
||||
});
|
||||
openAt("/auth/login");
|
||||
await mountShell(null);
|
||||
// The account query retries a 5xx once; wait for both answers.
|
||||
await expect
|
||||
.poll(() => fetchStub.calls("GET", ME).length, { timeout: 4000 })
|
||||
.toBe(2);
|
||||
await settle();
|
||||
expect(clearOffline).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not clear on a public page outside the sign-in flow", async () => {
|
||||
stubFetch().on("GET", ME, { status: 401, error: "Unauthorized" });
|
||||
openAt("/about");
|
||||
await mountShell(null);
|
||||
await settle();
|
||||
expect(clearOffline).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthShell on a protected page", () => {
|
||||
/** Signed in on `path`, with the marker a confirmed session leaves. */
|
||||
async function signedInAt(path: string) {
|
||||
let session = true;
|
||||
stubFetch().on("GET", ME, () =>
|
||||
session
|
||||
? { data: authUser({ id: "signed-in" }) }
|
||||
: { status: 401, error: "Unauthorized" },
|
||||
);
|
||||
openAt(path);
|
||||
const view = await mountShell(null);
|
||||
await expect
|
||||
.poll(() => document.querySelector('[data-slot="page"]'))
|
||||
.toBeTruthy();
|
||||
return {
|
||||
...view,
|
||||
endSession: () => {
|
||||
session = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("returns to the page after a session that ended on its own", async () => {
|
||||
// Signed in once on this browser; the session has since expired while
|
||||
// the cookie stayed, so the next visit gets past the edge and the
|
||||
// account answer is the first to say so.
|
||||
const first = await signedInAt("/");
|
||||
first.endSession();
|
||||
await first.screen.unmount();
|
||||
openAt("/measurements?add=WEIGHT");
|
||||
await mountShell(null);
|
||||
|
||||
await expect.poll(() => window.location.pathname).toBe("/auth/login");
|
||||
expect(new URLSearchParams(window.location.search).get("next")).toBe(
|
||||
"/measurements?add=WEIGHT",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not carry the page past a deliberate sign-out", async () => {
|
||||
const { screen, queryClient, endSession } =
|
||||
await signedInAt("/medications");
|
||||
// What the sign-out does once the server confirmed it; the navigation
|
||||
// it starts renders the shell again, which asks for the account anew.
|
||||
endSession();
|
||||
clearCachesForSessionEnd(queryClient);
|
||||
await screen.rerender(shellTree(queryClient));
|
||||
|
||||
await expect.poll(() => window.location.pathname).toBe("/auth/login");
|
||||
expect(window.location.search).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* The "machine translated" strip is not part of signing in.
|
||||
*
|
||||
* It belongs to the app, where the language can be changed and a correction
|
||||
* reported; on the sign-in, registration and take-over pages it was the first
|
||||
* thing a visitor in eight of ten languages read, above the form.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithApp } from "../../../../tests/browser/render";
|
||||
import { stubFetch } from "../../../../tests/browser/fetch-stub";
|
||||
|
||||
// Chrome that is not the subject.
|
||||
vi.mock("../sidebar-nav", () => ({ SidebarNav: () => null }));
|
||||
vi.mock("../bottom-nav", () => ({ BottomNav: () => null }));
|
||||
vi.mock("../top-bar", () => ({ TopBar: () => null }));
|
||||
vi.mock("../offline-banner", () => ({ OfflineBanner: () => null }));
|
||||
vi.mock("../shared-record-banner", () => ({ SharedRecordBanner: () => null }));
|
||||
vi.mock("@/components/gamification/achievement-unlock-notifier", () => ({
|
||||
AchievementUnlockNotifier: () => null,
|
||||
}));
|
||||
vi.mock("@/components/insights/layout-coach-fab", () => ({
|
||||
LayoutCoachFab: () => null,
|
||||
}));
|
||||
vi.mock("@/components/insights/layout-coach-mount", () => ({
|
||||
LayoutCoachMount: () => null,
|
||||
}));
|
||||
vi.mock("@/components/onboarding/tour-launcher", () => ({
|
||||
TourLauncher: () => null,
|
||||
}));
|
||||
vi.mock("@/components/day/day-layer", () => ({ DayLayerMount: () => null }));
|
||||
vi.mock("@/components/command-palette/command-palette.lazy", () => ({
|
||||
CommandPaletteMount: () => null,
|
||||
}));
|
||||
vi.mock("../global-shortcuts", () => ({ GlobalShortcuts: () => null }));
|
||||
|
||||
import { AuthShell } from "../auth-shell";
|
||||
|
||||
const BANNER = '[data-slot="maintainership-banner"]';
|
||||
|
||||
async function mountAt(path: string, user: { id: string } | null) {
|
||||
window.history.replaceState(null, "", path);
|
||||
await renderWithApp(
|
||||
<AuthShell>
|
||||
<div data-slot="page" />
|
||||
</AuthShell>,
|
||||
{ user, locale: "fr" },
|
||||
);
|
||||
await expect
|
||||
.poll(() => document.querySelector('[data-slot="page"]'))
|
||||
.toBeTruthy();
|
||||
}
|
||||
|
||||
describe("translation notice", () => {
|
||||
it("is not shown on the sign-in page", async () => {
|
||||
stubFetch().on("GET", "/api/auth/me", {
|
||||
status: 401,
|
||||
error: "Unauthorized",
|
||||
});
|
||||
await mountAt("/auth/login", null);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(document.querySelector(BANNER)).toBeNull();
|
||||
});
|
||||
|
||||
it("is still shown inside the app", async () => {
|
||||
stubFetch();
|
||||
await mountAt("/", { id: "signed-in" });
|
||||
await expect.poll(() => document.querySelector(BANNER)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,17 @@ import { MaintainershipBanner } from "@/components/i18n/maintainership-banner";
|
||||
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
|
||||
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
||||
import { TourLauncher } from "@/components/onboarding/tour-launcher";
|
||||
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
clearCachesForSessionEnd,
|
||||
readWasAuthenticated,
|
||||
useAuth,
|
||||
} from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
|
||||
import { signInHref } from "@/lib/api/read-failure";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
|
||||
// The leaf module, not the `@/lib/record-settings` barrel. This shell wraps
|
||||
// every authenticated route, and the barrel also re-exports the managed
|
||||
// settings-patch schemas — so one destination predicate that reads a slug
|
||||
@@ -77,6 +83,92 @@ const PUBLIC_PATHS = [
|
||||
"/claim/",
|
||||
];
|
||||
|
||||
/**
|
||||
* `<main id="main-content">` is the app shell's one scroller and stays mounted
|
||||
* across routes, so its offset would carry from page to page, and the
|
||||
* browser's own restoration on Back moves only the document, never `<main>`.
|
||||
* A forward navigation starts at the top; Back and Forward return to the
|
||||
* offset that entry was left at.
|
||||
*/
|
||||
function useShellScrollMemory(pathname: string): void {
|
||||
// Recorded while scrolling: once the pathname has changed, `<main>` holds
|
||||
// the next page and the offset is gone.
|
||||
const offsets = useRef(new Map<string, number>());
|
||||
// Set by `popstate` for the entry it lands on, taken by the next render.
|
||||
const restore = useRef<{ url: string; top: number } | null>(null);
|
||||
useEffect(() => {
|
||||
const here = () => `${location.pathname}${location.search}`;
|
||||
const remember = (event: Event) => {
|
||||
const el = event.target;
|
||||
if (el instanceof HTMLElement && el.id === "main-content") {
|
||||
offsets.current.set(here(), el.scrollTop);
|
||||
}
|
||||
};
|
||||
const recall = () => {
|
||||
restore.current = { url: here(), top: offsets.current.get(here()) ?? 0 };
|
||||
};
|
||||
const capture = { capture: true, passive: true } as const;
|
||||
document.addEventListener("scroll", remember, capture);
|
||||
window.addEventListener("popstate", recall);
|
||||
return () => {
|
||||
document.removeEventListener("scroll", remember, capture);
|
||||
window.removeEventListener("popstate", recall);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const target = restore.current;
|
||||
restore.current = null;
|
||||
const main = document.getElementById("main-content");
|
||||
if (target?.url !== `${location.pathname}${location.search}`) {
|
||||
if (main) main.scrollTop = 0;
|
||||
// The body-scrolled shells (sign-in, setup, legal pages) scroll the
|
||||
// document instead.
|
||||
window.scrollTo(0, 0);
|
||||
return;
|
||||
}
|
||||
if (!main) return;
|
||||
// A list still painting its skeleton is shorter than the offset; keep
|
||||
// trying until it has the height, for at most a second.
|
||||
const until = performance.now() + 1000;
|
||||
let frame = 0;
|
||||
const apply = () => {
|
||||
main.scrollTop = target.top;
|
||||
if (main.scrollTop < target.top - 1 && performance.now() < until) {
|
||||
frame = requestAnimationFrame(apply);
|
||||
}
|
||||
};
|
||||
apply();
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [pathname]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A session that ended without passing through the shell's session-end wipe
|
||||
* (an expired cookie the edge sent straight to the sign-in page, a sign-in
|
||||
* page opened directly) left the previous account's offline copies on this
|
||||
* device. Clear them on every `/auth/*` page once the server has said there is
|
||||
* no session, before anyone signs in. Once per signed-out stretch, and never
|
||||
* on an answer that is not a verdict (offline, 5xx).
|
||||
*/
|
||||
function useSignInPageOfflineWipe(pathname: string): void {
|
||||
const { isAuthenticated, isAuthUnknown, isLoading } = useAuth();
|
||||
const cleared = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
cleared.current = false;
|
||||
} else if (
|
||||
!isLoading &&
|
||||
!isAuthUnknown &&
|
||||
pathname.startsWith("/auth/") &&
|
||||
!cleared.current
|
||||
) {
|
||||
cleared.current = true;
|
||||
void clearOfflineCachesForSessionEnd();
|
||||
}
|
||||
}, [isLoading, isAuthenticated, isAuthUnknown, pathname]);
|
||||
}
|
||||
|
||||
export function AuthShell({
|
||||
children,
|
||||
demoMode = false,
|
||||
@@ -181,63 +273,28 @@ export function AuthShell({
|
||||
isBodyScrolled ? "body" : rendersAppShell ? "shell" : null,
|
||||
);
|
||||
|
||||
// v1.25.1 — the authenticated shell keeps `<main id="main-content">`
|
||||
// mounted across every route change, so its scroll offset survives
|
||||
// navigation: scroll down in one section (e.g. Settings → Sources
|
||||
// Priority), hop to another, and the new view opens already scrolled.
|
||||
// Reset the scroll container to the top on every forward navigation.
|
||||
// A back/forward navigation skips the reset so the browser's own scroll
|
||||
// restoration stands: `popstate` arms a one-shot skip for the single
|
||||
// pathname change it triggers, and a macrotask clears the flag so it can
|
||||
// never get stuck on a later forward navigation.
|
||||
const skipNextScrollReset = useRef(false);
|
||||
useEffect(() => {
|
||||
const armSkip = () => {
|
||||
skipNextScrollReset.current = true;
|
||||
setTimeout(() => {
|
||||
skipNextScrollReset.current = false;
|
||||
}, 0);
|
||||
};
|
||||
window.addEventListener("popstate", armSkip);
|
||||
return () => window.removeEventListener("popstate", armSkip);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipNextScrollReset.current) {
|
||||
skipNextScrollReset.current = false;
|
||||
return;
|
||||
}
|
||||
const main = document.getElementById("main-content");
|
||||
if (main) {
|
||||
main.scrollTop = 0;
|
||||
}
|
||||
// Fallback for the body-scrolled shells (login / onboarding / standalone
|
||||
// legal pages) where the document, not `<main>`, owns the scroll.
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
useShellScrollMemory(pathname);
|
||||
|
||||
// Session END on a protected page (`/api/auth/me` answered 401/403): wipe
|
||||
// every client cache, since the QueryClient outlives navigation and health
|
||||
// data is not user-scoped, then go to the sign-in page. Logout does the same
|
||||
// from `useLogout`; this covers the expiry path it never runs through. No
|
||||
// verdict (`isAuthUnknown`: offline, 5xx, no marker) never wipes: a wipe on
|
||||
// a network failure is what used to destroy the offline caches.
|
||||
//
|
||||
// The target is fixed once per signed-out stretch, before the wipe clears
|
||||
// the marker it reads (the refetch that follows runs this again). A session
|
||||
// that ended on its own returns here after the next sign-in; a deliberate
|
||||
// sign-out already cleared the marker, so the next person gets a bare page.
|
||||
const signInTarget = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (isAuthenticated || isPublicPage) signInTarget.current = null;
|
||||
if (!isLoading && !isAuthenticated && !isPublicPage) {
|
||||
// Session END (expired / invalidated cookie → `/api/auth/me` 401/403).
|
||||
// Wipe the in-memory query cache (the cross-user guard — the
|
||||
// QueryClient outlives the navigation on this SPA and the health-data
|
||||
// families are not user-scoped) plus the persisted IndexedDB + SW
|
||||
// caches, so nothing leaks to the next account on a shared browser.
|
||||
// Logout does the same from `useLogout`; this covers the expiry path
|
||||
// logout never runs through. Best-effort; the redirect proceeds
|
||||
// regardless.
|
||||
//
|
||||
// `isAuthUnknown` = the auth probe failed WITHOUT a session verdict
|
||||
// (offline relaunch, network blip, 5xx) and no prior-session marker
|
||||
// exists. Nothing to protect and nothing to render — send the visitor
|
||||
// to the login gate, but never run the wipe off a non-verdict: the
|
||||
// wipe on a network failure is exactly what used to destroy the
|
||||
// offline caches. (With the marker present, `isAuthenticated` holds
|
||||
// the last-known state and this branch never runs.)
|
||||
if (!isAuthUnknown) {
|
||||
clearCachesForSessionEnd(queryClient);
|
||||
}
|
||||
router.replace("/auth/login");
|
||||
signInTarget.current ??= readWasAuthenticated()
|
||||
? signInHref()
|
||||
: "/auth/login";
|
||||
if (!isAuthUnknown) clearCachesForSessionEnd(queryClient);
|
||||
router.replace(signInTarget.current);
|
||||
}
|
||||
}, [
|
||||
isLoading,
|
||||
@@ -247,6 +304,7 @@ export function AuthShell({
|
||||
router,
|
||||
queryClient,
|
||||
]);
|
||||
useSignInPageOfflineWipe(pathname);
|
||||
|
||||
// Redirect non-admins away from /admin
|
||||
useEffect(() => {
|
||||
@@ -284,7 +342,6 @@ export function AuthShell({
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col pt-[env(safe-area-inset-top,0px)] pr-[env(safe-area-inset-right,0px)] pb-[env(safe-area-inset-bottom,0px)] pl-[env(safe-area-inset-left,0px)]">
|
||||
{demoMode ? <DemoBanner /> : null}
|
||||
<MaintainershipBanner />
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex flex-1 items-center justify-center px-4 py-6"
|
||||
|
||||
+16
-15
@@ -183,6 +183,11 @@ const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> =
|
||||
{ path: "/api/auth/login", method: "POST" },
|
||||
{ path: "/api/auth/passkey/login-options", method: "POST" },
|
||||
{ path: "/api/auth/passkey/login-verify", method: "POST" },
|
||||
// Each touches only this visitor: the session, the language cookie (the
|
||||
// route leaves the shared row alone under DEMO_MODE), a metrics log line.
|
||||
{ path: "/api/auth/logout", method: "POST" },
|
||||
{ path: "/api/auth/me/locale", method: "PUT" },
|
||||
{ path: "/api/internal/web-vitals", method: "POST" },
|
||||
// Chart display-pref toggles above the charts (dashboard + insights):
|
||||
{ path: "/api/dashboard/chart-overlay-prefs", method: "PUT" },
|
||||
{ path: "/api/dashboard/widgets", method: "PUT" },
|
||||
@@ -231,8 +236,7 @@ const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> =
|
||||
// The per-profile routes (`/api/managed-profiles/{id}`, its guardians)
|
||||
// are different paths and stay closed too.
|
||||
{ path: "/api/auth/profile", method: "PUT" },
|
||||
// v1.42.2 — the record search is a read that travels as a POST so the
|
||||
// query stays out of URLs and access logs. It writes nothing.
|
||||
// The record search: a read sent as a POST to keep the query out of logs.
|
||||
{ path: "/api/search", method: "POST" },
|
||||
];
|
||||
|
||||
@@ -473,7 +477,7 @@ export function proxy(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Demo mode: block all mutations except login
|
||||
// Demo mode: block every mutation outside the allowlist.
|
||||
if (process.env.DEMO_MODE === "true") {
|
||||
const method = request.method.toUpperCase();
|
||||
const isMutation =
|
||||
@@ -565,20 +569,17 @@ export function proxy(request: NextRequest) {
|
||||
const isSessionGatedPage = !isApiRoute && !isStaticFile && !isPublic;
|
||||
if (isSessionGatedPage) {
|
||||
if (!readSessionCookie(request.cookies)) {
|
||||
return applyBaselineSecurityHeaders(
|
||||
NextResponse.redirect(new URL("/auth/login", request.url)),
|
||||
);
|
||||
// Back here after signing in; the sign-in page re-checks `next`.
|
||||
const signIn = new URL("/auth/login", request.url);
|
||||
const here = `${pathname}${request.nextUrl.search}`;
|
||||
if (here !== "/") signIn.searchParams.set("next", here);
|
||||
return applyBaselineSecurityHeaders(NextResponse.redirect(signIn));
|
||||
}
|
||||
|
||||
// v1.4.22 — server-side onboarding redirect. Previously the
|
||||
// `<AuthShell>` ran this in a post-hydration `useEffect`, so a user
|
||||
// with `onboardingCompletedAt === null` briefly saw the dashboard
|
||||
// flash before the redirect fired. The auth flows (login, passkey,
|
||||
// register, /api/auth/me) keep an `hl_onboarding` cookie in sync
|
||||
// with the DB state; the proxy reads it without a DB roundtrip so
|
||||
// the redirect lands on the first server response. The /onboarding
|
||||
// page itself is in PUBLIC_PATHS above, so this branch only runs
|
||||
// for the surfaces that need to redirect away.
|
||||
// The onboarding redirect, here so it lands on the first response with no
|
||||
// dashboard flash. The auth flows (login, passkey, register,
|
||||
// /api/auth/me) keep the `hl_onboarding` cookie in step with the DB, so no
|
||||
// DB round trip; `/onboarding` itself is public above.
|
||||
const onboardingPending =
|
||||
request.cookies.get("hl_onboarding")?.value === "pending";
|
||||
// v1.4.22 — exact match the redirect
|
||||
|
||||
Reference in new issue
Block a user