87 Commits
1.2.1 ... 2.1.1

Author SHA1 Message Date
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
df82316097 feat(federation): update federation configuration comments for clarity and key path details
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s
Build & Push Docker Image / build (release) Successful in 6m29s
2026-03-02 23:04:51 +01:00
e4001cb33f feat(federation): update key path handling and ensure directory creation for federation keys
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-03-02 23:01:15 +01:00
4a4ec0a3a3 feat(i18n): add German and English email templates for invitations and verifications
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s
2026-03-02 18:55:38 +01:00
9be3be7712 feat(notifications): add notification sound playback for new alerts and include sound file documentation
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m27s
2026-03-02 18:32:47 +01:00
dc7a78badb feat(notifications): add delete functionality for individual and all notifications
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m26s
feat(guest-join): support access code in guest join URL
2026-03-02 18:17:13 +01:00
272c5dc2cc feat(branding): add imprint and privacy links to GuestJoin and Home components
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m28s
2026-03-02 16:57:02 +01:00
c13090bc80 feat(notifications): implement notification system with CRUD operations and UI integration
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m27s
2026-03-02 16:45:53 +01:00
304349fce8 feat(sidebar): enhance user avatar display with image support and overflow handling
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s
2026-03-02 16:22:20 +01:00
b5218046c9 Refactor code and improve internationalization support
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
- Updated import statements to remove invisible characters.
- Standardized comments to use a consistent hyphen format.
- Adjusted username validation error messages for consistency.
- Enhanced email sending functions to include language support.
- Added email internationalization configuration for dynamic translations.
- Updated calendar and federation routes to include language in user queries.
- Improved user feedback messages in German and English for clarity.
2026-03-02 16:14:54 +01:00
c2c10f9a4b feat(calendar): implement local calendar invitations with email notifications
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m19s
- Added functionality to create, accept, decline, and delete local calendar invitations.
- Integrated email notifications for calendar event invitations and deletions.
- Updated database schema to support local invitations and outbound event tracking.
- Enhanced the calendar UI to display pending invitations and allow users to manage them.
- Localized new strings for invitations in English and German.
2026-03-02 14:37:54 +01:00
d989e1291d feat: implement calendar invitation system with creation, acceptance, and management features
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m26s
2026-03-02 13:57:56 +01:00
62a3812424 feat: update red modular light theme colors for improved consistency
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m3s
2026-03-02 11:59:31 +01:00
2a8ded5211 feat(outlook-addin): remove add-in files and update server to prevent serving static files
All checks were successful
Build & Push Docker Image / build (push) Successful in 5m59s
feat(i18n): add translations for "Add to Outlook" and "Add to Google Calendar"
feat(calendar): implement functionality to generate Outlook and Google Calendar event links
2026-03-02 11:49:01 +01:00
9275c20d19 feat(outlook-addin): remove deprecated version overrides and add new icon assets
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m4s
2026-03-02 11:38:15 +01:00
af7540eb8c feat(outlook-addin): serve static files and prevent SPA routing for add-in paths
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m6s
2026-03-02 11:31:56 +01:00
13c60ba052 feat: add calendar component with event management features including create, edit, delete, and share functionalities
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m25s
2026-03-02 10:35:01 +01:00
fae46c8395 feat(admin): add context menu for user actions with dynamic positioning
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m31s
2026-03-01 14:18:21 +01:00
bfec8de195 fix(database): modify admin seeded setting insertion to return key
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m27s
2026-03-01 13:20:27 +01:00
25b13b4078 fix(database): prevent duplicate admin creation during database initialization
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m17s
2026-03-01 13:09:22 +01:00
df4666bb63 feat(invite-system): implement user invite functionality with registration mode control
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m24s
Build & Push Docker Image / build (release) Successful in 6m25s
2026-03-01 12:53:45 +01:00
8c39275615 fix(database): update user creation query to include display_name field
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m21s
2026-03-01 12:32:25 +01:00
64 changed files with 11706 additions and 2351 deletions

View File

@@ -43,7 +43,8 @@ SMTP_FROM=noreply@example.com
# TRUST_PROXY=loopback
# Federation (inter-instance meeting invitations)
# Set both values to enable federation between Redlight instances
# Set FEDERATION_DOMAIN to enable federation between Redlight instances
# FEDERATION_DOMAIN=redlight.example.com
# RSA Private Key for signing outbound invitations (automatically generated if missing on startup)
# FEDERATION_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgk...\n-----END PRIVATE KEY-----"
# The Ed25519 key pair is auto-generated on first start and stored at ./keys/federation_key.pem
# Override the path with FEDERATION_KEY_PATH if needed
# FEDERATION_KEY_PATH=/app/keys/federation_key.pem

View File

@@ -4,7 +4,7 @@ on:
push:
branches:
- main
- federation-try
- develop-calendar
release:
types: [published]

View File

@@ -1,28 +1,31 @@
# ── Stage 1: Build frontend ──────────────────────────────────────────────────
FROM node:20-bullseye-slim AS builder
# ── Stage 1: Install dependencies ────────────────────────────────────────────
FROM node:22-trixie-slim AS deps
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 \
python3 build-essential libsqlite3-dev ca-certificates \
python3 build-essential libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json* ./
# Install all dependencies (including dev for vite build)
RUN npm ci
# ── Stage 2: Build frontend ─────────────────────────────────────────────────
FROM deps AS builder
COPY . .
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
# 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}
# ── Stage 3: Production image ───────────────────────────────────────────────
FROM node:22-trixie-slim
WORKDIR /app

347
README.md
View File

@@ -1,61 +1,71 @@
# 🔴 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)
![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)
## ✨ Features
### Core Features
- 🎥 **Video Conferencing** Integrated BigBlueButton support for professional video meetings
- 🎨 **15+ Themes** Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox, and more
- 📝 **Room Management** Create unlimited rooms with custom settings, access codes, and moderator codes
- 🔐 **User Management** Registration, login, role-based access control (Admin/User)
- 📹 **Recording Management** View, publish, and delete meeting recordings per room
- 🌍 **Multi-Language Support** German (Deutsch) and English built-in, easily extensible
- ✉️ **Email Verification** Optional SMTP-based email verification for user registration
- 👤 **User Profiles** Customizable avatars, themes, and language preferences
- 📱 **Responsive Design** Works seamlessly on mobile, tablet, and desktop
- 🌐 **Federation** Invite users from remote Redlight instances via Ed25519-signed messages
- 🐉 **DragonflyDB / Redis** JWT blacklisting for secure token revocation on logout
- 🎥 **Video Conferencing** - Integrated BigBlueButton support for professional video meetings
- 🎨 **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
- 🔐 **User Management** - Registration, login, role-based access control (Admin/User)
- 📹 **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
- 🔔 **In-App Notifications** - Real-time notifications for room shares, federation invites, and calendar reminders
- ✉️ **Email Verification** - Optional SMTP-based email verification for user registration
- 🔑 **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
- 🌐 **Federation** - Invite users from remote Redlight instances via Ed25519-signed messages
- 🐉 **DragonflyDB / Redis** - JWT blacklisting for secure token revocation on logout
### Admin Features
- 👥 **User Administration** Manage users and roles
- 🏢 **Branding Customization** Custom app name, logos, and default theme
- 📊 **Dashboard** Overview of system statistics
- 🔧 **Settings Management** System-wide configuration
- 👥 **User Administration** - Manage users and roles
- 🏢 **Branding Customization** - Custom app name, logos, and default theme
- 📊 **Dashboard** - Overview of system statistics
- 🔧 **Settings Management** - System-wide configuration
- ✉️ **Invite-Only Registration** - Generate invite tokens for controlled user signup
### Room Features
- 🔑 **Access Codes** Restrict room access with optional passwords
- 🔐 **Moderator Codes** Separate code to grant moderator privileges
- 🚪 **Guest Access** Allow unauthenticated users to join meetings (rate-limited)
- ⏱️ **Max Participants** Set limits on concurrent participants
- 🎤 **Mute on Join** Automatically mute new participants
-**Approval Mode** Require moderator approval for participants
- 🎙️ **Anyone Can Start** Allow participants to start the meeting
- 📹 **Recording Settings** Control whether meetings are recorded
- 📊 **Presentation Upload** Upload PDF, PPTX, ODP, or image files as default slides
- 🤝 **Room Sharing** Share rooms with other registered users
- 🔑 **Access Codes** - Restrict room access with optional passwords
- 🔐 **Moderator Codes** - Separate code to grant moderator privileges
- 🚪 **Guest Access** - Allow unauthenticated users to join meetings (rate-limited)
- ⏱️ **Max Participants** - Set limits on concurrent participants
- 🎤 **Mute on Join** - Automatically mute new participants
-**Approval Mode** - Require moderator approval for participants
- 🎙️ **Anyone Can Start** - Allow participants to start the meeting
- 📹 **Recording Settings** - Control whether meetings are recorded
- 📊 **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
### Security
- 🛡️ **Comprehensive Rate Limiting** Login, register, profile, avatar, guest-join, and federation endpoints
- 🔒 **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`
- 📏 **Streaming Upload Limits** Avatar (5 MB) and presentation (50 MB) uploads reject early without buffering
- 🧹 **XSS Prevention** HTML-escaped emails, XML-escaped BBB parameters, SVG sanitization
- 🔐 **JWT Blacklist** Token revocation via DragonflyDB/Redis on logout
- 🌐 **CORS Restriction** Locked to `APP_URL` in production
- ⚙️ **Configurable Trust Proxy** `TRUST_PROXY` env var for reverse proxy setups
- 🛡️ **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
- 🕐 **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
- 🧹 **XSS Prevention** - HTML-escaped emails, XML-escaped BBB parameters, SVG sanitization
- 🔐 **JWT Blacklist** - Token revocation via DragonflyDB/Redis on logout
- 🌐 **CORS Restriction** - Locked to `APP_URL` in production
- ⚙️ **Configurable Trust Proxy** - `TRUST_PROXY` env var for reverse proxy setups
- 🔏 **HMAC-Secured Callbacks** - Learning analytics callback URLs signed with HMAC-SHA256
### Developer Features
- 🐳 **Docker Support** Easy deployment with Docker Compose (includes PostgreSQL + DragonflyDB)
- 🗄️ **Database Flexibility** SQLite (default) or PostgreSQL support
- 🔌 **REST API** Comprehensive API for custom integrations
- 📦 **Open Source** Full source code transparency
- 🛠️ **Self-Hosted** Complete data privacy and control
- 🐳 **Docker Support** - Easy deployment with Docker Compose (includes PostgreSQL + DragonflyDB)
- 🗄️ **Database Flexibility** - SQLite (default) or PostgreSQL support
- 🔌 **REST API** - Comprehensive API for custom integrations
- 📦 **Open Source** - Full source code transparency
- 🛠️ **Self-Hosted** - Complete data privacy and control
---
@@ -63,20 +73,24 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
| 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 |
| **Notifications** | ✅ In-app + calendar reminders | ❌ Not supported |
| **Language Support** | Multi-language ready | Multi-language ready |
| **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 |
| **Docker** | ✅ Supported | ✅ Supported |
| **Admin Dashboard** | Modern React UI | Legacy Rails interface |
| **Room Sharing** | ✅ Share rooms with users | ✅ Supported |
| **Recording Management** | Full control per room | Standard management |
| **Presentation Upload** | ✅ Supported | ✅ Supported |
| **API** | RESTful JSON API | RESTful API |
| **Setup Complexity** | Simple (5 min) | Moderate (10-15 min) |
| **Customization** | Easy (Tailwind CSS) | Requires Ruby/Rails |
| **Community** | doesn't exist lol | Established |
---
@@ -103,9 +117,9 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
```env
BBB_URL=https://your-bbb-server.com/bigbluebutton/api/
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
DATABASE_URL=postgres://user:password@postgres:5432/redlight
DATABASE_URL=postgres://redlight:redlight@postgres:5432/redlight
POSTGRES_USER=redlight
POSTGRES_PASSWORD=redlight
@@ -118,18 +132,23 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
# TRUST_PROXY=loopback
# Optional: Email verification
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_USER=your-email@gmail.com
# SMTP_PASS=your-app-password
# Optional: Federation (cross-instance room invites)
# 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**
```bash
docker-compose up -d
docker compose up -d
```
4. **Access the application**
@@ -165,8 +184,10 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
- **Frontend**: React 18, Tailwind CSS, React Router, Lucide Icons
- **Backend**: Node.js 20, Express, JWT, Bcrypt
- **Database**: SQLite / PostgreSQL with better-sqlite3 / pg
- **Cache**: DragonflyDB / Redis (ioredis) JWT blacklisting
- **Cache**: DragonflyDB / Redis (ioredis) - JWT blacklisting
- **Email**: Nodemailer
- **CalDAV**: xml2js-based WebDAV/CalDAV server
- **Auth**: JWT + OAuth/OIDC (PKCE)
- **Build**: Vite
---
@@ -176,20 +197,24 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
```
redlight/
├── server/ # Node.js/Express backend
│ ├── config/ # Database, Redis, mailer, BBB & federation config
│ ├── middleware/ # JWT authentication & token blacklisting
│ ├── routes/ # API endpoints (auth, rooms, recordings, admin, branding, federation)
│ ├── config/ # Database, Redis, mailer, BBB, federation, OAuth & notification config
│ ├── i18n/ # Server-side translations (email templates)
│ ├── 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
├── src/ # React frontend
│ ├── components/ # Reusable components
│ ├── contexts/ # React context (Auth, Language, Theme, Branding)
│ ├── components/ # Reusable components (RecordingList, AnalyticsList, etc.)
│ ├── contexts/ # React context (Auth, Language, Theme, Branding, Notification)
│ ├── i18n/ # Translations (DE, EN)
│ ├── pages/ # Page components
│ ├── services/ # API client
│ ├── themes/ # Tailwind theme config
│ ├── themes/ # 25+ theme definitions
│ └── main.jsx # Frontend entry point
├── public/ # Static assets
├── uploads/ # User avatars, branding & presentations (runtime)
├── keys/ # Federation Ed25519 key pair (auto-generated)
├── compose.yml # Docker Compose (Redlight + PostgreSQL + DragonflyDB)
├── Dockerfile # Multi-stage container image
└── package.json # Dependencies
@@ -199,77 +224,109 @@ redlight/
## 🔐 Security
- **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
- **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
- **Email Verification** Optional SMTP-based email verification with resend support
- **CORS Protection** Restricted to `APP_URL` in production, open in development
- **Rate Limiting** Login, register, profile, password, avatar, guest-join, and federation endpoints
- **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`
- **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`
- **Admin Isolation** Role-based access control with strict admin checks
- **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
- **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
- **Password Hashing** - bcryptjs with salt rounds 12, minimum 8-character passwords
- **Email Verification** - Optional SMTP-based email verification with resend support
- **CORS Protection** - Restricted to `APP_URL` in production, open in development
- **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
- **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
- **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
- **Network Isolation** - Docker Compose uses an internal backend network for DB and cache
---
## 📦 API Endpoints
### Authentication
- `POST /api/auth/register` Register new user
- `POST /api/auth/login` Login user
- `POST /api/auth/logout` Logout (blacklists JWT)
- `GET /api/auth/verify-email?token=...` Verify email with token
- `POST /api/auth/resend-verification` Resend verification email
- `GET /api/auth/me` Get current user info
- `PUT /api/auth/profile` Update profile (theme, language, display name)
- `PUT /api/auth/password` Change password
- `POST /api/auth/avatar` Upload avatar image
- `DELETE /api/auth/avatar` Remove avatar image
- `POST /api/auth/register` - Register new user
- `POST /api/auth/login` - Login user
- `POST /api/auth/logout` - Logout (blacklists JWT)
- `GET /api/auth/verify-email?token=...` - Verify email with token
- `POST /api/auth/resend-verification` - Resend verification email
- `GET /api/auth/me` - Get current user info
- `PUT /api/auth/profile` - Update profile (theme, language, display name)
- `PUT /api/auth/password` - Change password
- `POST /api/auth/avatar` - Upload avatar image
- `DELETE /api/auth/avatar` - Remove avatar image
### Rooms
- `GET /api/rooms` List user's rooms (owned + shared)
- `POST /api/rooms` Create new room
- `GET /api/rooms/:uid` Get room details
- `PUT /api/rooms/:uid` Update room
- `DELETE /api/rooms/:uid` Delete room
- `POST /api/rooms/:uid/start` Start meeting
- `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/end` End meeting
- `GET /api/rooms/:uid/running` Check if meeting is running
- `GET /api/rooms/:uid/shares` List shared users
- `POST /api/rooms/:uid/shares` Share room with user
- `DELETE /api/rooms/:uid/shares/:userId` Remove share
- `POST /api/rooms/:uid/presentation` Upload default presentation (PDF, PPTX, ODP, images)
- `DELETE /api/rooms/:uid/presentation` Remove presentation
- `GET /api/rooms` - List user's rooms (owned + shared)
- `POST /api/rooms` - Create new room
- `GET /api/rooms/:uid` - Get room details
- `PUT /api/rooms/:uid` - Update room (incl. learning analytics toggle)
- `DELETE /api/rooms/:uid` - Delete room
- `POST /api/rooms/:uid/start` - Start meeting
- `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/end` - End meeting
- `GET /api/rooms/:uid/status` - Check if meeting is running
- `GET /api/rooms/:uid/shares` - List shared users
- `POST /api/rooms/:uid/shares` - Share room with user
- `DELETE /api/rooms/:uid/shares/:userId` - Remove share
- `POST /api/rooms/:uid/presentation` - Upload default presentation
- `DELETE /api/rooms/:uid/presentation` - Remove presentation
### Recordings
- `GET /api/recordings/:roomUid` List room recordings
- `PUT /api/recordings/:recordingId` Publish/unpublish recording
- `DELETE /api/recordings/:recordingId` Delete recording
- `GET /api/recordings/room/:uid` - List room recordings
- `PUT /api/recordings/:recordID/publish` - Publish/unpublish 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
- `GET /api/admin/users` List all users
- `GET /api/admin/stats` System statistics
- `POST /api/admin/users` Create user (admin)
- `PUT /api/admin/users/:id` Update user
- `DELETE /api/admin/users/:id` Delete user
- `GET /api/admin/users` - List all users
- `GET /api/admin/stats` - System statistics
- `POST /api/admin/users` - Create user (admin)
- `PUT /api/admin/users/:id` - Update user
- `DELETE /api/admin/users/:id` - Delete user
### Branding
- `GET /api/branding` Get branding settings
- `PUT /api/branding` Update branding (admin only)
- `POST /api/branding/logo` Upload custom logo
- `DELETE /api/branding/logo` Remove custom logo
- `GET /api/branding` - Get branding settings
- `PUT /api/branding` - Update branding (admin only)
- `POST /api/branding/logo` - Upload 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
- `GET /.well-known/redlight` Instance discovery (domain, public key)
- `POST /api/federation/invite` Send invitation to remote user
- `POST /api/federation/receive` Receive invitation from remote instance (rate-limited)
- `GET /api/federation/invitations` List received invitations
- `PUT /api/federation/invitations/:id` Accept / decline invitation
- `DELETE /api/federation/invitations/:id` Delete invitation
- `GET /.well-known/redlight` - Instance discovery (domain, public key)
- `POST /api/federation/invite` - Send invitation to remote user
- `POST /api/federation/receive` - Receive invitation from remote instance (rate-limited)
- `GET /api/federation/invitations` - List received invitations
- `PUT /api/federation/invitations/:id` - Accept / decline 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
---
@@ -290,15 +347,25 @@ Redlight comes with built-in support for multiple languages. Currently supported
## 🎨 Themes
Redlight includes the following themes:
- 🌙 Dracula
Redlight includes 25+ themes:
- ☀️ Light / 🌙 Dark (default)
- 🐱 Catppuccin Mocha / Latte
- 🧛 Dracula
- ❄️ Nord
- 🐱 Catppuccin
- 🌹 Rosé Pine
- 🍂 Gruvbox (Dark, Light)
- 💜 One Dark
- 🌊 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`.
@@ -309,30 +376,37 @@ Themes are fully customizable by editing `src/themes/index.js`.
### Using Docker Compose (Recommended)
```bash
docker-compose up -d
docker compose up -d
```
Services:
- **redlight** Node.js application
- **postgres** PostgreSQL database
- **dragonfly** DragonflyDB (Redis-compatible) for JWT blacklisting
- **redlight** - Node.js application (port 3001)
- **postgres** - PostgreSQL 17 database
- **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
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `BBB_URL` | Yes | | BigBlueButton API URL |
| `BBB_SECRET` | Yes | | BigBlueButton shared secret |
| `JWT_SECRET` | Yes | | Secret for signing JWTs (server won't start without it) |
| `APP_URL` | Recommended | | Public URL of the app (used for CORS + email links) |
| `BBB_URL` | Yes | - | BigBlueButton API URL |
| `BBB_SECRET` | Yes | - | BigBlueButton shared secret |
| `JWT_SECRET` | Yes | - | Secret for signing JWTs (server won't start without it) |
| `APP_URL` | Recommended | - | Public URL of the app (used for CORS + email links) |
| `DATABASE_URL` | No | SQLite | PostgreSQL connection string |
| `REDIS_URL` | No | `redis://localhost:6379` | DragonflyDB / Redis URL |
| `TRUST_PROXY` | No | `loopback` | Express trust proxy setting (number or string) |
| `SMTP_HOST` | No | | SMTP server for email verification |
| `SMTP_HOST` | No | - | SMTP server for email verification |
| `SMTP_PORT` | No | `587` | SMTP port |
| `SMTP_USER` | No | | SMTP username |
| `SMTP_PASS` | No | | SMTP password |
| `FEDERATION_DOMAIN` | No | | Domain for federation (enables cross-instance invites) |
| `SMTP_USER` | No | - | SMTP username |
| `SMTP_PASS` | No | - | SMTP password |
| `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
@@ -376,8 +450,9 @@ Federation allows users on different Redlight instances to invite each other int
### Setup
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`.
3. Other instances discover your public key via `GET /.well-known/redlight`.
2. On first start, an Ed25519 key pair is generated automatically and stored in `keys/federation_key.pem`.
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
@@ -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`.
### 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
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,12 +6,16 @@ services:
- "3001:3001"
env_file: ".env"
volumes:
- uploads:/app/uploads
- ./uploads:/app/uploads
- ./keys:/app/keys
depends_on:
postgres:
condition: service_healthy
dragonfly:
condition: service_healthy
networks:
- frontend
- backend
postgres:
image: postgres:17-alpine
@@ -24,6 +28,8 @@ services:
interval: 5s
timeout: 5s
retries: 5
networks:
- backend
dragonfly:
image: ghcr.io/dragonflydb/dragonfly:latest
@@ -37,8 +43,33 @@ services:
interval: 5s
timeout: 5s
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:
pgdata:
uploads:
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",
"private": true,
"version": "1.2.1",
"version": "2.1.1",
"license": "GPL-3.0-or-later",
"type": "module",
"scripts": {
"dev": "concurrently -n client,server -c blue,green \"vite\" \"node --watch server/index.js\"",
@@ -13,19 +14,24 @@
},
"dependencies": {
"axios": "^1.7.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.0.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.6.2",
"concurrently": "^9.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"dotenv": "^17.3.1",
"exceljs": "^4.4.0",
"express": "^4.21.0",
"express-rate-limit": "^7.5.1",
"flatpickr": "^4.6.13",
"ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.0",
"lucide-react": "^0.460.0",
"multer": "^2.0.2",
"multer": "^2.1.0",
"nodemailer": "^8.0.1",
"otpauth": "^9.5.0",
"pdfkit": "^0.17.2",
"pg": "^8.18.0",
"qrcode": "^1.5.4",
"rate-limit-redis": "^4.3.1",
"react": "^18.3.0",
"react-dom": "^18.3.0",
@@ -37,10 +43,10 @@
"devDependencies": {
"@types/react": "^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",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"vite": "^5.4.0"
"vite": "^8.0.0"
}
}

13
public/sounds/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Notification Sound
Pop-up Sound by BeezleFM -- https://freesound.org/s/512135/ -- License: Attribution 4.0
Place your notification sound file here as:
`notification.mp3`
The file is served at `/sounds/notification.mp3` and played automatically
whenever a new in-app notification arrives.
Supported formats: MP3, OGG, WAV — MP3 recommended for broadest browser support.
Keep the file short (< 2 s) and not too loud.

Binary file not shown.

Binary file not shown.

View File

@@ -1,10 +1,24 @@
import crypto from 'crypto';
import crypto from 'crypto';
import xml2js from 'xml2js';
import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParams } from './logger.js';
const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/';
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getChecksum(apiCall, params) {
const queryString = new URLSearchParams(params).toString();
const raw = apiCall + queryString + BBB_SECRET;
@@ -59,22 +73,22 @@ function getRoomPasswords(uid) {
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);
// 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) {
const guestLink = `${logoutURL}/join/${room.uid}`;
welcome += `<br><br>To invite other participants, share this link:<br><a href="${guestLink}">${guestLink}</a>`;
if (room.access_code) {
welcome += `<br>Access Code: <b>${room.access_code}</b>`;
}
welcome += `<br><br>To invite other participants, share this link:<br><a href="${escapeHtml(guestLink)}">${escapeHtml(guestLink)}</a>`;
// Access code is intentionally NOT shown in the welcome message to prevent
// leaking it to all meeting participants.
}
const params = {
meetingID: room.uid,
name: room.name,
name: room.name.length >= 2 ? room.name : room.name.padEnd(2, ' '),
attendeePW,
moderatorPW,
welcome,
@@ -97,8 +111,11 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati
if (room.access_code) {
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;
if (presentationUrl) {
const safeUrl = presentationUrl
@@ -174,4 +191,8 @@ export async function publishRecording(recordID, publish) {
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 };

View File

@@ -49,6 +49,12 @@ class SqliteAdapter {
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() {
this.db.close();
}
@@ -77,7 +83,9 @@ class PostgresAdapter {
let pgSql = convertPlaceholders(sql);
const isInsert = /^\s*INSERT/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);
return {
@@ -98,6 +106,14 @@ class PostgresAdapter {
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() {
this.pool?.end();
}
@@ -106,7 +122,7 @@ class PostgresAdapter {
// ── Public API ──────────────────────────────────────────────────────────────
export function getDb() {
if (!db) {
throw new Error('Database not initialised call initDatabase() first');
throw new Error('Database not initialised - call initDatabase() first');
}
return db;
}
@@ -405,17 +421,417 @@ export async function initDatabase() {
`);
}
// ── Default admin ───────────────────────────────────────────────────────
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
// User invite tokens (invite-only registration)
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS user_invites (
id SERIAL PRIMARY KEY,
token TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
used_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
used_at TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_invites_token ON user_invites(token);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS user_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
created_by INTEGER NOT NULL,
used_by INTEGER,
used_at DATETIME,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_user_invites_token ON user_invites(token);
`);
}
const existingAdmin = await db.get('SELECT id FROM users WHERE email = ?', [adminEmail]);
if (!existingAdmin) {
const hash = bcrypt.hashSync(adminPassword, 12);
await db.run(
'INSERT INTO users (name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, 1)',
['Administrator', adminEmail, hash, 'admin']
);
log.db.info(`Default admin created: ${adminEmail}`);
// ── Calendar tables ──────────────────────────────────────────────────────
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_events (
id SERIAL PRIMARY KEY,
uid TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
room_uid TEXT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
color TEXT DEFAULT '#6366f1',
federated_from TEXT DEFAULT NULL,
federated_join_url TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cal_events_user_id ON calendar_events(user_id);
CREATE INDEX IF NOT EXISTS idx_cal_events_uid ON calendar_events(uid);
CREATE INDEX IF NOT EXISTS idx_cal_events_start ON calendar_events(start_time);
CREATE TABLE IF NOT EXISTS calendar_event_shares (
id SERIAL PRIMARY KEY,
event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(event_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_cal_shares_event ON calendar_event_shares(event_id);
CREATE INDEX IF NOT EXISTS idx_cal_shares_user ON calendar_event_shares(user_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
room_uid TEXT,
user_id INTEGER NOT NULL,
color TEXT DEFAULT '#6366f1',
federated_from TEXT DEFAULT NULL,
federated_join_url TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_cal_events_user_id ON calendar_events(user_id);
CREATE INDEX IF NOT EXISTS idx_cal_events_uid ON calendar_events(uid);
CREATE INDEX IF NOT EXISTS idx_cal_events_start ON calendar_events(start_time);
CREATE TABLE IF NOT EXISTS calendar_event_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(event_id, user_id),
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_cal_shares_event ON calendar_event_shares(event_id);
CREATE INDEX IF NOT EXISTS idx_cal_shares_user ON calendar_event_shares(user_id);
`);
}
// Calendar migrations: add federated columns if missing
if (!(await db.columnExists('calendar_events', 'federated_from'))) {
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_from TEXT DEFAULT NULL');
}
if (!(await db.columnExists('calendar_events', 'federated_join_url'))) {
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)
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_invitations (
id SERIAL PRIMARY KEY,
event_uid TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
room_uid TEXT,
join_url TEXT,
from_user TEXT NOT NULL,
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
color TEXT DEFAULT '#6366f1',
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cal_inv_to_user ON calendar_invitations(to_user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_cal_inv_uid_user ON calendar_invitations(event_uid, to_user_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_invitations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_uid TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
room_uid TEXT,
join_url TEXT,
from_user TEXT NOT NULL,
to_user_id INTEGER NOT NULL,
color TEXT DEFAULT '#6366f1',
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(event_uid, to_user_id)
);
CREATE INDEX IF NOT EXISTS idx_cal_inv_to_user ON calendar_invitations(to_user_id);
`);
}
// Track outbound calendar event federation sends for deletion propagation
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_event_outbound (
id SERIAL PRIMARY KEY,
event_uid TEXT NOT NULL,
remote_domain TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(event_uid, remote_domain)
);
CREATE INDEX IF NOT EXISTS idx_cal_out_event_uid ON calendar_event_outbound(event_uid);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_event_outbound (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_uid TEXT NOT NULL,
remote_domain TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(event_uid, remote_domain)
);
CREATE INDEX IF NOT EXISTS idx_cal_out_event_uid ON calendar_event_outbound(event_uid);
`);
}
// Local calendar event invitations (share-with-acceptance flow)
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_local_invitations (
id SERIAL PRIMARY KEY,
event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(event_id, to_user_id)
);
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_to_user ON calendar_local_invitations(to_user_id);
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_event ON calendar_local_invitations(event_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_local_invitations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL,
from_user_id INTEGER NOT NULL,
to_user_id INTEGER NOT NULL,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(event_id, to_user_id),
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_to_user ON calendar_local_invitations(to_user_id);
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_event ON calendar_local_invitations(event_id);
`);
}
// ── Notifications table ──────────────────────────────────────────────────
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS notifications (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT,
link TEXT,
read INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT,
link TEXT,
read INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
`);
}
// ── 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) ────────────────────────────
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
if (!adminAlreadySeeded) {
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
// Check if admin already exists (upgrade from older version without the flag)
const existing = await db.get('SELECT id FROM users WHERE email = ?', [adminEmail]);
if (!existing) {
const hash = bcrypt.hashSync(adminPassword, 12);
await db.run(
'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)',
['Administrator', 'Administrator', adminEmail, hash, 'admin']
);
log.db.info(`Default admin created: ${adminEmail}`);
}
// Mark as seeded so it never runs again, even if the admin email is changed
await db.run("INSERT INTO settings (key, value) VALUES ('admin_seeded', '1') RETURNING key");
}
}

