67 Commits
1.4.0 ... main

Author SHA1 Message Date
8cbe28f915 chore: bump version to 2.1.2 and update user name handling in GuestJoin component
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m28s
Build & Push Docker Image / build (release) Successful in 4m20s
2026-03-26 09:40:41 +01:00
5472e190d9 chore: Bump version to 2.1.1
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m19s
Build & Push Docker Image / build (release) Successful in 4m12s
2026-03-25 11:34:38 +01:00
45be976de1 Don't show guestWaitingMessage when "anyone_can_start" is set
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m14s
2026-03-25 10:13:02 +01:00
6dcb1e959b feat: allow guests to start a room if anyone_can_start is enabled
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m58s
2026-03-25 09:55:47 +01:00
bb2d179871 style: Update button styling and icon size in RecordingList component for improved UI
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m29s
2026-03-24 11:28:15 +01:00
82b7d060ba Merge remote-tracking branch 'refs/remotes/origin/main'
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m34s
2026-03-16 13:32:45 +01:00
0836436fe7 feat: Implement Two-Factor Authentication (2FA) for enhanced user account security. 2026-03-16 13:28:43 +01:00
99d3b22f62 chore: update bcryptjs and better-sqlite3 dependencies; upgrade dotenv version
All checks were successful
Build & Push Docker Image / build (release) Successful in 4m5s
Build & Push Docker Image / build (push) Successful in 3m59s
2026-03-13 23:00:38 +01:00
eed5d98ccc chore: update dependencies for Vite and React plugin
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m9s
2026-03-13 22:48:08 +01:00
6513fdee41 fix(Dockerfile): update base image and streamline build stages
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m17s
2026-03-13 22:41:18 +01:00
cae84754e4 feat: add analytics visibility settings and export functionality
All checks were successful
Build & Push Docker Image / build (push) Successful in 5m11s
- Added `analytics_visibility` column to `rooms` table to control who can view analytics data.
- Updated analytics routes to check visibility settings before allowing access and export of analytics data.
- Implemented export functionality for analytics in CSV, XLSX, and PDF formats.
- Enhanced `AnalyticsList` component to include export options for analytics entries.
- Updated room detail page to allow setting analytics visibility when creating or editing rooms.
- Added translations for new analytics visibility options and export messages.
2026-03-13 22:36:07 +01:00
a0a972b53a fix(NotificationContext): ensure audio playback is unlocked only for authenticated users
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m32s
2026-03-13 13:00:54 +01:00
9b98803053 fix(NotificationContext): handle user ID for notifications fetching and prevent stale responses
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m6s
2026-03-13 12:43:20 +01:00
e43e7f5fc5 fix: update license information to GNU GPL v3 in README, package.json, and package-lock.json
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m7s
Build & Push Docker Image / build (release) Successful in 6m14s
fix: Add valkey to compose in case system is too old for DragonflyDB
2026-03-13 12:11:32 +01:00
5731e6a9a8 change README again
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m10s
2026-03-13 11:59:59 +01:00
fa8292263c chore: update version to 2.0.0 in package files and README
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-03-13 11:57:29 +01:00
4bc3403040 fix(README): correct warning message formatting for clarity
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-03-13 11:54:25 +01:00
e4f596f8c3 change README
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-03-13 11:52:55 +01:00
00e563664e feat(analytics): enhance analytics functionality with token validation and data extraction
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s
2026-03-13 10:34:39 +01:00
41ad3e037a feat(rooms): add learning analytics field to room update request
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m31s
2026-03-13 10:09:11 +01:00
7ef173c49e feat(analytics): implement learning analytics feature with data collection and display
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m33s
2026-03-13 09:46:15 +01:00
530377272b feat(theme): add cotton candy light theme with custom colors
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m28s
2026-03-12 14:05:15 +01:00
52f122a98a Merge remote-tracking branch 'refs/remotes/origin/main'
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m30s
2026-03-12 10:31:46 +01:00
c2dcb02e9b bugfix red-modular-light theme 2026-03-12 10:30:52 +01:00
71557280f5 feat(sidebar): update user initials display to show first two letters of first and last name
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m32s
2026-03-10 22:50:47 +01:00
03e484b8c6 feat(auth): improve logout process to redirect to Keycloak before clearing user state
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m24s
2026-03-10 22:41:27 +01:00
14ed0c3689 feat(toaster): adjust container style for improved toast positioning
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m30s
2026-03-10 22:28:47 +01:00
5fc64330e0 feat(migration): enhance migration script to include site settings, branding, and OAuth configuration
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-03-10 22:25:53 +01:00
3ab7ab6a70 feat(auth): enhance logout process to support RP-Initiated Logout for OIDC users
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m35s
2026-03-10 22:19:01 +01:00
a7b0b84f2d feat(database): modify PostgreSQL insert query to return the entire inserted row for tables without an "id" column
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m35s
2026-03-10 15:14:03 +01:00
11d3972a74 feat(caldav): implement service discovery for CalDAV with redirection
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m25s
2026-03-04 22:33:43 +01:00
d8c52aae4e feat(database): make token column nullable in caldav_tokens table for improved flexibility
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m36s
2026-03-04 22:24:11 +01:00
f16fd9aef2 feat(i18n): update hero title in English and German for consistency
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m26s
2026-03-04 13:52:30 +01:00
8edcb7d3df feat(calendar): store only token hash in database to enhance security
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m25s
feat(federation): escape LIKE special characters in originDomain to prevent wildcard injection

feat(oauth): redirect with token in hash fragment to avoid exposure in logs

feat(OAuthCallback): retrieve token from hash fragment for improved security
2026-03-04 13:41:40 +01:00
6aa01d39f4 feat(DateTimePicker): optimize onChange handling with ref for improved performance
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s
2026-03-04 13:25:53 +01:00
bb4da19f4f feat(Sidebar): update BrandLogo size for improved layout
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m26s
2026-03-04 13:11:55 +01:00
e8d8ccda42 feat(Home): update text for branding and hero title in multiple languages
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-03-04 13:08:23 +01:00
1d647d0a36 feat(DateTimePicker): update styles for month navigation and current month display
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m30s
2026-03-04 12:54:18 +01:00
e3a5f21c8b feat(DateTimePicker): import flatpickr CSS for improved styling
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m33s
2026-03-04 12:41:36 +01:00
014de634b1 feat(DateTimePicker): integrate flatpickr for enhanced date/time selection and theming
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s
2026-03-04 12:33:51 +01:00
268f6d0c5a feat(DateTimePicker): replace custom date/time picker with native inputs for improved theming and accessibility
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m50s
2026-03-04 12:17:42 +01:00
7018c5579f feat(DateTimePicker): add fixed strategy to popperProps for improved positioning
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m30s
2026-03-04 11:55:48 +01:00
2a7754dd56 feat(DateTimePicker): add return statement to render DateTimePicker component
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m48s
2026-03-04 11:14:04 +01:00
a78fc06f2b feat(DateTimePicker): implement calendar open handler to preserve scroll position
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
feat(Modal): remove max height restriction for modal body
style: clean up z-index for datepicker popper
2026-03-04 11:12:56 +01:00
15bfcc80c3 feat(DateTimePicker): remove custom popper container and adjust z-index for improved layout
All checks were successful
Build & Push Docker Image / build (push) Successful in 7m4s
2026-03-04 11:00:39 +01:00
fcb83a9b72 feat(DateTimePicker): implement custom popper container for improved layout handling
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m58s
feat(Modal): enhance modal styling with rounded corners and improved overflow handling
style: adjust z-index for datepicker popper to ensure proper layering above modals
2026-03-04 10:49:17 +01:00
a69b2e4d9a feat(database): update reminder_sent_at column type for calendar_events based on database type
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m59s
2026-03-04 10:33:55 +01:00
0d84610e3b feat(BrandLogo): enhance logo sizing and visibility based on app name visibility
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m57s
2026-03-04 10:23:23 +01:00
8823f8789e feat(calendar): add reminder functionality for events with notifications
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-03-04 10:18:43 +01:00
ce2cf499dc feat: implement hide app name feature with toggle in admin settings and update branding context
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-03-04 10:11:35 +01:00
bac4e8ae7c chore: update dependencies and enhance datetime picker styling
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m27s
- Updated `multer` to version 2.1.0 and `@vitejs/plugin-react` to version 4.7.0 in package.json.
- Updated `vite` to version 6.4.1.
- Enhanced CSS for various themes to include `color-scheme` and `--picker-icon-filter` properties for better datetime picker integration.
- Refactored Calendar component to include styled datetime picker with icons for start and end time inputs.
- Improved timezone display for better user experience.
2026-03-04 09:54:07 +01:00
43d94181f9 feat: add getBaseUrl function for consistent base URL generation across routes
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m28s
feat(calendar): display local timezone in calendar view
feat(i18n): add timezone label to German and English translations
2026-03-04 09:44:02 +01:00
61274d31f1 feat(calendar): implement local date string formatting for event filtering
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m27s
2026-03-04 09:31:45 +01:00
3d21967681 fix(caldav): update legacy token migration to set token as empty string
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s
2026-03-04 09:26:22 +01:00
2d919cdc67 feat(caldav): add token_hash column and store SHA-256 hashed tokens
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m27s
2026-03-04 09:17:31 +01:00
6e301e2928 feat(notfound): add NotFound page with 404 handling and localization
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m33s
2026-03-04 08:57:21 +01:00
cdfc585c8a feat: implement OAuth 2.0 / OpenID Connect support
Some checks failed
Build & Push Docker Image / build (push) Failing after 1m12s
- Added OAuth configuration management in the admin panel.
- Implemented OAuth authorization flow with PKCE for enhanced security.
- Created routes for handling OAuth provider discovery, authorization, and callback.
- Integrated OAuth login and registration options in the frontend.
- Updated UI components to support OAuth login and registration.
- Added internationalization strings for OAuth-related messages.
- Implemented encryption for client secrets and secure state management.
- Added error handling and user feedback for OAuth processes.
2026-03-04 08:54:25 +01:00
e22a895672 feat(security): enhance input validation and security measures across various routes
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m38s
2026-03-04 08:39:29 +01:00
ba096a31a2 feat(room): add copy link functionality with options for room and guest links
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m25s
2026-03-03 14:23:57 +01:00
d886725c4f feat(migration): add Greenlight to Redlight migration script
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s
2026-03-03 12:34:35 +01:00
f3ef490012 feat(caldav): enhance eventToICS function to include join links and organizer details
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m24s
2026-03-03 12:13:36 +01:00
ddc0c684ec feat(caldav): implement CalDAV support with token management and calendar operations
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m5s
2026-03-03 11:41:35 +01:00
68f31467af feat(theme): add new themes for Everforest, Kanagawa, Ayu, Moonlight, and Cyberpunk
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m7s
2026-03-03 11:28:35 +01:00
05f2941b16 fix(room): prevent click event propagation on actions container
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m26s
2026-03-03 10:38:48 +01:00
4bb22be496 feat(room): enforce minimum room name length of 2 characters in creation and editing
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m17s
2026-03-03 10:02:28 +01:00
1c9c5224ae feat(room): add copy link functionality with clipboard support and update translations
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m27s
2026-03-03 09:00:50 +01:00
2b8c179d03 Add waiting queue for guest join with sound
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m26s
2026-03-03 06:38:01 +01:00
51 changed files with 7557 additions and 2149 deletions

View File

@@ -1,28 +1,31 @@
# ── Stage 1: Build frontend ────────────────────────────────────────────────── # ── Stage 1: Install dependencies ────────────────────────────────────────────
FROM node:20-bullseye-slim AS builder FROM node:22-trixie-slim AS deps
WORKDIR /app WORKDIR /app
# Install build tools and sqlite headers for native modules ENV DEBIAN_FRONTEND=noninteractive
# Install build tools for native modules (better-sqlite3, pdfkit)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 build-essential libsqlite3-dev ca-certificates \ python3 build-essential libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
# Install all dependencies (including dev for vite build)
RUN npm ci RUN npm ci
# ── Stage 2: Build frontend ─────────────────────────────────────────────────
FROM deps AS builder
COPY . . COPY . .
RUN npm run build RUN npm run build
# Produce production node_modules (compile native modules here for the target arch)
RUN npm ci --omit=dev && npm cache clean --force
# ── Stage 2: Production image ─────────────────────────────────────────────── # Prune dev dependencies in-place (avoids a second npm ci)
RUN npm prune --omit=dev
FROM node:20-bullseye-slim # ── Stage 3: Production image ───────────────────────────────────────────────
FROM node:22-trixie-slim
# Allow forcing build from source (useful when prebuilt binaries are not available)
ARG BUILD_FROM_SOURCE=false
ENV npm_config_build_from_source=${BUILD_FROM_SOURCE}
WORKDIR /app WORKDIR /app

159
README.md
View File

