Merge main into jisti-integration branch

- Resolved conflicts in server/reflector/views/rooms.py to keep platform-agnostic approach
- Resolved conflicts in www/app/[roomName]/page.tsx to keep VideoPlatformEmbed approach
- Accepted main's version of generated API files (schemas.gen.ts, services.gen.ts, types.gen.ts)
- Removed config-template.ts as per main branch changes
This commit is contained in:
2025-09-15 12:53:49 -06:00
127 changed files with 11011 additions and 8821 deletions

View File

@@ -0,0 +1,132 @@
"use client";
import { createContext, useContext } from "react";
import { useSession as useNextAuthSession } from "next-auth/react";
import { signOut, signIn } from "next-auth/react";
import { configureApiAuth } from "./apiClient";
import { assertCustomSession, CustomSession } from "./types";
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" }
| { status: "refreshing"; user: CustomSession["user"] }
| { status: "unauthenticated"; error?: string }
| {
status: "authenticated";
accessToken: string;
accessTokenExpires: number;
user: CustomSession["user"];
}
) & {
update: () => Promise<Session | null>;
signIn: typeof signIn;
signOut: typeof signOut;
};
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 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 "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 = status;
throw new Error("unreachable");
}
}
})(),
update,
signIn,
signOut,
}
: noopAuthContext;
// not useEffect, we need it ASAP
// apparently, still no guarantee this code runs before mutations are fired
configureApiAuth(
contextValue.status === "authenticated"
? contextValue.accessToken
: contextValue.status === "loading"
? undefined
: null,
);
return (
<AuthContext.Provider value={contextValue}>
<SessionAutoRefresh>{children}</SessionAutoRefresh>
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@@ -1,5 +1,5 @@
/**
* This is a custom hook that automatically refreshes the session when the access token is about to expire.
* This is a custom provider that automatically refreshes the session when the access token is about to expire.
* When communicating with the reflector API, we need to ensure that the access token is always valid.
*
* We could have implemented that as an interceptor on the API client, but not everything is using the
@@ -7,30 +7,35 @@
*/
"use client";
import { useSession } from "next-auth/react";
import { useEffect } from "react";
import { CustomSession } from "./types";
import { useAuth } from "./AuthProvider";
import { shouldRefreshToken } from "./auth";
export function SessionAutoRefresh({
children,
refreshInterval = 20 /* seconds */,
}) {
const { data: session, update } = useSession();
const customSession = session as CustomSession;
const accessTokenExpires = customSession?.accessTokenExpires;
export function SessionAutoRefresh({ children }) {
const auth = useAuth();
const accessTokenExpires =
auth.status === "authenticated" ? auth.accessTokenExpires : null;
useEffect(() => {
// technical value for how often the setInterval will be polling news - not too fast (no spam in case of errors)
// and not too slow (debuggable)
const INTERVAL_REFRESH_MS = 5000;
const interval = setInterval(() => {
if (accessTokenExpires) {
const timeLeft = accessTokenExpires - Date.now();
if (timeLeft < refreshInterval * 1000) {
update();
}
if (accessTokenExpires === null) return;
if (shouldRefreshToken(accessTokenExpires)) {
auth
.update()
.then(() => {})
.catch((e) => {
// note: 401 won't be considered error here
console.error("error refreshing auth token", e);
});
}
}, refreshInterval * 1000);
}, INTERVAL_REFRESH_MS);
return () => clearInterval(interval);
}, [accessTokenExpires, refreshInterval, update]);
}, [accessTokenExpires, auth.update]);
return children;
}

View File

@@ -1,11 +0,0 @@
"use client";
import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
import { SessionAutoRefresh } from "./SessionAutoRefresh";
export default function SessionProvider({ children }) {
return (
<SessionProviderNextAuth>
<SessionAutoRefresh>{children}</SessionAutoRefresh>
</SessionProviderNextAuth>
);
}

View File

