mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-22 21:29:05 +00:00
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:
132
www/app/lib/AuthProvider.tsx
Normal file
132
www/app/lib/AuthProvider.tsx
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
85
www/app/lib/__tests__/redisTokenCache.test.ts
Normal file
85
www/app/lib/__tests__/redisTokenCache.test.ts
Normal 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
72
www/app/lib/apiClient.tsx
Normal 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
612
www/app/lib/apiHooks.ts
Normal 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
12
www/app/lib/array.ts
Normal 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");
|
||||
};
|
||||
@@ -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
245
www/app/lib/authBackend.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
55
www/app/lib/features.ts
Normal 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
2
www/app/lib/next.ts
Normal 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");
|
||||
17
www/app/lib/queryClient.tsx
Normal file
17
www/app/lib/queryClient.tsx
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
78
www/app/lib/redisClient.ts
Normal file
78
www/app/lib/redisClient.ts
Normal 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();
|
||||
61
www/app/lib/redisTokenCache.ts
Normal file
61
www/app/lib/redisTokenCache.ts
Normal 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);
|
||||
}
|
||||
5
www/app/lib/transcript.ts
Normal file
5
www/app/lib/transcript.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { components } from "../reflector-api";
|
||||
|
||||
type ApiTranscriptStatus = components["schemas"]["GetTranscript"]["status"];
|
||||
|
||||
export type TranscriptStatus = ApiTranscriptStatus;
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
26
www/app/lib/useLoginRequiredPages.ts
Normal file
26
www/app/lib/useLoginRequiredPages.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
8
www/app/lib/useUserName.ts
Normal file
8
www/app/lib/useUserName.ts
Normal 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;
|
||||
};
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user