@@ -1,23 +1,30 @@
# 🔴 Redlight # 🔴 Redlight
A modern, self-hosted BigBlueButton frontend with beautiful themes, federation, and powerful features. > ⚠️ **Warning:** This project is entirely *vibe coded* and meant to be a fun/hobby project. Use at your own risk!
A modern, self-hosted BigBlueButton frontend with 25+ themes, federation, calendar, CalDAV, OAuth/OIDC, and powerful room management.
![Node.js](https://img.shields.io/badge/Node.js-20+-green) ![Node.js](https://img.shields.io/badge/Node.js-20+-green)
![React](https://img.shields.io/badge/React-18+-blue) ![React](https://img.shields.io/badge/React-18+-blue)
![License](https://img.shields.io/badge/License-MIT-yellow) ![License](https://img.shields.io/badge/License-GPL-pink)
![BigBlueButton](https://img.shields.io/badge/BigBlueButton-Compatible-red) ![BigBlueButton](https://img.shields.io/badge/BigBlueButton-Compatible-red)
## ✨ Features ## ✨ Features
### Core Features ### Core Features
- 🎥 **Video Conferencing** - Integrated BigBlueButton support for professional video meetings - 🎥 **Video Conferencing** - Integrated BigBlueButton support for professional video meetings
- 🎨 **15+ Themes** - Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox, and more - 🎨 **25+ Themes** - Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox, Tokyo Night, Solarized, Everforest, Ayu, Kanagawa, Moonlight, Cyberpunk, Cotton Candy, and more
- 📝 **Room Management** - Create unlimited rooms with custom settings, access codes, and moderator codes - 📝 **Room Management** - Create unlimited rooms with custom settings, access codes, and moderator codes
- 🔐 **User Management** - Registration, login, role-based access control (Admin/User) - 🔐 **User Management** - Registration, login, role-based access control (Admin/User)
- 📹 **Recording Management** - View, publish, and delete meeting recordings per room - 📹 **Recording Management** - View, publish, and delete meeting recordings per room
- 📊 **Learning Analytics** - Collect and view per-room participant engagement data (talk time, messages, reactions) via BBB callbacks, secured with HMAC tokens
- 📅 **Calendar** - Built-in calendar with event creation, sharing, customizable reminders, and room linking
- 📆 **CalDAV Server** - Full CalDAV support for syncing calendars with Thunderbird, Apple Calendar, GNOME Calendar, DAVx⁵ (Android), and other standard clients
- 🌍 **Multi-Language Support** - German (Deutsch) and English built-in, easily extensible - 🌍 **Multi-Language Support** - German (Deutsch) and English built-in, easily extensible
- 🔔 **In-App Notifications** - Real-time notifications for room shares, federation invites, and calendar reminders
- ✉️ **Email Verification** - Optional SMTP-based email verification for user registration - ✉️ **Email Verification** - Optional SMTP-based email verification for user registration
- 👤 **User Profiles** - Customizable avatars, themes, and language preferences - 🔑 **OAuth / OIDC** - Login via OpenID Connect providers (Keycloak, Authentik, etc.) with PKCE
- 👤 **User Profiles** - Customizable display names, avatars, themes, and language preferences
- 📱 **Responsive Design** - Works seamlessly on mobile, tablet, and desktop - 📱 **Responsive Design** - Works seamlessly on mobile, tablet, and desktop
- 🌐 **Federation** - Invite users from remote Redlight instances via Ed25519-signed messages - 🌐 **Federation** - Invite users from remote Redlight instances via Ed25519-signed messages
- 🐉 **DragonflyDB / Redis** - JWT blacklisting for secure token revocation on logout - 🐉 **DragonflyDB / Redis** - JWT blacklisting for secure token revocation on logout
@@ -27,6 +34,7 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
- 🏢 **Branding Customization** - Custom app name, logos, and default theme - 🏢 **Branding Customization** - Custom app name, logos, and default theme
- 📊 **Dashboard** - Overview of system statistics - 📊 **Dashboard** - Overview of system statistics
- 🔧 **Settings Management** - System-wide configuration - 🔧 **Settings Management** - System-wide configuration
- ✉️ **Invite-Only Registration** - Generate invite tokens for controlled user signup
### Room Features ### Room Features
- 🔑 **Access Codes** - Restrict room access with optional passwords - 🔑 **Access Codes** - Restrict room access with optional passwords
@@ -37,11 +45,12 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
-**Approval Mode** - Require moderator approval for participants -**Approval Mode** - Require moderator approval for participants
- 🎙️ **Anyone Can Start** - Allow participants to start the meeting - 🎙️ **Anyone Can Start** - Allow participants to start the meeting
- 📹 **Recording Settings** - Control whether meetings are recorded - 📹 **Recording Settings** - Control whether meetings are recorded
- 📊 **Presentation Upload** - Upload PDF, PPTX, ODP, or image files as default slides - 📊 **Learning Analytics** - Toggle per-room to collect participant engagement data after each meeting
- 📑 **Presentation Upload** - Upload PDF, PPTX, ODP, DOC, DOCX as default slides
- 🤝 **Room Sharing** - Share rooms with other registered users - 🤝 **Room Sharing** - Share rooms with other registered users
### Security ### Security
- 🛡️ **Comprehensive Rate Limiting** - Login, register, profile, avatar, guest-join, and federation endpoints - 🛡️ **Comprehensive Rate Limiting** - Login, register, profile, avatar, guest-join, OAuth, and federation endpoints
- 🔒 **Input Validation** - Email format, field length limits, ID format checks, color format validation - 🔒 **Input Validation** - Email format, field length limits, ID format checks, color format validation
- 🕐 **Timing-Safe Comparisons** - Access codes and moderator codes compared with `crypto.timingSafeEqual` - 🕐 **Timing-Safe Comparisons** - Access codes and moderator codes compared with `crypto.timingSafeEqual`
- 📏 **Streaming Upload Limits** - Avatar (5 MB) and presentation (50 MB) uploads reject early without buffering - 📏 **Streaming Upload Limits** - Avatar (5 MB) and presentation (50 MB) uploads reject early without buffering
@@ -49,6 +58,7 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
- 🔐 **JWT Blacklist** - Token revocation via DragonflyDB/Redis on logout - 🔐 **JWT Blacklist** - Token revocation via DragonflyDB/Redis on logout
- 🌐 **CORS Restriction** - Locked to `APP_URL` in production - 🌐 **CORS Restriction** - Locked to `APP_URL` in production
- ⚙️ **Configurable Trust Proxy** - `TRUST_PROXY` env var for reverse proxy setups - ⚙️ **Configurable Trust Proxy** - `TRUST_PROXY` env var for reverse proxy setups
- 🔏 **HMAC-Secured Callbacks** - Learning analytics callback URLs signed with HMAC-SHA256
### Developer Features ### Developer Features
- 🐳 **Docker Support** - Easy deployment with Docker Compose (includes PostgreSQL + DragonflyDB) - 🐳 **Docker Support** - Easy deployment with Docker Compose (includes PostgreSQL + DragonflyDB)
@@ -63,20 +73,24 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
| Feature | Redlight | Greenlight | | Feature | Redlight | Greenlight |
|---------|----------|-----------| |---------|----------|-----------|
| **Theme System** | 15+ customizable themes | Limited theming | | **Theme System** | 25+ customizable themes | Limited theming |
| **Learning Analytics** | ✅ Per-room engagement data | ❌ Not supported |
| **Calendar / CalDAV** | ✅ Built-in calendar + CalDAV sync | ❌ Not supported |
| **OAuth / OIDC** | ✅ OpenID Connect (PKCE) | ✅ Supported |
| **Federation** | ✅ Cross-instance invites | ❌ Not supported | | **Federation** | ✅ Cross-instance invites | ❌ Not supported |
| **Notifications** | ✅ In-app + calendar reminders | ❌ Not supported |
| **Language Support** | Multi-language ready | Multi-language ready | | **Language Support** | Multi-language ready | Multi-language ready |
| **UI Framework** | React + Tailwind (Modern) | Rails-based (Traditional) | | **UI Framework** | React + Tailwind (Modern) | Rails-based (Traditional) |
| **User Preferences** | Theme, language, avatar | Limited customization | | **User Preferences** | Theme, language, avatar, display name | Limited customization |
| **Database Options** | SQLite / PostgreSQL | PostgreSQL only | | **Database Options** | SQLite / PostgreSQL | PostgreSQL only |
| **Docker** | ✅ Supported | ✅ Supported | | **Docker** | ✅ Supported | ✅ Supported |
| **Admin Dashboard** | Modern React UI | Legacy Rails interface | | **Admin Dashboard** | Modern React UI | Legacy Rails interface |
| **Room Sharing** | ✅ Share rooms with users | ✅ Supported | | **Room Sharing** | ✅ Share rooms with users | ✅ Supported |
| **Recording Management** | Full control per room | Standard management | | **Recording Management** | Full control per room | Standard management |
| **Presentation Upload** | ✅ Supported | ✅ Supported |
| **API** | RESTful JSON API | RESTful API | | **API** | RESTful JSON API | RESTful API |
| **Setup Complexity** | Simple (5 min) | Moderate (10-15 min) | | **Setup Complexity** | Simple (5 min) | Moderate (10-15 min) |
| **Customization** | Easy (Tailwind CSS) | Requires Ruby/Rails | | **Customization** | Easy (Tailwind CSS) | Requires Ruby/Rails |
| **Community** | doesn't exist lol | Established |
--- ---
@@ -105,7 +119,7 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
BBB_SECRET=your-bbb-shared-secret BBB_SECRET=your-bbb-shared-secret
JWT_SECRET=your-secret-key # REQUIRED - app won't start without this JWT_SECRET=your-secret-key # REQUIRED - app won't start without this
APP_URL=https://your-domain.com # Used for CORS and email links APP_URL=https://your-domain.com # Used for CORS and email links
DATABASE_URL=postgres://user:password@postgres:5432/redlight DATABASE_URL=postgres://redlight:redlight@postgres:5432/redlight
POSTGRES_USER=redlight POSTGRES_USER=redlight
POSTGRES_PASSWORD=redlight POSTGRES_PASSWORD=redlight
@@ -118,18 +132,23 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
# TRUST_PROXY=loopback # TRUST_PROXY=loopback
# Optional: Email verification # Optional: Email verification
SMTP_HOST=smtp.gmail.com # SMTP_HOST=smtp.gmail.com
SMTP_PORT=587 # SMTP_PORT=587
SMTP_USER=your-email@gmail.com # SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password # SMTP_PASS=your-app-password
# Optional: Federation (cross-instance room invites) # Optional: Federation (cross-instance room invites)
# FEDERATION_DOMAIN=your-domain.com # FEDERATION_DOMAIN=your-domain.com
# Optional: OAuth / OIDC login
# OAUTH_ISSUER=https://auth.your-domain.com/realms/your-realm
# OAUTH_CLIENT_ID=redlight
# OAUTH_CLIENT_SECRET=your-client-secret
``` ```
3. **Start the application** 3. **Start the application**
```bash ```bash
docker-compose up -d docker compose up -d
``` ```
4. **Access the application** 4. **Access the application**
@@ -167,6 +186,8 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
- **Database**: SQLite / PostgreSQL with better-sqlite3 / pg - **Database**: SQLite / PostgreSQL with better-sqlite3 / pg
- **Cache**: DragonflyDB / Redis (ioredis) - JWT blacklisting - **Cache**: DragonflyDB / Redis (ioredis) - JWT blacklisting
- **Email**: Nodemailer - **Email**: Nodemailer
- **CalDAV**: xml2js-based WebDAV/CalDAV server
- **Auth**: JWT + OAuth/OIDC (PKCE)
- **Build**: Vite - **Build**: Vite
--- ---
@@ -176,20 +197,24 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
``` ```
redlight/ redlight/
├── server/ # Node.js/Express backend ├── server/ # Node.js/Express backend
│ ├── config/ # Database, Redis, mailer, BBB & federation config │ ├── config/ # Database, Redis, mailer, BBB, federation, OAuth & notification config
│ ├── middleware/ # JWT authentication & token blacklisting │ ├── i18n/ # Server-side translations (email templates)
│ ├── routes/ # API endpoints (auth, rooms, recordings, admin, branding, federation) │ ├── jobs/ # Background jobs (federation sync, calendar reminders)
│ ├── middleware/ # JWT authentication, logging & token blacklisting
│ ├── routes/ # API endpoints (auth, rooms, recordings, admin, branding,
│ │ # federation, calendar, caldav, notifications, oauth, analytics)
│ └── index.js # Server entry point │ └── index.js # Server entry point
├── src/ # React frontend ├── src/ # React frontend
│ ├── components/ # Reusable components │ ├── components/ # Reusable components (RecordingList, AnalyticsList, etc.)
│ ├── contexts/ # React context (Auth, Language, Theme, Branding) │ ├── contexts/ # React context (Auth, Language, Theme, Branding, Notification)
│ ├── i18n/ # Translations (DE, EN) │ ├── i18n/ # Translations (DE, EN)
│ ├── pages/ # Page components │ ├── pages/ # Page components
│ ├── services/ # API client │ ├── services/ # API client
│ ├── themes/ # Tailwind theme config │ ├── themes/ # 25+ theme definitions
│ └── main.jsx # Frontend entry point │ └── main.jsx # Frontend entry point
├── public/ # Static assets ├── public/ # Static assets
├── uploads/ # User avatars, branding & presentations (runtime) ├── uploads/ # User avatars, branding & presentations (runtime)
├── keys/ # Federation Ed25519 key pair (auto-generated)
├── compose.yml # Docker Compose (Redlight + PostgreSQL + DragonflyDB) ├── compose.yml # Docker Compose (Redlight + PostgreSQL + DragonflyDB)
├── Dockerfile # Multi-stage container image ├── Dockerfile # Multi-stage container image
└── package.json # Dependencies └── package.json # Dependencies
@@ -201,16 +226,19 @@ redlight/
- **JWT Authentication** - Secure token-based auth with 7-day expiration and `jti`-based blacklisting via DragonflyDB/Redis - **JWT Authentication** - Secure token-based auth with 7-day expiration and `jti`-based blacklisting via DragonflyDB/Redis
- **Mandatory JWT Secret** - Server refuses to start without a `JWT_SECRET` env var - **Mandatory JWT Secret** - Server refuses to start without a `JWT_SECRET` env var
- **OAuth / OIDC** - OpenID Connect with PKCE (S256) and cryptographic state tokens
- **HTTPS Ready** - Configure behind reverse proxy (nginx, Caddy); trust proxy via `TRUST_PROXY` env - **HTTPS Ready** - Configure behind reverse proxy (nginx, Caddy); trust proxy via `TRUST_PROXY` env
- **Password Hashing** - bcryptjs with salt rounds 12, minimum 8-character passwords - **Password Hashing** - bcryptjs with salt rounds 12, minimum 8-character passwords
- **Email Verification** - Optional SMTP-based email verification with resend support - **Email Verification** - Optional SMTP-based email verification with resend support
- **CORS Protection** - Restricted to `APP_URL` in production, open in development - **CORS Protection** - Restricted to `APP_URL` in production, open in development
- **Rate Limiting** - Login, register, profile, password, avatar, guest-join, and federation endpoints - **Rate Limiting** - Login, register, profile, password, avatar, guest-join, OAuth, and federation endpoints
- **Input Validation** - Email regex, field length limits, ID format checks, hex-color format checks - **Input Validation** - Email regex, field length limits, ID format checks, hex-color format checks
- **Timing-Safe Comparisons** - Access codes and moderator codes compared via `crypto.timingSafeEqual` - **Timing-Safe Comparisons** - Access codes and moderator codes compared via `crypto.timingSafeEqual`
- **Upload Safety** - Streaming body size limits (avatar 5 MB, presentation 50 MB) abort early without buffering - **Upload Safety** - Streaming body size limits (avatar 5 MB, presentation 50 MB) abort early without buffering
- **XSS / Injection Prevention** - HTML-escaped emails, XML-escaped BBB API parameters, SVG logos served as `attachment` - **XSS / Injection Prevention** - HTML-escaped emails, XML-escaped BBB API parameters, SVG logos served as `attachment`
- **HMAC-Secured Callbacks** - Learning analytics callback URLs signed with HMAC-SHA256 derived from BBB_SECRET
- **Admin Isolation** - Role-based access control with strict admin checks - **Admin Isolation** - Role-based access control with strict admin checks
- **Network Isolation** - Docker Compose uses an internal backend network for DB and cache
--- ---
@@ -232,23 +260,43 @@ redlight/
- `GET /api/rooms` - List user's rooms (owned + shared) - `GET /api/rooms` - List user's rooms (owned + shared)
- `POST /api/rooms` - Create new room - `POST /api/rooms` - Create new room
- `GET /api/rooms/:uid` - Get room details - `GET /api/rooms/:uid` - Get room details
- `PUT /api/rooms/:uid` - Update room - `PUT /api/rooms/:uid` - Update room (incl. learning analytics toggle)
- `DELETE /api/rooms/:uid` - Delete room - `DELETE /api/rooms/:uid` - Delete room
- `POST /api/rooms/:uid/start` - Start meeting - `POST /api/rooms/:uid/start` - Start meeting
- `POST /api/rooms/:uid/join` - Join meeting as authenticated user - `POST /api/rooms/:uid/join` - Join meeting as authenticated user
- `POST /api/rooms/:uid/guest-join` - Join meeting as guest (rate-limited) - `POST /api/rooms/:uid/guest-join` - Join meeting as guest (rate-limited)
- `POST /api/rooms/:uid/end` - End meeting - `POST /api/rooms/:uid/end` - End meeting
- `GET /api/rooms/:uid/running` - Check if meeting is running - `GET /api/rooms/:uid/status` - Check if meeting is running
- `GET /api/rooms/:uid/shares` - List shared users - `GET /api/rooms/:uid/shares` - List shared users
- `POST /api/rooms/:uid/shares` - Share room with user - `POST /api/rooms/:uid/shares` - Share room with user
- `DELETE /api/rooms/:uid/shares/:userId` - Remove share - `DELETE /api/rooms/:uid/shares/:userId` - Remove share
- `POST /api/rooms/:uid/presentation` - Upload default presentation (PDF, PPTX, ODP, images) - `POST /api/rooms/:uid/presentation` - Upload default presentation
- `DELETE /api/rooms/:uid/presentation` - Remove presentation - `DELETE /api/rooms/:uid/presentation` - Remove presentation
### Recordings ### Recordings
- `GET /api/recordings/:roomUid` - List room recordings - `GET /api/recordings/room/:uid` - List room recordings
- `PUT /api/recordings/:recordingId` - Publish/unpublish recording - `PUT /api/recordings/:recordID/publish` - Publish/unpublish recording
- `DELETE /api/recordings/:recordingId` - Delete recording - `DELETE /api/recordings/:recordID` - Delete recording
### Learning Analytics
- `POST /api/analytics/callback/:uid?token=...` - BBB callback (HMAC-secured)
- `GET /api/analytics/room/:uid` - Get analytics for a room
- `DELETE /api/analytics/:id` - Delete analytics entry
### Calendar
- `GET /api/calendar` - List calendar events
- `POST /api/calendar` - Create event
- `PUT /api/calendar/:uid` - Update event
- `DELETE /api/calendar/:uid` - Delete event
- `GET /api/calendar/caldav-tokens` - List CalDAV tokens
- `POST /api/calendar/caldav-tokens` - Create CalDAV token
- `DELETE /api/calendar/caldav-tokens/:id` - Delete CalDAV token
### Notifications
- `GET /api/notifications` - List notifications
- `PUT /api/notifications/:id/read` - Mark as read
- `POST /api/notifications/read-all` - Mark all as read
- `DELETE /api/notifications/:id` - Delete notification
### Admin ### Admin
- `GET /api/admin/users` - List all users - `GET /api/admin/users` - List all users
@@ -263,6 +311,10 @@ redlight/
- `POST /api/branding/logo` - Upload custom logo - `POST /api/branding/logo` - Upload custom logo
- `DELETE /api/branding/logo` - Remove custom logo - `DELETE /api/branding/logo` - Remove custom logo
### OAuth
- `GET /api/oauth/url` - Get OAuth authorization URL
- `GET /api/oauth/callback` - OAuth callback (PKCE exchange)
### Federation ### Federation
- `GET /.well-known/redlight` - Instance discovery (domain, public key) - `GET /.well-known/redlight` - Instance discovery (domain, public key)
- `POST /api/federation/invite` - Send invitation to remote user - `POST /api/federation/invite` - Send invitation to remote user
@@ -271,6 +323,11 @@ redlight/
- `PUT /api/federation/invitations/:id` - Accept / decline invitation - `PUT /api/federation/invitations/:id` - Accept / decline invitation
- `DELETE /api/federation/invitations/:id` - Delete invitation - `DELETE /api/federation/invitations/:id` - Delete invitation
### CalDAV
- `PROPFIND /caldav/` - CalDAV discovery
- `REPORT /caldav/:user/calendar/` - Calendar query
- `GET/PUT/DELETE /caldav/:user/calendar/:uid.ics` - Event CRUD
--- ---
## 🌍 Internationalization (i18n) ## 🌍 Internationalization (i18n)
@@ -290,15 +347,25 @@ Redlight comes with built-in support for multiple languages. Currently supported
## 🎨 Themes ## 🎨 Themes
Redlight includes the following themes: Redlight includes 25+ themes:
- 🌙 Dracula - ☀️ Light / 🌙 Dark (default)
- 🐱 Catppuccin Mocha / Latte
- 🧛 Dracula
- ❄️ Nord - ❄️ Nord
- 🐱 Catppuccin
- 🌹 Rosé Pine
- 🍂 Gruvbox (Dark, Light)
- 💜 One Dark
- 🌊 Tokyo Night - 🌊 Tokyo Night
- And more... - 💜 One Dark
- 🐙 GitHub Dark
- 🌹 Rosé Pine / Rosé Pine Dawn
- 🍂 Gruvbox Dark / Gruvbox Light
- ☀️ Solarized Dark / Solarized Light
- 🌲 Everforest Dark / Everforest Light
- 🌊 Kanagawa
- 🌙 Moonlight
- 🎮 Cyberpunk
- 🌸 Ayu Dark
- 🔴 Red Modular Light
- 🍬 Cotton Candy Light
- 🐱 scrunkly.cat Dark
Themes are fully customizable by editing `src/themes/index.js`. Themes are fully customizable by editing `src/themes/index.js`.
@@ -309,14 +376,16 @@ Themes are fully customizable by editing `src/themes/index.js`.
### Using Docker Compose (Recommended) ### Using Docker Compose (Recommended)
```bash ```bash
docker-compose up -d docker compose up -d
``` ```
Services: Services:
- **redlight** - Node.js application - **redlight** - Node.js application (port 3001)
- **postgres** - PostgreSQL database - **postgres** - PostgreSQL 17 database
- **dragonfly** - DragonflyDB (Redis-compatible) for JWT blacklisting - **dragonfly** - DragonflyDB (Redis-compatible) for JWT blacklisting
The `compose.yml` uses isolated networks: `frontend` (public) and `backend` (internal, no external access). Data is persisted via named volumes (`pgdata`, `uploads`, `dragonflydata`). Federation keys are mounted from `./keys`.
### Environment Variables ### Environment Variables
| Variable | Required | Default | Description | | Variable | Required | Default | Description |
@@ -333,6 +402,11 @@ Services:
| `SMTP_USER` | No | - | SMTP username | | `SMTP_USER` | No | - | SMTP username |
| `SMTP_PASS` | No | - | SMTP password | | `SMTP_PASS` | No | - | SMTP password |
| `FEDERATION_DOMAIN` | No | - | Domain for federation (enables cross-instance invites) | | `FEDERATION_DOMAIN` | No | - | Domain for federation (enables cross-instance invites) |
| `OAUTH_ISSUER` | No | - | OIDC issuer URL (enables OAuth login) |
| `OAUTH_CLIENT_ID` | No | - | OIDC client ID |
| `OAUTH_CLIENT_SECRET` | No | - | OIDC client secret |
| `ADMIN_EMAIL` | No | `admin@example.com` | Default admin email (first start only) |
| `ADMIN_PASSWORD` | No | `admin123` | Default admin password (first start only) |
### Production Deployment ### Production Deployment
@@ -376,8 +450,9 @@ Federation allows users on different Redlight instances to invite each other int
### Setup ### Setup
1. Set `FEDERATION_DOMAIN=your-domain.com` in `.env`. 1. Set `FEDERATION_DOMAIN=your-domain.com` in `.env`.
2. On first start, an Ed25519 key pair is generated automatically and stored in `server/config/federation_key.pem`. 2. On first start, an Ed25519 key pair is generated automatically and stored in `keys/federation_key.pem`.
3. Other instances discover your public key via `GET /.well-known/redlight`. 3. In Docker, mount `./keys:/app/keys` (already configured in `compose.yml`).
4. Other instances discover your public key via `GET /.well-known/redlight`.
### How it works ### How it works
@@ -413,13 +488,13 @@ curl "https://your-bbb-server/bigbluebutton/api/getMeetings?checksum=..."
**Solution**: Clear browser cache (Ctrl+Shift+Del) or restart dev server with `npm run dev`. **Solution**: Clear browser cache (Ctrl+Shift+Del) or restart dev server with `npm run dev`.
### Issue: "DragonflyDB connection error" ### Issue: "DragonflyDB connection error"
**Solution**: Ensure DragonflyDB (or Redis) is running and `REDIS_URL` is correct. If unavailable, the app still works JWT blacklisting degrades gracefully (logout won't revoke tokens immediately). **Solution**: Ensure DragonflyDB (or Redis) is running and `REDIS_URL` is correct. If unavailable, the app still works - JWT blacklisting degrades gracefully (logout won't revoke tokens immediately).
--- ---
## 📝 License ## 📝 License
This project is licensed under the MIT License - see [LICENSE](LICENSE) file for details. This project is licensed under the GNU GPL v3 (or later) - see [LICENSE](LICENSE) file for details.
--- ---

View File

@@ -6,13 +6,16 @@ services:
- "3001:3001" - "3001:3001"
env_file: ".env" env_file: ".env"
volumes: volumes:
- uploads:/app/uploads - ./uploads:/app/uploads
- ./keys:/app/keys - ./keys:/app/keys
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
dragonfly: dragonfly:
condition: service_healthy condition: service_healthy
networks:
- frontend
- backend
postgres: postgres:
image: postgres:17-alpine image: postgres:17-alpine
@@ -25,6 +28,8 @@ services:
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
networks:
- backend
dragonfly: dragonfly:
image: ghcr.io/dragonflydb/dragonfly:latest image: ghcr.io/dragonflydb/dragonfly:latest
@@ -38,8 +43,33 @@ services:
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
networks:
- backend
# Use valkey, if your system is too old for DragonflyDB
# valkey:
# image: valkey/valkey:9
# restart: unless-stopped
# ulimits:
# memlock: -1
# volumes:
# - valkeydata:/data
# healthcheck:
# test: ["CMD", "redis-cli", "-p", "6379", "ping"]
# interval: 5s
# timeout: 5s
# retries: 5
# networks:
# - backend
volumes: volumes:
pgdata: pgdata:
uploads:
dragonflydata: dragonflydata:
#valkeydata:
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true

694
migrate-from-greenlight.mjs Normal file
View File

@@ -0,0 +1,694 @@
/**
* Greenlight → Redlight Migration Script
*
* Migrates users, rooms (including settings and shared accesses),
* site settings (branding, registration), logo, and OAuth/OIDC configuration
* from a Greenlight v3 PostgreSQL database into a Redlight instance
* (SQLite or PostgreSQL).
*
* Usage:
* node migrate-from-greenlight.mjs [options]
*
* Options:
* --dry-run Show what would be migrated without writing anything
* --skip-rooms Only migrate users, not rooms
* --skip-shares Do not migrate room share (shared_accesses)
* --skip-settings Do not migrate site_settings / branding
* --skip-oauth Do not migrate OAuth / OIDC configuration
* --verbose Print every imported row
*
* Environment variables (can also be in .env):
*
* Source (Greenlight DB — PostgreSQL):
* GL_DATABASE_URL Full Postgres connection string
* e.g. postgres://gl_user:pass@localhost/greenlight_db
*
* Target (Redlight DB — auto-detected):
* DATABASE_URL Set for PostgreSQL target (same format as GL_DATABASE_URL)
* SQLITE_PATH Set (or leave empty) for SQLite target
* Default: ./redlight.db (relative to this script)
*
* OAuth (from Greenlight .env — optional, only if --skip-oauth is NOT set):
* GL_OIDC_ISSUER OIDC issuer URL (e.g. https://keycloak.example.com/realms/myrealm)
* GL_OIDC_CLIENT_ID OIDC client ID
* GL_OIDC_CLIENT_SECRET OIDC client secret
* GL_OIDC_DISPLAY_NAME Button label on the login page (default: "SSO")
*
* Password hashes:
* Both Greenlight and Redlight use bcrypt, so password_digest is
* copied as-is — users can log in with their existing passwords.
*
* Site-settings mapping (GL site_settings → Redlight settings):
* BrandingImage → downloads file → uploads/branding/logo.*
* PrimaryColor → (logged, not mapped — Redlight uses themes)
* RegistrationMethod → registration_mode (open / invite)
* Terms → imprint_url
* PrivacyPolicy → privacy_url
*
* Meeting-option → Redlight field mapping:
* record → record_meeting
* muteOnStart → mute_on_join
* guestPolicy → require_approval (ASK_MODERATOR = true)
* glAnyoneCanStart → anyone_can_start
* glAnyoneJoinAsModerator → all_join_moderator
* glViewerAccessCode → access_code
* glModeratorAccessCode → moderator_code
*/
import 'dotenv/config';
import pg from 'pg';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ── helpers ────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const DRY_RUN = args.includes('--dry-run');
const SKIP_ROOMS = args.includes('--skip-rooms');
const SKIP_SHARES = args.includes('--skip-shares');
const SKIP_SETTINGS = args.includes('--skip-settings');
const SKIP_OAUTH = args.includes('--skip-oauth');
const VERBOSE = args.includes('--verbose');
const c = {
reset: '\x1b[0m',
bold: '\x1b[1m',
green: '\x1b[32m',
yellow:'\x1b[33m',
red: '\x1b[31m',
cyan: '\x1b[36m',
dim: '\x1b[2m',
};
const log = (...a) => console.log(...a);
const ok = (msg) => log(` ${c.green}${c.reset} ${msg}`);
const warn = (msg) => log(` ${c.yellow}${c.reset} ${msg}`);
const skip = (msg) => log(` ${c.dim} ${msg}${c.reset}`);
const err = (msg) => log(` ${c.red}${c.reset} ${msg}`);
const info = (msg) => log(` ${c.cyan}i${c.reset} ${msg}`);
const loud = (msg) => { if (VERBOSE) log(` ${c.dim}${msg}${c.reset}`); };
// ── Source DB (Greenlight PostgreSQL) ─────────────────────────────────────
const GL_URL = process.env.GL_DATABASE_URL;
if (!GL_URL) {
err('GL_DATABASE_URL is not set. Please set it in your .env file or environment.');
err(' Example: GL_DATABASE_URL=postgres://gl_user:pass@localhost/greenlight_production');
process.exit(1);
}
// ── Target DB (Redlight) ───────────────────────────────────────────────────
const RL_URL = process.env.DATABASE_URL;
const isPostgresTarget = !!(RL_URL && RL_URL.startsWith('postgres'));
// ── SQLite adapter (only loaded when needed) ───────────────────────────────
async function openSqlite() {
const require = createRequire(import.meta.url);
let Database;
try {
Database = require('better-sqlite3');
} catch {
err('better-sqlite3 is not installed. Run: npm install better-sqlite3');
process.exit(1);
}
const dbPath = process.env.SQLITE_PATH || './redlight.db';
const db = Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
return {
async get(sql, params = []) { return db.prepare(sql).get(...params); },
async all(sql, params = []) { return db.prepare(sql).all(...params); },
async run(sql, params = []) {
const r = db.prepare(sql).run(...params);
return { lastInsertRowid: Number(r.lastInsertRowid), changes: r.changes };
},
close() { db.close(); },
type: 'sqlite',
path: dbPath,
};
}
// ── PostgreSQL adapter ─────────────────────────────────────────────────────
async function openPostgres(url, label) {
const pool = new pg.Pool({ connectionString: url });
let index = 0;
const convert = (sql) => sql.replace(/\?/g, () => `$${++index}`);
return {
async get(sql, params = []) {
index = 0;
const r = await pool.query(convert(sql), params);
return r.rows[0];
},
async all(sql, params = []) {
index = 0;
const r = await pool.query(convert(sql), params);
return r.rows;
},
async run(sql, params = []) {
index = 0;
let q = convert(sql);
if (/^\s*INSERT/i.test(q) && !/RETURNING/i.test(q)) q += ' RETURNING id';
const r = await pool.query(q, params);
return { lastInsertRowid: r.rows[0]?.id, changes: r.rowCount };
},
async end() { await pool.end(); },
type: 'postgres',
};
}
// ── meeting option → redlight field map ───────────────────────────────────
const OPTION_MAP = {
record: 'record_meeting',
muteOnStart: 'mute_on_join',
guestPolicy: 'require_approval', // special: "ASK_MODERATOR"
glAnyoneCanStart: 'anyone_can_start',
glAnyoneJoinAsModerator: 'all_join_moderator',
glViewerAccessCode: 'access_code',
glModeratorAccessCode: 'moderator_code',
};
function boolOption(val) {
return val === 'true' || val === '1' ? 1 : 0;
}
// ── AES-256-GCM encryption (matching Redlight's oauth.js) ─────────────────
const ENCRYPTION_KEY = crypto
.createHash('sha256')
.update(process.env.JWT_SECRET || '')
.digest();
function encryptSecret(plaintext) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
}
// ── Helper: upsert a setting into the Redlight settings table ─────────────
async function upsertSetting(db, key, value, isPostgres) {
const existing = await db.get('SELECT key FROM settings WHERE key = ?', [key]);
if (existing) {
await db.run('UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?', [value, key]);
} else {
await db.run('INSERT INTO settings (key, value) VALUES (?, ?) RETURNING key', [key, value]);
}
}
// ── main ───────────────────────────────────────────────────────────────────
async function main() {
log();
log(`${c.bold}Greenlight → Redlight Migration${c.reset}`);
if (DRY_RUN) log(`${c.yellow}${c.bold} DRY RUN — nothing will be written${c.reset}`);
log();
// Connect to source
let glDb;
try {
glDb = await openPostgres(GL_URL, 'Greenlight');
await glDb.get('SELECT 1');
info(`Connected to Greenlight DB (PostgreSQL)`);
} catch (e) {
err(`Cannot connect to Greenlight DB: ${e.message}`);
process.exit(1);
}
// Connect to target
let rlDb;
try {
if (isPostgresTarget) {
rlDb = await openPostgres(RL_URL, 'Redlight');
await rlDb.get('SELECT 1');
info(`Connected to Redlight DB (PostgreSQL)`);
} else {
rlDb = await openSqlite();
info(`Connected to Redlight DB (SQLite: ${rlDb.path})`);
}
} catch (e) {
err(`Cannot connect to Redlight DB: ${e.message}`);
process.exit(1);
}
log();
// ── 1. Load Greenlight roles ───────────────────────────────────────────
const roles = await glDb.all('SELECT id, name FROM roles');
const adminRoleIds = new Set(
roles.filter(r => /admin|administrator/i.test(r.name)).map(r => r.id)
);
info(`Found ${roles.length} roles (${adminRoleIds.size} admin role(s))`);
// ── 2. Load Greenlight users (local + OIDC) ────────────────────────────
const glUsers = await glDb.all(`
SELECT id, name, email, password_digest, language, role_id, verified, status, provider, external_id
FROM users
ORDER BY created_at ASC
`);
const localUsers = glUsers.filter(u => u.provider === 'greenlight');
const oidcUsers = glUsers.filter(u => u.provider !== 'greenlight' && u.provider !== null);
info(`Found ${localUsers.length} local user(s), ${oidcUsers.length} OIDC/SSO user(s)`);
log();
// ── 3. Migrate local users ─────────────────────────────────────────────
log(`${c.bold}Local Users${c.reset}`);
const userIdMap = new Map(); // gl user id → rl user id
let usersCreated = 0, usersSkipped = 0;
for (const u of localUsers) {
if (!u.password_digest) {
warn(`${u.email} — no password, skipping`);
usersSkipped++;
continue;
}
const role = adminRoleIds.has(u.role_id) ? 'admin' : 'user';
const emailVerified = u.verified ? 1 : 0;
const lang = u.language || 'de';
const displayName = u.name || '';
const existing = await rlDb.get('SELECT id FROM users WHERE email = ?', [u.email]);
if (existing) {
userIdMap.set(u.id, existing.id);
skip(`${u.email} — already exists (id=${existing.id}), skipping`);
usersSkipped++;
continue;
}
loud(`INSERT user ${u.email} (role=${role})`);
if (!DRY_RUN) {
const result = await rlDb.run(
`INSERT INTO users (name, display_name, email, password_hash, role, language, email_verified)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[displayName, displayName, u.email, u.password_digest, role, lang, emailVerified]
);
userIdMap.set(u.id, result.lastInsertRowid);
ok(`${u.email} (${role})`);
} else {
ok(`[dry] ${u.email} (${role})`);
}
usersCreated++;
}
log();
log(` Created: ${c.green}${usersCreated}${c.reset} Skipped: ${usersSkipped}`);
log();
// ── 3b. Migrate OIDC / SSO users ───────────────────────────────────────
log(`${c.bold}OIDC / SSO Users${c.reset}`);
let oidcCreated = 0, oidcSkipped = 0;
for (const u of oidcUsers) {
const role = adminRoleIds.has(u.role_id) ? 'admin' : 'user';
const lang = u.language || 'de';
const displayName = u.name || '';
const email = (u.email || '').toLowerCase().trim();
if (!email) {
warn(`OIDC user id=${u.id} — no email, skipping`);
oidcSkipped++;
continue;
}
const existing = await rlDb.get('SELECT id FROM users WHERE email = ?', [email]);
if (existing) {
userIdMap.set(u.id, existing.id);
// Link OAuth provider if not set yet
if (!DRY_RUN && u.external_id) {
await rlDb.run(
'UPDATE users SET oauth_provider = ?, oauth_provider_id = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND oauth_provider IS NULL',
['oidc', u.external_id, existing.id]
);
}
skip(`${email} — already exists (id=${existing.id}), linked OIDC`);
oidcSkipped++;
continue;
}
// Generate a random unusable password hash for OIDC users
const randomHash = `oauth:${crypto.randomUUID()}`;
loud(`INSERT OIDC user ${email} (role=${role}, sub=${u.external_id || '?'})`);
if (!DRY_RUN) {
const result = await rlDb.run(
`INSERT INTO users (name, display_name, email, password_hash, role, language, email_verified, oauth_provider, oauth_provider_id)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`,
[displayName, displayName, email, randomHash, role, lang, 'oidc', u.external_id || null]
);
userIdMap.set(u.id, result.lastInsertRowid);
ok(`${email} (${role}, OIDC)`);
} else {
ok(`[dry] ${email} (${role}, OIDC)`);
}
oidcCreated++;
}
log();
log(` Created: ${c.green}${oidcCreated}${c.reset} Skipped: ${oidcSkipped}`);
log();
if (SKIP_ROOMS) {
log(`${c.yellow}--skip-rooms set, stopping here.${c.reset}`);
await cleanup(glDb, rlDb);
return;
}
// ── 4. Load Greenlight rooms + meeting options ─────────────────────────
log(`${c.bold}Rooms${c.reset}`);
const glRooms = await glDb.all(`
SELECT r.id, r.name, r.meeting_id, r.user_id
FROM rooms r
ORDER BY r.created_at ASC
`);
// Load all meeting options (key name list)
const meetingOptions = await glDb.all('SELECT id, name FROM meeting_options');
const optionIdToName = new Map(meetingOptions.map(m => [m.id, m.name]));
// Load all room_meeting_option values in one shot
const roomOptionRows = await glDb.all('SELECT room_id, meeting_option_id, value FROM room_meeting_options');
const roomOptions = new Map(); // room_id → { optionName: value }
for (const row of roomOptionRows) {
const name = optionIdToName.get(row.meeting_option_id);
if (!name) continue;
if (!roomOptions.has(row.room_id)) roomOptions.set(row.room_id, {});
roomOptions.get(row.room_id)[name] = row.value;
}
info(`Found ${glRooms.length} room(s) across all users`);
log();
let roomsCreated = 0, roomsSkipped = 0;
const roomIdMap = new Map(); // gl room id → rl room id
for (const room of glRooms) {
// Determine the redlight owner
const rlUserId = userIdMap.get(room.user_id);
if (!rlUserId && !DRY_RUN) {
// Try to look up the email in redlight directly in case user already existed
const glUser = glUsers.find(u => u.id === room.user_id);
if (glUser) {
const ex = await rlDb.get('SELECT id FROM users WHERE email = ?', [glUser.email]);
if (ex) {
userIdMap.set(room.user_id, ex.id);
} else {
warn(`Room "${room.name}" — owner not found in Redlight, skipping`);
roomsSkipped++;
continue;
}
} else {
warn(`Room "${room.name}" — owner not found in Greenlight users, skipping`);
roomsSkipped++;
continue;
}
}
const ownerId = userIdMap.get(room.user_id) || null;
// Use Greenlight meeting_id as the uid (preserves BBB meeting identity)
// Greenlight meeting_ids can be longer, but Redlight stores uid as TEXT — no problem.
const uid = room.meeting_id;
// Check if already in Redlight
const existingRoom = await rlDb.get('SELECT id FROM rooms WHERE uid = ?', [uid]);
if (existingRoom) {
roomIdMap.set(room.id, existingRoom.id);
skip(`"${room.name}" (${uid.substring(0, 12)}…) — already exists`);
roomsSkipped++;
continue;
}
// Map meeting options to Redlight fields
const opts = roomOptions.get(room.id) || {};
const mute_on_join = opts.muteOnStart ? boolOption(opts.muteOnStart) : 1;
const record_meeting = opts.record ? boolOption(opts.record) : 1;
const anyone_can_start = opts.glAnyoneCanStart ? boolOption(opts.glAnyoneCanStart) : 0;
const all_join_moderator = opts.glAnyoneJoinAsModerator ? boolOption(opts.glAnyoneJoinAsModerator): 0;
const require_approval = opts.guestPolicy === 'ASK_MODERATOR' ? 1 : 0;
const access_code = (opts.glViewerAccessCode && opts.glViewerAccessCode !== 'false')
? opts.glViewerAccessCode : null;
const moderator_code = (opts.glModeratorAccessCode && opts.glModeratorAccessCode !== 'false')
? opts.glModeratorAccessCode : null;
const guest_access = 1; // Default open like Greenlight
// Ensure room name meets Redlight 2-char minimum
const roomName = (room.name || 'Room').length >= 2 ? room.name : (room.name || 'Room').padEnd(2, ' ');
loud(`INSERT room "${roomName}" uid=${uid.substring(0, 16)}… owner=${ownerId}`);
if (!DRY_RUN && ownerId) {
const result = await rlDb.run(
`INSERT INTO rooms (uid, name, user_id, mute_on_join, record_meeting, anyone_can_start,
all_join_moderator, require_approval, access_code, moderator_code, guest_access)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[uid, roomName, ownerId, mute_on_join, record_meeting, anyone_can_start,
all_join_moderator, require_approval, access_code, moderator_code, guest_access]
);
roomIdMap.set(room.id, result.lastInsertRowid);
const optSummary = [
mute_on_join ? 'muted' : null,
record_meeting ? 'record' : null,
anyone_can_start ? 'anyStart' : null,
all_join_moderator ? 'allMod' : null,
require_approval ? 'approval' : null,
access_code ? 'code:'+access_code.substring(0,4)+'…' : null,
].filter(Boolean).join(', ');
ok(`"${roomName}"${optSummary ? ` [${optSummary}]` : ''}`);
} else if (DRY_RUN) {
ok(`[dry] "${roomName}" (uid=${uid.substring(0, 16)}…)`);
} else {
warn(`"${roomName}" — skipped (no owner resolved)`);
roomsSkipped++;
continue;
}
roomsCreated++;
}
log();
log(` Created: ${c.green}${roomsCreated}${c.reset} Skipped: ${roomsSkipped}`);
log();
// ── 5. Migrate shared_accesses ─────────────────────────────────────────
if (!SKIP_SHARES) {
log(`${c.bold}Room Shares${c.reset}`);
const shares = await glDb.all('SELECT user_id, room_id FROM shared_accesses');
info(`Found ${shares.length} shared accesse(s)`);
log();
let sharesCreated = 0, sharesSkipped = 0;
for (const s of shares) {
const rlUser = userIdMap.get(s.user_id);
const rlRoom = roomIdMap.get(s.room_id);
if (!rlUser || !rlRoom) {
loud(`Skip share user=${s.user_id} room=${s.room_id} — not found in target`);
sharesSkipped++;
continue;
}
const exists = await rlDb.get(
'SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?',
[rlRoom, rlUser]
);
if (exists) {
sharesSkipped++;
continue;
}
if (!DRY_RUN) {
await rlDb.run(
'INSERT INTO room_shares (room_id, user_id) VALUES (?, ?)',
[rlRoom, rlUser]
);
ok(`Share room_id=${rlRoom} → user_id=${rlUser}`);
} else {
ok(`[dry] Share room_id=${rlRoom} → user_id=${rlUser}`);
}
sharesCreated++;
}
log();
log(` Created: ${c.green}${sharesCreated}${c.reset} Skipped: ${sharesSkipped}`);
log();
}
// ── 6. Migrate site_settings / branding ────────────────────────────────
if (!SKIP_SETTINGS) {
log(`${c.bold}Site Settings & Branding${c.reset}`);
let settingsCount = 0;
// Check if site_settings table exists in Greenlight
let hasSiteSettings = false;
try {
await glDb.get('SELECT 1 FROM site_settings LIMIT 1');
hasSiteSettings = true;
} catch {
warn('No site_settings table found in Greenlight DB — skipping settings migration');
}
if (hasSiteSettings) {
const glSettings = await glDb.all('SELECT setting, value FROM site_settings');
const settingsMap = new Map(glSettings.map(s => [s.setting, s.value]));
info(`Found ${glSettings.length} site_setting(s) in Greenlight`);
// ── Registration mode ──────────────────────────────────────────────
const regMethod = settingsMap.get('RegistrationMethod');
if (regMethod) {
// Greenlight: "open", "invite", "approval" → Redlight: "open" or "invite"
const mode = regMethod === 'open' ? 'open' : 'invite';
if (!DRY_RUN) await upsertSetting(rlDb, 'registration_mode', mode, isPostgresTarget);
ok(`registration_mode → ${mode} (was: ${regMethod})`);
settingsCount++;
}
// ── Privacy policy URL ─────────────────────────────────────────────
const privacy = settingsMap.get('PrivacyPolicy');
if (privacy && privacy.trim()) {
if (!DRY_RUN) await upsertSetting(rlDb, 'privacy_url', privacy.trim(), isPostgresTarget);
ok(`privacy_url → ${privacy.trim()}`);
settingsCount++;
}
// ── Terms / Imprint URL ────────────────────────────────────────────
const terms = settingsMap.get('Terms');
if (terms && terms.trim()) {
if (!DRY_RUN) await upsertSetting(rlDb, 'imprint_url', terms.trim(), isPostgresTarget);
ok(`imprint_url → ${terms.trim()}`);
settingsCount++;
}
// ── Primary color (informational only — Redlight uses themes) ─────
const primaryColor = settingsMap.get('PrimaryColor');
if (primaryColor) {
info(`PrimaryColor in Greenlight was: ${primaryColor} (not mapped — Redlight uses themes)`);
}
// ── Branding image / logo ──────────────────────────────────────────
const brandingImage = settingsMap.get('BrandingImage');
if (brandingImage && brandingImage.trim()) {
const logoUrl = brandingImage.trim();
info(`BrandingImage URL: ${logoUrl}`);
if (!DRY_RUN) {
try {
const response = await fetch(logoUrl, { signal: AbortSignal.timeout(15_000) });
if (response.ok) {
const contentType = response.headers.get('content-type') || '';
let ext = '.png';
if (contentType.includes('svg')) ext = '.svg';
else if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = '.jpg';
else if (contentType.includes('gif')) ext = '.gif';
else if (contentType.includes('webp')) ext = '.webp';
else if (contentType.includes('ico')) ext = '.ico';
const brandingDir = path.join(__dirname, 'uploads', 'branding');
fs.mkdirSync(brandingDir, { recursive: true });
// Remove old logos
if (fs.existsSync(brandingDir)) {
for (const f of fs.readdirSync(brandingDir)) {
if (f.startsWith('logo.')) fs.unlinkSync(path.join(brandingDir, f));
}
}
const logoPath = path.join(brandingDir, `logo${ext}`);
const buffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync(logoPath, buffer);
ok(`Logo saved → uploads/branding/logo${ext} (${(buffer.length / 1024).toFixed(1)} KB)`);
settingsCount++;
} else {
warn(`Could not download logo (HTTP ${response.status}) — skipping`);
}
} catch (dlErr) {
warn(`Logo download failed: ${dlErr.message}`);
}
} else {
ok(`[dry] Would download logo from ${logoUrl}`);
settingsCount++;
}
}
log();
log(` Settings migrated: ${c.green}${settingsCount}${c.reset}`);
log();
}
}
// ── 7. Migrate OAuth / OIDC configuration ──────────────────────────────
if (!SKIP_OAUTH) {
log(`${c.bold}OAuth / OIDC Configuration${c.reset}`);
const issuer = process.env.GL_OIDC_ISSUER;
const clientId = process.env.GL_OIDC_CLIENT_ID;
const clientSecret = process.env.GL_OIDC_CLIENT_SECRET;
const displayName = process.env.GL_OIDC_DISPLAY_NAME || 'SSO';
if (issuer && clientId && clientSecret) {
if (!process.env.JWT_SECRET) {
warn('JWT_SECRET is not set — cannot encrypt client secret. Skipping OAuth migration.');
} else {
// Check if already configured
const existing = await rlDb.get("SELECT key FROM settings WHERE key = 'oauth_config'");
if (existing) {
warn('oauth_config already exists in Redlight — skipping (delete it first to re-migrate)');
} else {
info(`Issuer: ${issuer}`);
info(`Client ID: ${clientId}`);
info(`Display name: ${displayName}`);
if (!DRY_RUN) {
const config = JSON.stringify({
issuer,
clientId,
encryptedSecret: encryptSecret(clientSecret),
displayName,
autoRegister: true,
});
await upsertSetting(rlDb, 'oauth_config', config, isPostgresTarget);
ok('OAuth/OIDC configuration saved');
} else {
ok('[dry] Would save OAuth/OIDC configuration');
}
}
}
} else if (issuer || clientId || clientSecret) {
warn('Incomplete OIDC config — set GL_OIDC_ISSUER, GL_OIDC_CLIENT_ID, and GL_OIDC_CLIENT_SECRET');
} else {
info('No GL_OIDC_* env vars set — skipping OAuth migration');
info('Set GL_OIDC_ISSUER, GL_OIDC_CLIENT_ID, GL_OIDC_CLIENT_SECRET to migrate OIDC config');
}
log();
}
// ── Summary ────────────────────────────────────────────────────────────
log(`${c.bold}${c.green}Migration complete${c.reset}`);
log();
log(` Local users migrated: ${c.green}${usersCreated}${c.reset}`);
log(` OIDC users migrated: ${c.green}${oidcCreated}${c.reset}`);
log(` Rooms migrated: ${c.green}${roomsCreated}${c.reset}`);
if (!SKIP_SHARES) log(` Shares migrated: (see above)`);
if (!SKIP_SETTINGS) log(` Settings migrated: (see above)`);
if (!SKIP_OAUTH) log(` OAuth config: (see above)`);
if (DRY_RUN) {
log();
log(` ${c.yellow}${c.bold}This was a DRY RUN — rerun without --dry-run to apply.${c.reset}`);
}
log();
await cleanup(glDb, rlDb);
}
async function cleanup(glDb, rlDb) {
try { await glDb.end?.(); } catch {}
try { await rlDb.end?.(); rlDb.close?.(); } catch {}
}
main().catch(e => {
console.error(`\n${c.red}Fatal error:${c.reset}`, e.message);
process.exit(1);
});

3449
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
{ {
"name": "redlight", "name": "redlight",
"private": true, "private": true,
"version": "1.4.0", "version": "2.1.2",
"license": "GPL-3.0-or-later",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently -n client,server -c blue,green \"vite\" \"node --watch server/index.js\"", "dev": "concurrently -n client,server -c blue,green \"vite\" \"node --watch server/index.js\"",
@@ -13,19 +14,24 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.0", "axios": "^1.7.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^11.0.0", "better-sqlite3": "^12.6.2",
"concurrently": "^9.0.0", "concurrently": "^9.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.0", "dotenv": "^17.3.1",
"exceljs": "^4.4.0",
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^7.5.1", "express-rate-limit": "^7.5.1",
"flatpickr": "^4.6.13",
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"multer": "^2.0.2", "multer": "^2.1.0",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"otpauth": "^9.5.0",
"pdfkit": "^0.17.2",
"pg": "^8.18.0", "pg": "^8.18.0",
"qrcode": "^1.5.4",
"rate-limit-redis": "^4.3.1", "rate-limit-redis": "^4.3.1",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
@@ -37,10 +43,10 @@
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.0", "@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.0", "autoprefixer": "^10.4.0",
"postcss": "^8.4.0", "postcss": "^8.4.0",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"vite": "^5.4.0" "vite": "^8.0.0"
} }
} }

Binary file not shown.

View File

@@ -5,6 +5,20 @@ import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParam
const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/'; const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/';
const BBB_SECRET = process.env.BBB_SECRET || ''; const BBB_SECRET = process.env.BBB_SECRET || '';
if (!BBB_SECRET) {
log.bbb.warn('WARNING: BBB_SECRET is not set. BBB API calls will use an empty secret.');
}
// HTML-escape for safe embedding in BBB welcome messages
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getChecksum(apiCall, params) { function getChecksum(apiCall, params) {
const queryString = new URLSearchParams(params).toString(); const queryString = new URLSearchParams(params).toString();
const raw = apiCall + queryString + BBB_SECRET; const raw = apiCall + queryString + BBB_SECRET;
@@ -59,22 +73,22 @@ function getRoomPasswords(uid) {
return { moderatorPW: modPw, attendeePW: attPw }; return { moderatorPW: modPw, attendeePW: attPw };
} }
export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null) { export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null, analyticsCallbackURL = null) {
const { moderatorPW, attendeePW } = getRoomPasswords(room.uid); const { moderatorPW, attendeePW } = getRoomPasswords(room.uid);
// Build welcome message with guest invite link // Build welcome message with guest invite link
let welcome = room.welcome_message || t('defaultWelcome'); // HTML-escape user-controlled content to prevent stored XSS via BBB
let welcome = room.welcome_message ? escapeHtml(room.welcome_message) : t('defaultWelcome');
if (logoutURL) { if (logoutURL) {
const guestLink = `${logoutURL}/join/${room.uid}`; const guestLink = `${logoutURL}/join/${room.uid}`;
welcome += `<br><br>To invite other participants, share this link:<br><a href="${guestLink}">${guestLink}</a>`; welcome += `<br><br>To invite other participants, share this link:<br><a href="${escapeHtml(guestLink)}">${escapeHtml(guestLink)}</a>`;
if (room.access_code) { // Access code is intentionally NOT shown in the welcome message to prevent
welcome += `<br>Access Code: <b>${room.access_code}</b>`; // leaking it to all meeting participants.
}
} }
const params = { const params = {
meetingID: room.uid, meetingID: room.uid,
name: room.name, name: room.name.length >= 2 ? room.name : room.name.padEnd(2, ' '),
attendeePW, attendeePW,
moderatorPW, moderatorPW,
welcome, welcome,
@@ -97,6 +111,9 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati
if (room.access_code) { if (room.access_code) {
params.lockSettingsLockOnJoin = 'true'; params.lockSettingsLockOnJoin = 'true';
} }
if (analyticsCallbackURL) {
params['meta_analytics-callback-url'] = analyticsCallbackURL;
}
// Build optional presentation XML body - escape URL to prevent XML injection // Build optional presentation XML body - escape URL to prevent XML injection
let xmlBody = null; let xmlBody = null;
@@ -174,4 +191,8 @@ export async function publishRecording(recordID, publish) {
return apiCall('publishRecordings', { recordID, publish: publish ? 'true' : 'false' }); return apiCall('publishRecordings', { recordID, publish: publish ? 'true' : 'false' });
} }
export function getAnalyticsToken(uid) {
return crypto.createHmac('sha256', BBB_SECRET).update('analytics_' + uid).digest('hex');
}
export { getRoomPasswords }; export { getRoomPasswords };

View File

@@ -1,4 +1,4 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { log } from './logger.js'; import { log } from './logger.js';
@@ -49,6 +49,12 @@ class SqliteAdapter {
return !!columns.find(c => c.name === column); return !!columns.find(c => c.name === column);
} }
async columnIsNullable(table, column) {
const columns = this.db.pragma(`table_info(${table})`);
const col = columns.find(c => c.name === column);
return col ? col.notnull === 0 : true;
}
close() { close() {
this.db.close(); this.db.close();
} }
@@ -77,7 +83,9 @@ class PostgresAdapter {
let pgSql = convertPlaceholders(sql); let pgSql = convertPlaceholders(sql);
const isInsert = /^\s*INSERT/i.test(pgSql); const isInsert = /^\s*INSERT/i.test(pgSql);
if (isInsert && !/RETURNING/i.test(pgSql)) { if (isInsert && !/RETURNING/i.test(pgSql)) {
pgSql += ' RETURNING id'; // Some tables (e.g. settings, oauth_states) have no "id" column.
// Return the inserted row generically and read id only when present.
pgSql += ' RETURNING *';
} }
const result = await this.pool.query(pgSql, params); const result = await this.pool.query(pgSql, params);
return { return {
@@ -98,6 +106,14 @@ class PostgresAdapter {
return result.rows.length > 0; return result.rows.length > 0;
} }
async columnIsNullable(table, column) {
const result = await this.pool.query(
'SELECT is_nullable FROM information_schema.columns WHERE table_name = $1 AND column_name = $2',
[table, column]
);
return result.rows.length > 0 ? result.rows[0].is_nullable === 'YES' : true;
}
close() { close() {
this.pool?.end(); this.pool?.end();
} }
@@ -513,6 +529,12 @@ export async function initDatabase() {
if (!(await db.columnExists('calendar_events', 'federated_join_url'))) { if (!(await db.columnExists('calendar_events', 'federated_join_url'))) {
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL'); await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL');
} }
if (!(await db.columnExists('calendar_events', 'reminder_minutes'))) {
await db.exec('ALTER TABLE calendar_events ADD COLUMN reminder_minutes INTEGER DEFAULT NULL');
}
if (!(await db.columnExists('calendar_events', 'reminder_sent_at'))) {
await db.exec(`ALTER TABLE calendar_events ADD COLUMN reminder_sent_at ${isPostgres ? 'TIMESTAMP' : 'DATETIME'} DEFAULT NULL`);
}
// Calendar invitations (federated calendar events that must be accepted first) // Calendar invitations (federated calendar events that must be accepted first)
if (isPostgres) { if (isPostgres) {
@@ -649,6 +671,150 @@ export async function initDatabase() {
`); `);
} }
// ── CalDAV tokens ────────────────────────────────────────────────────────
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS caldav_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
last_used_at TIMESTAMP DEFAULT NULL
);
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS caldav_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME DEFAULT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token);
`);
}
// CalDAV: add token_hash column for SHA-256 hashed token lookup
if (!(await db.columnExists('caldav_tokens', 'token_hash'))) {
await db.exec('ALTER TABLE caldav_tokens ADD COLUMN token_hash TEXT DEFAULT NULL');
await db.exec('CREATE INDEX IF NOT EXISTS idx_caldav_tokens_hash ON caldav_tokens(token_hash)');
}
// CalDAV: make token column nullable (now only token_hash is stored for new tokens)
if (!(await db.columnIsNullable('caldav_tokens', 'token'))) {
if (isPostgres) {
await db.exec('ALTER TABLE caldav_tokens ALTER COLUMN token DROP NOT NULL');
} else {
// SQLite does not support ALTER COLUMN — recreate the table
await db.exec(`
CREATE TABLE caldav_tokens_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT UNIQUE,
token_hash TEXT DEFAULT NULL,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME DEFAULT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
INSERT INTO caldav_tokens_new (id, user_id, token, token_hash, name, created_at, last_used_at)
SELECT id, user_id, token, token_hash, name, created_at, last_used_at FROM caldav_tokens;
DROP TABLE caldav_tokens;
ALTER TABLE caldav_tokens_new RENAME TO caldav_tokens;
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token);
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_hash ON caldav_tokens(token_hash);
`);
}
}
// ── OAuth tables ────────────────────────────────────────────────────────
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS oauth_states (
state TEXT PRIMARY KEY,
provider TEXT NOT NULL,
code_verifier TEXT NOT NULL,
return_to TEXT,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS oauth_states (
state TEXT PRIMARY KEY,
provider TEXT NOT NULL,
code_verifier TEXT NOT NULL,
return_to TEXT,
expires_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at);
`);
}
// Add OAuth columns to users table
if (!(await db.columnExists('users', 'oauth_provider'))) {
await db.exec('ALTER TABLE users ADD COLUMN oauth_provider TEXT DEFAULT NULL');
}
if (!(await db.columnExists('users', 'oauth_provider_id'))) {
await db.exec('ALTER TABLE users ADD COLUMN oauth_provider_id TEXT DEFAULT NULL');
}
// ── Learning Analytics table ─────────────────────────────────────────────
if (!(await db.columnExists('rooms', 'learning_analytics'))) {
await db.exec('ALTER TABLE rooms ADD COLUMN learning_analytics INTEGER DEFAULT 0');
}
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS learning_analytics_data (
id SERIAL PRIMARY KEY,
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
meeting_id TEXT NOT NULL,
meeting_name TEXT,
data JSONB NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_analytics_room_id ON learning_analytics_data(room_id);
CREATE INDEX IF NOT EXISTS idx_analytics_meeting_id ON learning_analytics_data(meeting_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS learning_analytics_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
meeting_id TEXT NOT NULL,
meeting_name TEXT,
data TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_analytics_room_id ON learning_analytics_data(room_id);
CREATE INDEX IF NOT EXISTS idx_analytics_meeting_id ON learning_analytics_data(meeting_id);
`);
}
// ── TOTP 2FA columns ──────────────────────────────────────────────────────
if (!(await db.columnExists('users', 'totp_secret'))) {
await db.exec('ALTER TABLE users ADD COLUMN totp_secret TEXT DEFAULT NULL');
}
if (!(await db.columnExists('users', 'totp_enabled'))) {
await db.exec('ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0');
}
// ── Analytics visibility setting ────────────────────────────────────────
if (!(await db.columnExists('rooms', 'analytics_visibility'))) {
await db.exec("ALTER TABLE rooms ADD COLUMN analytics_visibility TEXT DEFAULT 'owner'");
}
// ── Default admin (only on very first start) ──────────────────────────── // ── Default admin (only on very first start) ────────────────────────────
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'"); const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
if (!adminAlreadySeeded) { if (!adminAlreadySeeded) {

View File

@@ -4,6 +4,9 @@ import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { log } from './logger.js'; import { log } from './logger.js';
import dns from 'dns';
import net from 'net';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -93,13 +96,69 @@ export function verifyPayload(payload, signature, remotePublicKeyPem) {
} }
} }
/**
* Check if a domain resolves to a private/internal IP address (SSRF protection).
* Blocks RFC 1918, loopback, link-local, and cloud metadata IPs.
* @param {string} domain
* @returns {Promise<void>} throws if domain resolves to a blocked IP
*/
async function assertPublicDomain(domain) {
// Allow localhost only in development
if (domain === 'localhost' || domain === '127.0.0.1' || domain === '::1') {
if (process.env.NODE_ENV === 'production') {
throw new Error('Federation to localhost is blocked in production');
}
return; // allow in dev
}
// If domain is a raw IP, check it directly
if (net.isIP(domain)) {
if (isPrivateIP(domain)) {
throw new Error(`Federation blocked: ${domain} resolves to a private IP`);
}
return;
}
// Resolve domain and check all resulting IPs
const { resolve4, resolve6 } = dns.promises;
const ips = [];
try { ips.push(...await resolve4(domain)); } catch {}
try { ips.push(...await resolve6(domain)); } catch {}
if (ips.length === 0) {
throw new Error(`Federation blocked: could not resolve ${domain}`);
}
for (const ip of ips) {
if (isPrivateIP(ip)) {
throw new Error(`Federation blocked: ${domain} resolves to a private IP (${ip})`);
}
}
}
function isPrivateIP(ip) {
// IPv4 private ranges
if (/^10\./.test(ip)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
if (/^192\.168\./.test(ip)) return true;
if (/^127\./.test(ip)) return true;
if (/^0\./.test(ip)) return true;
if (/^169\.254\./.test(ip)) return true; // link-local
if (ip === '::1' || ip === '::' || ip.startsWith('fe80:') || ip.startsWith('fc') || ip.startsWith('fd')) return true;
return false;
}
/** /**
* Discover a remote Redlight instance's federation API base URL. * Discover a remote Redlight instance's federation API base URL.
* Fetches https://{domain}/.well-known/redlight and caches the result. * Fetches https://{domain}/.well-known/redlight and caches the result.
* Includes SSRF protection: blocks private/internal IPs.
* @param {string} domain * @param {string} domain
* @returns {Promise<{ baseUrl: string, publicKey: string }>} * @returns {Promise<{ baseUrl: string, publicKey: string }>}
*/ */
export async function discoverInstance(domain) { export async function discoverInstance(domain) {
// SSRF protection: validate domain doesn't resolve to internal IP
await assertPublicDomain(domain);
const cached = discoveryCache.get(domain); const cached = discoveryCache.get(domain);
if (cached && (Date.now() - cached.cachedAt) < DISCOVERY_TTL_MS) { if (cached && (Date.now() - cached.cachedAt) < DISCOVERY_TTL_MS) {
return cached; return cached;
@@ -112,7 +171,8 @@ export async function discoverInstance(domain) {
try { try {
response = await fetch(wellKnownUrl, { signal: AbortSignal.timeout(TIMEOUT_MS) }); response = await fetch(wellKnownUrl, { signal: AbortSignal.timeout(TIMEOUT_MS) });
} catch (e) { } catch (e) {
if (e.message.includes('fetch') && wellKnownUrl.startsWith('https://localhost')) { // HTTP fallback only allowed in development for localhost
if (e.message.includes('fetch') && domain === 'localhost' && process.env.NODE_ENV !== 'production') {
response = await fetch(`http://${domain}/.well-known/redlight`, { signal: AbortSignal.timeout(TIMEOUT_MS) }); response = await fetch(`http://${domain}/.well-known/redlight`, { signal: AbortSignal.timeout(TIMEOUT_MS) });
} else throw e; } else throw e;
} }
@@ -128,7 +188,9 @@ export async function discoverInstance(domain) {
const baseUrl = `https://${domain}${data.federation_api || '/api/federation'}`; const baseUrl = `https://${domain}${data.federation_api || '/api/federation'}`;
const result = { const result = {
baseUrl: baseUrl.replace('https://localhost', 'http://localhost'), baseUrl: (domain === 'localhost' && process.env.NODE_ENV !== 'production')
? baseUrl.replace('https://localhost', 'http://localhost')
: baseUrl,
publicKey: data.public_key, publicKey: data.public_key,
cachedAt: Date.now(), cachedAt: Date.now(),
}; };

275
server/config/oauth.js Normal file
View File

@@ -0,0 +1,275 @@
/**
* OAuth / OpenID Connect configuration for Redlight.
*
* Supports generic OIDC providers (Keycloak, Authentik, Google, GitHub, etc.)
* configured at runtime via admin settings stored in the database.
*
* Security:
* - PKCE (S256) on every authorization request
* - Anti-CSRF via cryptographic `state` parameter stored server-side
* - State entries expire after 10 minutes and are single-use
* - Client secrets are stored AES-256-GCM encrypted in the DB
* - Only https callback URLs in production
* - Token exchange uses server-side secret, never exposed to the browser
*/
import crypto from 'crypto';
import { getDb } from './database.js';
import { log } from './logger.js';
// ── Encryption helpers for client secrets ──────────────────────────────────
// Derive a key from JWT_SECRET (always available)
const ENCRYPTION_KEY = crypto
.createHash('sha256')
.update(process.env.JWT_SECRET || '')
.digest(); // 32 bytes → AES-256
/**
* Encrypt a plaintext string with AES-256-GCM.
* Returns "iv:authTag:ciphertext" (all hex-encoded).
*/
export function encryptSecret(plaintext) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
}
/**
* Decrypt an AES-256-GCM encrypted string.
*/
export function decryptSecret(encryptedStr) {
const [ivHex, authTagHex, ciphertext] = encryptedStr.split(':');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// ── PKCE helpers ───────────────────────────────────────────────────────────
/** Generate a cryptographically random code_verifier (RFC 7636). */
export function generateCodeVerifier() {
return crypto.randomBytes(32).toString('base64url');
}
/** Compute the S256 code_challenge from a code_verifier. */
export function computeCodeChallenge(verifier) {
return crypto.createHash('sha256').update(verifier).digest('base64url');
}
// ── State management (anti-CSRF) ───────────────────────────────────────────
const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
/**
* Create and persist an OAuth state token with associated PKCE verifier.
* @param {string} provider provider key (e.g. 'oidc')
* @param {string} codeVerifier PKCE code_verifier
* @param {string|null} returnTo optional return URL after login
* @returns {Promise<string>} state token
*/
export async function createOAuthState(provider, codeVerifier, returnTo = null) {
const state = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + STATE_TTL_MS).toISOString();
const db = getDb();
await db.run(
'INSERT INTO oauth_states (state, provider, code_verifier, return_to, expires_at) VALUES (?, ?, ?, ?, ?)',
[state, provider, codeVerifier, returnTo, expiresAt],
);
return state;
}
/**
* Consume (validate + delete) an OAuth state token.
* Returns the stored data or null if invalid/expired.
* @param {string} state
* @returns {Promise<{ provider: string, code_verifier: string, return_to: string|null } | null>}
*/
export async function consumeOAuthState(state) {
if (!state || typeof state !== 'string' || state.length > 128) return null;
const db = getDb();
const row = await db.get(
'SELECT * FROM oauth_states WHERE state = ?',
[state],
);
if (!row) return null;
// Always delete (single-use)
await db.run('DELETE FROM oauth_states WHERE state = ?', [state]);
// Check expiry
if (new Date(row.expires_at) < new Date()) return null;
return {
provider: row.provider,
code_verifier: row.code_verifier,
return_to: row.return_to,
};
}
/**
* Garbage-collect expired OAuth states (called periodically).
*/
export async function cleanupExpiredStates() {
try {
const db = getDb();
await db.run('DELETE FROM oauth_states WHERE expires_at < CURRENT_TIMESTAMP');
} catch (err) {
log.auth.warn(`OAuth state cleanup failed: ${err.message}`);
}
}
// ── Provider configuration ─────────────────────────────────────────────────
/**
* Load the stored OAuth provider config from the settings table.
* Returns null if OAuth is not configured.
* @returns {Promise<{ issuer: string, clientId: string, clientSecret: string, displayName: string, autoRegister: boolean } | null>}
*/
export async function getOAuthConfig() {
try {
const db = getDb();
const row = await db.get("SELECT value FROM settings WHERE key = 'oauth_config'");
if (!row?.value) return null;
const config = JSON.parse(row.value);
if (!config.issuer || !config.clientId || !config.encryptedSecret) return null;
return {
issuer: config.issuer,
clientId: config.clientId,
clientSecret: decryptSecret(config.encryptedSecret),
displayName: config.displayName || 'SSO',
autoRegister: config.autoRegister !== false,
};
} catch (err) {
log.auth.error(`Failed to load OAuth config: ${err.message}`);
return null;
}
}
/**
* Save OAuth provider config to the settings table.
* The client secret is encrypted before storage.
*/
export async function saveOAuthConfig({ issuer, clientId, clientSecret, displayName, autoRegister }) {
const db = getDb();
const config = {
issuer,
clientId,
encryptedSecret: encryptSecret(clientSecret),
displayName: displayName || 'SSO',
autoRegister: autoRegister !== false,
};
const value = JSON.stringify(config);
const existing = await db.get("SELECT key FROM settings WHERE key = 'oauth_config'");
if (existing) {
await db.run("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = 'oauth_config'", [value]);
} else {
await db.run("INSERT INTO settings (key, value) VALUES ('oauth_config', ?) RETURNING key", [value]);
}
}
/**
* Remove OAuth configuration.
*/
export async function deleteOAuthConfig() {
const db = getDb();
await db.run("DELETE FROM settings WHERE key = 'oauth_config'");
}
// ── OIDC Discovery ─────────────────────────────────────────────────────────
// Cache discovered OIDC endpoints { authorization_endpoint, token_endpoint, userinfo_endpoint, ... }
const discoveryCache = new Map();
const DISCOVERY_TTL_MS = 30 * 60 * 1000; // 30 minutes
/**
* Fetch and cache the OpenID Connect discovery document for the given issuer.
* @param {string} issuer
* @returns {Promise<object>}
*/
export async function discoverOIDC(issuer) {
const cached = discoveryCache.get(issuer);
if (cached && Date.now() - cached.fetchedAt < DISCOVERY_TTL_MS) {
return cached.data;
}
// Normalize issuer URL
const base = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;
const url = `${base}/.well-known/openid-configuration`;
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!response.ok) {
throw new Error(`OIDC discovery failed for ${issuer}: HTTP ${response.status}`);
}
const data = await response.json();
if (!data.authorization_endpoint || !data.token_endpoint) {
throw new Error(`OIDC discovery response missing required endpoints`);
}
discoveryCache.set(issuer, { data, fetchedAt: Date.now() });
return data;
}
/**
* Exchange an authorization code for tokens.
* @param {object} oidcConfig discovery document
* @param {string} code
* @param {string} redirectUri
* @param {string} clientId
* @param {string} clientSecret
* @param {string} codeVerifier PKCE verifier
* @returns {Promise<object>} token response
*/
export async function exchangeCode(oidcConfig, code, redirectUri, clientId, clientSecret, codeVerifier) {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret,
code_verifier: codeVerifier,
});
const response = await fetch(oidcConfig.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
throw new Error(`Token exchange failed: HTTP ${response.status} ${errText.slice(0, 200)}`);
}
return response.json();
}
/**
* Fetch user info from the provider's userinfo endpoint.
* @param {string} userInfoUrl
* @param {string} accessToken
* @returns {Promise<object>}
*/
export async function fetchUserInfo(userInfoUrl, accessToken) {
const response = await fetch(userInfoUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`UserInfo fetch failed: HTTP ${response.status}`);
}
return response.json();
}