@@ -0,0 +1,85 @@
import {
getTokenCache,
setTokenCache,
deleteTokenCache,
TokenCacheEntry,
KV,
} from "../redisTokenCache";
const mockKV: KV & {
clear: () => void;
} = (() => {
const data = new Map<string, string>();
return {
async get(key: string): Promise<string | null> {
return data.get(key) || null;
},
async setex(key: string, seconds_: number, value: string): Promise<"OK"> {
data.set(key, value);
return "OK";
},
async del(key: string): Promise<number> {
const existed = data.has(key);
data.delete(key);
return existed ? 1 : 0;
},
clear() {
data.clear();
},
};
})();
describe("Redis Token Cache", () => {
beforeEach(() => {
mockKV.clear();
});
test("basic write/read - value written equals value read", async () => {
const testKey = "token:test-user-123";
const testValue: TokenCacheEntry = {
token: {
sub: "test-user-123",
name: "Test User",
email: "test@example.com",
accessToken: "access-token-123",
accessTokenExpires: Date.now() + 3600000, // 1 hour from now
refreshToken: "refresh-token-456",
},
timestamp: Date.now(),
};
await setTokenCache(mockKV, testKey, testValue);
const retrievedValue = await getTokenCache(mockKV, testKey);
expect(retrievedValue).not.toBeNull();
expect(retrievedValue).toEqual(testValue);
expect(retrievedValue?.token.accessToken).toBe(testValue.token.accessToken);
expect(retrievedValue?.token.sub).toBe(testValue.token.sub);
expect(retrievedValue?.timestamp).toBe(testValue.timestamp);
});
test("get returns null for non-existent key", async () => {
const result = await getTokenCache(mockKV, "non-existent-key");
expect(result).toBeNull();
});
test("delete removes token from cache", async () => {
const testKey = "token:delete-test";
const testValue: TokenCacheEntry = {
token: {
accessToken: "test-token",
accessTokenExpires: Date.now() + 3600000,
},
timestamp: Date.now(),
};
await setTokenCache(mockKV, testKey, testValue);
await deleteTokenCache(mockKV, testKey);
const result = await getTokenCache(mockKV, testKey);
expect(result).toBeNull();
});
});

72
www/app/lib/apiClient.tsx Normal file
View File

@@ -0,0 +1,72 @@
"use client";
import createClient from "openapi-fetch";
import type { paths } from "../reflector-api";
import createFetchClient from "openapi-react-query";
import { assertExistsAndNonEmptyString, parseNonEmptyString } from "./utils";
import { isBuildPhase } from "./next";
import { getSession } from "next-auth/react";
import { assertExtendedToken } from "./types";
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,
});
// will assert presence/absence of login initially
const initialSessionPromise = getSession();
const waitForAuthTokenDefinitivePresenceOrAbsence = async () => {
const initialSession = await initialSessionPromise;
if (currentAuthToken === undefined) {
currentAuthToken =
initialSession === null
? null
: assertExtendedToken(initialSession).accessToken;
}
// otherwise already overwritten by external forces
return currentAuthToken;
};
client.use({
async onRequest({ request }) {
const token = await waitForAuthTokenDefinitivePresenceOrAbsence();
if (token !== null) {
request.headers.set(
"Authorization",
`Bearer ${parseNonEmptyString(token)}`,
);
}
// XXX Only set Content-Type if not already set (FormData will set its own boundary)
// This is a work around for uploading file, we're passing a formdata
// but the content type was still application/json
if (
!request.headers.has("Content-Type") &&
!(request.body instanceof FormData)
) {
request.headers.set("Content-Type", "application/json");
}
return request;
},
});
export const $api = createFetchClient<paths>(client);
let currentAuthToken: string | null | undefined = undefined;
// the function contract: lightweight, idempotent
export const configureApiAuth = (token: string | null | undefined) => {
// watch only for the initial loading; "reloading" state assumes token presence/absence
if (token === undefined && currentAuthToken !== undefined) return;
currentAuthToken = token;
};

612
www/app/lib/apiHooks.ts Normal file
View File

