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") {