View File

@@ -14,8 +14,12 @@ import adminRoutes from './routes/admin.js';
import brandingRoutes from './routes/branding.js'; import brandingRoutes from './routes/branding.js';
import federationRoutes, { wellKnownHandler } from './routes/federation.js'; import federationRoutes, { wellKnownHandler } from './routes/federation.js';
import calendarRoutes from './routes/calendar.js'; import calendarRoutes from './routes/calendar.js';
import caldavRoutes from './routes/caldav.js';
import notificationRoutes from './routes/notifications.js'; import notificationRoutes from './routes/notifications.js';
import oauthRoutes from './routes/oauth.js';
import analyticsRoutes from './routes/analytics.js';
import { startFederationSync } from './jobs/federationSync.js'; import { startFederationSync } from './jobs/federationSync.js';
import { startCalendarReminders } from './jobs/calendarReminders.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -29,13 +33,25 @@ const rawTrustProxy = process.env.TRUST_PROXY ?? 'loopback';
const trustProxy = /^\d+$/.test(rawTrustProxy) ? parseInt(rawTrustProxy, 10) : rawTrustProxy; const trustProxy = /^\d+$/.test(rawTrustProxy) ? parseInt(rawTrustProxy, 10) : rawTrustProxy;
app.set('trust proxy', trustProxy); app.set('trust proxy', trustProxy);
// ── Security headers ───────────────────────────────────────────────────────
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
if (process.env.NODE_ENV === 'production') {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
next();
});
// Middleware // Middleware
// M10: restrict CORS in production; allow all in development // M10: restrict CORS in production; deny cross-origin by default
const corsOptions = process.env.APP_URL const corsOptions = process.env.APP_URL
? { origin: process.env.APP_URL, credentials: true } ? { origin: process.env.APP_URL, credentials: true }
: {}; : { origin: false };
app.use(cors(corsOptions)); app.use(cors(corsOptions));
app.use(express.json()); app.use(express.json({ limit: '100kb' }));
// Request/Response logging (filters sensitive fields) // Request/Response logging (filters sensitive fields)
app.use(requestResponseLogger); app.use(requestResponseLogger);
@@ -44,9 +60,9 @@ async function start() {
await initDatabase(); await initDatabase();
initMailer(); initMailer();
// Serve uploaded files (avatars, presentations) // Serve uploaded files (branding only — avatars served via /api/auth/avatar/:filename, presentations require auth)
const uploadsPath = path.join(__dirname, '..', 'uploads'); const uploadsPath = path.join(__dirname, '..', 'uploads');
app.use('/uploads', express.static(uploadsPath)); app.use('/uploads/branding', express.static(path.join(uploadsPath, 'branding')));
// API Routes // API Routes
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
@@ -57,10 +73,29 @@ async function start() {
app.use('/api/federation', federationRoutes); app.use('/api/federation', federationRoutes);
app.use('/api/calendar', calendarRoutes); app.use('/api/calendar', calendarRoutes);
app.use('/api/notifications', notificationRoutes); app.use('/api/notifications', notificationRoutes);
app.use('/api/oauth', oauthRoutes);
app.use('/api/analytics', analyticsRoutes);
// CalDAV — mounted outside /api so calendar clients use a clean path
app.use('/caldav', caldavRoutes);
// Mount calendar federation receive also under /api/federation for remote instances // Mount calendar federation receive also under /api/federation for remote instances
app.use('/api/federation', calendarRoutes); app.use('/api/federation', calendarRoutes);
app.get('/.well-known/redlight', wellKnownHandler); app.get('/.well-known/redlight', wellKnownHandler);
// ── CalDAV service discovery (RFC 6764) ──────────────────────────────────
// Clients probe /.well-known/caldav then PROPFIND / before they know the
// real CalDAV mount point. Redirect them to /caldav/ for all HTTP methods.
app.all('/.well-known/caldav', (req, res) => {
res.redirect(301, '/caldav/');
});
// Some clients (e.g. Thunderbird) send PROPFIND / directly at the server root.
// Express doesn't register non-standard methods, so intercept via middleware.
app.use('/', (req, res, next) => {
if (req.method === 'PROPFIND' && req.path === '/') {
return res.redirect(301, '/caldav/');
}
next();
});
// Serve static files in production // Serve static files in production
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, '..', 'dist'))); app.use(express.static(path.join(__dirname, '..', 'dist')));
@@ -75,6 +110,8 @@ async function start() {
// Start periodic federation sync job (checks remote room settings every 60s) // Start periodic federation sync job (checks remote room settings every 60s)
startFederationSync(); startFederationSync();
// Start calendar reminder job (sends in-app + browser notifications before events)
startCalendarReminders();
} }
start().catch(err => { start().catch(err => {

View File

@@ -0,0 +1,90 @@
import { getDb } from '../config/database.js';
import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js';
const CHECK_INTERVAL_MS = 60_000; // every minute
let timer = null;
/**
* Check for upcoming calendar events that need a reminder notification fired.
* Runs every minute. Updates `reminder_sent_at` after firing so reminders
* are never sent twice. Also resets `reminder_sent_at` to NULL when
* start_time or reminder_minutes is changed (handled in calendar route).
*/
async function runCheck() {
try {
const db = getDb();
// Fetch all events that have a reminder configured and haven't been sent yet
const pending = await db.all(`
SELECT ce.id, ce.uid, ce.title, ce.start_time, ce.reminder_minutes, ce.user_id,
ce.room_uid, ce.color
FROM calendar_events ce
WHERE ce.reminder_minutes IS NOT NULL
AND ce.reminder_sent_at IS NULL
`);
if (pending.length === 0) return;
const now = new Date();
const toFire = pending.filter(ev => {
const start = new Date(ev.start_time);
// Don't fire reminders for events that started more than 10 minutes ago (server downtime tolerance)
if (start < new Date(now.getTime() - 10 * 60_000)) return false;
const reminderTime = new Date(start.getTime() - ev.reminder_minutes * 60_000);
return reminderTime <= now;
});
for (const ev of toFire) {
try {
// Mark as sent immediately to prevent double-fire even if notification creation fails
await db.run(
'UPDATE calendar_events SET reminder_sent_at = ? WHERE id = ?',
[now.toISOString(), ev.id],
);
const start = new Date(ev.start_time);
const timeStr = start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const dateStr = start.toLocaleDateString([], { day: 'numeric', month: 'short' });
const body = `${dateStr} · ${timeStr}`;
const link = '/calendar';
// Notify the owner
await createNotification(ev.user_id, 'calendar_reminder', ev.title, body, link);
// Notify all accepted share users as well
const shares = await db.all(
'SELECT user_id FROM calendar_event_shares WHERE event_id = ?',
[ev.id],
);
for (const { user_id } of shares) {
await createNotification(user_id, 'calendar_reminder', ev.title, body, link);
}
log.server.info(`Calendar reminder fired for event ${ev.uid} (id=${ev.id})`);
} catch (evErr) {
log.server.error(`Calendar reminder failed for event ${ev.id}: ${evErr.message}`);
}
}
} catch (err) {
log.server.error(`Calendar reminder job error: ${err.message}`);
}
}
export function startCalendarReminders() {
if (timer) return;
// Slight delay on startup so DB is fully ready
setTimeout(() => {
runCheck();
timer = setInterval(runCheck, CHECK_INTERVAL_MS);
}, 5_000);
log.server.info('Calendar reminder job started');
}
export function stopCalendarReminders() {
if (timer) {
clearInterval(timer);
timer = null;
}
}

View File

@@ -35,7 +35,7 @@ export async function authenticateToken(req, res, next) {
} }
const db = getDb(); const db = getDb();
const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [decoded.userId]); const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified, oauth_provider, totp_enabled FROM users WHERE id = ?', [decoded.userId]);
if (!user) { if (!user) {
return res.status(401).json({ error: 'User not found' }); return res.status(401).json({ error: 'User not found' });
} }
@@ -57,3 +57,14 @@ export function generateToken(userId) {
const jti = uuidv4(); const jti = uuidv4();
return jwt.sign({ userId, jti }, JWT_SECRET, { expiresIn: '7d' }); return jwt.sign({ userId, jti }, JWT_SECRET, { expiresIn: '7d' });
} }
/**
* Build the public base URL for the application.
* Prefers APP_URL env var. Falls back to X-Forwarded-Proto + Host header
* so that links are correct behind a TLS-terminating reverse proxy.
*/
export function getBaseUrl(req) {
if (process.env.APP_URL) return process.env.APP_URL.replace(/\/+$/, '');
const proto = req.get('x-forwarded-proto')?.split(',')[0]?.trim() || req.protocol;
return `${proto}://${req.get('host')}`;
}

View File

@@ -2,9 +2,15 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js'; import { getDb } from '../config/database.js';
import { authenticateToken, requireAdmin } from '../middleware/auth.js'; import { authenticateToken, requireAdmin, getBaseUrl } from '../middleware/auth.js';
import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js'; import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
import {
getOAuthConfig,
saveOAuthConfig,
deleteOAuthConfig,
discoverOIDC,
} from '../config/oauth.js';
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/; const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
@@ -51,7 +57,7 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
return res.status(409).json({ error: 'Username is already taken' }); return res.status(409).json({ error: 'Username is already taken' });
} }
const hash = bcrypt.hashSync(password, 12); const hash = await bcrypt.hash(password, 12);
const result = await db.run( const result = await db.run(
'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)', 'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)',
[name, display_name, email.toLowerCase(), hash, validRole] [name, display_name, email.toLowerCase(), hash, validRole]
@@ -156,7 +162,7 @@ router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, r
} }
const db = getDb(); const db = getDb();
const hash = bcrypt.hashSync(newPassword, 12); const hash = await bcrypt.hash(newPassword, 12);
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.params.id]); await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.params.id]);
res.json({ message: 'Password reset' }); res.json({ message: 'Password reset' });
@@ -202,7 +208,7 @@ router.post('/invites', authenticateToken, requireAdmin, async (req, res) => {
); );
// Send invite email if SMTP is configured // Send invite email if SMTP is configured
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const baseUrl = getBaseUrl(req);
const inviteUrl = `${baseUrl}/register?invite=${token}`; const inviteUrl = `${baseUrl}/register?invite=${token}`;
// Load app name // Load app name
@@ -260,4 +266,100 @@ router.delete('/invites/:id', authenticateToken, requireAdmin, async (req, res)
} }
}); });
// ── OAuth / SSO Configuration (admin only) ──────────────────────────────────
// GET /api/admin/oauth - Get current OAuth configuration
router.get('/oauth', authenticateToken, requireAdmin, async (req, res) => {
try {
const config = await getOAuthConfig();
if (!config) {
return res.json({ configured: false, config: null });
}
// Never expose the decrypted client secret to the frontend
res.json({
configured: true,
config: {
issuer: config.issuer,
clientId: config.clientId,
hasClientSecret: !!config.clientSecret,
displayName: config.displayName || 'SSO',
autoRegister: config.autoRegister ?? true,
},
});
} catch (err) {
log.admin.error(`Get OAuth config error: ${err.message}`);
res.status(500).json({ error: 'Could not load OAuth configuration' });
}
});
// PUT /api/admin/oauth - Save OAuth configuration
router.put('/oauth', authenticateToken, requireAdmin, async (req, res) => {
try {
const { issuer, clientId, clientSecret, displayName, autoRegister } = req.body;
if (!issuer || !clientId) {
return res.status(400).json({ error: 'Issuer URL and Client ID are required' });
}
// Validate issuer URL
try {
const parsed = new URL(issuer);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return res.status(400).json({ error: 'Issuer URL must use https:// (or http:// for development)' });
}
} catch {
return res.status(400).json({ error: 'Invalid Issuer URL' });
}
// Validate display name length
if (displayName && displayName.length > 50) {
return res.status(400).json({ error: 'Display name must not exceed 50 characters' });
}
// Check if the existing config has a secret and none is being sent (keep old one)
let finalSecret = clientSecret;
if (!clientSecret) {
const existing = await getOAuthConfig();
if (existing?.clientSecret) {
finalSecret = existing.clientSecret;
}
}
// Attempt OIDC discovery to validate the issuer endpoint
try {
await discoverOIDC(issuer);
} catch (discErr) {
return res.status(400).json({
error: `Could not discover OIDC configuration at ${issuer}: ${discErr.message}`,
});
}
await saveOAuthConfig({
issuer,
clientId,
clientSecret: finalSecret || '',
displayName: displayName || 'SSO',
autoRegister: autoRegister !== false,
});
log.admin.info(`OAuth configuration saved by admin (issuer: ${issuer})`);
res.json({ message: 'OAuth configuration saved' });
} catch (err) {
log.admin.error(`Save OAuth config error: ${err.message}`);
res.status(500).json({ error: 'Could not save OAuth configuration' });
}
});
// DELETE /api/admin/oauth - Remove OAuth configuration
router.delete('/oauth', authenticateToken, requireAdmin, async (req, res) => {
try {
await deleteOAuthConfig();
log.admin.info('OAuth configuration removed by admin');
res.json({ message: 'OAuth configuration removed' });
} catch (err) {
log.admin.error(`Delete OAuth config error: ${err.message}`);
res.status(500).json({ error: 'Could not remove OAuth configuration' });
}
});
export default router; export default router;

309
server/routes/analytics.js Normal file
View File

@@ -0,0 +1,309 @@
import { Router } from 'express';
import crypto from 'crypto';
import ExcelJS from 'exceljs';
import PDFDocument from 'pdfkit';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
import { log } from '../config/logger.js';
import { getAnalyticsToken } from '../config/bbb.js';
const router = Router();
// POST /api/analytics/callback/:uid?token=... - BBB Learning Analytics callback (token-secured)
router.post('/callback/:uid', async (req, res) => {
try {
const { token } = req.query;
const expectedToken = getAnalyticsToken(req.params.uid);
// Constant-time comparison to prevent timing attacks
if (!token || token.length !== expectedToken.length ||
!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) {
return res.status(403).json({ error: 'Invalid token' });
}
const db = getDb();
const room = await db.get('SELECT id, uid, learning_analytics FROM rooms WHERE uid = ?', [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
}
if (!room.learning_analytics) {
return res.status(403).json({ error: 'Learning analytics not enabled for this room' });
}
const data = req.body;
if (!data || typeof data !== 'object') {
return res.status(400).json({ error: 'Invalid analytics data' });
}
// Extract meeting info from BBB learning analytics payload
// Format: { meeting_id, internal_meeting_id, data: { metadata: { meeting_name }, duration, attendees, ... } }
const meetingId = data.internal_meeting_id || data.meeting_id || room.uid;
const meetingName = data.data?.metadata?.meeting_name || data.meeting_id || room.uid;
// Upsert: update if same meeting already exists (BBB sends updates during the meeting)
const existing = await db.get(
'SELECT id FROM learning_analytics_data WHERE room_id = ? AND meeting_id = ?',
[room.id, meetingId]
);
const jsonData = JSON.stringify(data);
if (existing) {
await db.run(
'UPDATE learning_analytics_data SET data = ?, meeting_name = ?, created_at = CURRENT_TIMESTAMP WHERE id = ?',
[jsonData, meetingName, existing.id]
);
} else {
await db.run(
'INSERT INTO learning_analytics_data (room_id, meeting_id, meeting_name, data) VALUES (?, ?, ?, ?)',
[room.id, meetingId, meetingName, jsonData]
);
}
log.server.info(`Analytics callback received for room ${room.uid} (meeting: ${meetingId})`);
res.json({ success: true });
} catch (err) {
log.server.error(`Analytics callback error: ${err.message}`);
res.status(500).json({ error: 'Error processing analytics data' });
}
});
// GET /api/analytics/room/:uid - Get analytics for a room (authenticated)
router.get('/room/:uid', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get('SELECT id, user_id, analytics_visibility FROM rooms WHERE uid = ?', [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
}
// Check access: owner, shared (if visibility allows), or admin
if (room.user_id !== req.user.id && req.user.role !== 'admin') {
if (room.analytics_visibility !== 'shared') {
return res.status(403).json({ error: 'No permission to view analytics for this room' });
}
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
if (!share) {
return res.status(403).json({ error: 'No permission to view analytics for this room' });
}
}
const rows = await db.all(
'SELECT id, meeting_id, meeting_name, data, created_at FROM learning_analytics_data WHERE room_id = ? ORDER BY created_at DESC',
[room.id]
);
const analytics = rows.map(row => ({
id: row.id,
meetingId: row.meeting_id,
meetingName: row.meeting_name,
data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data,
createdAt: row.created_at,
}));
res.json({ analytics });
} catch (err) {
log.server.error(`Get analytics error: ${err.message}`);
res.status(500).json({ error: 'Error fetching analytics' });
}
});
// DELETE /api/analytics/:id - Delete analytics entry (authenticated, owner only)
router.delete('/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const entry = await db.get(
`SELECT la.id, la.room_id, r.user_id
FROM learning_analytics_data la
JOIN rooms r ON la.room_id = r.id
WHERE la.id = ?`,
[req.params.id]
);
if (!entry) {
return res.status(404).json({ error: 'Analytics entry not found' });
}
if (entry.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'No permission to delete this entry' });
}
await db.run('DELETE FROM learning_analytics_data WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (err) {
log.server.error(`Delete analytics error: ${err.message}`);
res.status(500).json({ error: 'Error deleting analytics entry' });
}
});
// Helper: extract flat attendee rows from analytics entry
function extractRows(entry) {
const data = typeof entry.data === 'string' ? JSON.parse(entry.data) : entry.data;
const attendees = data?.data?.attendees || [];
const meetingName = data?.data?.metadata?.meeting_name || entry.meeting_name || '';
const meetingDuration = data?.data?.duration || 0;
const meetingStart = data?.data?.start || '';
const meetingFinish = data?.data?.finish || '';
return attendees.map(a => ({
meetingName,
meetingStart,
meetingFinish,
meetingDuration,
name: a.name || '',
role: a.moderator ? 'Moderator' : 'Viewer',
duration: a.duration || 0,
talkTime: a.engagement?.talk_time || 0,
chats: a.engagement?.chats || 0,
talks: a.engagement?.talks || 0,
raiseHand: a.engagement?.raisehand || 0,
emojis: a.engagement?.emojis || 0,
pollVotes: a.engagement?.poll_votes || 0,
}));
}
const COLUMNS = [
{ header: 'Meeting', key: 'meetingName', width: 25 },
{ header: 'Start', key: 'meetingStart', width: 20 },
{ header: 'End', key: 'meetingFinish', width: 20 },
{ header: 'Meeting Duration (s)', key: 'meetingDuration', width: 18 },
{ header: 'Name', key: 'name', width: 25 },
{ header: 'Role', key: 'role', width: 12 },
{ header: 'Duration (s)', key: 'duration', width: 14 },
{ header: 'Talk Time (s)', key: 'talkTime', width: 14 },
{ header: 'Chats', key: 'chats', width: 8 },
{ header: 'Talks', key: 'talks', width: 8 },
{ header: 'Raise Hand', key: 'raiseHand', width: 12 },
{ header: 'Emojis', key: 'emojis', width: 8 },
{ header: 'Poll Votes', key: 'pollVotes', width: 10 },
];
// GET /api/analytics/:id/export/:format - Export a single analytics entry (csv, xlsx, pdf)
router.get('/:id/export/:format', authenticateToken, async (req, res) => {
try {
const format = req.params.format;
if (!['csv', 'xlsx', 'pdf'].includes(format)) {
return res.status(400).json({ error: 'Unsupported format. Use csv, xlsx, or pdf.' });
}
const db = getDb();
const entry = await db.get(
`SELECT la.*, r.user_id, r.analytics_visibility
FROM learning_analytics_data la
JOIN rooms r ON la.room_id = r.id
WHERE la.id = ?`,
[req.params.id]
);
if (!entry) {
return res.status(404).json({ error: 'Analytics entry not found' });
}
if (entry.user_id !== req.user.id && req.user.role !== 'admin') {
if (entry.analytics_visibility !== 'shared') {
return res.status(403).json({ error: 'No permission to export this entry' });
}
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [entry.room_id, req.user.id]);
if (!share) {
return res.status(403).json({ error: 'No permission to export this entry' });
}
}
const rows = extractRows(entry);
const safeName = (entry.meeting_name || 'analytics').replace(/[^a-zA-Z0-9_-]/g, '_');
if (format === 'csv') {
const header = COLUMNS.map(c => c.header).join(',');
const csvRows = rows.map(r =>
COLUMNS.map(c => {
const val = r[c.key];
if (typeof val === 'string' && (val.includes(',') || val.includes('"') || val.includes('\n'))) {
return '"' + val.replace(/"/g, '""') + '"';
}
return val;
}).join(',')
);
const csv = [header, ...csvRows].join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.csv"`);
return res.send('\uFEFF' + csv); // BOM for Excel UTF-8
}
if (format === 'xlsx') {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Analytics');
sheet.columns = COLUMNS;
rows.forEach(r => sheet.addRow(r));
// Style header row
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.xlsx"`);
await workbook.xlsx.write(res);
return res.end();
}
if (format === 'pdf') {
const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 30 });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.pdf"`);
doc.pipe(res);
// Title
doc.fontSize(16).text(entry.meeting_name || 'Learning Analytics', { align: 'center' });
doc.moveDown(0.5);
// Table header columns for PDF (subset for readability)
const pdfCols = [
{ header: 'Name', key: 'name', width: 120 },
{ header: 'Role', key: 'role', width: 65 },
{ header: 'Duration (s)', key: 'duration', width: 75 },
{ header: 'Talk Time (s)', key: 'talkTime', width: 75 },
{ header: 'Chats', key: 'chats', width: 50 },
{ header: 'Talks', key: 'talks', width: 50 },
{ header: 'Raise Hand', key: 'raiseHand', width: 65 },
{ header: 'Emojis', key: 'emojis', width: 50 },
{ header: 'Poll Votes', key: 'pollVotes', width: 60 },
];
const startX = doc.x;
let y = doc.y;
// Header
doc.fontSize(8).font('Helvetica-Bold');
pdfCols.forEach((col, i) => {
const x = startX + pdfCols.slice(0, i).reduce((s, c) => s + c.width, 0);
doc.text(col.header, x, y, { width: col.width, align: 'left' });
});
y += 14;
doc.moveTo(startX, y).lineTo(startX + pdfCols.reduce((s, c) => s + c.width, 0), y).stroke();
y += 4;
// Rows
doc.font('Helvetica').fontSize(8);
rows.forEach(r => {
if (y > doc.page.height - 50) {
doc.addPage();
y = 30;
}
pdfCols.forEach((col, i) => {
const x = startX + pdfCols.slice(0, i).reduce((s, c) => s + c.width, 0);
doc.text(String(r[col.key]), x, y, { width: col.width, align: 'left' });
});
y += 14;
});
doc.end();
return;
}
} catch (err) {
log.server.error(`Export analytics error: ${err.message}`);
res.status(500).json({ error: 'Error exporting analytics data' });
}
});
export default router;

View File