@@ -0,0 +1,612 @@
"use client";
import { $api } from "./apiClient";
import { useError } from "../(errors)/errorContext";
import { useQueryClient } from "@tanstack/react-query";
import type { components } from "../reflector-api";
import { useAuth } from "./AuthProvider";
/*
* XXX error types returned from the hooks are not always correct; declared types are ValidationError but real type could be string or any other
* this is either a limitation or incorrect usage of Python json schema generator
* or, limitation or incorrect usage of .d type generator from json schema
* */
const useAuthReady = () => {
const auth = useAuth();
return {
isAuthenticated: auth.status === "authenticated",
isLoading: auth.status === "loading",
};
};
export function useRoomsList(page: number = 1) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/rooms",
{
params: {
query: { page },
},
},
{
enabled: isAuthenticated,
},
);
}
type SourceKind = components["schemas"]["SourceKind"];
export function useTranscriptsSearch(
q: string = "",
options: {
limit?: number;
offset?: number;
room_id?: string;
source_kind?: SourceKind;
} = {},
) {
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,
},
},
},
{
enabled: true,
},
);
}
export function useTranscriptDelete() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("delete", "/v1/transcripts/{transcript_id}", {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["get", "/v1/transcripts/search"],
});
},
onError: (error) => {
setError(error as Error, "There was an error deleting the transcript");
},
});
}
export function useTranscriptProcess() {
const { setError } = useError();
return $api.useMutation("post", "/v1/transcripts/{transcript_id}/process", {
onError: (error) => {
setError(error as Error, "There was an error processing the transcript");
},
});
}
export function useTranscriptGet(transcriptId: string | null) {
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}",
{
params: {
path: {
transcript_id: transcriptId || "",
},
},
},
{
enabled: !!transcriptId,
},
);
}
export function useRoomGet(roomId: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/rooms/{room_id}",
{
params: {
path: { room_id: roomId || "" },
},
},
{
enabled: !!roomId && isAuthenticated,
},
);
}
export function useRoomTestWebhook() {
const { setError } = useError();
return $api.useMutation("post", "/v1/rooms/{room_id}/webhook/test", {
onError: (error) => {
setError(error as Error, "There was an error testing the webhook");
},
});
}
export function useRoomCreate() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("post", "/v1/rooms", {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error creating the room");
},
});
}
export function useRoomUpdate() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("patch", "/v1/rooms/{room_id}", {
onSuccess: async (room) => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
}),
queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/rooms/{room_id}", {
params: {
path: {
room_id: room.id,
},
},
}).queryKey,
}),
]);
},
onError: (error) => {
setError(error as Error, "There was an error updating the room");
},
});
}
export function useRoomDelete() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("delete", "/v1/rooms/{room_id}", {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error deleting the room");
},
});
}
export function useZulipStreams() {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/zulip/streams",
{},
{
enabled: isAuthenticated,
},
);
}
export function useZulipTopics(streamId: number | null) {
const { isAuthenticated } = useAuthReady();
const enabled = !!streamId && isAuthenticated;
return $api.useQuery(
"get",
"/v1/zulip/streams/{stream_id}/topics",
{
params: {
path: {
stream_id: enabled ? streamId : 0,
},
},
},
{
enabled,
},
);
}
export function useTranscriptUpdate() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("patch", "/v1/transcripts/{transcript_id}", {
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/transcripts/{transcript_id}", {
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
}).queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error updating the transcript");
},
});
}
export function useTranscriptPostToZulip() {
const { setError } = useError();
// @ts-ignore - Zulip endpoint not in OpenAPI spec
return $api.useMutation("post", "/v1/transcripts/{transcript_id}/zulip", {
onError: (error) => {
setError(error as Error, "There was an error posting to Zulip");
},
});
}
export function useTranscriptUploadAudio() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation(
"post",
"/v1/transcripts/{transcript_id}/record/upload",
{
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
).queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error uploading the audio file");
},
},
);
}
export function useTranscriptWaveform(transcriptId: string | null) {
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}/audio/waveform",
{
params: {
path: { transcript_id: transcriptId! },
},
},
{
enabled: !!transcriptId,
},
);
}
export function useTranscriptMP3(transcriptId: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}/audio/mp3",
{
params: {
path: { transcript_id: transcriptId! },
},
},
{
enabled: !!transcriptId && isAuthenticated,
},
);
}
export function useTranscriptTopics(transcriptId: string | null) {
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}/topics",
{
params: {
path: { transcript_id: transcriptId || "" },
},
},
{
enabled: !!transcriptId,
},
);
}
export function useTranscriptTopicsWithWords(transcriptId: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}/topics/with-words",
{
params: {
path: { transcript_id: transcriptId || "" },
},
},
{
enabled: !!transcriptId && isAuthenticated,
},
);
}
export function useTranscriptTopicsWithWordsPerSpeaker(
transcriptId: string | null,
topicId: string | null,
) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}/topics/{topic_id}/words-per-speaker",
{
params: {
path: {
transcript_id: transcriptId || "",
topic_id: topicId || "",
},
},
},
{
enabled: !!transcriptId && !!topicId && isAuthenticated,
},
);
}
export function useTranscriptParticipants(transcriptId: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}/participants",
{
params: {
path: { transcript_id: transcriptId || "" },
},
},
{
enabled: !!transcriptId && isAuthenticated,
},
);
}
export function useTranscriptParticipantUpdate() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation(
"patch",
"/v1/transcripts/{transcript_id}/participants/{participant_id}",
{
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
).queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error updating the participant");
},
},
);
}
export function useTranscriptParticipantCreate() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation(
"post",
"/v1/transcripts/{transcript_id}/participants",
{
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
).queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error creating the participant");
},
},
);
}
export function useTranscriptParticipantDelete() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation(
"delete",
"/v1/transcripts/{transcript_id}/participants/{participant_id}",
{
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
).queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error deleting the participant");
},
},
);
}
export function useTranscriptSpeakerAssign() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation(
"patch",
"/v1/transcripts/{transcript_id}/speaker/assign",
{
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
).queryKey,
});
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
).queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error assigning the speaker");
},
},
);
}
export function useTranscriptSpeakerMerge() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation(
"patch",
"/v1/transcripts/{transcript_id}/speaker/merge",
{
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
).queryKey,
});
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
).queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error merging speakers");
},
},
);
}
export function useMeetingAudioConsent() {
const { setError } = useError();
return $api.useMutation("post", "/v1/meetings/{meeting_id}/consent", {
onError: (error) => {
setError(error as Error, "There was an error recording consent");
},
});
}
export function useTranscriptWebRTC() {
const { setError } = useError();
return $api.useMutation(
"post",
"/v1/transcripts/{transcript_id}/record/webrtc",
{
onError: (error) => {
setError(error as Error, "There was an error with WebRTC connection");
},
},
);
}
export function useTranscriptCreate() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("post", "/v1/transcripts", {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["get", "/v1/transcripts/search"],
});
},
onError: (error) => {
setError(error as Error, "There was an error creating the transcript");
},
});
}
export function useRoomsCreateMeeting() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("post", "/v1/rooms/{room_name}/meeting", {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error creating the meeting");
},
});
}

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,157 +1,20 @@
// import { kv } from "@vercel/kv";
import Redlock, { ResourceLockedError } from "redlock";
import { AuthOptions } from "next-auth";
import AuthentikProvider from "next-auth/providers/authentik";
import { JWT } from "next-auth/jwt";
import { JWTWithAccessToken, CustomSession } from "./types";
import Redis from "ioredis";
import { assertExistsAndNonEmptyString } from "./utils";
const PRETIMEOUT = 60; // seconds before token expires to refresh it
const DEFAULT_REDIS_KEY_TIMEOUT = 60 * 60 * 24 * 30; // 30 days (refresh token expires in 30 days)
const kv = new Redis(process.env.KV_URL || "", {
tls: {},
});
const redlock = new Redlock([kv], {});
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;
redlock.on("error", (error) => {
if (error instanceof ResourceLockedError) {
return;
}
// Log all other errors.
console.error(error);
});
export const authOptions: AuthOptions = {
providers: [
AuthentikProvider({
clientId: process.env.AUTHENTIK_CLIENT_ID as string,
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET as string,
issuer: process.env.AUTHENTIK_ISSUER,
authorization: {
params: {
scope: "openid email profile offline_access",
},
},
}),
],
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, account, user }) {
const extendedToken = token as JWTWithAccessToken;
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 = {
...extendedToken,
accessToken: account.access_token,
accessTokenExpires: expiresAt * 1000,
refreshToken: account.refresh_token,
};
kv.set(
`token:${jwtToken.sub}`,
JSON.stringify(jwtToken),
"EX",
DEFAULT_REDIS_KEY_TIMEOUT,
);
return jwtToken;
}
if (Date.now() < extendedToken.accessTokenExpires) {
return token;
}
// access token has expired, try to update it
return await redisLockedrefreshAccessToken(token);
},
async session({ session, token }) {
const extendedToken = token as JWTWithAccessToken;
const customSession = session as CustomSession;
customSession.accessToken = extendedToken.accessToken;
customSession.accessTokenExpires = extendedToken.accessTokenExpires;
customSession.error = extendedToken.error;
customSession.user = {
id: extendedToken.sub,
name: extendedToken.name,
email: extendedToken.email,
};
return customSession;
},
},
export const shouldRefreshToken = (accessTokenExpires: number): boolean => {
const timeLeft = accessTokenExpires - Date.now();
return timeLeft < REFRESH_ACCESS_TOKEN_BEFORE;
};
async function redisLockedrefreshAccessToken(token: JWT) {
return await redlock.using(
[token.sub as string, "jwt-refresh"],
5000,
async () => {
const redisToken = await kv.get(`token:${token.sub}`);
const currentToken = JSON.parse(
redisToken as string,
) as JWTWithAccessToken;
export const LOGIN_REQUIRED_PAGES = [
"/transcripts/[!new]",
"/browse(.*)",
"/rooms(.*)",
];
// if there is multiple requests for the same token, it may already have been refreshed
if (Date.now() < currentToken.accessTokenExpires) {
return currentToken;
}
// now really do the request
const newToken = await refreshAccessToken(currentToken);
await kv.set(
`token:${currentToken.sub}`,
JSON.stringify(newToken),
"EX",
DEFAULT_REDIS_KEY_TIMEOUT,
);
return newToken;
},
);
}
async function refreshAccessToken(token: JWT): Promise<JWTWithAccessToken> {
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,
grant_type: "refresh_token",
refresh_token: token.refreshToken as string,
}).toString(),
method: "POST",
};
const response = await fetch(url, options);
if (!response.ok) {
console.error(
new Date().toISOString(),
"Failed to refresh access token. Response status:",
response.status,
);
const responseBody = await response.text();
console.error(new Date().toISOString(), "Response body:", responseBody);
throw new Error(`Failed to refresh access token: ${response.statusText}`);
}
const refreshedTokens = await response.json();
return {
...token,
accessToken: refreshedTokens.access_token,
accessTokenExpires:
Date.now() + (refreshedTokens.expires_in - PRETIMEOUT) * 1000,
refreshToken: refreshedTokens.refresh_token,
};
} catch (error) {
console.error("Error refreshing access token", error);
return {
...token,
error: "RefreshAccessTokenError",
} as JWTWithAccessToken;
}
}
export const PROTECTED_PAGES = new RegExp(
LOGIN_REQUIRED_PAGES.map((page) => `^${page}$`).join("|"),
);

