Merge branch 'main' into mathieu/calendar-integration-rebased

This commit is contained in:
Igor Monadical
2025-09-12 13:10:39 -04:00
committed by GitHub
50 changed files with 3034 additions and 2548 deletions

View File

@@ -1,5 +1,5 @@
import { Container, Flex, Link } from "@chakra-ui/react";
import { getConfig } from "../lib/edgeConfig";
import { featureEnabled } from "../lib/features";
import NextLink from "next/link";
import Image from "next/image";
import UserInfo from "../(auth)/userInfo";
@@ -11,8 +11,6 @@ export default async function AppLayout({
}: {
children: React.ReactNode;
}) {
const config = await getConfig();
const { requireLogin, privacy, browse, rooms } = config.features;
return (
<Container
minW="100vw"
@@ -58,7 +56,7 @@ export default async function AppLayout({
>
Create
</Link>
{browse ? (
{featureEnabled("browse") ? (
<>
&nbsp;·&nbsp;
<Link href="/browse" as={NextLink} className="font-light px-2">
@@ -68,7 +66,7 @@ export default async function AppLayout({
) : (
<></>
)}
{rooms ? (
{featureEnabled("rooms") ? (
<>
&nbsp;·&nbsp;
<Link href="/rooms" as={NextLink} className="font-light px-2">
@@ -78,7 +76,7 @@ export default async function AppLayout({
) : (
<></>
)}
{requireLogin ? (
{featureEnabled("requireLogin") ? (
<>
&nbsp;·&nbsp;
<UserInfo />

View File

@@ -3,10 +3,11 @@ import ScrollToBottom from "../../scrollToBottom";
import { Topic } from "../../webSocketTypes";
import useParticipants from "../../useParticipants";
import { Box, Flex, Text, Accordion } from "@chakra-ui/react";
import { featureEnabled } from "../../../../domainContext";
import { TopicItem } from "./TopicItem";
import { TranscriptStatus } from "../../../../lib/transcript";
import { featureEnabled } from "../../../../lib/features";
type TopicListProps = {
topics: Topic[];
useActiveTopic: [

View File

@@ -1,5 +1,5 @@
"use client";
import { useState } from "react";
import { useState, use } from "react";
import TopicHeader from "./topicHeader";
import TopicWords from "./topicWords";
import TopicPlayer from "./topicPlayer";
@@ -18,14 +18,16 @@ import { useRouter } from "next/navigation";
import { Box, Grid } from "@chakra-ui/react";
export type TranscriptCorrect = {
params: {
params: Promise<{
transcriptId: string;
};
}>;
};
export default function TranscriptCorrect({
params: { transcriptId },
}: TranscriptCorrect) {
export default function TranscriptCorrect(props: TranscriptCorrect) {
const params = use(props.params);
const { transcriptId } = params;
const updateTranscriptMutation = useTranscriptUpdate();
const transcript = useTranscriptGet(transcriptId);
const stateCurrentTopic = useState<GetTranscriptTopic>();

View File

@@ -5,7 +5,7 @@ import useWaveform from "../useWaveform";
import useMp3 from "../useMp3";
import { TopicList } from "./_components/TopicList";
import { Topic } from "../webSocketTypes";
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, use } from "react";
import FinalSummary from "./finalSummary";
import TranscriptTitle from "../transcriptTitle";
import Player from "../player";
@@ -15,13 +15,14 @@ import { useTranscriptGet } from "../../../lib/apiHooks";
import { TranscriptStatus } from "../../../lib/transcript";
type TranscriptDetails = {
params: {
params: Promise<{
transcriptId: string;
};
}>;
};
export default function TranscriptDetails(details: TranscriptDetails) {
const transcriptId = details.params.transcriptId;
const params = use(details.params);
const transcriptId = params.transcriptId;
const router = useRouter();
const statusToRedirect = [
"idle",
@@ -43,7 +44,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
useEffect(() => {
if (waiting) {
const newUrl = "/transcripts/" + details.params.transcriptId + "/record";
const newUrl = "/transcripts/" + params.transcriptId + "/record";
// Shallow redirection does not work on NextJS 13
// https://github.com/vercel/next.js/discussions/48110
// https://github.com/vercel/next.js/discussions/49540

View File

@@ -1,5 +1,5 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, use } from "react";
import Recorder from "../../recorder";
import { TopicList } from "../_components/TopicList";
import { useWebSockets } from "../../useWebSockets";
@@ -14,19 +14,20 @@ import { useTranscriptGet } from "../../../../lib/apiHooks";
import { TranscriptStatus } from "../../../../lib/transcript";
type TranscriptDetails = {
params: {
params: Promise<{
transcriptId: string;
};
}>;
};
const TranscriptRecord = (details: TranscriptDetails) => {
const transcript = useTranscriptGet(details.params.transcriptId);
const params = use(details.params);
const transcript = useTranscriptGet(params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false);
const useActiveTopic = useState<Topic | null>(null);
const webSockets = useWebSockets(details.params.transcriptId);
const webSockets = useWebSockets(params.transcriptId);
const mp3 = useMp3(details.params.transcriptId, true);
const mp3 = useMp3(params.transcriptId, true);
const router = useRouter();
@@ -47,7 +48,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting");
const newUrl = "/transcripts/" + details.params.transcriptId;
const newUrl = "/transcripts/" + params.transcriptId;
router.replace(newUrl);
}
}, [webSockets.status?.value, transcript.data?.status]);
@@ -75,7 +76,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
<WaveformLoading />
) : (
// todo: only start recording animation when you get "recorded" status
<Recorder transcriptId={details.params.transcriptId} status={status} />
<Recorder transcriptId={params.transcriptId} status={status} />
)}
<VStack
align={"left"}
@@ -98,7 +99,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
topics={webSockets.topics}
useActiveTopic={useActiveTopic}
autoscroll={true}
transcriptId={details.params.transcriptId}
transcriptId={params.transcriptId}
status={status}
currentTranscriptText={webSockets.accumulatedText}
/>

View File

@@ -1,5 +1,5 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, use } from "react";
import { useWebSockets } from "../../useWebSockets";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation";
@@ -9,18 +9,19 @@ import FileUploadButton from "../../fileUploadButton";
import { useTranscriptGet } from "../../../../lib/apiHooks";
type TranscriptUpload = {
params: {
params: Promise<{
transcriptId: string;
};
}>;
};
const TranscriptUpload = (details: TranscriptUpload) => {
const transcript = useTranscriptGet(details.params.transcriptId);
const params = use(details.params);
const transcript = useTranscriptGet(params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false);
const webSockets = useWebSockets(details.params.transcriptId);
const webSockets = useWebSockets(params.transcriptId);
const mp3 = useMp3(details.params.transcriptId, true);
const mp3 = useMp3(params.transcriptId, true);
const router = useRouter();
@@ -50,7 +51,7 @@ const TranscriptUpload = (details: TranscriptUpload) => {
if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting");
const newUrl = "/transcripts/" + details.params.transcriptId;
const newUrl = "/transcripts/" + params.transcriptId;
router.replace(newUrl);
}
}, [webSockets.status?.value, transcript.data?.status]);
@@ -84,7 +85,7 @@ const TranscriptUpload = (details: TranscriptUpload) => {
Please select the file, supported formats: .mp3, m4a, .wav,
.mp4, .mov or .webm
</Text>
<FileUploadButton transcriptId={details.params.transcriptId} />
<FileUploadButton transcriptId={params.transcriptId} />
</>
)}
{status && status == "uploaded" && (

View File

@@ -9,7 +9,6 @@ import { useRouter } from "next/navigation";
import useCreateTranscript from "../createTranscript";
import SelectSearch from "react-select-search";
import { supportedLanguages } from "../../../supportedLanguages";
import { featureEnabled } from "../../../domainContext";
import {
Flex,
Box,
@@ -21,10 +20,9 @@ import {
Spacer,
} from "@chakra-ui/react";
import { useAuth } from "../../../lib/AuthProvider";
import type { components } from "../../../reflector-api";
import { featureEnabled } from "../../../lib/features";
const TranscriptCreate = () => {
const isClient = typeof window !== "undefined";
const router = useRouter();
const auth = useAuth();
const isAuthenticated = auth.status === "authenticated";
@@ -176,7 +174,7 @@ const TranscriptCreate = () => {
placeholder="Choose your language"
/>
</Box>
{isClient && !loading ? (
{!loading ? (
permissionOk ? (
<Spacer />
) : permissionDenied ? (

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from "react";
import { featureEnabled } from "../../domainContext";
import { ShareMode, toShareMode } from "../../lib/shareMode";
import type { components } from "../../reflector-api";
@@ -24,6 +23,8 @@ import ShareCopy from "./shareCopy";
import ShareZulip from "./shareZulip";
import { useAuth } from "../../lib/AuthProvider";
import { featureEnabled } from "../../lib/features";
type ShareAndPrivacyProps = {
finalSummaryRef: any;
transcriptResponse: GetTranscript;

View File

@@ -1,8 +1,9 @@
import React, { useState, useRef, useEffect, use } from "react";
import { featureEnabled } from "../../domainContext";
import { Button, Flex, Input, Text } from "@chakra-ui/react";
import QRCode from "react-qr-code";
import { featureEnabled } from "../../lib/features";
type ShareLinkProps = {
transcriptId: string;
};

View File

@@ -1,5 +1,4 @@
import { useState, useEffect, useMemo } from "react";
import { featureEnabled } from "../../domainContext";
import type { components } from "../../reflector-api";
type GetTranscript = components["schemas"]["GetTranscript"];
@@ -25,6 +24,8 @@ import {
useTranscriptPostToZulip,
} from "../../lib/apiHooks";
import { featureEnabled } from "../../lib/features";
type ShareZulipProps = {
transcriptResponse: GetTranscript;
topicsResponse: GetTranscriptTopic[];

View File

@@ -1,7 +1,7 @@
import { useContext, useEffect, useState } from "react";
import { DomainContext } from "../../domainContext";
import { useEffect, useState } from "react";
import { useTranscriptGet } from "../../lib/apiHooks";
import { useAuth } from "../../lib/AuthProvider";
import { API_URL } from "../../lib/apiClient";
export type Mp3Response = {
media: HTMLMediaElement | null;
@@ -19,7 +19,6 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
null,
);
const [audioDeleted, setAudioDeleted] = useState<boolean | null>(null);
const { api_url } = useContext(DomainContext);
const auth = useAuth();
const accessTokenInfo =
auth.status === "authenticated" ? auth.accessToken : null;
@@ -78,7 +77,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
// Audio is not deleted, proceed to load it
audioElement = document.createElement("audio");
audioElement.src = `${api_url}/v1/transcripts/${transcriptId}/audio/mp3`;
audioElement.src = `${API_URL}/v1/transcripts/${transcriptId}/audio/mp3`;
audioElement.crossOrigin = "anonymous";
audioElement.preload = "auto";
@@ -110,7 +109,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
if (handleError) audioElement.removeEventListener("error", handleError);
}
};
}, [transcriptId, transcript, later, api_url]);
}, [transcriptId, transcript, later]);
const getNow = () => {
setLater(false);

View File

@@ -1,13 +1,12 @@
import { useContext, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { Topic, FinalSummary, Status } from "./webSocketTypes";
import { useError } from "../../(errors)/errorContext";
import { DomainContext } from "../../domainContext";
import type { components } from "../../reflector-api";
type AudioWaveform = components["schemas"]["AudioWaveform"];
type GetTranscriptSegmentTopic =
components["schemas"]["GetTranscriptSegmentTopic"];
import { useQueryClient } from "@tanstack/react-query";
import { $api } from "../../lib/apiClient";
import { $api, WEBSOCKET_URL } from "../../lib/apiClient";
export type UseWebSockets = {
transcriptTextLive: string;
@@ -37,7 +36,6 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
const [status, setStatus] = useState<Status | null>(null);
const { setError } = useError();
const { websocket_url: websocketUrl } = useContext(DomainContext);
const queryClient = useQueryClient();
const [accumulatedText, setAccumulatedText] = useState<string>("");
@@ -328,7 +326,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
if (!transcriptId) return;
const url = `${websocketUrl}/v1/transcripts/${transcriptId}/events`;
const url = `${WEBSOCKET_URL}/v1/transcripts/${transcriptId}/events`;
let ws = new WebSocket(url);
ws.onopen = () => {
@@ -494,7 +492,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
return () => {
ws.close();
};
}, [transcriptId, websocketUrl]);
}, [transcriptId]);
return {
transcriptTextLive,

View File

@@ -7,6 +7,7 @@ import {
useState,
useContext,
RefObject,
use,
} from "react";
import {
Box,
@@ -37,9 +38,9 @@ import { FaBars } from "react-icons/fa6";
import { useAuth } from "../lib/AuthProvider";
export type RoomDetails = {
params: {
params: Promise<{
roomName: string;
};
}>;
};
// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially
@@ -262,9 +263,11 @@ const useWhereby = () => {
};
export default function Room(details: RoomDetails) {
const params = use(details.params);
const wherebyLoaded = useWhereby();
const wherebyRef = useRef<HTMLElement>(null);
const roomName = details.params.roomName;
const roomName = params.roomName;
const meeting = useRoomMeeting(roomName);
const router = useRouter();
const auth = useAuth();
const status = auth.status;

View File

@@ -1,6 +1,6 @@
import NextAuth from "next-auth";
import { authOptions } from "../../../lib/authBackend";
const handler = NextAuth(authOptions);
const handler = NextAuth(authOptions());
export { handler as GET, handler as POST };

View File

@@ -1,49 +0,0 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { DomainConfig } from "./lib/edgeConfig";
type DomainContextType = Omit<DomainConfig, "auth_callback_url">;
export const DomainContext = createContext<DomainContextType>({
features: {
requireLogin: false,
privacy: true,
browse: false,
sendToZulip: false,
},
api_url: "",
websocket_url: "",
});
export const DomainContextProvider = ({
config,
children,
}: {
config: DomainConfig;
children: any;
}) => {
const [context, setContext] = useState<DomainContextType>();
useEffect(() => {
if (!config) return;
const { auth_callback_url, ...others } = config;
setContext(others);
}, [config]);
if (!context) return;
return (
<DomainContext.Provider value={context}>{children}</DomainContext.Provider>
);
};
// Get feature config client-side with
export const featureEnabled = (
featureName: "requireLogin" | "privacy" | "browse" | "sendToZulip",
) => {
const context = useContext(DomainContext);
return context.features[featureName] as boolean | undefined;
};
// Get config server-side (out of react) : see lib/edgeConfig.

View File

@@ -3,11 +3,10 @@ import { Metadata, Viewport } from "next";
import { Poppins } from "next/font/google";
import { ErrorProvider } from "./(errors)/errorContext";
import ErrorMessage from "./(errors)/errorMessage";
import { DomainContextProvider } from "./domainContext";
import { RecordingConsentProvider } from "./recordingConsentContext";
import { getConfig } from "./lib/edgeConfig";
import { ErrorBoundary } from "@sentry/nextjs";
import { Providers } from "./providers";
import { assertExistsAndNonEmptyString } from "./lib/utils";
const poppins = Poppins({
subsets: ["latin"],
@@ -22,8 +21,13 @@ export const viewport: Viewport = {
maximumScale: 1,
};
const NEXT_PUBLIC_SITE_URL = assertExistsAndNonEmptyString(
process.env.NEXT_PUBLIC_SITE_URL,
"NEXT_PUBLIC_SITE_URL required",
);
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL!),
metadataBase: new URL(NEXT_PUBLIC_SITE_URL),
title: {
template: "%s Reflector",
default: "Reflector - AI-Powered Meeting Transcriptions by Monadical",
@@ -68,21 +72,17 @@ export default async function RootLayout({
}: {
children: React.ReactNode;
}) {
const config = await getConfig();
return (
<html lang="en" className={poppins.className} suppressHydrationWarning>
<body className={"h-[100svh] w-[100svw] overflow-x-hidden relative"}>
<DomainContextProvider config={config}>
<RecordingConsentProvider>
<ErrorBoundary fallback={<p>"something went really wrong"</p>}>
<ErrorProvider>
<ErrorMessage />
<Providers>{children}</Providers>
</ErrorProvider>
</ErrorBoundary>
</RecordingConsentProvider>
</DomainContextProvider>
<RecordingConsentProvider>
<ErrorBoundary fallback={<p>"something went really wrong"</p>}>
<ErrorProvider>
<ErrorMessage />
<Providers>{children}</Providers>
</ErrorProvider>
</ErrorBoundary>
</RecordingConsentProvider>
</body>
</html>
);

View File

@@ -9,6 +9,7 @@ import { Session } from "next-auth";
import { SessionAutoRefresh } from "./SessionAutoRefresh";
import { REFRESH_ACCESS_TOKEN_ERROR } from "./auth";
import { assertExists } from "./utils";
import { featureEnabled } from "./features";
type AuthContextType = (
| { status: "loading" }
@@ -27,65 +28,83 @@ type AuthContextType = (
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const isAuthEnabled = featureEnabled("requireLogin");
const noopAuthContext: AuthContextType = {
status: "unauthenticated",
update: async () => {
return null;
},
signIn: async () => {
throw new Error("signIn not supposed to be called");
},
signOut: async () => {
throw new Error("signOut not supposed to be called");
},
};
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { data: session, status, update } = useNextAuthSession();
const customSession = session ? assertCustomSession(session) : null;
const contextValue: AuthContextType = {
...(() => {
switch (status) {
case "loading": {
const sessionIsHere = !!customSession;
switch (sessionIsHere) {
case false: {
return { status };
const contextValue: AuthContextType = isAuthEnabled
? {
...(() => {
switch (status) {
case "loading": {
const sessionIsHere = !!session;
// actually exists sometimes; nextAuth types are something else
switch (sessionIsHere as boolean) {
case false: {
return { status };
}
case true: {
return {
status: "refreshing" as const,
user: assertCustomSession(
assertExists(session as unknown as Session),
).user,
};
}
default: {
throw new Error("unreachable");
}
}
}
case true: {
return {
status: "refreshing" as const,
user: assertExists(customSession).user,
};
case "authenticated": {
const customSession = assertCustomSession(session);
if (customSession?.error === REFRESH_ACCESS_TOKEN_ERROR) {
// token had expired but next auth still returns "authenticated" so show user unauthenticated state
return {
status: "unauthenticated" as const,
};
} else if (customSession?.accessToken) {
return {
status,
accessToken: customSession.accessToken,
accessTokenExpires: customSession.accessTokenExpires,
user: customSession.user,
};
} else {
console.warn(
"illegal state: authenticated but have no session/or access token. ignoring",
);
return { status: "unauthenticated" as const };
}
}
case "unauthenticated": {
return { status: "unauthenticated" as const };
}
default: {
const _: never = sessionIsHere;
const _: never = status;
throw new Error("unreachable");
}
}
}
case "authenticated": {
if (customSession?.error === REFRESH_ACCESS_TOKEN_ERROR) {
// token had expired but next auth still returns "authenticated" so show user unauthenticated state
return {
status: "unauthenticated" as const,
};
} else if (customSession?.accessToken) {
return {
status,
accessToken: customSession.accessToken,
accessTokenExpires: customSession.accessTokenExpires,
user: customSession.user,
};
} else {
console.warn(
"illegal state: authenticated but have no session/or access token. ignoring",
);
return { status: "unauthenticated" as const };
}
}
case "unauthenticated": {
return { status: "unauthenticated" as const };
}
default: {
const _: never = status;
throw new Error("unreachable");
}
})(),
update,
signIn,
signOut,
}
})(),
update,
signIn,
signOut,
};
: noopAuthContext;
// not useEffect, we need it ASAP
// apparently, still no guarantee this code runs before mutations are fired

View File

@@ -6,10 +6,17 @@ import createFetchClient from "openapi-react-query";
import { assertExistsAndNonEmptyString } from "./utils";
import { isBuildPhase } from "./next";
const API_URL = !isBuildPhase
? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL)
export const API_URL = !isBuildPhase
? assertExistsAndNonEmptyString(
process.env.NEXT_PUBLIC_API_URL,
"NEXT_PUBLIC_API_URL required",
)
: "http://localhost";
// TODO decide strict validation or not
export const WEBSOCKET_URL =
process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://127.0.0.1:1250";
export const client = createClient<paths>({
baseUrl: API_URL,
});

12
www/app/lib/array.ts Normal file
View File

@@ -0,0 +1,12 @@
export type NonEmptyArray<T> = [T, ...T[]];
export const isNonEmptyArray = <T>(arr: T[]): arr is NonEmptyArray<T> =>
arr.length > 0;
export const assertNonEmptyArray = <T>(
arr: T[],
err?: string,
): NonEmptyArray<T> => {
if (isNonEmptyArray(arr)) {
return arr;
}
throw new Error(err ?? "Expected non-empty array");
};

View File

@@ -1,3 +1,5 @@
import { assertExistsAndNonEmptyString } from "./utils";
export const REFRESH_ACCESS_TOKEN_ERROR = "RefreshAccessTokenError" as const;
// 4 min is 1 min less than default authentic value. here we assume that authentic won't be set to access tokens < 4 min
export const REFRESH_ACCESS_TOKEN_BEFORE = 4 * 60 * 1000;

View File

@@ -19,102 +19,126 @@ import {
} from "./redisTokenCache";
import { tokenCacheRedis, redlock } from "./redisClient";
import { isBuildPhase } from "./next";
import { sequenceThrows } from "./errorUtils";
import { featureEnabled } from "./features";
const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
const CLIENT_ID = !isBuildPhase
? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_ID)
: "noop";
const CLIENT_SECRET = !isBuildPhase
? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_SECRET)
: "noop";
const getAuthentikClientId = () =>
assertExistsAndNonEmptyString(
process.env.AUTHENTIK_CLIENT_ID,
"AUTHENTIK_CLIENT_ID required",
);
const getAuthentikClientSecret = () =>
assertExistsAndNonEmptyString(
process.env.AUTHENTIK_CLIENT_SECRET,
"AUTHENTIK_CLIENT_SECRET required",
);
const getAuthentikRefreshTokenUrl = () =>
assertExistsAndNonEmptyString(
process.env.AUTHENTIK_REFRESH_TOKEN_URL,
"AUTHENTIK_REFRESH_TOKEN_URL required",
);
export const authOptions: AuthOptions = {
providers: [
AuthentikProvider({
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
issuer: process.env.AUTHENTIK_ISSUER,
authorization: {
params: {
scope: "openid email profile offline_access",
export const authOptions = (): AuthOptions =>
featureEnabled("requireLogin")
? {
providers: [
AuthentikProvider({
...(() => {
const [clientId, clientSecret] = sequenceThrows(
getAuthentikClientId,
getAuthentikClientSecret,
);
return {
clientId,
clientSecret,
};
})(),
issuer: process.env.AUTHENTIK_ISSUER,
authorization: {
params: {
scope: "openid email profile offline_access",
},
},
}),
],
session: {
strategy: "jwt",
},
},
}),
],
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, account, user }) {
if (account && !account.access_token) {
await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
}
callbacks: {
async jwt({ token, account, user }) {
if (account && !account.access_token) {
await deleteTokenCache(tokenCacheRedis, `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
if (account.access_token) {
const expiresAtS = assertExists(account.expires_at);
const expiresAtMs = expiresAtS * 1000;
const jwtToken: JWTWithAccessToken = {
...token,
accessToken: account.access_token,
accessTokenExpires: expiresAtMs,
refreshToken: account.refresh_token,
};
if (jwtToken.error) {
await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
} else {
assertNotExists(
jwtToken.error,
`panic! trying to cache token with error in jwt: ${jwtToken.error}`,
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
if (account.access_token) {
const expiresAtS = assertExists(account.expires_at);
const expiresAtMs = expiresAtS * 1000;
const jwtToken: JWTWithAccessToken = {
...token,
accessToken: account.access_token,
accessTokenExpires: expiresAtMs,
refreshToken: account.refresh_token,
};
if (jwtToken.error) {
await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
} else {
assertNotExists(
jwtToken.error,
`panic! trying to cache token with error in jwt: ${jwtToken.error}`,
);
await setTokenCache(tokenCacheRedis, `token:${token.sub}`, {
token: jwtToken,
timestamp: Date.now(),
});
return jwtToken;
}
}
}
const currentToken = await getTokenCache(
tokenCacheRedis,
`token:${token.sub}`,
);
await setTokenCache(tokenCacheRedis, `token:${token.sub}`, {
token: jwtToken,
timestamp: Date.now(),
});
return jwtToken;
}
}
}
console.debug(
"currentToken from cache",
JSON.stringify(currentToken, null, 2),
"will be returned?",
currentToken &&
!shouldRefreshToken(currentToken.token.accessTokenExpires),
);
if (
currentToken &&
!shouldRefreshToken(currentToken.token.accessTokenExpires)
) {
return currentToken.token;
}
const currentToken = await getTokenCache(
tokenCacheRedis,
`token:${token.sub}`,
);
console.debug(
"currentToken from cache",
JSON.stringify(currentToken, null, 2),
"will be returned?",
currentToken &&
!shouldRefreshToken(currentToken.token.accessTokenExpires),
);
if (
currentToken &&
!shouldRefreshToken(currentToken.token.accessTokenExpires)
) {
return currentToken.token;
}
// access token has expired, try to update it
return await lockedRefreshAccessToken(token);
},
async session({ session, token }) {
const extendedToken = token as JWTWithAccessToken;
return {
...session,
accessToken: extendedToken.accessToken,
accessTokenExpires: extendedToken.accessTokenExpires,
error: extendedToken.error,
user: {
id: assertExists(extendedToken.sub),
name: extendedToken.name,
email: extendedToken.email,
// access token has expired, try to update it
return await lockedRefreshAccessToken(token);
},
async session({ session, token }) {
const extendedToken = token as JWTWithAccessToken;
return {
...session,
accessToken: extendedToken.accessToken,
accessTokenExpires: extendedToken.accessTokenExpires,
error: extendedToken.error,
user: {
id: assertExists(extendedToken.sub),
name: extendedToken.name,
email: extendedToken.email,
},
} satisfies CustomSession;
},
},
} satisfies CustomSession;
},
},
};
}
: {
providers: [],
};
async function lockedRefreshAccessToken(
token: JWT,
@@ -174,16 +198,19 @@ async function lockedRefreshAccessToken(
}
async function refreshAccessToken(token: JWT): Promise<JWTWithAccessToken> {
const [url, clientId, clientSecret] = sequenceThrows(
getAuthentikRefreshTokenUrl,
getAuthentikClientId,
getAuthentikClientSecret,
);
try {
const url = `${process.env.AUTHENTIK_REFRESH_TOKEN_URL}`;
const options = {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: process.env.AUTHENTIK_CLIENT_ID as string,
client_secret: process.env.AUTHENTIK_CLIENT_SECRET as string,
client_id: clientId,
client_secret: clientSecret,
grant_type: "refresh_token",
refresh_token: token.refreshToken as string,
}).toString(),

View File

@@ -1,54 +0,0 @@
import { get } from "@vercel/edge-config";
import { isBuildPhase } from "./next";
type EdgeConfig = {
[domainWithDash: string]: {
features: {
[featureName in
| "requireLogin"
| "privacy"
| "browse"
| "sendToZulip"]: boolean;
};
auth_callback_url: string;
websocket_url: string;
api_url: string;
};
};
export type DomainConfig = EdgeConfig["domainWithDash"];
// Edge config main keys can only be alphanumeric and _ or -
export function edgeKeyToDomain(key: string) {
return key.replaceAll("_", ".");
}
export function edgeDomainToKey(domain: string) {
return domain.replaceAll(".", "_");
}
// get edge config server-side (prefer DomainContext when available), domain is the hostname
export async function getConfig() {
if (process.env.NEXT_PUBLIC_ENV === "development") {
try {
return require("../../config").localConfig;
} catch (e) {
// next build() WILL try to execute the require above even if conditionally protected
// but thank god it at least runs catch{} block properly
if (!isBuildPhase) throw new Error(e);
return require("../../config-template").localConfig;
}
}
const domain = new URL(process.env.NEXT_PUBLIC_SITE_URL!).hostname;
let config = await get(edgeDomainToKey(domain));
if (typeof config !== "object") {
console.warn("No config for this domain, falling back to default");
config = await get(edgeDomainToKey("default"));
}
if (typeof config !== "object") throw Error("Error fetching config");
return config as DomainConfig;
}

View File

@@ -1,4 +1,6 @@
function shouldShowError(error: Error | null | undefined) {
import { isNonEmptyArray, NonEmptyArray } from "./array";
export function shouldShowError(error: Error | null | undefined) {
if (
error?.name == "ResponseError" &&
(error["response"].status == 404 || error["response"].status == 403)
@@ -8,4 +10,40 @@ function shouldShowError(error: Error | null | undefined) {
return true;
}
export { shouldShowError };
const defaultMergeErrors = (ex: NonEmptyArray<unknown>): unknown => {
try {
return new Error(
ex
.map((e) =>
e ? (e.toString ? e.toString() : JSON.stringify(e)) : `${e}`,
)
.join("\n"),
);
} catch (e) {
console.error("Error merging errors:", e);
return ex[0];
}
};
type ReturnTypes<T extends readonly (() => any)[]> = {
[K in keyof T]: T[K] extends () => infer R ? R : never;
};
// sequence semantic for "throws"
// calls functions passed and collects its thrown values
export function sequenceThrows<Fns extends readonly (() => any)[]>(
...fs: Fns
): ReturnTypes<Fns> {
const results: unknown[] = [];
const errors: unknown[] = [];
for (const f of fs) {
try {
results.push(f());
} catch (e) {
errors.push(e);
}
}
if (errors.length) throw defaultMergeErrors(errors as NonEmptyArray<unknown>);
return results as ReturnTypes<Fns>;
}

55
www/app/lib/features.ts Normal file
View File

@@ -0,0 +1,55 @@
export const FEATURES = [
"requireLogin",
"privacy",
"browse",
"sendToZulip",
"rooms",
] as const;
export type FeatureName = (typeof FEATURES)[number];
export type Features = Readonly<Record<FeatureName, boolean>>;
export const DEFAULT_FEATURES: Features = {
requireLogin: true,
privacy: true,
browse: true,
sendToZulip: true,
rooms: true,
} as const;
function parseBooleanEnv(
value: string | undefined,
defaultValue: boolean = false,
): boolean {
if (!value) return defaultValue;
return value.toLowerCase() === "true";
}
// WARNING: keep process.env.* as-is, next.js won't see them if you generate dynamically
const features: Features = {
requireLogin: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN,
DEFAULT_FEATURES.requireLogin,
),
privacy: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_PRIVACY,
DEFAULT_FEATURES.privacy,
),
browse: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_BROWSE,
DEFAULT_FEATURES.browse,
),
sendToZulip: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP,
DEFAULT_FEATURES.sendToZulip,
),
rooms: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_ROOMS,
DEFAULT_FEATURES.rooms,
),
};
export const featureEnabled = (featureName: FeatureName): boolean => {
return features[featureName];
};

View File

@@ -72,3 +72,7 @@ export const assertCustomSession = <S extends Session>(s: S): CustomSession => {
// no other checks for now
return r as CustomSession;
};
export type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};

View File

@@ -171,5 +171,6 @@ export const assertNotExists = <T>(
export const assertExistsAndNonEmptyString = (
value: string | null | undefined,
err?: string,
): NonEmptyString =>
parseNonEmptyString(assertExists(value, "Expected non-empty string"));
parseNonEmptyString(assertExists(value, err || "Expected non-empty string"));

View File

@@ -2,8 +2,8 @@
import { ChakraProvider } from "@chakra-ui/react";
import system from "./styles/theme";
import dynamic from "next/dynamic";
import { WherebyProvider } from "@whereby.com/browser-sdk/react";
import { Toaster } from "./components/ui/toaster";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { QueryClientProvider } from "@tanstack/react-query";
@@ -11,6 +11,14 @@ import { queryClient } from "./lib/queryClient";
import { AuthProvider } from "./lib/AuthProvider";
import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
const WherebyProvider = dynamic(
() =>
import("@whereby.com/browser-sdk/react").then((mod) => ({
default: mod.WherebyProvider,
})),
{ ssr: false },
);
export function Providers({ children }: { children: React.ReactNode }) {
return (
<NuqsAdapter>

View File

@@ -1,5 +1,5 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, use } from "react";
import Link from "next/link";
import Image from "next/image";
import { notFound } from "next/navigation";
@@ -30,9 +30,9 @@ const FORM_FIELDS = {
};
export type WebinarDetails = {
params: {
params: Promise<{
title: string;
};
}>;
};
export type Webinar = {
@@ -63,7 +63,8 @@ const WEBINARS: Webinar[] = [
];
export default function WebinarPage(details: WebinarDetails) {
const title = details.params.title;
const params = use(details.params);
const title = params.title;
const webinar = WEBINARS.find((webinar) => webinar.title === title);
if (!webinar) {
return notFound();