fix: prevent unauthorized API calls before authentication

- Add global AuthGuard component to handle authentication at layout level
- Make all API query hooks conditional on authentication status
- Define public routes (like /transcripts/new) that don't require auth
- Fix login flow to use NextAuth signIn instead of non-existent /login route
- Prevent 401 errors by waiting for auth token before making API calls

Previously, all routes under (app) were publicly accessible with each page
handling auth individually. Now authentication is enforced globally while
still allowing specific routes to remain public.
This commit is contained in:
2025-08-28 15:35:49 -06:00
parent 0eac7501c5
commit 26154af25c
5 changed files with 144 additions and 79 deletions

View File

@@ -0,0 +1,70 @@
"use client";
import { useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import { signIn } from "next-auth/react";
import useSessionStatus from "../lib/useSessionStatus";
import { Flex, Spinner } from "@chakra-ui/react";
interface AuthGuardProps {
children: React.ReactNode;
requireAuth?: boolean;
}
// Routes that should be accessible without authentication
const PUBLIC_ROUTES = ["/transcripts/new"];
export default function AuthGuard({
children,
requireAuth = true,
}: AuthGuardProps) {
const { isAuthenticated, isLoading, status } = useSessionStatus();
const router = useRouter();
const pathname = usePathname();
// Check if current route is public
const isPublicRoute = PUBLIC_ROUTES.some((route) =>
pathname.startsWith(route),
);
useEffect(() => {
// Don't require auth for public routes
if (isPublicRoute) return;
// Only redirect if we're sure the user is not authenticated and auth is required
if (!isLoading && requireAuth && status === "unauthenticated") {
// Instead of redirecting to /login, trigger NextAuth signIn
signIn("authentik");
}
}, [isLoading, requireAuth, status, isPublicRoute]);
// For public routes, always show content
if (isPublicRoute) {
return <>{children}</>;
}
// Show loading spinner while checking authentication
if (
isLoading ||
(requireAuth && !isAuthenticated && status !== "unauthenticated")
) {
return (
<Flex
flexDir="column"
alignItems="center"
justifyContent="center"
h="100%"
>
<Spinner size="xl" />
</Flex>
);
}
// If authentication is not required or user is authenticated, show content
if (!requireAuth || isAuthenticated) {
return <>{children}</>;
}
// Don't render anything while redirecting
return null;
}

View File

@@ -6,6 +6,7 @@ import About from "../(aboutAndPrivacy)/about";
import Privacy from "../(aboutAndPrivacy)/privacy";
import UserInfo from "../(auth)/userInfo";
import { RECORD_A_MEETING_URL } from "../lib/constants";
import AuthGuard from "./AuthGuard";
export default async function AppLayout({
children,
@@ -90,7 +91,7 @@ export default async function AppLayout({
</div>
</Flex>
{children}
<AuthGuard requireAuth={requireLogin}>{children}</AuthGuard>
</Container>
);
}

View File

@@ -1,53 +1,13 @@
"use client";
import { useEffect, useContext, useRef } from "react";
import { client, configureApiAuth } from "./apiClient";
import { useEffect } from "react";
import { configureApiAuth } from "./apiClient";
import useSessionAccessToken from "./useSessionAccessToken";
import { DomainContext } from "../domainContext";
// Store the current API URL globally
let currentApiUrl: string | null = null;
// Set up base URL middleware once
const baseUrlMiddlewareSetup = () => {
client.use({
onRequest({ request }) {
if (currentApiUrl) {
// Update the base URL for all requests
const url = new URL(request.url);
const apiUrl = new URL(currentApiUrl);
url.protocol = apiUrl.protocol;
url.host = apiUrl.host;
url.port = apiUrl.port;
return new Request(url.toString(), request);
}
return request;
},
});
};
// Initialize base URL middleware once
if (typeof window !== "undefined") {
baseUrlMiddlewareSetup();
}
// Note: Base URL is now configured directly in apiClient.tsx
export function ApiAuthProvider({ children }: { children: React.ReactNode }) {
const { accessToken } = useSessionAccessToken();
const { api_url } = useContext(DomainContext);
const initialized = useRef(false);
// Initialize middleware once on client side
useEffect(() => {
if (!initialized.current && typeof window !== "undefined") {
baseUrlMiddlewareSetup();
initialized.current = true;
}
}, []);
useEffect(() => {
// Update the global API URL
currentApiUrl = api_url;
}, [api_url]);
useEffect(() => {
// Configure authentication

View File

@@ -4,16 +4,26 @@ import { $api } from "./apiClient";
import { useError } from "../(errors)/errorContext";
import { useQueryClient } from "@tanstack/react-query";
import type { paths } from "../reflector-api";
import useSessionStatus from "./useSessionStatus";
// Rooms hooks
export function useRoomsList(page: number = 1) {
const { setError } = useError();
const { isAuthenticated, isLoading } = useSessionStatus();
return $api.useQuery("get", "/v1/rooms", {
params: {
query: { page },
return $api.useQuery(
"get",
"/v1/rooms",
{
params: {
query: { page },
},
},
});
{
// Only fetch when authenticated
enabled: isAuthenticated && !isLoading,
},
);
}
// Transcripts hooks
@@ -27,18 +37,27 @@ export function useTranscriptsSearch(
} = {},
) {
const { setError } = useError();
const { isAuthenticated, isLoading } = useSessionStatus();
return $api.useQuery("get", "/v1/transcripts/search", {
params: {
query: {
q,
limit: options.limit,
offset: options.offset,
room_id: options.room_id,
source_kind: options.source_kind as any,
return $api.useQuery(
"get",
"/v1/transcripts/search",
{
params: {
query: {
q,
limit: options.limit,
offset: options.offset,
room_id: options.room_id,
source_kind: options.source_kind as any,
},
},
},
});
{
// Only fetch when authenticated
enabled: isAuthenticated && !isLoading,
},
);
}
export function useTranscriptDelete() {
@@ -72,6 +91,7 @@ export function useTranscriptProcess() {
export function useTranscriptGet(transcriptId: string | null) {
const { setError } = useError();
const { isAuthenticated, isLoading } = useSessionStatus();
return $api.useQuery(
"get",
@@ -84,7 +104,8 @@ export function useTranscriptGet(transcriptId: string | null) {
},
},
{
enabled: !!transcriptId,
// Only fetch when authenticated and transcriptId is provided
enabled: !!transcriptId && isAuthenticated && !isLoading,
},
);
}
@@ -141,25 +162,32 @@ export function useRoomDelete() {
// Zulip hooks - NOTE: These endpoints are not in the OpenAPI spec yet
export function useZulipStreams() {
const { setError } = useError();
// @ts-ignore - Zulip endpoint not in OpenAPI spec
return $api.useQuery("get", "/v1/zulip/get-streams" as any, {});
}
export function useZulipTopics(streamId: number | null) {
const { setError } = useError();
const { isAuthenticated, isLoading } = useSessionStatus();
// @ts-ignore - Zulip endpoint not in OpenAPI spec
return $api.useQuery(
"get",
"/v1/zulip/get-topics" as any,
"/v1/zulip/streams" as any,
{},
{
params: {
query: { stream_id: streamId || 0 },
},
// Only fetch when authenticated
enabled: isAuthenticated && !isLoading,
},
);
}
export function useZulipTopics(streamId: number | null) {
const { setError } = useError();
const { isAuthenticated, isLoading } = useSessionStatus();
// @ts-ignore - Zulip endpoint not in OpenAPI spec
return $api.useQuery(
"get",
streamId ? (`/v1/zulip/streams/${streamId}/topics` as any) : null,
{},
{
enabled: !!streamId,
// Only fetch when authenticated and streamId is provided
enabled: !!streamId && isAuthenticated && !isLoading,
},
);
}
@@ -233,6 +261,7 @@ export function useTranscriptUploadAudio() {
// Transcript queries
export function useTranscriptWaveform(transcriptId: string | null) {
const { setError } = useError();
const { isAuthenticated, isLoading } = useSessionStatus();
return $api.useQuery(
"get",
@@ -243,13 +272,14 @@ export function useTranscriptWaveform(transcriptId: string | null) {
},
},
{
enabled: !!transcriptId,
enabled: !!transcriptId && isAuthenticated && !isLoading,
},
);
}
export function useTranscriptMP3(transcriptId: string | null) {
const { setError } = useError();
const { isAuthenticated, isLoading } = useSessionStatus();
return $api.useQuery(
"get",
@@ -260,13 +290,14 @@ export function useTranscriptMP3(transcriptId: string | null) {
},
},
{
enabled: !!transcriptId,
enabled: !!transcriptId && isAuthenticated && !isLoading,
},
);
}
export function useTranscriptTopics(transcriptId: string | null) {
const { setError } = useError();
const { isAuthenticated, isLoading } = useSessionStatus();
return $api.useQuery(
"get",
@@ -277,13 +308,14 @@ export function useTranscriptTopics(transcriptId: string | null) {
},
},
{
enabled: !!transcriptId,
enabled: !!transcriptId && isAuthenticated && !isLoading,
},
);
}
export function useTranscriptTopicsWithWords(transcriptId: string | null) {
const { setError } = useError();
const { isAuthenticated, isLoading } = useSessionStatus();
return $api.useQuery(
"get",
@@ -294,7 +326,7 @@ export function useTranscriptTopicsWithWords(transcriptId: string | null) {
},
},
{
enabled: !!transcriptId,
enabled: !!transcriptId && isAuthenticated && !isLoading,
},
);
}
@@ -304,6 +336,7 @@ export function useTranscriptTopicsWithWordsPerSpeaker(
topicId: string | null,
) {
const { setError } = useError();
const { isAuthenticated, isLoading } = useSessionStatus();
return $api.useQuery(
"get",
@@ -317,7 +350,7 @@ export function useTranscriptTopicsWithWordsPerSpeaker(
},
},
{
enabled: !!transcriptId && !!topicId,
enabled: !!transcriptId && !!topicId && isAuthenticated && !isLoading,
},
);
}
@@ -325,6 +358,7 @@ export function useTranscriptTopicsWithWordsPerSpeaker(
// Participant operations
export function useTranscriptParticipants(transcriptId: string | null) {
const { setError } = useError();
const { isAuthenticated, isLoading } = useSessionStatus();
return $api.useQuery(
"get",
@@ -335,7 +369,7 @@ export function useTranscriptParticipants(transcriptId: string | null) {
},
},
{
enabled: !!transcriptId,
enabled: !!transcriptId && isAuthenticated && !isLoading,
},
);
}

View File

@@ -10,10 +10,10 @@ import {
} from "@tanstack/react-query";
import createFetchClient from "openapi-react-query";
// Create the base openapi-fetch client
// Create the base openapi-fetch client with a default URL
// The actual URL will be set via middleware in ApiAuthProvider
export const client = createClient<paths>({
// Base URL will be set dynamically via middleware
baseUrl: "",
baseUrl: "http://127.0.0.1:1250",
headers: {
"Content-Type": "application/json",
},