mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 12:19:06 +00:00
feat: docker-compose for production frontend (#664)
* docker-compose for production frontend * fix: Remove external Redis port mapping for Coolify compatibility Redis should only be accessible within the internal Docker network in Coolify deployments to avoid port conflicts with other applications. * fix: Remove external port mapping for web service in Coolify Coolify handles port exposure through its proxy (Traefik), so services should not expose ports directly in the docker-compose file. * server side client envs * missing vars * nextjs experimental * fix claude 'fix' * remove build env vars compose * docker * remove ports for coolify * review * cleanup --------- Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
This commit is contained in:
@@ -151,7 +151,7 @@ All endpoints prefixed `/v1/`:
|
|||||||
|
|
||||||
**Frontend** (`www/.env`):
|
**Frontend** (`www/.env`):
|
||||||
- `NEXTAUTH_URL`, `NEXTAUTH_SECRET` - Authentication configuration
|
- `NEXTAUTH_URL`, `NEXTAUTH_SECRET` - Authentication configuration
|
||||||
- `NEXT_PUBLIC_REFLECTOR_API_URL` - Backend API endpoint
|
- `REFLECTOR_API_URL` - Backend API endpoint
|
||||||
- `REFLECTOR_DOMAIN_CONFIG` - Feature flags and domain settings
|
- `REFLECTOR_DOMAIN_CONFIG` - Feature flags and domain settings
|
||||||
|
|
||||||
## Testing Strategy
|
## Testing Strategy
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -168,6 +168,13 @@ You can manually process an audio file by calling the process tool:
|
|||||||
uv run python -m reflector.tools.process path/to/audio.wav
|
uv run python -m reflector.tools.process path/to/audio.wav
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Build-time env variables
|
||||||
|
|
||||||
|
Next.js projects are more used to NEXT_PUBLIC_ prefixed buildtime vars. We don't have those for the reason we need to serve a ccustomizable prebuild docker container.
|
||||||
|
|
||||||
|
Instead, all the variables are runtime. Variables needed to the frontend are served to the frontend app at initial render.
|
||||||
|
|
||||||
|
It also means there's no static prebuild and no static files to serve for js/html.
|
||||||
|
|
||||||
## Feature Flags
|
## Feature Flags
|
||||||
|
|
||||||
@@ -177,24 +184,24 @@ Reflector uses environment variable-based feature flags to control application f
|
|||||||
|
|
||||||
| Feature Flag | Environment Variable |
|
| Feature Flag | Environment Variable |
|
||||||
|-------------|---------------------|
|
|-------------|---------------------|
|
||||||
| `requireLogin` | `NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN` |
|
| `requireLogin` | `FEATURE_REQUIRE_LOGIN` |
|
||||||
| `privacy` | `NEXT_PUBLIC_FEATURE_PRIVACY` |
|
| `privacy` | `FEATURE_PRIVACY` |
|
||||||
| `browse` | `NEXT_PUBLIC_FEATURE_BROWSE` |
|
| `browse` | `FEATURE_BROWSE` |
|
||||||
| `sendToZulip` | `NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP` |
|
| `sendToZulip` | `FEATURE_SEND_TO_ZULIP` |
|
||||||
| `rooms` | `NEXT_PUBLIC_FEATURE_ROOMS` |
|
| `rooms` | `FEATURE_ROOMS` |
|
||||||
|
|
||||||
### Setting Feature Flags
|
### 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.
|
Feature flags are controlled via environment variables using the pattern `FEATURE_{FEATURE_NAME}` where `{FEATURE_NAME}` is the SCREAMING_SNAKE_CASE version of the feature name.
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
```bash
|
```bash
|
||||||
# Enable user authentication requirement
|
# Enable user authentication requirement
|
||||||
NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true
|
FEATURE_REQUIRE_LOGIN=true
|
||||||
|
|
||||||
# Disable browse functionality
|
# Disable browse functionality
|
||||||
NEXT_PUBLIC_FEATURE_BROWSE=false
|
FEATURE_BROWSE=false
|
||||||
|
|
||||||
# Enable Zulip integration
|
# Enable Zulip integration
|
||||||
NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true
|
FEATURE_SEND_TO_ZULIP=true
|
||||||
```
|
```
|
||||||
|
|||||||
39
docker-compose.prod.yml
Normal file
39
docker-compose.prod.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Production Docker Compose configuration for Frontend
|
||||||
|
# Usage: docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ./www
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: reflector-frontend:latest
|
||||||
|
environment:
|
||||||
|
- KV_URL=${KV_URL:-redis://redis:6379}
|
||||||
|
- SITE_URL=${SITE_URL}
|
||||||
|
- API_URL=${API_URL}
|
||||||
|
- WEBSOCKET_URL=${WEBSOCKET_URL}
|
||||||
|
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
|
||||||
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-changeme-in-production}
|
||||||
|
- AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER}
|
||||||
|
- AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID}
|
||||||
|
- AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET}
|
||||||
|
- AUTHENTIK_REFRESH_TOKEN_URL=${AUTHENTIK_REFRESH_TOKEN_URL}
|
||||||
|
- SENTRY_DSN=${SENTRY_DSN}
|
||||||
|
- SENTRY_IGNORE_API_RESOLUTION_ERROR=${SENTRY_IGNORE_API_RESOLUTION_ERROR:-1}
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7.2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis_data:
|
||||||
@@ -39,7 +39,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
web:
|
web:
|
||||||
image: node:18
|
image: node:22-alpine
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
command: sh -c "corepack enable && pnpm install && pnpm dev"
|
command: sh -c "corepack enable && pnpm install && pnpm dev"
|
||||||
@@ -50,6 +50,8 @@ services:
|
|||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
env_file:
|
env_file:
|
||||||
- ./www/.env.local
|
- ./www/.env.local
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
@@ -65,6 +65,12 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
|
||||||
# metrics
|
# metrics
|
||||||
instrumentator = Instrumentator(
|
instrumentator = Instrumentator(
|
||||||
excluded_handlers=["/docs", "/metrics"],
|
excluded_handlers=["/docs", "/metrics"],
|
||||||
|
|||||||
14
www/.dockerignore
Normal file
14
www/.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
.env.production
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.DS_Store
|
||||||
|
coverage
|
||||||
|
.pnpm-store
|
||||||
|
*.log
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
# Environment
|
|
||||||
ENVIRONMENT=development
|
|
||||||
NEXT_PUBLIC_ENV=development
|
|
||||||
|
|
||||||
# Site Configuration
|
# Site Configuration
|
||||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
SITE_URL=http://localhost:3000
|
||||||
|
|
||||||
# Nextauth envs
|
# Nextauth envs
|
||||||
# not used in app code but in lib code
|
# not used in app code but in lib code
|
||||||
@@ -18,16 +14,16 @@ AUTHENTIK_CLIENT_ID=your-client-id-here
|
|||||||
AUTHENTIK_CLIENT_SECRET=your-client-secret-here
|
AUTHENTIK_CLIENT_SECRET=your-client-secret-here
|
||||||
|
|
||||||
# Feature Flags
|
# Feature Flags
|
||||||
# NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true
|
# FEATURE_REQUIRE_LOGIN=true
|
||||||
# NEXT_PUBLIC_FEATURE_PRIVACY=false
|
# FEATURE_PRIVACY=false
|
||||||
# NEXT_PUBLIC_FEATURE_BROWSE=true
|
# FEATURE_BROWSE=true
|
||||||
# NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true
|
# FEATURE_SEND_TO_ZULIP=true
|
||||||
# NEXT_PUBLIC_FEATURE_ROOMS=true
|
# FEATURE_ROOMS=true
|
||||||
|
|
||||||
# API URLs
|
# API URLs
|
||||||
NEXT_PUBLIC_API_URL=http://127.0.0.1:1250
|
API_URL=http://127.0.0.1:1250
|
||||||
NEXT_PUBLIC_WEBSOCKET_URL=ws://127.0.0.1:1250
|
WEBSOCKET_URL=ws://127.0.0.1:1250
|
||||||
NEXT_PUBLIC_AUTH_CALLBACK_URL=http://localhost:3000/auth-callback
|
AUTH_CALLBACK_URL=http://localhost:3000/auth-callback
|
||||||
|
|
||||||
# Sentry
|
# Sentry
|
||||||
# SENTRY_DSN=https://your-dsn@sentry.io/project-id
|
# SENTRY_DSN=https://your-dsn@sentry.io/project-id
|
||||||
|
|||||||
81
www/DOCKER_README.md
Normal file
81
www/DOCKER_README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Docker Production Build Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Docker image builds without any environment variables and requires all configuration to be provided at runtime.
|
||||||
|
|
||||||
|
## Environment Variables (ALL Runtime)
|
||||||
|
|
||||||
|
### Required Runtime Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
API_URL # Backend API URL (e.g., https://api.example.com)
|
||||||
|
WEBSOCKET_URL # WebSocket URL (e.g., wss://api.example.com)
|
||||||
|
NEXTAUTH_URL # NextAuth base URL (e.g., https://app.example.com)
|
||||||
|
NEXTAUTH_SECRET # Random secret for NextAuth (generate with: openssl rand -base64 32)
|
||||||
|
KV_URL # Redis URL (e.g., redis://redis:6379)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Runtime Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SITE_URL # Frontend URL (defaults to NEXTAUTH_URL)
|
||||||
|
|
||||||
|
AUTHENTIK_ISSUER # OAuth issuer URL
|
||||||
|
AUTHENTIK_CLIENT_ID # OAuth client ID
|
||||||
|
AUTHENTIK_CLIENT_SECRET # OAuth client secret
|
||||||
|
AUTHENTIK_REFRESH_TOKEN_URL # OAuth token refresh URL
|
||||||
|
|
||||||
|
FEATURE_REQUIRE_LOGIN=false # Require authentication
|
||||||
|
FEATURE_PRIVACY=true # Enable privacy features
|
||||||
|
FEATURE_BROWSE=true # Enable browsing features
|
||||||
|
FEATURE_SEND_TO_ZULIP=false # Enable Zulip integration
|
||||||
|
FEATURE_ROOMS=true # Enable rooms feature
|
||||||
|
|
||||||
|
SENTRY_DSN # Sentry error tracking
|
||||||
|
AUTH_CALLBACK_URL # OAuth callback URL
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building the Image
|
||||||
|
|
||||||
|
### Option 1: Using Docker Compose
|
||||||
|
|
||||||
|
1. Build the image (no environment variables needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create a `.env` file with runtime variables
|
||||||
|
|
||||||
|
3. Run with environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Using Docker CLI
|
||||||
|
|
||||||
|
1. Build the image (no build args):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t reflector-frontend:latest ./www
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run with environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-e API_URL=https://api.example.com \
|
||||||
|
-e WEBSOCKET_URL=wss://api.example.com \
|
||||||
|
-e NEXTAUTH_URL=https://app.example.com \
|
||||||
|
-e NEXTAUTH_SECRET=your-secret \
|
||||||
|
-e KV_URL=redis://redis:6379 \
|
||||||
|
-e AUTHENTIK_ISSUER=https://auth.example.com/application/o/reflector \
|
||||||
|
-e AUTHENTIK_CLIENT_ID=your-client-id \
|
||||||
|
-e AUTHENTIK_CLIENT_SECRET=your-client-secret \
|
||||||
|
-e AUTHENTIK_REFRESH_TOKEN_URL=https://auth.example.com/application/o/token/ \
|
||||||
|
-e FEATURE_REQUIRE_LOGIN=true \
|
||||||
|
reflector-frontend:latest
|
||||||
|
```
|
||||||
@@ -24,7 +24,8 @@ COPY --link . .
|
|||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
# If using npm comment out above and use below instead
|
# If using npm comment out above and use below instead
|
||||||
RUN pnpm build
|
# next.js has the feature of excluding build step planned https://github.com/vercel/next.js/discussions/46544
|
||||||
|
RUN pnpm build-production
|
||||||
# RUN npm run build
|
# RUN npm run build
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Production image, copy all the files and run next
|
||||||
@@ -51,6 +52,10 @@ USER nextjs
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV PORT 3000
|
ENV PORT 3000
|
||||||
ENV HOSTNAME localhost
|
ENV HOSTNAME 0.0.0.0
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health \
|
||||||
|
|| exit 1
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -200,7 +200,13 @@ export default function ICSSettings({
|
|||||||
<HStack gap={0} position="relative" width="100%">
|
<HStack gap={0} position="relative" width="100%">
|
||||||
<Input
|
<Input
|
||||||
ref={roomUrlInputRef}
|
ref={roomUrlInputRef}
|
||||||
value={roomAbsoluteUrl(parseNonEmptyString(roomName))}
|
value={roomAbsoluteUrl(
|
||||||
|
parseNonEmptyString(
|
||||||
|
roomName,
|
||||||
|
true,
|
||||||
|
"panic! roomName is required",
|
||||||
|
),
|
||||||
|
)}
|
||||||
readOnly
|
readOnly
|
||||||
onClick={handleRoomUrlClick}
|
onClick={handleRoomUrlClick}
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
|
|||||||
@@ -274,15 +274,31 @@ export function RoomTable({
|
|||||||
<IconButton
|
<IconButton
|
||||||
aria-label="Force sync calendar"
|
aria-label="Force sync calendar"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleForceSync(parseNonEmptyString(room.name))
|
handleForceSync(
|
||||||
|
parseNonEmptyString(
|
||||||
|
room.name,
|
||||||
|
true,
|
||||||
|
"panic! room.name is required",
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={syncingRooms.has(
|
disabled={syncingRooms.has(
|
||||||
parseNonEmptyString(room.name),
|
parseNonEmptyString(
|
||||||
|
room.name,
|
||||||
|
true,
|
||||||
|
"panic! room.name is required",
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{syncingRooms.has(parseNonEmptyString(room.name)) ? (
|
{syncingRooms.has(
|
||||||
|
parseNonEmptyString(
|
||||||
|
room.name,
|
||||||
|
true,
|
||||||
|
"panic! room.name is required",
|
||||||
|
),
|
||||||
|
) ? (
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
) : (
|
) : (
|
||||||
<CalendarSyncIcon />
|
<CalendarSyncIcon />
|
||||||
@@ -297,7 +313,13 @@ export function RoomTable({
|
|||||||
<IconButton
|
<IconButton
|
||||||
aria-label="Copy URL"
|
aria-label="Copy URL"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onCopyUrl(parseNonEmptyString(room.name))
|
onCopyUrl(
|
||||||
|
parseNonEmptyString(
|
||||||
|
room.name,
|
||||||
|
true,
|
||||||
|
"panic! room.name is required",
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -833,7 +833,13 @@ export default function RoomsList() {
|
|||||||
<Field.Root>
|
<Field.Root>
|
||||||
<ICSSettings
|
<ICSSettings
|
||||||
roomName={
|
roomName={
|
||||||
room.name ? parseNonEmptyString(room.name) : null
|
room.name
|
||||||
|
? parseNonEmptyString(
|
||||||
|
room.name,
|
||||||
|
true,
|
||||||
|
"panic! room.name required",
|
||||||
|
)
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
icsUrl={room.icsUrl}
|
icsUrl={room.icsUrl}
|
||||||
icsEnabled={room.icsEnabled}
|
icsEnabled={room.icsEnabled}
|
||||||
|
|||||||
@@ -261,7 +261,11 @@ export default function Room(details: RoomDetails) {
|
|||||||
const params = use(details.params);
|
const params = use(details.params);
|
||||||
const wherebyLoaded = useWhereby();
|
const wherebyLoaded = useWhereby();
|
||||||
const wherebyRef = useRef<HTMLElement>(null);
|
const wherebyRef = useRef<HTMLElement>(null);
|
||||||
const roomName = parseNonEmptyString(params.roomName);
|
const roomName = parseNonEmptyString(
|
||||||
|
params.roomName,
|
||||||
|
true,
|
||||||
|
"panic! params.roomName is required",
|
||||||
|
);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const status = auth.status;
|
const status = auth.status;
|
||||||
@@ -308,7 +312,14 @@ export default function Room(details: RoomDetails) {
|
|||||||
|
|
||||||
const handleMeetingSelect = (selectedMeeting: Meeting) => {
|
const handleMeetingSelect = (selectedMeeting: Meeting) => {
|
||||||
router.push(
|
router.push(
|
||||||
roomMeetingUrl(roomName, parseNonEmptyString(selectedMeeting.id)),
|
roomMeetingUrl(
|
||||||
|
roomName,
|
||||||
|
parseNonEmptyString(
|
||||||
|
selectedMeeting.id,
|
||||||
|
true,
|
||||||
|
"panic! selectedMeeting.id is required",
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
38
www/app/api/health/route.ts
Normal file
38
www/app/api/health/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const health = {
|
||||||
|
status: "healthy",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
environment: process.env.NODE_ENV,
|
||||||
|
checks: {
|
||||||
|
redis: await checkRedis(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const allHealthy = Object.values(health.checks).every((check) => check);
|
||||||
|
|
||||||
|
return NextResponse.json(health, {
|
||||||
|
status: allHealthy ? 200 : 503,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkRedis(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (!process.env.KV_URL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tokenCacheRedis } = await import("../../lib/redisClient");
|
||||||
|
const testKey = `health:check:${Date.now()}`;
|
||||||
|
await tokenCacheRedis.setex(testKey, 10, "OK");
|
||||||
|
const value = await tokenCacheRedis.get(testKey);
|
||||||
|
await tokenCacheRedis.del(testKey);
|
||||||
|
|
||||||
|
return value === "OK";
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Redis health check failed:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@ import ErrorMessage from "./(errors)/errorMessage";
|
|||||||
import { RecordingConsentProvider } from "./recordingConsentContext";
|
import { RecordingConsentProvider } from "./recordingConsentContext";
|
||||||
import { ErrorBoundary } from "@sentry/nextjs";
|
import { ErrorBoundary } from "@sentry/nextjs";
|
||||||
import { Providers } from "./providers";
|
import { Providers } from "./providers";
|
||||||
import { assertExistsAndNonEmptyString } from "./lib/utils";
|
import { getNextEnvVar } from "./lib/nextBuild";
|
||||||
|
import { getClientEnv } from "./lib/clientEnv";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const poppins = Poppins({
|
const poppins = Poppins({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -21,13 +24,11 @@ export const viewport: Viewport = {
|
|||||||
maximumScale: 1,
|
maximumScale: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const NEXT_PUBLIC_SITE_URL = assertExistsAndNonEmptyString(
|
const SITE_URL = getNextEnvVar("SITE_URL");
|
||||||
process.env.NEXT_PUBLIC_SITE_URL,
|
const env = getClientEnv();
|
||||||
"NEXT_PUBLIC_SITE_URL required",
|
|
||||||
);
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(NEXT_PUBLIC_SITE_URL),
|
metadataBase: new URL(SITE_URL),
|
||||||
title: {
|
title: {
|
||||||
template: "%s – Reflector",
|
template: "%s – Reflector",
|
||||||
default: "Reflector - AI-Powered Meeting Transcriptions by Monadical",
|
default: "Reflector - AI-Powered Meeting Transcriptions by Monadical",
|
||||||
@@ -74,15 +75,16 @@ export default async function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
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
|
||||||
<RecordingConsentProvider>
|
className={"h-[100svh] w-[100svw] overflow-x-hidden relative"}
|
||||||
|
data-env={JSON.stringify(env)}
|
||||||
|
>
|
||||||
<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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,21 +3,19 @@
|
|||||||
import createClient from "openapi-fetch";
|
import createClient from "openapi-fetch";
|
||||||
import type { paths } from "../reflector-api";
|
import type { paths } from "../reflector-api";
|
||||||
import createFetchClient from "openapi-react-query";
|
import createFetchClient from "openapi-react-query";
|
||||||
import { assertExistsAndNonEmptyString, parseNonEmptyString } from "./utils";
|
import { parseNonEmptyString } from "./utils";
|
||||||
import { isBuildPhase } from "./next";
|
import { isBuildPhase } from "./next";
|
||||||
import { getSession } from "next-auth/react";
|
import { getSession } from "next-auth/react";
|
||||||
import { assertExtendedToken } from "./types";
|
import { assertExtendedToken } from "./types";
|
||||||
|
import { getClientEnv } from "./clientEnv";
|
||||||
|
|
||||||
export const API_URL = !isBuildPhase
|
export const API_URL = !isBuildPhase
|
||||||
? assertExistsAndNonEmptyString(
|
? getClientEnv().API_URL
|
||||||
process.env.NEXT_PUBLIC_API_URL,
|
|
||||||
"NEXT_PUBLIC_API_URL required",
|
|
||||||
)
|
|
||||||
: "http://localhost";
|
: "http://localhost";
|
||||||
|
|
||||||
// TODO decide strict validation or not
|
export const WEBSOCKET_URL = !isBuildPhase
|
||||||
export const WEBSOCKET_URL =
|
? getClientEnv().WEBSOCKET_URL || "ws://127.0.0.1:1250"
|
||||||
process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://127.0.0.1:1250";
|
: "ws://localhost";
|
||||||
|
|
||||||
export const client = createClient<paths>({
|
export const client = createClient<paths>({
|
||||||
baseUrl: API_URL,
|
baseUrl: API_URL,
|
||||||
@@ -44,7 +42,7 @@ client.use({
|
|||||||
if (token !== null) {
|
if (token !== null) {
|
||||||
request.headers.set(
|
request.headers.set(
|
||||||
"Authorization",
|
"Authorization",
|
||||||
`Bearer ${parseNonEmptyString(token)}`,
|
`Bearer ${parseNonEmptyString(token, true, "panic! token is required")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// XXX Only set Content-Type if not already set (FormData will set its own boundary)
|
// XXX Only set Content-Type if not already set (FormData will set its own boundary)
|
||||||
|
|||||||
@@ -18,26 +18,25 @@ import {
|
|||||||
deleteTokenCache,
|
deleteTokenCache,
|
||||||
} from "./redisTokenCache";
|
} from "./redisTokenCache";
|
||||||
import { tokenCacheRedis, redlock } from "./redisClient";
|
import { tokenCacheRedis, redlock } from "./redisClient";
|
||||||
import { isBuildPhase } from "./next";
|
|
||||||
import { sequenceThrows } from "./errorUtils";
|
import { sequenceThrows } from "./errorUtils";
|
||||||
import { featureEnabled } from "./features";
|
import { featureEnabled } from "./features";
|
||||||
|
import { getNextEnvVar } from "./nextBuild";
|
||||||
|
|
||||||
const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
|
const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
|
||||||
const getAuthentikClientId = () =>
|
const getAuthentikClientId = () => getNextEnvVar("AUTHENTIK_CLIENT_ID");
|
||||||
assertExistsAndNonEmptyString(
|
const getAuthentikClientSecret = () => getNextEnvVar("AUTHENTIK_CLIENT_SECRET");
|
||||||
process.env.AUTHENTIK_CLIENT_ID,
|
|
||||||
"AUTHENTIK_CLIENT_ID required",
|
|
||||||
);
|
|
||||||
const getAuthentikClientSecret = () =>
|
|
||||||
assertExistsAndNonEmptyString(
|
|
||||||
process.env.AUTHENTIK_CLIENT_SECRET,
|
|
||||||
"AUTHENTIK_CLIENT_SECRET required",
|
|
||||||
);
|
|
||||||
const getAuthentikRefreshTokenUrl = () =>
|
const getAuthentikRefreshTokenUrl = () =>
|
||||||
assertExistsAndNonEmptyString(
|
getNextEnvVar("AUTHENTIK_REFRESH_TOKEN_URL");
|
||||||
process.env.AUTHENTIK_REFRESH_TOKEN_URL,
|
|
||||||
"AUTHENTIK_REFRESH_TOKEN_URL required",
|
const getAuthentikIssuer = () => {
|
||||||
);
|
const stringUrl = getNextEnvVar("AUTHENTIK_ISSUER");
|
||||||
|
try {
|
||||||
|
new URL(stringUrl);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error("AUTHENTIK_ISSUER is not a valid URL: " + stringUrl);
|
||||||
|
}
|
||||||
|
return stringUrl;
|
||||||
|
};
|
||||||
|
|
||||||
export const authOptions = (): AuthOptions =>
|
export const authOptions = (): AuthOptions =>
|
||||||
featureEnabled("requireLogin")
|
featureEnabled("requireLogin")
|
||||||
@@ -45,16 +44,17 @@ export const authOptions = (): AuthOptions =>
|
|||||||
providers: [
|
providers: [
|
||||||
AuthentikProvider({
|
AuthentikProvider({
|
||||||
...(() => {
|
...(() => {
|
||||||
const [clientId, clientSecret] = sequenceThrows(
|
const [clientId, clientSecret, issuer] = sequenceThrows(
|
||||||
getAuthentikClientId,
|
getAuthentikClientId,
|
||||||
getAuthentikClientSecret,
|
getAuthentikClientSecret,
|
||||||
|
getAuthentikIssuer,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
|
issuer,
|
||||||
};
|
};
|
||||||
})(),
|
})(),
|
||||||
issuer: process.env.AUTHENTIK_ISSUER,
|
|
||||||
authorization: {
|
authorization: {
|
||||||
params: {
|
params: {
|
||||||
scope: "openid email profile offline_access",
|
scope: "openid email profile offline_access",
|
||||||
|
|||||||
88
www/app/lib/clientEnv.ts
Normal file
88
www/app/lib/clientEnv.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
assertExists,
|
||||||
|
assertExistsAndNonEmptyString,
|
||||||
|
NonEmptyString,
|
||||||
|
parseNonEmptyString,
|
||||||
|
} from "./utils";
|
||||||
|
import { isBuildPhase } from "./next";
|
||||||
|
import { getNextEnvVar } from "./nextBuild";
|
||||||
|
|
||||||
|
export const FEATURE_REQUIRE_LOGIN_ENV_NAME = "FEATURE_REQUIRE_LOGIN" as const;
|
||||||
|
export const FEATURE_PRIVACY_ENV_NAME = "FEATURE_PRIVACY" as const;
|
||||||
|
export const FEATURE_BROWSE_ENV_NAME = "FEATURE_BROWSE" as const;
|
||||||
|
export const FEATURE_SEND_TO_ZULIP_ENV_NAME = "FEATURE_SEND_TO_ZULIP" as const;
|
||||||
|
export const FEATURE_ROOMS_ENV_NAME = "FEATURE_ROOMS" as const;
|
||||||
|
|
||||||
|
const FEATURE_ENV_NAMES = [
|
||||||
|
FEATURE_REQUIRE_LOGIN_ENV_NAME,
|
||||||
|
FEATURE_PRIVACY_ENV_NAME,
|
||||||
|
FEATURE_BROWSE_ENV_NAME,
|
||||||
|
FEATURE_SEND_TO_ZULIP_ENV_NAME,
|
||||||
|
FEATURE_ROOMS_ENV_NAME,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type EnvFeaturePartial = {
|
||||||
|
[key in (typeof FEATURE_ENV_NAMES)[number]]: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// CONTRACT: isomorphic with JSON.stringify
|
||||||
|
export type ClientEnvCommon = EnvFeaturePartial & {
|
||||||
|
API_URL: NonEmptyString;
|
||||||
|
WEBSOCKET_URL: NonEmptyString | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let clientEnv: ClientEnvCommon | null = null;
|
||||||
|
export const getClientEnvClient = (): ClientEnvCommon => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
throw new Error(
|
||||||
|
"getClientEnv() called during SSR - this should only be called in browser environment",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (clientEnv) return clientEnv;
|
||||||
|
clientEnv = assertExists(
|
||||||
|
JSON.parse(
|
||||||
|
assertExistsAndNonEmptyString(
|
||||||
|
document.body.dataset.env,
|
||||||
|
"document.body.dataset.env is missing",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"document.body.dataset.env is parsed to nullish",
|
||||||
|
);
|
||||||
|
return clientEnv!;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseBooleanString = (str: string | undefined): boolean => {
|
||||||
|
return str === "true";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getClientEnvServer = (): ClientEnvCommon => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
throw new Error(
|
||||||
|
"getClientEnv() not called during SSR - this should only be called in server environment",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (clientEnv) return clientEnv;
|
||||||
|
|
||||||
|
const features = FEATURE_ENV_NAMES.reduce((acc, x) => {
|
||||||
|
acc[x] = parseBooleanString(process.env[x]);
|
||||||
|
return acc;
|
||||||
|
}, {} as EnvFeaturePartial);
|
||||||
|
|
||||||
|
if (isBuildPhase) {
|
||||||
|
return {
|
||||||
|
API_URL: getNextEnvVar("API_URL"),
|
||||||
|
WEBSOCKET_URL: getNextEnvVar("WEBSOCKET_URL"),
|
||||||
|
...features,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
clientEnv = {
|
||||||
|
API_URL: getNextEnvVar("API_URL"),
|
||||||
|
WEBSOCKET_URL: getNextEnvVar("WEBSOCKET_URL"),
|
||||||
|
...features,
|
||||||
|
};
|
||||||
|
return clientEnv;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getClientEnv =
|
||||||
|
typeof window === "undefined" ? getClientEnvServer : getClientEnvClient;
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
|
import {
|
||||||
|
FEATURE_BROWSE_ENV_NAME,
|
||||||
|
FEATURE_PRIVACY_ENV_NAME,
|
||||||
|
FEATURE_REQUIRE_LOGIN_ENV_NAME,
|
||||||
|
FEATURE_ROOMS_ENV_NAME,
|
||||||
|
FEATURE_SEND_TO_ZULIP_ENV_NAME,
|
||||||
|
} from "./clientEnv";
|
||||||
|
|
||||||
export const FEATURES = [
|
export const FEATURES = [
|
||||||
"requireLogin",
|
"requireLogin",
|
||||||
"privacy",
|
"privacy",
|
||||||
@@ -26,26 +34,25 @@ function parseBooleanEnv(
|
|||||||
return value.toLowerCase() === "true";
|
return value.toLowerCase() === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
// WARNING: keep process.env.* as-is, next.js won't see them if you generate dynamically
|
|
||||||
const features: Features = {
|
const features: Features = {
|
||||||
requireLogin: parseBooleanEnv(
|
requireLogin: parseBooleanEnv(
|
||||||
process.env.NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN,
|
process.env[FEATURE_REQUIRE_LOGIN_ENV_NAME],
|
||||||
DEFAULT_FEATURES.requireLogin,
|
DEFAULT_FEATURES.requireLogin,
|
||||||
),
|
),
|
||||||
privacy: parseBooleanEnv(
|
privacy: parseBooleanEnv(
|
||||||
process.env.NEXT_PUBLIC_FEATURE_PRIVACY,
|
process.env[FEATURE_PRIVACY_ENV_NAME],
|
||||||
DEFAULT_FEATURES.privacy,
|
DEFAULT_FEATURES.privacy,
|
||||||
),
|
),
|
||||||
browse: parseBooleanEnv(
|
browse: parseBooleanEnv(
|
||||||
process.env.NEXT_PUBLIC_FEATURE_BROWSE,
|
process.env[FEATURE_BROWSE_ENV_NAME],
|
||||||
DEFAULT_FEATURES.browse,
|
DEFAULT_FEATURES.browse,
|
||||||
),
|
),
|
||||||
sendToZulip: parseBooleanEnv(
|
sendToZulip: parseBooleanEnv(
|
||||||
process.env.NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP,
|
process.env[FEATURE_SEND_TO_ZULIP_ENV_NAME],
|
||||||
DEFAULT_FEATURES.sendToZulip,
|
DEFAULT_FEATURES.sendToZulip,
|
||||||
),
|
),
|
||||||
rooms: parseBooleanEnv(
|
rooms: parseBooleanEnv(
|
||||||
process.env.NEXT_PUBLIC_FEATURE_ROOMS,
|
process.env[FEATURE_ROOMS_ENV_NAME],
|
||||||
DEFAULT_FEATURES.rooms,
|
DEFAULT_FEATURES.rooms,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
17
www/app/lib/nextBuild.ts
Normal file
17
www/app/lib/nextBuild.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { isBuildPhase } from "./next";
|
||||||
|
import { assertExistsAndNonEmptyString, NonEmptyString } from "./utils";
|
||||||
|
|
||||||
|
const _getNextEnvVar = (name: string, e?: string): NonEmptyString =>
|
||||||
|
isBuildPhase
|
||||||
|
? (() => {
|
||||||
|
throw new Error(
|
||||||
|
"panic! getNextEnvVar called during build phase; we don't support build envs",
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
: assertExistsAndNonEmptyString(
|
||||||
|
process.env[name],
|
||||||
|
`${name} is required; ${e}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getNextEnvVar = (name: string, e?: string): NonEmptyString =>
|
||||||
|
_getNextEnvVar(name, e);
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
export function isDevelopment() {
|
|
||||||
return process.env.NEXT_PUBLIC_ENV === "development";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to calculate WCAG contrast ratio
|
// Function to calculate WCAG contrast ratio
|
||||||
export const getContrastRatio = (
|
export const getContrastRatio = (
|
||||||
foreground: [number, number, number],
|
foreground: [number, number, number],
|
||||||
@@ -145,8 +141,15 @@ export const parseMaybeNonEmptyString = (
|
|||||||
s = trim ? s.trim() : s;
|
s = trim ? s.trim() : s;
|
||||||
return s.length > 0 ? (s as NonEmptyString) : null;
|
return s.length > 0 ? (s as NonEmptyString) : null;
|
||||||
};
|
};
|
||||||
export const parseNonEmptyString = (s: string, trim = true): NonEmptyString =>
|
export const parseNonEmptyString = (
|
||||||
assertExists(parseMaybeNonEmptyString(s, trim), "Expected non-empty string");
|
s: string,
|
||||||
|
trim = true,
|
||||||
|
e?: string,
|
||||||
|
): NonEmptyString =>
|
||||||
|
assertExists(
|
||||||
|
parseMaybeNonEmptyString(s, trim),
|
||||||
|
"Expected non-empty string" + (e ? `: ${e}` : ""),
|
||||||
|
);
|
||||||
|
|
||||||
export const assertExists = <T>(
|
export const assertExists = <T>(
|
||||||
value: T | null | undefined,
|
value: T | null | undefined,
|
||||||
@@ -173,4 +176,8 @@ export const assertExistsAndNonEmptyString = (
|
|||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
err?: string,
|
err?: string,
|
||||||
): NonEmptyString =>
|
): NonEmptyString =>
|
||||||
parseNonEmptyString(assertExists(value, err || "Expected non-empty string"));
|
parseNonEmptyString(
|
||||||
|
assertExists(value, err || "Expected non-empty string"),
|
||||||
|
true,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { queryClient } from "./lib/queryClient";
|
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";
|
||||||
|
import { RecordingConsentProvider } from "./recordingConsentContext";
|
||||||
|
|
||||||
const WherebyProvider = dynamic(
|
const WherebyProvider = dynamic(
|
||||||
() =>
|
() =>
|
||||||
@@ -26,10 +27,12 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||||||
<SessionProviderNextAuth>
|
<SessionProviderNextAuth>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ChakraProvider value={system}>
|
<ChakraProvider value={system}>
|
||||||
|
<RecordingConsentProvider>
|
||||||
<WherebyProvider>
|
<WherebyProvider>
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</WherebyProvider>
|
</WherebyProvider>
|
||||||
|
</RecordingConsentProvider>
|
||||||
</ChakraProvider>
|
</ChakraProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</SessionProviderNextAuth>
|
</SessionProviderNextAuth>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
"build-production": "next build --experimental-build-mode compile",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
|
|||||||
Reference in New Issue
Block a user