View File

@@ -0,0 +1,52 @@
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url);
const cache = {};
function load(lang) {
if (cache[lang]) return cache[lang];
try {
cache[lang] = require(path.resolve(__dirname, '../i18n', `${lang}.json`));
return cache[lang];
} catch {
if (lang !== 'en') return load('en');
cache[lang] = {};
return cache[lang];
}
}
/**
* Translate a dot-separated key for the given language.
* Interpolates {placeholder} tokens from params.
* Unresolved tokens are left as-is so callers can do HTML substitution afterwards.
*
* @param {string} lang Language code, e.g. 'en', 'de'
* @param {string} keyPath Dot-separated key, e.g. 'email.verify.subject'
* @param {Record<string,string>} [params] Values to interpolate
* @returns {string}
*/
export function t(lang, keyPath, params = {}) {
const keys = keyPath.split('.');
function resolve(dict) {
let val = dict;
for (const k of keys) {
val = val?.[k];
}
return typeof val === 'string' ? val : undefined;
}
let value = resolve(load(lang));
// Fallback to English
if (value === undefined) value = resolve(load('en'));
if (value === undefined) return keyPath;
return value.replace(/\{(\w+)\}/g, (match, k) =>
params[k] !== undefined ? String(params[k]) : match
);
}

View File

@@ -4,6 +4,9 @@ import path from 'path';
import { fileURLToPath } from 'url';
import { log } from './logger.js';
import dns from 'dns';
import net from 'net';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -13,7 +16,11 @@ let publicKeyPem = '';
// Load or generate Ed25519 keys
if (FEDERATION_DOMAIN) {
const keyPath = path.join(__dirname, 'federation_key.pem');
const keyPath = process.env.FEDERATION_KEY_PATH || '/app/keys/federation_key.pem';
const keyDir = path.dirname(keyPath);
if (!fs.existsSync(keyDir)) {
fs.mkdirSync(keyDir, { recursive: true });
}
if (!privateKeyPem && fs.existsSync(keyPath)) {
privateKeyPem = fs.readFileSync(keyPath, 'utf8');
@@ -89,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.
* Fetches https://{domain}/.well-known/redlight and caches the result.
* Includes SSRF protection: blocks private/internal IPs.
* @param {string} domain
* @returns {Promise<{ baseUrl: string, publicKey: string }>}
*/
export async function discoverInstance(domain) {
// SSRF protection: validate domain doesn't resolve to internal IP
await assertPublicDomain(domain);
const cached = discoveryCache.get(domain);
if (cached && (Date.now() - cached.cachedAt) < DISCOVERY_TTL_MS) {
return cached;
@@ -108,7 +171,8 @@ export async function discoverInstance(domain) {
try {
response = await fetch(wellKnownUrl, { signal: AbortSignal.timeout(TIMEOUT_MS) });
} 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) });
} else throw e;
}
@@ -124,7 +188,9 @@ export async function discoverInstance(domain) {
const baseUrl = `https://${domain}${data.federation_api || '/api/federation'}`;
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,
cachedAt: Date.now(),
};

View File

