diff --git a/www/app/(app)/browse/_components/FilterSidebar.tsx b/www/app/(app)/browse/_components/FilterSidebar.tsx index 8aa77095..6eef61b8 100644 --- a/www/app/(app)/browse/_components/FilterSidebar.tsx +++ b/www/app/(app)/browse/_components/FilterSidebar.tsx @@ -98,7 +98,7 @@ export default function FilterSidebar({ onFilterChange("live" as SourceKind, "")} + onClick={() => onFilterChange("live", "")} color={selectedSourceKind === "live" ? "blue.500" : "gray.600"} _hover={{ color: "blue.300" }} fontWeight={selectedSourceKind === "live" ? "bold" : "normal"} @@ -109,7 +109,7 @@ export default function FilterSidebar({ onFilterChange("file" as SourceKind, "")} + onClick={() => onFilterChange("file", "")} color={selectedSourceKind === "file" ? "blue.500" : "gray.600"} _hover={{ color: "blue.300" }} fontWeight={selectedSourceKind === "file" ? "bold" : "normal"} diff --git a/www/app/(app)/browse/page.tsx b/www/app/(app)/browse/page.tsx index 21e364bb..659588cc 100644 --- a/www/app/(app)/browse/page.tsx +++ b/www/app/(app)/browse/page.tsx @@ -42,7 +42,7 @@ import Pagination, { import TranscriptCards from "./_components/TranscriptCards"; import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog"; import { formatLocalDate } from "../../lib/time"; -import { RECORD_A_MEETING_URL } from "../../lib/constants"; +import { RECORD_A_MEETING_URL } from "../../api/urls"; const SEARCH_FORM_QUERY_INPUT_NAME = "query" as const; @@ -298,24 +298,11 @@ export default function TranscriptBrowser() { }; const handleProcessTranscript = (transcriptId: string) => { - processTranscript.mutate( - { - params: { - path: { transcript_id: transcriptId }, - }, + processTranscript.mutate({ + params: { + path: { transcript_id: transcriptId }, }, - { - onSuccess: (result) => { - const status = - result && typeof result === "object" && "status" in result - ? (result as { status: string }).status - : undefined; - if (status === "already running") { - // Note: setError is already handled in the hook - } - }, - }, - ); + }); }; const transcriptToDelete = results?.find( diff --git a/www/app/(app)/layout.tsx b/www/app/(app)/layout.tsx index 8822214b..801be28f 100644 --- a/www/app/(app)/layout.tsx +++ b/www/app/(app)/layout.tsx @@ -2,11 +2,9 @@ import { Container, Flex, Link } from "@chakra-ui/react"; import { getConfig } from "../lib/edgeConfig"; import NextLink from "next/link"; import Image from "next/image"; -import About from "../(aboutAndPrivacy)/about"; -import Privacy from "../(aboutAndPrivacy)/privacy"; import UserInfo from "../(auth)/userInfo"; import AuthWrapper from "./AuthWrapper"; -import { RECORD_A_MEETING_URL } from "../lib/constants"; +import { RECORD_A_MEETING_URL } from "../api/urls"; export default async function AppLayout({ children, diff --git a/www/app/(app)/transcripts/new/page.tsx b/www/app/(app)/transcripts/new/page.tsx index 50b80b17..a236c763 100644 --- a/www/app/(app)/transcripts/new/page.tsx +++ b/www/app/(app)/transcripts/new/page.tsx @@ -35,7 +35,9 @@ import { const TranscriptCreate = () => { const isClient = typeof window !== "undefined"; const router = useRouter(); - const { isLoading, isAuthenticated } = useSessionStatus(); + const status = useSessionStatus(); + const isAuthenticated = status === "authenticated"; + const isLoading = status === "loading"; const requireLogin = featureEnabled("requireLogin"); const [name, setName] = useState(""); diff --git a/www/app/(app)/transcripts/recorder.tsx b/www/app/(app)/transcripts/recorder.tsx index a06cee8b..2a81395a 100644 --- a/www/app/(app)/transcripts/recorder.tsx +++ b/www/app/(app)/transcripts/recorder.tsx @@ -6,12 +6,11 @@ import RecordPlugin from "../../lib/custom-plugins/record"; import { formatTime, formatTimeMs } from "../../lib/time"; import { waveSurferStyles } from "../../styles/recorder"; import { useError } from "../../(errors)/errorContext"; -import FileUploadButton from "./fileUploadButton"; import useWebRTC from "./useWebRTC"; import useAudioDevice from "./useAudioDevice"; import { Box, Flex, IconButton, Menu, RadioGroup } from "@chakra-ui/react"; import { LuScreenShare, LuMic, LuPlay, LuCircleStop } from "react-icons/lu"; -import { RECORD_A_MEETING_URL } from "../../lib/constants"; +import { RECORD_A_MEETING_URL } from "../../api/urls"; type RecorderProps = { transcriptId: string; diff --git a/www/app/(app)/transcripts/shareZulip.tsx b/www/app/(app)/transcripts/shareZulip.tsx index 3c3b1ea4..62ce1b2c 100644 --- a/www/app/(app)/transcripts/shareZulip.tsx +++ b/www/app/(app)/transcripts/shareZulip.tsx @@ -43,7 +43,6 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) { const [topic, setTopic] = useState(undefined); const [includeTopics, setIncludeTopics] = useState(false); - // React Query hooks const { data: streams = [], isLoading: isLoadingStreams } = useZulipStreams(); const { data: topics = [] } = useZulipTopics(selectedStreamId); const postToZulipMutation = useTranscriptPostToZulip(); diff --git a/www/app/(app)/transcripts/useMp3.ts b/www/app/(app)/transcripts/useMp3.ts index eccdd162..fdd813f7 100644 --- a/www/app/(app)/transcripts/useMp3.ts +++ b/www/app/(app)/transcripts/useMp3.ts @@ -2,6 +2,7 @@ import { useContext, useEffect, useState } from "react"; import { DomainContext } from "../../domainContext"; import { useTranscriptGet } from "../../lib/apiHooks"; import { useSession } from "next-auth/react"; +import { assertExtendedToken } from "../../lib/types"; export type Mp3Response = { media: HTMLMediaElement | null; @@ -21,9 +22,11 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => { const [audioDeleted, setAudioDeleted] = useState(null); const { api_url } = useContext(DomainContext); const { data: session } = useSession(); - const accessTokenInfo = (session as any)?.accessToken as string | undefined; + const sessionExtended = + session === null ? null : assertExtendedToken(session); + const accessTokenInfo = + sessionExtended === null ? null : sessionExtended.accessToken; - // Use React Query to fetch transcript metadata const { data: transcript, isLoading: transcriptMetadataLoading, diff --git a/www/app/(app)/transcripts/useSearchTranscripts.ts b/www/app/(app)/transcripts/useSearchTranscripts.ts deleted file mode 100644 index 5b5d6c1e..00000000 --- a/www/app/(app)/transcripts/useSearchTranscripts.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Wrapper for backward compatibility -import type { components } from "../../reflector-api"; -type SearchResult = components["schemas"]["SearchResult"]; -type SourceKind = components["schemas"]["SourceKind"]; -import { useTranscriptsSearch } from "../../lib/apiHooks"; -import { - PaginationPage, - paginationPageTo0Based, -} from "../browse/_components/Pagination"; - -interface SearchFilters { - roomIds: readonly string[] | null; - sourceKind: SourceKind | null; -} - -type UseSearchTranscriptsOptions = { - pageSize: number; - page: PaginationPage; -}; - -interface UseSearchTranscriptsReturn { - results: SearchResult[]; - totalCount: number; - isLoading: boolean; - error: unknown; - reload: () => void; -} - -export function useSearchTranscripts( - query: string = "", - filters: SearchFilters = { roomIds: null, sourceKind: null }, - options: UseSearchTranscriptsOptions = { - pageSize: 20, - page: PaginationPage(1), - }, -): UseSearchTranscriptsReturn { - const { pageSize, page } = options; - - const { data, isLoading, error, refetch } = useTranscriptsSearch(query, { - limit: pageSize, - offset: paginationPageTo0Based(page) * pageSize, - room_id: filters.roomIds?.[0], - source_kind: filters.sourceKind || undefined, - }); - - return { - results: data?.results || [], - totalCount: data?.total || 0, - isLoading, - error, - reload: refetch, - }; -} diff --git a/www/app/(app)/transcripts/useWebSockets.ts b/www/app/(app)/transcripts/useWebSockets.ts index 736362ed..3346a089 100644 --- a/www/app/(app)/transcripts/useWebSockets.ts +++ b/www/app/(app)/transcripts/useWebSockets.ts @@ -37,7 +37,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { const [status, setStatus] = useState({ value: "" }); const { setError } = useError(); - const { websocket_url } = useContext(DomainContext); + const { websocket_url: websocketUrl } = useContext(DomainContext); const queryClient = useQueryClient(); const [accumulatedText, setAccumulatedText] = useState(""); @@ -328,7 +328,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { if (!transcriptId) return; - const url = `${websocket_url}/v1/transcripts/${transcriptId}/events`; + const url = `${websocketUrl}/v1/transcripts/${transcriptId}/events`; let ws = new WebSocket(url); ws.onopen = () => { @@ -489,7 +489,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { return () => { ws.close(); }; - }, [transcriptId, websocket_url, queryClient]); + }, [transcriptId, websocketUrl, queryClient]); return { transcriptTextLive, diff --git a/www/app/(auth)/userInfo.tsx b/www/app/(auth)/userInfo.tsx index ffb286b3..90ba7be9 100644 --- a/www/app/(auth)/userInfo.tsx +++ b/www/app/(auth)/userInfo.tsx @@ -4,8 +4,9 @@ import useSessionStatus from "../lib/useSessionStatus"; import { Spinner, Link } from "@chakra-ui/react"; export default function UserInfo() { - const { isLoading, isAuthenticated } = useSessionStatus(); - + const status = useSessionStatus(); + const isLoading = status === "loading"; + const isAuthenticated = status === "authenticated"; return isLoading ? ( ) : !isAuthenticated ? ( diff --git a/www/app/lib/constants.ts b/www/app/api/urls.ts similarity index 68% rename from www/app/lib/constants.ts rename to www/app/api/urls.ts index 996da64c..89ce5af8 100644 --- a/www/app/lib/constants.ts +++ b/www/app/api/urls.ts @@ -1,2 +1 @@ -// Application-wide constants export const RECORD_A_MEETING_URL = "/transcripts/new" as const; diff --git a/www/app/lib/ApiAuthProvider.tsx b/www/app/lib/ApiAuthProvider.tsx index ae1701d2..6215408c 100644 --- a/www/app/lib/ApiAuthProvider.tsx +++ b/www/app/lib/ApiAuthProvider.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { configureApiAuth } from "./apiClient"; import useSessionAccessToken from "./useSessionAccessToken"; +// TODO should be context export function ApiAuthProvider({ children }: { children: React.ReactNode }) { const { accessToken } = useSessionAccessToken(); diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx index 5c5b2ca2..ad41012e 100644 --- a/www/app/lib/apiClient.tsx +++ b/www/app/lib/apiClient.tsx @@ -13,7 +13,7 @@ import createFetchClient from "openapi-react-query"; // Create the base openapi-fetch client with a default URL // The actual URL will be set via middleware in ApiAuthProvider export const client = createClient({ - baseUrl: "http://127.0.0.1:1250", + baseUrl: "http://192.0.2.1:1250", }); export const $api = createFetchClient(client); diff --git a/www/app/lib/auth.ts b/www/app/lib/auth.ts index d200587b..54c8c4ef 100644 --- a/www/app/lib/auth.ts +++ b/www/app/lib/auth.ts @@ -1,7 +1,15 @@ import { AuthOptions } from "next-auth"; import AuthentikProvider from "next-auth/providers/authentik"; import { JWT } from "next-auth/jwt"; -import { JWTWithAccessToken, CustomSession } from "./types"; +import { + JWTWithAccessToken, + CustomSession, + assertExtendedToken, +} from "./types"; +import { + assertExistsAndNonEmptyString, + parseMaybeNonEmptyString, +} from "./utils"; const PRETIMEOUT = 60; // seconds before token expires to refresh it @@ -15,11 +23,18 @@ const TOKEN_CACHE_TTL = 60 * 60 * 24 * 30 * 1000; // 30 days in milliseconds // Simple lock mechanism to prevent concurrent token refreshes const refreshLocks = new Map>(); +const CLIENT_ID = assertExistsAndNonEmptyString( + process.env.AUTHENTIK_CLIENT_ID, +); +const CLIENT_SECRET = assertExistsAndNonEmptyString( + process.env.AUTHENTIK_CLIENT_SECRET, +); + export const authOptions: AuthOptions = { providers: [ AuthentikProvider({ - clientId: process.env.AUTHENTIK_CLIENT_ID as string, - clientSecret: process.env.AUTHENTIK_CLIENT_SECRET as string, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, issuer: process.env.AUTHENTIK_ISSUER, authorization: { params: { @@ -33,23 +48,28 @@ export const authOptions: AuthOptions = { }, callbacks: { async jwt({ token, account, user }) { - const extendedToken = token as JWTWithAccessToken; + const extendedToken = assertExtendedToken(token); + const KEY = `token:${token.sub}`; if (account && user) { // called only on first login // XXX account.expires_in used in example is not defined for authentik backend, but expires_at is const expiresAt = (account.expires_at as number) - PRETIMEOUT; - const jwtToken: JWTWithAccessToken = { - ...extendedToken, - accessToken: account.access_token || "", - accessTokenExpires: expiresAt * 1000, - refreshToken: account.refresh_token || "", - }; - // Store in memory cache - tokenCache.set(`token:${jwtToken.sub}`, { - token: jwtToken, - timestamp: Date.now(), - }); - return jwtToken; + if (!account.access_token) { + tokenCache.delete(KEY); + } else { + const jwtToken: JWTWithAccessToken = { + ...extendedToken, + accessToken: account.access_token, + accessTokenExpires: expiresAt * 1000, + refreshToken: account.refresh_token || "", + }; + // Store in memory cache + tokenCache.set(`token:${jwtToken.sub}`, { + token: jwtToken, + timestamp: Date.now(), + }); + return jwtToken; + } } if (Date.now() < extendedToken.accessTokenExpires) { diff --git a/www/app/lib/types.ts b/www/app/lib/types.ts index 851ee5be..00c50820 100644 --- a/www/app/lib/types.ts +++ b/www/app/lib/types.ts @@ -1,5 +1,6 @@ import { Session } from "next-auth"; import { JWT } from "next-auth/jwt"; +import { parseMaybeNonEmptyString } from "./utils"; export interface JWTWithAccessToken extends JWT { accessToken: string; @@ -18,3 +19,28 @@ export interface CustomSession extends Session { email?: string | null; }; } + +// assumption that JWT is JWTWithAccessToken - not ideal, TODO find a reason we have to do that +export const assertExtendedToken = ( + t: T, +): T & { + accessTokenExpires: number; + accessToken: string; +} => { + if ( + typeof (t as { accessTokenExpires: any }).accessTokenExpires === "number" && + !isNaN((t as { accessTokenExpires: any }).accessTokenExpires) && + typeof ( + t as { + accessToken: any; + } + ).accessToken === "string" && + parseMaybeNonEmptyString((t as { accessToken: any }).accessToken) !== null + ) { + return t as T & { + accessTokenExpires: number; + accessToken: string; + }; + } + throw new Error("Token is not extended with access token"); +}; diff --git a/www/app/lib/useAuthReady.ts b/www/app/lib/useAuthReady.ts index ac85c808..fc3493b0 100644 --- a/www/app/lib/useAuthReady.ts +++ b/www/app/lib/useAuthReady.ts @@ -10,13 +10,16 @@ import { isAuthConfigured } from "./apiClient"; * Prevents race conditions where React Query fires requests before the token is set. */ export default function useAuthReady() { - const { status, isAuthenticated } = useSessionStatus(); + const status = useSessionStatus(); + const isAuthenticated = status === "authenticated"; const [authReady, setAuthReady] = useState(false); useEffect(() => { + let ready_ = false; // Check if both session is authenticated and token is configured const checkAuthReady = () => { const ready = isAuthenticated && isAuthConfigured(); + ready_ = ready; setAuthReady(ready); }; @@ -27,7 +30,14 @@ export default function useAuthReady() { const interval = setInterval(checkAuthReady, 100); // Stop checking after 2 seconds (auth should be ready by then) - const timeout = setTimeout(() => clearInterval(interval), 2000); + const timeout = setTimeout(() => { + if (ready_) { + clearInterval(interval); + return; + } else { + console.warn("Auth not ready after 2 seconds"); + } + }, 2000); return () => { clearInterval(interval); diff --git a/www/app/lib/useSessionStatus.ts b/www/app/lib/useSessionStatus.ts index a56691b2..62f02023 100644 --- a/www/app/lib/useSessionStatus.ts +++ b/www/app/lib/useSessionStatus.ts @@ -1,22 +1,8 @@ "use client"; -import { useState, useEffect } from "react"; import { useSession as useNextAuthSession } from "next-auth/react"; -import { Session } from "next-auth"; export default function useSessionStatus() { - const { status: naStatus } = useNextAuthSession(); - const [status, setStatus] = useState("loading"); - - useEffect(() => { - if (naStatus !== "loading" && naStatus !== status) { - setStatus(naStatus); - } - }, [naStatus]); - - return { - status, - isLoading: status === "loading", - isAuthenticated: status === "authenticated", - }; + const { status } = useNextAuthSession(); + return status; } diff --git a/www/app/lib/utils.ts b/www/app/lib/utils.ts index 80d0d91b..122ab234 100644 --- a/www/app/lib/utils.ts +++ b/www/app/lib/utils.ts @@ -137,9 +137,28 @@ export function extractDomain(url) { } } -export function assertExists(value: T | null | undefined, err?: string): T { +export type NonEmptyString = string & { __brand: "NonEmptyString" }; +export const parseMaybeNonEmptyString = ( + s: string, + trim = true, +): NonEmptyString | null => { + s = trim ? s.trim() : s; + return s.length > 0 ? (s as NonEmptyString) : null; +}; +export const parseNonEmptyString = (s: string, trim = true): NonEmptyString => + assertExists(parseMaybeNonEmptyString(s, trim), "Expected non-empty string"); + +export const assertExists = ( + value: T | null | undefined, + err?: string, +): T => { if (value === null || value === undefined) { throw new Error(`Assertion failed: ${err ?? "value is null or undefined"}`); } return value; -} +}; + +export const assertExistsAndNonEmptyString = ( + value: string | null | undefined, +): NonEmptyString => + parseNonEmptyString(assertExists(value, "Expected non-empty string")); diff --git a/www/app/page.tsx b/www/app/page.tsx index 07da398f..225fe877 100644 --- a/www/app/page.tsx +++ b/www/app/page.tsx @@ -1,6 +1,6 @@ "use client"; import { redirect } from "next/navigation"; -import { RECORD_A_MEETING_URL } from "./lib/constants"; +import { RECORD_A_MEETING_URL } from "./api/urls"; export default function Index() { redirect(RECORD_A_MEETING_URL); diff --git a/www/public/service-worker.js b/www/public/service-worker.js index 109561d5..e798e369 100644 --- a/www/public/service-worker.js +++ b/www/public/service-worker.js @@ -1,4 +1,4 @@ -let authToken = ""; // Variable to store the token +let authToken = null; self.addEventListener("message", (event) => { if (event.data && event.data.type === "SET_AUTH_TOKEN") {