mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
feat: replace nextjs-config with environment variables (#632)
* chore: remove nextjs-config * build fix * update readme * explicit nextjs env vars + remove feature-unrelated things and obsolete vars from config * full config removal * remove force-dynamic from pages * compile fix * restore claude-deleted tests * better .env.example --------- Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
This commit is contained in:
@@ -66,7 +66,6 @@ pnpm install
|
|||||||
|
|
||||||
# Copy configuration templates
|
# Copy configuration templates
|
||||||
cp .env_template .env
|
cp .env_template .env
|
||||||
cp config-template.ts config.ts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Development:**
|
**Development:**
|
||||||
|
|||||||
36
README.md
36
README.md
@@ -99,11 +99,10 @@ Start with `cd www`.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
cp .env_template .env
|
cp .env.example .env
|
||||||
cp config-template.ts config.ts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, fill in the environment variables in `.env` and the configuration in `config.ts` as needed. If you are unsure on how to proceed, ask in Zulip.
|
Then, fill in the environment variables in `.env` as needed. If you are unsure on how to proceed, ask in Zulip.
|
||||||
|
|
||||||
**Run in development mode**
|
**Run in development mode**
|
||||||
|
|
||||||
@@ -168,3 +167,34 @@ You can manually process an audio file by calling the process tool:
|
|||||||
```bash
|
```bash
|
||||||
uv run python -m reflector.tools.process path/to/audio.wav
|
uv run python -m reflector.tools.process path/to/audio.wav
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Feature Flags
|
||||||
|
|
||||||
|
Reflector uses environment variable-based feature flags to control application functionality. These flags allow you to enable or disable features without code changes.
|
||||||
|
|
||||||
|
### Available Feature Flags
|
||||||
|
|
||||||
|
| Feature Flag | Environment Variable |
|
||||||
|
|-------------|---------------------|
|
||||||
|
| `requireLogin` | `NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN` |
|
||||||
|
| `privacy` | `NEXT_PUBLIC_FEATURE_PRIVACY` |
|
||||||
|
| `browse` | `NEXT_PUBLIC_FEATURE_BROWSE` |
|
||||||
|
| `sendToZulip` | `NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP` |
|
||||||
|
| `rooms` | `NEXT_PUBLIC_FEATURE_ROOMS` |
|
||||||
|
|
||||||
|
### Setting Feature Flags
|
||||||
|
|
||||||
|
Feature flags are controlled via environment variables using the pattern `NEXT_PUBLIC_FEATURE_{FEATURE_NAME}` where `{FEATURE_NAME}` is the SCREAMING_SNAKE_CASE version of the feature name.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```bash
|
||||||
|
# Enable user authentication requirement
|
||||||
|
NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true
|
||||||
|
|
||||||
|
# Disable browse functionality
|
||||||
|
NEXT_PUBLIC_FEATURE_BROWSE=false
|
||||||
|
|
||||||
|
# Enable Zulip integration
|
||||||
|
NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true
|
||||||
|
```
|
||||||
|
|||||||
34
www/.env.example
Normal file
34
www/.env.example
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Environment
|
||||||
|
ENVIRONMENT=development
|
||||||
|
NEXT_PUBLIC_ENV=development
|
||||||
|
|
||||||
|
# Site Configuration
|
||||||
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Nextauth envs
|
||||||
|
# not used in app code but in lib code
|
||||||
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
NEXTAUTH_SECRET=your-nextauth-secret-here
|
||||||
|
# / Nextauth envs
|
||||||
|
|
||||||
|
# Authentication (Authentik OAuth/OIDC)
|
||||||
|
AUTHENTIK_ISSUER=https://authentik.example.com/application/o/reflector
|
||||||
|
AUTHENTIK_REFRESH_TOKEN_URL=https://authentik.example.com/application/o/token/
|
||||||
|
AUTHENTIK_CLIENT_ID=your-client-id-here
|
||||||
|
AUTHENTIK_CLIENT_SECRET=your-client-secret-here
|
||||||
|
|
||||||
|
# Feature Flags
|
||||||
|
# NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true
|
||||||
|
# NEXT_PUBLIC_FEATURE_PRIVACY=false
|
||||||
|
# NEXT_PUBLIC_FEATURE_BROWSE=true
|
||||||
|
# NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true
|
||||||
|
# NEXT_PUBLIC_FEATURE_ROOMS=true
|
||||||
|
|
||||||
|
# API URLs
|
||||||
|
NEXT_PUBLIC_API_URL=http://127.0.0.1:1250
|
||||||
|
NEXT_PUBLIC_WEBSOCKET_URL=ws://127.0.0.1:1250
|
||||||
|
NEXT_PUBLIC_AUTH_CALLBACK_URL=http://localhost:3000/auth-callback
|
||||||
|
|
||||||
|
# Sentry
|
||||||
|
# SENTRY_DSN=https://your-dsn@sentry.io/project-id
|
||||||
|
# SENTRY_IGNORE_API_RESOLUTION_ERROR=1
|
||||||
1
www/.gitignore
vendored
1
www/.gitignore
vendored
@@ -40,7 +40,6 @@ next-env.d.ts
|
|||||||
# Sentry Auth Token
|
# Sentry Auth Token
|
||||||
.sentryclirc
|
.sentryclirc
|
||||||
|
|
||||||
config.ts
|
|
||||||
|
|
||||||
# openapi logs
|
# openapi logs
|
||||||
openapi-ts-error-*.log
|
openapi-ts-error-*.log
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Container, Flex, Link } from "@chakra-ui/react";
|
import { Container, Flex, Link } from "@chakra-ui/react";
|
||||||
import { getConfig } from "../lib/edgeConfig";
|
import { featureEnabled } from "../lib/features";
|
||||||
import NextLink from "next/link";
|
import NextLink from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import UserInfo from "../(auth)/userInfo";
|
import UserInfo from "../(auth)/userInfo";
|
||||||
@@ -11,8 +11,6 @@ export default async function AppLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const config = await getConfig();
|
|
||||||
const { requireLogin, privacy, browse, rooms } = config.features;
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
minW="100vw"
|
minW="100vw"
|
||||||
@@ -58,7 +56,7 @@ export default async function AppLayout({
|
|||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</Link>
|
</Link>
|
||||||
{browse ? (
|
{featureEnabled("browse") ? (
|
||||||
<>
|
<>
|
||||||
·
|
·
|
||||||
<Link href="/browse" as={NextLink} className="font-light px-2">
|
<Link href="/browse" as={NextLink} className="font-light px-2">
|
||||||
@@ -68,7 +66,7 @@ export default async function AppLayout({
|
|||||||
) : (
|
) : (
|
||||||
<></>
|
<></>
|
||||||
)}
|
)}
|
||||||
{rooms ? (
|
{featureEnabled("rooms") ? (
|
||||||
<>
|
<>
|
||||||
·
|
·
|
||||||
<Link href="/rooms" as={NextLink} className="font-light px-2">
|
<Link href="/rooms" as={NextLink} className="font-light px-2">
|
||||||
@@ -78,7 +76,7 @@ export default async function AppLayout({
|
|||||||
) : (
|
) : (
|
||||||
<></>
|
<></>
|
||||||
)}
|
)}
|
||||||
{requireLogin ? (
|
{featureEnabled("requireLogin") ? (
|
||||||
<>
|
<>
|
||||||
·
|
·
|
||||||
<UserInfo />
|
<UserInfo />
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import ScrollToBottom from "../../scrollToBottom";
|
|||||||
import { Topic } from "../../webSocketTypes";
|
import { Topic } from "../../webSocketTypes";
|
||||||
import useParticipants from "../../useParticipants";
|
import useParticipants from "../../useParticipants";
|
||||||
import { Box, Flex, Text, Accordion } from "@chakra-ui/react";
|
import { Box, Flex, Text, Accordion } from "@chakra-ui/react";
|
||||||
import { featureEnabled } from "../../../../domainContext";
|
|
||||||
import { TopicItem } from "./TopicItem";
|
import { TopicItem } from "./TopicItem";
|
||||||
import { TranscriptStatus } from "../../../../lib/transcript";
|
import { TranscriptStatus } from "../../../../lib/transcript";
|
||||||
|
|
||||||
|
import { featureEnabled } from "../../../../lib/features";
|
||||||
|
|
||||||
type TopicListProps = {
|
type TopicListProps = {
|
||||||
topics: Topic[];
|
topics: Topic[];
|
||||||
useActiveTopic: [
|
useActiveTopic: [
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { useRouter } from "next/navigation";
|
|||||||
import useCreateTranscript from "../createTranscript";
|
import useCreateTranscript from "../createTranscript";
|
||||||
import SelectSearch from "react-select-search";
|
import SelectSearch from "react-select-search";
|
||||||
import { supportedLanguages } from "../../../supportedLanguages";
|
import { supportedLanguages } from "../../../supportedLanguages";
|
||||||
import { featureEnabled } from "../../../domainContext";
|
|
||||||
import {
|
import {
|
||||||
Flex,
|
Flex,
|
||||||
Box,
|
Box,
|
||||||
@@ -21,10 +20,9 @@ import {
|
|||||||
Spacer,
|
Spacer,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useAuth } from "../../../lib/AuthProvider";
|
import { useAuth } from "../../../lib/AuthProvider";
|
||||||
import type { components } from "../../../reflector-api";
|
import { featureEnabled } from "../../../lib/features";
|
||||||
|
|
||||||
const TranscriptCreate = () => {
|
const TranscriptCreate = () => {
|
||||||
const isClient = typeof window !== "undefined";
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const isAuthenticated = auth.status === "authenticated";
|
const isAuthenticated = auth.status === "authenticated";
|
||||||
@@ -176,7 +174,7 @@ const TranscriptCreate = () => {
|
|||||||
placeholder="Choose your language"
|
placeholder="Choose your language"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{isClient && !loading ? (
|
{!loading ? (
|
||||||
permissionOk ? (
|
permissionOk ? (
|
||||||
<Spacer />
|
<Spacer />
|
||||||
) : permissionDenied ? (
|
) : permissionDenied ? (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { featureEnabled } from "../../domainContext";
|
|
||||||
|
|
||||||
import { ShareMode, toShareMode } from "../../lib/shareMode";
|
import { ShareMode, toShareMode } from "../../lib/shareMode";
|
||||||
import type { components } from "../../reflector-api";
|
import type { components } from "../../reflector-api";
|
||||||
@@ -24,6 +23,8 @@ import ShareCopy from "./shareCopy";
|
|||||||
import ShareZulip from "./shareZulip";
|
import ShareZulip from "./shareZulip";
|
||||||
import { useAuth } from "../../lib/AuthProvider";
|
import { useAuth } from "../../lib/AuthProvider";
|
||||||
|
|
||||||
|
import { featureEnabled } from "../../lib/features";
|
||||||
|
|
||||||
type ShareAndPrivacyProps = {
|
type ShareAndPrivacyProps = {
|
||||||
finalSummaryRef: any;
|
finalSummaryRef: any;
|
||||||
transcriptResponse: GetTranscript;
|
transcriptResponse: GetTranscript;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useRef, useEffect, use } from "react";
|
import React, { useState, useRef, useEffect, use } from "react";
|
||||||
import { featureEnabled } from "../../domainContext";
|
|
||||||
import { Button, Flex, Input, Text } from "@chakra-ui/react";
|
import { Button, Flex, Input, Text } from "@chakra-ui/react";
|
||||||
import QRCode from "react-qr-code";
|
import QRCode from "react-qr-code";
|
||||||
|
|
||||||
|
import { featureEnabled } from "../../lib/features";
|
||||||
|
|
||||||
type ShareLinkProps = {
|
type ShareLinkProps = {
|
||||||
transcriptId: string;
|
transcriptId: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { featureEnabled } from "../../domainContext";
|
|
||||||
import type { components } from "../../reflector-api";
|
import type { components } from "../../reflector-api";
|
||||||
|
|
||||||
type GetTranscript = components["schemas"]["GetTranscript"];
|
type GetTranscript = components["schemas"]["GetTranscript"];
|
||||||
@@ -25,6 +24,8 @@ import {
|
|||||||
useTranscriptPostToZulip,
|
useTranscriptPostToZulip,
|
||||||
} from "../../lib/apiHooks";
|
} from "../../lib/apiHooks";
|
||||||
|
|
||||||
|
import { featureEnabled } from "../../lib/features";
|
||||||
|
|
||||||
type ShareZulipProps = {
|
type ShareZulipProps = {
|
||||||
transcriptResponse: GetTranscript;
|
transcriptResponse: GetTranscript;
|
||||||
topicsResponse: GetTranscriptTopic[];
|
topicsResponse: GetTranscriptTopic[];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useContext, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { DomainContext } from "../../domainContext";
|
|
||||||
import { useTranscriptGet } from "../../lib/apiHooks";
|
import { useTranscriptGet } from "../../lib/apiHooks";
|
||||||
import { useAuth } from "../../lib/AuthProvider";
|
import { useAuth } from "../../lib/AuthProvider";
|
||||||
|
import { API_URL } from "../../lib/apiClient";
|
||||||
|
|
||||||
export type Mp3Response = {
|
export type Mp3Response = {
|
||||||
media: HTMLMediaElement | null;
|
media: HTMLMediaElement | null;
|
||||||
@@ -19,7 +19,6 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [audioDeleted, setAudioDeleted] = useState<boolean | null>(null);
|
const [audioDeleted, setAudioDeleted] = useState<boolean | null>(null);
|
||||||
const { api_url } = useContext(DomainContext);
|
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const accessTokenInfo =
|
const accessTokenInfo =
|
||||||
auth.status === "authenticated" ? auth.accessToken : null;
|
auth.status === "authenticated" ? auth.accessToken : null;
|
||||||
@@ -78,7 +77,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
|||||||
|
|
||||||
// Audio is not deleted, proceed to load it
|
// Audio is not deleted, proceed to load it
|
||||||
audioElement = document.createElement("audio");
|
audioElement = document.createElement("audio");
|
||||||
audioElement.src = `${api_url}/v1/transcripts/${transcriptId}/audio/mp3`;
|
audioElement.src = `${API_URL}/v1/transcripts/${transcriptId}/audio/mp3`;
|
||||||
audioElement.crossOrigin = "anonymous";
|
audioElement.crossOrigin = "anonymous";
|
||||||
audioElement.preload = "auto";
|
audioElement.preload = "auto";
|
||||||
|
|
||||||
@@ -110,7 +109,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
|||||||
if (handleError) audioElement.removeEventListener("error", handleError);
|
if (handleError) audioElement.removeEventListener("error", handleError);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [transcriptId, transcript, later, api_url]);
|
}, [transcriptId, transcript, later]);
|
||||||
|
|
||||||
const getNow = () => {
|
const getNow = () => {
|
||||||
setLater(false);
|
setLater(false);
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useContext, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Topic, FinalSummary, Status } from "./webSocketTypes";
|
import { Topic, FinalSummary, Status } from "./webSocketTypes";
|
||||||
import { useError } from "../../(errors)/errorContext";
|
import { useError } from "../../(errors)/errorContext";
|
||||||
import { DomainContext } from "../../domainContext";
|
|
||||||
import type { components } from "../../reflector-api";
|
import type { components } from "../../reflector-api";
|
||||||
type AudioWaveform = components["schemas"]["AudioWaveform"];
|
type AudioWaveform = components["schemas"]["AudioWaveform"];
|
||||||
type GetTranscriptSegmentTopic =
|
type GetTranscriptSegmentTopic =
|
||||||
components["schemas"]["GetTranscriptSegmentTopic"];
|
components["schemas"]["GetTranscriptSegmentTopic"];
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { $api } from "../../lib/apiClient";
|
import { $api, WEBSOCKET_URL } from "../../lib/apiClient";
|
||||||
|
|
||||||
export type UseWebSockets = {
|
export type UseWebSockets = {
|
||||||
transcriptTextLive: string;
|
transcriptTextLive: string;
|
||||||
@@ -37,7 +36,6 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
|
|||||||
const [status, setStatus] = useState<Status | null>(null);
|
const [status, setStatus] = useState<Status | null>(null);
|
||||||
const { setError } = useError();
|
const { setError } = useError();
|
||||||
|
|
||||||
const { websocket_url: websocketUrl } = useContext(DomainContext);
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [accumulatedText, setAccumulatedText] = useState<string>("");
|
const [accumulatedText, setAccumulatedText] = useState<string>("");
|
||||||
@@ -328,7 +326,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
|
|||||||
|
|
||||||
if (!transcriptId) return;
|
if (!transcriptId) return;
|
||||||
|
|
||||||
const url = `${websocketUrl}/v1/transcripts/${transcriptId}/events`;
|
const url = `${WEBSOCKET_URL}/v1/transcripts/${transcriptId}/events`;
|
||||||
let ws = new WebSocket(url);
|
let ws = new WebSocket(url);
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
@@ -494,7 +492,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
|
|||||||
return () => {
|
return () => {
|
||||||
ws.close();
|
ws.close();
|
||||||
};
|
};
|
||||||
}, [transcriptId, websocketUrl]);
|
}, [transcriptId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
transcriptTextLive,
|
transcriptTextLive,
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
|
||||||
import { DomainConfig } from "./lib/edgeConfig";
|
|
||||||
|
|
||||||
type DomainContextType = Omit<DomainConfig, "auth_callback_url">;
|
|
||||||
|
|
||||||
export const DomainContext = createContext<DomainContextType>({
|
|
||||||
features: {
|
|
||||||
requireLogin: false,
|
|
||||||
privacy: true,
|
|
||||||
browse: false,
|
|
||||||
sendToZulip: false,
|
|
||||||
},
|
|
||||||
api_url: "",
|
|
||||||
websocket_url: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const DomainContextProvider = ({
|
|
||||||
config,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
config: DomainConfig;
|
|
||||||
children: any;
|
|
||||||
}) => {
|
|
||||||
const [context, setContext] = useState<DomainContextType>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!config) return;
|
|
||||||
const { auth_callback_url, ...others } = config;
|
|
||||||
setContext(others);
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
if (!context) return;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DomainContext.Provider value={context}>{children}</DomainContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get feature config client-side with
|
|
||||||
export const featureEnabled = (
|
|
||||||
featureName: "requireLogin" | "privacy" | "browse" | "sendToZulip",
|
|
||||||
) => {
|
|
||||||
const context = useContext(DomainContext);
|
|
||||||
|
|
||||||
return context.features[featureName] as boolean | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get config server-side (out of react) : see lib/edgeConfig.
|
|
||||||
@@ -3,9 +3,7 @@ import { Metadata, Viewport } from "next";
|
|||||||
import { Poppins } from "next/font/google";
|
import { Poppins } from "next/font/google";
|
||||||
import { ErrorProvider } from "./(errors)/errorContext";
|
import { ErrorProvider } from "./(errors)/errorContext";
|
||||||
import ErrorMessage from "./(errors)/errorMessage";
|
import ErrorMessage from "./(errors)/errorMessage";
|
||||||
import { DomainContextProvider } from "./domainContext";
|
|
||||||
import { RecordingConsentProvider } from "./recordingConsentContext";
|
import { RecordingConsentProvider } from "./recordingConsentContext";
|
||||||
import { getConfig } from "./lib/edgeConfig";
|
|
||||||
import { ErrorBoundary } from "@sentry/nextjs";
|
import { ErrorBoundary } from "@sentry/nextjs";
|
||||||
import { Providers } from "./providers";
|
import { Providers } from "./providers";
|
||||||
|
|
||||||
@@ -68,21 +66,17 @@ export default async function RootLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={poppins.className} suppressHydrationWarning>
|
<html lang="en" className={poppins.className} suppressHydrationWarning>
|
||||||
<body className={"h-[100svh] w-[100svw] overflow-x-hidden relative"}>
|
<body className={"h-[100svh] w-[100svw] overflow-x-hidden relative"}>
|
||||||
<DomainContextProvider config={config}>
|
<RecordingConsentProvider>
|
||||||
<RecordingConsentProvider>
|
<ErrorBoundary fallback={<p>"something went really wrong"</p>}>
|
||||||
<ErrorBoundary fallback={<p>"something went really wrong"</p>}>
|
<ErrorProvider>
|
||||||
<ErrorProvider>
|
<ErrorMessage />
|
||||||
<ErrorMessage />
|
<Providers>{children}</Providers>
|
||||||
<Providers>{children}</Providers>
|
</ErrorProvider>
|
||||||
</ErrorProvider>
|
</ErrorBoundary>
|
||||||
</ErrorBoundary>
|
</RecordingConsentProvider>
|
||||||
</RecordingConsentProvider>
|
|
||||||
</DomainContextProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ import createFetchClient from "openapi-react-query";
|
|||||||
import { assertExistsAndNonEmptyString } from "./utils";
|
import { assertExistsAndNonEmptyString } from "./utils";
|
||||||
import { isBuildPhase } from "./next";
|
import { isBuildPhase } from "./next";
|
||||||
|
|
||||||
const API_URL = !isBuildPhase
|
export const API_URL = !isBuildPhase
|
||||||
? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL)
|
? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL)
|
||||||
: "http://localhost";
|
: "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>({
|
export const client = createClient<paths>({
|
||||||
baseUrl: API_URL,
|
baseUrl: API_URL,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { assertExistsAndNonEmptyString } from "./utils";
|
||||||
|
|
||||||
export const REFRESH_ACCESS_TOKEN_ERROR = "RefreshAccessTokenError" as const;
|
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
|
// 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;
|
export const REFRESH_ACCESS_TOKEN_BEFORE = 4 * 60 * 1000;
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import { get } from "@vercel/edge-config";
|
|
||||||
import { isBuildPhase } from "./next";
|
|
||||||
|
|
||||||
type EdgeConfig = {
|
|
||||||
[domainWithDash: string]: {
|
|
||||||
features: {
|
|
||||||
[featureName in
|
|
||||||
| "requireLogin"
|
|
||||||
| "privacy"
|
|
||||||
| "browse"
|
|
||||||
| "sendToZulip"]: boolean;
|
|
||||||
};
|
|
||||||
auth_callback_url: string;
|
|
||||||
websocket_url: string;
|
|
||||||
api_url: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DomainConfig = EdgeConfig["domainWithDash"];
|
|
||||||
|
|
||||||
// Edge config main keys can only be alphanumeric and _ or -
|
|
||||||
export function edgeKeyToDomain(key: string) {
|
|
||||||
return key.replaceAll("_", ".");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function edgeDomainToKey(domain: string) {
|
|
||||||
return domain.replaceAll(".", "_");
|
|
||||||
}
|
|
||||||
|
|
||||||
// get edge config server-side (prefer DomainContext when available), domain is the hostname
|
|
||||||
export async function getConfig() {
|
|
||||||
if (process.env.NEXT_PUBLIC_ENV === "development") {
|
|
||||||
try {
|
|
||||||
return require("../../config").localConfig;
|
|
||||||
} catch (e) {
|
|
||||||
// next build() WILL try to execute the require above even if conditionally protected
|
|
||||||
// but thank god it at least runs catch{} block properly
|
|
||||||
if (!isBuildPhase) throw new Error(e);
|
|
||||||
return require("../../config-template").localConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const domain = new URL(process.env.NEXT_PUBLIC_SITE_URL!).hostname;
|
|
||||||
let config = await get(edgeDomainToKey(domain));
|
|
||||||
|
|
||||||
if (typeof config !== "object") {
|
|
||||||
console.warn("No config for this domain, falling back to default");
|
|
||||||
config = await get(edgeDomainToKey("default"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof config !== "object") throw Error("Error fetching config");
|
|
||||||
|
|
||||||
return config as DomainConfig;
|
|
||||||
}
|
|
||||||
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: false,
|
||||||
|
privacy: true,
|
||||||
|
browse: false,
|
||||||
|
sendToZulip: false,
|
||||||
|
rooms: false,
|
||||||
|
} 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];
|
||||||
|
};
|
||||||
@@ -72,3 +72,7 @@ export const assertCustomSession = <S extends Session>(s: S): CustomSession => {
|
|||||||
// no other checks for now
|
// no other checks for now
|
||||||
return r as CustomSession;
|
return r as CustomSession;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Mutable<T> = {
|
||||||
|
-readonly [P in keyof T]: T[P];
|
||||||
|
};
|
||||||
|
|||||||
@@ -171,5 +171,6 @@ export const assertNotExists = <T>(
|
|||||||
|
|
||||||
export const assertExistsAndNonEmptyString = (
|
export const assertExistsAndNonEmptyString = (
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
|
err?: string,
|
||||||
): NonEmptyString =>
|
): NonEmptyString =>
|
||||||
parseNonEmptyString(assertExists(value, "Expected non-empty string"));
|
parseNonEmptyString(assertExists(value, err || "Expected non-empty string"));
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { ChakraProvider } from "@chakra-ui/react";
|
import { ChakraProvider } from "@chakra-ui/react";
|
||||||
import system from "./styles/theme";
|
import system from "./styles/theme";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
import { WherebyProvider } from "@whereby.com/browser-sdk/react";
|
|
||||||
import { Toaster } from "./components/ui/toaster";
|
import { Toaster } from "./components/ui/toaster";
|
||||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
@@ -11,6 +11,14 @@ import { queryClient } from "./lib/queryClient";
|
|||||||
import { AuthProvider } from "./lib/AuthProvider";
|
import { AuthProvider } from "./lib/AuthProvider";
|
||||||
import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
|
import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
|
||||||
|
|
||||||
|
const WherebyProvider = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@whereby.com/browser-sdk/react").then((mod) => ({
|
||||||
|
default: mod.WherebyProvider,
|
||||||
|
})),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<NuqsAdapter>
|
<NuqsAdapter>
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
export const localConfig = {
|
|
||||||
features: {
|
|
||||||
requireLogin: true,
|
|
||||||
privacy: true,
|
|
||||||
browse: true,
|
|
||||||
sendToZulip: true,
|
|
||||||
rooms: true,
|
|
||||||
},
|
|
||||||
api_url: "http://127.0.0.1:1250",
|
|
||||||
websocket_url: "ws://127.0.0.1:1250",
|
|
||||||
auth_callback_url: "http://localhost:3000/auth-callback",
|
|
||||||
zulip_streams: "", // Find the value on zulip
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { withAuth } from "next-auth/middleware";
|
import { withAuth } from "next-auth/middleware";
|
||||||
import { getConfig } from "./app/lib/edgeConfig";
|
import { featureEnabled } from "./app/lib/features";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { PROTECTED_PAGES } from "./app/lib/auth";
|
import { PROTECTED_PAGES } from "./app/lib/auth";
|
||||||
|
|
||||||
@@ -19,13 +19,12 @@ export const config = {
|
|||||||
|
|
||||||
export default withAuth(
|
export default withAuth(
|
||||||
async function middleware(request) {
|
async function middleware(request) {
|
||||||
const config = await getConfig();
|
|
||||||
const pathname = request.nextUrl.pathname;
|
const pathname = request.nextUrl.pathname;
|
||||||
|
|
||||||
// feature-flags protected paths
|
// feature-flags protected paths
|
||||||
if (
|
if (
|
||||||
(!config.features.browse && pathname.startsWith("/browse")) ||
|
(!featureEnabled("browse") && pathname.startsWith("/browse")) ||
|
||||||
(!config.features.rooms && pathname.startsWith("/rooms"))
|
(!featureEnabled("rooms") && pathname.startsWith("/rooms"))
|
||||||
) {
|
) {
|
||||||
return NextResponse.redirect(request.nextUrl.origin);
|
return NextResponse.redirect(request.nextUrl.origin);
|
||||||
}
|
}
|
||||||
@@ -33,10 +32,8 @@ export default withAuth(
|
|||||||
{
|
{
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async authorized({ req, token }) {
|
async authorized({ req, token }) {
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
config.features.requireLogin &&
|
featureEnabled("requireLogin") &&
|
||||||
PROTECTED_PAGES.test(req.nextUrl.pathname)
|
PROTECTED_PAGES.test(req.nextUrl.pathname)
|
||||||
) {
|
) {
|
||||||
return !!token;
|
return !!token;
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
"@sentry/nextjs": "^7.77.0",
|
"@sentry/nextjs": "^7.77.0",
|
||||||
"@tanstack/react-query": "^5.85.9",
|
"@tanstack/react-query": "^5.85.9",
|
||||||
"@types/ioredis": "^5.0.0",
|
"@types/ioredis": "^5.0.0",
|
||||||
"@vercel/edge-config": "^0.4.1",
|
|
||||||
"@whereby.com/browser-sdk": "^3.3.4",
|
"@whereby.com/browser-sdk": "^3.3.4",
|
||||||
"autoprefixer": "10.4.20",
|
"autoprefixer": "10.4.20",
|
||||||
"axios": "^1.8.2",
|
"axios": "^1.8.2",
|
||||||
@@ -63,8 +62,7 @@
|
|||||||
"jest": "^30.1.3",
|
"jest": "^30.1.3",
|
||||||
"openapi-typescript": "^7.9.1",
|
"openapi-typescript": "^7.9.1",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"ts-jest": "^29.4.1",
|
"ts-jest": "^29.4.1"
|
||||||
"vercel": "^37.3.0"
|
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748"
|
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748"
|
||||||
}
|
}
|
||||||
|
|||||||
2562
www/pnpm-lock.yaml
generated
2562
www/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user