Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5472e190d9 | |||
| 45be976de1 | |||
| 6dcb1e959b | |||
| bb2d179871 | |||
| 82b7d060ba | |||
| 0836436fe7 | |||
| 99d3b22f62 | |||
| eed5d98ccc | |||
| 6513fdee41 | |||
| cae84754e4 | |||
| a0a972b53a | |||
| 9b98803053 | |||
| e43e7f5fc5 | |||
| 5731e6a9a8 | |||
| fa8292263c | |||
| 4bc3403040 | |||
| e4f596f8c3 | |||
| 00e563664e | |||
| 41ad3e037a | |||
| 7ef173c49e | |||
| 530377272b | |||
| 52f122a98a | |||
| c2dcb02e9b | |||
| 71557280f5 | |||
| 03e484b8c6 | |||
| 14ed0c3689 | |||
| 5fc64330e0 | |||
| 3ab7ab6a70 | |||
| a7b0b84f2d | |||
| 11d3972a74 | |||
| d8c52aae4e | |||
| f16fd9aef2 | |||
| 8edcb7d3df | |||
| 6aa01d39f4 | |||
| bb4da19f4f | |||
| e8d8ccda42 | |||
| 1d647d0a36 | |||
| e3a5f21c8b | |||
| 014de634b1 | |||
| 268f6d0c5a | |||
| 7018c5579f | |||
| 2a7754dd56 | |||
| a78fc06f2b | |||
| 15bfcc80c3 | |||
| fcb83a9b72 | |||
| a69b2e4d9a | |||
| 0d84610e3b | |||
| 8823f8789e | |||
| ce2cf499dc | |||
| bac4e8ae7c | |||
| 43d94181f9 | |||
| 61274d31f1 | |||
| 3d21967681 | |||
| 2d919cdc67 | |||
| 6e301e2928 | |||
| cdfc585c8a | |||
| e22a895672 | |||
| ba096a31a2 | |||
| d886725c4f | |||
| f3ef490012 | |||
| ddc0c684ec | |||
| 68f31467af | |||
| 05f2941b16 | |||
| 4bb22be496 | |||
| 1c9c5224ae | |||
| 2b8c179d03 | |||
| df82316097 | |||
| e4001cb33f | |||
| 4a4ec0a3a3 | |||
| 9be3be7712 | |||
| dc7a78badb | |||
| 272c5dc2cc | |||
| c13090bc80 | |||
| 304349fce8 | |||
| b5218046c9 | |||
| c2c10f9a4b | |||
| d989e1291d | |||
| 62a3812424 | |||
| 2a8ded5211 | |||
| 9275c20d19 | |||
| af7540eb8c | |||
| 13c60ba052 | |||
| fae46c8395 | |||
| bfec8de195 | |||
| 25b13b4078 |
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- federation-try
|
||||
- develop-calendar
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
|
||||
27
Dockerfile
27
Dockerfile
@@ -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
347
README.md
@@ -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.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## ✨ 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
35
compose.yml
35
compose.yml
@@ -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
694
migrate-from-greenlight.mjs
Normal 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
3449
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "redlight",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"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
13
public/sounds/README.md
Normal 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.
|
||||
BIN
public/sounds/meeting-started.mp3
Normal file
BIN
public/sounds/meeting-started.mp3
Normal file
Binary file not shown.
BIN
public/sounds/notification.mp3
Normal file
BIN
public/sounds/notification.mp3
Normal file
Binary file not shown.
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -438,19 +454,384 @@ export async function initDatabase() {
|
||||
`);
|
||||
}
|
||||
|
||||
// ── 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';
|
||||
|
||||
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']
|
||||
);
|
||||
// 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')");
|
||||
log.db.info(`Default admin created: ${adminEmail}`);
|
||||
await db.run("INSERT INTO settings (key, value) VALUES ('admin_seeded', '1') RETURNING key");
|
||||
}
|
||||
}
|
||||
|
||||
52
server/config/emaili18n.js
Normal file
52
server/config/emaili18n.js
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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,40 +114,126 @@ 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;">"${safeMessage}"</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;">"${safeDesc}"</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")
|
||||
* @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') {
|
||||
export async function sendInviteEmail(to, inviteUrl, appName = 'Redlight', lang = 'en') {
|
||||
if (!transporter) {
|
||||
throw new Error('SMTP not configured');
|
||||
}
|
||||
@@ -155,30 +242,33 @@ export async function sendInviteEmail(to, inviteUrl, appName = 'Redlight') {
|
||||
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: `${headerAppName} – You've been invited`,
|
||||
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;">You've been invited! 🎉</h2>
|
||||
<p>You have been invited to create an account on <strong style="color:#cdd6f4;">${safeAppName}</strong>.</p>
|
||||
<p>Click the button below to register:</p>
|
||||
<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;">
|
||||
Create Account
|
||||
${t(lang, 'email.invite.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="${inviteUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(inviteUrl)}</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">This link is valid for 7 days.</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;">If you didn't expect this invitation, you can safely ignore this email.</p>
|
||||
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.invite.footer')}</p>
|
||||
</div>
|
||||
`,
|
||||
text: `You've been invited to create an account on ${appName}.\n\nRegister here: ${inviteUrl}\n\nThis link is valid for 7 days.\n\n– ${appName}`,
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
|
||||
23
server/config/notifications.js
Normal file
23
server/config/notifications.js
Normal 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
275
server/config/oauth.js
Normal 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
39
server/i18n/de.json
Normal 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
39
server/i18n/en.json
Normal 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}."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
90
server/jobs/calendarReminders.js
Normal file
90
server/jobs/calendarReminders.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
|
||||
@@ -1,10 +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,}$/;
|
||||
|
||||
@@ -26,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 - (3–30 chars)' });
|
||||
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
@@ -51,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]
|
||||
@@ -156,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' });
|
||||
@@ -202,7 +208,7 @@ router.post('/invites', authenticateToken, requireAdmin, async (req, res) => {
|
||||
);
|
||||
|
||||
// Send invite email if SMTP is configured
|
||||
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const inviteUrl = `${baseUrl}/register?invite=${token}`;
|
||||
|
||||
// Load app name
|
||||
@@ -211,7 +217,7 @@ router.post('/invites', authenticateToken, requireAdmin, async (req, res) => {
|
||||
|
||||
if (isMailerConfigured()) {
|
||||
try {
|
||||
await sendInviteEmail(email.toLowerCase(), inviteUrl, appName);
|
||||
await sendInviteEmail(email.toLowerCase(), inviteUrl, appName, 'en');
|
||||
} catch (mailErr) {
|
||||
log.admin.warn(`Invite email failed (non-fatal): ${mailErr.message}`);
|
||||
}
|
||||
@@ -260,4 +266,100 @@ router.delete('/invites/:id', authenticateToken, requireAdmin, async (req, res)
|
||||
}
|
||||
});
|
||||
|
||||
// ── OAuth / SSO Configuration (admin only) ──────────────────────────────────
|
||||
|
||||
// GET /api/admin/oauth - Get current OAuth configuration
|
||||
router.get('/oauth', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const config = await getOAuthConfig();
|
||||
if (!config) {
|
||||
return res.json({ configured: false, config: null });
|
||||
}
|
||||
// Never expose the decrypted client secret to the frontend
|
||||
res.json({
|
||||
configured: true,
|
||||
config: {
|
||||
issuer: config.issuer,
|
||||
clientId: config.clientId,
|
||||
hasClientSecret: !!config.clientSecret,
|
||||
displayName: config.displayName || 'SSO',
|
||||
autoRegister: config.autoRegister ?? true,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
log.admin.error(`Get OAuth config error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Could not load OAuth configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/admin/oauth - Save OAuth configuration
|
||||
router.put('/oauth', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { issuer, clientId, clientSecret, displayName, autoRegister } = req.body;
|
||||
|
||||
if (!issuer || !clientId) {
|
||||
return res.status(400).json({ error: 'Issuer URL and Client ID are required' });
|
||||
}
|
||||
|
||||
// Validate issuer URL
|
||||
try {
|
||||
const parsed = new URL(issuer);
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
return res.status(400).json({ error: 'Issuer URL must use https:// (or http:// for development)' });
|
||||
}
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'Invalid Issuer URL' });
|
||||
}
|
||||
|
||||
// Validate display name length
|
||||
if (displayName && displayName.length > 50) {
|
||||
return res.status(400).json({ error: 'Display name must not exceed 50 characters' });
|
||||
}
|
||||
|
||||
// Check if the existing config has a secret and none is being sent (keep old one)
|
||||
let finalSecret = clientSecret;
|
||||
if (!clientSecret) {
|
||||
const existing = await getOAuthConfig();
|
||||
if (existing?.clientSecret) {
|
||||
finalSecret = existing.clientSecret;
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt OIDC discovery to validate the issuer endpoint
|
||||
try {
|
||||
await discoverOIDC(issuer);
|
||||
} catch (discErr) {
|
||||
return res.status(400).json({
|
||||
error: `Could not discover OIDC configuration at ${issuer}: ${discErr.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
await saveOAuthConfig({
|
||||
issuer,
|
||||
clientId,
|
||||
clientSecret: finalSecret || '',
|
||||
displayName: displayName || 'SSO',
|
||||
autoRegister: autoRegister !== false,
|
||||
});
|
||||
|
||||
log.admin.info(`OAuth configuration saved by admin (issuer: ${issuer})`);
|
||||
res.json({ message: 'OAuth configuration saved' });
|
||||
} catch (err) {
|
||||
log.admin.error(`Save OAuth config error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Could not save OAuth configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/admin/oauth - Remove OAuth configuration
|
||||
router.delete('/oauth', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await deleteOAuthConfig();
|
||||
log.admin.info('OAuth configuration removed by admin');
|
||||
res.json({ message: 'OAuth configuration removed' });
|
||||
} catch (err) {
|
||||
log.admin.error(`Delete OAuth config error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Could not remove OAuth configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
309
server/routes/analytics.js
Normal file
309
server/routes/analytics.js
Normal 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;
|
||||
@@ -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');
|
||||
@@ -145,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 - (3–30 chars)' });
|
||||
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
|
||||
}
|
||||
|
||||
// M1: email format
|
||||
@@ -168,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()) {
|
||||
@@ -189,7 +200,7 @@ router.post('/register', registerLimiter, async (req, res) => {
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -200,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
|
||||
@@ -210,7 +221,7 @@ 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]
|
||||
@@ -278,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
|
||||
@@ -303,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'");
|
||||
@@ -313,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.' });
|
||||
@@ -335,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' });
|
||||
}
|
||||
@@ -351,8 +362,14 @@ router.post('/login', loginLimiter, async (req, res) => {
|
||||
return res.status(403).json({ error: 'Email address not yet verified. Please check your inbox.', needsVerification: true });
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
@@ -361,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;
|
||||
@@ -379,7 +443,31 @@ router.post('/logout', authenticateToken, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
// ── RP-Initiated Logout for OIDC/Keycloak users ──────────────────────
|
||||
let keycloakLogoutUrl = null;
|
||||
if (req.user.oauth_provider === 'oidc') {
|
||||
try {
|
||||
const config = await getOAuthConfig();
|
||||
if (config) {
|
||||
const oidc = await discoverOIDC(config.issuer);
|
||||
if (oidc.end_session_endpoint) {
|
||||
const idToken = await redis.get(`oidc:id_token:${req.user.id}`);
|
||||
await redis.del(`oidc:id_token:${req.user.id}`);
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const params = new URLSearchParams({
|
||||
post_logout_redirect_uri: `${baseUrl}/`,
|
||||
client_id: config.clientId,
|
||||
});
|
||||
if (idToken) params.set('id_token_hint', idToken);
|
||||
keycloakLogoutUrl = `${oidc.end_session_endpoint}?${params.toString()}`;
|
||||
}
|
||||
}
|
||||
} catch (oidcErr) {
|
||||
log.auth.warn(`Could not build Keycloak logout URL: ${oidcErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: 'Logged out successfully', keycloakLogoutUrl });
|
||||
} catch (err) {
|
||||
log.auth.error(`Logout error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
@@ -432,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 - (3–30 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) {
|
||||
@@ -485,7 +573,7 @@ router.put('/password', authenticateToken, passwordLimiter, async (req, res) =>
|
||||
return res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
|
||||
}
|
||||
|
||||
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' });
|
||||
@@ -498,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 = [];
|
||||
@@ -528,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);
|
||||
|
||||
@@ -634,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;
|
||||
|
||||
@@ -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)) {
|
||||
@@ -83,6 +94,21 @@ router.get('/', async (req, res) => {
|
||||
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',
|
||||
@@ -90,6 +116,11 @@ router.get('/', async (req, res) => {
|
||||
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);
|
||||
@@ -210,4 +241,67 @@ router.put('/registration-mode', authenticateToken, requireAdmin, async (req, re
|
||||
}
|
||||
});
|
||||
|
||||
// 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
537
server/routes/caldav.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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
786
server/routes/calendar.js
Normal 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;
|
||||
@@ -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.3.0',
|
||||
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})`);
|
||||
|
||||
74
server/routes/notifications.js
Normal file
74
server/routes/notifications.js
Normal 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
272
server/routes/oauth.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
261
src/components/AnalyticsList.jsx
Normal file
261
src/components/AnalyticsList.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
105
src/components/DateTimePicker.jsx
Normal file
105
src/components/DateTimePicker.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
191
src/components/NotificationBell.jsx
Normal file
191
src/components/NotificationBell.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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); }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -23,6 +23,16 @@ 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 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;
|
||||
@@ -41,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>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,9 @@ export function BrandingProvider({ children }) {
|
||||
hasLogo: false,
|
||||
logoUrl: null,
|
||||
defaultTheme: null,
|
||||
imprintUrl: null,
|
||||
privacyUrl: null,
|
||||
hideAppName: false,
|
||||
});
|
||||
|
||||
const fetchBranding = useCallback(async () => {
|
||||
|
||||
189
src/contexts/NotificationContext.jsx
Normal file
189
src/contexts/NotificationContext.jsx
Normal 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 '🔔';
|
||||
}
|
||||
}
|
||||
278
src/i18n/de.json
278
src/i18n/de.json
@@ -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,11 +78,11 @@
|
||||
"emailNotVerified": "E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach.",
|
||||
"username": "Benutzername",
|
||||
"usernamePlaceholder": "z.B. maxmuster",
|
||||
"usernameHint": "Nur Buchstaben, Zahlen, _ und - erlaubt (3–30 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 (3–30 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.",
|
||||
@@ -88,10 +91,26 @@
|
||||
"emailVerificationResendSuccess": "Verifizierungsmail wurde gesendet!",
|
||||
"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."
|
||||
"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.",
|
||||
@@ -155,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",
|
||||
@@ -209,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",
|
||||
@@ -230,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",
|
||||
@@ -254,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",
|
||||
@@ -283,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",
|
||||
@@ -332,6 +432,9 @@
|
||||
"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",
|
||||
@@ -354,7 +457,44 @@
|
||||
"inviteExpired": "Abgelaufen",
|
||||
"inviteUsedBy": "Verwendet von",
|
||||
"inviteExpiresAt": "Läuft ab am",
|
||||
"noInvites": "Noch keine Einladungen"
|
||||
"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",
|
||||
@@ -392,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",
|
||||
@@ -406,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"
|
||||
}
|
||||
}
|
||||
278
src/i18n/en.json
278
src/i18n/en.json
@@ -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,11 +78,11 @@
|
||||
"emailNotVerified": "Email not yet verified. Please check your inbox.",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "e.g. johndoe",
|
||||
"usernameHint": "Letters, numbers, _ and - only (3–30 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 - (3–30 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.",
|
||||
@@ -88,10 +91,26 @@
|
||||
"emailVerificationResendSuccess": "Verification email sent!",
|
||||
"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."
|
||||
"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.",
|
||||
@@ -155,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",
|
||||
@@ -209,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",
|
||||
@@ -230,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",
|
||||
@@ -254,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",
|
||||
@@ -283,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",
|
||||
@@ -332,6 +432,9 @@
|
||||
"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",
|
||||
@@ -354,7 +457,44 @@
|
||||
"inviteExpired": "Expired",
|
||||
"inviteUsedBy": "Used by",
|
||||
"inviteExpiresAt": "Expires",
|
||||
"noInvites": "No invitations yet"
|
||||
"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",
|
||||
@@ -392,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",
|
||||
@@ -406,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"
|
||||
}
|
||||
}
|
||||
485
src/index.css
485
src/index.css
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
28
src/main.jsx
28
src/main.jsx
@@ -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>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
||||
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
||||
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
|
||||
ShieldCheck, Globe,
|
||||
ShieldCheck, Globe, Link as LinkIcon, LogIn,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
@@ -16,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, registrationMode, 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);
|
||||
@@ -27,6 +27,8 @@ 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([]);
|
||||
@@ -41,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') {
|
||||
@@ -49,6 +62,7 @@ export default function Admin() {
|
||||
}
|
||||
fetchUsers();
|
||||
fetchInvites();
|
||||
fetchOauthConfig();
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,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');
|
||||
@@ -88,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) => {
|
||||
@@ -100,6 +123,7 @@ export default function Admin() {
|
||||
toast.error(err.response?.data?.error || t('admin.userDeleteFailed'));
|
||||
}
|
||||
setOpenMenu(null);
|
||||
setMenuPos(null);
|
||||
};
|
||||
|
||||
const handleResetPassword = async (e) => {
|
||||
@@ -145,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);
|
||||
@@ -233,6 +269,84 @@ export default function Admin() {
|
||||
}
|
||||
};
|
||||
|
||||
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())
|
||||
@@ -346,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>
|
||||
|
||||
@@ -377,6 +513,59 @@ 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 */}
|
||||
@@ -501,6 +690,106 @@ export default function Admin() {
|
||||
)}
|
||||
</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 */}
|
||||
<div className="card p-4 mb-6">
|
||||
<div className="relative">
|
||||
@@ -580,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 bottom-full mb-1 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>
|
||||
@@ -633,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
888
src/pages/Calendar.jsx
Normal 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}`;
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,7 @@ export default function Dashboard() {
|
||||
className="input-field"
|
||||
placeholder={t('dashboard.roomNamePlaceholder')}
|
||||
required
|
||||
minLength={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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 } = useBranding();
|
||||
const { registrationMode, imprintUrl, privacyUrl } = useBranding();
|
||||
const isInviteOnly = registrationMode === 'invite';
|
||||
|
||||
const features = [
|
||||
@@ -70,7 +70,7 @@ export default function Home() {
|
||||
<div className="relative z-10 max-w-4xl mx-auto text-center px-6 pt-20 pb-32">
|
||||
<div className="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">
|
||||
@@ -143,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>
|
||||
);
|
||||
|
||||
@@ -1,9 +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 { useBranding } from '../contexts/BrandingContext';
|
||||
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw, LogIn, ShieldCheck } from 'lucide-react';
|
||||
import BrandLogo from '../components/BrandLogo';
|
||||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -15,9 +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 } = useBranding();
|
||||
const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -26,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);
|
||||
@@ -48,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) {
|
||||
@@ -62,6 +82,27 @@ export default function Login() {
|
||||
}
|
||||
};
|
||||
|
||||
const handle2FASubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setVerifying2FA(true);
|
||||
try {
|
||||
await verify2FA(tempToken, totpCode);
|
||||
toast.success(t('auth.loginSuccess'));
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('auth.2fa.verifyFailed'));
|
||||
setTotpCode('');
|
||||
} finally {
|
||||
setVerifying2FA(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setNeeds2FA(false);
|
||||
setTempToken('');
|
||||
setTotpCode('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
||||
{/* Animated background */}
|
||||
@@ -81,91 +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')}
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
|
||||
<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
59
src/pages/NotFound.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
src/pages/OAuthCallback.jsx
Normal file
79
src/pages/OAuthCallback.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useBranding } from '../contexts/BrandingContext';
|
||||
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle, ShieldAlert } from 'lucide-react';
|
||||
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle, ShieldAlert, LogIn } from 'lucide-react';
|
||||
import BrandLogo from '../components/BrandLogo';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function Register() {
|
||||
const [needsVerification, setNeedsVerification] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const { registrationMode } = useBranding();
|
||||
const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Invite-only mode without a token → show blocked message
|
||||
@@ -33,7 +33,7 @@ export default function Register() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
if (password.length < 8) {
|
||||
toast.error(t('auth.passwordTooShort'));
|
||||
return;
|
||||
}
|
||||
@@ -197,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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user