@@ -1,5 +1,6 @@
import nodemailer from 'nodemailer';
import nodemailer from 'nodemailer';
import { log } from './logger.js';
import { t } from './emaili18n.js';
let transporter;
@@ -21,7 +22,7 @@ export function initMailer() {
const pass = process.env.SMTP_PASS;
if (!host || !user || !pass) {
log.mailer.warn('SMTP not configured email verification disabled');
log.mailer.warn('SMTP not configured - email verification disabled');
return false;
}
@@ -45,17 +46,17 @@ export function isMailerConfigured() {
/**
* Send the verification email with a clickable link.
* @param {string} to recipient email
* @param {string} name user's display name
* @param {string} verifyUrl full verification URL
* @param {string} appName branding app name (default "Redlight")
* @param {string} to - recipient email
* @param {string} name - user's display name
* @param {string} verifyUrl - full verification URL
* @param {string} appName - branding app name (default "Redlight")
*/
// S3: sanitize name for use in email From header (strip quotes, newlines, control chars)
function sanitizeHeaderValue(str) {
return String(str).replace(/["\\\r\n\x00-\x1f]/g, '').trim().slice(0, 100);
}
export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight') {
export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight', lang = 'en') {
if (!transporter) {
throw new Error('SMTP not configured');
}
@@ -68,41 +69,41 @@ export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redl
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: `${headerAppName} Verify your email`,
subject: t(lang, 'email.verify.subject', { appName: headerAppName }),
html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2>
<p>Please verify your email address by clicking the button below:</p>
<h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
<p>${t(lang, 'email.verify.intro')}</p>
<p style="text-align:center;margin:28px 0;">
<a href="${verifyUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
Verify Email
${t(lang, 'email.verify.button')}
</a>
</p>
<p style="font-size:13px;color:#7f849c;">
Or copy this link in your browser:<br/>
${t(lang, 'email.linkHint')}<br/>
<a href="${verifyUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(verifyUrl)}</a>
</p>
<p style="font-size:13px;color:#7f849c;">This link is valid for 24 hours.</p>
<p style="font-size:13px;color:#7f849c;">${t(lang, 'email.verify.validity')}</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">If you didn't register, please ignore this email.</p>
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.verify.footer')}</p>
</div>
`,
text: `Hey ${name},\n\nPlease verify your email: ${verifyUrl}\n\nThis link is valid for 24 hours.\n\n ${appName}`,
text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.verify.intro')}\n${verifyUrl}\n\n${t(lang, 'email.verify.validity')}\n\n- ${appName}`,
});
}
/**
* Send a federation meeting invitation email.
* @param {string} to recipient email
* @param {string} name recipient display name
* @param {string} fromUser sender federated address (user@domain)
* @param {string} roomName name of the invited room
* @param {string} message optional personal message
* @param {string} inboxUrl URL to the federation inbox
* @param {string} appName branding app name (default "Redlight")
* @param {string} to - recipient email
* @param {string} name - recipient display name
* @param {string} fromUser - sender federated address (user@domain)
* @param {string} roomName - name of the invited room
* @param {string} message - optional personal message
* @param {string} inboxUrl - URL to the federation inbox
* @param {string} appName - branding app name (default "Redlight")
*/
export async function sendFederationInviteEmail(to, name, fromUser, roomName, message, inboxUrl, appName = 'Redlight') {
export async function sendFederationInviteEmail(to, name, fromUser, roomName, message, inboxUrl, appName = 'Redlight', lang = 'en') {
if (!transporter) return; // silently skip if SMTP not configured
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
@@ -113,29 +114,161 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
const safeMessage = message ? escapeHtml(message) : null;
const safeAppName = escapeHtml(appName);
const introHtml = t(lang, 'email.federationInvite.intro')
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: `${headerAppName} Meeting invitation from ${sanitizeHeaderValue(fromUser)}`,
subject: t(lang, 'email.federationInvite.subject', { appName: headerAppName, fromUser: sanitizeHeaderValue(fromUser) }),
html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2>
<p>You have received a meeting invitation from <strong style="color:#cdd6f4;">${safeFromUser}</strong>.</p>
<h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
<p>${introHtml}</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 8px 0;font-size:13px;color:#7f849c;">Room:</p>
<p style="margin:0 0 8px 0;font-size:13px;color:#7f849c;">${t(lang, 'email.federationInvite.roomLabel')}</p>
<p style="margin:0;font-size:16px;font-weight:bold;color:#cdd6f4;">${safeRoomName}</p>
${safeMessage ? `<p style="margin:12px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeMessage}&quot;</p>` : ''}
</div>
<p style="text-align:center;margin:28px 0;">
<a href="${inboxUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
View Invitation
${t(lang, 'email.viewInvitation')}
</a>
</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">Open the link above to accept or decline the invitation.</p>
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.invitationFooter')}</p>
</div>
`,
text: `Hey ${name},\n\nYou have received a meeting invitation from ${fromUser}.\nRoom: ${roomName}${message ? `\nMessage: "${message}"` : ''}\n\nView invitation: ${inboxUrl}\n\n ${appName}`,
text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.federationInvite.intro', { fromUser })}\n${t(lang, 'email.federationInvite.roomLabel')} ${roomName}${message ? `\n"${message}"` : ''}\n\n${t(lang, 'email.viewInvitation')}: ${inboxUrl}\n\n- ${appName}`,
});
}
/**
* Send a calendar event invitation email (federated).
*/
export async function sendCalendarInviteEmail(to, name, fromUser, title, startTime, endTime, description, inboxUrl, appName = 'Redlight', lang = 'en') {
if (!transporter) return;
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeName = escapeHtml(name);
const safeFromUser = escapeHtml(fromUser);
const safeTitle = escapeHtml(title);
const safeDesc = description ? escapeHtml(description) : null;
const formatDate = (iso) => {
try { return new Date(iso).toLocaleString(lang === 'de' ? 'de-DE' : 'en-GB', { dateStyle: 'full', timeStyle: 'short' }); }
catch { return iso; }
};
const introHtml = t(lang, 'email.calendarInvite.intro')
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: t(lang, 'email.calendarInvite.subject', { appName: headerAppName, fromUser: sanitizeHeaderValue(fromUser) }),
html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
<p>${introHtml}</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 4px 0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
<p style="margin:6px 0 0 0;font-size:13px;color:#a6adc8;">🕐 ${escapeHtml(formatDate(startTime))} - ${escapeHtml(formatDate(endTime))}</p>
${safeDesc ? `<p style="margin:10px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeDesc}&quot;</p>` : ''}
</div>
<p style="text-align:center;margin:28px 0;">
<a href="${inboxUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
${t(lang, 'email.viewInvitation')}
</a>
</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.invitationFooter')}</p>
</div>
`,
text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.calendarInvite.intro', { fromUser })}\n${safeTitle}\n${formatDate(startTime)} \u2013 ${formatDate(endTime)}${description ? `\n\n"${description}"` : ''}\n\n${t(lang, 'email.viewInvitation')}: ${inboxUrl}\n\n\u2013 ${appName}`,
});
}
/**
* Notify a user that a federated calendar event they received was deleted by the organiser.
*/
export async function sendCalendarEventDeletedEmail(to, name, fromUser, title, appName = 'Redlight', lang = 'en') {
if (!transporter) return;
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeName = escapeHtml(name);
const safeFromUser = escapeHtml(fromUser);
const safeTitle = escapeHtml(title);
const introHtml = t(lang, 'email.calendarDeleted.intro')
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: t(lang, 'email.calendarDeleted.subject', { appName: headerAppName, title: sanitizeHeaderValue(title) }),
html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#f38ba8;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
<p>${introHtml}</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;border-left:4px solid #f38ba8;">
<p style="margin:0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
</div>
<p style="font-size:13px;color:#7f849c;">${t(lang, 'email.calendarDeleted.note')}</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.calendarDeleted.footer', { appName: escapeHtml(appName) })}</p>
</div>
`,
text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.calendarDeleted.intro', { fromUser })}\n"${title}"\n\n${t(lang, 'email.calendarDeleted.note')}\n\n\u2013 ${appName}`,
});
}
/**
* Send a user registration invite email.
* @param {string} to - recipient email
* @param {string} inviteUrl - full invite registration URL
* @param {string} appName - branding app name (default "Redlight")
*/
export async function sendInviteEmail(to, inviteUrl, appName = 'Redlight', lang = 'en') {
if (!transporter) {
throw new Error('SMTP not configured');
}
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeAppName = escapeHtml(appName);
const introHtml = t(lang, 'email.invite.intro')
.replace('{appName}', `<strong style="color:#cdd6f4;">${safeAppName}</strong>`);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: t(lang, 'email.invite.subject', { appName: headerAppName }),
html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.invite.title')}</h2>
<p>${introHtml}</p>
<p>${t(lang, 'email.invite.prompt')}</p>
<p style="text-align:center;margin:28px 0;">
<a href="${inviteUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
${t(lang, 'email.invite.button')}
</a>
</p>
<p style="font-size:13px;color:#7f849c;">
${t(lang, 'email.linkHint')}<br/>
<a href="${inviteUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(inviteUrl)}</a>
</p>
<p style="font-size:13px;color:#7f849c;">${t(lang, 'email.invite.validity')}</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.invite.footer')}</p>
</div>
`,
text: `${t(lang, 'email.invite.title')}\n\n${t(lang, 'email.invite.intro', { appName })}\n\n${t(lang, 'email.invite.prompt')}\n${inviteUrl}\n\n${t(lang, 'email.invite.validity')}\n\n- ${appName}`,
});
}

View File

@@ -0,0 +1,23 @@
import { getDb } from './database.js';
/**
* Create an in-app notification for a user.
* Non-fatal — exceptions are swallowed so that the main operation is never blocked.
*
* @param {number} userId - Recipient user ID
* @param {string} type - Notification type (room_share_added | room_share_removed | federation_invite_received)
* @param {string} title - Short title (e.g. room name or "from" address)
* @param {string|null} body - Optional longer message
* @param {string|null} link - Optional frontend path to navigate to when clicked
*/
export async function createNotification(userId, type, title, body = null, link = null) {
try {
const db = getDb();
await db.run(
'INSERT INTO notifications (user_id, type, title, body, link) VALUES (?, ?, ?, ?, ?)',
[userId, type, title, body, link],
);
} catch {
// Notifications are non-critical — never break main functionality
}
}

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();
}

39
server/i18n/de.json Normal file
View File

@@ -0,0 +1,39 @@
{
"email": {
"greeting": "Hey {name} 👋",
"viewInvitation": "Einladung anzeigen",
"invitationFooter": "Öffne den Link oben, um die Einladung anzunehmen oder abzulehnen.",
"linkHint": "Oder kopiere diesen Link in deinen Browser:",
"verify": {
"subject": "{appName} - E-Mail-Adresse bestätigen",
"intro": "Bitte bestätige deine E-Mail-Adresse, indem du auf den Button klickst:",
"button": "E-Mail bestätigen",
"validity": "Dieser Link ist 24 Stunden gültig.",
"footer": "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren."
},
"invite": {
"subject": "{appName} - Du wurdest eingeladen",
"title": "Du wurdest eingeladen! 🎉",
"intro": "Du wurdest eingeladen, ein Konto auf {appName} zu erstellen.",
"prompt": "Klicke auf den Button, um dich zu registrieren:",
"button": "Konto erstellen",
"validity": "Dieser Link ist 7 Tage gültig.",
"footer": "Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren."
},
"federationInvite": {
"subject": "{appName} - Meeting-Einladung von {fromUser}",
"intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.",
"roomLabel": "Raum:"
},
"calendarInvite": {
"subject": "{appName} - Kalendereinladung von {fromUser}",
"intro": "Du hast eine Kalendereinladung von {fromUser} erhalten."
},
"calendarDeleted": {
"subject": "{appName} - Kalendereintrag abgesagt: {title}",
"intro": "Der folgende Kalendereintrag wurde vom Organisator ({fromUser}) gelöscht und ist nicht mehr verfügbar:",
"note": "Der Termin wurde automatisch aus deinem Kalender entfernt.",
"footer": "Diese Nachricht wurde automatisch von {appName} versendet."
}
}
}

39
server/i18n/en.json Normal file
View File

@@ -0,0 +1,39 @@
{
"email": {
"greeting": "Hey {name} 👋",
"viewInvitation": "View Invitation",
"invitationFooter": "Open the link above to accept or decline the invitation.",
"linkHint": "Or copy this link in your browser:",
"verify": {
"subject": "{appName} - Verify your email",
"intro": "Please verify your email address by clicking the button below:",
"button": "Verify Email",
"validity": "This link is valid for 24 hours.",
"footer": "If you didn't register, please ignore this email."
},
"invite": {
"subject": "{appName} - You've been invited",
"title": "You've been invited! 🎉",
"intro": "You have been invited to create an account on {appName}.",
"prompt": "Click the button below to register:",
"button": "Create Account",
"validity": "This link is valid for 7 days.",
"footer": "If you didn't expect this invitation, you can safely ignore this email."
},
"federationInvite": {
"subject": "{appName} - Meeting invitation from {fromUser}",
"intro": "You have received a meeting invitation from {fromUser}.",
"roomLabel": "Room:"
},
"calendarInvite": {
"subject": "{appName} - Calendar invitation from {fromUser}",
"intro": "You have received a calendar invitation from {fromUser}."
},
"calendarDeleted": {
"subject": "{appName} - Calendar event cancelled: {title}",
"intro": "The following calendar event was deleted by the organiser ({fromUser}) and is no longer available:",
"note": "The event has been automatically removed from your calendar.",
"footer": "This message was sent automatically by {appName}."
}
}
}

View File

@@ -1,4 +1,4 @@
import 'dotenv/config';
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import path from 'path';
@@ -13,7 +13,13 @@ import recordingRoutes from './routes/recordings.js';
import adminRoutes from './routes/admin.js';
import brandingRoutes from './routes/branding.js';
import federationRoutes, { wellKnownHandler } from './routes/federation.js';
import calendarRoutes from './routes/calendar.js';
import caldavRoutes from './routes/caldav.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 { startCalendarReminders } from './jobs/calendarReminders.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -21,19 +27,31 @@ const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3001;
// Trust proxy configurable via TRUST_PROXY env var (default: 1 = one local reverse proxy)
// Trust proxy - configurable via TRUST_PROXY env var (default: 1 = one local reverse proxy)
// Use a number to trust that many hops, or a string like 'loopback' / an IP/CIDR.
const rawTrustProxy = process.env.TRUST_PROXY ?? 'loopback';
const trustProxy = /^\d+$/.test(rawTrustProxy) ? parseInt(rawTrustProxy, 10) : rawTrustProxy;
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
// 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
? { origin: process.env.APP_URL, credentials: true }
: {};
: { origin: false };
app.use(cors(corsOptions));
app.use(express.json());
app.use(express.json({ limit: '100kb' }));
// Request/Response logging (filters sensitive fields)
app.use(requestResponseLogger);
@@ -42,9 +60,9 @@ async function start() {
await initDatabase();
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');
app.use('/uploads', express.static(uploadsPath));
app.use('/uploads/branding', express.static(path.join(uploadsPath, 'branding')));
// API Routes
app.use('/api/auth', authRoutes);
@@ -53,8 +71,31 @@ async function start() {
app.use('/api/admin', adminRoutes);
app.use('/api/branding', brandingRoutes);
app.use('/api/federation', federationRoutes);
app.use('/api/calendar', calendarRoutes);
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
app.use('/api/federation', calendarRoutes);
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
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, '..', 'dist')));
@@ -69,6 +110,8 @@ async function start() {
// Start periodic federation sync job (checks remote room settings every 60s)
startFederationSync();
// Start calendar reminder job (sends in-app + browser notifications before events)
startCalendarReminders();
}
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 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) {
return res.status(401).json({ error: 'User not found' });
}
@@ -57,3 +57,14 @@ export function generateToken(userId) {
const jti = uuidv4();
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

@@ -1,8 +1,16 @@
import { Router } from 'express';
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
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 { 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,}$/;
@@ -24,7 +32,7 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(name)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
}
if (password.length < 8) {
@@ -49,7 +57,7 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
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(
'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)',
[name, display_name, email.toLowerCase(), hash, validRole]
@@ -154,7 +162,7 @@ router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, r
}
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]);
res.json({ message: 'Password reset' });
@@ -164,4 +172,194 @@ router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, r
}
});
// ── User Invite System ─────────────────────────────────────────────────────
// POST /api/admin/invites - Create and send an invite
router.post('/invites', authenticateToken, requireAdmin, async (req, res) => {
try {
const { email } = req.body;
if (!email || !EMAIL_RE.test(email)) {
return res.status(400).json({ error: 'A valid email address is required' });
}
const db = getDb();
// Check if user with this email already exists
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existing) {
return res.status(409).json({ error: 'A user with this email already exists' });
}
// Check if there's already a pending invite for this email
const existingInvite = await db.get(
'SELECT id FROM user_invites WHERE email = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP',
[email.toLowerCase()]
);
if (existingInvite) {
return res.status(409).json({ error: 'There is already a pending invite for this email' });
}
const token = uuidv4();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days
await db.run(
'INSERT INTO user_invites (token, email, created_by, expires_at) VALUES (?, ?, ?, ?)',
[token, email.toLowerCase(), req.user.id, expiresAt]
);
// Send invite email if SMTP is configured
const baseUrl = getBaseUrl(req);
const inviteUrl = `${baseUrl}/register?invite=${token}`;
// Load app name
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'app_name'");
const appName = brandingSetting?.value || 'Redlight';
if (isMailerConfigured()) {
try {
await sendInviteEmail(email.toLowerCase(), inviteUrl, appName, 'en');
} catch (mailErr) {
log.admin.warn(`Invite email failed (non-fatal): ${mailErr.message}`);
}
}
res.status(201).json({ invite: { token, email: email.toLowerCase(), expiresAt, inviteUrl } });
} catch (err) {
log.admin.error(`Create invite error: ${err.message}`);
res.status(500).json({ error: 'Invite could not be created' });
}
});
// GET /api/admin/invites - List all invites
router.get('/invites', authenticateToken, requireAdmin, async (req, res) => {
try {
const db = getDb();
const invites = await db.all(`
SELECT ui.id, ui.token, ui.email, ui.expires_at, ui.created_at, ui.used_at,
creator.name as created_by_name,
used_user.name as used_by_name
FROM user_invites ui
LEFT JOIN users creator ON creator.id = ui.created_by
LEFT JOIN users used_user ON used_user.id = ui.used_by
ORDER BY ui.created_at DESC
`);
res.json({ invites });
} catch (err) {
log.admin.error(`List invites error: ${err.message}`);
res.status(500).json({ error: 'Invites could not be loaded' });
}
});
// DELETE /api/admin/invites/:id - Delete an invite
router.delete('/invites/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
const db = getDb();
const invite = await db.get('SELECT id FROM user_invites WHERE id = ?', [req.params.id]);
if (!invite) {
return res.status(404).json({ error: 'Invite not found' });
}
await db.run('DELETE FROM user_invites WHERE id = ?', [req.params.id]);
res.json({ message: 'Invite deleted' });
} catch (err) {
log.admin.error(`Delete invite error: ${err.message}`);
res.status(500).json({ error: 'Invite could not be deleted' });
}
});
// ── 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;

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

@@ -7,10 +7,12 @@ import path from 'path';
import { fileURLToPath } from 'url';
import { rateLimit } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
import * as OTPAuth from 'otpauth';
import { getDb } from '../config/database.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 { getOAuthConfig, discoverOIDC } from '../config/oauth.js';
import { log } from '../config/logger.js';
if (!process.env.JWT_SECRET) {
@@ -37,7 +39,7 @@ const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
// Simple format check for theme/language IDs (actual validation happens on the frontend)
const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;
// Allowlist for CSS color values only permits hsl(), hex (#rgb/#rrggbb) and plain names
// 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})$/;
const MIN_PASSWORD_LENGTH = 8;
@@ -98,6 +100,15 @@ const resendVerificationLimiter = rateLimit({
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 __dirname = path.dirname(__filename);
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars');
@@ -112,7 +123,27 @@ const router = Router();
// POST /api/auth/register
router.post('/register', registerLimiter, async (req, res) => {
try {
const { username, display_name, email, password } = req.body;
const { username, display_name, email, password, invite_token } = req.body;
// Check registration mode
const db = getDb();
const regModeSetting = await db.get("SELECT value FROM settings WHERE key = 'registration_mode'");
const registrationMode = regModeSetting?.value || 'open';
let validatedInvite = null;
if (registrationMode === 'invite') {
if (!invite_token) {
return res.status(403).json({ error: 'Registration is currently invite-only. You need an invitation link to register.' });
}
// Validate the invite token
validatedInvite = await db.get(
'SELECT * FROM user_invites WHERE token = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP',
[invite_token]
);
if (!validatedInvite) {
return res.status(403).json({ error: 'Invalid or expired invitation link.' });
}
}
if (!username || !display_name || !email || !password) {
return res.status(400).json({ error: 'All fields are required' });
@@ -125,7 +156,7 @@ router.post('/register', registerLimiter, async (req, res) => {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(username)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
}
// M1: email format
@@ -138,7 +169,6 @@ router.post('/register', registerLimiter, async (req, res) => {
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
}
const db = getDb();
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]);
if (existing) {
return res.status(409).json({ error: 'Email is already in use' });
@@ -149,7 +179,7 @@ router.post('/register', registerLimiter, async (req, res) => {
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 (isMailerConfigured()) {
@@ -161,8 +191,16 @@ router.post('/register', registerLimiter, async (req, res) => {
[username, display_name, email.toLowerCase(), hash, verificationToken, expires]
);
// Mark invite as used if applicable
if (validatedInvite) {
const newUser = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (newUser) {
await db.run('UPDATE user_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?', [newUser.id, validatedInvite.id]);
}
}
// 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}`;
// Load app name from branding settings
@@ -173,7 +211,7 @@ router.post('/register', registerLimiter, async (req, res) => {
}
try {
await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName);
await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName, 'en');
} catch (mailErr) {
log.auth.error(`Verification mail failed: ${mailErr.message}`);
// Account is created but email failed — user can resend from login page
@@ -183,12 +221,17 @@ router.post('/register', registerLimiter, async (req, res) => {
return res.status(201).json({ needsVerification: true, message: 'Verification email has been sent' });
}
// No SMTP configured register and login immediately (legacy behaviour)
// No SMTP configured - register and login immediately (legacy behaviour)
const result = await db.run(
'INSERT INTO users (name, display_name, email, password_hash, email_verified) VALUES (?, ?, ?, ?, 1)',
[username, display_name, email.toLowerCase(), hash]
);
// Mark invite as used if applicable
if (validatedInvite) {
await db.run('UPDATE user_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?', [result.lastInsertRowid, validatedInvite.id]);
}
const token = generateToken(result.lastInsertRowid);
const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [result.lastInsertRowid]);
@@ -246,7 +289,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
}
const db = getDb();
const user = await db.get('SELECT id, name, display_name, email_verified, verification_resend_at FROM users WHERE email = ?', [email.toLowerCase()]);
const user = await db.get('SELECT id, name, display_name, language, email_verified, verification_resend_at FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user || user.email_verified) {
// Don't reveal whether account exists
@@ -271,7 +314,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
[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 brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
@@ -281,7 +324,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
}
try {
await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName);
await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName, user.language || 'en');
} catch (mailErr) {
log.auth.error(`Resend verification mail failed: ${mailErr.message}`);
return res.status(502).json({ error: 'Email could not be sent. Please check your SMTP configuration.' });
@@ -303,7 +346,7 @@ router.post('/login', loginLimiter, async (req, res) => {
return res.status(400).json({ error: 'Email and password are required' });
}
// M1: basic email format check invalid format can never match a real account
// M1: basic email format check - invalid format can never match a real account
if (!EMAIL_RE.test(email)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
@@ -319,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 });
}
// ── 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 { password_hash, ...safeUser } = user;
const { password_hash, verification_token, verification_token_expires, verification_resend_at, totp_secret, ...safeUser } = user;
res.json({ token, user: safeUser });
} catch (err) {
@@ -329,7 +378,54 @@ router.post('/login', loginLimiter, async (req, res) => {
}
});
// POST /api/auth/logout revoke JWT via DragonflyDB blacklist
// 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
router.post('/logout', authenticateToken, async (req, res) => {
try {
const authHeader = req.headers.authorization;
@@ -347,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) {
log.auth.error(`Logout error: ${err.message}`);
res.status(500).json({ error: 'Logout failed' });
@@ -400,7 +520,7 @@ router.put('/profile', authenticateToken, profileLimiter, async (req, res) => {
if (name && name !== req.user.name) {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(name)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
}
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?) AND id != ?', [name, req.user.id]);
if (existingUsername) {
@@ -453,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` });
}
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]);
res.json({ message: 'Password changed successfully' });
@@ -466,13 +586,13 @@ router.put('/password', authenticateToken, passwordLimiter, async (req, res) =>
// POST /api/auth/avatar - Upload avatar image
router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
try {
// Validate content type
// Validate file content by checking magic bytes (file signatures)
const contentType = req.headers['content-type'];
if (!contentType || !contentType.startsWith('image/')) {
return res.status(400).json({ error: 'Only image files are allowed' });
}
// M15: stream-level size limit abort as soon as 2 MB is exceeded
// M15: stream-level size limit - abort as soon as 2 MB is exceeded
const MAX_AVATAR_SIZE = 2 * 1024 * 1024;
const buffer = await new Promise((resolve, reject) => {
const chunks = [];
@@ -496,7 +616,18 @@ router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
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 filepath = path.join(uploadsDir, filename);
@@ -602,4 +733,125 @@ router.get('/avatar/:filename', (req, 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;

View File

@@ -6,6 +6,7 @@ import { fileURLToPath } from 'url';
import { getDb } from '../config/database.js';
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
import { log } from '../config/logger.js';
import { getOAuthConfig } from '../config/oauth.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -14,6 +15,16 @@ const router = Router();
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
const brandingDir = path.join(__dirname, '..', '..', 'uploads', 'branding');
if (!fs.existsSync(brandingDir)) {
@@ -82,11 +93,34 @@ router.get('/', async (req, res) => {
const defaultTheme = await getSetting('default_theme');
const logoFile = findLogoFile();
const registrationMode = await getSetting('registration_mode');
const imprintUrl = await getSetting('imprint_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({
appName: appName || 'Redlight',
hasLogo: !!logoFile,
logoUrl: logoFile ? '/api/branding/logo' : null,
defaultTheme: defaultTheme || null,
registrationMode: registrationMode || 'open',
imprintUrl: imprintUrl || null,
privacyUrl: privacyUrl || null,
oauthEnabled,
oauthDisplayName,
hideAppName: hideAppName === 'true',
});
} catch (err) {
log.branding.error('Get branding error:', err);
@@ -192,4 +226,82 @@ router.put('/default-theme', authenticateToken, requireAdmin, async (req, res) =
}
});
// PUT /api/branding/registration-mode - Set registration mode (admin only)
router.put('/registration-mode', authenticateToken, requireAdmin, async (req, res) => {
try {
const { registrationMode } = req.body;
if (!registrationMode || !['open', 'invite'].includes(registrationMode)) {
return res.status(400).json({ error: 'registrationMode must be "open" or "invite"' });
}
await setSetting('registration_mode', registrationMode);
res.json({ registrationMode });
} catch (err) {
log.branding.error('Update registration mode error:', err);
res.status(500).json({ error: 'Could not update registration mode' });
}
});
// PUT /api/branding/imprint-url - Set imprint URL (admin only)
router.put('/imprint-url', authenticateToken, requireAdmin, async (req, res) => {
try {
const { imprintUrl } = req.body;
if (imprintUrl && imprintUrl.length > 500) {
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()) {
await setSetting('imprint_url', imprintUrl.trim());
} else {
await deleteSetting('imprint_url');
}
res.json({ imprintUrl: imprintUrl?.trim() || null });
} catch (err) {
log.branding.error('Update imprint URL error:', err);
res.status(500).json({ error: 'Could not update imprint URL' });
}
});
// PUT /api/branding/privacy-url - Set privacy policy URL (admin only)
router.put('/privacy-url', authenticateToken, requireAdmin, async (req, res) => {
try {
const { privacyUrl } = req.body;
if (privacyUrl && privacyUrl.length > 500) {
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()) {
await setSetting('privacy_url', privacyUrl.trim());
} else {
await deleteSetting('privacy_url');
}
res.json({ privacyUrl: privacyUrl?.trim() || null });
} catch (err) {
log.branding.error('Update privacy URL error:', err);
res.status(500).json({ error: 'Could not update privacy URL' });
}
});
// 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;

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;

786
server/routes/calendar.js Normal file
View File

@@ -0,0 +1,786 @@
import { Router } from 'express';
import crypto from 'crypto';
import { getDb } from '../config/database.js';
import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
import { log } from '../config/logger.js';
import { sendCalendarInviteEmail } from '../config/mailer.js';
import {
isFederationEnabled,
getFederationDomain,
signPayload,
verifyPayload,
discoverInstance,
parseAddress,
} from '../config/federation.js';
import { rateLimit } from 'express-rate-limit';
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
const calendarFederationLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests. Please try again later.' },
});
// ── GET /api/calendar/events — List events for the current user ─────────────
router.get('/events', authenticateToken, async (req, res) => {
try {
const db = getDb();
const { from, to } = req.query;
let sql = `
SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name
FROM calendar_events ce
JOIN users u ON ce.user_id = u.id
WHERE (ce.user_id = ? OR ce.id IN (
SELECT event_id FROM calendar_event_shares WHERE user_id = ?
))
`;
const params = [req.user.id, req.user.id];
if (from) {
sql += ' AND ce.end_time >= ?';
params.push(from);
}
if (to) {
sql += ' AND ce.start_time <= ?';
params.push(to);
}
sql += ' ORDER BY ce.start_time ASC';
const events = await db.all(sql, params);
// Mark shared events
for (const ev of events) {
ev.is_owner = ev.user_id === req.user.id;
}
res.json({ events });
} catch (err) {
log.server.error(`Calendar list error: ${err.message}`);
res.status(500).json({ error: 'Events could not be loaded' });
}
});
// ── GET /api/calendar/events/:id — Get single event ─────────────────────────
router.get('/events/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const event = await db.get(`
SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name
FROM calendar_events ce
JOIN users u ON ce.user_id = u.id
WHERE ce.id = ?
`, [req.params.id]);
if (!event) return res.status(404).json({ error: 'Event not found' });
// Check access
if (event.user_id !== req.user.id) {
const share = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, req.user.id]);
if (!share) return res.status(403).json({ error: 'No permission' });
}
// Get shared users
const sharedUsers = await db.all(`
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM calendar_event_shares ces
JOIN users u ON ces.user_id = u.id
WHERE ces.event_id = ?
`, [event.id]);
event.is_owner = event.user_id === req.user.id;
let pendingInvitations = [];
if (event.user_id === req.user.id) {
pendingInvitations = await db.all(`
SELECT cli.id, cli.to_user_id as user_id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM calendar_local_invitations cli
JOIN users u ON cli.to_user_id = u.id
WHERE cli.event_id = ? AND cli.status = 'pending'
`, [event.id]);
}
res.json({ event, sharedUsers, pendingInvitations });
} catch (err) {
log.server.error(`Calendar get event error: ${err.message}`);
res.status(500).json({ error: 'Event could not be loaded' });
}
});
// ── POST /api/calendar/events — Create event ────────────────────────────────
router.post('/events', authenticateToken, async (req, res) => {
try {
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 (!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 (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 endDate = new Date(end_time);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return res.status(400).json({ error: 'Invalid date format' });
}
if (endDate <= startDate) {
return res.status(400).json({ error: 'End time must be after start time' });
}
// Verify room exists if specified
const db = getDb();
if (room_uid) {
const room = await db.get('SELECT id FROM rooms WHERE uid = ?', [room_uid]);
if (!room) return res.status(400).json({ error: 'Linked room not found' });
}
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(`
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, reminder_minutes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
uid,
title.trim(),
description || null,
startDate.toISOString(),
endDate.toISOString(),
room_uid || null,
req.user.id,
color || '#6366f1',
validReminder,
]);
const event = await db.get('SELECT * FROM calendar_events WHERE id = ?', [result.lastInsertRowid]);
res.status(201).json({ event });
} catch (err) {
log.server.error(`Calendar create error: ${err.message}`);
res.status(500).json({ error: 'Event could not be created' });
}
});
// ── PUT /api/calendar/events/:id — Update event ─────────────────────────────
router.put('/events/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
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' });
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 (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) {
const s = new Date(start_time);
const e = new Date(end_time);
if (isNaN(s.getTime()) || isNaN(e.getTime())) return res.status(400).json({ error: 'Invalid date format' });
if (e <= s) return res.status(400).json({ error: 'End time must be after start time' });
}
if (room_uid) {
const room = await db.get('SELECT id FROM rooms WHERE uid = ?', [room_uid]);
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(`
UPDATE calendar_events SET
title = COALESCE(?, title),
description = ?,
start_time = COALESCE(?, start_time),
end_time = COALESCE(?, end_time),
room_uid = ?,
color = COALESCE(?, color),
reminder_minutes = COALESCE(?, reminder_minutes),
reminder_sent_at = ${resetReminder ? 'NULL' : 'reminder_sent_at'},
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, [
title || null,
description !== undefined ? description : event.description,
start_time || null,
end_time || null,
room_uid !== undefined ? (room_uid || null) : event.room_uid,
color || null,
validReminder !== undefined ? validReminder : null,
req.params.id,
]);
const updated = await db.get('SELECT * FROM calendar_events WHERE id = ?', [req.params.id]);
res.json({ event: updated });
} catch (err) {
log.server.error(`Calendar update error: ${err.message}`);
res.status(500).json({ error: 'Event could not be updated' });
}
});
// ── DELETE /api/calendar/events/:id — Delete event ──────────────────────────
router.delete('/events/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
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' });
// Propagate deletion to all remote instances that received this event
if (isFederationEnabled()) {
try {
const outbound = await db.all(
'SELECT remote_domain FROM calendar_event_outbound WHERE event_uid = ?',
[event.uid]
);
for (const { remote_domain } of outbound) {
try {
const payload = {
event_uid: event.uid,
from_user: `@${req.user.name}@${getFederationDomain()}`,
timestamp: new Date().toISOString(),
};
const signature = signPayload(payload);
const { baseUrl: remoteApi } = await discoverInstance(remote_domain);
await fetch(`${remoteApi}/calendar-event-deleted`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Federation-Signature': signature,
'X-Federation-Origin': getFederationDomain(),
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10_000),
});
} catch (remoteErr) {
log.server.warn(`Calendar deletion propagation failed for ${remote_domain}: ${remoteErr.message}`);
}
}
await db.run('DELETE FROM calendar_event_outbound WHERE event_uid = ?', [event.uid]);
} catch (propErr) {
log.server.warn(`Calendar deletion propagation error: ${propErr.message}`);
}
}
await db.run('DELETE FROM calendar_events WHERE id = ?', [req.params.id]);
res.json({ message: 'Event deleted' });
} catch (err) {
log.server.error(`Calendar delete error: ${err.message}`);
res.status(500).json({ error: 'Event could not be deleted' });
}
});
// ── POST /api/calendar/events/:id/share — Invite local user to event ────────
router.post('/events/:id/share', authenticateToken, async (req, res) => {
try {
const { user_id } = req.body;
if (!user_id) return res.status(400).json({ error: 'User ID is required' });
const db = getDb();
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 (user_id === req.user.id) return res.status(400).json({ error: 'Cannot share with yourself' });
const existing = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, user_id]);
if (existing) return res.status(400).json({ error: 'Already shared with this user' });
const pendingCheck = await db.get(
"SELECT id FROM calendar_local_invitations WHERE event_id = ? AND to_user_id = ? AND status = 'pending'",
[event.id, user_id]
);
if (pendingCheck) return res.status(400).json({ error: 'Invitation already pending for this user' });
await db.run(
'INSERT INTO calendar_local_invitations (event_id, from_user_id, to_user_id) VALUES (?, ?, ?)',
[event.id, req.user.id, user_id]
);
// Send notification email (fire-and-forget)
const targetUser = await db.get('SELECT name, display_name, email, language FROM users WHERE id = ?', [user_id]);
if (targetUser?.email) {
const appUrl = getBaseUrl(req);
const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight';
const senderUser = await db.get('SELECT name, display_name FROM users WHERE id = ?', [req.user.id]);
const fromDisplay = (senderUser?.display_name && senderUser.display_name !== '') ? senderUser.display_name : (senderUser?.name || req.user.name);
sendCalendarInviteEmail(
targetUser.email,
(targetUser.display_name && targetUser.display_name !== '') ? targetUser.display_name : targetUser.name,
fromDisplay,
event.title, event.start_time, event.end_time, event.description,
inboxUrl, appName, targetUser.language || 'en'
).catch(mailErr => {
log.server.warn('Calendar local share mail failed (non-fatal):', mailErr.message);
});
}
const sharedUsers = await db.all(`
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM calendar_event_shares ces
JOIN users u ON ces.user_id = u.id
WHERE ces.event_id = ?
`, [event.id]);
const pendingInvitations = await db.all(`
SELECT cli.id, cli.to_user_id as user_id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM calendar_local_invitations cli
JOIN users u ON cli.to_user_id = u.id
WHERE cli.event_id = ? AND cli.status = 'pending'
`, [event.id]);
res.json({ sharedUsers, pendingInvitations });
} catch (err) {
log.server.error(`Calendar share error: ${err.message}`);
res.status(500).json({ error: 'Could not share event' });
}
});
// ── DELETE /api/calendar/events/:id/share/:userId — Remove share or cancel invitation ──
router.delete('/events/:id/share/:userId', authenticateToken, async (req, res) => {
try {
const db = getDb();
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' });
// Remove accepted share
await db.run('DELETE FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, parseInt(req.params.userId)]);
// Also cancel any pending local invitation for this user
await db.run(
"UPDATE calendar_local_invitations SET status = 'declined' WHERE event_id = ? AND to_user_id = ? AND status = 'pending'",
[event.id, parseInt(req.params.userId)]
);
const sharedUsers = await db.all(`
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM calendar_event_shares ces
JOIN users u ON ces.user_id = u.id
WHERE ces.event_id = ?
`, [event.id]);
const pendingInvitations = await db.all(`
SELECT cli.id, cli.to_user_id as user_id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM calendar_local_invitations cli
JOIN users u ON cli.to_user_id = u.id
WHERE cli.event_id = ? AND cli.status = 'pending'
`, [event.id]);
res.json({ sharedUsers, pendingInvitations });
} catch (err) {
log.server.error(`Calendar unshare error: ${err.message}`);
res.status(500).json({ error: 'Could not remove share' });
}
});
// ── GET /api/calendar/local-invitations — List local calendar invitations for current user ──
router.get('/local-invitations', authenticateToken, async (req, res) => {
try {
const db = getDb();
const invitations = await db.all(`
SELECT
cli.id, cli.event_id, cli.status, cli.created_at,
ce.title, ce.start_time, ce.end_time, ce.description, ce.color,
COALESCE(NULLIF(u.display_name,''), u.name) as from_name
FROM calendar_local_invitations cli
JOIN calendar_events ce ON cli.event_id = ce.id
JOIN users u ON cli.from_user_id = u.id
WHERE cli.to_user_id = ?
ORDER BY cli.created_at DESC
`, [req.user.id]);
res.json({ invitations });
} catch (err) {
log.server.error(`Calendar local invitations error: ${err.message}`);
res.status(500).json({ error: 'Could not load invitations' });
}
});
// ── POST /api/calendar/local-invitations/:id/accept — Accept local invitation ──
router.post('/local-invitations/:id/accept', authenticateToken, async (req, res) => {
try {
const db = getDb();
const inv = await db.get(
"SELECT * FROM calendar_local_invitations WHERE id = ? AND to_user_id = ? AND status = 'pending'",
[req.params.id, req.user.id]
);
if (!inv) return res.status(404).json({ error: 'Invitation not found' });
await db.run("UPDATE calendar_local_invitations SET status = 'accepted' WHERE id = ?", [inv.id]);
// Insert into calendar_event_shares so the event appears in the user's calendar
const existingShare = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [inv.event_id, req.user.id]);
if (!existingShare) {
await db.run('INSERT INTO calendar_event_shares (event_id, user_id) VALUES (?, ?)', [inv.event_id, req.user.id]);
}
res.json({ message: 'Invitation accepted' });
} catch (err) {
log.server.error(`Calendar local invitation accept error: ${err.message}`);
res.status(500).json({ error: 'Could not accept invitation' });
}
});
// ── DELETE /api/calendar/local-invitations/:id — Decline/remove local invitation ──
router.delete('/local-invitations/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const inv = await db.get(
'SELECT * FROM calendar_local_invitations WHERE id = ? AND to_user_id = ?',
[req.params.id, req.user.id]
);
if (!inv) return res.status(404).json({ error: 'Invitation not found' });
if (inv.status === 'pending') {
await db.run("UPDATE calendar_local_invitations SET status = 'declined' WHERE id = ?", [inv.id]);
} else {
// Accepted/declined - remove the share too if it was accepted
if (inv.status === 'accepted') {
await db.run('DELETE FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [inv.event_id, req.user.id]);
}
await db.run('DELETE FROM calendar_local_invitations WHERE id = ?', [inv.id]);
}
res.json({ message: 'Invitation removed' });
} catch (err) {
log.server.error(`Calendar local invitation delete error: ${err.message}`);
res.status(500).json({ error: 'Could not remove invitation' });
}
});
// ── GET /api/calendar/events/:id/ics — Download event as ICS ────────────────
router.get('/events/:id/ics', authenticateToken, async (req, res) => {
try {
const db = getDb();
const event = await db.get(`
SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name, u.email as organizer_email
FROM calendar_events ce
JOIN users u ON ce.user_id = u.id
WHERE ce.id = ?
`, [req.params.id]);
if (!event) return res.status(404).json({ error: 'Event not found' });
// Check access
if (event.user_id !== req.user.id) {
const share = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, req.user.id]);
if (!share) return res.status(403).json({ error: 'No permission' });
}
// Build room join URL if linked
const baseUrl = getBaseUrl(req);
let location = '';
if (event.room_uid) {
location = `${baseUrl}/join/${event.room_uid}`;
}
const ics = generateICS(event, location, baseUrl);
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(event.title)}.ics"`);
res.send(ics);
} catch (err) {
log.server.error(`ICS download error: ${err.message}`);
res.status(500).json({ error: 'Could not generate ICS file' });
}
});
// ── POST /api/calendar/events/:id/federation — Send event to remote user ────
router.post('/events/:id/federation', authenticateToken, async (req, res) => {
try {
if (!isFederationEnabled()) {
return res.status(400).json({ error: 'Federation is not configured on this instance' });
}
const { to } = req.body;
if (!to) return res.status(400).json({ error: 'Remote address is required' });
const { username, domain } = parseAddress(to);
if (!domain) return res.status(400).json({ error: 'Remote address must be in format username@domain' });
if (domain === getFederationDomain()) {
return res.status(400).json({ error: 'Cannot send to your own instance. Use local sharing instead.' });
}
const db = getDb();
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' });
const baseUrl = getBaseUrl(req);
let joinUrl = null;
if (event.room_uid) {
joinUrl = `${baseUrl}/join/${event.room_uid}`;
}
const payload = {
type: 'calendar_event',
event_uid: event.uid,
title: event.title,
description: event.description || '',
start_time: event.start_time,
end_time: event.end_time,
room_uid: event.room_uid || null,
join_url: joinUrl,
from_user: `@${req.user.name}@${getFederationDomain()}`,
to_user: to,
timestamp: new Date().toISOString(),
};
const signature = signPayload(payload);
const { baseUrl: remoteApi } = await discoverInstance(domain);
const response = await fetch(`${remoteApi}/calendar-event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Federation-Signature': signature,
'X-Federation-Origin': getFederationDomain(),
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || `Remote server responded with ${response.status}`);
}
// Track outbound send for deletion propagation
try {
await db.run(
`INSERT INTO calendar_event_outbound (event_uid, remote_domain) VALUES (?, ?)
ON CONFLICT(event_uid, remote_domain) DO NOTHING`,
[event.uid, domain]
);
} catch { /* table may not exist yet on upgrade */ }
res.json({ success: true });
} catch (err) {
log.server.error(`Calendar federation send error: ${err.message}`);
res.status(500).json({ error: err.message || 'Could not send event to remote instance' });
}
});
// ── POST /receive-event or /calendar-event — Receive calendar event from remote ──
// '/receive-event' when mounted at /api/calendar
// '/calendar-event' when mounted at /api/federation (for remote instance discovery)
router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, async (req, res) => {
try {
if (!isFederationEnabled()) {
return res.status(400).json({ error: 'Federation is not configured on this instance' });
}
const signature = req.headers['x-federation-signature'];
const payload = req.body || {};
if (!signature) return res.status(401).json({ error: 'Missing federation signature' });
const { event_uid, title, description, start_time, end_time, room_uid, join_url, from_user, to_user } = payload;
if (!event_uid || !title || !start_time || !end_time || !from_user || !to_user) {
return res.status(400).json({ error: 'Incomplete event payload' });
}
// Validate lengths
if (event_uid.length > 100 || title.length > 200 || (description && description.length > 5000) ||
from_user.length > 200 || to_user.length > 200 || (join_url && join_url.length > 2000)) {
return res.status(400).json({ error: 'Payload fields exceed maximum allowed length' });
}
// Verify signature
const { domain: senderDomain } = parseAddress(from_user);
if (!senderDomain) return res.status(400).json({ error: 'Sender address must include a domain' });
const { publicKey } = await discoverInstance(senderDomain);
if (!publicKey) return res.status(400).json({ error: 'Sender instance did not provide a public key' });
if (!verifyPayload(payload, signature, publicKey)) {
return res.status(403).json({ error: 'Invalid federation signature' });
}
// Find local user
const { username } = parseAddress(to_user);
const db = getDb();
const targetUser = await db.get('SELECT id, name, email, language FROM users WHERE LOWER(name) = LOWER(?)', [username]);
if (!targetUser) return res.status(404).json({ error: 'User not found on this instance' });
// Check duplicate (already in invitations or already accepted into calendar)
const existingInv = await db.get('SELECT id FROM calendar_invitations WHERE event_uid = ? AND to_user_id = ?', [event_uid, targetUser.id]);
if (existingInv) return res.json({ success: true, message: 'Calendar invitation already received' });
// Store as pending invitation — user must accept before it appears in calendar
await db.run(`
INSERT INTO calendar_invitations (event_uid, title, description, start_time, end_time, room_uid, join_url, from_user, to_user_id, color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
event_uid,
title,
description || null,
start_time,
end_time,
room_uid || null,
join_url || null,
from_user,
targetUser.id,
'#6366f1',
]);
// Send notification email (fire-and-forget)
if (targetUser.email) {
const appUrl = getBaseUrl(req);
const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight';
sendCalendarInviteEmail(
targetUser.email, targetUser.name, from_user,
title, start_time, end_time, description || null,
inboxUrl, appName, targetUser.language || 'en'
).catch(mailErr => {
log.server.warn('Calendar invite mail failed (non-fatal):', mailErr.message);
});
}
res.json({ success: true });
} catch (err) {
log.server.error(`Calendar federation receive error: ${err.message}`);
res.status(500).json({ error: 'Failed to process calendar event' });
}
});
// ── Helper: Generate ICS content ────────────────────────────────────────────
function generateICS(event, location, prodIdDomain) {
const formatDate = (dateStr) => {
const d = new Date(dateStr);
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
};
const escapeICS = (str) => {
if (!str) return '';
return str.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
};
const now = formatDate(new Date().toISOString());
const dtStart = formatDate(event.start_time);
const dtEnd = formatDate(event.end_time);
let ics = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
`PRODID:-//${prodIdDomain}//Redlight Calendar//EN`,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
`UID:${event.uid}@${prodIdDomain}`,
`DTSTAMP:${now}`,
`DTSTART:${dtStart}`,
`DTEND:${dtEnd}`,
`SUMMARY:${escapeICS(event.title)}`,
];
if (event.description) {
ics.push(`DESCRIPTION:${escapeICS(event.description)}`);
}
if (location) {
ics.push(`LOCATION:${escapeICS(location)}`);
ics.push(`URL:${location}`);
}
if (event.organizer_name && 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');
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;

View File

@@ -2,17 +2,18 @@ import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { rateLimit } from 'express-rate-limit';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
import { sendFederationInviteEmail } from '../config/mailer.js';
import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js';
import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js';
// M13: rate limit the unauthenticated federation receive endpoint
const federationReceiveLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many federation requests. Please try again later.' },
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many federation requests. Please try again later.' },
});
import {
@@ -39,7 +40,7 @@ export function wellKnownHandler(req, res) {
federation_api: '/api/federation',
public_key: getPublicKey(),
software: 'Redlight',
version: '1.2.1',
version: '2.1.1',
});
}
@@ -82,8 +83,11 @@ router.post('/invite', authenticateToken, async (req, res) => {
}
// Build guest join URL for the remote user
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const joinUrl = `${baseUrl}/join/${room.uid}`;
// If the room has an access code, embed it so the recipient can join without manual entry
const baseUrl = getBaseUrl(req);
const joinUrl = room.access_code
? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}`
: `${baseUrl}/join/${room.uid}`;
// Build invitation payload
const inviteId = uuidv4();
@@ -157,6 +161,16 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
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
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)) {
@@ -220,19 +234,28 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
} catch { /* column may not exist on very old installs */ }
}
// 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) {
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight';
sendFederationInviteEmail(
targetUser.email, targetUser.name, from_user,
room_name, message || null, inboxUrl, appName
).catch(mailErr => {
log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message);
});
const appUrl = getBaseUrl(req);
const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight';
sendFederationInviteEmail(
targetUser.email, targetUser.name, from_user,
room_name, message || null, inboxUrl, appName, targetUser.language || 'en'
).catch(mailErr => {
log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message);
});
}
// In-app notification
await createNotification(
targetUser.id,
'federation_invite_received',
from_user,
room_name,
'/federation/inbox',
);
res.json({ success: true });
} catch (err) {
log.federation.error('Federation receive error:', err);
@@ -261,12 +284,28 @@ router.get('/invitations', authenticateToken, async (req, res) => {
router.get('/invitations/pending-count', authenticateToken, async (req, res) => {
try {
const db = getDb();
const result = await db.get(
const roomResult = await db.get(
`SELECT COUNT(*) as count FROM federation_invitations
WHERE to_user_id = ? AND status = 'pending'`,
[req.user.id]
);
res.json({ count: result?.count || 0 });
let calResult = { count: 0 };
try {
calResult = await db.get(
`SELECT COUNT(*) as count FROM calendar_invitations
WHERE to_user_id = ? AND status = 'pending'`,
[req.user.id]
);
} catch { /* table may not exist yet */ }
let localCalResult = { count: 0 };
try {
localCalResult = await db.get(
`SELECT COUNT(*) as count FROM calendar_local_invitations
WHERE to_user_id = ? AND status = 'pending'`,
[req.user.id]
);
} catch { /* table may not exist yet */ }
res.json({ count: (roomResult?.count || 0) + (calResult?.count || 0) + (localCalResult?.count || 0) });
} catch (err) {
res.json({ count: 0 });
}
@@ -338,6 +377,94 @@ router.delete('/invitations/:id', authenticateToken, async (req, res) => {
}
});
// ── GET /api/federation/calendar-invitations — List calendar invitations ─────
router.get('/calendar-invitations', authenticateToken, async (req, res) => {
try {
const db = getDb();
const invitations = await db.all(
`SELECT * FROM calendar_invitations
WHERE to_user_id = ?
ORDER BY created_at DESC`,
[req.user.id]
);
res.json({ invitations });
} catch (err) {
log.federation.error('List calendar invitations error:', err);
res.status(500).json({ error: 'Failed to load calendar invitations' });
}
});
// ── POST /api/federation/calendar-invitations/:id/accept ─────────────────────
router.post('/calendar-invitations/:id/accept', authenticateToken, async (req, res) => {
try {
const db = getDb();
const inv = await db.get(
`SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`,
[req.params.id, req.user.id]
);
if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' });
if (inv.status === 'accepted') return res.status(400).json({ error: 'Already accepted' });
await db.run(
`UPDATE calendar_invitations SET status = 'accepted' WHERE id = ?`,
[inv.id]
);
// Check if event was already previously accepted (duplicate guard)
const existing = await db.get(
'SELECT id FROM calendar_events WHERE uid = ? AND user_id = ?',
[inv.event_uid, req.user.id]
);
if (!existing) {
await db.run(`
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, federated_from, federated_join_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
inv.event_uid,
inv.title,
inv.description || null,
inv.start_time,
inv.end_time,
inv.room_uid || null,
req.user.id,
inv.color || '#6366f1',
inv.from_user,
inv.join_url || null,
]);
}
res.json({ success: true });
} catch (err) {
log.federation.error('Accept calendar invitation error:', err);
res.status(500).json({ error: 'Failed to accept calendar invitation' });
}
});
// ── DELETE /api/federation/calendar-invitations/:id — Decline/dismiss ────────
router.delete('/calendar-invitations/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const inv = await db.get(
`SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`,
[req.params.id, req.user.id]
);
if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' });
if (inv.status === 'pending') {
// mark as declined
await db.run(`UPDATE calendar_invitations SET status = 'declined' WHERE id = ?`, [inv.id]);
} else {
// accepted or declined — permanently remove from inbox
await db.run('DELETE FROM calendar_invitations WHERE id = ?', [inv.id]);
}
res.json({ success: true });
} catch (err) {
log.federation.error('Delete calendar invitation error:', err);
res.status(500).json({ error: 'Failed to remove calendar invitation' });
}
});
// ── GET /api/federation/federated-rooms — List saved federated rooms ────────
router.get('/federated-rooms', authenticateToken, async (req, res) => {
try {
@@ -423,6 +550,99 @@ router.post('/room-sync', federationReceiveLimiter, async (req, res) => {
}
});
// ── POST /api/federation/calendar-event-deleted — Receive calendar deletion ─
router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res) => {
try {
if (!isFederationEnabled()) {
return res.status(400).json({ error: 'Federation is not configured on this instance' });
}
const signature = req.headers['x-federation-signature'];
const originDomain = req.headers['x-federation-origin'];
const payload = req.body || {};
if (!signature || !originDomain) {
return res.status(401).json({ error: 'Missing federation signature or origin' });
}
const { publicKey } = await discoverInstance(originDomain);
if (!publicKey || !verifyPayload(payload, signature, publicKey)) {
return res.status(403).json({ error: 'Invalid federation signature' });
}
const { event_uid } = payload;
if (!event_uid || typeof event_uid !== 'string') {
return res.status(400).json({ error: 'event_uid is required' });
}
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)
let affectedUsers = [];
try {
// Users with pending/declined invitations
const invUsers = await db.all(
`SELECT u.email, u.name, u.language, ci.title, ci.from_user
FROM calendar_invitations ci
JOIN users u ON ci.to_user_id = u.id
WHERE ci.event_uid = ? AND ci.from_user LIKE ? ESCAPE '\\'`,
[event_uid, `%@${safeDomain}`]
);
// Users who already accepted (event in their calendar)
const calUsers = await db.all(
`SELECT u.email, u.name, u.language, ce.title, ce.federated_from AS from_user
FROM calendar_events ce
JOIN users u ON ce.user_id = u.id
WHERE ce.uid = ? AND ce.federated_from LIKE ? ESCAPE '\\'`,
[event_uid, `%@${safeDomain}`]
);
// Merge, deduplicate by email
const seen = new Set();
for (const row of [...invUsers, ...calUsers]) {
if (row.email && !seen.has(row.email)) {
seen.add(row.email);
affectedUsers.push(row);
}
}
} catch { /* non-fatal */ }
// Remove from calendar_invitations for all users on this instance
await db.run(
`DELETE FROM calendar_invitations
WHERE event_uid = ? AND from_user LIKE ? ESCAPE '\\'`,
[event_uid, `%@${safeDomain}`]
);
// Remove from calendar_events (accepted invitations) for all users on this instance
await db.run(
`DELETE FROM calendar_events
WHERE uid = ? AND federated_from LIKE ? ESCAPE '\\'`,
[event_uid, `%@${safeDomain}`]
);
log.federation.info(`Calendar event ${event_uid} deleted (origin: ${originDomain})`);
// Notify affected users by email (fire-and-forget)
if (affectedUsers.length > 0) {
const appName = process.env.APP_NAME || 'Redlight';
for (const u of affectedUsers) {
sendCalendarEventDeletedEmail(u.email, u.name, u.from_user, u.title, appName, u.language || 'en')
.catch(mailErr => {
log.federation.warn(`Calendar deletion mail to ${u.email} failed (non-fatal): ${mailErr.message}`);
});
}
}
res.json({ success: true });
} catch (err) {
log.federation.error('Calendar-event-deleted error:', err);
res.status(500).json({ error: 'Failed to process calendar event deletion' });
}
});
// ── POST /api/federation/room-deleted — Receive deletion notification ───────
// Origin instance pushes this to notify that a room has been deleted.
router.post('/room-deleted', federationReceiveLimiter, async (req, res) => {
@@ -450,11 +670,13 @@ router.post('/room-deleted', federationReceiveLimiter, async (req, res) => {
}
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
await db.run(
`UPDATE federated_rooms SET deleted = 1, updated_at = CURRENT_TIMESTAMP
WHERE meet_id = ? AND from_user LIKE ?`,
[room_uid, `%@${originDomain}`]
WHERE meet_id = ? AND from_user LIKE ? ESCAPE '\\'`,
[room_uid, `%@${safeDomain}`]
);
log.federation.info(`Room ${room_uid} marked as deleted (origin: ${originDomain})`);

View File

@@ -0,0 +1,74 @@
import { Router } from 'express';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
const router = Router();
// GET /api/notifications — List recent notifications for the current user
router.get('/', authenticateToken, async (req, res) => {
try {
const db = getDb();
const notifications = await db.all(
`SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50`,
[req.user.id],
);
const unreadCount = notifications.filter(n => !n.read).length;
res.json({ notifications, unreadCount });
} catch {
res.status(500).json({ error: 'Failed to load notifications' });
}
});
// POST /api/notifications/read-all — Mark all notifications as read
// NOTE: Must be declared before /:id/read to avoid routing collision
router.post('/read-all', authenticateToken, async (req, res) => {
try {
const db = getDb();
await db.run('UPDATE notifications SET read = 1 WHERE user_id = ?', [req.user.id]);
res.json({ success: true });
} catch {
res.status(500).json({ error: 'Failed to update notifications' });
}
});
// POST /api/notifications/:id/read — Mark a single notification as read
router.post('/:id/read', authenticateToken, async (req, res) => {
try {
const db = getDb();
await db.run(
'UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?',
[req.params.id, req.user.id],
);
res.json({ success: true });
} catch {
res.status(500).json({ error: 'Failed to update notification' });
}
});
// DELETE /api/notifications/all — Delete all notifications for current user
// NOTE: Declared before /:id to avoid routing collision
router.delete('/all', authenticateToken, async (req, res) => {
try {
const db = getDb();
await db.run('DELETE FROM notifications WHERE user_id = ?', [req.user.id]);
res.json({ success: true });
} catch {
res.status(500).json({ error: 'Failed to delete notifications' });
}
});
// DELETE /api/notifications/:id — Delete a single notification
router.delete('/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
await db.run(
'DELETE FROM notifications WHERE id = ? AND user_id = ?',
[req.params.id, req.user.id],
);
res.json({ success: true });
} catch {
res.status(500).json({ error: 'Failed to delete notification' });
}
});
export default router;

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

@@ -1,18 +1,20 @@
import { Router } from 'express';
import { Router } from 'express';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { rateLimit } from 'express-rate-limit';
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 { createNotification } from '../config/notifications.js';
import {
createMeeting,
joinMeeting,
endMeeting,
getMeetingInfo,
isMeetingRunning,
getAnalyticsToken,
} from '../config/bbb.js';
import {
isFederationEnabled,
@@ -48,7 +50,7 @@ const router = Router();
// Build avatar URL for a user (uploaded image or generated initials)
function getUserAvatarURL(req, user) {
const baseUrl = `${req.protocol}://${req.get('host')}`;
const baseUrl = getBaseUrl(req);
if (user.avatar_image) {
return `${baseUrl}/api/auth/avatar/${user.avatar_image}`;
}
@@ -165,6 +167,9 @@ router.post('/', authenticateToken, async (req, res) => {
if (!name || name.trim().length === 0) {
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
if (name.trim().length > 100) {
@@ -239,9 +244,14 @@ router.put('/:uid', authenticateToken, async (req, res) => {
record_meeting,
guest_access,
moderator_code,
learning_analytics,
analytics_visibility,
} = req.body;
// 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) {
return res.status(400).json({ error: 'Room name must not exceed 100 characters' });
}
@@ -275,6 +285,8 @@ router.put('/:uid', authenticateToken, async (req, res) => {
record_meeting = COALESCE(?, record_meeting),
guest_access = COALESCE(?, guest_access),
moderator_code = ?,
learning_analytics = COALESCE(?, learning_analytics),
analytics_visibility = COALESCE(?, analytics_visibility),
updated_at = CURRENT_TIMESTAMP
WHERE uid = ?
`, [
@@ -289,6 +301,8 @@ router.put('/:uid', authenticateToken, async (req, res) => {
record_meeting !== undefined ? (record_meeting ? 1 : 0) : null,
guest_access !== undefined ? (guest_access ? 1 : 0) : null,
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,
]);
@@ -402,6 +416,15 @@ router.post('/:uid/shares', authenticateToken, async (req, res) => {
JOIN users u ON rs.user_id = u.id
WHERE rs.room_id = ?
`, [room.id]);
// Notify the user who was given access
const sharerName = req.user.display_name || req.user.name;
await createNotification(
user_id,
'room_share_added',
room.name,
sharerName,
`/rooms/${room.uid}`,
);
res.json({ shares });
} catch (err) {
log.rooms.error(`Share room error: ${err.message}`);
@@ -417,13 +440,22 @@ router.delete('/:uid/shares/:userId', authenticateToken, async (req, res) => {
if (!room) {
return res.status(404).json({ error: 'Room not found or no permission' });
}
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, parseInt(req.params.userId)]);
const removedUserId = parseInt(req.params.userId);
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, removedUserId]);
const shares = await db.all(`
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM room_shares rs
JOIN users u ON rs.user_id = u.id
WHERE rs.room_id = ?
`, [room.id]);
// Notify the user whose access was removed
await createNotification(
removedUserId,
'room_share_removed',
room.name,
null,
'/dashboard',
);
res.json({ shares });
} catch (err) {
log.rooms.error(`Remove share error: ${err.message}`);
@@ -450,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 presentationUrl = room.presentation_file
? `${baseUrl}/uploads/presentations/${room.presentation_file}`
: 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 displayName = req.user.display_name || req.user.name;
const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL);
@@ -598,9 +633,12 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
// If meeting not running but anyone_can_start, create it
if (!running && room.anyone_can_start) {
const baseUrl = `${req.protocol}://${req.get('host')}`;
const baseUrl = getBaseUrl(req);
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
@@ -609,7 +647,7 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
isModerator = true;
}
const baseUrl = `${req.protocol}://${req.get('host')}`;
const baseUrl = getBaseUrl(req);
const guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL);
res.json({ joinUrl });
@@ -648,7 +686,7 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => {
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
if (!room) return res.status(404).json({ error: 'Room not found or no permission' });
// M16: stream-level size limit abort as soon as 50 MB is exceeded
// M16: stream-level size limit - abort as soon as 50 MB is exceeded
const MAX_PRESENTATION_SIZE = 50 * 1024 * 1024;
const buffer = await new Promise((resolve, reject) => {
const chunks = [];
@@ -684,6 +722,15 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => {
const ext = extMap[contentType];
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)
const rawName = req.headers['x-filename'];
const originalName = rawName

View File

@@ -16,6 +16,9 @@ import Admin from './pages/Admin';
import GuestJoin from './pages/GuestJoin';
import FederationInbox from './pages/FederationInbox';
import FederatedRoomDetail from './pages/FederatedRoomDetail';
import Calendar from './pages/Calendar';
import OAuthCallback from './pages/OAuthCallback';
import NotFound from './pages/NotFound';
export default function App() {
const { user, loading } = useAuth();
@@ -49,11 +52,13 @@ export default function App() {
<Route path="/login" element={user ? <Navigate to="/dashboard" /> : <Login />} />
<Route path="/register" element={user ? <Navigate to="/dashboard" /> : <Register />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/oauth/callback" element={<OAuthCallback />} />
<Route path="/join/:uid" element={<GuestJoin />} />
{/* Protected routes */}
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/calendar" element={<Calendar />} />
<Route path="/rooms/:uid" element={<RoomDetail />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} />
@@ -61,8 +66,8 @@ export default function App() {
<Route path="/federation/rooms/:id" element={<FederatedRoomDetail />} />
</Route>
{/* Catch all */}
<Route path="*" element={<Navigate to="/" />} />
{/* 404 */}
<Route path="*" element={<NotFound />} />
</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';
const sizes = {
sm: { box: 'w-8 h-8', rounded: 'rounded-lg', icon: 16, text: 'text-lg' },
md: { box: 'w-9 h-9', rounded: 'rounded-lg', icon: 20, text: 'text-xl' },
lg: { box: 'w-10 h-10', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' },
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', h: 'h-12', maxW: 'max-w-[10rem]', rounded: 'rounded-lg', icon: 20, text: 'text-xl' },
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 = '' }) {
const { appName, hasLogo, logoUrl } = useBranding();
const { appName, hasLogo, logoUrl, hideAppName } = useBranding();
const s = sizes[size] || sizes.md;
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 (
<div className={`flex items-center gap-2.5 ${className}`}>
<img
src={logoUrl}
alt={appName}
className={`${s.box} ${s.rounded} object-contain`}
/>
<span className={`${s.text} font-bold gradient-text`}>{appName}</span>
<div className={`flex items-center ${hideAppName ? 'justify-center' : 'gap-2.5'} ${className}`}>
<img src={logoUrl} alt={appName} className={imgClass} />
{!hideAppName && <span className={`${s.text} font-bold gradient-text`}>{appName}</span>}
</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 (
<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={`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 */}
<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>
<button
onClick={onClose}

View File

@@ -4,6 +4,7 @@ import { useLanguage } from '../contexts/LanguageContext';
import { useNavigate } from 'react-router-dom';
import { useState, useRef, useEffect } from 'react';
import api from '../services/api';
import NotificationBell from './NotificationBell';
export default function Navbar({ onMenuClick }) {
const { user, logout } = useAuth();
@@ -51,6 +52,9 @@ export default function Navbar({ onMenuClick }) {
{/* Right section */}
<div className="flex items-center gap-2">
{/* Notification bell */}
<NotificationBell />
{/* User dropdown */}
<div className="relative" ref={dropdownRef}>
<button

View File

@@ -0,0 +1,191 @@
import { useRef, useState, useEffect } from 'react';
import { Bell, BellOff, CheckCheck, ExternalLink, Trash2, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useNotifications } from '../contexts/NotificationContext';
import { useLanguage } from '../contexts/LanguageContext';
function timeAgo(dateStr, lang) {
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60_000);
const h = Math.floor(diff / 3_600_000);
const d = Math.floor(diff / 86_400_000);
if (lang === 'de') {
if (m < 1) return 'gerade eben';
if (m < 60) return `vor ${m} Min.`;
if (h < 24) return `vor ${h} Std.`;
return `vor ${d} Tag${d !== 1 ? 'en' : ''}`;
}
if (m < 1) return 'just now';
if (m < 60) return `${m}m ago`;
if (h < 24) return `${h}h ago`;
return `${d}d ago`;
}
function notificationIcon(type) {
switch (type) {
case 'room_share_added': return '🔗';
case 'room_share_removed': return '🚫';
case 'federation_invite_received': return '📩';
default: return '🔔';
}
}
function notificationSubtitle(n, t, lang) {
switch (n.type) {
case 'room_share_added':
return n.body
? (lang === 'de' ? `Geteilt von ${n.body}` : `Shared by ${n.body}`)
: t('notifications.roomShareAdded');
case 'room_share_removed':
return t('notifications.roomShareRemoved');
case 'federation_invite_received':
return n.body
? (lang === 'de' ? `Raum: ${n.body}` : `Room: ${n.body}`)
: t('notifications.federationInviteReceived');
default:
return n.body || '';
}
}
export default function NotificationBell() {
const { notifications, unreadCount, markRead, markAllRead, deleteNotification, clearAll } = useNotifications();
const { t, language } = useLanguage();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const containerRef = useRef(null);
useEffect(() => {
function handleOutsideClick(e) {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleOutsideClick);
return () => document.removeEventListener('mousedown', handleOutsideClick);
}, []);
const handleNotificationClick = async (n) => {
if (!n.read) await markRead(n.id);
if (n.link) navigate(n.link);
setOpen(false);
};
const handleDelete = async (e, id) => {
e.stopPropagation();
await deleteNotification(id);
};
const recent = notifications.slice(0, 20);
return (
<div className="relative" ref={containerRef}>
{/* Bell button */}
<button
onClick={() => setOpen(prev => !prev)}
className="relative p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
title={t('notifications.bell')}
>
<Bell size={20} />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 min-w-[16px] h-4 px-0.5 flex items-center justify-center rounded-full bg-th-accent text-th-accent-t text-[10px] font-bold leading-none">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* Dropdown */}
{open && (
<div className="absolute right-0 mt-2 w-80 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden z-50">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-th-border">
<div className="flex items-center gap-2">
<Bell size={16} className="text-th-accent" />
<span className="text-sm font-semibold text-th-text">{t('notifications.bell')}</span>
{unreadCount > 0 && (
<span className="px-1.5 py-0.5 rounded-full bg-th-accent text-th-accent-t text-xs font-bold">
{unreadCount}
</span>
)}
</div>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
title={t('notifications.markAllRead')}
>
<CheckCheck size={14} />
{t('notifications.markAllRead')}
</button>
)}
{notifications.length > 0 && (
<button
onClick={clearAll}
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-error transition-colors"
title={t('notifications.clearAll')}
>
<Trash2 size={13} />
{t('notifications.clearAll')}
</button>
)}
</div>
</div>
{/* List */}
<div className="max-h-80 overflow-y-auto">
{recent.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-th-text-s gap-2">
<BellOff size={24} />
<span className="text-sm">{t('notifications.noNotifications')}</span>
</div>
) : (
<ul>
{recent.map(n => (
<li
key={n.id}
onClick={() => handleNotificationClick(n)}
className={`group flex items-start gap-3 px-4 py-3 cursor-pointer transition-colors border-b border-th-border/50 last:border-0
${n.read ? 'hover:bg-th-hover' : 'bg-th-accent/5 hover:bg-th-accent/10'}`}
>
{/* Icon */}
<span className="text-lg flex-shrink-0 mt-0.5">{notificationIcon(n.type)}</span>
{/* Content */}
<div className="flex-1 min-w-0">
<p className={`text-sm truncate ${n.read ? 'text-th-text-s' : 'text-th-text font-medium'}`}>
{n.title}
</p>
<p className="text-xs text-th-text-s truncate">
{notificationSubtitle(n, t, language)}
</p>
<p className="text-xs text-th-text-s/70 mt-0.5">
{timeAgo(n.created_at, language)}
</p>
</div>
{/* Right side: unread dot, link icon, delete button */}
<div className="flex flex-col items-end gap-1 flex-shrink-0">
{!n.read && (
<span className="w-2 h-2 rounded-full bg-th-accent mt-1" />
)}
{n.link && (
<ExternalLink size={12} className="text-th-text-s/50" />
)}
<button
onClick={(e) => handleDelete(e, n.id)}
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:text-th-error transition-all text-th-text-s/50"
title={t('notifications.delete')}
>
<X size={13} />
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -122,9 +122,9 @@ export default function RecordingList({ recordings, onRefresh }) {
href={format.url}
target="_blank"
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}
</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 { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import api from '../services/api';
import { useLanguage } from '../contexts/LanguageContext';
import toast from 'react-hot-toast';
@@ -10,6 +10,24 @@ export default function RoomCard({ room, onDelete }) {
const { t } = useLanguage();
const [status, setStatus] = useState({ running: false, participantCount: 0 });
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(() => {
const checkStatus = async () => {
@@ -69,7 +87,7 @@ export default function RoomCard({ room, onDelete }) {
</div>
{/* 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
onClick={async (e) => {
e.stopPropagation();
@@ -99,6 +117,33 @@ export default function RoomCard({ room, onDelete }) {
{starting ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
{status.running ? t('room.join') : t('room.startMeeting')}
</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 && (
<button
onClick={(e) => { e.stopPropagation(); onDelete(room); }}

View File

@@ -1,8 +1,9 @@
import { NavLink } from 'react-router-dom';
import { LayoutDashboard, Settings, Shield, X, Palette, Globe } from 'lucide-react';
import { LayoutDashboard, Settings, Shield, X, Palette, Globe, CalendarDays, FileText, Lock } from 'lucide-react';
import BrandLogo from './BrandLogo';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useBranding } from '../contexts/BrandingContext';
import ThemeSelector from './ThemeSelector';
import { useState, useEffect } from 'react';
import api from '../services/api';
@@ -10,6 +11,7 @@ import api from '../services/api';
export default function Sidebar({ open, onClose }) {
const { user } = useAuth();
const { t } = useLanguage();
const { imprintUrl, privacyUrl } = useBranding();
const [themeOpen, setThemeOpen] = useState(false);
const [federationCount, setFederationCount] = useState(0);
@@ -30,6 +32,7 @@ export default function Sidebar({ open, onClose }) {
const navItems = [
{ to: '/dashboard', icon: LayoutDashboard, label: t('nav.dashboard') },
{ to: '/calendar', icon: CalendarDays, label: t('nav.calendar') },
{ to: '/federation/inbox', icon: Globe, label: t('nav.federation'), badge: federationCount },
{ to: '/settings', icon: Settings, label: t('nav.settings') },
];
@@ -54,7 +57,7 @@ export default function Sidebar({ open, onClose }) {
<div className="flex flex-col h-full">
{/* Logo */}
<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
onClick={onClose}
className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
@@ -103,16 +106,55 @@ export default function Sidebar({ open, onClose }) {
<div className="p-4 border-t border-th-border">
<div className="flex items-center gap-3">
<div
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0"
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0 overflow-hidden"
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
>
{(user?.display_name || user?.name)?.[0]?.toUpperCase() || '?'}
{user?.avatar_image ? (
<img
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
alt="Avatar"
className="w-full h-full object-cover"
/>
) : (
(user?.display_name || user?.name)?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) || '?'
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-th-text truncate">{user?.display_name || user?.name}</p>
<p className="text-xs text-th-text-s truncate">@{user?.name}</p>
</div>
</div>
{/* Imprint / Privacy Policy links */}
{(imprintUrl || privacyUrl) && (
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-th-border/60">
{imprintUrl && (
<a
href={imprintUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
>
<FileText size={11} />
{t('nav.imprint')}
</a>
)}
{imprintUrl && privacyUrl && (
<span className="text-th-border text-xs">·</span>
)}
{privacyUrl && (
<a
href={privacyUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
>
<Lock size={11} />
{t('nav.privacy')}
</a>
)}
</div>
)}
</div>
</div>
</aside>

View File

@@ -23,13 +23,25 @@ export function AuthProvider({ children }) {
const login = useCallback(async (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 register = useCallback(async (username, displayName, email, password) => {
const res = await api.post('/auth/register', { username, display_name: displayName, email, password });
const verify2FA = useCallback(async (tempToken, code) => {
const res = await api.post('/auth/login/2fa', { tempToken, code });
localStorage.setItem('token', res.data.token);
setUser(res.data.user);
return res.data.user;
}, []);
const register = useCallback(async (username, displayName, email, password, inviteToken) => {
const payload = { username, display_name: displayName, email, password };
if (inviteToken) payload.invite_token = inviteToken;
const res = await api.post('/auth/register', payload);
if (res.data.needsVerification) {
return { needsVerification: true };
}
@@ -39,21 +51,41 @@ export function AuthProvider({ children }) {
}, []);
const logout = useCallback(async () => {
let keycloakLogoutUrl = null;
try {
await api.post('/auth/logout');
const res = await api.post('/auth/logout');
keycloakLogoutUrl = res.data?.keycloakLogoutUrl || null;
} catch {
// ignore — token is removed locally regardless
}
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);
}, []);
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) => {
setUser(updatedUser);
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, register, logout, updateUser }}>
<AuthContext.Provider value={{ user, loading, login, verify2FA, register, logout, loginWithOAuth, updateUser }}>
{children}
</AuthContext.Provider>
);

View File

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

View File

@@ -0,0 +1,189 @@
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
import toast from 'react-hot-toast';
import { useAuth } from './AuthContext';
import api from '../services/api';
// Lazily created Audio instance — reused across calls to avoid memory churn
let _audio = null;
let _audioUnlocked = false;
function getAudio() {
if (!_audio) {
_audio = new Audio('/sounds/notification.mp3');
_audio.volume = 0.5;
}
return _audio;
}
/** Called once on the first user gesture to silently play→pause the element,
* which "unlocks" it so later timer-based .play() calls are not blocked. */
function unlockAudio() {
if (_audioUnlocked) return;
_audioUnlocked = true;
const audio = getAudio();
audio.muted = true;
audio.play().then(() => {
audio.pause();
audio.muted = false;
audio.currentTime = 0;
}).catch(() => {
audio.muted = false;
});
}
function playNotificationSound() {
try {
const audio = getAudio();
audio.currentTime = 0;
audio.play().catch(() => {
// Autoplay still blocked — silent fail
});
} catch {
// Ignore any other errors (e.g. unsupported format)
}
}
const NotificationContext = createContext();
export function NotificationProvider({ children }) {
const { user } = useAuth();
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const activeUserId = useRef(null);
// Track seen IDs to detect genuinely new arrivals and show toasts
const seenIds = useRef(new Set());
const initialized = useRef(false);
const fetch = useCallback(async () => {
const requestUserId = user?.id;
if (!requestUserId) return;
try {
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 || [];
setNotifications(incoming);
setUnreadCount(res.data.unreadCount || 0);
// First fetch: just seed the seen-set without toasting
if (!initialized.current) {
incoming.forEach(n => seenIds.current.add(n.id));
initialized.current = true;
return;
}
// Subsequent fetches: toast new unread notifications
const newItems = incoming.filter(n => !n.read && !seenIds.current.has(n.id));
if (newItems.length > 0) {
playNotificationSound();
}
newItems.forEach(n => {
seenIds.current.add(n.id);
const icon = notificationIcon(n.type);
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 {
/* silent server may not be reachable */
}
}, [user]);
// Unlock audio playback only for authenticated sessions.
// This avoids any audio interaction while logged out (e.g. anonymous/incognito tabs).
useEffect(() => {
if (!user?.id) return;
const events = ['click', 'keydown', 'pointerdown'];
const handler = () => {
unlockAudio();
events.forEach(e => window.removeEventListener(e, handler));
};
events.forEach(e => window.addEventListener(e, handler, { once: true }));
return () => events.forEach(e => window.removeEventListener(e, handler));
}, [user?.id]);
useEffect(() => {
activeUserId.current = user?.id ?? null;
if (!user) {
_audioUnlocked = false;
setNotifications([]);
setUnreadCount(0);
seenIds.current = new Set();
initialized.current = false;
return;
}
fetch();
const interval = setInterval(fetch, 30_000);
return () => clearInterval(interval);
}, [user, fetch]);
const markRead = async (id) => {
try {
await api.post(`/notifications/${id}/read`);
setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, read: 1 } : n)),
);
setUnreadCount(prev => Math.max(0, prev - 1));
} catch { /* silent */ }
};
const markAllRead = async () => {
try {
await api.post('/notifications/read-all');
setNotifications(prev => prev.map(n => ({ ...n, read: 1 })));
setUnreadCount(0);
} catch { /* silent */ }
};
const deleteNotification = async (id) => {
try {
await api.delete(`/notifications/${id}`);
setNotifications(prev => {
const removed = prev.find(n => n.id === id);
if (removed && !removed.read) setUnreadCount(c => Math.max(0, c - 1));
return prev.filter(n => n.id !== id);
});
seenIds.current.delete(id);
} catch { /* silent */ }
};
const clearAll = async () => {
try {
await api.delete('/notifications/all');
setNotifications([]);
setUnreadCount(0);
seenIds.current = new Set();
} catch { /* silent */ }
};
return (
<NotificationContext.Provider value={{ notifications, unreadCount, markRead, markAllRead, deleteNotification, clearAll, refresh: fetch }}>
{children}
</NotificationContext.Provider>
);
}
export function useNotifications() {
const ctx = useContext(NotificationContext);
if (!ctx) throw new Error('useNotifications must be used within NotificationProvider');
return ctx;
}
function notificationIcon(type) {
switch (type) {
case 'room_share_added': return '🔗';
case 'room_share_removed': return '🚫';
case 'federation_invite_received': return '📩';
case 'calendar_reminder': return '🔔';
default: return '🔔';
}
}

View File

@@ -32,7 +32,10 @@
"appearance": "Darstellung",
"changeTheme": "Theme ändern",
"navigation": "Navigation",
"federation": "Einladungen"
"calendar": "Kalender",
"federation": "Einladungen",
"imprint": "Impressum",
"privacy": "Datenschutz"
},
"auth": {
"login": "Anmelden",
@@ -75,21 +78,39 @@
"emailNotVerified": "E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach.",
"username": "Benutzername",
"usernamePlaceholder": "z.B. maxmuster",
"usernameHint": "Nur Buchstaben, Zahlen, _ und - erlaubt (330 Zeichen)",
"usernameHint": "Nur Buchstaben, Zahlen, _ und - erlaubt (3-30 Zeichen)",
"displayName": "Anzeigename",
"displayNamePlaceholder": "Max Mustermann",
"usernameTaken": "Benutzername ist bereits vergeben",
"usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, _ und - enthalten (330 Zeichen)",
"usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, _ und - enthalten (3-30 Zeichen)",
"usernameRequired": "Benutzername ist erforderlich",
"displayNameRequired": "Anzeigename ist erforderlich",
"emailVerificationBanner": "Deine E-Mail-Adresse wurde noch nicht verifiziert.",
"emailVerificationResend": "Hier klicken um eine neue Verifizierungsmail zu erhalten",
"emailVerificationResendCooldown": "Erneut senden in {seconds}s",
"emailVerificationResendSuccess": "Verifizierungsmail wurde gesendet!",
"emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden"
"emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden",
"inviteOnly": "Nur mit Einladung",
"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": {
"poweredBy": "Powered by BigBlueButton",
"madeFor": "Made for BigBlueButton",
"heroTitle": "Meetings neu ",
"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.",
@@ -153,6 +174,8 @@
"settings": "Einstellungen",
"participants": "{count} Teilnehmer",
"copyLink": "Link kopieren",
"copyRoomLink": "Raum-Link",
"copyGuestLink": "Gast-Link",
"linkCopied": "Link kopiert!",
"meetingDetails": "Meeting-Details",
"meetingId": "Meeting ID",
@@ -207,6 +230,11 @@
"guestModeratorPlaceholder": "Nur wenn Sie Moderator sind",
"guestJoinButton": "Meeting beitreten",
"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",
"guestNameRequired": "Name ist erforderlich",
"guestJoinFailed": "Beitritt fehlgeschlagen",
@@ -228,13 +256,21 @@
"presentationRemoveFailed": "Präsentation konnte nicht entfernt werden",
"presentationAllowedTypes": "PDF, PPT, PPTX, ODP, DOC, DOCX · max. 50 MB",
"presentationCurrent": "Aktuell:",
"shareTitle": "Raum teilen",
"shareDescription": "Teilen Sie diesen Raum mit anderen Benutzern, damit diese ihn in ihrem Dashboard sehen und beitreten k\u00f6nnen.",
"shareSearchPlaceholder": "Benutzer suchen (Name oder E-Mail)...",
"shareAdded": "Benutzer hinzugef\u00fcgt",
"shareRemoved": "Freigabe entfernt",
"shareFailed": "Freigabe fehlgeschlagen",
"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": {
"title": "Aufnahmen",
@@ -252,6 +288,30 @@
"publish": "Veröffentlichen",
"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": {
"title": "Einstellungen",
"subtitle": "Verwalten Sie Ihr Profil und Ihre Einstellungen",
@@ -281,7 +341,49 @@
"passwordChanged": "Passwort geändert",
"passwordChangeFailed": "Fehler beim Ändern",
"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": {
"selectTheme": "Theme auswählen",
@@ -330,10 +432,69 @@
"appNameLabel": "App-Name",
"appNameUpdated": "App-Name aktualisiert",
"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",
"defaultThemeDesc": "Wird für nicht angemeldete Seiten (Gast-Join, Login, Startseite) verwendet, wenn keine persönliche Einstellung gesetzt ist.",
"defaultThemeSaved": "Standard-Theme gespeichert",
"defaultThemeUpdateFailed": "Standard-Theme konnte nicht aktualisiert werden"
"defaultThemeUpdateFailed": "Standard-Theme konnte nicht aktualisiert werden",
"regModeTitle": "Registrierungsmodus",
"regModeDescription": "Steuern Sie, wie sich neue Benutzer registrieren können. \"Offen\" erlaubt jedem die Anmeldung. \"Nur mit Einladung\" erfordert einen Einladungslink.",
"regModeOpen": "Offene Registrierung",
"regModeInvite": "Nur mit Einladung",
"regModeSaved": "Registrierungsmodus aktualisiert",
"regModeFailed": "Registrierungsmodus konnte nicht aktualisiert werden",
"inviteTitle": "Benutzer-Einladungen",
"inviteDescription": "Laden Sie neue Benutzer per E-Mail ein. Sie erhalten einen Registrierungslink, der 7 Tage gültig ist.",
"sendInvite": "Einladung senden",
"inviteSent": "Einladung gesendet!",
"inviteFailed": "Einladung konnte nicht gesendet werden",
"inviteDeleted": "Einladung gelöscht",
"inviteDeleteFailed": "Einladung konnte nicht gelöscht werden",
"inviteLinkCopied": "Einladungslink kopiert!",
"copyInviteLink": "Einladungslink kopieren",
"inviteExpired": "Abgelaufen",
"inviteUsedBy": "Verwendet von",
"inviteExpiresAt": "Läuft ab am",
"noInvites": "Noch keine Einladungen",
"legalLinksTitle": "Rechtliche Links",
"legalLinksDesc": "Impressum- und Datenschutz-Links am unteren Rand der Seitenleiste anzeigen. Leer lassen zum Ausblenden.",
"imprintUrl": "Impressum-URL",
"privacyUrl": "Datenschutz-URL",
"imprintUrlSaved": "Impressum-URL gespeichert",
"privacyUrlSaved": "Datenschutz-URL gespeichert",
"imprintUrlFailed": "Impressum-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": {
"bell": "Benachrichtigungen",
"markAllRead": "Alle gelesen",
"clearAll": "Alle löschen",
"delete": "Löschen",
"noNotifications": "Keine Benachrichtigungen",
"roomShareAdded": "Raum wurde mit dir geteilt",
"roomShareRemoved": "Raumzugriff wurde entfernt",
"federationInviteReceived": "Neue Meeting-Einladung"
},
"federation": {
"inbox": "Einladungen",
@@ -371,7 +532,7 @@
"removeRoomConfirm": "Raum wirklich entfernen?",
"roomRemoved": "Raum entfernt",
"roomRemoveFailed": "Raum konnte nicht entfernt werden",
"acceptedSaved": "Einladung angenommen Raum wurde in deinem Dashboard gespeichert!",
"acceptedSaved": "Einladung angenommen - Raum wurde in deinem Dashboard gespeichert!",
"meetingId": "Meeting ID",
"maxParticipants": "Max. Teilnehmer",
"recordingOn": "Aufnahme aktiviert",
@@ -385,6 +546,124 @@
"roomDetails": "Raumdetails",
"joinUrl": "Beitritts-URL",
"roomDeleted": "Gelöscht",
"roomDeletedNotice": "Dieser Raum wurde vom Besitzer auf der Ursprungsinstanz gelöscht und ist nicht mehr verfügbar."
"roomDeletedNotice": "Dieser Raum wurde vom Besitzer auf der Ursprungsinstanz gelöscht und ist nicht mehr verfügbar.",
"calendarEvent": "Kalendereinladung",
"calendarAccepted": "Kalender-Event angenommen und in deinen Kalender eingetragen!",
"localCalendarEvent": "Lokale Kalendereinladung",
"calendarLocalAccepted": "Einladung angenommen - Event wurde in deinen Kalender eingetragen!",
"invitationRemoved": "Einladung entfernt",
"removeInvitation": "Einladung entfernen"
},
"calendar": {
"title": "Kalender",
"subtitle": "Meetings planen und verwalten",
"newEvent": "Neues Event",
"createEvent": "Event erstellen",
"editEvent": "Event bearbeiten",
"eventTitle": "Titel",
"eventTitlePlaceholder": "z.B. Team Meeting",
"description": "Beschreibung",
"descriptionPlaceholder": "Beschreibung hinzufügen...",
"startTime": "Beginn",
"endTime": "Ende",
"linkedRoom": "Verknüpfter Raum",
"noRoom": "Kein Raum (kein Videomeeting)",
"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",
"eventCreated": "Event erstellt!",
"eventUpdated": "Event aktualisiert!",
"eventDeleted": "Event gelöscht",
"saveFailed": "Event konnte nicht gespeichert werden",
"deleteFailed": "Event konnte nicht gelöscht werden",
"deleteConfirm": "Dieses Event wirklich löschen?",
"loadFailed": "Events konnten nicht geladen werden",
"today": "Heute",
"month": "Monat",
"week": "Woche",
"more": "weitere",
"mon": "Mo",
"tue": "Di",
"wed": "Mi",
"thu": "Do",
"fri": "Fr",
"sat": "Sa",
"sun": "So",
"downloadICS": "ICS herunterladen",
"addToOutlook": "Zu Outlook hinzufügen",
"addToGoogleCalendar": "Zu Google Kalender",
"icsDownloaded": "ICS-Datei heruntergeladen",
"icsFailed": "ICS-Datei konnte nicht heruntergeladen werden",
"share": "Teilen",
"shareEvent": "Event teilen",
"shareAdded": "Benutzer zum Event hinzugefügt",
"shareRemoved": "Freigabe entfernt",
"shareFailed": "Event konnte nicht geteilt werden",
"invitationSent": "Einladung gesendet!",
"invitationCancelled": "Einladung widerrufen",
"invitationPending": "Einladung ausstehend",
"pendingInvitations": "Ausstehende Einladungen",
"accepted": "Angenommen",
"sendFederated": "An Remote senden",
"sendFederatedTitle": "Event an Remote-Instanz senden",
"sendFederatedDesc": "Sende dieses Kalender-Event an einen Benutzer auf einer anderen Redlight-Instanz. Der Empfänger muss die Einladung zuerst annehmen, bevor das Event in seinem Kalender erscheint.",
"send": "Senden",
"fedSent": "Kalendereinladung gesendet! Der Empfänger muss diese zuerst annehmen.",
"fedFailed": "Event konnte nicht an Remote-Instanz gesendet werden",
"openRoom": "Verknüpften Raum öffnen",
"organizer": "Organisator",
"federatedFrom": "Von Remote-Instanz",
"joinFederatedMeeting": "Remote-Meeting beitreten"
},
"email": {
"greeting": "Hey {name} 👋",
"viewInvitation": "Einladung anzeigen",
"invitationFooter": "Öffne den Link oben, um die Einladung anzunehmen oder abzulehnen.",
"linkHint": "Oder kopiere diesen Link in deinen Browser:",
"verify": {
"subject": "{appName} - E-Mail-Adresse bestätigen",
"intro": "Bitte bestätige deine E-Mail-Adresse, indem du auf den Button klickst:",
"button": "E-Mail bestätigen",
"validity": "Dieser Link ist 24 Stunden gültig.",
"footer": "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren."
},
"invite": {
"subject": "{appName} - Du wurdest eingeladen",
"title": "Du wurdest eingeladen! 🎉",
"intro": "Du wurdest eingeladen, ein Konto auf {appName} zu erstellen.",
"prompt": "Klicke auf den Button, um dich zu registrieren:",
"button": "Konto erstellen",
"validity": "Dieser Link ist 7 Tage gültig.",
"footer": "Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren."
},
"federationInvite": {
"subject": "{appName} - Meeting-Einladung von {fromUser}",
"intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.",
"roomLabel": "Raum:"
},
"calendarInvite": {
"subject": "{appName} - Kalendereinladung von {fromUser}",
"intro": "Du hast eine Kalendereinladung von {fromUser} erhalten."
},
"calendarDeleted": {
"subject": "{appName} - Kalendereintrag abgesagt: {title}",
"intro": "Der folgende Kalendereintrag wurde vom Organisator ({fromUser}) gelöscht und ist nicht mehr verfügbar:",
"note": "Der Termin wurde automatisch aus deinem Kalender entfernt.",
"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

@@ -32,7 +32,10 @@
"appearance": "Appearance",
"changeTheme": "Change theme",
"navigation": "Navigation",
"federation": "Invitations"
"calendar": "Calendar",
"federation": "Invitations",
"imprint": "Imprint",
"privacy": "Privacy Policy"
},
"auth": {
"login": "Sign in",
@@ -75,21 +78,39 @@
"emailNotVerified": "Email not yet verified. Please check your inbox.",
"username": "Username",
"usernamePlaceholder": "e.g. johndoe",
"usernameHint": "Letters, numbers, _ and - only (330 chars)",
"usernameHint": "Letters, numbers, _ and - only (3-30 chars)",
"displayName": "Display Name",
"displayNamePlaceholder": "John Doe",
"usernameTaken": "Username is already taken",
"usernameInvalid": "Username may only contain letters, numbers, _ and - (330 chars)",
"usernameInvalid": "Username may only contain letters, numbers, _ and - (3-30 chars)",
"usernameRequired": "Username is required",
"displayNameRequired": "Display name is required",
"emailVerificationBanner": "Your email address has not been verified yet.",
"emailVerificationResend": "Click here to receive a new verification email",
"emailVerificationResendCooldown": "Resend in {seconds}s",
"emailVerificationResendSuccess": "Verification email sent!",
"emailVerificationResendFailed": "Could not send verification email"
"emailVerificationResendFailed": "Could not send verification email",
"inviteOnly": "Invite Only",
"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": {
"poweredBy": "Powered by BigBlueButton",
"madeFor": "Made for BigBlueButton",
"heroTitle": "Meetings re",
"heroTitleHighlight": "defined",
"heroSubtitle": "The modern, self-hosted BigBlueButton frontend. Create rooms, manage recordings and enjoy a beautiful interface with over 15 themes.",
@@ -153,6 +174,8 @@
"settings": "Settings",
"participants": "{count} participants",
"copyLink": "Copy link",
"copyRoomLink": "Room Link",
"copyGuestLink": "Guest Link",
"linkCopied": "Link copied!",
"meetingDetails": "Meeting details",
"meetingId": "Meeting ID",
@@ -207,6 +230,11 @@
"guestModeratorPlaceholder": "Only if you are a moderator",
"guestJoinButton": "Join meeting",
"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",
"guestNameRequired": "Name is required",
"guestJoinFailed": "Join failed",
@@ -228,13 +256,21 @@
"presentationRemoveFailed": "Could not remove presentation",
"presentationAllowedTypes": "PDF, PPT, PPTX, ODP, DOC, DOCX · max. 50 MB",
"presentationCurrent": "Current:",
"shareTitle": "Share Room",
"shareDescription": "Share this room with other users so they can see it in their dashboard and join meetings.",
"shareSearchPlaceholder": "Search users (name or email)...",
"shareAdded": "User added",
"shareRemoved": "Share removed",
"shareFailed": "Share failed",
"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": {
"title": "Recordings",
@@ -252,6 +288,30 @@
"publish": "Publish",
"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": {
"title": "Settings",
"subtitle": "Manage your profile and settings",
@@ -281,7 +341,49 @@
"passwordChanged": "Password changed",
"passwordChangeFailed": "Error changing password",
"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": {
"selectTheme": "Select theme",
@@ -330,10 +432,69 @@
"appNameLabel": "App name",
"appNameUpdated": "App name updated",
"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",
"defaultThemeDesc": "Applied to unauthenticated pages (guest join, login, home) when no personal preference is set.",
"defaultThemeSaved": "Default theme saved",
"defaultThemeUpdateFailed": "Could not update default theme"
"defaultThemeUpdateFailed": "Could not update default theme",
"regModeTitle": "Registration Mode",
"regModeDescription": "Control how new users can register. \"Open\" allows everyone to sign up. \"Invite only\" requires an invitation link.",
"regModeOpen": "Open registration",
"regModeInvite": "Invite only",
"regModeSaved": "Registration mode updated",
"regModeFailed": "Could not update registration mode",
"inviteTitle": "User Invitations",
"inviteDescription": "Invite new users by email. They will receive a registration link valid for 7 days.",
"sendInvite": "Send invite",
"inviteSent": "Invitation sent!",
"inviteFailed": "Could not send invitation",
"inviteDeleted": "Invitation deleted",
"inviteDeleteFailed": "Could not delete invitation",
"inviteLinkCopied": "Invite link copied!",
"copyInviteLink": "Copy invite link",
"inviteExpired": "Expired",
"inviteUsedBy": "Used by",
"inviteExpiresAt": "Expires",
"noInvites": "No invitations yet",
"legalLinksTitle": "Legal Links",
"legalLinksDesc": "Show Imprint and Privacy Policy links at the bottom of the sidebar. Leave blank to hide.",
"imprintUrl": "Imprint URL",
"privacyUrl": "Privacy Policy URL",
"imprintUrlSaved": "Imprint URL saved",
"privacyUrlSaved": "Privacy Policy URL saved",
"imprintUrlFailed": "Could not save Imprint 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": {
"bell": "Notifications",
"markAllRead": "Mark all read",
"clearAll": "Clear all",
"delete": "Delete",
"noNotifications": "No notifications yet",
"roomShareAdded": "Room shared with you",
"roomShareRemoved": "Room access removed",
"federationInviteReceived": "New meeting invitation"
},
"federation": {
"inbox": "Invitations",
@@ -371,7 +532,7 @@
"removeRoomConfirm": "Really remove this room?",
"roomRemoved": "Room removed",
"roomRemoveFailed": "Could not remove room",
"acceptedSaved": "Invitation accepted room saved to your dashboard!",
"acceptedSaved": "Invitation accepted - room saved to your dashboard!",
"meetingId": "Meeting ID",
"maxParticipants": "Max. participants",
"recordingOn": "Recording enabled",
@@ -385,6 +546,124 @@
"roomDetails": "Room Details",
"joinUrl": "Join URL",
"roomDeleted": "Deleted",
"roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available."
"roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available.",
"calendarEvent": "Calendar Invitation",
"calendarAccepted": "Calendar event accepted and added to your calendar!",
"localCalendarEvent": "Local Calendar Invitation",
"calendarLocalAccepted": "Invitation accepted - event added to your calendar!",
"invitationRemoved": "Invitation removed",
"removeInvitation": "Remove invitation"
},
"calendar": {
"title": "Calendar",
"subtitle": "Plan and manage your meetings",
"newEvent": "New Event",
"createEvent": "Create Event",
"editEvent": "Edit Event",
"eventTitle": "Title",
"eventTitlePlaceholder": "e.g. Team Meeting",
"description": "Description",
"descriptionPlaceholder": "Add a description...",
"startTime": "Start",
"endTime": "End",
"linkedRoom": "Linked Room",
"noRoom": "No room (no video meeting)",
"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",
"eventCreated": "Event created!",
"eventUpdated": "Event updated!",
"eventDeleted": "Event deleted",
"saveFailed": "Could not save event",
"deleteFailed": "Could not delete event",
"deleteConfirm": "Really delete this event?",
"loadFailed": "Events could not be loaded",
"today": "Today",
"month": "Month",
"week": "Week",
"more": "more",
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat",
"sun": "Sun",
"downloadICS": "Download ICS",
"addToOutlook": "Add to Outlook",
"addToGoogleCalendar": "Google Calendar",
"icsDownloaded": "ICS file downloaded",
"icsFailed": "Could not download ICS file",
"share": "Share",
"shareEvent": "Share Event",
"shareAdded": "User added to event",
"shareRemoved": "Share removed",
"shareFailed": "Could not share event",
"invitationSent": "Invitation sent!",
"invitationCancelled": "Invitation cancelled",
"invitationPending": "Invitation pending",
"pendingInvitations": "Pending Invitations",
"accepted": "Accepted",
"sendFederated": "Send to remote",
"sendFederatedTitle": "Send Event to Remote Instance",
"sendFederatedDesc": "Send this calendar event to a user on another Redlight instance. The recipient must accept the invitation before the event appears in their calendar.",
"send": "Send",
"fedSent": "Calendar invitation sent! The recipient must accept it first.",
"fedFailed": "Could not send event to remote instance",
"openRoom": "Open linked room",
"organizer": "Organizer",
"federatedFrom": "From remote instance",
"joinFederatedMeeting": "Join remote meeting"
},
"email": {
"greeting": "Hey {name} 👋",
"viewInvitation": "View Invitation",
"invitationFooter": "Open the link above to accept or decline the invitation.",
"linkHint": "Or copy this link in your browser:",
"verify": {
"subject": "{appName} - Verify your email",
"intro": "Please verify your email address by clicking the button below:",
"button": "Verify Email",
"validity": "This link is valid for 24 hours.",
"footer": "If you didn't register, please ignore this email."
},
"invite": {
"subject": "{appName} - You've been invited",
"title": "You've been invited! 🎉",
"intro": "You have been invited to create an account on {appName}.",
"prompt": "Click the button below to register:",
"button": "Create Account",
"validity": "This link is valid for 7 days.",
"footer": "If you didn't expect this invitation, you can safely ignore this email."
},
"federationInvite": {
"subject": "{appName} - Meeting invitation from {fromUser}",
"intro": "You have received a meeting invitation from {fromUser}.",
"roomLabel": "Room:"
},
"calendarInvite": {
"subject": "{appName} - Calendar invitation from {fromUser}",
"intro": "You have received a calendar invitation from {fromUser}."
},
"calendarDeleted": {
"subject": "{appName} - Calendar event cancelled: {title}",
"intro": "The following calendar event was deleted by the organiser ({fromUser}) and is no longer available:",
"note": "The event has been automatically removed from your calendar.",
"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 ===== */
:root,
[data-theme="light"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
@@ -32,6 +34,8 @@
/* ===== DEFAULT DARK ===== */
[data-theme="dark"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
@@ -58,6 +62,8 @@
/* ===== DRACULA ===== */
[data-theme="dracula"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #282a36;
--bg-secondary: #44475a;
--bg-tertiary: #383a4c;
@@ -84,6 +90,8 @@
/* ===== CATPPUCCIN MOCHA ===== */
[data-theme="mocha"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #1e1e2e;
--bg-secondary: #313244;
--bg-tertiary: #45475a;
@@ -110,6 +118,8 @@
/* ===== CATPPUCCIN LATTE (Light) ===== */
[data-theme="latte"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #eff1f5;
--bg-secondary: #e6e9ef;
--bg-tertiary: #dce0e8;
@@ -136,6 +146,8 @@
/* ===== NORD ===== */
[data-theme="nord"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #2e3440;
--bg-secondary: #3b4252;
--bg-tertiary: #434c5e;
@@ -162,6 +174,8 @@
/* ===== TOKYO NIGHT ===== */
[data-theme="tokyo-night"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #1a1b26;
--bg-secondary: #24283b;
--bg-tertiary: #2f3349;
@@ -188,6 +202,8 @@
/* ===== GRUVBOX DARK ===== */
[data-theme="gruvbox-dark"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #282828;
--bg-secondary: #3c3836;
--bg-tertiary: #504945;
@@ -214,6 +230,8 @@
/* ===== GRUVBOX LIGHT ===== */
[data-theme="gruvbox-light"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #fbf1c7;
--bg-secondary: #ebdbb2;
--bg-tertiary: #d5c4a1;
@@ -240,6 +258,8 @@
/* ===== ROSE PINE ===== */
[data-theme="rose-pine"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #191724;
--bg-secondary: #1f1d2e;
--bg-tertiary: #26233a;
@@ -266,6 +286,8 @@
/* ===== ROSE PINE DAWN (Light) ===== */
[data-theme="rose-pine-dawn"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #faf4ed;
--bg-secondary: #fffaf3;
--bg-tertiary: #f2e9e1;
@@ -292,6 +314,8 @@
/* ===== SOLARIZED DARK ===== */
[data-theme="solarized-dark"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #002b36;
--bg-secondary: #073642;
--bg-tertiary: #0a4050;
@@ -318,6 +342,8 @@
/* ===== SOLARIZED LIGHT ===== */
[data-theme="solarized-light"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #fdf6e3;
--bg-secondary: #eee8d5;
--bg-tertiary: #e4ddc8;
@@ -344,6 +370,8 @@
/* ===== ONE DARK ===== */
[data-theme="one-dark"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #282c34;
--bg-secondary: #2c313a;
--bg-tertiary: #353b45;
@@ -370,6 +398,8 @@
/* ===== GITHUB DARK ===== */
[data-theme="github-dark"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
@@ -416,6 +446,8 @@
/* ===== SCRUNKLY.CAT DARK ===== */
[data-theme="scrunkly-cat"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #161924;
--bg-secondary: #161924;
--bg-tertiary: #1b2130;
@@ -440,6 +472,230 @@
--gradient-end: #d6336a;
}
/* ===== RED MODULAR LIGHT ===== */
[data-theme="red-modular-light"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
--text-primary: #000000;
--text-secondary: #333333;
--accent: #e60000;
--accent-hover: #ff3333;
--accent-text: #ffffff;
--border: #e2e8f0;
--card-bg: #ffffff;
--input-bg: #ffffff;
--input-border: #cbd5e1;
--nav-bg: #ffffff;
--sidebar-bg: #f8fafc;
--hover-bg: #f1f5f9;
--success: #86b300;
--warning: #ecb637;
--error: #ec4137;
--ring: #e60000;
--shadow-color: rgba(0, 0, 0, 0.3);
--gradient-start: #e60000;
--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;
}
@layer components {
.btn-primary {
@@ -511,3 +767,232 @@
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

@@ -7,6 +7,7 @@ import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { LanguageProvider } from './contexts/LanguageContext';
import { BrandingProvider } from './contexts/BrandingContext';
import { NotificationProvider } from './contexts/NotificationContext';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
@@ -16,18 +17,21 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<ThemeProvider>
<BrandingProvider>
<AuthProvider>
<App />
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: 'var(--card-bg)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
},
}}
/>
<NotificationProvider>
<App />
<Toaster
position="top-right"
containerStyle={{ top: 70 }}
toastOptions={{
duration: 4000,
style: {
background: 'var(--card-bg)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
},
}}
/>
</NotificationProvider>
</AuthProvider>
</BrandingProvider>
</ThemeProvider>

View File

@@ -3,7 +3,8 @@ import { useNavigate } from 'react-router-dom';
import {
Users, Shield, Search, Trash2, ChevronDown, Loader2,
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
Upload, X as XIcon, Image, Type, Palette,
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
ShieldCheck, Globe, Link as LinkIcon, LogIn,
} from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
@@ -15,7 +16,7 @@ import toast from 'react-hot-toast';
export default function Admin() {
const { user } = useAuth();
const { t, language } = useLanguage();
const { appName, hasLogo, logoUrl, defaultTheme, refreshBranding } = useBranding();
const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, imprintUrl, privacyUrl, hideAppName, refreshBranding } = useBranding();
const navigate = useNavigate();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
@@ -26,6 +27,14 @@ export default function Admin() {
const [showCreateUser, setShowCreateUser] = useState(false);
const [creatingUser, setCreatingUser] = useState(false);
const [newUser, setNewUser] = useState({ name: '', display_name: '', email: '', password: '', role: 'user' });
const menuBtnRefs = useRef({});
const [menuPos, setMenuPos] = useState(null);
// Invite state
const [invites, setInvites] = useState([]);
const [inviteEmail, setInviteEmail] = useState('');
const [sendingInvite, setSendingInvite] = useState(false);
const [savingRegMode, setSavingRegMode] = useState(false);
// Branding state
const [editAppName, setEditAppName] = useState('');
@@ -34,6 +43,17 @@ export default function Admin() {
const logoInputRef = useRef(null);
const [editDefaultTheme, setEditDefaultTheme] = useState('');
const [savingDefaultTheme, setSavingDefaultTheme] = useState(false);
const [editImprintUrl, setEditImprintUrl] = useState('');
const [savingImprintUrl, setSavingImprintUrl] = useState(false);
const [editPrivacyUrl, setEditPrivacyUrl] = useState('');
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(() => {
if (user?.role !== 'admin') {
@@ -41,6 +61,8 @@ export default function Admin() {
return;
}
fetchUsers();
fetchInvites();
fetchOauthConfig();
}, [user]);
useEffect(() => {
@@ -51,6 +73,14 @@ export default function Admin() {
setEditDefaultTheme(defaultTheme || 'dark');
}, [defaultTheme]);
useEffect(() => {
setEditImprintUrl(imprintUrl || '');
}, [imprintUrl]);
useEffect(() => {
setEditPrivacyUrl(privacyUrl || '');
}, [privacyUrl]);
const fetchUsers = async () => {
try {
const res = await api.get('/admin/users');
@@ -62,6 +92,15 @@ export default function Admin() {
}
};
const fetchInvites = async () => {
try {
const res = await api.get('/admin/invites');
setInvites(res.data.invites);
} catch {
// silently fail
}
};
const handleRoleChange = async (userId, newRole) => {
try {
await api.put(`/admin/users/${userId}/role`, { role: newRole });
@@ -71,6 +110,7 @@ export default function Admin() {
toast.error(err.response?.data?.error || t('admin.roleUpdateFailed'));
}
setOpenMenu(null);
setMenuPos(null);
};
const handleDelete = async (userId, userName) => {
@@ -83,6 +123,7 @@ export default function Admin() {
toast.error(err.response?.data?.error || t('admin.userDeleteFailed'));
}
setOpenMenu(null);
setMenuPos(null);
};
const handleResetPassword = async (e) => {
@@ -128,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 () => {
if (!editAppName.trim()) return;
setSavingName(true);
@@ -172,6 +225,128 @@ export default function Admin() {
}
};
const handleSendInvite = async (e) => {
e.preventDefault();
setSendingInvite(true);
try {
const res = await api.post('/admin/invites', { email: inviteEmail });
toast.success(t('admin.inviteSent'));
setInviteEmail('');
fetchInvites();
} catch (err) {
toast.error(err.response?.data?.error || t('admin.inviteFailed'));
} finally {
setSendingInvite(false);
}
};
const handleDeleteInvite = async (id) => {
try {
await api.delete(`/admin/invites/${id}`);
toast.success(t('admin.inviteDeleted'));
fetchInvites();
} catch {
toast.error(t('admin.inviteDeleteFailed'));
}
};
const handleCopyInviteLink = (token) => {
const baseUrl = window.location.origin;
navigator.clipboard.writeText(`${baseUrl}/register?invite=${token}`);
toast.success(t('admin.inviteLinkCopied'));
};
const handleRegModeChange = async (mode) => {
setSavingRegMode(true);
try {
await api.put('/branding/registration-mode', { registrationMode: mode });
toast.success(t('admin.regModeSaved'));
refreshBranding();
} catch {
toast.error(t('admin.regModeFailed'));
} finally {
setSavingRegMode(false);
}
};
const handleImprintUrlSave = async () => {
setSavingImprintUrl(true);
try {
await api.put('/branding/imprint-url', { imprintUrl: editImprintUrl.trim() });
toast.success(t('admin.imprintUrlSaved'));
refreshBranding();
} catch {
toast.error(t('admin.imprintUrlFailed'));
} finally {
setSavingImprintUrl(false);
}
};
const handlePrivacyUrlSave = async () => {
setSavingPrivacyUrl(true);
try {
await api.put('/branding/privacy-url', { privacyUrl: editPrivacyUrl.trim() });
toast.success(t('admin.privacyUrlSaved'));
refreshBranding();
} catch {
toast.error(t('admin.privacyUrlFailed'));
} finally {
setSavingPrivacyUrl(false);
}
};
// ── 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 =>
(u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase())
@@ -285,6 +460,28 @@ export default function Admin() {
{savingName ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button>
</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>
@@ -316,6 +513,281 @@ export default function Admin() {
</button>
</div>
</div>
{/* Legal links */}
<div className="mt-6 pt-6 border-t border-th-border">
<div className="flex items-center gap-2 mb-1">
<LinkIcon size={16} className="text-th-accent" />
<label className="block text-sm font-medium text-th-text">{t('admin.legalLinksTitle')}</label>
</div>
<p className="text-xs text-th-text-s mb-4">{t('admin.legalLinksDesc')}</p>
<div className="grid gap-4 sm:grid-cols-2">
{/* Imprint */}
<div>
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.imprintUrl')}</label>
<div className="flex items-center gap-2">
<input
type="url"
value={editImprintUrl}
onChange={e => setEditImprintUrl(e.target.value)}
className="input-field text-sm flex-1"
placeholder="https://example.com/imprint"
maxLength={500}
/>
<button
onClick={handleImprintUrlSave}
disabled={savingImprintUrl || editImprintUrl === (imprintUrl || '')}
className="btn-primary text-sm px-4 flex-shrink-0"
>
{savingImprintUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button>
</div>
</div>
{/* Privacy Policy */}
<div>
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.privacyUrl')}</label>
<div className="flex items-center gap-2">
<input
type="url"
value={editPrivacyUrl}
onChange={e => setEditPrivacyUrl(e.target.value)}
className="input-field text-sm flex-1"
placeholder="https://example.com/privacy"
maxLength={500}
/>
<button
onClick={handlePrivacyUrlSave}
disabled={savingPrivacyUrl || editPrivacyUrl === (privacyUrl || '')}
className="btn-primary text-sm px-4 flex-shrink-0"
>
{savingPrivacyUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button>
</div>
</div>
</div>
</div>
</div>
{/* Registration Mode */}
<div className="card p-6 mb-8">
<div className="flex items-center gap-2 mb-4">
<ShieldCheck size={20} className="text-th-accent" />
<h2 className="text-lg font-semibold text-th-text">{t('admin.regModeTitle')}</h2>
</div>
<p className="text-sm text-th-text-s mb-5">{t('admin.regModeDescription')}</p>
<div className="flex items-center gap-3">
<button
onClick={() => handleRegModeChange('open')}
disabled={savingRegMode}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border text-sm font-medium transition-colors ${
registrationMode === 'open'
? 'border-th-accent bg-th-accent/10 text-th-accent'
: 'border-th-border text-th-text-s hover:bg-th-hover'
}`}
>
<Globe size={16} />
{t('admin.regModeOpen')}
</button>
<button
onClick={() => handleRegModeChange('invite')}
disabled={savingRegMode}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border text-sm font-medium transition-colors ${
registrationMode === 'invite'
? 'border-th-accent bg-th-accent/10 text-th-accent'
: 'border-th-border text-th-text-s hover:bg-th-hover'
}`}
>
<Mail size={16} />
{t('admin.regModeInvite')}
</button>
</div>
</div>
{/* User Invites */}
<div className="card p-6 mb-8">
<div className="flex items-center gap-2 mb-4">
<Send size={20} className="text-th-accent" />
<h2 className="text-lg font-semibold text-th-text">{t('admin.inviteTitle')}</h2>
</div>
<p className="text-sm text-th-text-s mb-5">{t('admin.inviteDescription')}</p>
{/* Send invite form */}
<form onSubmit={handleSendInvite} className="flex items-center gap-2 mb-6">
<div className="relative flex-1">
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="email"
value={inviteEmail}
onChange={e => setInviteEmail(e.target.value)}
className="input-field pl-9 text-sm"
placeholder={t('auth.emailPlaceholder')}
required
/>
</div>
<button
type="submit"
disabled={sendingInvite || !inviteEmail.trim()}
className="btn-primary text-sm px-4 flex-shrink-0"
>
{sendingInvite ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
{t('admin.sendInvite')}
</button>
</form>
{/* Invite list */}
{invites.length > 0 && (
<div className="space-y-2">
{invites.map(inv => {
const isExpired = new Date(inv.expires_at) < new Date();
const isUsed = !!inv.used_at;
return (
<div key={inv.id} className="flex items-center justify-between gap-3 p-3 rounded-xl bg-th-bg border border-th-border">
<div className="flex items-center gap-3 min-w-0">
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
isUsed ? 'bg-green-500/15 text-green-400' : isExpired ? 'bg-red-500/15 text-red-400' : 'bg-th-accent/15 text-th-accent'
}`}>
{isUsed ? <Check size={14} /> : isExpired ? <XIcon size={14} /> : <Clock size={14} />}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-th-text truncate">{inv.email}</p>
<p className="text-xs text-th-text-s">
{isUsed
? `${t('admin.inviteUsedBy')} ${inv.used_by_name}`
: isExpired
? t('admin.inviteExpired')
: `${t('admin.inviteExpiresAt')} ${new Date(inv.expires_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}`
}
</p>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{!isUsed && !isExpired && (
<button
onClick={() => handleCopyInviteLink(inv.token)}
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
title={t('admin.copyInviteLink')}
>
<Copy size={14} />
</button>
)}
<button
onClick={() => handleDeleteInvite(inv.id)}
className="p-1.5 rounded-lg hover:bg-th-hover text-th-error transition-colors"
title={t('common.delete')}
>
<Trash2 size={14} />
</button>
</div>
</div>
);
})}
</div>
)}
{invites.length === 0 && (
<p className="text-sm text-th-text-s text-center py-4">{t('admin.noInvites')}</p>
)}
</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 */}
@@ -397,43 +869,32 @@ export default function Admin() {
{new Date(u.created_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}
</td>
<td className="px-5 py-4">
<div className="flex items-center justify-end relative">
<div className="flex items-center justify-end">
<button
onClick={() => setOpenMenu(openMenu === u.id ? null : u.id)}
ref={el => { menuBtnRefs.current[u.id] = el; }}
onClick={() => {
if (openMenu === u.id) {
setOpenMenu(null);
setMenuPos(null);
} else {
const rect = menuBtnRefs.current[u.id]?.getBoundingClientRect();
if (rect) {
const menuHeight = 130;
const spaceAbove = rect.top;
if (spaceAbove >= menuHeight) {
setMenuPos({ top: rect.top - menuHeight - 4, left: rect.right - 192 });
} else {
setMenuPos({ top: rect.bottom + 4, left: rect.right - 192 });
}
}
setOpenMenu(u.id);
}
}}
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
disabled={u.id === user.id}
>
<MoreVertical size={16} />
</button>
{openMenu === u.id && u.id !== user.id && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpenMenu(null)} />
<div className="absolute right-0 top-8 z-20 w-48 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
<button
onClick={() => handleRoleChange(u.id, u.role === 'admin' ? 'user' : 'admin')}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
{u.role === 'admin' ? <UserX size={14} /> : <UserCheck size={14} />}
{u.role === 'admin' ? t('admin.makeUser') : t('admin.makeAdmin')}
</button>
<button
onClick={() => { setResetPwModal(u.id); setOpenMenu(null); }}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
<Key size={14} />
{t('admin.resetPassword')}
</button>
<button
onClick={() => handleDelete(u.id, u.name)}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-error hover:bg-th-hover transition-colors"
>
<Trash2 size={14} />
{t('admin.deleteUser')}
</button>
</div>
</>
)}
</div>
</td>
</tr>
@@ -450,6 +911,43 @@ export default function Admin() {
)}
</div>
{/* Context menu portal */}
{openMenu && menuPos && openMenu !== user.id && (() => {
const u = users.find(u => u.id === openMenu);
if (!u) return null;
return (
<>
<div className="fixed inset-0 z-40" onClick={() => { setOpenMenu(null); setMenuPos(null); }} />
<div
className="fixed z-50 w-48 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden"
style={{ top: menuPos.top, left: menuPos.left }}
>
<button
onClick={() => handleRoleChange(u.id, u.role === 'admin' ? 'user' : 'admin')}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
{u.role === 'admin' ? <UserX size={14} /> : <UserCheck size={14} />}
{u.role === 'admin' ? t('admin.makeUser') : t('admin.makeAdmin')}
</button>
<button
onClick={() => { setResetPwModal(u.id); setOpenMenu(null); setMenuPos(null); }}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
<Key size={14} />
{t('admin.resetPassword')}
</button>
<button
onClick={() => { handleDelete(u.id, u.name); setMenuPos(null); }}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-error hover:bg-th-hover transition-colors"
>
<Trash2 size={14} />
{t('admin.deleteUser')}
</button>
</div>
</>
);
})()}
{/* Reset password modal */}
{resetPwModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">

888
src/pages/Calendar.jsx Normal file
View File

@@ -0,0 +1,888 @@
import { useState, useEffect, useMemo } from 'react';
import {
ChevronLeft, ChevronRight, Plus, Clock, Video, Bell,
Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send, ExternalLink,
} from 'lucide-react';
import api from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import Modal from '../components/Modal';
import DateTimePicker from '../components/DateTimePicker';
import toast from 'react-hot-toast';
const COLORS = ['#6366f1', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6'];
export default function Calendar() {
const { user } = useAuth();
const { t } = useLanguage();
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [currentDate, setCurrentDate] = useState(new Date());
const [view, setView] = useState('month'); // month | week
const [rooms, setRooms] = useState([]);
// Modal state
const [showCreate, setShowCreate] = useState(false);
const [showDetail, setShowDetail] = useState(null);
const [editingEvent, setEditingEvent] = useState(null);
const [showShare, setShowShare] = useState(null);
const [showFedShare, setShowFedShare] = useState(null);
// Create/Edit form
const [form, setForm] = useState({
title: '', description: '', start_time: '', end_time: '',
room_uid: '', color: '#6366f1', reminder_minutes: null,
});
const [saving, setSaving] = useState(false);
// Share state
const [shareSearch, setShareSearch] = useState('');
const [shareResults, setShareResults] = useState([]);
const [sharedUsers, setSharedUsers] = useState([]);
const [pendingInvitations, setPendingInvitations] = useState([]);
const [fedAddress, setFedAddress] = useState('');
const [fedSending, setFedSending] = useState(false);
// Load events on month change
useEffect(() => {
fetchEvents();
}, [currentDate]);
useEffect(() => {
fetchRooms();
}, []);
const fetchEvents = async () => {
try {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const from = new Date(year, month - 1, 1).toISOString();
const to = new Date(year, month + 2, 0).toISOString();
const res = await api.get(`/calendar/events?from=${from}&to=${to}`);
setEvents(res.data.events || []);
} catch {
toast.error(t('calendar.loadFailed'));
} finally {
setLoading(false);
}
};
const fetchRooms = async () => {
try {
const res = await api.get('/rooms');
setRooms(res.data.rooms || []);
} catch { /* ignore */ }
};
// Calendar grid computation
const calendarDays = useMemo(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Start from Monday (ISO week)
let startOffset = firstDay.getDay() - 1;
if (startOffset < 0) startOffset = 6;
const calStart = new Date(year, month, 1 - startOffset);
const days = [];
const current = new Date(calStart);
for (let i = 0; i < 42; i++) {
days.push(new Date(current));
current.setDate(current.getDate() + 1);
}
return days;
}, [currentDate]);
const weekDays = useMemo(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const date = currentDate.getDate();
const dayOfWeek = currentDate.getDay();
let mondayOffset = dayOfWeek - 1;
if (mondayOffset < 0) mondayOffset = 6;
const monday = new Date(year, month, date - mondayOffset);
const days = [];
for (let i = 0; i < 7; i++) {
days.push(new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + i));
}
return days;
}, [currentDate]);
const eventsForDay = (day) => {
const dayStr = toLocalDateStr(day);
return events.filter(ev => {
const start = toLocalDateStr(new Date(ev.start_time));
const end = toLocalDateStr(new Date(ev.end_time));
return dayStr >= start && dayStr <= end;
});
};
const isToday = (day) => {
const today = new Date();
return day.toDateString() === today.toDateString();
};
const isCurrentMonth = (day) => {
return day.getMonth() === currentDate.getMonth();
};
const navigatePrev = () => {
const d = new Date(currentDate);
if (view === 'month') d.setMonth(d.getMonth() - 1);
else d.setDate(d.getDate() - 7);
setCurrentDate(d);
};
const navigateNext = () => {
const d = new Date(currentDate);
if (view === 'month') d.setMonth(d.getMonth() + 1);
else d.setDate(d.getDate() + 7);
setCurrentDate(d);
};
const goToToday = () => setCurrentDate(new Date());
const monthLabel = currentDate.toLocaleString('default', { month: 'long', year: 'numeric' });
const openCreateForDay = (day) => {
const start = new Date(day);
start.setHours(9, 0, 0, 0);
const end = new Date(day);
end.setHours(10, 0, 0, 0);
setForm({
title: '', description: '',
start_time: toLocalDateTimeStr(start),
end_time: toLocalDateTimeStr(end),
room_uid: '', color: '#6366f1', reminder_minutes: null,
});
setEditingEvent(null);
setShowCreate(true);
};
const openEdit = (ev) => {
setForm({
title: ev.title,
description: ev.description || '',
start_time: toLocalDateTimeStr(new Date(ev.start_time)),
end_time: toLocalDateTimeStr(new Date(ev.end_time)),
room_uid: ev.room_uid || '',
color: ev.color || '#6366f1',
reminder_minutes: ev.reminder_minutes ?? null,
});
setEditingEvent(ev);
setShowDetail(null);
setShowCreate(true);
};
const handleSave = async (e) => {
e.preventDefault();
setSaving(true);
try {
const data = {
...form,
start_time: new Date(form.start_time).toISOString(),
end_time: new Date(form.end_time).toISOString(),
};
if (editingEvent) {
await api.put(`/calendar/events/${editingEvent.id}`, data);
toast.success(t('calendar.eventUpdated'));
} else {
await api.post('/calendar/events', data);
toast.success(t('calendar.eventCreated'));
}
setShowCreate(false);
setEditingEvent(null);
fetchEvents();
} catch (err) {
toast.error(err.response?.data?.error || t('calendar.saveFailed'));
} finally {
setSaving(false);
}
};
const handleDelete = async (ev) => {
if (!confirm(t('calendar.deleteConfirm'))) return;
try {
await api.delete(`/calendar/events/${ev.id}`);
toast.success(t('calendar.eventDeleted'));
setShowDetail(null);
fetchEvents();
} catch {
toast.error(t('calendar.deleteFailed'));
}
};
const handleDownloadICS = async (ev) => {
try {
const res = await api.get(`/calendar/events/${ev.id}/ics`, { responseType: 'blob' });
const url = window.URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = `${ev.title}.ics`;
a.click();
window.URL.revokeObjectURL(url);
toast.success(t('calendar.icsDownloaded'));
} catch {
toast.error(t('calendar.icsFailed'));
}
};
const buildOutlookUrl = (ev) => {
const start = new Date(ev.start_time);
const end = new Date(ev.end_time);
const fmt = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
const baseUrl = window.location.origin;
const location = ev.room_uid ? `${baseUrl}/join/${ev.room_uid}` : '';
const body = [ev.description || '', location ? `\n\nMeeting: ${location}` : ''].join('');
const params = new URLSearchParams({
rru: 'addevent',
subject: ev.title,
startdt: start.toISOString(),
enddt: end.toISOString(),
body: body.trim(),
location,
allday: 'false',
path: '/calendar/action/compose',
});
return `https://outlook.live.com/calendar/0/action/compose?${params.toString()}`;
};
const buildGoogleCalUrl = (ev) => {
const fmt = (d) => new Date(d).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
const baseUrl = window.location.origin;
const location = ev.room_uid ? `${baseUrl}/join/${ev.room_uid}` : '';
const details = [ev.description || '', location ? `\nMeeting: ${location}` : ''].join('');
const params = new URLSearchParams({
action: 'TEMPLATE',
text: ev.title,
dates: `${fmt(ev.start_time)}/${fmt(ev.end_time)}`,
details: details.trim(),
location,
});
return `https://calendar.google.com/calendar/render?${params.toString()}`;
};
// Share functions
const openShareModal = async (ev) => {
setShowShare(ev);
setShareSearch('');
setShareResults([]);
setPendingInvitations([]);
try {
const res = await api.get(`/calendar/events/${ev.id}`);
setSharedUsers(res.data.sharedUsers || []);
setPendingInvitations(res.data.pendingInvitations || []);
} catch { /* ignore */ }
};
const searchUsers = async (query) => {
setShareSearch(query);
if (query.length < 2) { setShareResults([]); return; }
try {
const res = await api.get(`/rooms/users/search?q=${encodeURIComponent(query)}`);
const sharedIds = new Set(sharedUsers.map(u => u.id));
const pendingIds = new Set(pendingInvitations.map(u => u.user_id));
setShareResults(res.data.users.filter(u => !sharedIds.has(u.id) && !pendingIds.has(u.id)));
} catch { setShareResults([]); }
};
const handleShare = async (userId) => {
if (!showShare) return;
try {
const res = await api.post(`/calendar/events/${showShare.id}/share`, { user_id: userId });
setSharedUsers(res.data.sharedUsers);
setPendingInvitations(res.data.pendingInvitations || []);
setShareSearch('');
setShareResults([]);
toast.success(t('calendar.invitationSent'));
} catch (err) {
toast.error(err.response?.data?.error || t('calendar.shareFailed'));
}
};
const handleUnshare = async (userId) => {
if (!showShare) return;
try {
const res = await api.delete(`/calendar/events/${showShare.id}/share/${userId}`);
setSharedUsers(res.data.sharedUsers);
setPendingInvitations(res.data.pendingInvitations || []);
toast.success(t('calendar.shareRemoved'));
} catch { toast.error(t('calendar.shareFailed')); }
};
const handleCancelInvitation = async (userId) => {
if (!showShare) return;
try {
const res = await api.delete(`/calendar/events/${showShare.id}/share/${userId}`);
setSharedUsers(res.data.sharedUsers);
setPendingInvitations(res.data.pendingInvitations || []);
toast.success(t('calendar.invitationCancelled'));
} catch { toast.error(t('calendar.shareFailed')); }
};
const handleFedSend = async (e) => {
e.preventDefault();
if (!showFedShare) return;
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
if (!normalized.includes('@') || normalized.endsWith('@')) {
toast.error(t('federation.addressHint'));
return;
}
setFedSending(true);
try {
await api.post(`/calendar/events/${showFedShare.id}/federation`, { to: fedAddress });
toast.success(t('calendar.fedSent'));
setShowFedShare(null);
setFedAddress('');
} catch (err) {
toast.error(err.response?.data?.error || t('calendar.fedFailed'));
} finally {
setFedSending(false);
}
};
const dayNames = [
t('calendar.mon'), t('calendar.tue'), t('calendar.wed'),
t('calendar.thu'), t('calendar.fri'), t('calendar.sat'), t('calendar.sun'),
];
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 size={32} className="animate-spin text-th-accent" />
</div>
);
}
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-th-text">{t('calendar.title')}</h1>
<p className="text-sm text-th-text-s mt-1">{t('calendar.subtitle')}</p>
</div>
<button onClick={() => {
const now = new Date();
now.setHours(now.getHours() + 1, 0, 0, 0);
const end = new Date(now);
end.setHours(end.getHours() + 1);
setForm({
title: '', description: '',
start_time: toLocalDateTimeStr(now),
end_time: toLocalDateTimeStr(end),
room_uid: '', color: '#6366f1',
});
setEditingEvent(null);
setShowCreate(true);
}} className="btn-primary">
<Plus size={18} />
<span className="hidden sm:inline">{t('calendar.newEvent')}</span>
</button>
</div>
{/* Toolbar */}
<div className="card p-3 mb-4 flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<button onClick={navigatePrev} className="btn-ghost p-2">
<ChevronLeft size={18} />
</button>
<button onClick={goToToday} className="btn-ghost text-sm px-3 py-1.5">
{t('calendar.today')}
</button>
<button onClick={navigateNext} className="btn-ghost p-2">
<ChevronRight size={18} />
</button>
<h2 className="text-lg font-semibold text-th-text ml-2">{monthLabel}</h2>
</div>
<div className="flex items-center bg-th-bg-s rounded-lg border border-th-border p-0.5">
<button
onClick={() => setView('month')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
view === 'month' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
}`}
>
{t('calendar.month')}
</button>
<button
onClick={() => setView('week')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
view === 'week' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
}`}
>
{t('calendar.week')}
</button>
</div>
</div>
{/* Calendar Grid */}
<div className="card overflow-hidden">
{/* Day headers */}
<div className="grid grid-cols-7 border-b border-th-border">
{dayNames.map((name, i) => (
<div key={i} className="py-2.5 text-center text-xs font-semibold text-th-text-s uppercase tracking-wider border-r border-th-border last:border-r-0">
{name}
</div>
))}
</div>
{/* Days */}
{view === 'month' ? (
<div className="grid grid-cols-7">
{calendarDays.map((day, i) => {
const dayEvents = eventsForDay(day);
const today = isToday(day);
const inMonth = isCurrentMonth(day);
return (
<div
key={i}
onClick={() => openCreateForDay(day)}
className={`min-h-[100px] p-1.5 border-r border-b border-th-border last:border-r-0 cursor-pointer hover:bg-th-hover/50 transition-colors
${!inMonth ? 'opacity-40' : ''}`}
>
<div className={`text-xs font-medium mb-1 w-6 h-6 flex items-center justify-center rounded-full
${today ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s'}`}>
{day.getDate()}
</div>
<div className="space-y-0.5">
{dayEvents.slice(0, 3).map(ev => (
<div
key={ev.id}
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
className="text-[10px] leading-tight px-1.5 py-0.5 rounded truncate text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: ev.color || '#6366f1' }}
title={ev.title}
>
{formatTime(ev.start_time)} {ev.title}
</div>
))}
{dayEvents.length > 3 && (
<div className="text-[10px] text-th-text-s font-medium px-1.5">
+{dayEvents.length - 3} {t('calendar.more')}
</div>
)}
</div>
</div>
);
})}
</div>
) : (
/* Week view */
<div className="grid grid-cols-7">
{weekDays.map((day, i) => {
const dayEvents = eventsForDay(day);
const today = isToday(day);
return (
<div
key={i}
onClick={() => openCreateForDay(day)}
className="min-h-[300px] p-2 border-r border-b border-th-border last:border-r-0 cursor-pointer hover:bg-th-hover/50 transition-colors"
>
<div className={`text-sm font-medium mb-2 w-7 h-7 flex items-center justify-center rounded-full
${today ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s'}`}>
{day.getDate()}
</div>
<div className="space-y-1">
{dayEvents.map(ev => (
<div
key={ev.id}
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
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' }}
>
<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>
))}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Create/Edit Modal */}
{showCreate && (
<Modal title={editingEvent ? t('calendar.editEvent') : t('calendar.createEvent')} onClose={() => { setShowCreate(false); setEditingEvent(null); }}>
<form onSubmit={handleSave} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.eventTitle')} *</label>
<input
type="text"
value={form.title}
onChange={e => setForm({ ...form, title: e.target.value })}
className="input-field"
placeholder={t('calendar.eventTitlePlaceholder')}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.description')}</label>
<textarea
value={form.description}
onChange={e => setForm({ ...form, description: e.target.value })}
className="input-field resize-none"
rows={2}
placeholder={t('calendar.descriptionPlaceholder')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<DateTimePicker
label={t('calendar.startTime')}
value={form.start_time}
onChange={v => setForm({ ...form, start_time: v })}
required
icon="calendar"
/>
<DateTimePicker
label={t('calendar.endTime')}
value={form.end_time}
onChange={v => setForm({ ...form, end_time: v })}
required
icon="clock"
minDate={form.start_time ? new Date(form.start_time) : null}
/>
</div>
<div className="flex items-center gap-1.5 -mt-2 text-xs text-th-text-s">
<Globe size={12} className="flex-shrink-0" />
<span>{getLocalTimezone()}</span>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.linkedRoom')}</label>
<select
value={form.room_uid}
onChange={e => setForm({ ...form, room_uid: e.target.value })}
className="input-field"
>
<option value="">{t('calendar.noRoom')}</option>
{rooms.map(r => (
<option key={r.uid} value={r.uid}>{r.name}</option>
))}
</select>
<p className="text-xs text-th-text-s mt-1">{t('calendar.linkedRoomHint')}</p>
</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>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.color')}</label>
<div className="flex gap-2">
{COLORS.map(c => (
<button
key={c}
type="button"
onClick={() => setForm({ ...form, color: c })}
className={`w-7 h-7 rounded-full border-2 transition-all ${form.color === c ? 'border-th-text scale-110' : 'border-transparent'}`}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
<div className="flex items-center gap-3 pt-4 border-t border-th-border">
<button type="button" onClick={() => { setShowCreate(false); setEditingEvent(null); }} className="btn-secondary flex-1">
{t('common.cancel')}
</button>
<button type="submit" disabled={saving} className="btn-primary flex-1">
{saving ? <Loader2 size={18} className="animate-spin" /> : t('common.save')}
</button>
</div>
</form>
</Modal>
)}
{/* Event Detail Modal */}
{showDetail && (
<Modal title={showDetail.title} onClose={() => setShowDetail(null)}>
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-th-text-s">
<Clock size={14} />
<span>
{new Date(showDetail.start_time).toLocaleString()} - {new Date(showDetail.end_time).toLocaleString()}
</span>
</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 && (
<p className="text-sm text-th-text">{showDetail.description}</p>
)}
{showDetail.room_uid && (
<div className="flex items-center gap-2 text-sm">
<Video size={14} className="text-th-accent" />
<a
href={`/rooms/${showDetail.room_uid}`}
className="text-th-accent hover:underline"
onClick={(e) => { e.preventDefault(); window.location.href = `/rooms/${showDetail.room_uid}`; }}
>
{t('calendar.openRoom')}
</a>
</div>
)}
{showDetail.federated_from && (
<div className="flex items-center gap-2 text-xs text-th-text-s">
<Globe size={12} />
<span>{t('calendar.federatedFrom')}: {showDetail.federated_from}</span>
</div>
)}
{showDetail.federated_join_url && (
<a
href={showDetail.federated_join_url}
target="_blank"
rel="noopener noreferrer"
className="btn-primary text-sm w-full justify-center"
>
<Video size={14} />
{t('calendar.joinFederatedMeeting')}
</a>
)}
{showDetail.organizer_name && (
<div className="text-xs text-th-text-s">
{t('calendar.organizer')}: {showDetail.organizer_name}
</div>
)}
{/* Actions */}
<div className="flex flex-wrap items-center gap-2 pt-4 border-t border-th-border">
<a
href={buildOutlookUrl(showDetail)}
target="_blank"
rel="noopener noreferrer"
className="btn-ghost text-xs py-1.5 px-3 inline-flex items-center gap-1.5 no-underline"
>
<ExternalLink size={14} />
{t('calendar.addToOutlook')}
</a>
<a
href={buildGoogleCalUrl(showDetail)}
target="_blank"
rel="noopener noreferrer"
className="btn-ghost text-xs py-1.5 px-3 inline-flex items-center gap-1.5 no-underline"
>
<ExternalLink size={14} />
{t('calendar.addToGoogleCalendar')}
</a>
<button onClick={() => handleDownloadICS(showDetail)} className="btn-ghost text-xs py-1.5 px-3">
<Download size={14} />
{t('calendar.downloadICS')}
</button>
{showDetail.is_owner && (
<>
<button onClick={() => openEdit(showDetail)} className="btn-ghost text-xs py-1.5 px-3">
<Edit size={14} />
{t('common.edit')}
</button>
<button onClick={() => openShareModal(showDetail)} className="btn-ghost text-xs py-1.5 px-3">
<Share2 size={14} />
{t('calendar.share')}
</button>
<button onClick={() => { setShowFedShare(showDetail); setShowDetail(null); }} className="btn-ghost text-xs py-1.5 px-3">
<Globe size={14} />
{t('calendar.sendFederated')}
</button>
<button onClick={() => handleDelete(showDetail)} className="btn-ghost text-xs py-1.5 px-3 text-th-error hover:text-th-error">
<Trash2 size={14} />
{t('common.delete')}
</button>
</>
)}
</div>
</div>
</Modal>
)}
{/* Share Modal */}
{showShare && (
<Modal title={t('calendar.shareEvent')} onClose={() => setShowShare(null)}>
<div className="space-y-4">
<div className="relative">
<UserPlus size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={shareSearch}
onChange={e => searchUsers(e.target.value)}
className="input-field pl-11"
placeholder={t('room.shareSearchPlaceholder')}
/>
{shareResults.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-th-card border border-th-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
{shareResults.map(u => (
<button
key={u.id}
onClick={() => handleShare(u.id)}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
<div className="text-xs text-th-text-s truncate">{u.email}</div>
</div>
</button>
))}
</div>
)}
</div>
{pendingInvitations.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold text-th-text-s uppercase tracking-wider">{t('calendar.pendingInvitations')}</p>
{pendingInvitations.map(u => (
<div key={u.user_id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border border-dashed">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
<div className="text-xs text-th-warning">{t('calendar.invitationPending')}</div>
</div>
</div>
<button onClick={() => handleCancelInvitation(u.user_id)} className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors">
<X size={16} />
</button>
</div>
))}
</div>
)}
{sharedUsers.length > 0 && (
<div className="space-y-2">
{pendingInvitations.length > 0 && (
<p className="text-xs font-semibold text-th-text-s uppercase tracking-wider">{t('calendar.accepted')}</p>
)}
{sharedUsers.map(u => (
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
<div className="text-xs text-th-text-s truncate">{u.email}</div>
</div>
</div>
<button onClick={() => handleUnshare(u.id)} className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors">
<X size={16} />
</button>
</div>
))}
</div>
)}
</div>
</Modal>
)}
{/* Federation Share Modal */}
{showFedShare && (
<Modal title={t('calendar.sendFederatedTitle')} onClose={() => setShowFedShare(null)}>
<p className="text-sm text-th-text-s mb-4">{t('calendar.sendFederatedDesc')}</p>
<form onSubmit={handleFedSend} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
<input
type="text"
value={fedAddress}
onChange={e => setFedAddress(e.target.value)}
className="input-field"
placeholder={t('federation.addressPlaceholder')}
required
/>
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
</div>
<div className="flex items-center gap-3 pt-2 border-t border-th-border">
<button type="button" onClick={() => setShowFedShare(null)} className="btn-secondary flex-1">
{t('common.cancel')}
</button>
<button type="submit" disabled={fedSending} className="btn-primary flex-1">
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
{t('calendar.send')}
</button>
</div>
</form>
</Modal>
)}
</div>
);
}
// 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) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
return `${y}-${m}-${d}T${h}:${min}`;
}
function formatTime(dateStr) {
const d = new Date(dateStr);
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"
placeholder={t('dashboard.roomNamePlaceholder')}
required
minLength={2}
/>
</div>

View File

@@ -39,7 +39,17 @@ export default function FederatedRoomDetail() {
}, [id]);
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 () => {

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Globe, Mail, Check, X, ExternalLink, Loader2, Inbox } from 'lucide-react';
import { useState, useEffect } from 'react';
import { Globe, Mail, Check, X, ExternalLink, Loader2, Inbox, Calendar, Trash2 } from 'lucide-react';
import api from '../services/api';
import { useLanguage } from '../contexts/LanguageContext';
import toast from 'react-hot-toast';
@@ -7,12 +7,20 @@ import toast from 'react-hot-toast';
export default function FederationInbox() {
const { t } = useLanguage();
const [invitations, setInvitations] = useState([]);
const [calendarInvitations, setCalendarInvitations] = useState([]);
const [localCalInvitations, setLocalCalInvitations] = useState([]);
const [loading, setLoading] = useState(true);
const fetchInvitations = async () => {
try {
const res = await api.get('/federation/invitations');
setInvitations(res.data.invitations || []);
const [roomRes, calRes, localCalRes] = await Promise.all([
api.get('/federation/invitations'),
api.get('/federation/calendar-invitations').catch(() => ({ data: { invitations: [] } })),
api.get('/calendar/local-invitations').catch(() => ({ data: { invitations: [] } })),
]);
setInvitations(roomRes.data.invitations || []);
setCalendarInvitations(calRes.data.invitations || []);
setLocalCalInvitations(localCalRes.data.invitations || []);
} catch {
toast.error(t('federation.loadFailed'));
} finally {
@@ -24,6 +32,7 @@ export default function FederationInbox() {
fetchInvitations();
}, []);
// ── Room invitation actions ──────────────────────────────────────────────
const handleAccept = async (id) => {
try {
await api.post(`/federation/invitations/${id}/accept`);
@@ -44,6 +53,78 @@ export default function FederationInbox() {
}
};
const handleDeleteInvitation = async (id) => {
try {
await api.delete(`/federation/invitations/${id}`);
toast.success(t('federation.invitationRemoved'));
fetchInvitations();
} catch {
toast.error(t('federation.declineFailed'));
}
};
// ── Calendar invitation actions ──────────────────────────────────────────
const handleCalAccept = async (id) => {
try {
await api.post(`/federation/calendar-invitations/${id}/accept`);
toast.success(t('federation.calendarAccepted'));
fetchInvitations();
} catch {
toast.error(t('federation.acceptFailed'));
}
};
const handleCalDecline = async (id) => {
try {
await api.delete(`/federation/calendar-invitations/${id}`);
toast.success(t('federation.declined'));
fetchInvitations();
} catch {
toast.error(t('federation.declineFailed'));
}
};
const handleCalDelete = async (id) => {
try {
await api.delete(`/federation/calendar-invitations/${id}`);
toast.success(t('federation.invitationRemoved'));
fetchInvitations();
} catch {
toast.error(t('federation.declineFailed'));
}
};
// ── Local calendar invitation actions ───────────────────────────────────
const handleLocalCalAccept = async (id) => {
try {
await api.post(`/calendar/local-invitations/${id}/accept`);
toast.success(t('federation.calendarLocalAccepted'));
fetchInvitations();
} catch {
toast.error(t('federation.acceptFailed'));
}
};
const handleLocalCalDecline = async (id) => {
try {
await api.delete(`/calendar/local-invitations/${id}`);
toast.success(t('federation.declined'));
fetchInvitations();
} catch {
toast.error(t('federation.declineFailed'));
}
};
const handleLocalCalDelete = async (id) => {
try {
await api.delete(`/calendar/local-invitations/${id}`);
toast.success(t('federation.invitationRemoved'));
fetchInvitations();
} catch {
toast.error(t('federation.declineFailed'));
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@@ -52,8 +133,15 @@ export default function FederationInbox() {
);
}
const pending = invitations.filter(i => i.status === 'pending');
const past = invitations.filter(i => i.status !== 'pending');
const pendingRooms = invitations.filter(i => i.status === 'pending');
const pastRooms = invitations.filter(i => i.status !== 'pending');
const pendingCal = calendarInvitations.filter(i => i.status === 'pending');
const pastCal = calendarInvitations.filter(i => i.status !== 'pending');
const pendingLocalCal = localCalInvitations.filter(i => i.status === 'pending');
const pastLocalCal = localCalInvitations.filter(i => i.status !== 'pending');
const totalPending = pendingRooms.length + pendingCal.length + pendingLocalCal.length;
const totalPast = pastRooms.length + pastCal.length + pastLocalCal.length;
return (
<div>
@@ -67,14 +155,15 @@ export default function FederationInbox() {
</div>
{/* Pending invitations */}
{pending.length > 0 && (
{totalPending > 0 && (
<div className="mb-8">
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
{t('federation.pending')} ({pending.length})
{t('federation.pending')} ({totalPending})
</h2>
<div className="space-y-3">
{pending.map(inv => (
<div key={inv.id} className="card p-5 border-l-4 border-l-th-accent">
{/* Pending room invitations */}
{pendingRooms.map(inv => (
<div key={`room-${inv.id}`} className="card p-5 border-l-4 border-l-th-accent">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
@@ -92,17 +181,89 @@ export default function FederationInbox() {
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => handleAccept(inv.id)}
className="btn-primary text-sm"
>
<button onClick={() => handleAccept(inv.id)} className="btn-primary text-sm">
<Check size={16} />
{t('federation.accept')}
</button>
<button
onClick={() => handleDecline(inv.id)}
className="btn-secondary text-sm"
>
<button onClick={() => handleDecline(inv.id)} className="btn-secondary text-sm">
<X size={16} />
{t('federation.decline')}
</button>
</div>
</div>
</div>
))}
{/* Pending calendar invitations */}
{pendingCal.map(inv => (
<div key={`cal-${inv.id}`} className="card p-5 border-l-4 border-l-th-success">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<Calendar size={16} className="text-th-success flex-shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wide text-th-success mr-1">
{t('federation.calendarEvent')}
</span>
<h3 className="text-base font-semibold text-th-text truncate">{inv.title}</h3>
</div>
<p className="text-sm text-th-text-s">
{t('federation.from')}: <span className="font-medium text-th-text">{inv.from_user}</span>
</p>
<p className="text-sm text-th-text-s mt-1">
{new Date(inv.start_time).toLocaleString()} - {new Date(inv.end_time).toLocaleString()}
</p>
{inv.description && (
<p className="text-sm text-th-text-s mt-1 italic">"{inv.description}"</p>
)}
<p className="text-xs text-th-text-s mt-1">
{new Date(inv.created_at).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={() => handleCalAccept(inv.id)} className="btn-primary text-sm">
<Check size={16} />
{t('federation.accept')}
</button>
<button onClick={() => handleCalDecline(inv.id)} className="btn-secondary text-sm">
<X size={16} />
{t('federation.decline')}
</button>
</div>
</div>
</div>
))}
{/* Pending local calendar invitations */}
{pendingLocalCal.map(inv => (
<div key={`localcal-${inv.id}`} className="card p-5 border-l-4 border-l-th-accent">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<Calendar size={16} className="text-th-accent flex-shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wide text-th-accent mr-1">
{t('federation.localCalendarEvent')}
</span>
<h3 className="text-base font-semibold text-th-text truncate">{inv.title}</h3>
</div>
<p className="text-sm text-th-text-s">
{t('federation.from')}: <span className="font-medium text-th-text">{inv.from_name}</span>
</p>
<p className="text-sm text-th-text-s mt-1">
{new Date(inv.start_time).toLocaleString()} - {new Date(inv.end_time).toLocaleString()}
</p>
{inv.description && (
<p className="text-sm text-th-text-s mt-1 italic">"{inv.description}"</p>
)}
<p className="text-xs text-th-text-s mt-1">
{new Date(inv.created_at).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={() => handleLocalCalAccept(inv.id)} className="btn-primary text-sm">
<Check size={16} />
{t('federation.accept')}
</button>
<button onClick={() => handleLocalCalDecline(inv.id)} className="btn-secondary text-sm">
<X size={16} />
{t('federation.decline')}
</button>
@@ -115,24 +276,28 @@ export default function FederationInbox() {
)}
{/* Past invitations */}
{past.length > 0 && (
{totalPast > 0 && (
<div className="mb-8">
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
{t('federation.previousInvites')}
</h2>
<div className="space-y-2">
{past.map(inv => (
<div key={inv.id} className="card p-4 opacity-60">
{/* Past room invitations */}
{pastRooms.map(inv => (
<div key={`room-past-${inv.id}`} className="card p-4 opacity-70">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3>
<p className="text-xs text-th-text-s">{inv.from_user}</p>
<div className="min-w-0 flex items-center gap-2">
<Mail size={14} className="text-th-text-s flex-shrink-0" />
<div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3>
<p className="text-xs text-th-text-s">{inv.from_user}</p>
</div>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<div className="flex items-center gap-2 flex-shrink-0">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error'
}`}>
? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error'
}`}>
{inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')}
</span>
{inv.status === 'accepted' && (
@@ -144,6 +309,82 @@ export default function FederationInbox() {
<ExternalLink size={14} />
</button>
)}
<button
onClick={() => handleDeleteInvitation(inv.id)}
className="btn-ghost text-xs py-1.5 px-2 text-th-text-s hover:text-th-error"
title={t('federation.removeInvitation')}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
))}
{/* Past calendar invitations */}
{pastCal.map(inv => (
<div key={`cal-past-${inv.id}`} className="card p-4 opacity-70">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex items-center gap-2">
<Calendar size={14} className="text-th-text-s flex-shrink-0" />
<div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
<p className="text-xs text-th-text-s">{inv.from_user} · {new Date(inv.start_time).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error'
}`}>
{inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')}
</span>
{inv.status === 'accepted' && inv.join_url && (
<button
onClick={() => window.open(inv.join_url, '_blank')}
className="btn-ghost text-xs py-1.5 px-2"
title={t('federation.openLink')}
>
<ExternalLink size={14} />
</button>
)}
<button
onClick={() => handleCalDelete(inv.id)}
className="btn-ghost text-xs py-1.5 px-2 text-th-text-s hover:text-th-error"
title={t('federation.removeInvitation')}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
))}
{/* Past local calendar invitations */}
{pastLocalCal.map(inv => (
<div key={`localcal-past-${inv.id}`} className="card p-4 opacity-70">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex items-center gap-2">
<Calendar size={14} className="text-th-text-s flex-shrink-0" />
<div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
<p className="text-xs text-th-text-s">{inv.from_name} · {new Date(inv.start_time).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error'
}`}>
{inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')}
</span>
<button
onClick={() => handleLocalCalDelete(inv.id)}
className="btn-ghost text-xs py-1.5 px-2 text-th-text-s hover:text-th-error"
title={t('federation.removeInvitation')}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
@@ -153,7 +394,7 @@ export default function FederationInbox() {
)}
{/* Empty state */}
{invitations.length === 0 && (
{totalPending === 0 && totalPast === 0 && (
<div className="card p-12 text-center">
<Inbox size={48} className="mx-auto text-th-text-s/40 mb-4" />
<h3 className="text-lg font-semibold text-th-text mb-2">{t('federation.noInvitations')}</h3>
@@ -163,3 +404,4 @@ export default function FederationInbox() {
</div>
);
}

View File

@@ -1,26 +1,56 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle } from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import { useParams, Link, useSearchParams } from 'react-router-dom';
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText, Clock, X } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import api from '../services/api';
import toast from 'react-hot-toast';
import { useLanguage } from '../contexts/LanguageContext';
import { useAuth } from '../contexts/AuthContext';
import { useBranding } from '../contexts/BrandingContext';
export default function GuestJoin() {
const { uid } = useParams();
const [searchParams] = useSearchParams();
const { t } = useLanguage();
const { user } = useAuth();
const { imprintUrl, privacyUrl } = useBranding();
const isLoggedIn = !!user;
const [roomInfo, setRoomInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [joining, setJoining] = useState(false);
const [name, setName] = useState(user?.name || '');
const [accessCode, setAccessCode] = useState('');
const [accessCode, setAccessCode] = useState(searchParams.get('ac') || '');
const [moderatorCode, setModeratorCode] = useState('');
const [status, setStatus] = useState({ running: 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(() => {
const fetchRoom = async () => {
@@ -28,6 +58,7 @@ export default function GuestJoin() {
const res = await api.get(`/rooms/${uid}/public`);
setRoomInfo(res.data.room);
setStatus({ running: res.data.running });
prevRunningRef.current = res.data.running;
} catch (err) {
const status = err.response?.status;
if (status === 403) {
@@ -50,45 +81,36 @@ export default function GuestJoin() {
} catch {
// ignore
}
}, 10000);
}, 5000);
return () => clearInterval(interval);
}, [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) => {
e.preventDefault();
if (!name.trim()) {
toast.error(t('room.guestNameRequired'));
return;
}
if (roomInfo?.allow_recording && !recordingConsent) {
toast.error(t('room.guestRecordingConsent'));
return;
}
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 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);
if (!status.running && !roomInfo?.anyone_can_start) {
setWaiting(true);
return;
}
await joinMeeting();
};
if (loading) {
@@ -161,97 +183,125 @@ export default function GuestJoin() {
</div>
{/* Join form */}
<form onSubmit={handleJoin} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label>
<div className="relative">
<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}
/>
{waiting ? (
<div className="flex flex-col items-center gap-5 py-4">
<div className="w-16 h-16 rounded-full flex items-center justify-center bg-th-accent/10">
<Clock size={28} className="text-th-accent animate-pulse" />
</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>
{roomInfo.has_access_code && (
) : (
<form onSubmit={handleJoin} className="space-y-4">
<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">
<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
type="text"
value={accessCode}
onChange={e => setAccessCode(e.target.value)}
className="input-field pl-11"
placeholder={t('room.guestAccessCodePlaceholder')}
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>
<label className="block text-sm font-medium text-th-text mb-1.5">
{t('room.guestModeratorCode')}
<span className="text-th-text-s font-normal ml-1">{t('room.guestModeratorOptional')}</span>
</label>
<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>
{roomInfo.has_access_code && (
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</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="text"
value={accessCode}
onChange={e => setAccessCode(e.target.value)}
className="input-field pl-11"
placeholder={t('room.guestAccessCodePlaceholder')}
/>
</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 && (
<p className="text-xs text-th-text-s text-center">
{t('room.guestWaitingMessage')}
</p>
)}
</form>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">
{t('room.guestModeratorCode')}
<span className="text-th-text-s font-normal ml-1">{t('room.guestModeratorOptional')}</span>
</label>
<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 && (
<div className="mt-6 pt-4 border-t border-th-border text-center">
@@ -260,6 +310,36 @@ export default function GuestJoin() {
</Link>
</div>
)}
{(imprintUrl || privacyUrl) && (
<div className="flex items-center justify-center gap-4 mt-4 pt-4 border-t border-th-border/60">
{imprintUrl && (
<a
href={imprintUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
>
<FileText size={11} />
{t('nav.imprint')}
</a>
)}
{imprintUrl && privacyUrl && (
<span className="text-th-border text-xs">·</span>
)}
{privacyUrl && (
<a
href={privacyUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
>
<Lock size={11} />
{t('nav.privacy')}
</a>
)}
</div>
)}
</div>
</div>
</div>

View File

@@ -1,10 +1,13 @@
import { Link } from 'react-router-dom';
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe } from 'lucide-react';
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe, FileText, Lock } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import { useLanguage } from '../contexts/LanguageContext';
import { useBranding } from '../contexts/BrandingContext';
export default function Home() {
const { t } = useLanguage();
const { registrationMode, imprintUrl, privacyUrl } = useBranding();
const isInviteOnly = registrationMode === 'invite';
const features = [
{
@@ -54,10 +57,12 @@ export default function Home() {
<Link to="/login" className="btn-ghost text-sm">
{t('auth.login')}
</Link>
<Link to="/register" className="btn-primary text-sm">
{t('auth.register')}
<ArrowRight size={16} />
</Link>
{!isInviteOnly && (
<Link to="/register" className="btn-primary text-sm">
{t('auth.register')}
<ArrowRight size={16} />
</Link>
)}
</div>
</nav>
@@ -65,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="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} />
{t('home.poweredBy')}
{t('home.madeFor')}
</div>
<h1 className="text-4xl md:text-6xl lg:text-7xl font-extrabold text-th-text mb-6 leading-tight tracking-tight">
@@ -78,11 +83,13 @@ export default function Home() {
</p>
<div className="flex items-center gap-4 justify-center">
<Link to="/register" className="btn-primary text-base px-8 py-3">
{t('home.getStarted')}
<ArrowRight size={18} />
</Link>
<Link to="/login" className="btn-secondary text-base px-8 py-3">
{!isInviteOnly && (
<Link to="/register" className="btn-primary text-base px-8 py-3">
{t('home.getStarted')}
<ArrowRight size={18} />
</Link>
)}
<Link to="/login" className={`${isInviteOnly ? 'btn-primary' : 'btn-secondary'} text-base px-8 py-3`}>
{t('auth.login')}
</Link>
</div>
@@ -136,6 +143,35 @@ export default function Home() {
<p className="text-sm text-th-text-s">
{t('home.footer', { year: new Date().getFullYear() })}
</p>
{(imprintUrl || privacyUrl) && (
<div className="flex items-center justify-center gap-4 mt-3">
{imprintUrl && (
<a
href={imprintUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
>
<FileText size={12} />
{t('nav.imprint')}
</a>
)}
{imprintUrl && privacyUrl && (
<span className="text-th-border text-xs">·</span>
)}
{privacyUrl && (
<a
href={privacyUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
>
<Lock size={12} />
{t('nav.privacy')}
</a>
)}
</div>
)}
</footer>
</div>
);

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw } from 'lucide-react';
import { useBranding } from '../contexts/BrandingContext';
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw, LogIn, ShieldCheck } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import api from '../services/api';
import toast from 'react-hot-toast';
@@ -14,8 +15,16 @@ export default function Login() {
const [needsVerification, setNeedsVerification] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0);
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 { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
const navigate = useNavigate();
useEffect(() => {
@@ -24,6 +33,13 @@ export default function Login() {
return () => clearTimeout(timer);
}, [resendCooldown]);
// Auto-focus TOTP input when 2FA screen appears
useEffect(() => {
if (needs2FA && totpInputRef.current) {
totpInputRef.current.focus();
}
}, [needs2FA]);
const handleResend = async () => {
if (resendCooldown > 0 || resending) return;
setResending(true);
@@ -46,7 +62,13 @@ export default function Login() {
e.preventDefault();
setLoading(true);
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'));
navigate('/dashboard');
} catch (err) {
@@ -60,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 (
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
{/* Animated background */}
@@ -79,89 +122,171 @@ export default function Login() {
<BrandLogo size="lg" />
</div>
<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>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<div className="relative">
<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
/>
{needs2FA ? (
<>
{/* 2FA verification step */}
<div className="mb-8 text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-th-accent/10 mb-4">
<ShieldCheck size={28} className="text-th-accent" />
</div>
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.2fa.title')}</h2>
<p className="text-th-text-s text-sm">
{t('auth.2fa.prompt')}
</p>
</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
/>
<form onSubmit={handle2FASubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.2fa.codeLabel')}</label>
<div className="relative">
<ShieldCheck size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
ref={totpInputRef}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
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>
<button
type="submit"
disabled={loading}
className="btn-primary w-full py-3"
>
{loading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<div className="relative">
<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>
<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')}
<ArrowRight size={18} />
<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.loginWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
</a>
</>
)}
</button>
</form>
{needsVerification && (
<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">
<AlertTriangle size={16} className="text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p>
</div>
<button
onClick={handleResend}
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"
>
<RefreshCw size={13} className={resending ? 'animate-spin' : ''} />
{resendCooldown > 0
? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown)
: t('auth.emailVerificationResend')}
</button>
</div>
{needsVerification && (
<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">
<AlertTriangle size={16} className="text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p>
</div>
<button
onClick={handleResend}
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"
>
<RefreshCw size={13} className={resending ? 'animate-spin' : ''} />
{resendCooldown > 0
? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown)
: t('auth.emailVerificationResend')}
</button>
</div>
)}
{registrationMode !== 'invite' && (
<p className="mt-6 text-center text-sm text-th-text-s">
{t('auth.noAccount')}{' '}
<Link to="/register" className="text-th-accent hover:underline font-medium">
{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>
</>
)}
<p className="mt-6 text-center text-sm text-th-text-s">
{t('auth.noAccount')}{' '}
<Link to="/register" className="text-th-accent hover:underline font-medium">
{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>
</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

@@ -1,12 +1,15 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle } from 'lucide-react';
import { useBranding } from '../contexts/BrandingContext';
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle, ShieldAlert, LogIn } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import toast from 'react-hot-toast';
export default function Register() {
const [searchParams] = useSearchParams();
const inviteToken = searchParams.get('invite') || '';
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
@@ -16,8 +19,12 @@ export default function Register() {
const [needsVerification, setNeedsVerification] = useState(false);
const { register } = useAuth();
const { t } = useLanguage();
const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
const navigate = useNavigate();
// Invite-only mode without a token → show blocked message
const isBlocked = registrationMode === 'invite' && !inviteToken;
const handleSubmit = async (e) => {
e.preventDefault();
@@ -26,14 +33,14 @@ export default function Register() {
return;
}
if (password.length < 6) {
if (password.length < 8) {
toast.error(t('auth.passwordTooShort'));
return;
}
setLoading(true);
try {
const result = await register(username, displayName, email, password);
const result = await register(username, displayName, email, password, inviteToken);
if (result?.needsVerification) {
setNeedsVerification(true);
toast.success(t('auth.verificationSent'));
@@ -77,6 +84,15 @@ export default function Register() {
{t('auth.login')}
</Link>
</div>
) : isBlocked ? (
<div className="text-center space-y-4">
<ShieldAlert size={48} className="mx-auto text-amber-400" />
<h2 className="text-2xl font-bold text-th-text">{t('auth.inviteOnly')}</h2>
<p className="text-th-text-s">{t('auth.inviteOnlyDesc')}</p>
<Link to="/login" className="btn-primary inline-flex items-center gap-2 mt-4">
{t('auth.login')}
</Link>
</div>
) : (
<>
<div className="mb-8">
@@ -181,6 +197,26 @@ export default function Register() {
</button>
</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">
{t('auth.hasAccount')}{' '}
<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,
Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck,
Shield, Save, UserPlus, X, Share2, Globe, Send,
FileText, Upload, Trash2,
FileText, Upload, Trash2, Link, BarChart3,
} from 'lucide-react';
import Modal from '../components/Modal';
import api from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import RecordingList from '../components/RecordingList';
import AnalyticsList from '../components/AnalyticsList';
import toast from 'react-hot-toast';
export default function RoomDetail() {
@@ -22,6 +23,7 @@ export default function RoomDetail() {
const [room, setRoom] = useState(null);
const [status, setStatus] = useState({ running: false, participantCount: 0, moderatorCount: 0 });
const [recordings, setRecordings] = useState([]);
const [analytics, setAnalytics] = useState([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(null);
const [activeTab, setActiveTab] = useState('overview');
@@ -31,6 +33,20 @@ export default function RoomDetail() {
const [shareSearch, setShareSearch] = useState('');
const [shareResults, setShareResults] = useState([]);
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
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(() => {
fetchRoom();
fetchStatus();
fetchRecordings();
fetchAnalytics();
const interval = setInterval(fetchStatus, 10000);
return () => clearInterval(interval);
}, [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 () => {
setActionLoading('start');
try {
@@ -104,6 +145,12 @@ export default function RoomDetail() {
};
const handleJoin = async () => {
if (!status.running) {
setWaitingToJoin(true);
toast(t('room.guestWaitingTitle'), { icon: '🕐' });
return;
}
setWaitingToJoin(false);
setActionLoading('join');
try {
const data = room.access_code ? { access_code: prompt(t('room.enterAccessCode')) } : {};
@@ -148,6 +195,8 @@ export default function RoomDetail() {
record_meeting: !!editRoom.record_meeting,
guest_access: !!editRoom.guest_access,
moderator_code: editRoom.moderator_code,
learning_analytics: !!editRoom.learning_analytics,
analytics_visibility: editRoom.analytics_visibility || 'owner',
});
setRoom(res.data.room);
setEditRoom(res.data.room);
@@ -159,9 +208,10 @@ export default function RoomDetail() {
}
};
const copyLink = () => {
navigator.clipboard.writeText(`${window.location.origin}/rooms/${uid}`);
const copyToClipboard = (url) => {
navigator.clipboard.writeText(url);
toast.success(t('room.linkCopied'));
setShowCopyMenu(false);
};
// Federation invite handler
@@ -295,6 +345,7 @@ export default function RoomDetail() {
const tabs = [
{ id: 'overview', label: t('room.overview'), icon: Play },
{ 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 }] : []),
];
@@ -333,10 +384,33 @@ export default function RoomDetail() {
{t('common.protected')}
</span>
)}
<button onClick={copyLink} className="flex items-center gap-1 hover:text-th-accent transition-colors">
<Copy size={14} />
{t('room.copyLink')}
</button>
<div className="relative" ref={copyMenuRef}>
<button
onClick={() => setShowCopyMenu(v => !v)}
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>
@@ -351,18 +425,21 @@ export default function RoomDetail() {
<span className="hidden sm:inline">{t('federation.inviteRemote')}</span>
</button>
)}
{canManage && !status.running && (
{canManage && !status.running && !waitingToJoin && (
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
{t('room.start')}
</button>
)}
{status.running && (
<button onClick={handleJoin} disabled={actionLoading === 'join'} className="btn-primary">
{actionLoading === 'join' ? <Loader2 size={16} className="animate-spin" /> : <ExternalLink size={16} />}
{t('room.join')}
</button>
)}
<button
onClick={waitingToJoin ? () => setWaitingToJoin(false) : handleJoin}
disabled={actionLoading === 'join'}
className={waitingToJoin ? 'btn-ghost' : 'btn-primary'}
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 && (
<button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger">
{actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />}
@@ -375,7 +452,7 @@ export default function RoomDetail() {
{/* Tabs */}
<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
key={tab.id}
onClick={() => setActiveTab(tab.id)}
@@ -466,6 +543,10 @@ export default function RoomDetail() {
<RecordingList recordings={recordings} onRefresh={fetchRecordings} />
)}
{activeTab === 'analytics' && (
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} isOwner={isOwner} />
)}
{activeTab === 'settings' && isOwner && editRoom && (
<form onSubmit={handleSaveSettings} className="card p-6 space-y-5 max-w-2xl">
<div>
@@ -476,6 +557,7 @@ export default function RoomDetail() {
onChange={e => setEditRoom({ ...editRoom, name: e.target.value })}
className="input-field"
required
minLength={2}
/>
</div>
@@ -558,6 +640,29 @@ export default function RoomDetail() {
/>
<span className="text-sm text-th-text">{t('room.allowRecording')}</span>
</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>
{/* Guest access section */}

View File

@@ -1,5 +1,5 @@
import { useState, useRef } from 'react';
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X } from 'lucide-react';
import { useState, useRef, useEffect } from '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 { useTheme } from '../contexts/ThemeContext';
import { useLanguage } from '../contexts/LanguageContext';
@@ -38,6 +38,121 @@ export default function Settings() {
const [uploadingAvatar, setUploadingAvatar] = useState(false);
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 avatarColors = [
@@ -137,8 +252,10 @@ export default function Settings() {
const sections = [
{ id: 'profile', label: t('settings.profile'), icon: User },
{ 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: 'themes', label: t('settings.themes'), icon: Palette },
{ id: 'caldav', label: t('settings.caldav.title'), icon: Calendar },
];
return (
@@ -363,6 +480,147 @@ export default function Settings() {
</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 */}
{activeSection === 'language' && (
<div className="card p-6">
@@ -425,8 +683,126 @@ export default function Settings() {
))}
</div>
)}
</div>
</div>
{/* CalDAV section */}
{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>
);
}
}

View File

@@ -111,6 +111,62 @@ export const themes = [
group: 'Community',
colors: { bg: '#161924', accent: '#b30051', text: '#dadada' },
},
{
id: 'red-modular-light',
name: 'Red Modular Light',
type: 'light',
group: 'Community',
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) {