@@ -1,4 +1,4 @@
import { Router } from 'express'; import { Router } from 'express';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@@ -7,10 +7,12 @@ import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { rateLimit } from 'express-rate-limit'; import { rateLimit } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis'; import { RedisStore } from 'rate-limit-redis';
import * as OTPAuth from 'otpauth';
import { getDb } from '../config/database.js'; import { getDb } from '../config/database.js';
import redis from '../config/redis.js'; import redis from '../config/redis.js';
import { authenticateToken, generateToken } from '../middleware/auth.js'; import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js';
import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js'; import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
import { getOAuthConfig, discoverOIDC } from '../config/oauth.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
@@ -98,6 +100,15 @@ const resendVerificationLimiter = rateLimit({
store: makeRedisStore('rl:resend:'), store: makeRedisStore('rl:resend:'),
}); });
const twoFaLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many 2FA attempts. Please try again later.' },
store: makeRedisStore('rl:2fa:'),
});
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars'); const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars');
@@ -168,7 +179,7 @@ router.post('/register', registerLimiter, async (req, res) => {
return res.status(409).json({ error: 'Username is already taken' }); return res.status(409).json({ error: 'Username is already taken' });
} }
const hash = bcrypt.hashSync(password, 12); const hash = await bcrypt.hash(password, 12);
// If SMTP is configured, require email verification // If SMTP is configured, require email verification
if (isMailerConfigured()) { if (isMailerConfigured()) {
@@ -189,7 +200,7 @@ router.post('/register', registerLimiter, async (req, res) => {
} }
// Build verification URL // Build verification URL
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const baseUrl = getBaseUrl(req);
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`; const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
// Load app name from branding settings // Load app name from branding settings
@@ -303,7 +314,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
[verificationToken, expires, now, user.id] [verificationToken, expires, now, user.id]
); );
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const baseUrl = getBaseUrl(req);
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`; const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'"); const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
@@ -351,8 +362,14 @@ router.post('/login', loginLimiter, async (req, res) => {
return res.status(403).json({ error: 'Email address not yet verified. Please check your inbox.', needsVerification: true }); return res.status(403).json({ error: 'Email address not yet verified. Please check your inbox.', needsVerification: true });
} }
// ── 2FA check ────────────────────────────────────────────────────────
if (user.totp_enabled) {
const tempToken = jwt.sign({ userId: user.id, purpose: '2fa' }, JWT_SECRET, { expiresIn: '5m' });
return res.json({ requires2FA: true, tempToken });
}
const token = generateToken(user.id); const token = generateToken(user.id);
const { password_hash, ...safeUser } = user; const { password_hash, verification_token, verification_token_expires, verification_resend_at, totp_secret, ...safeUser } = user;
res.json({ token, user: safeUser }); res.json({ token, user: safeUser });
} catch (err) { } catch (err) {
@@ -361,6 +378,53 @@ router.post('/login', loginLimiter, async (req, res) => {
} }
}); });
// POST /api/auth/login/2fa - Verify TOTP code and complete login
router.post('/login/2fa', twoFaLimiter, async (req, res) => {
try {
const { tempToken, code } = req.body;
if (!tempToken || !code) {
return res.status(400).json({ error: 'Token and code are required' });
}
let decoded;
try {
decoded = jwt.verify(tempToken, JWT_SECRET);
} catch {
return res.status(401).json({ error: 'Invalid or expired token. Please log in again.' });
}
if (decoded.purpose !== '2fa') {
return res.status(401).json({ error: 'Invalid token' });
}
const db = getDb();
const user = await db.get('SELECT * FROM users WHERE id = ?', [decoded.userId]);
if (!user || !user.totp_enabled || !user.totp_secret) {
return res.status(401).json({ error: 'Invalid token' });
}
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(user.totp_secret),
algorithm: 'SHA1',
digits: 6,
period: 30,
});
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
if (delta === null) {
return res.status(401).json({ error: 'Invalid 2FA code' });
}
const token = generateToken(user.id);
const { password_hash, verification_token, verification_token_expires, verification_resend_at, totp_secret, ...safeUser } = user;
res.json({ token, user: safeUser });
} catch (err) {
log.auth.error(`2FA login error: ${err.message}`);
res.status(500).json({ error: '2FA verification failed' });
}
});
// POST /api/auth/logout - revoke JWT via DragonflyDB blacklist // POST /api/auth/logout - revoke JWT via DragonflyDB blacklist
router.post('/logout', authenticateToken, async (req, res) => { router.post('/logout', authenticateToken, async (req, res) => {
try { try {
@@ -379,7 +443,31 @@ router.post('/logout', authenticateToken, async (req, res) => {
} }
} }
res.json({ message: 'Logged out successfully' }); // ── RP-Initiated Logout for OIDC/Keycloak users ──────────────────────
let keycloakLogoutUrl = null;
if (req.user.oauth_provider === 'oidc') {
try {
const config = await getOAuthConfig();
if (config) {
const oidc = await discoverOIDC(config.issuer);
if (oidc.end_session_endpoint) {
const idToken = await redis.get(`oidc:id_token:${req.user.id}`);
await redis.del(`oidc:id_token:${req.user.id}`);
const baseUrl = getBaseUrl(req);
const params = new URLSearchParams({
post_logout_redirect_uri: `${baseUrl}/`,
client_id: config.clientId,
});
if (idToken) params.set('id_token_hint', idToken);
keycloakLogoutUrl = `${oidc.end_session_endpoint}?${params.toString()}`;
}
}
} catch (oidcErr) {
log.auth.warn(`Could not build Keycloak logout URL: ${oidcErr.message}`);
}
}
res.json({ message: 'Logged out successfully', keycloakLogoutUrl });
} catch (err) { } catch (err) {
log.auth.error(`Logout error: ${err.message}`); log.auth.error(`Logout error: ${err.message}`);
res.status(500).json({ error: 'Logout failed' }); res.status(500).json({ error: 'Logout failed' });
@@ -485,7 +573,7 @@ router.put('/password', authenticateToken, passwordLimiter, async (req, res) =>
return res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters long` }); return res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
} }
const hash = bcrypt.hashSync(newPassword, 12); const hash = await bcrypt.hash(newPassword, 12);
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.user.id]); await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.user.id]);
res.json({ message: 'Password changed successfully' }); res.json({ message: 'Password changed successfully' });
@@ -498,7 +586,7 @@ router.put('/password', authenticateToken, passwordLimiter, async (req, res) =>
// POST /api/auth/avatar - Upload avatar image // POST /api/auth/avatar - Upload avatar image
router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => { router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
try { try {
// Validate content type // Validate file content by checking magic bytes (file signatures)
const contentType = req.headers['content-type']; const contentType = req.headers['content-type'];
if (!contentType || !contentType.startsWith('image/')) { if (!contentType || !contentType.startsWith('image/')) {
return res.status(400).json({ error: 'Only image files are allowed' }); return res.status(400).json({ error: 'Only image files are allowed' });
@@ -528,7 +616,18 @@ router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
return res.status(400).json({ error: 'Image must not exceed 2MB' }); return res.status(400).json({ error: 'Image must not exceed 2MB' });
} }
const ext = contentType.includes('png') ? 'png' : contentType.includes('gif') ? 'gif' : contentType.includes('webp') ? 'webp' : 'jpg'; // Validate magic bytes to prevent Content-Type spoofing
const magicBytes = buffer.slice(0, 8);
const isJPEG = magicBytes[0] === 0xFF && magicBytes[1] === 0xD8 && magicBytes[2] === 0xFF;
const isPNG = magicBytes[0] === 0x89 && magicBytes[1] === 0x50 && magicBytes[2] === 0x4E && magicBytes[3] === 0x47;
const isGIF = magicBytes[0] === 0x47 && magicBytes[1] === 0x49 && magicBytes[2] === 0x46;
const isWEBP = magicBytes[0] === 0x52 && magicBytes[1] === 0x49 && magicBytes[2] === 0x46 && magicBytes[3] === 0x46
&& buffer.length > 11 && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50;
if (!isJPEG && !isPNG && !isGIF && !isWEBP) {
return res.status(400).json({ error: 'File content does not match a supported image format (JPEG, PNG, GIF, WebP)' });
}
const ext = isPNG ? 'png' : isGIF ? 'gif' : isWEBP ? 'webp' : 'jpg';
const filename = `${req.user.id}_${Date.now()}.${ext}`; const filename = `${req.user.id}_${Date.now()}.${ext}`;
const filepath = path.join(uploadsDir, filename); const filepath = path.join(uploadsDir, filename);
@@ -634,4 +733,125 @@ router.get('/avatar/:filename', (req, res) => {
fs.createReadStream(filepath).pipe(res); fs.createReadStream(filepath).pipe(res);
}); });
// ── 2FA Management ──────────────────────────────────────────────────────────
// GET /api/auth/2fa/status
router.get('/2fa/status', authenticateToken, async (req, res) => {
const db = getDb();
const user = await db.get('SELECT totp_enabled FROM users WHERE id = ?', [req.user.id]);
res.json({ enabled: !!user?.totp_enabled });
});
// POST /api/auth/2fa/setup - Generate TOTP secret + provisioning URI
router.post('/2fa/setup', authenticateToken, twoFaLimiter, async (req, res) => {
try {
const db = getDb();
const user = await db.get('SELECT totp_enabled FROM users WHERE id = ?', [req.user.id]);
if (user?.totp_enabled) {
return res.status(400).json({ error: '2FA is already enabled' });
}
const secret = new OTPAuth.Secret({ size: 20 });
// Load app name from branding settings
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
let issuer = 'Redlight';
if (brandingSetting?.value) {
try { issuer = JSON.parse(brandingSetting.value).appName || issuer; } catch {}
}
const totp = new OTPAuth.TOTP({
issuer,
label: req.user.email,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret,
});
// Store the secret (but don't enable yet — user must verify first)
await db.run('UPDATE users SET totp_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [secret.base32, req.user.id]);
res.json({ secret: secret.base32, uri: totp.toString() });
} catch (err) {
log.auth.error(`2FA setup error: ${err.message}`);
res.status(500).json({ error: '2FA setup failed' });
}
});
// POST /api/auth/2fa/enable - Verify code and activate 2FA
router.post('/2fa/enable', authenticateToken, twoFaLimiter, async (req, res) => {
try {
const { code } = req.body;
if (!code) {
return res.status(400).json({ error: 'Code is required' });
}
const db = getDb();
const user = await db.get('SELECT totp_secret, totp_enabled FROM users WHERE id = ?', [req.user.id]);
if (!user?.totp_secret) {
return res.status(400).json({ error: 'Please run 2FA setup first' });
}
if (user.totp_enabled) {
return res.status(400).json({ error: '2FA is already enabled' });
}
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(user.totp_secret),
algorithm: 'SHA1',
digits: 6,
period: 30,
});
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
if (delta === null) {
return res.status(401).json({ error: 'Invalid code. Please try again.' });
}
await db.run('UPDATE users SET totp_enabled = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]);
res.json({ enabled: true, message: '2FA has been enabled' });
} catch (err) {
log.auth.error(`2FA enable error: ${err.message}`);
res.status(500).json({ error: '2FA could not be enabled' });
}
});
// POST /api/auth/2fa/disable - Disable 2FA (requires password + TOTP code)
router.post('/2fa/disable', authenticateToken, twoFaLimiter, async (req, res) => {
try {
const { password, code } = req.body;
if (!password || !code) {
return res.status(400).json({ error: 'Password and code are required' });
}
const db = getDb();
const user = await db.get('SELECT password_hash, totp_secret, totp_enabled FROM users WHERE id = ?', [req.user.id]);
if (!user?.totp_enabled) {
return res.status(400).json({ error: '2FA is not enabled' });
}
if (!bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Invalid password' });
}
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(user.totp_secret),
algorithm: 'SHA1',
digits: 6,
period: 30,
});
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
if (delta === null) {
return res.status(401).json({ error: 'Invalid 2FA code' });
}
await db.run('UPDATE users SET totp_enabled = 0, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]);
res.json({ enabled: false, message: '2FA has been disabled' });
} catch (err) {
log.auth.error(`2FA disable error: ${err.message}`);
res.status(500).json({ error: '2FA could not be disabled' });
}
});
export default router; export default router;

View File

@@ -6,6 +6,7 @@ import { fileURLToPath } from 'url';
import { getDb } from '../config/database.js'; import { getDb } from '../config/database.js';
import { authenticateToken, requireAdmin } from '../middleware/auth.js'; import { authenticateToken, requireAdmin } from '../middleware/auth.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
import { getOAuthConfig } from '../config/oauth.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -14,6 +15,16 @@ const router = Router();
const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/; const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;
// Validate that a URL uses a safe scheme (http/https only)
function isSafeUrl(url) {
try {
const parsed = new URL(url);
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
} catch {
return false;
}
}
// Ensure uploads/branding directory exists // Ensure uploads/branding directory exists
const brandingDir = path.join(__dirname, '..', '..', 'uploads', 'branding'); const brandingDir = path.join(__dirname, '..', '..', 'uploads', 'branding');
if (!fs.existsSync(brandingDir)) { if (!fs.existsSync(brandingDir)) {
@@ -86,6 +97,19 @@ router.get('/', async (req, res) => {
const imprintUrl = await getSetting('imprint_url'); const imprintUrl = await getSetting('imprint_url');
const privacyUrl = await getSetting('privacy_url'); const privacyUrl = await getSetting('privacy_url');
// OAuth: expose whether OAuth is enabled + display name for login page
let oauthEnabled = false;
let oauthDisplayName = null;
try {
const oauthConfig = await getOAuthConfig();
if (oauthConfig) {
oauthEnabled = true;
oauthDisplayName = oauthConfig.displayName || 'SSO';
}
} catch { /* not configured */ }
const hideAppName = await getSetting('hide_app_name');
res.json({ res.json({
appName: appName || 'Redlight', appName: appName || 'Redlight',
hasLogo: !!logoFile, hasLogo: !!logoFile,
@@ -94,6 +118,9 @@ router.get('/', async (req, res) => {
registrationMode: registrationMode || 'open', registrationMode: registrationMode || 'open',
imprintUrl: imprintUrl || null, imprintUrl: imprintUrl || null,
privacyUrl: privacyUrl || null, privacyUrl: privacyUrl || null,
oauthEnabled,
oauthDisplayName,
hideAppName: hideAppName === 'true',
}); });
} catch (err) { } catch (err) {
log.branding.error('Get branding error:', err); log.branding.error('Get branding error:', err);
@@ -221,6 +248,9 @@ router.put('/imprint-url', authenticateToken, requireAdmin, async (req, res) =>
if (imprintUrl && imprintUrl.length > 500) { if (imprintUrl && imprintUrl.length > 500) {
return res.status(400).json({ error: 'URL must not exceed 500 characters' }); return res.status(400).json({ error: 'URL must not exceed 500 characters' });
} }
if (imprintUrl && imprintUrl.trim() && !isSafeUrl(imprintUrl.trim())) {
return res.status(400).json({ error: 'URL must start with http:// or https://' });
}
if (imprintUrl && imprintUrl.trim()) { if (imprintUrl && imprintUrl.trim()) {
await setSetting('imprint_url', imprintUrl.trim()); await setSetting('imprint_url', imprintUrl.trim());
} else { } else {
@@ -240,6 +270,9 @@ router.put('/privacy-url', authenticateToken, requireAdmin, async (req, res) =>
if (privacyUrl && privacyUrl.length > 500) { if (privacyUrl && privacyUrl.length > 500) {
return res.status(400).json({ error: 'URL must not exceed 500 characters' }); return res.status(400).json({ error: 'URL must not exceed 500 characters' });
} }
if (privacyUrl && privacyUrl.trim() && !isSafeUrl(privacyUrl.trim())) {
return res.status(400).json({ error: 'URL must start with http:// or https://' });
}
if (privacyUrl && privacyUrl.trim()) { if (privacyUrl && privacyUrl.trim()) {
await setSetting('privacy_url', privacyUrl.trim()); await setSetting('privacy_url', privacyUrl.trim());
} else { } else {
@@ -252,4 +285,23 @@ router.put('/privacy-url', authenticateToken, requireAdmin, async (req, res) =>
} }
}); });
// PUT /api/branding/hide-app-name - Toggle app name visibility (admin only)
router.put('/hide-app-name', authenticateToken, requireAdmin, async (req, res) => {
try {
const { hideAppName } = req.body;
if (typeof hideAppName !== 'boolean') {
return res.status(400).json({ error: 'hideAppName must be a boolean' });
}
if (hideAppName) {
await setSetting('hide_app_name', 'true');
} else {
await deleteSetting('hide_app_name');
}
res.json({ hideAppName });
} catch (err) {
log.branding.error('Update hide app name error:', err);
res.status(500).json({ error: 'Could not update setting' });
}
});
export default router; export default router;

537
server/routes/caldav.js Normal file
View File

@@ -0,0 +1,537 @@
/**
* CalDAV server for Redlight
*
* Supports PROPFIND, REPORT, GET, PUT, DELETE, OPTIONS — enough for
* Thunderbird/Lightning, Apple Calendar, GNOME Calendar and DAVx⁵ (Android).
*
* Authentication: HTTP Basic Auth → email:caldav_token
* Token management: POST/GET/DELETE /api/calendar/caldav-tokens
*
* Mounted at: /caldav
*/
import { Router, text } from 'express';
import crypto from 'crypto';
import { getDb } from '../config/database.js';
import { log } from '../config/logger.js';
import { getBaseUrl } from '../middleware/auth.js';
const router = Router();
// ── Body parsing for XML and iCalendar payloads ────────────────────────────
router.use(text({ type: ['application/xml', 'text/xml', 'text/calendar', 'application/octet-stream'] }));
// ── Helpers ────────────────────────────────────────────────────────────────
function xmlHeader() {
return '<?xml version="1.0" encoding="UTF-8"?>';
}
function escapeXml(str) {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeICS(str) {
return String(str || '')
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n');
}
function foldICSLine(line) {
// RFC 5545: fold lines longer than 75 octets
const bytes = Buffer.from(line, 'utf8');
if (bytes.length <= 75) return line;
const chunks = [];
let offset = 0;
let first = true;
while (offset < bytes.length) {
const chunk = first ? bytes.slice(0, 75) : bytes.slice(offset, offset + 74);
chunks.push((first ? '' : ' ') + chunk.toString('utf8'));
offset += first ? 75 : 74;
first = false;
}
return chunks.join('\r\n');
}
function toICSDate(dateStr) {
const d = new Date(dateStr);
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}
function parseICSDate(str) {
if (!str) return null;
// Strip TZID= prefix if present
const raw = str.includes(':') ? str.split(':').pop() : str;
if (raw.length === 8) {
// All-day: YYYYMMDD
return new Date(`${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}T00:00:00Z`);
}
return new Date(
`${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}T` +
`${raw.slice(9, 11)}:${raw.slice(11, 13)}:${raw.slice(13, 15)}` +
(raw.endsWith('Z') ? 'Z' : 'Z'),
);
}
function getICSProp(ics, key) {
const re = new RegExp(`^${key}(?:;[^:]*)?:(.+)$`, 'im');
const m = ics.match(re);
if (!m) return null;
// Unfold: join continuation lines
let v = m[1];
const unfoldRe = /\r?\n[ \t](.+)/g;
v = v.replace(unfoldRe, '$1');
return v.trim();
}
function eventToICS(event, base, user) {
// Determine the most useful join URL
const joinUrl = event.federated_join_url
|| (event.room_uid ? `${base}/join/${event.room_uid}` : null);
const roomUrl = event.room_uid ? `${base}/rooms/${event.room_uid}` : null;
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Redlight//CalDAV//EN',
'CALSCALE:GREGORIAN',
'BEGIN:VEVENT',
`UID:${event.uid}`,
`SUMMARY:${escapeICS(event.title)}`,
`DTSTART:${toICSDate(event.start_time)}`,
`DTEND:${toICSDate(event.end_time)}`,
`DTSTAMP:${toICSDate(event.updated_at || event.created_at)}`,
`LAST-MODIFIED:${toICSDate(event.updated_at || event.created_at)}`,
];
// LOCATION: show join link so calendar apps display "where" the meeting is
if (joinUrl) {
lines.push(`LOCATION:${escapeICS(joinUrl)}`);
lines.push(`URL:${joinUrl}`);
} else if (roomUrl) {
lines.push(`LOCATION:${escapeICS(roomUrl)}`);
lines.push(`URL:${roomUrl}`);
}
// DESCRIPTION: combine user description + join link hint
const descParts = [];
if (event.description) descParts.push(event.description);
if (joinUrl) {
descParts.push(`Join meeting: ${joinUrl}`);
}
if (roomUrl && roomUrl !== joinUrl) {
descParts.push(`Room page: ${roomUrl}`);
}
if (descParts.length > 0) {
lines.push(`DESCRIPTION:${escapeICS(descParts.join('\n'))}`);
}
// ORGANIZER
if (user) {
const cn = user.display_name || user.name || user.email;
lines.push(`ORGANIZER;CN=${escapeICS(cn)}:mailto:${user.email}`);
}
if (event.room_uid) {
lines.push(`X-REDLIGHT-ROOM-UID:${event.room_uid}`);
}
if (joinUrl) {
lines.push(`X-REDLIGHT-JOIN-URL:${escapeICS(joinUrl)}`);
}
if (event.reminder_minutes) {
lines.push(
'BEGIN:VALARM',
'ACTION:DISPLAY',
`DESCRIPTION:${escapeICS(event.title)}`,
`TRIGGER:-PT${event.reminder_minutes}M`,
'END:VALARM',
);
}
lines.push('END:VEVENT', 'END:VCALENDAR');
return lines.map(foldICSLine).join('\r\n');
}
function parseICSBody(body) {
const uid = getICSProp(body, 'UID');
const summary = (getICSProp(body, 'SUMMARY') || '')
.replace(/\\n/g, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';');
const description = (getICSProp(body, 'DESCRIPTION') || '')
.replace(/\\n/g, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';');
const dtstart = getICSProp(body, 'DTSTART');
const dtend = getICSProp(body, 'DTEND');
return {
uid: uid || null,
title: summary || 'Untitled',
description: description || null,
start_time: parseICSDate(dtstart),
end_time: parseICSDate(dtend),
};
}
// Build etag from updated_at
function etag(event) {
return `"${Buffer.from(event.updated_at || event.created_at).toString('base64')}"`;
}
// ── CalDAV authentication middleware ───────────────────────────────────────
async function caldavAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Basic ')) {
res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"');
return res.status(401).end();
}
try {
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8');
const colonIdx = decoded.indexOf(':');
if (colonIdx === -1) throw new Error('no colon');
const email = decoded.slice(0, colonIdx);
const token = decoded.slice(colonIdx + 1);
const db = getDb();
const user = await db.get(
'SELECT id, name, display_name, email FROM users WHERE email = ?',
[email],
);
if (!user) {
res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"');
return res.status(401).end();
}
// Hash the provided token with SHA-256 for constant-time comparison in SQL
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const tokenRow = await db.get(
'SELECT * FROM caldav_tokens WHERE user_id = ? AND token_hash = ?',
[user.id, tokenHash],
);
// Fallback: also check legacy plaintext tokens for backward compatibility
const tokenRowLegacy = !tokenRow ? await db.get(
'SELECT * FROM caldav_tokens WHERE user_id = ? AND token = ?',
[user.id, token],
) : null;
const matchedToken = tokenRow || tokenRowLegacy;
if (!matchedToken) {
res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"');
return res.status(401).end();
}
// Migrate legacy plaintext token to hashed version
if (tokenRowLegacy && !tokenRow) {
db.run("UPDATE caldav_tokens SET token_hash = ?, token = '' WHERE id = ?", [tokenHash, matchedToken.id]).catch(() => {});
}
// Update last_used_at (fire and forget)
db.run('UPDATE caldav_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?', [matchedToken.id]).catch(() => {});
req.caldavUser = user;
next();
} catch (err) {
log.server.error(`CalDAV auth error: ${err.message}`);
res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"');
return res.status(401).end();
}
}
// ── Common response headers ────────────────────────────────────────────────
function setDAVHeaders(res) {
res.set('DAV', '1, 2, calendar-access');
res.set('Allow', 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT');
res.set('MS-Author-Via', 'DAV');
}
// ── CalDAV username authorization ──────────────────────────────────────────
// Ensures the :username param matches the authenticated user's email
function validateCalDAVUser(req, res, next) {
if (req.params.username && decodeURIComponent(req.params.username) !== req.caldavUser.email) {
return res.status(403).end();
}
next();
}
// ── Base URL helper (uses shared getBaseUrl from auth.js) ──────────────────
const baseUrl = getBaseUrl;
// ── PROPFIND response builders ─────────────────────────────────────────────
function multistatus(responses) {
return `${xmlHeader()}
<d:multistatus xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"
xmlns:c="urn:ietf:params:xml:ns:caldav">
${responses.join('\n')}
</d:multistatus>`;
}
function propResponse(href, props, status = '200 OK') {
const propXml = Object.entries(props)
.map(([k, v]) => ` <${k}>${v}</${k}>`)
.join('\n');
return ` <d:response>
<d:href>${escapeXml(href)}</d:href>
<d:propstat>
<d:prop>
${propXml}
</d:prop>
<d:status>HTTP/1.1 ${status}</d:status>
</d:propstat>
</d:response>`;
}
// ── OPTIONS ────────────────────────────────────────────────────────────────
router.options('*', (req, res) => {
setDAVHeaders(res);
res.status(200).end();
});
// ── PROPFIND / ─────────────────────────────────────────────────────────────
// Service discovery root: tells the client where the user principal lives.
router.all('/', caldavAuth, async (req, res) => {
if (req.method !== 'PROPFIND') {
setDAVHeaders(res);
return res.status(405).end();
}
const base = baseUrl(req);
const principalHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/`;
setDAVHeaders(res);
res.status(207).type('application/xml; charset=utf-8').send(
multistatus([
propResponse('/caldav/', {
'd:current-user-principal': `<d:href>${principalHref}</d:href>`,
'd:principal-URL': `<d:href>${principalHref}</d:href>`,
'd:resourcetype': '<d:collection/>',
'd:displayname': 'Redlight CalDAV',
}),
]),
);
});
// ── PROPFIND /{username}/ ──────────────────────────────────────────────────
// User principal: tells the client where the calendar home is.
router.all('/:username/', caldavAuth, validateCalDAVUser, async (req, res) => {
if (req.method !== 'PROPFIND') {
setDAVHeaders(res);
return res.status(405).end();
}
const principalHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/`;
const calendarHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/calendar/`;
setDAVHeaders(res);
res.status(207).type('application/xml; charset=utf-8').send(
multistatus([
propResponse(principalHref, {
'd:resourcetype': '<d:collection/><d:principal/>',
'd:displayname': escapeXml(req.caldavUser.display_name || req.caldavUser.name),
'd:principal-URL': `<d:href>${principalHref}</d:href>`,
'c:calendar-home-set': `<d:href>${calendarHref}</d:href>`,
'd:current-user-principal': `<d:href>${principalHref}</d:href>`,
}),
]),
);
});
// ── PROPFIND + REPORT /{username}/calendar/ ────────────────────────────────
router.all('/:username/calendar/', caldavAuth, validateCalDAVUser, async (req, res) => {
const db = getDb();
const calendarHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/calendar/`;
// PROPFIND: return calendar collection metadata
if (req.method === 'PROPFIND') {
const latestEvent = await db.get(
'SELECT updated_at, created_at FROM calendar_events WHERE user_id = ? ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 1',
[req.caldavUser.id],
);
const ctag = latestEvent
? Buffer.from(String(latestEvent.updated_at || latestEvent.created_at)).toString('base64')
: '0';
// If Depth: 1, also return all event hrefs
const depth = req.headers.depth || '0';
const responses = [
propResponse(calendarHref, {
'd:resourcetype': '<d:collection/><c:calendar/>',
'd:displayname': 'Redlight Calendar',
'c:supported-calendar-component-set': '<c:comp name="VEVENT"/>',
'cs:getctag': escapeXml(ctag),
'd:sync-token': escapeXml(ctag),
}),
];
if (depth === '1') {
const events = await db.all(
'SELECT uid, updated_at, created_at FROM calendar_events WHERE user_id = ?',
[req.caldavUser.id],
);
for (const ev of events) {
responses.push(
propResponse(`${calendarHref}${ev.uid}.ics`, {
'd:resourcetype': '',
'd:getcontenttype': 'text/calendar; charset=utf-8',
'd:getetag': escapeXml(etag(ev)),
}),
);
}
}
setDAVHeaders(res);
return res.status(207).type('application/xml; charset=utf-8').send(multistatus(responses));
}
// REPORT: calendar-query or calendar-multiget
if (req.method === 'REPORT') {
const body = typeof req.body === 'string' ? req.body : '';
// calendar-multiget: client sends explicit hrefs
if (body.includes('calendar-multiget')) {
const hrefMatches = [...body.matchAll(/<[^:>]*:?href[^>]*>([^<]+)<\//gi)];
const uids = hrefMatches
.map(m => m[1].trim())
.filter(h => h.endsWith('.ics'))
.map(h => h.split('/').pop().replace('.ics', ''));
const responses = [];
for (const uid of uids) {
const ev = await db.get(
'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?',
[uid, req.caldavUser.id],
);
if (!ev) continue;
const ics = eventToICS(ev);
responses.push(
propResponse(`${calendarHref}${ev.uid}.ics`, {
'd:getetag': escapeXml(etag(ev)),
'c:calendar-data': escapeXml(eventToICS(ev, baseUrl(req), req.caldavUser)),
}),
);
}
setDAVHeaders(res);
return res.status(207).type('application/xml; charset=utf-8').send(multistatus(responses));
}
// calendar-query: filter by time range
let sql = 'SELECT * FROM calendar_events WHERE user_id = ?';
const params = [req.caldavUser.id];
const startMatch = body.match(/start="([^"]+)"/i);
const endMatch = body.match(/end="([^"]+)"/i);
if (startMatch) {
const startIso = parseICSDate(startMatch[1])?.toISOString();
if (startIso) { sql += ' AND end_time >= ?'; params.push(startIso); }
}
if (endMatch) {
const endIso = parseICSDate(endMatch[1])?.toISOString();
if (endIso) { sql += ' AND start_time <= ?'; params.push(endIso); }
}
sql += ' ORDER BY start_time ASC';
const events = await db.all(sql, params);
const responses = events.map(ev =>
propResponse(`${calendarHref}${ev.uid}.ics`, {
'd:getetag': escapeXml(etag(ev)),
'c:calendar-data': escapeXml(eventToICS(ev, baseUrl(req), req.caldavUser)),
}),
);
setDAVHeaders(res);
return res.status(207).type('application/xml; charset=utf-8').send(multistatus(responses));
}
setDAVHeaders(res);
res.status(405).end();
});
// ── GET /{username}/calendar/{uid}.ics ────────────────────────────────────
router.get('/:username/calendar/:filename', caldavAuth, validateCalDAVUser, async (req, res) => {
const uid = req.params.filename.replace(/\.ics$/, '');
const db = getDb();
const ev = await db.get(
'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?',
[uid, req.caldavUser.id],
);
if (!ev) return res.status(404).end();
setDAVHeaders(res);
res.set('ETag', etag(ev));
res.set('Content-Type', 'text/calendar; charset=utf-8');
res.send(eventToICS(ev, baseUrl(req), req.caldavUser));
});
// ── PUT /{username}/calendar/{uid}.ics — create or update ─────────────────
router.put('/:username/calendar/:filename', caldavAuth, validateCalDAVUser, async (req, res) => {
const uid = req.params.filename.replace(/\.ics$/, '');
const body = typeof req.body === 'string' ? req.body : '';
if (!body) return res.status(400).end();
const parsed = parseICSBody(body);
if (!parsed.start_time || !parsed.end_time) return res.status(400).end();
// Normalize UID: prefer from ICS, fall back to filename
const eventUid = parsed.uid || uid;
const db = getDb();
const existing = await db.get(
'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?',
[eventUid, req.caldavUser.id],
);
try {
if (existing) {
await db.run(
`UPDATE calendar_events SET title = ?, description = ?, start_time = ?, end_time = ?,
updated_at = CURRENT_TIMESTAMP WHERE uid = ? AND user_id = ?`,
[
parsed.title,
parsed.description,
parsed.start_time.toISOString(),
parsed.end_time.toISOString(),
eventUid,
req.caldavUser.id,
],
);
const updated = await db.get('SELECT * FROM calendar_events WHERE uid = ?', [eventUid]);
setDAVHeaders(res);
res.set('ETag', etag(updated));
return res.status(204).end();
} else {
await db.run(
`INSERT INTO calendar_events (uid, title, description, start_time, end_time, user_id, color)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
eventUid,
parsed.title,
parsed.description,
parsed.start_time.toISOString(),
parsed.end_time.toISOString(),
req.caldavUser.id,
'#6366f1',
],
);
const created = await db.get('SELECT * FROM calendar_events WHERE uid = ?', [eventUid]);
setDAVHeaders(res);
res.set('ETag', etag(created));
return res.status(201).end();
}
} catch (err) {
log.server.error(`CalDAV PUT error: ${err.message}`);
return res.status(500).end();
}
});
// ── DELETE /{username}/calendar/{uid}.ics ─────────────────────────────────
router.delete('/:username/calendar/:filename', caldavAuth, validateCalDAVUser, async (req, res) => {
const uid = req.params.filename.replace(/\.ics$/, '');
const db = getDb();
const ev = await db.get(
'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?',
[uid, req.caldavUser.id],
);
if (!ev) return res.status(404).end();
await db.run('DELETE FROM calendar_events WHERE uid = ? AND user_id = ?', [uid, req.caldavUser.id]);
setDAVHeaders(res);
res.status(204).end();
});
// ── Fallback ───────────────────────────────────────────────────────────────
router.all('*', caldavAuth, (req, res) => {
setDAVHeaders(res);
res.status(405).end();
});
export default router;

View File

