Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 4 additions & 21 deletions src/app/api/setup-status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
import { env, isDemoMode } from "@/lib/env";
import { localProjectRequestIdentityPayload, unsafeLocalProjectResponse } from "@/lib/local-project-guard";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
import { formatSupabaseUnavailableError, isSupabaseUnavailableError, probeSupabaseHealth } from "@/lib/supabase/health";
import { checkSupabaseProjectConfig, formatSupabaseProjectCheck } from "@/lib/supabase/project";

Expand Down Expand Up @@ -409,27 +408,11 @@ async function readSetupStatusPayload() {
}
}

async function requireProductionSetupStatusAuth(request: Request) {
export async function GET(request: Request) {
const identity = localProjectRequestIdentityPayload(request);
if (process.env.NODE_ENV !== "production" || identity.localServer.currentUrl) {
return identity;
if (!identity.localServer.safeLocalOrigin) {
return unsafeLocalProjectResponse(identity);
}
await requireAuthenticatedUser(request, createAdminClient());
return identity;
}

export async function GET(request: Request) {
try {
const identity = await requireProductionSetupStatusAuth(request);
if (!identity.localServer.safeLocalOrigin) {
return unsafeLocalProjectResponse(identity);
}

return setupStatusResponse(await readSetupStatusPayload());
} catch (error) {
if (error instanceof AuthenticationError) {
return unauthorizedResponse(error);
}
throw error;
}
return setupStatusResponse(await readSetupStatusPayload());
}
27 changes: 16 additions & 11 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3586,17 +3586,17 @@ export function ClinicalDashboard({
const showDegradedNotice = !isOnline || apiUnavailable;
const hasMobileBottomSearch = searchMode !== "answer";
const showDesktopHomeComposer =
!loading &&
!error &&
((activeModeResultKind === "answer" && !answer && !modeSearchSubmitted) ||
(searchMode === "documents" &&
activeModeResultKind === "documents" &&
documentMatches.length === 0 &&
!modeSearchSubmitted) ||
(searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) ||
(activeModeResultKind === "differentials" && !modeSearchSubmitted) ||
(activeModeResultKind === "tools" ||
activeModeResultKind === "favourites" ||
activeModeResultKind === "tools");
(!loading &&
((activeModeResultKind === "answer" && !answer && !modeSearchSubmitted) ||
(searchMode === "documents" &&
activeModeResultKind === "documents" &&
documentMatches.length === 0 &&
!modeSearchSubmitted) ||
(searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) ||
(activeModeResultKind === "differentials" && !modeSearchSubmitted))));
const desktopHomeComposerSlotId = showDesktopHomeComposer ? modeHomeDesktopComposerSlotId : undefined;
// Favourites and Tools are content-rich hubs: they share the centred hero but
// stay top-aligned so their lists start in a stable position.
Expand All @@ -3614,21 +3614,26 @@ export function ClinicalDashboard({
const compactMobileBottomSearch = hasMobileBottomSearch && modeSearchSubmitted;
const differentialsCompareAddonActive =
searchMode === "differentials" && modeSearchSubmitted && Boolean(query.trim());
const isDeployedApp = process.env.NODE_ENV === "production";
const renderDegradedNotice = () => (
<UtilityDrawer
icon={!isOnline ? WifiOff : AlertCircle}
title={!isOnline ? "Offline" : "Service unavailable"}
summary={
!isOnline
? "Your browser is offline. Existing content may remain visible, but private search and uploads need network access."
: "The local API did not respond. Check the app server and setup status before retrying."
: isDeployedApp
? "The app could not reach its API. Try again in a moment."
: "The local API did not respond. Check the app server and setup status before retrying."
}
mobileSummary={!isOnline ? "Offline" : "API unavailable"}
>
<p className="text-[15px] leading-6 text-[color:var(--warning)]">
{!isOnline
? "Reconnect before uploading documents, refreshing source URLs, or generating answers."
: "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."}
: isDeployedApp
? "The app will preserve the current view. If this keeps happening, check your connection and try again shortly."
: "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."}
</p>
</UtilityDrawer>
);
Expand Down
34 changes: 25 additions & 9 deletions tests/setup-status-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ afterEach(() => {
});

describe("/api/setup-status", () => {
it("requires auth for non-local production requests before returning setup posture", async () => {
it("returns setup posture for anonymous production requests without exposing secret values", async () => {
vi.stubEnv("NODE_ENV", "production");
const getUser = vi.fn();
const createAdminClient = vi.fn(() => ({ auth: { getUser } }));
const from = vi.fn(async () => ({ error: null, data: [], count: 0 }));
const createAdminClient = vi.fn(() => ({
from,
rpc: vi.fn(),
}));
vi.doMock("@/lib/env", () => ({
env: {
NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co",
Expand All @@ -24,16 +27,29 @@ describe("/api/setup-status", () => {
isLocalNoAuthMode: () => false,
}));
vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient }));
vi.doMock("@/lib/supabase/health", () => ({
probeSupabaseHealth: vi.fn(async () => ({ ok: true })),
isSupabaseUnavailableError: () => false,
formatSupabaseUnavailableError: (error: unknown) => String(error),
}));
vi.doMock("@/lib/supabase/project", () => ({
checkSupabaseProjectConfig: () => ({ status: "ready", detail: "Clinical KB Database target is configured." }),
formatSupabaseProjectCheck: () => "Clinical KB Database target is configured.",
}));
const { GET } = await import("../src/app/api/setup-status/route");

const response = await GET(new Request("https://clinical.example/api/setup-status"));
const body = await response.json();

expect(response.status).toBe(401);
expect(body).toEqual({ error: "Authentication required." });
expect(JSON.stringify(body)).not.toContain("OPENAI");
expect(JSON.stringify(body)).not.toContain("Supabase");
expect(createAdminClient).toHaveBeenCalledTimes(1);
expect(getUser).not.toHaveBeenCalled();
expect(response.status).toBe(200);
expect(body).toMatchObject({
demoMode: false,
checks: expect.arrayContaining([
expect.objectContaining({ id: "env" }),
expect.objectContaining({ id: "openai" }),
]),
});
expect(JSON.stringify(body)).not.toContain("service-role-key");
expect(JSON.stringify(body)).not.toContain("openai-key");
});
});