245
www/app/lib/authBackend.ts Normal file
View File

@@ -0,0 +1,245 @@
import { AuthOptions } from "next-auth";
import AuthentikProvider from "next-auth/providers/authentik";
import type { JWT } from "next-auth/jwt";
import { JWTWithAccessToken, CustomSession } from "./types";
import {
assertExists,
assertExistsAndNonEmptyString,
assertNotExists,
} from "./utils";
import {
REFRESH_ACCESS_TOKEN_BEFORE,
REFRESH_ACCESS_TOKEN_ERROR,
shouldRefreshToken,
} from "./auth";
import {
getTokenCache,
setTokenCache,
deleteTokenCache,
} 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 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 =>
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",
},
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}`,
);
await setTokenCache(tokenCacheRedis, `token:${token.sub}`, {
token: jwtToken,
timestamp: Date.now(),
});
return jwtToken;
}
}
}
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,
},
} satisfies CustomSession;
},
},
}
: {
providers: [],
};
async function lockedRefreshAccessToken(
token: JWT,
): Promise<JWTWithAccessToken> {
const lockKey = `${token.sub}-lock`;
return redlock
.using([lockKey], 10000, async () => {
const cached = await getTokenCache(tokenCacheRedis, `token:${token.sub}`);
if (cached)
console.debug(
"received cached token. to delete?",
Date.now() - cached.timestamp > TOKEN_CACHE_TTL,
);
else console.debug("no cached token received");
if (cached) {
if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) {
await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
} else if (!shouldRefreshToken(cached.token.accessTokenExpires)) {
console.debug("returning cached token", cached.token);
return cached.token;
}
}
const currentToken = cached?.token || (token as JWTWithAccessToken);
const newToken = await refreshAccessToken(currentToken);
console.debug("current token during refresh", currentToken);
console.debug("new token during refresh", newToken);
if (newToken.error) {
await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
return newToken;
}
assertNotExists(
newToken.error,
`panic! trying to cache token with error during refresh: ${newToken.error}`,
);
await setTokenCache(tokenCacheRedis, `token:${token.sub}`, {
token: newToken,
timestamp: Date.now(),
});
return newToken;
})
.catch((e) => {
console.error("error refreshing token", e);
deleteTokenCache(tokenCacheRedis, `token:${token.sub}`).catch((e) => {
console.error("error deleting errored token", e);
});
return {
...token,
error: REFRESH_ACCESS_TOKEN_ERROR,
} as JWTWithAccessToken;
});
}
async function refreshAccessToken(token: JWT): Promise<JWTWithAccessToken> {
const [url, clientId, clientSecret] = sequenceThrows(
getAuthentikRefreshTokenUrl,
getAuthentikClientId,
getAuthentikClientSecret,
);
try {
const options = {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: "refresh_token",
refresh_token: token.refreshToken as string,
}).toString(),
method: "POST",
};
const response = await fetch(url, options);
if (!response.ok) {
console.error(
new Date().toISOString(),
"Failed to refresh access token. Response status:",
response.status,
);
const responseBody = await response.text();
console.error(new Date().toISOString(), "Response body:", responseBody);
throw new Error(`Failed to refresh access token: ${response.statusText}`);
}
const refreshedTokens = await response.json();
return {
...token,
accessToken: refreshedTokens.access_token,
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
refreshToken: refreshedTokens.refresh_token,
};
} catch (error) {
console.error("Error refreshing access token", error);
return {
...token,
error: REFRESH_ACCESS_TOKEN_ERROR,
} as JWTWithAccessToken;
}
}

View File

@@ -1,48 +0,0 @@
import { get } from "@vercel/edge-config";
import { isDevelopment } from "./utils";
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() {
const domain = new URL(process.env.NEXT_PUBLIC_SITE_URL!).hostname;
if (process.env.NEXT_PUBLIC_ENV === "development") {
return require("../../config").localConfig;
}
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];
};

2
www/app/lib/next.ts Normal file
View File

@@ -0,0 +1,2 @@
// next.js tries to run all the lib code during build phase; we don't always want it when e.g. we have connections initialized we don't want to have
export const isBuildPhase = process.env.NEXT_PHASE?.includes("build");

View File

@@ -0,0 +1,17 @@
"use client";
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
retry: 1,
refetchOnWindowFocus: false,
},
mutations: {
retry: 0,
},
},
});

View File

@@ -0,0 +1,78 @@
import Redis from "ioredis";
import { isBuildPhase } from "./next";
import Redlock, { ResourceLockedError } from "redlock";
export type RedisClient = Pick<Redis, "get" | "setex" | "del">;
export type RedlockClient = {
using: <T>(
keys: string | string[],
ttl: number,
cb: () => Promise<T>,
) => Promise<T>;
};
const KV_USE_TLS = process.env.KV_USE_TLS
? process.env.KV_USE_TLS === "true"
: undefined;
let redisClient: Redis | null = null;
const getRedisClient = (): RedisClient => {
if (redisClient) return redisClient;
const redisUrl = process.env.KV_URL;
if (!redisUrl) {
throw new Error("KV_URL environment variable is required");
}
redisClient = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
...(KV_USE_TLS === true
? {
tls: {},
}
: {}),
});
redisClient.on("error", (error) => {
console.error("Redis error:", error);
});
return redisClient;
};
// next.js buildtime usage - we want to isolate next.js "build" time concepts here
const noopClient: RedisClient = (() => {
const noopSetex: Redis["setex"] = async () => {
return "OK" as const;
};
const noopDel: Redis["del"] = async () => {
return 0;
};
return {
get: async () => {
return null;
},
setex: noopSetex,
del: noopDel,
};
})();
const noopRedlock: RedlockClient = {
using: <T>(resource: string | string[], ttl: number, cb: () => Promise<T>) =>
cb(),
};
export const redlock: RedlockClient = isBuildPhase
? noopRedlock
: (() => {
const r = new Redlock([getRedisClient()], {});
r.on("error", (error) => {
if (error instanceof ResourceLockedError) {
return;
}
// Log all other errors.
console.error(error);
});
return r;
})();
export const tokenCacheRedis = isBuildPhase ? noopClient : getRedisClient();

View File

@@ -0,0 +1,61 @@
import { z } from "zod";
import { REFRESH_ACCESS_TOKEN_BEFORE } from "./auth";
const TokenCacheEntrySchema = z.object({
token: z.object({
sub: z.string().optional(),
name: z.string().nullish(),
email: z.string().nullish(),
accessToken: z.string(),
accessTokenExpires: z.number(),
refreshToken: z.string().optional(),
}),
timestamp: z.number(),
});
const TokenCacheEntryCodec = z.codec(z.string(), TokenCacheEntrySchema, {
decode: (jsonString) => {
const parsed = JSON.parse(jsonString);
return TokenCacheEntrySchema.parse(parsed);
},
encode: (value) => JSON.stringify(value),
});
export type TokenCacheEntry = z.infer<typeof TokenCacheEntrySchema>;
export type KV = {
get(key: string): Promise<string | null>;
setex(key: string, seconds: number, value: string): Promise<"OK">;
del(key: string): Promise<number>;
};
export async function getTokenCache(
redis: KV,
key: string,
): Promise<TokenCacheEntry | null> {
const data = await redis.get(key);
if (!data) return null;
try {
return TokenCacheEntryCodec.decode(data);
} catch (error) {
console.error("Invalid token cache data:", error);
await redis.del(key);
return null;
}
}
const TTL_SECONDS = 30 * 24 * 60 * 60;
export async function setTokenCache(
redis: KV,
key: string,
value: TokenCacheEntry,
): Promise<void> {
const encodedValue = TokenCacheEntryCodec.encode(value);
await redis.setex(key, TTL_SECONDS, encodedValue);
}
export async function deleteTokenCache(redis: KV, key: string): Promise<void> {
await redis.del(key);
}

View File

@@ -0,0 +1,5 @@
import { components } from "../reflector-api";
type ApiTranscriptStatus = components["schemas"]["GetTranscript"]["status"];
export type TranscriptStatus = ApiTranscriptStatus;

View File

@@ -1,10 +1,11 @@
import { Session } from "next-auth";
import { JWT } from "next-auth/jwt";
import type { Session } from "next-auth";
import type { JWT } from "next-auth/jwt";
import { parseMaybeNonEmptyString } from "./utils";
export interface JWTWithAccessToken extends JWT {
accessToken: string;
accessTokenExpires: number;
refreshToken: string;
refreshToken?: string;
error?: string;
}
@@ -12,9 +13,68 @@ export interface CustomSession extends Session {
accessToken: string;
accessTokenExpires: number;
error?: string;
user: {
id?: string;
name?: string | null;
email?: string | null;
user: Session["user"] & {
id: string;
};
}
// assumption that JWT is JWTWithAccessToken - we set it in jwt callback of auth; typing isn't strong around there
// but the assumption is crucial to auth working
export const assertExtendedToken = <T>(
t: Exclude<T, null | undefined>,
): 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");
};
export const assertExtendedTokenAndUserId = <U, T extends { user?: U }>(
t: Exclude<T, null | undefined>,
): T & {
accessTokenExpires: number;
accessToken: string;
user: U & {
id: string;
};
} => {
const extendedToken = assertExtendedToken(t);
if (typeof (extendedToken.user as any)?.id === "string") {
return t as Exclude<T, null | undefined> & {
accessTokenExpires: number;
accessToken: string;
user: U & {
id: string;
};
};
}
throw new Error("Token is not extended with user id");
};
// best attempt to check the session is valid
export const assertCustomSession = <T extends Session>(
s: Exclude<T, null | undefined>,
): CustomSession => {
const r = assertExtendedTokenAndUserId(s);
// no other checks for now
return r as CustomSession;
};
export type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};

View File

@@ -1,37 +0,0 @@
import { useSession, signOut } from "next-auth/react";
import { useContext, useEffect, useState } from "react";
import { DomainContext, featureEnabled } from "../domainContext";
import { OpenApi, DefaultService } from "../api";
import { CustomSession } from "./types";
import useSessionStatus from "./useSessionStatus";
import useSessionAccessToken from "./useSessionAccessToken";
export default function useApi(): DefaultService | null {
const api_url = useContext(DomainContext).api_url;
const [api, setApi] = useState<OpenApi | null>(null);
const { isLoading, isAuthenticated } = useSessionStatus();
const { accessToken, error } = useSessionAccessToken();
if (!api_url) throw new Error("no API URL");
useEffect(() => {
if (error === "RefreshAccessTokenError") {
signOut();
}
}, [error]);
useEffect(() => {
if (isLoading || (isAuthenticated && !accessToken)) {
return;
}
const openApi = new OpenApi({
BASE: api_url,
TOKEN: accessToken || undefined,
});
setApi(openApi);
}, [isLoading, isAuthenticated, accessToken]);
return api?.default ?? null;
}

View File

@@ -0,0 +1,26 @@
// for paths that are not supposed to be public
import { PROTECTED_PAGES } from "./auth";
import { usePathname } from "next/navigation";
import { useAuth } from "./AuthProvider";
import { useEffect } from "react";
const HOME = "/" as const;
export const useLoginRequiredPages = () => {
const pathname = usePathname();
const isProtected = PROTECTED_PAGES.test(pathname);
const auth = useAuth();
const isNotLoggedIn = auth.status === "unauthenticated";
// safety
const isLastDestination = pathname === HOME;
const shouldRedirect = isNotLoggedIn && isProtected && !isLastDestination;
useEffect(() => {
if (!shouldRedirect) return;
// on the backend, the redirect goes straight to the auth provider, but we don't have it because it's hidden inside next-auth middleware
// so we just "softly" lead the user to the main page
// warning: if HOME redirects somewhere else, we won't be protected by isLastDestination
window.location.href = HOME;
}, [shouldRedirect]);
// optionally save from blink, since window.location.href takes a bit of time
return shouldRedirect ? HOME : null;
};

View File

@@ -1,42 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { useSession as useNextAuthSession } from "next-auth/react";
import { CustomSession } from "./types";
export default function useSessionAccessToken() {
const { data: session } = useNextAuthSession();
const customSession = session as CustomSession;
const naAccessToken = customSession?.accessToken;
const naAccessTokenExpires = customSession?.accessTokenExpires;
const naError = customSession?.error;
const [accessToken, setAccessToken] = useState<string | null>(null);
const [accessTokenExpires, setAccessTokenExpires] = useState<number | null>(
null,
);
const [error, setError] = useState<string | undefined>();
useEffect(() => {
if (naAccessToken !== accessToken) {
setAccessToken(naAccessToken);
}
}, [naAccessToken]);
useEffect(() => {
if (naAccessTokenExpires !== accessTokenExpires) {
setAccessTokenExpires(naAccessTokenExpires);
}
}, [naAccessTokenExpires]);
useEffect(() => {
if (naError !== error) {
setError(naError);
}
}, [naError]);
return {
accessToken,
accessTokenExpires,
error,
};
}

View File

@@ -1,22 +0,0 @@
"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",
};
}

View File

@@ -1,33 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { useSession as useNextAuthSession } from "next-auth/react";
import { Session } from "next-auth";
// user type with id, name, email
export interface User {
id?: string | null;
name?: string | null;
email?: string | null;
}
export default function useSessionUser() {
const { data: session } = useNextAuthSession();
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
if (!session?.user) {
setUser(null);
return;
}
if (JSON.stringify(session.user) !== JSON.stringify(user)) {
setUser(session.user);
}
}, [session]);
return {
id: user?.id,
name: user?.name,
email: user?.email,
};
}

View File

@@ -0,0 +1,8 @@
import { useAuth } from "./AuthProvider";
export const useUserName = (): string | null | undefined => {
const auth = useAuth();
if (auth.status !== "authenticated" && auth.status !== "refreshing")
return undefined;
return auth.user?.name || null;
};

View File

@@ -137,9 +137,40 @@ export function extractDomain(url) {
}
}
export function assertExists<T>(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 = <T>(
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 assertNotExists = <T>(
value: T | null | undefined,
err?: string,
): void => {
if (value !== null && value !== undefined) {
throw new Error(
`Assertion failed: ${err ?? "value is not null or undefined"}`,
);
}
};
export const assertExistsAndNonEmptyString = (
value: string | null | undefined,
err?: string,
): NonEmptyString =>
parseNonEmptyString(assertExists(value, err || "Expected non-empty string"));