@@ -1,7 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import crypto from 'crypto'; import crypto from 'crypto';
import { getDb } from '../config/database.js'; import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js'; import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
import { sendCalendarInviteEmail } from '../config/mailer.js'; import { sendCalendarInviteEmail } from '../config/mailer.js';
import { import {
@@ -16,6 +16,12 @@ import { rateLimit } from 'express-rate-limit';
const router = Router(); const router = Router();
// Allowlist for CSS color values - only permits hsl(), hex (#rgb/#rrggbb) and plain names
const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
// Allowed reminder intervals in minutes
const VALID_REMINDERS = new Set([5, 15, 30, 60, 120, 1440]);
// Rate limit for federation calendar receive // Rate limit for federation calendar receive
const calendarFederationLimiter = rateLimit({ const calendarFederationLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
@@ -114,13 +120,18 @@ router.get('/events/:id', authenticateToken, async (req, res) => {
// ── POST /api/calendar/events — Create event ──────────────────────────────── // ── POST /api/calendar/events — Create event ────────────────────────────────
router.post('/events', authenticateToken, async (req, res) => { router.post('/events', authenticateToken, async (req, res) => {
try { try {
const { title, description, start_time, end_time, room_uid, color } = req.body; const { title, description, start_time, end_time, room_uid, color, reminder_minutes } = req.body;
if (!title || !title.trim()) return res.status(400).json({ error: 'Title is required' }); if (!title || !title.trim()) return res.status(400).json({ error: 'Title is required' });
if (!start_time || !end_time) return res.status(400).json({ error: 'Start and end time are required' }); if (!start_time || !end_time) return res.status(400).json({ error: 'Start and end time are required' });
if (title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' }); if (title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' });
if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' }); if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' });
// Validate color format
if (color && !SAFE_COLOR_RE.test(color)) {
return res.status(400).json({ error: 'Invalid color format' });
}
const startDate = new Date(start_time); const startDate = new Date(start_time);
const endDate = new Date(end_time); const endDate = new Date(end_time);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
@@ -138,9 +149,12 @@ router.post('/events', authenticateToken, async (req, res) => {
} }
const uid = crypto.randomBytes(12).toString('hex'); const uid = crypto.randomBytes(12).toString('hex');
const validReminder = (reminder_minutes != null && VALID_REMINDERS.has(Number(reminder_minutes)))
? Number(reminder_minutes) : null;
const result = await db.run(` const result = await db.run(`
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color) INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, reminder_minutes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [ `, [
uid, uid,
title.trim(), title.trim(),
@@ -150,6 +164,7 @@ router.post('/events', authenticateToken, async (req, res) => {
room_uid || null, room_uid || null,
req.user.id, req.user.id,
color || '#6366f1', color || '#6366f1',
validReminder,
]); ]);
const event = await db.get('SELECT * FROM calendar_events WHERE id = ?', [result.lastInsertRowid]); const event = await db.get('SELECT * FROM calendar_events WHERE id = ?', [result.lastInsertRowid]);
@@ -167,11 +182,16 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
if (!event) return res.status(404).json({ error: 'Event not found or no permission' }); if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
const { title, description, start_time, end_time, room_uid, color } = req.body; const { title, description, start_time, end_time, room_uid, color, reminder_minutes } = req.body;
if (title && title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' }); if (title && title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' });
if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' }); if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' });
// Validate color format
if (color && !SAFE_COLOR_RE.test(color)) {
return res.status(400).json({ error: 'Invalid color format' });
}
if (start_time && end_time) { if (start_time && end_time) {
const s = new Date(start_time); const s = new Date(start_time);
const e = new Date(end_time); const e = new Date(end_time);
@@ -184,6 +204,13 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
if (!room) return res.status(400).json({ error: 'Linked room not found' }); if (!room) return res.status(400).json({ error: 'Linked room not found' });
} }
const validReminder = (reminder_minutes !== undefined)
? ((reminder_minutes != null && VALID_REMINDERS.has(Number(reminder_minutes))) ? Number(reminder_minutes) : null)
: undefined;
// Reset reminder_sent_at when start_time or reminder_minutes changes so the job re-fires
const resetReminder = (start_time !== undefined && start_time !== event.start_time)
|| (reminder_minutes !== undefined && validReminder !== event.reminder_minutes);
await db.run(` await db.run(`
UPDATE calendar_events SET UPDATE calendar_events SET
title = COALESCE(?, title), title = COALESCE(?, title),
@@ -192,6 +219,8 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
end_time = COALESCE(?, end_time), end_time = COALESCE(?, end_time),
room_uid = ?, room_uid = ?,
color = COALESCE(?, color), color = COALESCE(?, color),
reminder_minutes = COALESCE(?, reminder_minutes),
reminder_sent_at = ${resetReminder ? 'NULL' : 'reminder_sent_at'},
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
`, [ `, [
@@ -201,6 +230,7 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
end_time || null, end_time || null,
room_uid !== undefined ? (room_uid || null) : event.room_uid, room_uid !== undefined ? (room_uid || null) : event.room_uid,
color || null, color || null,
validReminder !== undefined ? validReminder : null,
req.params.id, req.params.id,
]); ]);
@@ -291,7 +321,7 @@ router.post('/events/:id/share', authenticateToken, async (req, res) => {
// Send notification email (fire-and-forget) // Send notification email (fire-and-forget)
const targetUser = await db.get('SELECT name, display_name, email, language FROM users WHERE id = ?', [user_id]); const targetUser = await db.get('SELECT name, display_name, email, language FROM users WHERE id = ?', [user_id]);
if (targetUser?.email) { if (targetUser?.email) {
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const appUrl = getBaseUrl(req);
const inboxUrl = `${appUrl}/federation/inbox`; const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight'; const appName = process.env.APP_NAME || 'Redlight';
const senderUser = await db.get('SELECT name, display_name FROM users WHERE id = ?', [req.user.id]); const senderUser = await db.get('SELECT name, display_name FROM users WHERE id = ?', [req.user.id]);
@@ -456,7 +486,7 @@ router.get('/events/:id/ics', authenticateToken, async (req, res) => {
} }
// Build room join URL if linked // Build room join URL if linked
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const baseUrl = getBaseUrl(req);
let location = ''; let location = '';
if (event.room_uid) { if (event.room_uid) {
location = `${baseUrl}/join/${event.room_uid}`; location = `${baseUrl}/join/${event.room_uid}`;
@@ -493,7 +523,7 @@ router.post('/events/:id/federation', authenticateToken, async (req, res) => {
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
if (!event) return res.status(404).json({ error: 'Event not found or no permission' }); if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const baseUrl = getBaseUrl(req);
let joinUrl = null; let joinUrl = null;
if (event.room_uid) { if (event.room_uid) {
joinUrl = `${baseUrl}/join/${event.room_uid}`; joinUrl = `${baseUrl}/join/${event.room_uid}`;
@@ -613,7 +643,7 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as
// Send notification email (fire-and-forget) // Send notification email (fire-and-forget)
if (targetUser.email) { if (targetUser.email) {
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const appUrl = getBaseUrl(req);
const inboxUrl = `${appUrl}/federation/inbox`; const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight'; const appName = process.env.APP_NAME || 'Redlight';
sendCalendarInviteEmail( sendCalendarInviteEmail(
@@ -673,8 +703,84 @@ function generateICS(event, location, prodIdDomain) {
ics.push(`ORGANIZER;CN=${escapeICS(event.organizer_name)}:mailto:${event.organizer_email}`); ics.push(`ORGANIZER;CN=${escapeICS(event.organizer_name)}:mailto:${event.organizer_email}`);
} }
if (event.reminder_minutes) {
ics.push(
'BEGIN:VALARM',
'ACTION:DISPLAY',
`DESCRIPTION:Reminder: ${escapeICS(event.title)}`,
`TRIGGER:-PT${event.reminder_minutes}M`,
'END:VALARM',
);
}
ics.push('END:VEVENT', 'END:VCALENDAR'); ics.push('END:VEVENT', 'END:VCALENDAR');
return ics.join('\r\n'); return ics.join('\r\n');
} }
// ── CalDAV token management ────────────────────────────────────────────────
// GET /api/calendar/caldav-tokens
router.get('/caldav-tokens', authenticateToken, async (req, res) => {
try {
const db = getDb();
const tokens = await db.all(
'SELECT id, name, created_at, last_used_at FROM caldav_tokens WHERE user_id = ? ORDER BY created_at DESC',
[req.user.id],
);
res.json({ tokens });
} catch (err) {
log.server.error(`CalDAV list tokens error: ${err.message}`);
res.status(500).json({ error: 'Could not load tokens' });
}
});
// POST /api/calendar/caldav-tokens
router.post('/caldav-tokens', authenticateToken, async (req, res) => {
try {
const { name } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Token name is required' });
}
const db = getDb();
const count = await db.get(
'SELECT COUNT(*) as c FROM caldav_tokens WHERE user_id = ?',
[req.user.id],
);
if (count.c >= 10) {
return res.status(400).json({ error: 'Maximum of 10 tokens allowed' });
}
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const result = await db.run(
// Store only the hash — never the plaintext — to limit exposure on DB breach.
'INSERT INTO caldav_tokens (user_id, token_hash, name) VALUES (?, ?, ?)',
[req.user.id, tokenHash, name.trim()],
);
res.status(201).json({
token: { id: result.lastInsertRowid, name: name.trim() },
plainToken: token,
});
} catch (err) {
log.server.error(`CalDAV create token error: ${err.message}`);
res.status(500).json({ error: 'Could not create token' });
}
});
// DELETE /api/calendar/caldav-tokens/:id
router.delete('/caldav-tokens/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const result = await db.run(
'DELETE FROM caldav_tokens WHERE id = ? AND user_id = ?',
[req.params.id, req.user.id],
);
if (result.changes === 0) {
return res.status(404).json({ error: 'Token not found' });
}
res.json({ ok: true });
} catch (err) {
log.server.error(`CalDAV delete token error: ${err.message}`);
res.status(500).json({ error: 'Could not delete token' });
}
});
export default router; export default router;

View File

@@ -2,18 +2,18 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { rateLimit } from 'express-rate-limit'; import { rateLimit } from 'express-rate-limit';
import { getDb } from '../config/database.js'; import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js'; import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js'; import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js'; import { createNotification } from '../config/notifications.js';
// M13: rate limit the unauthenticated federation receive endpoint // M13: rate limit the unauthenticated federation receive endpoint
const federationReceiveLimiter = rateLimit({ const federationReceiveLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, max: 100,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
message: { error: 'Too many federation requests. Please try again later.' }, message: { error: 'Too many federation requests. Please try again later.' },
}); });
import { import {
@@ -40,7 +40,7 @@ export function wellKnownHandler(req, res) {
federation_api: '/api/federation', federation_api: '/api/federation',
public_key: getPublicKey(), public_key: getPublicKey(),
software: 'Redlight', software: 'Redlight',
version: '1.4.0', version: '2.1.2',
}); });
} }
@@ -84,7 +84,7 @@ router.post('/invite', authenticateToken, async (req, res) => {
// Build guest join URL for the remote user // Build guest join URL for the remote user
// If the room has an access code, embed it so the recipient can join without manual entry // If the room has an access code, embed it so the recipient can join without manual entry
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const baseUrl = getBaseUrl(req);
const joinUrl = room.access_code const joinUrl = room.access_code
? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}` ? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}`
: `${baseUrl}/join/${room.uid}`; : `${baseUrl}/join/${room.uid}`;
@@ -161,6 +161,16 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
return res.status(400).json({ error: 'Incomplete invitation payload' }); return res.status(400).json({ error: 'Incomplete invitation payload' });
} }
// Validate join_url scheme to prevent javascript: or other malicious URIs
try {
const parsedUrl = new URL(join_url);
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
return res.status(400).json({ error: 'join_url must use https:// or http://' });
}
} catch {
return res.status(400).json({ error: 'Invalid join_url format' });
}
// S4: validate field lengths from remote to prevent oversized DB entries // S4: validate field lengths from remote to prevent oversized DB entries
if (invite_id.length > 100 || from_user.length > 200 || to_user.length > 200 || if (invite_id.length > 100 || from_user.length > 200 || to_user.length > 200 ||
room_name.length > 200 || join_url.length > 2000 || (message && message.length > 5000)) { room_name.length > 200 || join_url.length > 2000 || (message && message.length > 5000)) {
@@ -226,24 +236,24 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
// Send notification email (truly fire-and-forget - never blocks the response) // Send notification email (truly fire-and-forget - never blocks the response)
if (targetUser.email) { if (targetUser.email) {
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const appUrl = getBaseUrl(req);
const inboxUrl = `${appUrl}/federation/inbox`; const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight'; const appName = process.env.APP_NAME || 'Redlight';
sendFederationInviteEmail( sendFederationInviteEmail(
targetUser.email, targetUser.name, from_user, targetUser.email, targetUser.name, from_user,
room_name, message || null, inboxUrl, appName, targetUser.language || 'en' room_name, message || null, inboxUrl, appName, targetUser.language || 'en'
).catch(mailErr => { ).catch(mailErr => {
log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message); log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message);
}); });
} }
// In-app notification // In-app notification
await createNotification( await createNotification(
targetUser.id, targetUser.id,
'federation_invite_received', 'federation_invite_received',
from_user, from_user,
room_name, room_name,
'/federation/inbox', '/federation/inbox',
); );
res.json({ success: true }); res.json({ success: true });
@@ -567,6 +577,9 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res
const db = getDb(); const db = getDb();
// Escape LIKE special characters in originDomain to prevent wildcard injection.
const safeDomain = originDomain.replace(/[\\%_]/g, '\\$&');
// Collect all affected users before deleting (for email notifications) // Collect all affected users before deleting (for email notifications)
let affectedUsers = []; let affectedUsers = [];
try { try {
@@ -575,16 +588,16 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res
`SELECT u.email, u.name, u.language, ci.title, ci.from_user `SELECT u.email, u.name, u.language, ci.title, ci.from_user
FROM calendar_invitations ci FROM calendar_invitations ci
JOIN users u ON ci.to_user_id = u.id JOIN users u ON ci.to_user_id = u.id
WHERE ci.event_uid = ? AND ci.from_user LIKE ?`, WHERE ci.event_uid = ? AND ci.from_user LIKE ? ESCAPE '\\'`,
[event_uid, `%@${originDomain}`] [event_uid, `%@${safeDomain}`]
); );
// Users who already accepted (event in their calendar) // Users who already accepted (event in their calendar)
const calUsers = await db.all( const calUsers = await db.all(
`SELECT u.email, u.name, u.language, ce.title, ce.federated_from AS from_user `SELECT u.email, u.name, u.language, ce.title, ce.federated_from AS from_user
FROM calendar_events ce FROM calendar_events ce
JOIN users u ON ce.user_id = u.id JOIN users u ON ce.user_id = u.id
WHERE ce.uid = ? AND ce.federated_from LIKE ?`, WHERE ce.uid = ? AND ce.federated_from LIKE ? ESCAPE '\\'`,
[event_uid, `%@${originDomain}`] [event_uid, `%@${safeDomain}`]
); );
// Merge, deduplicate by email // Merge, deduplicate by email
const seen = new Set(); const seen = new Set();
@@ -599,15 +612,15 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res
// Remove from calendar_invitations for all users on this instance // Remove from calendar_invitations for all users on this instance
await db.run( await db.run(
`DELETE FROM calendar_invitations `DELETE FROM calendar_invitations
WHERE event_uid = ? AND from_user LIKE ?`, WHERE event_uid = ? AND from_user LIKE ? ESCAPE '\\'`,
[event_uid, `%@${originDomain}`] [event_uid, `%@${safeDomain}`]
); );
// Remove from calendar_events (accepted invitations) for all users on this instance // Remove from calendar_events (accepted invitations) for all users on this instance
await db.run( await db.run(
`DELETE FROM calendar_events `DELETE FROM calendar_events
WHERE uid = ? AND federated_from LIKE ?`, WHERE uid = ? AND federated_from LIKE ? ESCAPE '\\'`,
[event_uid, `%@${originDomain}`] [event_uid, `%@${safeDomain}`]
); );
log.federation.info(`Calendar event ${event_uid} deleted (origin: ${originDomain})`); log.federation.info(`Calendar event ${event_uid} deleted (origin: ${originDomain})`);
@@ -657,11 +670,13 @@ router.post('/room-deleted', federationReceiveLimiter, async (req, res) => {
} }
const db = getDb(); const db = getDb();
// Escape LIKE special characters in originDomain to prevent wildcard injection.
const safeDomain = originDomain.replace(/[\\%_]/g, '\\$&');
// Mark all federated_rooms with this meet_id (room_uid) from this origin as deleted // Mark all federated_rooms with this meet_id (room_uid) from this origin as deleted
await db.run( await db.run(
`UPDATE federated_rooms SET deleted = 1, updated_at = CURRENT_TIMESTAMP `UPDATE federated_rooms SET deleted = 1, updated_at = CURRENT_TIMESTAMP
WHERE meet_id = ? AND from_user LIKE ?`, WHERE meet_id = ? AND from_user LIKE ? ESCAPE '\\'`,
[room_uid, `%@${originDomain}`] [room_uid, `%@${safeDomain}`]
); );
log.federation.info(`Room ${room_uid} marked as deleted (origin: ${originDomain})`); log.federation.info(`Room ${room_uid} marked as deleted (origin: ${originDomain})`);

272
server/routes/oauth.js Normal file
View File

@@ -0,0 +1,272 @@
/**
* OAuth / OpenID Connect routes for Redlight.
*
* Flow:
* 1. Client calls GET /api/oauth/providers → returns enabled provider info
* 2. Client calls GET /api/oauth/authorize → server generates PKCE + state, redirects to IdP
* 3. IdP redirects to GET /api/oauth/callback with code + state
* 4. Server exchanges code for tokens, fetches user info, creates/links account, returns JWT
*
* Security:
* - PKCE (S256) everywhere
* - Cryptographic state token (single-use, 10-min TTL)
* - Client secret never leaves the server
* - OAuth user info validated and sanitized before DB insertion
* - Rate limited callback endpoint
*/
import { Router } from 'express';
import { rateLimit } from 'express-rate-limit';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js';
import { generateToken, getBaseUrl } from '../middleware/auth.js';
import { log } from '../config/logger.js';
import redis from '../config/redis.js';
import {
getOAuthConfig,
discoverOIDC,
createOAuthState,
consumeOAuthState,
generateCodeVerifier,
computeCodeChallenge,
exchangeCode,
fetchUserInfo,
} from '../config/oauth.js';
const router = Router();
// Rate limit the callback to prevent brute-force of authorization codes
const callbackLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many OAuth attempts. Please try again later.' },
});
// ── GET /api/oauth/providers — List available OAuth providers (public) ───────
router.get('/providers', async (req, res) => {
try {
const config = await getOAuthConfig();
if (!config) {
return res.json({ providers: [] });
}
// Never expose client secret or issuer details to the client
res.json({
providers: [
{
id: 'oidc',
name: config.displayName || 'SSO',
},
],
});
} catch (err) {
log.auth.error(`OAuth providers error: ${err.message}`);
res.json({ providers: [] });
}
});
// ── GET /api/oauth/authorize — Start OAuth flow (redirects to IdP) ──────────
router.get('/authorize', async (req, res) => {
try {
const config = await getOAuthConfig();
if (!config) {
return res.status(400).json({ error: 'OAuth is not configured' });
}
// Discover OIDC endpoints
const oidc = await discoverOIDC(config.issuer);
// Generate PKCE pair
const codeVerifier = generateCodeVerifier();
const codeChallenge = computeCodeChallenge(codeVerifier);
// Optional return_to from query (validate it's a relative path)
let returnTo = req.query.return_to || null;
if (returnTo && (typeof returnTo !== 'string' || !returnTo.startsWith('/') || returnTo.startsWith('//'))) {
returnTo = null; // prevent open redirect
}
// Create server-side state
const state = await createOAuthState('oidc', codeVerifier, returnTo);
// Build callback URL
const baseUrl = getBaseUrl(req);
const redirectUri = `${baseUrl}/api/oauth/callback`;
// Build authorization URL
const params = new URLSearchParams({
response_type: 'code',
client_id: config.clientId,
redirect_uri: redirectUri,
scope: 'openid email profile',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
const authUrl = `${oidc.authorization_endpoint}?${params.toString()}`;
res.redirect(authUrl);
} catch (err) {
log.auth.error(`OAuth authorize error: ${err.message}`);
res.status(500).json({ error: 'Could not start OAuth flow' });
}
});
// ── GET /api/oauth/callback — Handle IdP callback ──────────────────────────
router.get('/callback', callbackLimiter, async (req, res) => {
try {
const { code, state, error: oauthError, error_description } = req.query;
// Build frontend error redirect helper
const baseUrl = getBaseUrl(req);
const errorRedirect = (msg) =>
res.redirect(`${baseUrl}/oauth/callback?error=${encodeURIComponent(msg)}`);
// Handle IdP errors
if (oauthError) {
log.auth.warn(`OAuth IdP error: ${oauthError} ${error_description || ''}`);
return errorRedirect(error_description || oauthError);
}
if (!code || !state) {
return errorRedirect('Missing authorization code or state');
}
// Validate and consume state (CSRF + PKCE verifier retrieval)
const stateData = await consumeOAuthState(state);
if (!stateData) {
return errorRedirect('Invalid or expired OAuth state. Please try again.');
}
// Load provider config
const config = await getOAuthConfig();
if (!config) {
return errorRedirect('OAuth is not configured');
}
// Discover OIDC endpoints
const oidc = await discoverOIDC(config.issuer);
// Exchange code for tokens
const redirectUri = `${baseUrl}/api/oauth/callback`;
const tokenResponse = await exchangeCode(
oidc, code, redirectUri, config.clientId, config.clientSecret, stateData.code_verifier,
);
if (!tokenResponse.access_token) {
return errorRedirect('Token exchange failed: no access token received');
}
// Fetch user info from the IdP
let userInfo;
if (oidc.userinfo_endpoint) {
userInfo = await fetchUserInfo(oidc.userinfo_endpoint, tokenResponse.access_token);
} else {
return errorRedirect('Provider does not support userinfo endpoint');
}
// Extract and validate user attributes
const email = (userInfo.email || '').toLowerCase().trim();
const sub = userInfo.sub || '';
const name = userInfo.preferred_username || userInfo.name || email.split('@')[0] || '';
const displayName = userInfo.name || userInfo.preferred_username || '';
if (!email || !sub) {
return errorRedirect('OAuth provider did not return an email or subject');
}
// Basic email validation
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
if (!EMAIL_RE.test(email)) {
return errorRedirect('OAuth provider returned an invalid email address');
}
const db = getDb();
// ── Link or create user ──────────────────────────────────────────────
// 1. Check if there's already a user linked with this OAuth provider + sub
let user = await db.get(
'SELECT id, name, display_name, email, role, email_verified FROM users WHERE oauth_provider = ? AND oauth_provider_id = ?',
['oidc', sub],
);
if (!user) {
// 2. Check if a user with this email already exists (link accounts)
user = await db.get(
'SELECT id, name, display_name, email, role, email_verified, oauth_provider FROM users WHERE email = ?',
[email],
);
if (user) {
// Link OAuth to existing account
await db.run(
'UPDATE users SET oauth_provider = ?, oauth_provider_id = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
['oidc', sub, user.id],
);
log.auth.info(`Linked OAuth (oidc/${sub}) to existing user ${user.email}`);
} else {
// 3. Auto-register new user (if enabled)
if (!config.autoRegister) {
return errorRedirect('No account found for this email. Please register first or ask an admin.');
}
// Check registration mode
const regModeSetting = await db.get("SELECT value FROM settings WHERE key = 'registration_mode'");
const registrationMode = regModeSetting?.value || 'open';
if (registrationMode === 'invite') {
return errorRedirect('Registration is invite-only. An administrator must create your account first.');
}
// Sanitize username: only allow safe characters
const safeUsername = name.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 30) || `user_${Date.now()}`;
// Ensure username is unique
let finalUsername = safeUsername;
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?)', [safeUsername]);
if (existingUsername) {
finalUsername = `${safeUsername}_${Date.now().toString(36)}`;
}
// No password needed for OAuth users — use a random hash that can't be guessed
const randomPasswordHash = `oauth:${uuidv4()}`;
const result = await db.run(
`INSERT INTO users (name, display_name, email, password_hash, email_verified, oauth_provider, oauth_provider_id)
VALUES (?, ?, ?, ?, 1, ?, ?)`,
[finalUsername, displayName.slice(0, 100) || finalUsername, email, randomPasswordHash, 'oidc', sub],
);
user = await db.get(
'SELECT id, name, display_name, email, role, email_verified FROM users WHERE id = ?',
[result.lastInsertRowid],
);
log.auth.info(`Created new OAuth user: ${email} (oidc/${sub})`);
}
}
// Generate JWT
const token = generateToken(user.id);
// Store id_token in Redis for RP-Initiated Logout (Keycloak SLO)
if (tokenResponse.id_token) {
try {
await redis.setex(`oidc:id_token:${user.id}`, 7 * 24 * 3600, tokenResponse.id_token);
} catch (redisErr) {
log.auth.warn(`Failed to cache OIDC id_token: ${redisErr.message}`);
}
}
// Redirect to frontend callback page with token.
// Use a hash fragment so the token is never sent to the server (not logged, not in Referer headers).
const returnTo = stateData.return_to || '/dashboard';
res.redirect(`${baseUrl}/oauth/callback#token=${encodeURIComponent(token)}&return_to=${encodeURIComponent(returnTo)}`);
} catch (err) {
log.auth.error(`OAuth callback error: ${err.message}`);
const baseUrl = getBaseUrl(req);
res.redirect(`${baseUrl}/oauth/callback?error=${encodeURIComponent('OAuth authentication failed. Please try again.')}`);
}
});
export default router;

View File

@@ -5,7 +5,7 @@ import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { rateLimit } from 'express-rate-limit'; import { rateLimit } from 'express-rate-limit';
import { getDb } from '../config/database.js'; import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js'; import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js'; import { createNotification } from '../config/notifications.js';
import { import {
@@ -14,6 +14,7 @@ import {
endMeeting, endMeeting,
getMeetingInfo, getMeetingInfo,
isMeetingRunning, isMeetingRunning,
getAnalyticsToken,
} from '../config/bbb.js'; } from '../config/bbb.js';
import { import {
isFederationEnabled, isFederationEnabled,
@@ -49,7 +50,7 @@ const router = Router();
// Build avatar URL for a user (uploaded image or generated initials) // Build avatar URL for a user (uploaded image or generated initials)
function getUserAvatarURL(req, user) { function getUserAvatarURL(req, user) {
const baseUrl = `${req.protocol}://${req.get('host')}`; const baseUrl = getBaseUrl(req);
if (user.avatar_image) { if (user.avatar_image) {
return `${baseUrl}/api/auth/avatar/${user.avatar_image}`; return `${baseUrl}/api/auth/avatar/${user.avatar_image}`;
} }
@@ -166,6 +167,9 @@ router.post('/', authenticateToken, async (req, res) => {
if (!name || name.trim().length === 0) { if (!name || name.trim().length === 0) {
return res.status(400).json({ error: 'Room name is required' }); return res.status(400).json({ error: 'Room name is required' });
} }
if (name.trim().length < 2) {
return res.status(400).json({ error: 'Room name must be at least 2 characters' });
}
// M7: field length limits // M7: field length limits
if (name.trim().length > 100) { if (name.trim().length > 100) {
@@ -240,9 +244,14 @@ router.put('/:uid', authenticateToken, async (req, res) => {
record_meeting, record_meeting,
guest_access, guest_access,
moderator_code, moderator_code,
learning_analytics,
analytics_visibility,
} = req.body; } = req.body;
// M12: field length limits (same as create) // M12: field length limits (same as create)
if (name && name.trim().length < 2) {
return res.status(400).json({ error: 'Room name must be at least 2 characters' });
}
if (name && name.trim().length > 100) { if (name && name.trim().length > 100) {
return res.status(400).json({ error: 'Room name must not exceed 100 characters' }); return res.status(400).json({ error: 'Room name must not exceed 100 characters' });
} }
@@ -276,6 +285,8 @@ router.put('/:uid', authenticateToken, async (req, res) => {
record_meeting = COALESCE(?, record_meeting), record_meeting = COALESCE(?, record_meeting),
guest_access = COALESCE(?, guest_access), guest_access = COALESCE(?, guest_access),
moderator_code = ?, moderator_code = ?,
learning_analytics = COALESCE(?, learning_analytics),
analytics_visibility = COALESCE(?, analytics_visibility),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE uid = ? WHERE uid = ?
`, [ `, [
@@ -290,6 +301,8 @@ router.put('/:uid', authenticateToken, async (req, res) => {
record_meeting !== undefined ? (record_meeting ? 1 : 0) : null, record_meeting !== undefined ? (record_meeting ? 1 : 0) : null,
guest_access !== undefined ? (guest_access ? 1 : 0) : null, guest_access !== undefined ? (guest_access ? 1 : 0) : null,
moderator_code !== undefined ? (moderator_code || null) : room.moderator_code, moderator_code !== undefined ? (moderator_code || null) : room.moderator_code,
learning_analytics !== undefined ? (learning_analytics ? 1 : 0) : null,
analytics_visibility && ['owner', 'shared'].includes(analytics_visibility) ? analytics_visibility : null,
req.params.uid, req.params.uid,
]); ]);
@@ -469,12 +482,15 @@ router.post('/:uid/start', authenticateToken, async (req, res) => {
} }
} }
const baseUrl = `${req.protocol}://${req.get('host')}`; const baseUrl = getBaseUrl(req);
const loginURL = `${baseUrl}/join/${room.uid}`; const loginURL = `${baseUrl}/join/${room.uid}`;
const presentationUrl = room.presentation_file const presentationUrl = room.presentation_file
? `${baseUrl}/uploads/presentations/${room.presentation_file}` ? `${baseUrl}/uploads/presentations/${room.presentation_file}`
: null; : null;
await createMeeting(room, baseUrl, loginURL, presentationUrl); const analyticsCallbackURL = room.learning_analytics
? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}`
: null;
await createMeeting(room, baseUrl, loginURL, presentationUrl, analyticsCallbackURL);
const avatarURL = getUserAvatarURL(req, req.user); const avatarURL = getUserAvatarURL(req, req.user);
const displayName = req.user.display_name || req.user.name; const displayName = req.user.display_name || req.user.name;
const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL); const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL);
@@ -617,9 +633,12 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
// If meeting not running but anyone_can_start, create it // If meeting not running but anyone_can_start, create it
if (!running && room.anyone_can_start) { if (!running && room.anyone_can_start) {
const baseUrl = `${req.protocol}://${req.get('host')}`; const baseUrl = getBaseUrl(req);
const loginURL = `${baseUrl}/join/${room.uid}`; const loginURL = `${baseUrl}/join/${room.uid}`;
await createMeeting(room, baseUrl, loginURL); const analyticsCallbackURL = room.learning_analytics
? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}`
: null;
await createMeeting(room, baseUrl, loginURL, null, analyticsCallbackURL);
} }
// Check moderator code // Check moderator code
@@ -628,7 +647,7 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
isModerator = true; isModerator = true;
} }
const baseUrl = `${req.protocol}://${req.get('host')}`; const baseUrl = getBaseUrl(req);
const guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`; const guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL); const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL);
res.json({ joinUrl }); res.json({ joinUrl });
@@ -703,6 +722,15 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => {
const ext = extMap[contentType]; const ext = extMap[contentType];
if (!ext) return res.status(400).json({ error: 'Unsupported file type. Allowed: PDF, PPT, PPTX, ODP, DOC, DOCX' }); if (!ext) return res.status(400).json({ error: 'Unsupported file type. Allowed: PDF, PPT, PPTX, ODP, DOC, DOCX' });
// Validate magic bytes to prevent Content-Type spoofing
const magic = buffer.slice(0, 8);
const isPDF = magic[0] === 0x25 && magic[1] === 0x50 && magic[2] === 0x44 && magic[3] === 0x46; // %PDF
const isZip = magic[0] === 0x50 && magic[1] === 0x4B && magic[2] === 0x03 && magic[3] === 0x04; // PK (PPTX, DOCX, ODP, etc.)
const isOle = magic[0] === 0xD0 && magic[1] === 0xCF && magic[2] === 0x11 && magic[3] === 0xE0; // OLE2 (PPT, DOC)
if (ext === 'pdf' && !isPDF) return res.status(400).json({ error: 'File content does not match PDF format' });
if (['pptx', 'docx', 'odp'].includes(ext) && !isZip) return res.status(400).json({ error: 'File content does not match expected archive format' });
if (['ppt', 'doc'].includes(ext) && !isOle) return res.status(400).json({ error: 'File content does not match expected document format' });
// Preserve original filename (sent as X-Filename header) // Preserve original filename (sent as X-Filename header)
const rawName = req.headers['x-filename']; const rawName = req.headers['x-filename'];
const originalName = rawName const originalName = rawName

View File

@@ -17,6 +17,8 @@ import GuestJoin from './pages/GuestJoin';
import FederationInbox from './pages/FederationInbox'; import FederationInbox from './pages/FederationInbox';
import FederatedRoomDetail from './pages/FederatedRoomDetail'; import FederatedRoomDetail from './pages/FederatedRoomDetail';
import Calendar from './pages/Calendar'; import Calendar from './pages/Calendar';
import OAuthCallback from './pages/OAuthCallback';
import NotFound from './pages/NotFound';
export default function App() { export default function App() {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@@ -50,6 +52,7 @@ export default function App() {
<Route path="/login" element={user ? <Navigate to="/dashboard" /> : <Login />} /> <Route path="/login" element={user ? <Navigate to="/dashboard" /> : <Login />} />
<Route path="/register" element={user ? <Navigate to="/dashboard" /> : <Register />} /> <Route path="/register" element={user ? <Navigate to="/dashboard" /> : <Register />} />
<Route path="/verify-email" element={<VerifyEmail />} /> <Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/oauth/callback" element={<OAuthCallback />} />
<Route path="/join/:uid" element={<GuestJoin />} /> <Route path="/join/:uid" element={<GuestJoin />} />
{/* Protected routes */} {/* Protected routes */}
@@ -63,8 +66,8 @@ export default function App() {
<Route path="/federation/rooms/:id" element={<FederatedRoomDetail />} /> <Route path="/federation/rooms/:id" element={<FederatedRoomDetail />} />
</Route> </Route>
{/* Catch all */} {/* 404 */}
<Route path="*" element={<Navigate to="/" />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
); );
} }

View File

@@ -0,0 +1,261 @@
import { BarChart3, Trash2, Clock, Users, MessageSquare, Video, Mic, ChevronDown, ChevronUp, Hand, BarChart2, Download } from 'lucide-react';
import { useState } from 'react';
import api from '../services/api';
import { useLanguage } from '../contexts/LanguageContext';
import toast from 'react-hot-toast';
export default function AnalyticsList({ analytics, onRefresh, isOwner = true }) {
const [loading, setLoading] = useState({});
const [expanded, setExpanded] = useState({});
const [exportMenu, setExportMenu] = useState({});
const { t, language } = useLanguage();
const formatDate = (dateStr) => {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatDurationSec = (sec) => {
if (!sec || sec <= 0) return '0m';
const minutes = Math.floor(sec / 60);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
const secs = sec % 60;
if (hours > 0) return `${hours}h ${mins}m`;
if (minutes > 0) return `${mins}m ${secs}s`;
return `${secs}s`;
};
const handleDelete = async (id) => {
if (!confirm(t('analytics.deleteConfirm'))) return;
setLoading(prev => ({ ...prev, [id]: 'deleting' }));
try {
await api.delete(`/analytics/${id}`);
toast.success(t('analytics.deleted'));
onRefresh?.();
} catch {
toast.error(t('analytics.deleteFailed'));
} finally {
setLoading(prev => ({ ...prev, [id]: null }));
}
};
const toggleExpand = (id) => {
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
};
const toggleExportMenu = (id) => {
setExportMenu(prev => ({ ...prev, [id]: !prev[id] }));
};
const handleExport = async (id, format) => {
setExportMenu(prev => ({ ...prev, [id]: false }));
setLoading(prev => ({ ...prev, [id]: 'exporting' }));
try {
const response = await api.get(`/analytics/${id}/export/${format}`, { responseType: 'blob' });
const disposition = response.headers['content-disposition'];
const match = disposition?.match(/filename="?([^"]+)"?/);
const filename = match?.[1] || `analytics.${format}`;
const url = window.URL.createObjectURL(response.data);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
toast.success(t('analytics.exportSuccess'));
} catch {
toast.error(t('analytics.exportFailed'));
} finally {
setLoading(prev => ({ ...prev, [id]: null }));
}
};
// Extract user summary from BBB learning analytics callback data
// Payload: { meeting_id, data: { duration, start, finish, attendees: [{ name, moderator, duration, engagement: { chats, talks, raisehand, emojis, poll_votes, talk_time } }] } }
const getUserSummary = (data) => {
const attendees = data?.data?.attendees;
if (!Array.isArray(attendees)) return [];
return attendees.map(a => ({
name: a.name || '—',
isModerator: !!a.moderator,
duration: a.duration || 0,
talkTime: a.engagement?.talk_time || 0,
chats: a.engagement?.chats || 0,
talks: a.engagement?.talks || 0,
raiseHand: a.engagement?.raisehand || 0,
emojis: a.engagement?.emojis || 0,
pollVotes: a.engagement?.poll_votes || 0,
}));
};
const getMeetingSummary = (data) => ({
duration: data?.data?.duration || 0,
start: data?.data?.start || null,
finish: data?.data?.finish || null,
files: data?.data?.files || [],
polls: data?.data?.polls || [],
});
if (!analytics || analytics.length === 0) {
return (
<div className="text-center py-12">
<BarChart3 size={48} className="mx-auto text-th-text-s/40 mb-3" />
<p className="text-th-text-s text-sm">{t('analytics.noData')}</p>
</div>
);
}
return (
<div className="space-y-3">
{analytics.map(entry => {
const users = getUserSummary(entry.data);
const meeting = getMeetingSummary(entry.data);
const isExpanded = expanded[entry.id];
const totalParticipants = users.length;
const totalMessages = users.reduce((sum, u) => sum + u.chats, 0);
return (
<div key={entry.id} className="card p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-medium text-th-text truncate">
{entry.meetingName || entry.meetingId}
</h4>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-th-text-s">
<span className="flex items-center gap-1">
<Clock size={12} />
{meeting.start ? formatDate(meeting.start) : formatDate(entry.createdAt)}
</span>
<span className="flex items-center gap-1">
<BarChart2 size={12} />
{formatDurationSec(meeting.duration)}
</span>
<span className="flex items-center gap-1">
<Users size={12} />
{totalParticipants} {t('analytics.participants')}
</span>
<span className="flex items-center gap-1">
<MessageSquare size={12} />
{totalMessages} {t('analytics.messages')}
</span>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<div className="relative">
<button
onClick={() => toggleExportMenu(entry.id)}
disabled={loading[entry.id] === 'exporting'}
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-text transition-colors"
title={t('analytics.export')}
>
<Download size={16} />
</button>
{exportMenu[entry.id] && (
<div className="absolute right-0 top-full mt-1 bg-th-bg border border-th-border rounded-lg shadow-lg z-10 min-w-[120px] py-1">
<button
onClick={() => handleExport(entry.id, 'csv')}
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
CSV
</button>
<button
onClick={() => handleExport(entry.id, 'xlsx')}
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
Excel (.xlsx)
</button>
<button
onClick={() => handleExport(entry.id, 'pdf')}
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
PDF
</button>
</div>
)}
</div>
<button
onClick={() => toggleExpand(entry.id)}
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-text transition-colors"
title={isExpanded ? t('analytics.collapse') : t('analytics.expand')}
>
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{isOwner && (
<button
onClick={() => handleDelete(entry.id)}
disabled={loading[entry.id] === 'deleting'}
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors"
title={t('common.delete')}
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
{isExpanded && users.length > 0 && (
<div className="mt-4 border-t border-th-border pt-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-th-text-s border-b border-th-border">
<th className="pb-2 pr-4 font-medium">{t('analytics.userName')}</th>
<th className="pb-2 pr-4 font-medium">{t('analytics.role')}</th>
<th className="pb-2 pr-4 font-medium">
<span className="flex items-center gap-1"><Clock size={11} />{t('analytics.duration')}</span>
</th>
<th className="pb-2 pr-4 font-medium">
<span className="flex items-center gap-1"><Mic size={11} />{t('analytics.talkTime')}</span>
</th>
<th className="pb-2 pr-4 font-medium">
<span className="flex items-center gap-1"><MessageSquare size={11} />{t('analytics.messages')}</span>
</th>
<th className="pb-2 pr-4 font-medium">
<span className="flex items-center gap-1"><Hand size={11} />{t('analytics.raiseHand')}</span>
</th>
<th className="pb-2 font-medium">{t('analytics.reactions')}</th>
</tr>
</thead>
<tbody>
{users.map((u, i) => (
<tr key={i} className="border-b border-th-border/50 last:border-0">
<td className="py-2 pr-4 text-th-text font-medium">{u.name}</td>
<td className="py-2 pr-4">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${
u.isModerator
? 'bg-th-accent/15 text-th-accent'
: 'bg-th-bg-s text-th-text-s'
}`}>
{u.isModerator ? t('analytics.moderator') : t('analytics.viewer')}
</span>
</td>
<td className="py-2 pr-4 text-th-text-s">{formatDurationSec(u.duration)}</td>
<td className="py-2 pr-4 text-th-text-s">{formatDurationSec(u.talkTime)}</td>
<td className="py-2 pr-4 text-th-text-s">{u.chats}</td>
<td className="py-2 pr-4 text-th-text-s">{u.raiseHand}</td>
<td className="py-2 text-th-text-s">{u.emojis}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -2,24 +2,25 @@ import { Video } from 'lucide-react';
import { useBranding } from '../contexts/BrandingContext'; import { useBranding } from '../contexts/BrandingContext';
const sizes = { const sizes = {
sm: { box: 'w-8 h-8', rounded: 'rounded-lg', icon: 16, text: 'text-lg' }, sm: { box: 'w-8 h-8', h: 'h-8', maxW: 'max-w-[8rem]', rounded: 'rounded-lg', icon: 16, text: 'text-lg' },
md: { box: 'w-9 h-9', rounded: 'rounded-lg', icon: 20, text: 'text-xl' }, md: { box: 'w-9 h-9', h: 'h-12', maxW: 'max-w-[10rem]', rounded: 'rounded-lg', icon: 20, text: 'text-xl' },
lg: { box: 'w-10 h-10', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' }, lg: { box: 'w-10 h-10', h: 'h-10', maxW: 'max-w-[12rem]', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' },
}; };
export default function BrandLogo({ size = 'md', className = '' }) { export default function BrandLogo({ size = 'md', className = '' }) {
const { appName, hasLogo, logoUrl } = useBranding(); const { appName, hasLogo, logoUrl, hideAppName } = useBranding();
const s = sizes[size] || sizes.md; const s = sizes[size] || sizes.md;
if (hasLogo && logoUrl) { if (hasLogo && logoUrl) {
// When the app name is hidden, let the logo expand to its natural aspect
// ratio (w-auto) rather than being clamped into a tiny square.
const imgClass = hideAppName
? `${s.h} w-auto ${s.maxW} ${s.rounded} object-contain`
: `${s.box} ${s.rounded} object-contain`;
return ( return (
<div className={`flex items-center gap-2.5 ${className}`}> <div className={`flex items-center ${hideAppName ? 'justify-center' : 'gap-2.5'} ${className}`}>
<img <img src={logoUrl} alt={appName} className={imgClass} />
src={logoUrl} {!hideAppName && <span className={`${s.text} font-bold gradient-text`}>{appName}</span>}
alt={appName}
className={`${s.box} ${s.rounded} object-contain`}
/>
<span className={`${s.text} font-bold gradient-text`}>{appName}</span>
</div> </div>
); );
} }

View File

@@ -0,0 +1,105 @@
import { useEffect, useRef } from 'react';
import flatpickr from 'flatpickr';
import 'flatpickr/dist/flatpickr.min.css';
import { German } from 'flatpickr/dist/l10n/de.js';
import { Calendar as CalendarIcon, Clock } from 'lucide-react';
// Register German as default locale
flatpickr.localize(German);
/**
* Themed DateTimePicker using flatpickr.
* flatpickr uses position:fixed for its calendar dropdown — no overflow,
* no scroll issues, no Popper.js needed. CSS variables drive all theming.
*
* Props:
* value local datetime string 'YYYY-MM-DDTHH:mm' (or '')
* onChange (localDatetimeString) => void
* label string
* required bool
* minDate Date | null
* icon 'calendar' (default) | 'clock'
*/
export default function DateTimePicker({
value,
onChange,
label,
required = false,
minDate = null,
icon = 'calendar',
}) {
const inputRef = useRef(null);
const fpRef = useRef(null);
// Always keep a current ref to onChange so flatpickr's closure never goes stale
const onChangeRef = useRef(onChange);
useEffect(() => { onChangeRef.current = onChange; });
useEffect(() => {
if (!inputRef.current) return;
fpRef.current = flatpickr(inputRef.current, {
enableTime: true,
time_24hr: true,
dateFormat: 'd.m.Y H:i',
minuteIncrement: 15,
minDate: minDate || undefined,
defaultDate: value || undefined,
appendTo: document.body, // portal to body → never clipped
static: false,
onChange: (selectedDates) => {
if (selectedDates.length === 0) { onChangeRef.current(''); return; }
const d = selectedDates[0];
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const h = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
onChangeRef.current(`${y}-${mo}-${day}T${h}:${mi}`);
},
});
return () => fpRef.current?.destroy();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Sync value from outside
useEffect(() => {
if (!fpRef.current) return;
const current = fpRef.current.selectedDates[0];
const incoming = value ? new Date(value) : null;
// Only setDate if actually different (avoid loops)
if (incoming && (!current || Math.abs(incoming - current) > 60000)) {
fpRef.current.setDate(incoming, false);
} else if (!incoming && current) {
fpRef.current.clear(false);
}
}, [value]);
// Sync minDate
useEffect(() => {
if (!fpRef.current) return;
fpRef.current.set('minDate', minDate || undefined);
}, [minDate]);
const Icon = icon === 'clock' ? Clock : CalendarIcon;
return (
<div>
{label && (
<label className="block text-sm font-medium text-th-text mb-1.5">
{label}{required && ' *'}
</label>
)}
<div className="relative">
<Icon size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none z-[1]" />
<input
ref={inputRef}
type="text"
required={required}
readOnly
placeholder="Datum & Uhrzeit wählen…"
className="input-field pl-9 text-sm w-full cursor-pointer"
/>
</div>
</div>
);
}

View File

@@ -4,9 +4,9 @@ export default function Modal({ title, children, onClose, maxWidth = 'max-w-lg'
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} /> <div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className={`relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full ${maxWidth} overflow-hidden`}> <div className={`relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full ${maxWidth}`}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-th-border"> <div className="flex items-center justify-between px-6 py-4 border-b border-th-border rounded-t-2xl">
<h2 className="text-lg font-semibold text-th-text">{title}</h2> <h2 className="text-lg font-semibold text-th-text">{title}</h2>
<button <button
onClick={onClose} onClick={onClose}

View File

@@ -122,9 +122,9 @@ export default function RecordingList({ recordings, onRefresh }) {
href={format.url} href={format.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-th-accent/10 text-th-accent text-xs font-medium hover:bg-th-accent/20 transition-colors" className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-th-accent/10 text-th-accent text-sm font-medium hover:bg-th-accent/20 transition-colors"
> >
<Play size={12} /> <Play size={14} />
{format.type === 'presentation' ? t('recordings.presentation') : format.type} {format.type === 'presentation' ? t('recordings.presentation') : format.type}
</a> </a>
))} ))}

View File

@@ -1,6 +1,6 @@
import { Users, Play, Trash2, Radio, Loader2, Share2 } from 'lucide-react'; import { Users, Play, Trash2, Radio, Loader2, Share2, Copy, Link } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import api from '../services/api'; import api from '../services/api';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -10,6 +10,24 @@ export default function RoomCard({ room, onDelete }) {
const { t } = useLanguage(); const { t } = useLanguage();
const [status, setStatus] = useState({ running: false, participantCount: 0 }); const [status, setStatus] = useState({ running: false, participantCount: 0 });
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
const [showCopyMenu, setShowCopyMenu] = useState(false);
const copyMenuRef = useRef(null);
useEffect(() => {
const handleClickOutside = (e) => {
if (copyMenuRef.current && !copyMenuRef.current.contains(e.target)) {
setShowCopyMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const copyToClipboard = (url) => {
navigator.clipboard.writeText(url);
toast.success(t('room.linkCopied'));
setShowCopyMenu(false);
};
useEffect(() => { useEffect(() => {
const checkStatus = async () => { const checkStatus = async () => {
@@ -69,7 +87,7 @@ export default function RoomCard({ room, onDelete }) {
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2 pt-3 border-t border-th-border"> <div className="flex items-center gap-2 pt-3 border-t border-th-border" onClick={(e) => e.stopPropagation()}>
<button <button
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -99,6 +117,33 @@ export default function RoomCard({ room, onDelete }) {
{starting ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />} {starting ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
{status.running ? t('room.join') : t('room.startMeeting')} {status.running ? t('room.join') : t('room.startMeeting')}
</button> </button>
<div className="relative" ref={copyMenuRef}>
<button
onClick={(e) => { e.stopPropagation(); setShowCopyMenu(v => !v); }}
className="btn-ghost text-xs py-1.5 px-2"
title={t('room.copyLink')}
>
<Copy size={14} />
</button>
{showCopyMenu && (
<div className="absolute bottom-full right-0 mb-2 bg-th-surface border border-th-border rounded-lg shadow-lg z-50 min-w-[150px] py-1">
<button
onClick={(e) => { e.stopPropagation(); copyToClipboard(`${window.location.origin}/rooms/${room.uid}`); }}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
>
<Link size={12} />
{t('room.copyRoomLink')}
</button>
<button
onClick={(e) => { e.stopPropagation(); copyToClipboard(`${window.location.origin}/join/${room.uid}`); }}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
>
<Users size={12} />
{t('room.copyGuestLink')}
</button>
</div>
)}
</div>
{onDelete && !room.shared && ( {onDelete && !room.shared && (
<button <button
onClick={(e) => { e.stopPropagation(); onDelete(room); }} onClick={(e) => { e.stopPropagation(); onDelete(room); }}

View File

@@ -57,7 +57,7 @@ export default function Sidebar({ open, onClose }) {
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Logo */} {/* Logo */}
<div className="flex items-center justify-between h-16 px-4 border-b border-th-border"> <div className="flex items-center justify-between h-16 px-4 border-b border-th-border">
<BrandLogo size="sm" /> <BrandLogo size="md" className="flex-1 min-w-0" />
<button <button
onClick={onClose} onClick={onClose}
className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors" className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
@@ -116,7 +116,7 @@ export default function Sidebar({ open, onClose }) {
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (
(user?.display_name || user?.name)?.[0]?.toUpperCase() || '?' (user?.display_name || user?.name)?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) || '?'
)} )}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">

View File

@@ -23,6 +23,16 @@ export function AuthProvider({ children }) {
const login = useCallback(async (email, password) => { const login = useCallback(async (email, password) => {
const res = await api.post('/auth/login', { email, password }); const res = await api.post('/auth/login', { email, password });
if (res.data.requires2FA) {
return { requires2FA: true, tempToken: res.data.tempToken };
}
localStorage.setItem('token', res.data.token);
setUser(res.data.user);
return res.data.user;
}, []);
const verify2FA = useCallback(async (tempToken, code) => {
const res = await api.post('/auth/login/2fa', { tempToken, code });
localStorage.setItem('token', res.data.token); localStorage.setItem('token', res.data.token);
setUser(res.data.user); setUser(res.data.user);
return res.data.user; return res.data.user;
@@ -41,21 +51,41 @@ export function AuthProvider({ children }) {
}, []); }, []);
const logout = useCallback(async () => { const logout = useCallback(async () => {
let keycloakLogoutUrl = null;
try { try {
await api.post('/auth/logout'); const res = await api.post('/auth/logout');
keycloakLogoutUrl = res.data?.keycloakLogoutUrl || null;
} catch { } catch {
// ignore — token is removed locally regardless // ignore — token is removed locally regardless
} }
localStorage.removeItem('token'); localStorage.removeItem('token');
if (keycloakLogoutUrl) {
// Redirect to Keycloak BEFORE clearing React state to avoid
// flash-rendering the login page while the redirect is pending.
window.location.href = keycloakLogoutUrl;
return;
}
setUser(null); setUser(null);
}, []); }, []);
const loginWithOAuth = useCallback(async (token) => {
localStorage.setItem('token', token);
try {
const res = await api.get('/auth/me');
setUser(res.data.user);
return res.data.user;
} catch (err) {
localStorage.removeItem('token');
throw err;
}
}, []);
const updateUser = useCallback((updatedUser) => { const updateUser = useCallback((updatedUser) => {
setUser(updatedUser); setUser(updatedUser);
}, []); }, []);
return ( return (
<AuthContext.Provider value={{ user, loading, login, register, logout, updateUser }}> <AuthContext.Provider value={{ user, loading, login, verify2FA, register, logout, loginWithOAuth, updateUser }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@@ -13,6 +13,7 @@ export function BrandingProvider({ children }) {
defaultTheme: null, defaultTheme: null,
imprintUrl: null, imprintUrl: null,
privacyUrl: null, privacyUrl: null,
hideAppName: false,
}); });
const fetchBranding = useCallback(async () => { const fetchBranding = useCallback(async () => {

View File

@@ -49,14 +49,20 @@ export function NotificationProvider({ children }) {
const { user } = useAuth(); const { user } = useAuth();
const [notifications, setNotifications] = useState([]); const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const activeUserId = useRef(null);
// Track seen IDs to detect genuinely new arrivals and show toasts // Track seen IDs to detect genuinely new arrivals and show toasts
const seenIds = useRef(new Set()); const seenIds = useRef(new Set());
const initialized = useRef(false); const initialized = useRef(false);
const fetch = useCallback(async () => { const fetch = useCallback(async () => {
if (!user) return; const requestUserId = user?.id;
if (!requestUserId) return;
try { try {
const res = await api.get('/notifications'); const res = await api.get('/notifications');
// Ignore stale responses that arrived after logout or account switch.
if (activeUserId.current !== requestUserId) return;
const incoming = res.data.notifications || []; const incoming = res.data.notifications || [];
setNotifications(incoming); setNotifications(incoming);
setUnreadCount(res.data.unreadCount || 0); setUnreadCount(res.data.unreadCount || 0);
@@ -77,16 +83,25 @@ export function NotificationProvider({ children }) {
seenIds.current.add(n.id); seenIds.current.add(n.id);
const icon = notificationIcon(n.type); const icon = notificationIcon(n.type);
toast(`${icon} ${n.title}`, { duration: 5000 }); toast(`${icon} ${n.title}`, { duration: 5000 });
// Browser notification for calendar reminders
if (n.type === 'calendar_reminder' && 'Notification' in window) {
const fire = () => new Notification(n.title, { body: n.body || '', icon: '/favicon.ico' });
if (Notification.permission === 'granted') {
fire();
} else if (Notification.permission !== 'denied') {
Notification.requestPermission().then(p => { if (p === 'granted') fire(); });
}
}
}); });
} catch { } catch {
/* silent server may not be reachable */ /* silent server may not be reachable */
} }
}, [user]); }, [user]);
// Unlock audio playback on the first real user interaction. // Unlock audio playback only for authenticated sessions.
// Browsers block audio from timer callbacks unless the element was previously // This avoids any audio interaction while logged out (e.g. anonymous/incognito tabs).
// "touched" inside a gesture handler — this one-time listener does exactly that.
useEffect(() => { useEffect(() => {
if (!user?.id) return;
const events = ['click', 'keydown', 'pointerdown']; const events = ['click', 'keydown', 'pointerdown'];
const handler = () => { const handler = () => {
unlockAudio(); unlockAudio();
@@ -94,10 +109,12 @@ export function NotificationProvider({ children }) {
}; };
events.forEach(e => window.addEventListener(e, handler, { once: true })); events.forEach(e => window.addEventListener(e, handler, { once: true }));
return () => events.forEach(e => window.removeEventListener(e, handler)); return () => events.forEach(e => window.removeEventListener(e, handler));
}, []); }, [user?.id]);
useEffect(() => { useEffect(() => {
activeUserId.current = user?.id ?? null;
if (!user) { if (!user) {
_audioUnlocked = false;
setNotifications([]); setNotifications([]);
setUnreadCount(0); setUnreadCount(0);
seenIds.current = new Set(); seenIds.current = new Set();
@@ -166,6 +183,7 @@ function notificationIcon(type) {
case 'room_share_added': return '🔗'; case 'room_share_added': return '🔗';
case 'room_share_removed': return '🚫'; case 'room_share_removed': return '🚫';
case 'federation_invite_received': return '📩'; case 'federation_invite_received': return '📩';
case 'calendar_reminder': return '🔔';
default: return '🔔'; default: return '🔔';
} }
} }

View File

@@ -1,4 +1,4 @@
{ {
"common": { "common": {
"appName": "Redlight", "appName": "Redlight",
"loading": "Laden...", "loading": "Laden...",
@@ -91,10 +91,26 @@
"emailVerificationResendSuccess": "Verifizierungsmail wurde gesendet!", "emailVerificationResendSuccess": "Verifizierungsmail wurde gesendet!",
"emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden", "emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden",
"inviteOnly": "Nur mit Einladung", "inviteOnly": "Nur mit Einladung",
"inviteOnlyDesc": "Die Registrierung ist derzeit eingeschränkt. Sie benötigen einen Einladungslink von einem Administrator, um ein Konto zu erstellen." "inviteOnlyDesc": "Die Registrierung ist derzeit eingeschränkt. Sie benötigen einen Einladungslink von einem Administrator, um ein Konto zu erstellen.",
"orContinueWith": "oder weiter mit",
"loginWithOAuth": "Anmelden mit {provider}",
"registerWithOAuth": "Registrieren mit {provider}",
"backToLogin": "Zurück zum Login",
"oauthError": "Anmeldung fehlgeschlagen",
"oauthNoToken": "Kein Authentifizierungstoken erhalten.",
"oauthLoginFailed": "Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.",
"oauthRedirecting": "Du wirst angemeldet...",
"2fa": {
"title": "Zwei-Faktor-Authentifizierung",
"prompt": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
"codeLabel": "Bestätigungscode",
"verify": "Bestätigen",
"verifyFailed": "Überprüfung fehlgeschlagen",
"backToLogin": "← Zurück zum Login"
}
}, },
"home": { "home": {
"poweredBy": "Powered by BigBlueButton", "madeFor": "Made for BigBlueButton",
"heroTitle": "Meetings neu ", "heroTitle": "Meetings neu ",
"heroTitleHighlight": "definiert", "heroTitleHighlight": "definiert",
"heroSubtitle": "Das moderne, selbst gehostete BigBlueButton-Frontend. Erstellen Sie Räume, verwalten Sie Aufnahmen und genießen Sie ein wunderschönes Interface mit über 15 Themes.", "heroSubtitle": "Das moderne, selbst gehostete BigBlueButton-Frontend. Erstellen Sie Räume, verwalten Sie Aufnahmen und genießen Sie ein wunderschönes Interface mit über 15 Themes.",
@@ -158,6 +174,8 @@
"settings": "Einstellungen", "settings": "Einstellungen",
"participants": "{count} Teilnehmer", "participants": "{count} Teilnehmer",
"copyLink": "Link kopieren", "copyLink": "Link kopieren",
"copyRoomLink": "Raum-Link",
"copyGuestLink": "Gast-Link",
"linkCopied": "Link kopiert!", "linkCopied": "Link kopiert!",
"meetingDetails": "Meeting-Details", "meetingDetails": "Meeting-Details",
"meetingId": "Meeting ID", "meetingId": "Meeting ID",
@@ -212,6 +230,11 @@
"guestModeratorPlaceholder": "Nur wenn Sie Moderator sind", "guestModeratorPlaceholder": "Nur wenn Sie Moderator sind",
"guestJoinButton": "Meeting beitreten", "guestJoinButton": "Meeting beitreten",
"guestWaitingMessage": "Das Meeting wurde noch nicht gestartet. Bitte warten Sie, bis der Moderator es startet.", "guestWaitingMessage": "Das Meeting wurde noch nicht gestartet. Bitte warten Sie, bis der Moderator es startet.",
"guestWaitingTitle": "Warte auf Meeting-Start...",
"guestWaitingHint": "Du wirst automatisch beigetreten, sobald das Meeting gestartet wird.",
"guestCancelWaiting": "Abbrechen",
"guestMeetingStartedJoining": "Meeting gestartet! Trete jetzt bei...",
"waitingToJoin": "Warten...",
"guestAccessDenied": "Zugang nicht möglich", "guestAccessDenied": "Zugang nicht möglich",
"guestNameRequired": "Name ist erforderlich", "guestNameRequired": "Name ist erforderlich",
"guestJoinFailed": "Beitritt fehlgeschlagen", "guestJoinFailed": "Beitritt fehlgeschlagen",
@@ -240,7 +263,14 @@
"shareRemoved": "Freigabe entfernt", "shareRemoved": "Freigabe entfernt",
"shareFailed": "Freigabe fehlgeschlagen", "shareFailed": "Freigabe fehlgeschlagen",
"shareRemove": "Freigabe entfernen", "shareRemove": "Freigabe entfernen",
"defaultWelcome": "Willkommen zum Meeting!" "defaultWelcome": "Willkommen zum Meeting!",
"analytics": "Lernanalyse",
"enableAnalytics": "Lernanalyse aktivieren",
"enableAnalyticsHint": "Sammelt Engagement-Daten der Teilnehmer nach jedem Meeting.",
"analyticsVisibility": "Wer kann die Analyse sehen?",
"analyticsOwnerOnly": "Nur Raumbesitzer",
"analyticsSharedUsers": "Alle geteilten Benutzer",
"analyticsVisibilityHint": "Legt fest, wer die Analysedaten dieses Raums einsehen und exportieren kann."
}, },
"recordings": { "recordings": {
"title": "Aufnahmen", "title": "Aufnahmen",
@@ -258,6 +288,30 @@
"publish": "Veröffentlichen", "publish": "Veröffentlichen",
"loadFailed": "Aufnahmen konnten nicht geladen werden" "loadFailed": "Aufnahmen konnten nicht geladen werden"
}, },
"analytics": {
"title": "Lernanalyse",
"noData": "Keine Analysedaten vorhanden",
"participants": "Teilnehmer",
"messages": "Nachrichten",
"expand": "Details anzeigen",
"collapse": "Details ausblenden",
"deleteConfirm": "Analysedaten wirklich löschen?",
"deleted": "Analysedaten gelöscht",
"deleteFailed": "Fehler beim Löschen",
"userName": "Name",
"role": "Rolle",
"moderator": "Moderator",
"viewer": "Teilnehmer",
"talkTime": "Sprechzeit",
"webcamTime": "Webcam-Zeit",
"duration": "Dauer",
"meetingDuration": "Meeting-Dauer",
"raiseHand": "Handheben",
"reactions": "Reaktionen",
"export": "Herunterladen",
"exportSuccess": "Download gestartet",
"exportFailed": "Fehler beim Herunterladen"
},
"settings": { "settings": {
"title": "Einstellungen", "title": "Einstellungen",
"subtitle": "Verwalten Sie Ihr Profil und Ihre Einstellungen", "subtitle": "Verwalten Sie Ihr Profil und Ihre Einstellungen",
@@ -287,7 +341,49 @@
"passwordChanged": "Passwort geändert", "passwordChanged": "Passwort geändert",
"passwordChangeFailed": "Fehler beim Ändern", "passwordChangeFailed": "Fehler beim Ändern",
"passwordMismatch": "Passwörter stimmen nicht überein", "passwordMismatch": "Passwörter stimmen nicht überein",
"selectLanguage": "Sprache auswählen" "selectLanguage": "Sprache auswählen",
"security": {
"title": "Sicherheit",
"subtitle": "Schütze dein Konto mit Zwei-Faktor-Authentifizierung (2FA). Nach der Aktivierung benötigst du sowohl dein Passwort als auch einen Code aus deiner Authenticator-App zum Anmelden.",
"statusEnabled": "2FA ist aktiviert",
"statusEnabledDesc": "Dein Konto ist durch Zwei-Faktor-Authentifizierung geschützt.",
"statusDisabled": "2FA ist nicht aktiviert",
"statusDisabledDesc": "Aktiviere die Zwei-Faktor-Authentifizierung für zusätzliche Sicherheit.",
"enable": "2FA aktivieren",
"disable": "2FA deaktivieren",
"enabled": "Zwei-Faktor-Authentifizierung aktiviert!",
"disabled": "Zwei-Faktor-Authentifizierung deaktiviert.",
"enableFailed": "2FA konnte nicht aktiviert werden",
"disableFailed": "2FA konnte nicht deaktiviert werden",
"setupFailed": "2FA-Einrichtung konnte nicht gestartet werden",
"scanQR": "Scanne diesen QR-Code mit deiner Authenticator-App (Google Authenticator, Authy, etc.).",
"manualKey": "Oder gib diesen Schlüssel manuell ein:",
"verifyCode": "Gib den Code aus deiner App zur Überprüfung ein",
"codeLabel": "6-stelliger Code",
"disableConfirm": "Gib dein Passwort und einen aktuellen 2FA-Code ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren."
},
"caldav": {
"title": "CalDAV",
"subtitle": "Verbinde deine Kalender-App (z. B. Apple Kalender, Thunderbird, DAVx⁵) über das CalDAV-Protokoll. Verwende deine E-Mail-Adresse und ein App-Token als Passwort.",
"serverUrl": "Server-URL",
"username": "Benutzername (E-Mail)",
"hint": "Gib niemals dein echtes Redlight-Passwort in einer Kalender-App ein. Verwende stattdessen ein App-Token.",
"newToken": "Neues App-Token generieren",
"tokenNamePlaceholder": "z. B. \"iPhone\" oder \"Thunderbird\"",
"generate": "Generieren",
"existingTokens": "Aktive Tokens",
"noTokens": "Noch keine Tokens erstellt.",
"created": "Erstellt",
"lastUsed": "Zuletzt verwendet",
"revoke": "Widerrufen",
"revokeConfirm": "Dieses Token wirklich widerrufen? Alle Kalender-Apps, die dieses Token verwenden, verlieren den Zugriff.",
"revoked": "Token widerrufen",
"revokeFailed": "Token konnte nicht widerrufen werden",
"createFailed": "Token konnte nicht erstellt werden",
"newTokenCreated": "Token erstellt — jetzt kopieren!",
"newTokenHint": "Dieses Token wird nur einmal angezeigt. Kopiere es und trage es als Passwort in deiner Kalender-App ein.",
"dismiss": "Ich habe das Token kopiert"
}
}, },
"themes": { "themes": {
"selectTheme": "Theme auswählen", "selectTheme": "Theme auswählen",
@@ -336,6 +432,9 @@
"appNameLabel": "App-Name", "appNameLabel": "App-Name",
"appNameUpdated": "App-Name aktualisiert", "appNameUpdated": "App-Name aktualisiert",
"appNameUpdateFailed": "App-Name konnte nicht aktualisiert werden", "appNameUpdateFailed": "App-Name konnte nicht aktualisiert werden",
"hideAppNameLabel": "App-Namen ausblenden",
"hideAppNameHint": "Nur das Logo anzeigen, den App-Namen daneben ausblenden.",
"hideAppNameFailed": "Einstellung konnte nicht gespeichert werden",
"defaultThemeLabel": "Standard-Theme", "defaultThemeLabel": "Standard-Theme",
"defaultThemeDesc": "Wird für nicht angemeldete Seiten (Gast-Join, Login, Startseite) verwendet, wenn keine persönliche Einstellung gesetzt ist.", "defaultThemeDesc": "Wird für nicht angemeldete Seiten (Gast-Join, Login, Startseite) verwendet, wenn keine persönliche Einstellung gesetzt ist.",
"defaultThemeSaved": "Standard-Theme gespeichert", "defaultThemeSaved": "Standard-Theme gespeichert",
@@ -366,7 +465,26 @@
"imprintUrlSaved": "Impressum-URL gespeichert", "imprintUrlSaved": "Impressum-URL gespeichert",
"privacyUrlSaved": "Datenschutz-URL gespeichert", "privacyUrlSaved": "Datenschutz-URL gespeichert",
"imprintUrlFailed": "Impressum-URL konnte nicht gespeichert werden", "imprintUrlFailed": "Impressum-URL konnte nicht gespeichert werden",
"privacyUrlFailed": "Datenschutz-URL konnte nicht gespeichert werden" "privacyUrlFailed": "Datenschutz-URL konnte nicht gespeichert werden",
"oauthTitle": "OAuth / SSO",
"oauthDescription": "OpenID-Connect-Anbieter verbinden (z. B. Keycloak, Authentik, Google) für Single Sign-On.",
"oauthIssuer": "Issuer-URL",
"oauthIssuerHint": "Die OIDC-Issuer-URL, z. B. https://auth.example.com/realms/main",
"oauthClientId": "Client-ID",
"oauthClientSecret": "Client-Secret",
"oauthClientSecretHint": "Leer lassen, um das bestehende Secret beizubehalten",
"oauthDisplayName": "Button-Beschriftung",
"oauthDisplayNameHint": "Wird auf der Login-Seite angezeigt, z. B. Firmen-SSO",
"oauthAutoRegister": "Neue Benutzer automatisch registrieren",
"oauthAutoRegisterHint": "Erstellt automatisch Konten für Benutzer, die sich zum ersten Mal per OAuth anmelden.",
"oauthSaved": "OAuth-Konfiguration gespeichert",
"oauthSaveFailed": "OAuth-Konfiguration konnte nicht gespeichert werden",
"oauthRemoved": "OAuth-Konfiguration entfernt",
"oauthRemoveFailed": "OAuth-Konfiguration konnte nicht entfernt werden",
"oauthRemoveConfirm": "OAuth-Konfiguration wirklich entfernen? Benutzer können sich dann nicht mehr per SSO anmelden.",
"oauthNotConfigured": "OAuth ist noch nicht konfiguriert.",
"oauthSave": "OAuth speichern",
"oauthRemove": "OAuth entfernen"
}, },
"notifications": { "notifications": {
"bell": "Benachrichtigungen", "bell": "Benachrichtigungen",
@@ -451,6 +569,15 @@
"linkedRoom": "Verknüpfter Raum", "linkedRoom": "Verknüpfter Raum",
"noRoom": "Kein Raum (kein Videomeeting)", "noRoom": "Kein Raum (kein Videomeeting)",
"linkedRoomHint": "Verknüpfe einen Raum, um die Beitritts-URL automatisch ins Event einzufügen.", "linkedRoomHint": "Verknüpfe einen Raum, um die Beitritts-URL automatisch ins Event einzufügen.",
"reminderLabel": "Erinnerung",
"reminderNone": "Keine Erinnerung",
"reminder5": "5 Minuten vorher",
"reminder15": "15 Minuten vorher",
"reminder30": "30 Minuten vorher",
"reminder60": "1 Stunde vorher",
"reminder120": "2 Stunden vorher",
"reminder1440": "1 Tag vorher",
"timezone": "Zeitzone",
"color": "Farbe", "color": "Farbe",
"eventCreated": "Event erstellt!", "eventCreated": "Event erstellt!",
"eventUpdated": "Event aktualisiert!", "eventUpdated": "Event aktualisiert!",
@@ -532,5 +659,11 @@
"note": "Der Termin wurde automatisch aus deinem Kalender entfernt.", "note": "Der Termin wurde automatisch aus deinem Kalender entfernt.",
"footer": "Diese Nachricht wurde automatisch von {appName} versendet." "footer": "Diese Nachricht wurde automatisch von {appName} versendet."
} }
},
"notFound": {
"title": "Seite nicht gefunden",
"description": "Die Seite, die du suchst, existiert nicht oder wurde verschoben.",
"goBack": "Zurück",
"goHome": "Zur Startseite"
} }
} }

View File

@@ -1,4 +1,4 @@
{ {
"common": { "common": {
"appName": "Redlight", "appName": "Redlight",
"loading": "Loading...", "loading": "Loading...",
@@ -91,10 +91,26 @@
"emailVerificationResendSuccess": "Verification email sent!", "emailVerificationResendSuccess": "Verification email sent!",
"emailVerificationResendFailed": "Could not send verification email", "emailVerificationResendFailed": "Could not send verification email",
"inviteOnly": "Invite Only", "inviteOnly": "Invite Only",
"inviteOnlyDesc": "Registration is currently restricted. You need an invitation link from an administrator to create an account." "inviteOnlyDesc": "Registration is currently restricted. You need an invitation link from an administrator to create an account.",
"orContinueWith": "or continue with",
"loginWithOAuth": "Sign in with {provider}",
"registerWithOAuth": "Sign up with {provider}",
"backToLogin": "Back to login",
"oauthError": "Authentication failed",
"oauthNoToken": "No authentication token received.",
"oauthLoginFailed": "Could not complete sign in. Please try again.",
"oauthRedirecting": "Signing you in...",
"2fa": {
"title": "Two-Factor Authentication",
"prompt": "Enter the 6-digit code from your authenticator app.",
"codeLabel": "Verification code",
"verify": "Verify",
"verifyFailed": "Verification failed",
"backToLogin": "← Back to login"
}
}, },
"home": { "home": {
"poweredBy": "Powered by BigBlueButton", "madeFor": "Made for BigBlueButton",
"heroTitle": "Meetings re", "heroTitle": "Meetings re",
"heroTitleHighlight": "defined", "heroTitleHighlight": "defined",
"heroSubtitle": "The modern, self-hosted BigBlueButton frontend. Create rooms, manage recordings and enjoy a beautiful interface with over 15 themes.", "heroSubtitle": "The modern, self-hosted BigBlueButton frontend. Create rooms, manage recordings and enjoy a beautiful interface with over 15 themes.",
@@ -158,6 +174,8 @@
"settings": "Settings", "settings": "Settings",
"participants": "{count} participants", "participants": "{count} participants",
"copyLink": "Copy link", "copyLink": "Copy link",
"copyRoomLink": "Room Link",
"copyGuestLink": "Guest Link",
"linkCopied": "Link copied!", "linkCopied": "Link copied!",
"meetingDetails": "Meeting details", "meetingDetails": "Meeting details",
"meetingId": "Meeting ID", "meetingId": "Meeting ID",
@@ -212,6 +230,11 @@
"guestModeratorPlaceholder": "Only if you are a moderator", "guestModeratorPlaceholder": "Only if you are a moderator",
"guestJoinButton": "Join meeting", "guestJoinButton": "Join meeting",
"guestWaitingMessage": "The meeting has not started yet. Please wait for the moderator to start it.", "guestWaitingMessage": "The meeting has not started yet. Please wait for the moderator to start it.",
"guestWaitingTitle": "Waiting for meeting to start...",
"guestWaitingHint": "You will be joined automatically as soon as the meeting starts.",
"guestCancelWaiting": "Cancel",
"guestMeetingStartedJoining": "Meeting started! Joining now...",
"waitingToJoin": "Waiting...",
"guestAccessDenied": "Access denied", "guestAccessDenied": "Access denied",
"guestNameRequired": "Name is required", "guestNameRequired": "Name is required",
"guestJoinFailed": "Join failed", "guestJoinFailed": "Join failed",
@@ -240,7 +263,14 @@
"shareRemoved": "Share removed", "shareRemoved": "Share removed",
"shareFailed": "Share failed", "shareFailed": "Share failed",
"shareRemove": "Remove share", "shareRemove": "Remove share",
"defaultWelcome": "Welcome to the meeting!" "defaultWelcome": "Welcome to the meeting!",
"analytics": "Learning Analytics",
"enableAnalytics": "Enable learning analytics",
"enableAnalyticsHint": "Collects participant engagement data after each meeting.",
"analyticsVisibility": "Who can see analytics?",
"analyticsOwnerOnly": "Room owner only",
"analyticsSharedUsers": "All shared users",
"analyticsVisibilityHint": "Controls who can view and export analytics data for this room."
}, },
"recordings": { "recordings": {
"title": "Recordings", "title": "Recordings",
@@ -258,6 +288,30 @@
"publish": "Publish", "publish": "Publish",
"loadFailed": "Recordings could not be loaded" "loadFailed": "Recordings could not be loaded"
}, },
"analytics": {
"title": "Learning Analytics",
"noData": "No analytics data available",
"participants": "Participants",
"messages": "Messages",
"expand": "Show details",
"collapse": "Hide details",
"deleteConfirm": "Really delete analytics data?",
"deleted": "Analytics data deleted",
"deleteFailed": "Error deleting data",
"userName": "Name",
"role": "Role",
"moderator": "Moderator",
"viewer": "Viewer",
"talkTime": "Talk time",
"webcamTime": "Webcam time",
"duration": "Duration",
"meetingDuration": "Meeting duration",
"raiseHand": "Raise hand",
"reactions": "Reactions",
"export": "Download",
"exportSuccess": "Download started",
"exportFailed": "Error downloading data"
},
"settings": { "settings": {
"title": "Settings", "title": "Settings",
"subtitle": "Manage your profile and settings", "subtitle": "Manage your profile and settings",
@@ -287,7 +341,49 @@
"passwordChanged": "Password changed", "passwordChanged": "Password changed",
"passwordChangeFailed": "Error changing password", "passwordChangeFailed": "Error changing password",
"passwordMismatch": "Passwords do not match", "passwordMismatch": "Passwords do not match",
"selectLanguage": "Select language" "selectLanguage": "Select language",
"security": {
"title": "Security",
"subtitle": "Protect your account with two-factor authentication (2FA). After enabling, you will need both your password and a code from your authenticator app to sign in.",
"statusEnabled": "2FA is enabled",
"statusEnabledDesc": "Your account is protected with two-factor authentication.",
"statusDisabled": "2FA is not enabled",
"statusDisabledDesc": "Enable two-factor authentication for an extra layer of security.",
"enable": "Enable 2FA",
"disable": "Disable 2FA",
"enabled": "Two-factor authentication enabled!",
"disabled": "Two-factor authentication disabled.",
"enableFailed": "Could not enable 2FA",
"disableFailed": "Could not disable 2FA",
"setupFailed": "Could not start 2FA setup",
"scanQR": "Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.).",
"manualKey": "Or enter this key manually:",
"verifyCode": "Enter the code from your app to verify",
"codeLabel": "6-digit code",
"disableConfirm": "Enter your password and a current 2FA code to disable two-factor authentication."
},
"caldav": {
"title": "CalDAV",
"subtitle": "Connect your calendar app (e.g. Apple Calendar, Thunderbird, DAVx⁵) using the CalDAV protocol. Use your email address and an app token as password.",
"serverUrl": "Server URL",
"username": "Username (Email)",
"hint": "Never enter your real Redlight password in a calendar app. Use an app token instead.",
"newToken": "Generate new app token",
"tokenNamePlaceholder": "e.g. \"iPhone\" or \"Thunderbird\"",
"generate": "Generate",
"existingTokens": "Active tokens",
"noTokens": "No tokens created yet.",
"created": "Created",
"lastUsed": "Last used",
"revoke": "Revoke",
"revokeConfirm": "Really revoke this token? All connected calendar apps using this token will lose access.",
"revoked": "Token revoked",
"revokeFailed": "Could not revoke token",
"createFailed": "Could not create token",
"newTokenCreated": "Token created — copy it now!",
"newTokenHint": "This token will only be shown once. Copy it and enter it as the password in your calendar app.",
"dismiss": "I have copied the token"
}
}, },
"themes": { "themes": {
"selectTheme": "Select theme", "selectTheme": "Select theme",
@@ -336,6 +432,9 @@
"appNameLabel": "App name", "appNameLabel": "App name",
"appNameUpdated": "App name updated", "appNameUpdated": "App name updated",
"appNameUpdateFailed": "Could not update app name", "appNameUpdateFailed": "Could not update app name",
"hideAppNameLabel": "Hide app name",
"hideAppNameHint": "Only show the logo, hide the app name text next to it.",
"hideAppNameFailed": "Could not update setting",
"defaultThemeLabel": "Default Theme", "defaultThemeLabel": "Default Theme",
"defaultThemeDesc": "Applied to unauthenticated pages (guest join, login, home) when no personal preference is set.", "defaultThemeDesc": "Applied to unauthenticated pages (guest join, login, home) when no personal preference is set.",
"defaultThemeSaved": "Default theme saved", "defaultThemeSaved": "Default theme saved",
@@ -366,7 +465,26 @@
"imprintUrlSaved": "Imprint URL saved", "imprintUrlSaved": "Imprint URL saved",
"privacyUrlSaved": "Privacy Policy URL saved", "privacyUrlSaved": "Privacy Policy URL saved",
"imprintUrlFailed": "Could not save Imprint URL", "imprintUrlFailed": "Could not save Imprint URL",
"privacyUrlFailed": "Could not save Privacy Policy URL" "privacyUrlFailed": "Could not save Privacy Policy URL",
"oauthTitle": "OAuth / SSO",
"oauthDescription": "Connect an OpenID Connect provider (e.g. Keycloak, Authentik, Google) to allow Single Sign-On.",
"oauthIssuer": "Issuer URL",
"oauthIssuerHint": "The OIDC issuer URL, e.g. https://auth.example.com/realms/main",
"oauthClientId": "Client ID",
"oauthClientSecret": "Client Secret",
"oauthClientSecretHint": "Leave blank to keep the existing secret",
"oauthDisplayName": "Button label",
"oauthDisplayNameHint": "Shown on the login page, e.g. \"Company SSO\"",
"oauthAutoRegister": "Auto-register new users",
"oauthAutoRegisterHint": "Automatically create accounts for users signing in via OAuth for the first time.",
"oauthSaved": "OAuth configuration saved",
"oauthSaveFailed": "Could not save OAuth configuration",
"oauthRemoved": "OAuth configuration removed",
"oauthRemoveFailed": "Could not remove OAuth configuration",
"oauthRemoveConfirm": "Really remove OAuth configuration? Users will no longer be able to sign in with SSO.",
"oauthNotConfigured": "OAuth is not configured yet.",
"oauthSave": "Save OAuth",
"oauthRemove": "Remove OAuth"
}, },
"notifications": { "notifications": {
"bell": "Notifications", "bell": "Notifications",
@@ -451,6 +569,15 @@
"linkedRoom": "Linked Room", "linkedRoom": "Linked Room",
"noRoom": "No room (no video meeting)", "noRoom": "No room (no video meeting)",
"linkedRoomHint": "Link a room to automatically include the join-URL in the event.", "linkedRoomHint": "Link a room to automatically include the join-URL in the event.",
"reminderLabel": "Reminder",
"reminderNone": "No reminder",
"reminder5": "5 minutes before",
"reminder15": "15 minutes before",
"reminder30": "30 minutes before",
"reminder60": "1 hour before",
"reminder120": "2 hours before",
"reminder1440": "1 day before",
"timezone": "Timezone",
"color": "Color", "color": "Color",
"eventCreated": "Event created!", "eventCreated": "Event created!",
"eventUpdated": "Event updated!", "eventUpdated": "Event updated!",
@@ -532,5 +659,11 @@
"note": "The event has been automatically removed from your calendar.", "note": "The event has been automatically removed from your calendar.",
"footer": "This message was sent automatically by {appName}." "footer": "This message was sent automatically by {appName}."
} }
},
"notFound": {
"title": "Page not found",
"description": "The page you are looking for doesn't exist or has been moved.",
"goBack": "Go back",
"goHome": "Back to home"
} }
} }

View File

@@ -6,6 +6,8 @@
/* ===== DEFAULT LIGHT ===== */ /* ===== DEFAULT LIGHT ===== */
:root, :root,
[data-theme="light"] { [data-theme="light"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #ffffff; --bg-primary: #ffffff;
--bg-secondary: #f8fafc; --bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9; --bg-tertiary: #f1f5f9;
@@ -32,6 +34,8 @@
/* ===== DEFAULT DARK ===== */ /* ===== DEFAULT DARK ===== */
[data-theme="dark"] { [data-theme="dark"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #0f172a; --bg-primary: #0f172a;
--bg-secondary: #1e293b; --bg-secondary: #1e293b;
--bg-tertiary: #334155; --bg-tertiary: #334155;
@@ -58,6 +62,8 @@
/* ===== DRACULA ===== */ /* ===== DRACULA ===== */
[data-theme="dracula"] { [data-theme="dracula"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #282a36; --bg-primary: #282a36;
--bg-secondary: #44475a; --bg-secondary: #44475a;
--bg-tertiary: #383a4c; --bg-tertiary: #383a4c;
@@ -84,6 +90,8 @@
/* ===== CATPPUCCIN MOCHA ===== */ /* ===== CATPPUCCIN MOCHA ===== */
[data-theme="mocha"] { [data-theme="mocha"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #1e1e2e; --bg-primary: #1e1e2e;
--bg-secondary: #313244; --bg-secondary: #313244;
--bg-tertiary: #45475a; --bg-tertiary: #45475a;
@@ -110,6 +118,8 @@
/* ===== CATPPUCCIN LATTE (Light) ===== */ /* ===== CATPPUCCIN LATTE (Light) ===== */
[data-theme="latte"] { [data-theme="latte"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #eff1f5; --bg-primary: #eff1f5;
--bg-secondary: #e6e9ef; --bg-secondary: #e6e9ef;
--bg-tertiary: #dce0e8; --bg-tertiary: #dce0e8;
@@ -136,6 +146,8 @@
/* ===== NORD ===== */ /* ===== NORD ===== */
[data-theme="nord"] { [data-theme="nord"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #2e3440; --bg-primary: #2e3440;
--bg-secondary: #3b4252; --bg-secondary: #3b4252;
--bg-tertiary: #434c5e; --bg-tertiary: #434c5e;
@@ -162,6 +174,8 @@
/* ===== TOKYO NIGHT ===== */ /* ===== TOKYO NIGHT ===== */
[data-theme="tokyo-night"] { [data-theme="tokyo-night"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #1a1b26; --bg-primary: #1a1b26;
--bg-secondary: #24283b; --bg-secondary: #24283b;
--bg-tertiary: #2f3349; --bg-tertiary: #2f3349;
@@ -188,6 +202,8 @@
/* ===== GRUVBOX DARK ===== */ /* ===== GRUVBOX DARK ===== */
[data-theme="gruvbox-dark"] { [data-theme="gruvbox-dark"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #282828; --bg-primary: #282828;
--bg-secondary: #3c3836; --bg-secondary: #3c3836;
--bg-tertiary: #504945; --bg-tertiary: #504945;
@@ -214,6 +230,8 @@
/* ===== GRUVBOX LIGHT ===== */ /* ===== GRUVBOX LIGHT ===== */
[data-theme="gruvbox-light"] { [data-theme="gruvbox-light"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #fbf1c7; --bg-primary: #fbf1c7;
--bg-secondary: #ebdbb2; --bg-secondary: #ebdbb2;
--bg-tertiary: #d5c4a1; --bg-tertiary: #d5c4a1;
@@ -240,6 +258,8 @@
/* ===== ROSE PINE ===== */ /* ===== ROSE PINE ===== */
[data-theme="rose-pine"] { [data-theme="rose-pine"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #191724; --bg-primary: #191724;
--bg-secondary: #1f1d2e; --bg-secondary: #1f1d2e;
--bg-tertiary: #26233a; --bg-tertiary: #26233a;
@@ -266,6 +286,8 @@
/* ===== ROSE PINE DAWN (Light) ===== */ /* ===== ROSE PINE DAWN (Light) ===== */
[data-theme="rose-pine-dawn"] { [data-theme="rose-pine-dawn"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #faf4ed; --bg-primary: #faf4ed;
--bg-secondary: #fffaf3; --bg-secondary: #fffaf3;
--bg-tertiary: #f2e9e1; --bg-tertiary: #f2e9e1;
@@ -292,6 +314,8 @@
/* ===== SOLARIZED DARK ===== */ /* ===== SOLARIZED DARK ===== */
[data-theme="solarized-dark"] { [data-theme="solarized-dark"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #002b36; --bg-primary: #002b36;
--bg-secondary: #073642; --bg-secondary: #073642;
--bg-tertiary: #0a4050; --bg-tertiary: #0a4050;
@@ -318,6 +342,8 @@
/* ===== SOLARIZED LIGHT ===== */ /* ===== SOLARIZED LIGHT ===== */
[data-theme="solarized-light"] { [data-theme="solarized-light"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #fdf6e3; --bg-primary: #fdf6e3;
--bg-secondary: #eee8d5; --bg-secondary: #eee8d5;
--bg-tertiary: #e4ddc8; --bg-tertiary: #e4ddc8;
@@ -344,6 +370,8 @@
/* ===== ONE DARK ===== */ /* ===== ONE DARK ===== */
[data-theme="one-dark"] { [data-theme="one-dark"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #282c34; --bg-primary: #282c34;
--bg-secondary: #2c313a; --bg-secondary: #2c313a;
--bg-tertiary: #353b45; --bg-tertiary: #353b45;
@@ -370,6 +398,8 @@
/* ===== GITHUB DARK ===== */ /* ===== GITHUB DARK ===== */
[data-theme="github-dark"] { [data-theme="github-dark"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #0d1117; --bg-primary: #0d1117;
--bg-secondary: #161b22; --bg-secondary: #161b22;
--bg-tertiary: #21262d; --bg-tertiary: #21262d;
@@ -416,6 +446,8 @@
/* ===== SCRUNKLY.CAT DARK ===== */ /* ===== SCRUNKLY.CAT DARK ===== */
[data-theme="scrunkly-cat"] { [data-theme="scrunkly-cat"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #161924; --bg-primary: #161924;
--bg-secondary: #161924; --bg-secondary: #161924;
--bg-tertiary: #1b2130; --bg-tertiary: #1b2130;
@@ -442,6 +474,8 @@
/* ===== RED MODULAR LIGHT ===== */ /* ===== RED MODULAR LIGHT ===== */
[data-theme="red-modular-light"] { [data-theme="red-modular-light"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #ffffff; --bg-primary: #ffffff;
--bg-secondary: #f8fafc; --bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9; --bg-tertiary: #f1f5f9;
@@ -460,10 +494,206 @@
--success: #86b300; --success: #86b300;
--warning: #ecb637; --warning: #ecb637;
--error: #ec4137; --error: #ec4137;
--ring: #b30051; --ring: #e60000;
--shadow-color: rgba(0, 0, 0, 0.3); --shadow-color: rgba(0, 0, 0, 0.3);
--gradient-start: #b30051; --gradient-start: #e60000;
--gradient-end: #d6336a; --gradient-end: #ff3333;
}
/* ===== EVERFOREST DARK ===== */
[data-theme="everforest-dark"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #2d353b;
--bg-secondary: #343f44;
--bg-tertiary: #3d484d;
--text-primary: #d3c6aa;
--text-secondary: #859289;
--accent: #a7c080;
--accent-hover: #bdd4a0;
--accent-text: #2d353b;
--border: #4f585e;
--card-bg: #343f44;
--input-bg: #343f44;
--input-border: #4f585e;
--nav-bg: #272e33;
--sidebar-bg: #272e33;
--hover-bg: #3d484d;
--success: #a7c080;
--warning: #e69875;
--error: #e67e80;
--ring: #a7c080;
--shadow-color: rgba(0, 0, 0, 0.35);
--gradient-start: #a7c080;
--gradient-end: #83c092;
}
/* ===== EVERFOREST LIGHT ===== */
[data-theme="everforest-light"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #fdf6e3;
--bg-secondary: #f4f0d9;
--bg-tertiary: #eae4ca;
--text-primary: #5c6a72;
--text-secondary: #829181;
--accent: #8da101;
--accent-hover: #6e8c00;
--accent-text: #fdf6e3;
--border: #d5ceb5;
--card-bg: #f4f0d9;
--input-bg: #fdf6e3;
--input-border: #c5bda0;
--nav-bg: #f4f0d9;
--sidebar-bg: #eae4ca;
--hover-bg: #eae4ca;
--success: #8da101;
--warning: #dfa000;
--error: #f85552;
--ring: #8da101;
--shadow-color: rgba(92, 106, 114, 0.1);
--gradient-start: #8da101;
--gradient-end: #35a77c;
}
/* ===== KANAGAWA ===== */
[data-theme="kanagawa"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #1f1f28;
--bg-secondary: #2a2a37;
--bg-tertiary: #363646;
--text-primary: #dcd7ba;
--text-secondary: #727169;
--accent: #7e9cd8;
--accent-hover: #98b4e8;
--accent-text: #1f1f28;
--border: #363646;
--card-bg: #2a2a37;
--input-bg: #2a2a37;
--input-border: #363646;
--nav-bg: #16161d;
--sidebar-bg: #16161d;
--hover-bg: #363646;
--success: #76946a;
--warning: #dca561;
--error: #c34043;
--ring: #7e9cd8;
--shadow-color: rgba(0, 0, 0, 0.45);
--gradient-start: #7e9cd8;
--gradient-end: #957fb8;
}
/* ===== AYU DARK ===== */
[data-theme="ayu-dark"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #0d1017;
--bg-secondary: #131721;
--bg-tertiary: #1a212e;
--text-primary: #bfbdb6;
--text-secondary: #6c7380;
--accent: #39bae6;
--accent-hover: #59ccf0;
--accent-text: #0d1017;
--border: #1a212e;
--card-bg: #131721;
--input-bg: #0d1017;
--input-border: #242b38;
--nav-bg: #0d1017;
--sidebar-bg: #0d1017;
--hover-bg: #1a212e;
--success: #aad94c;
--warning: #ffb454;
--error: #f07178;
--ring: #39bae6;
--shadow-color: rgba(0, 0, 0, 0.5);
--gradient-start: #39bae6;
--gradient-end: #6a9ff7;
}
/* ===== MOONLIGHT ===== */
[data-theme="moonlight"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #212337;
--bg-secondary: #2b2d3f;
--bg-tertiary: #353750;
--text-primary: #c8d3f5;
--text-secondary: #828dae;
--accent: #82aaff;
--accent-hover: #9dbdff;
--accent-text: #212337;
--border: #353750;
--card-bg: #2b2d3f;
--input-bg: #2b2d3f;
--input-border: #444668;
--nav-bg: #1e2030;
--sidebar-bg: #1e2030;
--hover-bg: #353750;
--success: #c3e88d;
--warning: #ffc777;
--error: #ff757f;
--ring: #82aaff;
--shadow-color: rgba(0, 0, 0, 0.4);
--gradient-start: #82aaff;
--gradient-end: #c099ff;
}
/* ===== CYBERPUNK ===== */
[data-theme="cyberpunk"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #0a0a0f;
--bg-secondary: #0e0e1a;
--bg-tertiary: #141428;
--text-primary: #e0e0ff;
--text-secondary: #7878bb;
--accent: #ff0080;
--accent-hover: #ff33a0;
--accent-text: #ffffff;
--border: #1e1e3a;
--card-bg: #0e0e1a;
--input-bg: #0d0d18;
--input-border: #1e1e3a;
--nav-bg: #07070f;
--sidebar-bg: #07070f;
--hover-bg: #141428;
--success: #00ff9f;
--warning: #ffdd00;
--error: #ff3333;
--ring: #ff0080;
--shadow-color: rgba(255, 0, 128, 0.15);
--gradient-start: #ff0080;
--gradient-end: #00e5ff;
}
/* ===== COTTON CANDY LIGHT ===== */
[data-theme="cotton-candy-light"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #fff5f9;
--bg-secondary: #ffe8f2;
--bg-tertiary: #ffd6e8;
--text-primary: #8b2635;
--text-secondary: #b05470;
--accent: #ff85a2;
--accent-hover: #ff6b8d;
--accent-text: #ffffff;
--border: #ffc2d9;
--card-bg: #ffe8f2;
--input-bg: #fff5f9;
--input-border: #ffaac8;
--nav-bg: #ffe8f2;
--sidebar-bg: #ffd6e8;
--hover-bg: #ffd6e8;
--success: #5cb85c;
--warning: #f0ad4e;
--error: #d9534f;
--ring: #ff85a2;
--shadow-color: rgba(255, 133, 162, 0.15);
--gradient-start: #ff85a2;
--gradient-end: #c084fc;
} }
@@ -537,3 +767,232 @@
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
} }
} }
/* ═══════════════════════════════════════════════════════════════
FLATPICKR THEMED OVERRIDES — fully driven by CSS variables
═══════════════════════════════════════════════════════════════ */
/* Calendar container — appended to body */
.flatpickr-calendar {
background: var(--card-bg) !important;
border: 1px solid var(--border) !important;
border-radius: 0.75rem !important;
box-shadow: 0 10px 25px -5px var(--shadow-color), 0 4px 10px -6px var(--shadow-color) !important;
font-family: inherit !important;
color: var(--text-primary) !important;
z-index: 9999 !important;
overflow: hidden;
}
.flatpickr-calendar::before,
.flatpickr-calendar::after {
display: none !important; /* hide arrow */
}
/* ── Month navigation ─────────────────────────────────────────── */
.flatpickr-months {
background: var(--bg-secondary) !important;
border-bottom: 1px solid var(--border) !important;
padding: 0 !important;
align-items: center !important;
height: 2.75rem !important;
position: relative !important;
}
.flatpickr-months .flatpickr-month {
background: transparent !important;
color: var(--text-primary) !important;
height: 2.75rem !important;
display: flex !important;
align-items: center !important;
}
.flatpickr-current-month {
font-size: 0.875rem !important;
font-weight: 600 !important;
color: var(--text-primary) !important;
padding: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
height: 100% !important;
width: 100% !important;
position: relative !important;
left: 0 !important;
}
.flatpickr-current-month .flatpickr-monthDropdown-months {
background: var(--bg-secondary) !important;
color: var(--text-primary) !important;
border: none !important;
font-weight: 600 !important;
font-size: 0.875rem !important;
appearance: none !important;
-webkit-appearance: none !important;
}
.flatpickr-current-month .flatpickr-monthDropdown-months option {
background: var(--card-bg) !important;
color: var(--text-primary) !important;
}
.flatpickr-current-month input.cur-year {
color: var(--text-primary) !important;
font-weight: 600 !important;
font-size: 0.875rem !important;
}
.flatpickr-months .flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month {
color: var(--text-secondary) !important;
fill: var(--text-secondary) !important;
padding: 0.5rem 0.625rem !important;
transition: color 0.15s !important;
display: flex !important;
align-items: center !important;
height: 2.75rem !important;
top: 0 !important;
}
.flatpickr-months .flatpickr-prev-month:hover,
.flatpickr-months .flatpickr-next-month:hover {
color: var(--text-primary) !important;
fill: var(--text-primary) !important;
}
.flatpickr-months .flatpickr-prev-month svg,
.flatpickr-months .flatpickr-next-month svg {
fill: inherit !important;
width: 12px !important;
height: 12px !important;
}
/* ── Day names row ────────────────────────────────────────────── */
.flatpickr-weekdays {
background: var(--bg-secondary) !important;
border-bottom: 1px solid var(--border) !important;
padding: 0.125rem 0 !important;
}
span.flatpickr-weekday {
color: var(--text-secondary) !important;
font-size: 0.6875rem !important;
font-weight: 600 !important;
text-transform: uppercase !important;
letter-spacing: 0.05em !important;
}
/* ── Days grid ────────────────────────────────────────────────── */
.flatpickr-days {
border: none !important;
}
.dayContainer {
padding: 0.25rem !important;
}
.flatpickr-day {
color: var(--text-primary) !important;
border: none !important;
border-radius: 0.375rem !important;
font-size: 0.8125rem !important;
transition: background 0.12s, color 0.12s !important;
}
.flatpickr-day:hover:not(.selected):not(.flatpickr-disabled) {
background: var(--hover-bg) !important;
border: none !important;
}
.flatpickr-day.selected,
.flatpickr-day.selected:hover {
background: var(--accent) !important;
color: var(--accent-text) !important;
border: none !important;
font-weight: 600 !important;
}
.flatpickr-day.today:not(.selected) {
font-weight: 700 !important;
color: var(--accent) !important;
border: none !important;
position: relative !important;
}
.flatpickr-day.today:not(.selected)::after {
content: '';
position: absolute;
bottom: 3px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent);
}
.flatpickr-day.prevMonthDay,
.flatpickr-day.nextMonthDay {
color: var(--text-secondary) !important;
opacity: 0.4 !important;
}
.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover {
color: var(--text-secondary) !important;
opacity: 0.3 !important;
cursor: not-allowed !important;
}
/* ── Time picker ──────────────────────────────────────────────── */
.flatpickr-time {
border-top: 1px solid var(--border) !important;
background: var(--bg-secondary) !important;
max-height: none !important;
height: auto !important;
}
.flatpickr-time input {
color: var(--text-primary) !important;
background: transparent !important;
font-size: 0.9375rem !important;
font-weight: 600 !important;
font-variant-numeric: tabular-nums !important;
}
.flatpickr-time input:hover,
.flatpickr-time input:focus {
background: var(--hover-bg) !important;
border-radius: 0.375rem !important;
}
.flatpickr-time .flatpickr-time-separator {
color: var(--text-secondary) !important;
font-weight: 600 !important;
}
.flatpickr-time .flatpickr-am-pm {
color: var(--text-primary) !important;
background: transparent !important;
}
.flatpickr-time .numInputWrapper span {
border: none !important;
}
.flatpickr-time .numInputWrapper span.arrowUp::after {
border-bottom-color: var(--text-secondary) !important;
}
.flatpickr-time .numInputWrapper span.arrowDown::after {
border-top-color: var(--text-secondary) !important;
}
.flatpickr-time .numInputWrapper:hover span.arrowUp::after {
border-bottom-color: var(--text-primary) !important;
}
.flatpickr-time .numInputWrapper:hover span.arrowDown::after {
border-top-color: var(--text-primary) !important;
}

View File

@@ -21,6 +21,7 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<App /> <App />
<Toaster <Toaster
position="top-right" position="top-right"
containerStyle={{ top: 70 }}
toastOptions={{ toastOptions={{
duration: 4000, duration: 4000,
style: { style: {

View File

@@ -4,7 +4,7 @@ import {
Users, Shield, Search, Trash2, ChevronDown, Loader2, Users, Shield, Search, Trash2, ChevronDown, Loader2,
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User, MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check, Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
ShieldCheck, Globe, Link as LinkIcon, ShieldCheck, Globe, Link as LinkIcon, LogIn,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
@@ -16,7 +16,7 @@ import toast from 'react-hot-toast';
export default function Admin() { export default function Admin() {
const { user } = useAuth(); const { user } = useAuth();
const { t, language } = useLanguage(); const { t, language } = useLanguage();
const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, imprintUrl, privacyUrl, refreshBranding } = useBranding(); const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, imprintUrl, privacyUrl, hideAppName, refreshBranding } = useBranding();
const navigate = useNavigate(); const navigate = useNavigate();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -47,6 +47,13 @@ export default function Admin() {
const [savingImprintUrl, setSavingImprintUrl] = useState(false); const [savingImprintUrl, setSavingImprintUrl] = useState(false);
const [editPrivacyUrl, setEditPrivacyUrl] = useState(''); const [editPrivacyUrl, setEditPrivacyUrl] = useState('');
const [savingPrivacyUrl, setSavingPrivacyUrl] = useState(false); const [savingPrivacyUrl, setSavingPrivacyUrl] = useState(false);
const [savingHideAppName, setSavingHideAppName] = useState(false);
// OAuth state
const [oauthConfig, setOauthConfig] = useState(null);
const [oauthLoading, setOauthLoading] = useState(true);
const [oauthForm, setOauthForm] = useState({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true });
const [savingOauth, setSavingOauth] = useState(false);
useEffect(() => { useEffect(() => {
if (user?.role !== 'admin') { if (user?.role !== 'admin') {
@@ -55,6 +62,7 @@ export default function Admin() {
} }
fetchUsers(); fetchUsers();
fetchInvites(); fetchInvites();
fetchOauthConfig();
}, [user]); }, [user]);
useEffect(() => { useEffect(() => {
@@ -161,6 +169,18 @@ export default function Admin() {
} }
}; };
const handleHideAppNameToggle = async (value) => {
setSavingHideAppName(true);
try {
await api.put('/branding/hide-app-name', { hideAppName: value });
refreshBranding();
} catch {
toast.error(t('admin.hideAppNameFailed'));
} finally {
setSavingHideAppName(false);
}
};
const handleAppNameSave = async () => { const handleAppNameSave = async () => {
if (!editAppName.trim()) return; if (!editAppName.trim()) return;
setSavingName(true); setSavingName(true);
@@ -275,6 +295,58 @@ export default function Admin() {
} }
}; };
// ── OAuth handlers ──────────────────────────────────────────────────────
const fetchOauthConfig = async () => {
setOauthLoading(true);
try {
const res = await api.get('/admin/oauth');
if (res.data.configured) {
setOauthConfig(res.data.config);
setOauthForm({
issuer: res.data.config.issuer || '',
clientId: res.data.config.clientId || '',
clientSecret: '',
displayName: res.data.config.displayName || 'SSO',
autoRegister: res.data.config.autoRegister ?? true,
});
} else {
setOauthConfig(null);
}
} catch {
// silently fail
} finally {
setOauthLoading(false);
}
};
const handleOauthSave = async (e) => {
e.preventDefault();
setSavingOauth(true);
try {
await api.put('/admin/oauth', oauthForm);
toast.success(t('admin.oauthSaved'));
fetchOauthConfig();
refreshBranding();
} catch (err) {
toast.error(err.response?.data?.error || t('admin.oauthSaveFailed'));
} finally {
setSavingOauth(false);
}
};
const handleOauthRemove = async () => {
if (!confirm(t('admin.oauthRemoveConfirm'))) return;
try {
await api.delete('/admin/oauth');
toast.success(t('admin.oauthRemoved'));
setOauthConfig(null);
setOauthForm({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true });
refreshBranding();
} catch {
toast.error(t('admin.oauthRemoveFailed'));
}
};
const filteredUsers = users.filter(u => const filteredUsers = users.filter(u =>
(u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) || (u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase()) u.email.toLowerCase().includes(search.toLowerCase())
@@ -388,6 +460,28 @@ export default function Admin() {
{savingName ? <Loader2 size={14} className="animate-spin" /> : t('common.save')} {savingName ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button> </button>
</div> </div>
{hasLogo && (
<div className="flex items-center justify-between mt-3 p-3 rounded-lg bg-th-bg-s border border-th-border">
<div className="min-w-0">
<p className="text-sm font-medium text-th-text">{t('admin.hideAppNameLabel')}</p>
<p className="text-xs text-th-text-s mt-0.5">{t('admin.hideAppNameHint')}</p>
</div>
<button
type="button"
disabled={savingHideAppName}
onClick={() => handleHideAppNameToggle(!hideAppName)}
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-1 disabled:opacity-50 ml-4 ${
hideAppName ? 'bg-th-accent' : 'bg-th-border'
}`}
aria-checked={hideAppName}
role="switch"
>
<span className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
hideAppName ? 'translate-x-4' : 'translate-x-0'
}`} />
</button>
</div>
)}
</div> </div>
</div> </div>
@@ -596,6 +690,106 @@ export default function Admin() {
)} )}
</div> </div>
{/* OAuth / SSO Configuration */}
<div className="card p-6 mb-8">
<div className="flex items-center gap-2 mb-4">
<LogIn size={20} className="text-th-accent" />
<h2 className="text-lg font-semibold text-th-text">{t('admin.oauthTitle')}</h2>
</div>
<p className="text-sm text-th-text-s mb-5">{t('admin.oauthDescription')}</p>
{oauthLoading ? (
<div className="flex justify-center py-4">
<Loader2 size={20} className="animate-spin text-th-accent" />
</div>
) : (
<form onSubmit={handleOauthSave} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthIssuer')}</label>
<input
type="url"
value={oauthForm.issuer}
onChange={e => setOauthForm(f => ({ ...f, issuer: e.target.value }))}
className="input-field text-sm"
placeholder="https://auth.example.com/realms/main"
required
/>
<p className="text-xs text-th-text-s mt-1">{t('admin.oauthIssuerHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientId')}</label>
<input
type="text"
value={oauthForm.clientId}
onChange={e => setOauthForm(f => ({ ...f, clientId: e.target.value }))}
className="input-field text-sm"
placeholder="redlight"
required
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientSecret')}</label>
<input
type="password"
value={oauthForm.clientSecret}
onChange={e => setOauthForm(f => ({ ...f, clientSecret: e.target.value }))}
className="input-field text-sm"
placeholder={oauthConfig?.hasClientSecret ? '••••••••' : ''}
/>
{oauthConfig?.hasClientSecret && (
<p className="text-xs text-th-text-s mt-1">{t('admin.oauthClientSecretHint')}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthDisplayName')}</label>
<input
type="text"
value={oauthForm.displayName}
onChange={e => setOauthForm(f => ({ ...f, displayName: e.target.value }))}
className="input-field text-sm"
placeholder="Company SSO"
maxLength={50}
/>
<p className="text-xs text-th-text-s mt-1">{t('admin.oauthDisplayNameHint')}</p>
</div>
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={oauthForm.autoRegister}
onChange={e => setOauthForm(f => ({ ...f, autoRegister: e.target.checked }))}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-th-border rounded-full peer peer-checked:bg-th-accent transition-colors after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full" />
</label>
<div>
<span className="text-sm font-medium text-th-text">{t('admin.oauthAutoRegister')}</span>
<p className="text-xs text-th-text-s">{t('admin.oauthAutoRegisterHint')}</p>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<button type="submit" disabled={savingOauth} className="btn-primary text-sm px-5">
{savingOauth ? <Loader2 size={14} className="animate-spin" /> : null}
{t('admin.oauthSave')}
</button>
{oauthConfig && (
<button type="button" onClick={handleOauthRemove} className="btn-secondary text-sm px-5 text-red-400 hover:text-red-300">
<Trash2 size={14} />
{t('admin.oauthRemove')}
</button>
)}
</div>
</form>
)}
</div>
{/* Search */} {/* Search */}
<div className="card p-4 mb-6"> <div className="card p-4 mb-6">
<div className="relative"> <div className="relative">

View File

@@ -1,12 +1,13 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { import {
ChevronLeft, ChevronRight, Plus, Calendar as CalendarIcon, Clock, Video, ChevronLeft, ChevronRight, Plus, Clock, Video, Bell,
Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send, ExternalLink, Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send, ExternalLink,
} from 'lucide-react'; } from 'lucide-react';
import api from '../services/api'; import api from '../services/api';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
import DateTimePicker from '../components/DateTimePicker';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
const COLORS = ['#6366f1', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6']; const COLORS = ['#6366f1', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6'];
@@ -31,7 +32,7 @@ export default function Calendar() {
// Create/Edit form // Create/Edit form
const [form, setForm] = useState({ const [form, setForm] = useState({
title: '', description: '', start_time: '', end_time: '', title: '', description: '', start_time: '', end_time: '',
room_uid: '', color: '#6366f1', room_uid: '', color: '#6366f1', reminder_minutes: null,
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -111,10 +112,10 @@ export default function Calendar() {
}, [currentDate]); }, [currentDate]);
const eventsForDay = (day) => { const eventsForDay = (day) => {
const dayStr = day.toISOString().split('T')[0]; const dayStr = toLocalDateStr(day);
return events.filter(ev => { return events.filter(ev => {
const start = ev.start_time.split('T')[0]; const start = toLocalDateStr(new Date(ev.start_time));
const end = ev.end_time.split('T')[0]; const end = toLocalDateStr(new Date(ev.end_time));
return dayStr >= start && dayStr <= end; return dayStr >= start && dayStr <= end;
}); });
}; };
@@ -156,7 +157,7 @@ export default function Calendar() {
title: '', description: '', title: '', description: '',
start_time: toLocalDateTimeStr(start), start_time: toLocalDateTimeStr(start),
end_time: toLocalDateTimeStr(end), end_time: toLocalDateTimeStr(end),
room_uid: '', color: '#6366f1', room_uid: '', color: '#6366f1', reminder_minutes: null,
}); });
setEditingEvent(null); setEditingEvent(null);
setShowCreate(true); setShowCreate(true);
@@ -170,6 +171,7 @@ export default function Calendar() {
end_time: toLocalDateTimeStr(new Date(ev.end_time)), end_time: toLocalDateTimeStr(new Date(ev.end_time)),
room_uid: ev.room_uid || '', room_uid: ev.room_uid || '',
color: ev.color || '#6366f1', color: ev.color || '#6366f1',
reminder_minutes: ev.reminder_minutes ?? null,
}); });
setEditingEvent(ev); setEditingEvent(ev);
setShowDetail(null); setShowDetail(null);
@@ -493,7 +495,10 @@ export default function Calendar() {
className="text-xs px-2 py-1.5 rounded text-white font-medium cursor-pointer hover:opacity-80 transition-opacity" className="text-xs px-2 py-1.5 rounded text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: ev.color || '#6366f1' }} style={{ backgroundColor: ev.color || '#6366f1' }}
> >
<div className="truncate">{ev.title}</div> <div className="flex items-center gap-1 truncate">
{ev.reminder_minutes && <Bell size={9} className="flex-shrink-0 opacity-70" />}
<span className="truncate">{ev.title}</span>
</div>
<div className="opacity-80 text-[10px]">{formatTime(ev.start_time)} - {formatTime(ev.end_time)}</div> <div className="opacity-80 text-[10px]">{formatTime(ev.start_time)} - {formatTime(ev.end_time)}</div>
</div> </div>
))} ))}
@@ -533,26 +538,25 @@ export default function Calendar() {
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <DateTimePicker
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.startTime')} *</label> label={t('calendar.startTime')}
<input value={form.start_time}
type="datetime-local" onChange={v => setForm({ ...form, start_time: v })}
value={form.start_time} required
onChange={e => setForm({ ...form, start_time: e.target.value })} icon="calendar"
className="input-field" />
required <DateTimePicker
/> label={t('calendar.endTime')}
</div> value={form.end_time}
<div> onChange={v => setForm({ ...form, end_time: v })}
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.endTime')} *</label> required
<input icon="clock"
type="datetime-local" minDate={form.start_time ? new Date(form.start_time) : null}
value={form.end_time} />
onChange={e => setForm({ ...form, end_time: e.target.value })} </div>
className="input-field" <div className="flex items-center gap-1.5 -mt-2 text-xs text-th-text-s">
required <Globe size={12} className="flex-shrink-0" />
/> <span>{getLocalTimezone()}</span>
</div>
</div> </div>
<div> <div>
@@ -570,6 +574,26 @@ export default function Calendar() {
<p className="text-xs text-th-text-s mt-1">{t('calendar.linkedRoomHint')}</p> <p className="text-xs text-th-text-s mt-1">{t('calendar.linkedRoomHint')}</p>
</div> </div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.reminderLabel')}</label>
<div className="relative">
<Bell size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none" />
<select
value={form.reminder_minutes ?? ''}
onChange={e => setForm({ ...form, reminder_minutes: e.target.value === '' ? null : Number(e.target.value) })}
className="input-field pl-9"
>
<option value="">{t('calendar.reminderNone')}</option>
<option value="5">{t('calendar.reminder5')}</option>
<option value="15">{t('calendar.reminder15')}</option>
<option value="30">{t('calendar.reminder30')}</option>
<option value="60">{t('calendar.reminder60')}</option>
<option value="120">{t('calendar.reminder120')}</option>
<option value="1440">{t('calendar.reminder1440')}</option>
</select>
</div>
</div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.color')}</label> <label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.color')}</label>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -607,6 +631,10 @@ export default function Calendar() {
{new Date(showDetail.start_time).toLocaleString()} - {new Date(showDetail.end_time).toLocaleString()} {new Date(showDetail.start_time).toLocaleString()} - {new Date(showDetail.end_time).toLocaleString()}
</span> </span>
</div> </div>
<div className="flex items-center gap-1.5 text-xs text-th-text-s opacity-70 -mt-2">
<Globe size={12} />
<span>{getLocalTimezone()}</span>
</div>
{showDetail.description && ( {showDetail.description && (
<p className="text-sm text-th-text">{showDetail.description}</p> <p className="text-sm text-th-text">{showDetail.description}</p>
@@ -826,6 +854,13 @@ export default function Calendar() {
} }
// Helpers // Helpers
function toLocalDateStr(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function toLocalDateTimeStr(date) { function toLocalDateTimeStr(date) {
const y = date.getFullYear(); const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0'); const m = String(date.getMonth() + 1).padStart(2, '0');
@@ -839,3 +874,15 @@ function formatTime(dateStr) {
const d = new Date(dateStr); const d = new Date(dateStr);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} }
function getLocalTimezone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
const offset = -new Date().getTimezoneOffset();
const sign = offset >= 0 ? '+' : '-';
const h = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0');
const m = String(Math.abs(offset) % 60).padStart(2, '0');
return `UTC${sign}${h}:${m}`;
}
}

View File

@@ -205,6 +205,7 @@ export default function Dashboard() {
className="input-field" className="input-field"
placeholder={t('dashboard.roomNamePlaceholder')} placeholder={t('dashboard.roomNamePlaceholder')}
required required
minLength={2}
/> />
</div> </div>

View File

@@ -39,7 +39,17 @@ export default function FederatedRoomDetail() {
}, [id]); }, [id]);
const handleJoin = () => { const handleJoin = () => {
window.open(room.join_url, '_blank'); // Validate URL scheme to prevent javascript: or other malicious URIs
try {
const url = new URL(room.join_url);
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
toast.error(t('federation.invalidJoinUrl'));
return;
}
window.open(room.join_url, '_blank');
} catch {
toast.error(t('federation.invalidJoinUrl'));
}
}; };
const handleRemove = async () => { const handleRemove = async () => {

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useParams, Link, useSearchParams } from 'react-router-dom'; import { useParams, Link, useSearchParams } from 'react-router-dom';
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText } from 'lucide-react'; import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText, Clock, X } from 'lucide-react';
import BrandLogo from '../components/BrandLogo'; import BrandLogo from '../components/BrandLogo';
import api from '../services/api'; import api from '../services/api';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -19,11 +19,38 @@ export default function GuestJoin() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [joining, setJoining] = useState(false); const [joining, setJoining] = useState(false);
const [name, setName] = useState(user?.name || ''); const [name, setName] = useState(user?.display_name || user?.name || '');
const [accessCode, setAccessCode] = useState(searchParams.get('ac') || ''); const [accessCode, setAccessCode] = useState(searchParams.get('ac') || '');
const [moderatorCode, setModeratorCode] = useState(''); const [moderatorCode, setModeratorCode] = useState('');
const [status, setStatus] = useState({ running: false }); const [status, setStatus] = useState({ running: false });
const [recordingConsent, setRecordingConsent] = useState(false); const [recordingConsent, setRecordingConsent] = useState(false);
const [waiting, setWaiting] = useState(false);
const prevRunningRef = useRef(false);
const joinMeeting = async () => {
setJoining(true);
try {
const res = await api.post(`/rooms/${uid}/guest-join`, {
name: name.trim(),
access_code: accessCode || undefined,
moderator_code: moderatorCode || undefined,
});
if (res.data.joinUrl) {
window.location.href = res.data.joinUrl;
}
} catch (err) {
const errStatus = err.response?.status;
if (errStatus === 403) {
toast.error(t('room.guestWrongAccessCode'));
setWaiting(false);
} else {
toast.error(t('room.guestJoinFailed'));
setWaiting(false);
}
} finally {
setJoining(false);
}
};
useEffect(() => { useEffect(() => {
const fetchRoom = async () => { const fetchRoom = async () => {
@@ -31,6 +58,7 @@ export default function GuestJoin() {
const res = await api.get(`/rooms/${uid}/public`); const res = await api.get(`/rooms/${uid}/public`);
setRoomInfo(res.data.room); setRoomInfo(res.data.room);
setStatus({ running: res.data.running }); setStatus({ running: res.data.running });
prevRunningRef.current = res.data.running;
} catch (err) { } catch (err) {
const status = err.response?.status; const status = err.response?.status;
if (status === 403) { if (status === 403) {
@@ -53,45 +81,36 @@ export default function GuestJoin() {
} catch { } catch {
// ignore // ignore
} }
}, 10000); }, 5000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [uid]); }, [uid]);
// Auto-join when meeting starts while waiting
useEffect(() => {
if (!prevRunningRef.current && status.running && waiting) {
new Audio('/sounds/meeting-started.mp3').play().catch(() => { });
toast.success(t('room.guestMeetingStartedJoining'));
joinMeeting();
}
prevRunningRef.current = status.running;
}, [status.running]);
const handleJoin = async (e) => { const handleJoin = async (e) => {
e.preventDefault(); e.preventDefault();
if (!name.trim()) { if (!name.trim()) {
toast.error(t('room.guestNameRequired')); toast.error(t('room.guestNameRequired'));
return; return;
} }
if (roomInfo?.allow_recording && !recordingConsent) { if (roomInfo?.allow_recording && !recordingConsent) {
toast.error(t('room.guestRecordingConsent')); toast.error(t('room.guestRecordingConsent'));
return; return;
} }
if (!status.running && !roomInfo?.anyone_can_start) {
setJoining(true); setWaiting(true);
try { return;
const res = await api.post(`/rooms/${uid}/guest-join`, {
name: name.trim(),
access_code: accessCode || undefined,
moderator_code: moderatorCode || undefined,
});
if (res.data.joinUrl) {
window.location.href = res.data.joinUrl;
}
} catch (err) {
const status = err.response?.status;
if (status === 403) {
toast.error(t('room.guestWrongAccessCode'));
} else if (status === 400) {
toast.error(t('room.guestWaitingMessage'));
} else {
toast.error(t('room.guestJoinFailed'));
}
} finally {
setJoining(false);
} }
await joinMeeting();
}; };
if (loading) { if (loading) {
@@ -164,97 +183,125 @@ export default function GuestJoin() {
</div> </div>
{/* Join form */} {/* Join form */}
<form onSubmit={handleJoin} className="space-y-4"> {waiting ? (
<div> <div className="flex flex-col items-center gap-5 py-4">
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label> <div className="w-16 h-16 rounded-full flex items-center justify-center bg-th-accent/10">
<div className="relative"> <Clock size={28} className="text-th-accent animate-pulse" />
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={name}
onChange={e => !isLoggedIn && setName(e.target.value)}
readOnly={isLoggedIn}
className={`input-field pl-11 ${isLoggedIn ? 'opacity-70 cursor-not-allowed' : ''}`}
placeholder={t('room.guestNamePlaceholder')}
required
autoFocus={!isLoggedIn}
/>
</div> </div>
<div className="text-center">
<p className="font-semibold text-th-text mb-1">{t('room.guestWaitingTitle')}</p>
<p className="text-sm text-th-text-s">{t('room.guestWaitingHint')}</p>
</div>
{joining && (
<div className="flex items-center gap-2 text-sm text-th-success font-medium">
<Loader2 size={16} className="animate-spin" />
{t('room.guestMeetingStartedJoining')}
</div>
)}
{!joining && (
<button
type="button"
onClick={() => setWaiting(false)}
className="btn-ghost flex items-center gap-2 text-sm"
>
<X size={16} />
{t('room.guestCancelWaiting')}
</button>
)}
</div> </div>
) : (
{roomInfo.has_access_code && ( <form onSubmit={handleJoin} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</label> <label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label>
<div className="relative"> <div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input <input
type="text" type="text"
value={accessCode} value={name}
onChange={e => setAccessCode(e.target.value)} onChange={e => !isLoggedIn && setName(e.target.value)}
className="input-field pl-11" readOnly={isLoggedIn}
placeholder={t('room.guestAccessCodePlaceholder')} className={`input-field pl-11 ${isLoggedIn ? 'opacity-70 cursor-not-allowed' : ''}`}
placeholder={t('room.guestNamePlaceholder')}
required
autoFocus={!isLoggedIn}
/> />
</div> </div>
</div> </div>
)}
<div> {roomInfo.has_access_code && (
<label className="block text-sm font-medium text-th-text mb-1.5"> <div>
{t('room.guestModeratorCode')} <label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</label>
<span className="text-th-text-s font-normal ml-1">{t('room.guestModeratorOptional')}</span> <div className="relative">
</label> <Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<div className="relative"> <input
<Shield size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> type="text"
<input value={accessCode}
type="text" onChange={e => setAccessCode(e.target.value)}
value={moderatorCode} className="input-field pl-11"
onChange={e => setModeratorCode(e.target.value)} placeholder={t('room.guestAccessCodePlaceholder')}
className="input-field pl-11" />
placeholder={t('room.guestModeratorPlaceholder')} </div>
/>
</div>
</div>
{/* Recording consent notice */}
{roomInfo.allow_recording && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertCircle size={16} className="text-amber-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-400">{t('room.guestRecordingNotice')}</p>
</div> </div>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={recordingConsent}
onChange={e => setRecordingConsent(e.target.checked)}
className="w-4 h-4 rounded accent-amber-500 cursor-pointer"
/>
<span className="text-sm text-th-text">{t('room.guestRecordingConsent')}</span>
</label>
</div>
)}
<button
type="submit"
disabled={joining || (!status.running && !roomInfo.anyone_can_start) || (roomInfo.allow_recording && !recordingConsent)}
className="btn-primary w-full py-3"
>
{joining ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
{t('room.guestJoinButton')}
<ArrowRight size={18} />
</>
)} )}
</button>
{!status.running && ( <div>
<p className="text-xs text-th-text-s text-center"> <label className="block text-sm font-medium text-th-text mb-1.5">
{t('room.guestWaitingMessage')} {t('room.guestModeratorCode')}
</p> <span className="text-th-text-s font-normal ml-1">{t('room.guestModeratorOptional')}</span>
)} </label>
</form> <div className="relative">
<Shield size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={moderatorCode}
onChange={e => setModeratorCode(e.target.value)}
className="input-field pl-11"
placeholder={t('room.guestModeratorPlaceholder')}
/>
</div>
</div>
{/* Recording consent notice */}
{roomInfo.allow_recording && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertCircle size={16} className="text-amber-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-400">{t('room.guestRecordingNotice')}</p>
</div>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={recordingConsent}
onChange={e => setRecordingConsent(e.target.checked)}
className="w-4 h-4 rounded accent-amber-500 cursor-pointer"
/>
<span className="text-sm text-th-text">{t('room.guestRecordingConsent')}</span>
</label>
</div>
)}
<button
type="submit"
disabled={joining || (roomInfo.allow_recording && !recordingConsent)}
className="btn-primary w-full py-3"
>
{joining ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
{t('room.guestJoinButton')}
<ArrowRight size={18} />
</>
)}
</button>
{!status.running && !roomInfo?.anyone_can_start && (
<p className="text-xs text-th-text-s text-center">
{t('room.guestWaitingMessage')}
</p>
)}
</form>
)}
{!isLoggedIn && ( {!isLoggedIn && (
<div className="mt-6 pt-4 border-t border-th-border text-center"> <div className="mt-6 pt-4 border-t border-th-border text-center">

View File

@@ -70,7 +70,7 @@ export default function Home() {
<div className="relative z-10 max-w-4xl mx-auto text-center px-6 pt-20 pb-32"> <div className="relative z-10 max-w-4xl mx-auto text-center px-6 pt-20 pb-32">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-th-accent/10 text-th-accent text-sm font-medium mb-6"> <div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-th-accent/10 text-th-accent text-sm font-medium mb-6">
<Zap size={14} /> <Zap size={14} />
{t('home.poweredBy')} {t('home.madeFor')}
</div> </div>
<h1 className="text-4xl md:text-6xl lg:text-7xl font-extrabold text-th-text mb-6 leading-tight tracking-tight"> <h1 className="text-4xl md:text-6xl lg:text-7xl font-extrabold text-th-text mb-6 leading-tight tracking-tight">

View File

@@ -1,9 +1,9 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { useBranding } from '../contexts/BrandingContext'; import { useBranding } from '../contexts/BrandingContext';
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw } from 'lucide-react'; import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw, LogIn, ShieldCheck } from 'lucide-react';
import BrandLogo from '../components/BrandLogo'; import BrandLogo from '../components/BrandLogo';
import api from '../services/api'; import api from '../services/api';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -15,9 +15,16 @@ export default function Login() {
const [needsVerification, setNeedsVerification] = useState(false); const [needsVerification, setNeedsVerification] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0); const [resendCooldown, setResendCooldown] = useState(0);
const [resending, setResending] = useState(false); const [resending, setResending] = useState(false);
const { login } = useAuth(); // 2FA state
const [needs2FA, setNeeds2FA] = useState(false);
const [tempToken, setTempToken] = useState('');
const [totpCode, setTotpCode] = useState('');
const [verifying2FA, setVerifying2FA] = useState(false);
const totpInputRef = useRef(null);
const { login, verify2FA } = useAuth();
const { t } = useLanguage(); const { t } = useLanguage();
const { registrationMode } = useBranding(); const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
@@ -26,6 +33,13 @@ export default function Login() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [resendCooldown]); }, [resendCooldown]);
// Auto-focus TOTP input when 2FA screen appears
useEffect(() => {
if (needs2FA && totpInputRef.current) {
totpInputRef.current.focus();
}
}, [needs2FA]);
const handleResend = async () => { const handleResend = async () => {
if (resendCooldown > 0 || resending) return; if (resendCooldown > 0 || resending) return;
setResending(true); setResending(true);
@@ -48,7 +62,13 @@ export default function Login() {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
try { try {
await login(email, password); const result = await login(email, password);
if (result?.requires2FA) {
setTempToken(result.tempToken);
setNeeds2FA(true);
setLoading(false);
return;
}
toast.success(t('auth.loginSuccess')); toast.success(t('auth.loginSuccess'));
navigate('/dashboard'); navigate('/dashboard');
} catch (err) { } catch (err) {
@@ -62,6 +82,27 @@ export default function Login() {
} }
}; };
const handle2FASubmit = async (e) => {
e.preventDefault();
setVerifying2FA(true);
try {
await verify2FA(tempToken, totpCode);
toast.success(t('auth.loginSuccess'));
navigate('/dashboard');
} catch (err) {
toast.error(err.response?.data?.error || t('auth.2fa.verifyFailed'));
setTotpCode('');
} finally {
setVerifying2FA(false);
}
};
const handleBack = () => {
setNeeds2FA(false);
setTempToken('');
setTotpCode('');
};
return ( return (
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden"> <div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
{/* Animated background */} {/* Animated background */}
@@ -81,91 +122,171 @@ export default function Login() {
<BrandLogo size="lg" /> <BrandLogo size="lg" />
</div> </div>
<div className="mb-8"> {needs2FA ? (
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.welcomeBack')}</h2> <>
<p className="text-th-text-s"> {/* 2FA verification step */}
{t('auth.loginSubtitle')} <div className="mb-8 text-center">
</p> <div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-th-accent/10 mb-4">
</div> <ShieldCheck size={28} className="text-th-accent" />
</div>
<form onSubmit={handleSubmit} className="space-y-5"> <h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.2fa.title')}</h2>
<div> <p className="text-th-text-s text-sm">
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label> {t('auth.2fa.prompt')}
<div className="relative"> </p>
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="input-field pl-11"
placeholder={t('auth.emailPlaceholder')}
required
/>
</div> </div>
</div>
<div> <form onSubmit={handle2FASubmit} className="space-y-5">
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label> <div>
<div className="relative"> <label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.2fa.codeLabel')}</label>
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <div className="relative">
<input <ShieldCheck size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
type="password" <input
value={password} ref={totpInputRef}
onChange={e => setPassword(e.target.value)} type="text"
className="input-field pl-11" inputMode="numeric"
placeholder={t('auth.passwordPlaceholder')} autoComplete="one-time-code"
required value={totpCode}
/> onChange={e => setTotpCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
className="input-field pl-11 text-center text-lg tracking-[0.3em] font-mono"
placeholder="000 000"
required
maxLength={7}
/>
</div>
</div>
<button
type="submit"
disabled={verifying2FA || totpCode.replace(/\s/g, '').length < 6}
className="btn-primary w-full py-3"
>
{verifying2FA ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
{t('auth.2fa.verify')}
<ArrowRight size={18} />
</>
)}
</button>
</form>
<button
onClick={handleBack}
className="block mt-4 w-full text-center text-sm text-th-text-s hover:text-th-text transition-colors"
>
{t('auth.2fa.backToLogin')}
</button>
</>
) : (
<>
<div className="mb-8">
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.welcomeBack')}</h2>
<p className="text-th-text-s">
{t('auth.loginSubtitle')}
</p>
</div> </div>
</div>
<button <form onSubmit={handleSubmit} className="space-y-5">
type="submit" <div>
disabled={loading} <label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
className="btn-primary w-full py-3" <div className="relative">
> <Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
{loading ? ( <input
<Loader2 size={18} className="animate-spin" /> type="email"
) : ( value={email}
onChange={e => setEmail(e.target.value)}
className="input-field pl-11"
placeholder={t('auth.emailPlaceholder')}
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="input-field pl-11"
placeholder={t('auth.passwordPlaceholder')}
required
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full py-3"
>
{loading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
{t('auth.login')}
<ArrowRight size={18} />
</>
)}
</button>
</form>
{oauthEnabled && (
<> <>
{t('auth.login')} <div className="relative my-6">
<ArrowRight size={18} /> <div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-th-border" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-3 bg-th-card/80 text-th-text-s">{t('auth.orContinueWith')}</span>
</div>
</div>
<a
href="/api/oauth/authorize"
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
>
<LogIn size={18} />
{t('auth.loginWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
</a>
</> </>
)} )}
</button>
</form>
{needsVerification && ( {needsVerification && (
<div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2"> <div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertTriangle size={16} className="text-amber-400 flex-shrink-0 mt-0.5" /> <AlertTriangle size={16} className="text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p> <p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p>
</div> </div>
<button <button
onClick={handleResend} onClick={handleResend}
disabled={resendCooldown > 0 || resending} disabled={resendCooldown > 0 || resending}
className="flex items-center gap-1.5 text-sm text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors disabled:opacity-60 disabled:no-underline disabled:cursor-not-allowed" className="flex items-center gap-1.5 text-sm text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors disabled:opacity-60 disabled:no-underline disabled:cursor-not-allowed"
> >
<RefreshCw size={13} className={resending ? 'animate-spin' : ''} /> <RefreshCw size={13} className={resending ? 'animate-spin' : ''} />
{resendCooldown > 0 {resendCooldown > 0
? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown) ? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown)
: t('auth.emailVerificationResend')} : t('auth.emailVerificationResend')}
</button> </button>
</div> </div>
)} )}
{registrationMode !== 'invite' && ( {registrationMode !== 'invite' && (
<p className="mt-6 text-center text-sm text-th-text-s"> <p className="mt-6 text-center text-sm text-th-text-s">
{t('auth.noAccount')}{' '} {t('auth.noAccount')}{' '}
<Link to="/register" className="text-th-accent hover:underline font-medium"> <Link to="/register" className="text-th-accent hover:underline font-medium">
{t('auth.signUpNow')} {t('auth.signUpNow')}
</Link>
</p>
)}
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
{t('auth.backToHome')}
</Link> </Link>
</p> </>
)} )}
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
{t('auth.backToHome')}
</Link>
</div> </div>
</div> </div>
</div> </div>

59
src/pages/NotFound.jsx Normal file
View File

@@ -0,0 +1,59 @@
import { Link } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import { Ghost, ArrowLeft, Home } from 'lucide-react';
export default function NotFound() {
const { t } = useLanguage();
return (
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
{/* Animated background */}
<div className="absolute inset-0 bg-th-bg">
<div className="absolute inset-0 opacity-20">
<div className="absolute top-1/3 left-1/3 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
<div className="absolute bottom-1/3 right-1/3 w-72 h-72 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '3s' }} />
</div>
</div>
<div className="relative w-full max-w-md text-center">
<div className="card p-10 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
{/* Ghost icon with subtle animation */}
<div className="flex justify-center mb-6">
<div className="w-20 h-20 bg-th-accent/10 rounded-full flex items-center justify-center animate-bounce" style={{ animationDuration: '2s' }}>
<Ghost size={40} className="text-th-accent" />
</div>
</div>
{/* 404 number */}
<h1 className="text-7xl font-extrabold text-th-text mb-2 tracking-tight">404</h1>
<h2 className="text-xl font-semibold text-th-text mb-2">
{t('notFound.title')}
</h2>
<p className="text-th-text-s mb-8">
{t('notFound.description')}
</p>
{/* Action buttons */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<button
onClick={() => window.history.back()}
className="btn-secondary w-full sm:w-auto px-5 py-2.5 flex items-center justify-center gap-2"
>
<ArrowLeft size={16} />
{t('notFound.goBack')}
</button>
<Link
to="/"
className="btn-primary w-full sm:w-auto px-5 py-2.5 flex items-center justify-center gap-2"
>
<Home size={16} />
{t('notFound.goHome')}
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { Loader2, AlertTriangle } from 'lucide-react';
import toast from 'react-hot-toast';
export default function OAuthCallback() {
const [searchParams] = useSearchParams();
const [error, setError] = useState(null);
const { loginWithOAuth } = useAuth();
const { t } = useLanguage();
const navigate = useNavigate();
useEffect(() => {
// Token is passed via hash fragment (never sent to server, not logged, not in Referer).
// Error is still a regular query param since it contains no sensitive data.
const hash = window.location.hash.slice(1); // strip leading '#'
const hashParams = new URLSearchParams(hash);
const token = hashParams.get('token');
const errorMsg = searchParams.get('error');
const returnTo = hashParams.get('return_to') || searchParams.get('return_to') || '/dashboard';
if (errorMsg) {
setError(errorMsg);
return;
}
if (!token) {
setError(t('auth.oauthNoToken'));
return;
}
// Store token and redirect
loginWithOAuth(token)
.then(() => {
toast.success(t('auth.loginSuccess'));
navigate(returnTo, { replace: true });
})
.catch(() => {
setError(t('auth.oauthLoginFailed'));
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (error) {
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="absolute inset-0 bg-th-bg" />
<div className="relative w-full max-w-md">
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl text-center">
<div className="flex justify-center mb-4">
<div className="w-12 h-12 bg-red-500/20 rounded-full flex items-center justify-center">
<AlertTriangle size={24} className="text-red-400" />
</div>
</div>
<h2 className="text-xl font-bold text-th-text mb-2">{t('auth.oauthError')}</h2>
<p className="text-th-text-s mb-6">{error}</p>
<button
onClick={() => navigate('/login', { replace: true })}
className="btn-primary w-full py-3"
>
{t('auth.backToLogin')}
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="absolute inset-0 bg-th-bg" />
<div className="relative flex flex-col items-center gap-4">
<Loader2 size={32} className="animate-spin text-th-accent" />
<p className="text-th-text-s">{t('auth.oauthRedirecting')}</p>
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { useBranding } from '../contexts/BrandingContext'; import { useBranding } from '../contexts/BrandingContext';
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle, ShieldAlert } from 'lucide-react'; import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle, ShieldAlert, LogIn } from 'lucide-react';
import BrandLogo from '../components/BrandLogo'; import BrandLogo from '../components/BrandLogo';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -19,7 +19,7 @@ export default function Register() {
const [needsVerification, setNeedsVerification] = useState(false); const [needsVerification, setNeedsVerification] = useState(false);
const { register } = useAuth(); const { register } = useAuth();
const { t } = useLanguage(); const { t } = useLanguage();
const { registrationMode } = useBranding(); const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
const navigate = useNavigate(); const navigate = useNavigate();
// Invite-only mode without a token → show blocked message // Invite-only mode without a token → show blocked message
@@ -33,7 +33,7 @@ export default function Register() {
return; return;
} }
if (password.length < 6) { if (password.length < 8) {
toast.error(t('auth.passwordTooShort')); toast.error(t('auth.passwordTooShort'));
return; return;
} }
@@ -197,6 +197,26 @@ export default function Register() {
</button> </button>
</form> </form>
{oauthEnabled && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-th-border" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-3 bg-th-card/80 text-th-text-s">{t('auth.orContinueWith')}</span>
</div>
</div>
<a
href="/api/oauth/authorize"
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
>
<LogIn size={18} />
{t('auth.registerWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
</a>
</>
)}
<p className="mt-6 text-center text-sm text-th-text-s"> <p className="mt-6 text-center text-sm text-th-text-s">
{t('auth.hasAccount')}{' '} {t('auth.hasAccount')}{' '}
<Link to="/login" className="text-th-accent hover:underline font-medium"> <Link to="/login" className="text-th-accent hover:underline font-medium">

View File

@@ -4,13 +4,14 @@ import {
ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio, ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio,
Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck, Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck,
Shield, Save, UserPlus, X, Share2, Globe, Send, Shield, Save, UserPlus, X, Share2, Globe, Send,
FileText, Upload, Trash2, FileText, Upload, Trash2, Link, BarChart3,
} from 'lucide-react'; } from 'lucide-react';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
import api from '../services/api'; import api from '../services/api';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import RecordingList from '../components/RecordingList'; import RecordingList from '../components/RecordingList';
import AnalyticsList from '../components/AnalyticsList';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
export default function RoomDetail() { export default function RoomDetail() {
@@ -22,6 +23,7 @@ export default function RoomDetail() {
const [room, setRoom] = useState(null); const [room, setRoom] = useState(null);
const [status, setStatus] = useState({ running: false, participantCount: 0, moderatorCount: 0 }); const [status, setStatus] = useState({ running: false, participantCount: 0, moderatorCount: 0 });
const [recordings, setRecordings] = useState([]); const [recordings, setRecordings] = useState([]);
const [analytics, setAnalytics] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(null); const [actionLoading, setActionLoading] = useState(null);
const [activeTab, setActiveTab] = useState('overview'); const [activeTab, setActiveTab] = useState('overview');
@@ -31,6 +33,20 @@ export default function RoomDetail() {
const [shareSearch, setShareSearch] = useState(''); const [shareSearch, setShareSearch] = useState('');
const [shareResults, setShareResults] = useState([]); const [shareResults, setShareResults] = useState([]);
const [shareSearching, setShareSearching] = useState(false); const [shareSearching, setShareSearching] = useState(false);
const [waitingToJoin, setWaitingToJoin] = useState(false);
const prevRunningRef = useRef(false);
const [showCopyMenu, setShowCopyMenu] = useState(false);
const copyMenuRef = useRef(null);
useEffect(() => {
const handleClickOutside = (e) => {
if (copyMenuRef.current && !copyMenuRef.current.contains(e.target)) {
setShowCopyMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Federation invite state // Federation invite state
const [showFedInvite, setShowFedInvite] = useState(false); const [showFedInvite, setShowFedInvite] = useState(false);
@@ -79,14 +95,39 @@ export default function RoomDetail() {
} }
}; };
const fetchAnalytics = async () => {
try {
const res = await api.get(`/analytics/room/${uid}`);
setAnalytics(res.data.analytics || []);
} catch {
// Ignore
}
};
useEffect(() => { useEffect(() => {
fetchRoom(); fetchRoom();
fetchStatus(); fetchStatus();
fetchRecordings(); fetchRecordings();
fetchAnalytics();
const interval = setInterval(fetchStatus, 10000); const interval = setInterval(fetchStatus, 10000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [uid]); }, [uid]);
// Auto-join when meeting starts while waiting
useEffect(() => {
if (!prevRunningRef.current && status.running && waitingToJoin) {
new Audio('/sounds/meeting-started.mp3').play().catch(() => {});
toast.success(t('room.meetingStarted'));
setWaitingToJoin(false);
setActionLoading('join');
api.post(`/rooms/${uid}/join`, {})
.then(res => { if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank'); })
.catch(err => toast.error(err.response?.data?.error || t('room.joinFailed')))
.finally(() => setActionLoading(null));
}
prevRunningRef.current = status.running;
}, [status.running]);
const handleStart = async () => { const handleStart = async () => {
setActionLoading('start'); setActionLoading('start');
try { try {
@@ -104,6 +145,12 @@ export default function RoomDetail() {
}; };
const handleJoin = async () => { const handleJoin = async () => {
if (!status.running) {
setWaitingToJoin(true);
toast(t('room.guestWaitingTitle'), { icon: '🕐' });
return;
}
setWaitingToJoin(false);
setActionLoading('join'); setActionLoading('join');
try { try {
const data = room.access_code ? { access_code: prompt(t('room.enterAccessCode')) } : {}; const data = room.access_code ? { access_code: prompt(t('room.enterAccessCode')) } : {};
@@ -148,6 +195,8 @@ export default function RoomDetail() {
record_meeting: !!editRoom.record_meeting, record_meeting: !!editRoom.record_meeting,
guest_access: !!editRoom.guest_access, guest_access: !!editRoom.guest_access,
moderator_code: editRoom.moderator_code, moderator_code: editRoom.moderator_code,
learning_analytics: !!editRoom.learning_analytics,
analytics_visibility: editRoom.analytics_visibility || 'owner',
}); });
setRoom(res.data.room); setRoom(res.data.room);
setEditRoom(res.data.room); setEditRoom(res.data.room);
@@ -159,9 +208,10 @@ export default function RoomDetail() {
} }
}; };
const copyLink = () => { const copyToClipboard = (url) => {
navigator.clipboard.writeText(`${window.location.origin}/rooms/${uid}`); navigator.clipboard.writeText(url);
toast.success(t('room.linkCopied')); toast.success(t('room.linkCopied'));
setShowCopyMenu(false);
}; };
// Federation invite handler // Federation invite handler
@@ -295,6 +345,7 @@ export default function RoomDetail() {
const tabs = [ const tabs = [
{ id: 'overview', label: t('room.overview'), icon: Play }, { id: 'overview', label: t('room.overview'), icon: Play },
{ id: 'recordings', label: t('room.recordings'), icon: FileVideo, count: recordings.length }, { id: 'recordings', label: t('room.recordings'), icon: FileVideo, count: recordings.length },
{ id: 'analytics', label: t('room.analytics'), icon: BarChart3, count: analytics.length, hidden: !room.learning_analytics || (isShared && room.analytics_visibility !== 'shared') },
...(isOwner ? [{ id: 'settings', label: t('room.settings'), icon: Settings }] : []), ...(isOwner ? [{ id: 'settings', label: t('room.settings'), icon: Settings }] : []),
]; ];
@@ -333,10 +384,33 @@ export default function RoomDetail() {
{t('common.protected')} {t('common.protected')}
</span> </span>
)} )}
<button onClick={copyLink} className="flex items-center gap-1 hover:text-th-accent transition-colors"> <div className="relative" ref={copyMenuRef}>
<Copy size={14} /> <button
{t('room.copyLink')} onClick={() => setShowCopyMenu(v => !v)}
</button> className="flex items-center gap-1 hover:text-th-accent transition-colors"
>
<Copy size={14} />
{t('room.copyLink')}
</button>
{showCopyMenu && (
<div className="absolute bottom-full left-0 mb-2 bg-th-surface border border-th-border rounded-lg shadow-lg z-50 min-w-[160px] py-1">
<button
onClick={() => copyToClipboard(`${window.location.origin}/rooms/${uid}`)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
>
<Link size={12} />
{t('room.copyRoomLink')}
</button>
<button
onClick={() => copyToClipboard(`${window.location.origin}/join/${uid}`)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
>
<Users size={12} />
{t('room.copyGuestLink')}
</button>
</div>
)}
</div>
</div> </div>
</div> </div>
@@ -351,18 +425,21 @@ export default function RoomDetail() {
<span className="hidden sm:inline">{t('federation.inviteRemote')}</span> <span className="hidden sm:inline">{t('federation.inviteRemote')}</span>
</button> </button>
)} )}
{canManage && !status.running && ( {canManage && !status.running && !waitingToJoin && (
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary"> <button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />} {actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
{t('room.start')} {t('room.start')}
</button> </button>
)} )}
{status.running && ( <button
<button onClick={handleJoin} disabled={actionLoading === 'join'} className="btn-primary"> onClick={waitingToJoin ? () => setWaitingToJoin(false) : handleJoin}
{actionLoading === 'join' ? <Loader2 size={16} className="animate-spin" /> : <ExternalLink size={16} />} disabled={actionLoading === 'join'}
{t('room.join')} className={waitingToJoin ? 'btn-ghost' : 'btn-primary'}
</button> title={waitingToJoin ? t('room.guestCancelWaiting') : undefined}
)} >
{(actionLoading === 'join' || waitingToJoin) ? <Loader2 size={16} className="animate-spin" /> : <ExternalLink size={16} />}
{waitingToJoin ? t('room.waitingToJoin') : t('room.join')}
</button>
{canManage && status.running && ( {canManage && status.running && (
<button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger"> <button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger">
{actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />} {actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />}
@@ -375,7 +452,7 @@ export default function RoomDetail() {
{/* Tabs */} {/* Tabs */}
<div className="flex items-center gap-1 mb-6 border-b border-th-border"> <div className="flex items-center gap-1 mb-6 border-b border-th-border">
{tabs.map(tab => ( {tabs.filter(tab => !tab.hidden).map(tab => (
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
@@ -466,6 +543,10 @@ export default function RoomDetail() {
<RecordingList recordings={recordings} onRefresh={fetchRecordings} /> <RecordingList recordings={recordings} onRefresh={fetchRecordings} />
)} )}
{activeTab === 'analytics' && (
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} isOwner={isOwner} />
)}
{activeTab === 'settings' && isOwner && editRoom && ( {activeTab === 'settings' && isOwner && editRoom && (
<form onSubmit={handleSaveSettings} className="card p-6 space-y-5 max-w-2xl"> <form onSubmit={handleSaveSettings} className="card p-6 space-y-5 max-w-2xl">
<div> <div>
@@ -476,6 +557,7 @@ export default function RoomDetail() {
onChange={e => setEditRoom({ ...editRoom, name: e.target.value })} onChange={e => setEditRoom({ ...editRoom, name: e.target.value })}
className="input-field" className="input-field"
required required
minLength={2}
/> />
</div> </div>
@@ -558,6 +640,29 @@ export default function RoomDetail() {
/> />
<span className="text-sm text-th-text">{t('room.allowRecording')}</span> <span className="text-sm text-th-text">{t('room.allowRecording')}</span>
</label> </label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={!!editRoom.learning_analytics}
onChange={e => setEditRoom({ ...editRoom, learning_analytics: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('room.enableAnalytics')}</span>
</label>
{!!editRoom.learning_analytics && (
<div className="ml-7">
<label className="block text-xs font-medium text-th-text-s mb-1">{t('room.analyticsVisibility')}</label>
<select
value={editRoom.analytics_visibility || 'owner'}
onChange={e => setEditRoom({ ...editRoom, analytics_visibility: e.target.value })}
className="input-field text-sm py-1.5 max-w-xs"
>
<option value="owner">{t('room.analyticsOwnerOnly')}</option>
<option value="shared">{t('room.analyticsSharedUsers')}</option>
</select>
<p className="text-xs text-th-text-s mt-1">{t('room.analyticsVisibilityHint')}</p>
</div>
)}
</div> </div>
{/* Guest access section */} {/* Guest access section */}

View File

@@ -1,5 +1,5 @@
import { useState, useRef } from 'react'; import { useState, useRef, useEffect } from 'react';
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X } from 'lucide-react'; import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X, Calendar, Plus, Trash2, Copy, Eye, EyeOff, Shield, ShieldCheck, ShieldOff } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
@@ -38,6 +38,121 @@ export default function Settings() {
const [uploadingAvatar, setUploadingAvatar] = useState(false); const [uploadingAvatar, setUploadingAvatar] = useState(false);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
// CalDAV token state
const [caldavTokens, setCaldavTokens] = useState([]);
const [caldavLoading, setCaldavLoading] = useState(false);
const [newTokenName, setNewTokenName] = useState('');
const [creatingToken, setCreatingToken] = useState(false);
const [newlyCreatedToken, setNewlyCreatedToken] = useState(null);
const [tokenVisible, setTokenVisible] = useState(false);
// 2FA state
const [twoFaEnabled, setTwoFaEnabled] = useState(!!user?.totp_enabled);
const [twoFaLoading, setTwoFaLoading] = useState(false);
const [twoFaSetupData, setTwoFaSetupData] = useState(null); // { secret, uri, qrDataUrl }
const [twoFaCode, setTwoFaCode] = useState('');
const [twoFaEnabling, setTwoFaEnabling] = useState(false);
const [twoFaDisablePassword, setTwoFaDisablePassword] = useState('');
const [twoFaDisableCode, setTwoFaDisableCode] = useState('');
const [twoFaDisabling, setTwoFaDisabling] = useState(false);
const [showDisableForm, setShowDisableForm] = useState(false);
useEffect(() => {
if (activeSection === 'caldav') {
setCaldavLoading(true);
api.get('/calendar/caldav-tokens')
.then(r => setCaldavTokens(r.data.tokens || []))
.catch(() => {})
.finally(() => setCaldavLoading(false));
}
if (activeSection === 'security') {
setTwoFaLoading(true);
api.get('/auth/2fa/status')
.then(r => setTwoFaEnabled(r.data.enabled))
.catch(() => {})
.finally(() => setTwoFaLoading(false));
}
}, [activeSection]);
const handleCreateToken = async (e) => {
e.preventDefault();
if (!newTokenName.trim()) return;
setCreatingToken(true);
try {
const res = await api.post('/calendar/caldav-tokens', { name: newTokenName.trim() });
setNewlyCreatedToken(res.data.plainToken);
setTokenVisible(false);
setNewTokenName('');
const r = await api.get('/calendar/caldav-tokens');
setCaldavTokens(r.data.tokens || []);
} catch (err) {
toast.error(err.response?.data?.error || t('settings.caldav.createFailed'));
} finally {
setCreatingToken(false);
}
};
const handleRevokeToken = async (id) => {
if (!confirm(t('settings.caldav.revokeConfirm'))) return;
try {
await api.delete(`/calendar/caldav-tokens/${id}`);
setCaldavTokens(prev => prev.filter(tk => tk.id !== id));
toast.success(t('settings.caldav.revoked'));
} catch {
toast.error(t('settings.caldav.revokeFailed'));
}
};
// 2FA handlers
const handleSetup2FA = async () => {
setTwoFaLoading(true);
try {
const res = await api.post('/auth/2fa/setup');
// Generate QR code data URL client-side
const QRCode = (await import('qrcode')).default;
const qrDataUrl = await QRCode.toDataURL(res.data.uri, { width: 200, margin: 2, color: { dark: '#000000', light: '#ffffff' } });
setTwoFaSetupData({ secret: res.data.secret, uri: res.data.uri, qrDataUrl });
} catch (err) {
toast.error(err.response?.data?.error || t('settings.security.setupFailed'));
} finally {
setTwoFaLoading(false);
}
};
const handleEnable2FA = async (e) => {
e.preventDefault();
setTwoFaEnabling(true);
try {
await api.post('/auth/2fa/enable', { code: twoFaCode });
setTwoFaEnabled(true);
setTwoFaSetupData(null);
setTwoFaCode('');
toast.success(t('settings.security.enabled'));
} catch (err) {
toast.error(err.response?.data?.error || t('settings.security.enableFailed'));
setTwoFaCode('');
} finally {
setTwoFaEnabling(false);
}
};
const handleDisable2FA = async (e) => {
e.preventDefault();
setTwoFaDisabling(true);
try {
await api.post('/auth/2fa/disable', { password: twoFaDisablePassword, code: twoFaDisableCode });
setTwoFaEnabled(false);
setShowDisableForm(false);
setTwoFaDisablePassword('');
setTwoFaDisableCode('');
toast.success(t('settings.security.disabled'));
} catch (err) {
toast.error(err.response?.data?.error || t('settings.security.disableFailed'));
} finally {
setTwoFaDisabling(false);
}
};
const groups = getThemeGroups(); const groups = getThemeGroups();
const avatarColors = [ const avatarColors = [
@@ -137,8 +252,10 @@ export default function Settings() {
const sections = [ const sections = [
{ id: 'profile', label: t('settings.profile'), icon: User }, { id: 'profile', label: t('settings.profile'), icon: User },
{ id: 'password', label: t('settings.password'), icon: Lock }, { id: 'password', label: t('settings.password'), icon: Lock },
{ id: 'security', label: t('settings.security.title'), icon: Shield },
{ id: 'language', label: t('settings.language'), icon: Globe }, { id: 'language', label: t('settings.language'), icon: Globe },
{ id: 'themes', label: t('settings.themes'), icon: Palette }, { id: 'themes', label: t('settings.themes'), icon: Palette },
{ id: 'caldav', label: t('settings.caldav.title'), icon: Calendar },
]; ];
return ( return (
@@ -363,6 +480,147 @@ export default function Settings() {
</div> </div>
)} )}
{/* Security / 2FA section */}
{activeSection === 'security' && (
<div className="space-y-5">
<div className="card p-6">
<h2 className="text-lg font-semibold text-th-text mb-1">{t('settings.security.title')}</h2>
<p className="text-sm text-th-text-s mb-6">{t('settings.security.subtitle')}</p>
{twoFaLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 size={24} className="animate-spin text-th-text-s" />
</div>
) : twoFaEnabled ? (
/* 2FA is enabled */
<div>
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 mb-5">
<ShieldCheck size={22} className="text-emerald-400 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-emerald-300">{t('settings.security.statusEnabled')}</p>
<p className="text-xs text-emerald-400/70">{t('settings.security.statusEnabledDesc')}</p>
</div>
</div>
{!showDisableForm ? (
<button
onClick={() => setShowDisableForm(true)}
className="btn-ghost text-th-error hover:text-th-error text-sm"
>
<ShieldOff size={16} />
{t('settings.security.disable')}
</button>
) : (
<form onSubmit={handleDisable2FA} className="space-y-4 p-4 rounded-xl bg-th-bg-t border border-th-border">
<p className="text-sm text-th-text-s">{t('settings.security.disableConfirm')}</p>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="password"
value={twoFaDisablePassword}
onChange={e => setTwoFaDisablePassword(e.target.value)}
className="input-field pl-11"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.codeLabel')}</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={twoFaDisableCode}
onChange={e => setTwoFaDisableCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
className="input-field text-center text-lg tracking-[0.3em] font-mono"
placeholder="000 000"
required
maxLength={7}
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={twoFaDisabling} className="btn-primary bg-red-600 hover:bg-red-700 border-red-600">
{twoFaDisabling ? <Loader2 size={14} className="animate-spin" /> : <ShieldOff size={14} />}
{t('settings.security.disable')}
</button>
<button type="button" onClick={() => { setShowDisableForm(false); setTwoFaDisablePassword(''); setTwoFaDisableCode(''); }} className="btn-ghost text-sm">
{t('common.cancel')}
</button>
</div>
</form>
)}
</div>
) : twoFaSetupData ? (
/* Setup flow: show QR code + verification */
<div className="space-y-5">
<div className="text-center">
<p className="text-sm text-th-text mb-4">{t('settings.security.scanQR')}</p>
<div className="inline-block p-3 bg-white rounded-xl">
<img src={twoFaSetupData.qrDataUrl} alt="TOTP QR Code" className="w-[200px] h-[200px]" />
</div>
</div>
<div>
<p className="text-xs font-medium text-th-text-s mb-1">{t('settings.security.manualKey')}</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg text-th-accent font-mono break-all">
{twoFaSetupData.secret}
</code>
<button
onClick={() => { navigator.clipboard.writeText(twoFaSetupData.secret); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 flex-shrink-0"
>
<Copy size={14} />
</button>
</div>
</div>
<form onSubmit={handleEnable2FA} className="space-y-3">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.verifyCode')}</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={twoFaCode}
onChange={e => setTwoFaCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
className="input-field text-center text-lg tracking-[0.3em] font-mono"
placeholder="000 000"
required
maxLength={7}
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={twoFaEnabling || twoFaCode.replace(/\s/g, '').length < 6} className="btn-primary">
{twoFaEnabling ? <Loader2 size={14} className="animate-spin" /> : <ShieldCheck size={14} />}
{t('settings.security.enable')}
</button>
<button type="button" onClick={() => { setTwoFaSetupData(null); setTwoFaCode(''); }} className="btn-ghost text-sm">
{t('common.cancel')}
</button>
</div>
</form>
</div>
) : (
/* 2FA is disabled — show enable button */
<div>
<div className="flex items-center gap-3 p-4 rounded-xl bg-th-bg-t border border-th-border mb-5">
<ShieldOff size={22} className="text-th-text-s flex-shrink-0" />
<div>
<p className="text-sm font-medium text-th-text">{t('settings.security.statusDisabled')}</p>
<p className="text-xs text-th-text-s">{t('settings.security.statusDisabledDesc')}</p>
</div>
</div>
<button onClick={handleSetup2FA} disabled={twoFaLoading} className="btn-primary">
{twoFaLoading ? <Loader2 size={16} className="animate-spin" /> : <Shield size={16} />}
{t('settings.security.enable')}
</button>
</div>
)}
</div>
</div>
)}
{/* Language section */} {/* Language section */}
{activeSection === 'language' && ( {activeSection === 'language' && (
<div className="card p-6"> <div className="card p-6">
@@ -425,8 +683,126 @@ export default function Settings() {
))} ))}
</div> </div>
)} )}
</div> {/* CalDAV section */}
</div> {activeSection === 'caldav' && (
<div className="space-y-5">
{/* Info Card */}
<div className="card p-6">
<h2 className="text-lg font-semibold text-th-text mb-1">{t('settings.caldav.title')}</h2>
<p className="text-sm text-th-text-s mb-5">{t('settings.caldav.subtitle')}</p>
<div className="space-y-3">
<div>
<p className="text-xs font-medium text-th-text-s mb-1">{t('settings.caldav.serverUrl')}</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg text-th-accent font-mono truncate">
{`${window.location.origin}/caldav/`}
</code>
<button
onClick={() => { navigator.clipboard.writeText(`${window.location.origin}/caldav/`); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 flex-shrink-0"
>
<Copy size={14} />
</button>
</div>
</div>
<div>
<p className="text-xs font-medium text-th-text-s mb-1">{t('settings.caldav.username')}</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg text-th-text font-mono">
{user?.email}
</code>
<button
onClick={() => { navigator.clipboard.writeText(user?.email || ''); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 flex-shrink-0"
>
<Copy size={14} />
</button>
</div>
</div>
<p className="text-xs text-th-text-s">{t('settings.caldav.hint')}</p>
</div>
</div>
{/* New token was just created */}
{newlyCreatedToken && (
<div className="card p-5 border-2 border-th-success/40 bg-th-success/5">
<p className="text-sm font-semibold text-th-success mb-2">{t('settings.caldav.newTokenCreated')}</p>
<p className="text-xs text-th-text-s mb-3">{t('settings.caldav.newTokenHint')}</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg font-mono text-th-text break-all">
{tokenVisible ? newlyCreatedToken : '•'.repeat(48)}
</code>
<button onClick={() => setTokenVisible(v => !v)} className="btn-ghost py-1.5 px-2 flex-shrink-0">
{tokenVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button
onClick={() => { navigator.clipboard.writeText(newlyCreatedToken); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 flex-shrink-0"
>
<Copy size={14} />
</button>
</div>
<button
onClick={() => setNewlyCreatedToken(null)}
className="mt-3 text-xs text-th-text-s hover:text-th-text underline"
>
{t('settings.caldav.dismiss')}
</button>
</div>
)}
{/* Create new token */}
<div className="card p-5">
<h3 className="text-sm font-semibold text-th-text mb-3">{t('settings.caldav.newToken')}</h3>
<form onSubmit={handleCreateToken} className="flex gap-2">
<input
type="text"
value={newTokenName}
onChange={e => setNewTokenName(e.target.value)}
placeholder={t('settings.caldav.tokenNamePlaceholder')}
className="input-field flex-1 text-sm"
required
/>
<button type="submit" disabled={creatingToken || !newTokenName.trim()} className="btn-primary py-1.5 px-4">
{creatingToken ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
{t('settings.caldav.generate')}
</button>
</form>
</div>
{/* Token list */}
<div className="card p-5">
<h3 className="text-sm font-semibold text-th-text mb-3">{t('settings.caldav.existingTokens')}</h3>
{caldavLoading ? (
<div className="flex items-center justify-center py-6"><Loader2 size={20} className="animate-spin text-th-text-s" /></div>
) : caldavTokens.length === 0 ? (
<p className="text-sm text-th-text-s py-3">{t('settings.caldav.noTokens')}</p>
) : (
<div className="space-y-2">
{caldavTokens.map(tk => (
<div key={tk.id} className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg bg-th-bg-t">
<div className="min-w-0">
<p className="text-sm font-medium text-th-text truncate">{tk.name}</p>
<p className="text-xs text-th-text-s">
{t('settings.caldav.created')}: {new Date(tk.created_at).toLocaleDateString()}
{tk.last_used_at && ` · ${t('settings.caldav.lastUsed')}: ${new Date(tk.last_used_at).toLocaleDateString()}`}
</p>
</div>
<button
onClick={() => handleRevokeToken(tk.id)}
className="btn-ghost py-1 px-2 text-th-error hover:text-th-error flex-shrink-0"
title={t('settings.caldav.revoke')}
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
</div>
)}
</div> </div>
</div> </div>
); );
} }

View File

@@ -118,6 +118,55 @@ export const themes = [
group: 'Community', group: 'Community',
colors: { bg: '#ffffff', accent: '#e60000', text: '#000000' }, colors: { bg: '#ffffff', accent: '#e60000', text: '#000000' },
}, },
{
id: 'everforest-dark',
name: 'Everforest Dark',
type: 'dark',
group: 'Everforest',
colors: { bg: '#2d353b', accent: '#a7c080', text: '#d3c6aa' },
},
{
id: 'everforest-light',
name: 'Everforest Light',
type: 'light',
group: 'Everforest',
colors: { bg: '#fdf6e3', accent: '#8da101', text: '#5c6a72' },
},
{
id: 'kanagawa',
name: 'Kanagawa',
type: 'dark',
group: 'Community',
colors: { bg: '#1f1f28', accent: '#7e9cd8', text: '#dcd7ba' },
},
{
id: 'ayu-dark',
name: 'Ayu Dark',
type: 'dark',
group: 'Ayu',
colors: { bg: '#0d1017', accent: '#39bae6', text: '#bfbdb6' },
},
{
id: 'moonlight',
name: 'Moonlight',
type: 'dark',
group: 'Community',
colors: { bg: '#212337', accent: '#82aaff', text: '#c8d3f5' },
},
{
id: 'cyberpunk',
name: 'Cyberpunk',
type: 'dark',
group: 'Community',
colors: { bg: '#0a0a0f', accent: '#ff0080', text: '#e0e0ff' },
},
{
id: 'cotton-candy-light',
name: 'Cotton Candy Light',
type: 'light',
group: 'Community',
colors: { bg: '#fff5f9', accent: '#ff85a2', text: '#8b2635' },
},
]; ];
export function getThemeById(id) { export function getThemeById(id) {