Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51b42cfee5 | ||
|
|
dffb68efe5 | ||
|
|
db82cd944f | ||
|
|
2f65e53a24 | ||
|
|
1518645145 | ||
|
|
64a1ba2548 | ||
|
|
03098eaf2b | ||
|
|
7dd834cd35 | ||
|
|
4621010bd7 | ||
|
|
fbbcd79719 | ||
|
|
10f0ffd2e5 | ||
|
|
4aea069295 | ||
|
|
9fc51bdfc5 | ||
|
|
7f48685717 | ||
|
|
cff5398ebd | ||
|
|
109e75d976 | ||
|
|
24024cd1dd | ||
|
|
4028e913c4 | ||
|
|
aba7819f12 | ||
|
|
45fdbe4883 | ||
|
|
de696d422a | ||
|
|
995d6eabf7 | ||
|
|
9ee4f84d84 | ||
|
|
df1aa20e45 | ||
|
|
e0ce354eda | ||
|
|
1690a74c19 | ||
|
|
61585d8c63 | ||
|
|
d04793148a | ||
|
|
9bf4228d04 | ||
|
|
c058ba3bf1 | ||
|
|
b3b559e164 | ||
|
|
0db9227c20 | ||
|
|
30f106a7ea | ||
|
|
8cbe28f915 | ||
|
|
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 |
@@ -9,3 +9,4 @@ uploads/
|
|||||||
.gitignore
|
.gitignore
|
||||||
.vscode/
|
.vscode/
|
||||||
*.md
|
*.md
|
||||||
|
screenshots/
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ SMTP_FROM=noreply@example.com
|
|||||||
# App URL (used for verification links, auto-detected if not set)
|
# App URL (used for verification links, auto-detected if not set)
|
||||||
# APP_URL=https://your-domain.com
|
# APP_URL=https://your-domain.com
|
||||||
|
|
||||||
|
# Recording Transcription / AI Summarization
|
||||||
|
# When the per-room "AI summary / transcript" setting is enabled, Redlight will POST to
|
||||||
|
# ${TRANSCRIPTION_API_URL}/prompt with header `X-Api-Key: ${TRANSCRIPTION_API_KEY}` as soon as
|
||||||
|
# the "video" format of the recording is available. Leave empty to disable the integration entirely.
|
||||||
|
# TRANSCRIPTION_API_URL=
|
||||||
|
# TRANSCRIPTION_API_KEY=
|
||||||
|
|
||||||
# Reverse Proxy trust depth (express 'trust proxy' setting)
|
# Reverse Proxy trust depth (express 'trust proxy' setting)
|
||||||
# loopback = trust only 127.0.0.1 / ::1 (default)
|
# loopback = trust only 127.0.0.1 / ::1 (default)
|
||||||
# Use a number for proxy hops (e.g. 1), or a specific IP/CIDR.
|
# Use a number for proxy hops (e.g. 1), or a specific IP/CIDR.
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
redlight.db
|
redlight.db
|
||||||
|
redlight.db-wal
|
||||||
|
redlight.db-shm
|
||||||
uploads/
|
uploads/
|
||||||
.env
|
.env
|
||||||
|
.claude/
|
||||||
|
|||||||
+15
-12
@@ -1,28 +1,31 @@
|
|||||||
# ── Stage 1: Build frontend ──────────────────────────────────────────────────
|
# ── Stage 1: Install dependencies ────────────────────────────────────────────
|
||||||
FROM node:20-bullseye-slim AS builder
|
FROM node:22-trixie-slim AS deps
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install build tools and sqlite headers for native modules
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install build tools for native modules (better-sqlite3, pdfkit)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
python3 build-essential libsqlite3-dev ca-certificates \
|
python3 build-essential libsqlite3-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
|
# Install all dependencies (including dev for vite build)
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
# ── Stage 2: Build frontend ─────────────────────────────────────────────────
|
||||||
|
FROM deps AS builder
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
# Produce production node_modules (compile native modules here for the target arch)
|
|
||||||
RUN npm ci --omit=dev && npm cache clean --force
|
|
||||||
|
|
||||||
# ── Stage 2: Production image ───────────────────────────────────────────────
|
# Prune dev dependencies in-place (avoids a second npm ci)
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
FROM node:20-bullseye-slim
|
# ── Stage 3: Production image ───────────────────────────────────────────────
|
||||||
|
FROM node:22-trixie-slim
|
||||||
# Allow forcing build from source (useful when prebuilt binaries are not available)
|
|
||||||
ARG BUILD_FROM_SOURCE=false
|
|
||||||
ENV npm_config_build_from_source=${BUILD_FROM_SOURCE}
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,41 @@
|
|||||||
# 🔴 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.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
## 🖼️ Pictures
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Click to expand screenshots</summary>
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
### Core Features
|
### Core Features
|
||||||
- 🎥 **Video Conferencing** - Integrated BigBlueButton support for professional video meetings
|
- 🎥 **Video Conferencing** - Integrated BigBlueButton support for professional video meetings
|
||||||
- 🎨 **15+ Themes** - Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox, and more
|
- 🎨 **25+ Themes** - Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox, Tokyo Night, Solarized, Everforest, Ayu, Kanagawa, Moonlight, Cyberpunk, Cotton Candy, and more
|
||||||
- 📝 **Room Management** - Create unlimited rooms with custom settings, access codes, and moderator codes
|
- 📝 **Room Management** - Create unlimited rooms with custom settings, access codes, and moderator codes
|
||||||
- 🔐 **User Management** - Registration, login, role-based access control (Admin/User)
|
- 🔐 **User Management** - Registration, login, role-based access control (Admin/User)
|
||||||
- 📹 **Recording Management** - View, publish, and delete meeting recordings per room
|
- 📹 **Recording Management** - View, publish, and delete meeting recordings per room
|
||||||
|
- 📊 **Learning Analytics** - Collect and view per-room participant engagement data (talk time, messages, reactions) via BBB callbacks, secured with HMAC tokens
|
||||||
|
- 📅 **Calendar** - Built-in calendar with event creation, sharing, customizable reminders, and room linking
|
||||||
|
- 📆 **CalDAV Server** - Full CalDAV support for syncing calendars with Thunderbird, Apple Calendar, GNOME Calendar, DAVx⁵ (Android), and other standard clients
|
||||||
- 🌍 **Multi-Language Support** - German (Deutsch) and English built-in, easily extensible
|
- 🌍 **Multi-Language Support** - German (Deutsch) and English built-in, easily extensible
|
||||||
|
- 🔔 **In-App Notifications** - Real-time notifications for room shares, federation invites, and calendar reminders
|
||||||
- ✉️ **Email Verification** - Optional SMTP-based email verification for user registration
|
- ✉️ **Email Verification** - Optional SMTP-based email verification for user registration
|
||||||
- 👤 **User Profiles** - Customizable avatars, themes, and language preferences
|
- 🔑 **OAuth / OIDC** - Login via OpenID Connect providers (Keycloak, Authentik, etc.) with PKCE
|
||||||
|
- 👤 **User Profiles** - Customizable display names, avatars, themes, and language preferences
|
||||||
- 📱 **Responsive Design** - Works seamlessly on mobile, tablet, and desktop
|
- 📱 **Responsive Design** - Works seamlessly on mobile, tablet, and desktop
|
||||||
- 🌐 **Federation** - Invite users from remote Redlight instances via Ed25519-signed messages
|
- 🌐 **Federation** - Invite users from remote Redlight instances via Ed25519-signed messages
|
||||||
- 🐉 **DragonflyDB / Redis** - JWT blacklisting for secure token revocation on logout
|
- 🐉 **DragonflyDB / Redis** - JWT blacklisting for secure token revocation on logout
|
||||||
@@ -27,6 +45,7 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
|
|||||||
- 🏢 **Branding Customization** - Custom app name, logos, and default theme
|
- 🏢 **Branding Customization** - Custom app name, logos, and default theme
|
||||||
- 📊 **Dashboard** - Overview of system statistics
|
- 📊 **Dashboard** - Overview of system statistics
|
||||||
- 🔧 **Settings Management** - System-wide configuration
|
- 🔧 **Settings Management** - System-wide configuration
|
||||||
|
- ✉️ **Invite-Only Registration** - Generate invite tokens for controlled user signup
|
||||||
|
|
||||||
### Room Features
|
### Room Features
|
||||||
- 🔑 **Access Codes** - Restrict room access with optional passwords
|
- 🔑 **Access Codes** - Restrict room access with optional passwords
|
||||||
@@ -37,11 +56,12 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
|
|||||||
- ✅ **Approval Mode** - Require moderator approval for participants
|
- ✅ **Approval Mode** - Require moderator approval for participants
|
||||||
- 🎙️ **Anyone Can Start** - Allow participants to start the meeting
|
- 🎙️ **Anyone Can Start** - Allow participants to start the meeting
|
||||||
- 📹 **Recording Settings** - Control whether meetings are recorded
|
- 📹 **Recording Settings** - Control whether meetings are recorded
|
||||||
- 📊 **Presentation Upload** - Upload PDF, PPTX, ODP, or image files as default slides
|
- 📊 **Learning Analytics** - Toggle per-room to collect participant engagement data after each meeting
|
||||||
|
- 📑 **Presentation Upload** - Upload PDF, PPTX, ODP, DOC, DOCX as default slides
|
||||||
- 🤝 **Room Sharing** - Share rooms with other registered users
|
- 🤝 **Room Sharing** - Share rooms with other registered users
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- 🛡️ **Comprehensive Rate Limiting** - Login, register, profile, avatar, guest-join, and federation endpoints
|
- 🛡️ **Comprehensive Rate Limiting** - Login, register, profile, avatar, guest-join, OAuth, and federation endpoints
|
||||||
- 🔒 **Input Validation** - Email format, field length limits, ID format checks, color format validation
|
- 🔒 **Input Validation** - Email format, field length limits, ID format checks, color format validation
|
||||||
- 🕐 **Timing-Safe Comparisons** - Access codes and moderator codes compared with `crypto.timingSafeEqual`
|
- 🕐 **Timing-Safe Comparisons** - Access codes and moderator codes compared with `crypto.timingSafeEqual`
|
||||||
- 📏 **Streaming Upload Limits** - Avatar (5 MB) and presentation (50 MB) uploads reject early without buffering
|
- 📏 **Streaming Upload Limits** - Avatar (5 MB) and presentation (50 MB) uploads reject early without buffering
|
||||||
@@ -49,6 +69,7 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
|
|||||||
- 🔐 **JWT Blacklist** - Token revocation via DragonflyDB/Redis on logout
|
- 🔐 **JWT Blacklist** - Token revocation via DragonflyDB/Redis on logout
|
||||||
- 🌐 **CORS Restriction** - Locked to `APP_URL` in production
|
- 🌐 **CORS Restriction** - Locked to `APP_URL` in production
|
||||||
- ⚙️ **Configurable Trust Proxy** - `TRUST_PROXY` env var for reverse proxy setups
|
- ⚙️ **Configurable Trust Proxy** - `TRUST_PROXY` env var for reverse proxy setups
|
||||||
|
- 🔏 **HMAC-Secured Callbacks** - Learning analytics callback URLs signed with HMAC-SHA256
|
||||||
|
|
||||||
### Developer Features
|
### Developer Features
|
||||||
- 🐳 **Docker Support** - Easy deployment with Docker Compose (includes PostgreSQL + DragonflyDB)
|
- 🐳 **Docker Support** - Easy deployment with Docker Compose (includes PostgreSQL + DragonflyDB)
|
||||||
@@ -63,20 +84,24 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
|
|||||||
|
|
||||||
| Feature | Redlight | Greenlight |
|
| Feature | Redlight | Greenlight |
|
||||||
|---------|----------|-----------|
|
|---------|----------|-----------|
|
||||||
| **Theme System** | 15+ customizable themes | Limited theming |
|
| **Theme System** | 25+ customizable themes | Limited theming |
|
||||||
|
| **Learning Analytics** | ✅ Per-room engagement data | ❌ Not supported |
|
||||||
|
| **Calendar / CalDAV** | ✅ Built-in calendar + CalDAV sync | ❌ Not supported |
|
||||||
|
| **OAuth / OIDC** | ✅ OpenID Connect (PKCE) | ✅ Supported |
|
||||||
| **Federation** | ✅ Cross-instance invites | ❌ Not supported |
|
| **Federation** | ✅ Cross-instance invites | ❌ Not supported |
|
||||||
|
| **Notifications** | ✅ In-app + calendar reminders | ❌ Not supported |
|
||||||
| **Language Support** | Multi-language ready | Multi-language ready |
|
| **Language Support** | Multi-language ready | Multi-language ready |
|
||||||
| **UI Framework** | React + Tailwind (Modern) | Rails-based (Traditional) |
|
| **UI Framework** | React + Tailwind (Modern) | Rails-based (Traditional) |
|
||||||
| **User Preferences** | Theme, language, avatar | Limited customization |
|
| **User Preferences** | Theme, language, avatar, display name | Limited customization |
|
||||||
| **Database Options** | SQLite / PostgreSQL | PostgreSQL only |
|
| **Database Options** | SQLite / PostgreSQL | PostgreSQL only |
|
||||||
| **Docker** | ✅ Supported | ✅ Supported |
|
| **Docker** | ✅ Supported | ✅ Supported |
|
||||||
| **Admin Dashboard** | Modern React UI | Legacy Rails interface |
|
| **Admin Dashboard** | Modern React UI | Legacy Rails interface |
|
||||||
| **Room Sharing** | ✅ Share rooms with users | ✅ Supported |
|
| **Room Sharing** | ✅ Share rooms with users | ✅ Supported |
|
||||||
| **Recording Management** | Full control per room | Standard management |
|
| **Recording Management** | Full control per room | Standard management |
|
||||||
|
| **Presentation Upload** | ✅ Supported | ✅ Supported |
|
||||||
| **API** | RESTful JSON API | RESTful API |
|
| **API** | RESTful JSON API | RESTful API |
|
||||||
| **Setup Complexity** | Simple (5 min) | Moderate (10-15 min) |
|
| **Setup Complexity** | Simple (5 min) | Moderate (10-15 min) |
|
||||||
| **Customization** | Easy (Tailwind CSS) | Requires Ruby/Rails |
|
| **Customization** | Easy (Tailwind CSS) | Requires Ruby/Rails |
|
||||||
| **Community** | doesn't exist lol | Established |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -105,7 +130,7 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
|
|||||||
BBB_SECRET=your-bbb-shared-secret
|
BBB_SECRET=your-bbb-shared-secret
|
||||||
JWT_SECRET=your-secret-key # REQUIRED - app won't start without this
|
JWT_SECRET=your-secret-key # REQUIRED - app won't start without this
|
||||||
APP_URL=https://your-domain.com # Used for CORS and email links
|
APP_URL=https://your-domain.com # Used for CORS and email links
|
||||||
DATABASE_URL=postgres://user:password@postgres:5432/redlight
|
DATABASE_URL=postgres://redlight:redlight@postgres:5432/redlight
|
||||||
|
|
||||||
POSTGRES_USER=redlight
|
POSTGRES_USER=redlight
|
||||||
POSTGRES_PASSWORD=redlight
|
POSTGRES_PASSWORD=redlight
|
||||||
@@ -118,18 +143,23 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
|
|||||||
# TRUST_PROXY=loopback
|
# TRUST_PROXY=loopback
|
||||||
|
|
||||||
# Optional: Email verification
|
# Optional: Email verification
|
||||||
SMTP_HOST=smtp.gmail.com
|
# SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=587
|
# SMTP_PORT=587
|
||||||
SMTP_USER=your-email@gmail.com
|
# SMTP_USER=your-email@gmail.com
|
||||||
SMTP_PASS=your-app-password
|
# SMTP_PASS=your-app-password
|
||||||
|
|
||||||
# Optional: Federation (cross-instance room invites)
|
# Optional: Federation (cross-instance room invites)
|
||||||
# FEDERATION_DOMAIN=your-domain.com
|
# FEDERATION_DOMAIN=your-domain.com
|
||||||
|
|
||||||
|
# Optional: OAuth / OIDC login
|
||||||
|
# OAUTH_ISSUER=https://auth.your-domain.com/realms/your-realm
|
||||||
|
# OAUTH_CLIENT_ID=redlight
|
||||||
|
# OAUTH_CLIENT_SECRET=your-client-secret
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Start the application**
|
3. **Start the application**
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Access the application**
|
4. **Access the application**
|
||||||
@@ -167,6 +197,8 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
|
|||||||
- **Database**: SQLite / PostgreSQL with better-sqlite3 / pg
|
- **Database**: SQLite / PostgreSQL with better-sqlite3 / pg
|
||||||
- **Cache**: DragonflyDB / Redis (ioredis) - JWT blacklisting
|
- **Cache**: DragonflyDB / Redis (ioredis) - JWT blacklisting
|
||||||
- **Email**: Nodemailer
|
- **Email**: Nodemailer
|
||||||
|
- **CalDAV**: xml2js-based WebDAV/CalDAV server
|
||||||
|
- **Auth**: JWT + OAuth/OIDC (PKCE)
|
||||||
- **Build**: Vite
|
- **Build**: Vite
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -176,20 +208,24 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
|
|||||||
```
|
```
|
||||||
redlight/
|
redlight/
|
||||||
├── server/ # Node.js/Express backend
|
├── server/ # Node.js/Express backend
|
||||||
│ ├── config/ # Database, Redis, mailer, BBB & federation config
|
│ ├── config/ # Database, Redis, mailer, BBB, federation, OAuth & notification config
|
||||||
│ ├── middleware/ # JWT authentication & token blacklisting
|
│ ├── i18n/ # Server-side translations (email templates)
|
||||||
│ ├── routes/ # API endpoints (auth, rooms, recordings, admin, branding, federation)
|
│ ├── jobs/ # Background jobs (federation sync, calendar reminders)
|
||||||
|
│ ├── middleware/ # JWT authentication, logging & token blacklisting
|
||||||
|
│ ├── routes/ # API endpoints (auth, rooms, recordings, admin, branding,
|
||||||
|
│ │ # federation, calendar, caldav, notifications, oauth, analytics)
|
||||||
│ └── index.js # Server entry point
|
│ └── index.js # Server entry point
|
||||||
├── src/ # React frontend
|
├── src/ # React frontend
|
||||||
│ ├── components/ # Reusable components
|
│ ├── components/ # Reusable components (RecordingList, AnalyticsList, etc.)
|
||||||
│ ├── contexts/ # React context (Auth, Language, Theme, Branding)
|
│ ├── contexts/ # React context (Auth, Language, Theme, Branding, Notification)
|
||||||
│ ├── i18n/ # Translations (DE, EN)
|
│ ├── i18n/ # Translations (DE, EN)
|
||||||
│ ├── pages/ # Page components
|
│ ├── pages/ # Page components
|
||||||
│ ├── services/ # API client
|
│ ├── services/ # API client
|
||||||
│ ├── themes/ # Tailwind theme config
|
│ ├── themes/ # 25+ theme definitions
|
||||||
│ └── main.jsx # Frontend entry point
|
│ └── main.jsx # Frontend entry point
|
||||||
├── public/ # Static assets
|
├── public/ # Static assets
|
||||||
├── uploads/ # User avatars, branding & presentations (runtime)
|
├── uploads/ # User avatars, branding & presentations (runtime)
|
||||||
|
├── keys/ # Federation Ed25519 key pair (auto-generated)
|
||||||
├── compose.yml # Docker Compose (Redlight + PostgreSQL + DragonflyDB)
|
├── compose.yml # Docker Compose (Redlight + PostgreSQL + DragonflyDB)
|
||||||
├── Dockerfile # Multi-stage container image
|
├── Dockerfile # Multi-stage container image
|
||||||
└── package.json # Dependencies
|
└── package.json # Dependencies
|
||||||
@@ -201,16 +237,19 @@ redlight/
|
|||||||
|
|
||||||
- **JWT Authentication** - Secure token-based auth with 7-day expiration and `jti`-based blacklisting via DragonflyDB/Redis
|
- **JWT Authentication** - Secure token-based auth with 7-day expiration and `jti`-based blacklisting via DragonflyDB/Redis
|
||||||
- **Mandatory JWT Secret** - Server refuses to start without a `JWT_SECRET` env var
|
- **Mandatory JWT Secret** - Server refuses to start without a `JWT_SECRET` env var
|
||||||
|
- **OAuth / OIDC** - OpenID Connect with PKCE (S256) and cryptographic state tokens
|
||||||
- **HTTPS Ready** - Configure behind reverse proxy (nginx, Caddy); trust proxy via `TRUST_PROXY` env
|
- **HTTPS Ready** - Configure behind reverse proxy (nginx, Caddy); trust proxy via `TRUST_PROXY` env
|
||||||
- **Password Hashing** - bcryptjs with salt rounds 12, minimum 8-character passwords
|
- **Password Hashing** - bcryptjs with salt rounds 12, minimum 8-character passwords
|
||||||
- **Email Verification** - Optional SMTP-based email verification with resend support
|
- **Email Verification** - Optional SMTP-based email verification with resend support
|
||||||
- **CORS Protection** - Restricted to `APP_URL` in production, open in development
|
- **CORS Protection** - Restricted to `APP_URL` in production, open in development
|
||||||
- **Rate Limiting** - Login, register, profile, password, avatar, guest-join, and federation endpoints
|
- **Rate Limiting** - Login, register, profile, password, avatar, guest-join, OAuth, and federation endpoints
|
||||||
- **Input Validation** - Email regex, field length limits, ID format checks, hex-color format checks
|
- **Input Validation** - Email regex, field length limits, ID format checks, hex-color format checks
|
||||||
- **Timing-Safe Comparisons** - Access codes and moderator codes compared via `crypto.timingSafeEqual`
|
- **Timing-Safe Comparisons** - Access codes and moderator codes compared via `crypto.timingSafeEqual`
|
||||||
- **Upload Safety** - Streaming body size limits (avatar 5 MB, presentation 50 MB) abort early without buffering
|
- **Upload Safety** - Streaming body size limits (avatar 5 MB, presentation 50 MB) abort early without buffering
|
||||||
- **XSS / Injection Prevention** - HTML-escaped emails, XML-escaped BBB API parameters, SVG logos served as `attachment`
|
- **XSS / Injection Prevention** - HTML-escaped emails, XML-escaped BBB API parameters, SVG logos served as `attachment`
|
||||||
|
- **HMAC-Secured Callbacks** - Learning analytics callback URLs signed with HMAC-SHA256 derived from BBB_SECRET
|
||||||
- **Admin Isolation** - Role-based access control with strict admin checks
|
- **Admin Isolation** - Role-based access control with strict admin checks
|
||||||
|
- **Network Isolation** - Docker Compose uses an internal backend network for DB and cache
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -232,23 +271,43 @@ redlight/
|
|||||||
- `GET /api/rooms` - List user's rooms (owned + shared)
|
- `GET /api/rooms` - List user's rooms (owned + shared)
|
||||||
- `POST /api/rooms` - Create new room
|
- `POST /api/rooms` - Create new room
|
||||||
- `GET /api/rooms/:uid` - Get room details
|
- `GET /api/rooms/:uid` - Get room details
|
||||||
- `PUT /api/rooms/:uid` - Update room
|
- `PUT /api/rooms/:uid` - Update room (incl. learning analytics toggle)
|
||||||
- `DELETE /api/rooms/:uid` - Delete room
|
- `DELETE /api/rooms/:uid` - Delete room
|
||||||
- `POST /api/rooms/:uid/start` - Start meeting
|
- `POST /api/rooms/:uid/start` - Start meeting
|
||||||
- `POST /api/rooms/:uid/join` - Join meeting as authenticated user
|
- `POST /api/rooms/:uid/join` - Join meeting as authenticated user
|
||||||
- `POST /api/rooms/:uid/guest-join` - Join meeting as guest (rate-limited)
|
- `POST /api/rooms/:uid/guest-join` - Join meeting as guest (rate-limited)
|
||||||
- `POST /api/rooms/:uid/end` - End meeting
|
- `POST /api/rooms/:uid/end` - End meeting
|
||||||
- `GET /api/rooms/:uid/running` - Check if meeting is running
|
- `GET /api/rooms/:uid/status` - Check if meeting is running
|
||||||
- `GET /api/rooms/:uid/shares` - List shared users
|
- `GET /api/rooms/:uid/shares` - List shared users
|
||||||
- `POST /api/rooms/:uid/shares` - Share room with user
|
- `POST /api/rooms/:uid/shares` - Share room with user
|
||||||
- `DELETE /api/rooms/:uid/shares/:userId` - Remove share
|
- `DELETE /api/rooms/:uid/shares/:userId` - Remove share
|
||||||
- `POST /api/rooms/:uid/presentation` - Upload default presentation (PDF, PPTX, ODP, images)
|
- `POST /api/rooms/:uid/presentation` - Upload default presentation
|
||||||
- `DELETE /api/rooms/:uid/presentation` - Remove presentation
|
- `DELETE /api/rooms/:uid/presentation` - Remove presentation
|
||||||
|
|
||||||
### Recordings
|
### Recordings
|
||||||
- `GET /api/recordings/:roomUid` - List room recordings
|
- `GET /api/recordings/room/:uid` - List room recordings
|
||||||
- `PUT /api/recordings/:recordingId` - Publish/unpublish recording
|
- `PUT /api/recordings/:recordID/publish` - Publish/unpublish recording
|
||||||
- `DELETE /api/recordings/:recordingId` - Delete recording
|
- `DELETE /api/recordings/:recordID` - Delete recording
|
||||||
|
|
||||||
|
### Learning Analytics
|
||||||
|
- `POST /api/analytics/callback/:uid?token=...` - BBB callback (HMAC-secured)
|
||||||
|
- `GET /api/analytics/room/:uid` - Get analytics for a room
|
||||||
|
- `DELETE /api/analytics/:id` - Delete analytics entry
|
||||||
|
|
||||||
|
### Calendar
|
||||||
|
- `GET /api/calendar` - List calendar events
|
||||||
|
- `POST /api/calendar` - Create event
|
||||||
|
- `PUT /api/calendar/:uid` - Update event
|
||||||
|
- `DELETE /api/calendar/:uid` - Delete event
|
||||||
|
- `GET /api/calendar/caldav-tokens` - List CalDAV tokens
|
||||||
|
- `POST /api/calendar/caldav-tokens` - Create CalDAV token
|
||||||
|
- `DELETE /api/calendar/caldav-tokens/:id` - Delete CalDAV token
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
- `GET /api/notifications` - List notifications
|
||||||
|
- `PUT /api/notifications/:id/read` - Mark as read
|
||||||
|
- `POST /api/notifications/read-all` - Mark all as read
|
||||||
|
- `DELETE /api/notifications/:id` - Delete notification
|
||||||
|
|
||||||
### Admin
|
### Admin
|
||||||
- `GET /api/admin/users` - List all users
|
- `GET /api/admin/users` - List all users
|
||||||
@@ -263,6 +322,10 @@ redlight/
|
|||||||
- `POST /api/branding/logo` - Upload custom logo
|
- `POST /api/branding/logo` - Upload custom logo
|
||||||
- `DELETE /api/branding/logo` - Remove custom logo
|
- `DELETE /api/branding/logo` - Remove custom logo
|
||||||
|
|
||||||
|
### OAuth
|
||||||
|
- `GET /api/oauth/url` - Get OAuth authorization URL
|
||||||
|
- `GET /api/oauth/callback` - OAuth callback (PKCE exchange)
|
||||||
|
|
||||||
### Federation
|
### Federation
|
||||||
- `GET /.well-known/redlight` - Instance discovery (domain, public key)
|
- `GET /.well-known/redlight` - Instance discovery (domain, public key)
|
||||||
- `POST /api/federation/invite` - Send invitation to remote user
|
- `POST /api/federation/invite` - Send invitation to remote user
|
||||||
@@ -271,6 +334,11 @@ redlight/
|
|||||||
- `PUT /api/federation/invitations/:id` - Accept / decline invitation
|
- `PUT /api/federation/invitations/:id` - Accept / decline invitation
|
||||||
- `DELETE /api/federation/invitations/:id` - Delete invitation
|
- `DELETE /api/federation/invitations/:id` - Delete invitation
|
||||||
|
|
||||||
|
### CalDAV
|
||||||
|
- `PROPFIND /caldav/` - CalDAV discovery
|
||||||
|
- `REPORT /caldav/:user/calendar/` - Calendar query
|
||||||
|
- `GET/PUT/DELETE /caldav/:user/calendar/:uid.ics` - Event CRUD
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌍 Internationalization (i18n)
|
## 🌍 Internationalization (i18n)
|
||||||
@@ -290,15 +358,25 @@ Redlight comes with built-in support for multiple languages. Currently supported
|
|||||||
|
|
||||||
## 🎨 Themes
|
## 🎨 Themes
|
||||||
|
|
||||||
Redlight includes the following themes:
|
Redlight includes 25+ themes:
|
||||||
- 🌙 Dracula
|
- ☀️ Light / 🌙 Dark (default)
|
||||||
|
- 🐱 Catppuccin Mocha / Latte
|
||||||
|
- 🧛 Dracula
|
||||||
- ❄️ Nord
|
- ❄️ Nord
|
||||||
- 🐱 Catppuccin
|
|
||||||
- 🌹 Rosé Pine
|
|
||||||
- 🍂 Gruvbox (Dark, Light)
|
|
||||||
- 💜 One Dark
|
|
||||||
- 🌊 Tokyo Night
|
- 🌊 Tokyo Night
|
||||||
- And more...
|
- 💜 One Dark
|
||||||
|
- 🐙 GitHub Dark
|
||||||
|
- 🌹 Rosé Pine / Rosé Pine Dawn
|
||||||
|
- 🍂 Gruvbox Dark / Gruvbox Light
|
||||||
|
- ☀️ Solarized Dark / Solarized Light
|
||||||
|
- 🌲 Everforest Dark / Everforest Light
|
||||||
|
- 🌊 Kanagawa
|
||||||
|
- 🌙 Moonlight
|
||||||
|
- 🎮 Cyberpunk
|
||||||
|
- 🌸 Ayu Dark
|
||||||
|
- 🔴 Red Modular Light
|
||||||
|
- 🍬 Cotton Candy Light
|
||||||
|
- 🐱 scrunkly.cat Dark
|
||||||
|
|
||||||
Themes are fully customizable by editing `src/themes/index.js`.
|
Themes are fully customizable by editing `src/themes/index.js`.
|
||||||
|
|
||||||
@@ -309,14 +387,16 @@ Themes are fully customizable by editing `src/themes/index.js`.
|
|||||||
### Using Docker Compose (Recommended)
|
### Using Docker Compose (Recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Services:
|
Services:
|
||||||
- **redlight** - Node.js application
|
- **redlight** - Node.js application (port 3001)
|
||||||
- **postgres** - PostgreSQL database
|
- **postgres** - PostgreSQL 17 database
|
||||||
- **dragonfly** - DragonflyDB (Redis-compatible) for JWT blacklisting
|
- **dragonfly** - DragonflyDB (Redis-compatible) for JWT blacklisting
|
||||||
|
|
||||||
|
The `compose.yml` uses isolated networks: `frontend` (public) and `backend` (internal, no external access). Data is persisted via named volumes (`pgdata`, `uploads`, `dragonflydata`). Federation keys are mounted from `./keys`.
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
| Variable | Required | Default | Description |
|
| Variable | Required | Default | Description |
|
||||||
@@ -333,6 +413,11 @@ Services:
|
|||||||
| `SMTP_USER` | No | - | SMTP username |
|
| `SMTP_USER` | No | - | SMTP username |
|
||||||
| `SMTP_PASS` | No | - | SMTP password |
|
| `SMTP_PASS` | No | - | SMTP password |
|
||||||
| `FEDERATION_DOMAIN` | No | - | Domain for federation (enables cross-instance invites) |
|
| `FEDERATION_DOMAIN` | No | - | Domain for federation (enables cross-instance invites) |
|
||||||
|
| `OAUTH_ISSUER` | No | - | OIDC issuer URL (enables OAuth login) |
|
||||||
|
| `OAUTH_CLIENT_ID` | No | - | OIDC client ID |
|
||||||
|
| `OAUTH_CLIENT_SECRET` | No | - | OIDC client secret |
|
||||||
|
| `ADMIN_EMAIL` | No | `admin@example.com` | Default admin email (first start only) |
|
||||||
|
| `ADMIN_PASSWORD` | No | `admin123` | Default admin password (first start only) |
|
||||||
|
|
||||||
### Production Deployment
|
### Production Deployment
|
||||||
|
|
||||||
@@ -376,8 +461,9 @@ Federation allows users on different Redlight instances to invite each other int
|
|||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Set `FEDERATION_DOMAIN=your-domain.com` in `.env`.
|
1. Set `FEDERATION_DOMAIN=your-domain.com` in `.env`.
|
||||||
2. On first start, an Ed25519 key pair is generated automatically and stored in `server/config/federation_key.pem`.
|
2. On first start, an Ed25519 key pair is generated automatically and stored in `keys/federation_key.pem`.
|
||||||
3. Other instances discover your public key via `GET /.well-known/redlight`.
|
3. In Docker, mount `./keys:/app/keys` (already configured in `compose.yml`).
|
||||||
|
4. Other instances discover your public key via `GET /.well-known/redlight`.
|
||||||
|
|
||||||
### How it works
|
### How it works
|
||||||
|
|
||||||
@@ -413,13 +499,13 @@ curl "https://your-bbb-server/bigbluebutton/api/getMeetings?checksum=..."
|
|||||||
**Solution**: Clear browser cache (Ctrl+Shift+Del) or restart dev server with `npm run dev`.
|
**Solution**: Clear browser cache (Ctrl+Shift+Del) or restart dev server with `npm run dev`.
|
||||||
|
|
||||||
### Issue: "DragonflyDB connection error"
|
### Issue: "DragonflyDB connection error"
|
||||||
**Solution**: Ensure DragonflyDB (or Redis) is running and `REDIS_URL` is correct. If unavailable, the app still works — JWT blacklisting degrades gracefully (logout won't revoke tokens immediately).
|
**Solution**: Ensure DragonflyDB (or Redis) is running and `REDIS_URL` is correct. If unavailable, the app still works - JWT blacklisting degrades gracefully (logout won't revoke tokens immediately).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📝 License
|
## 📝 License
|
||||||
|
|
||||||
This project is licensed under the MIT License - see [LICENSE](LICENSE) file for details.
|
This project is licensed under the GNU GPL v3 (or later) - see [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+32
-2
@@ -6,13 +6,16 @@ services:
|
|||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
env_file: ".env"
|
env_file: ".env"
|
||||||
volumes:
|
volumes:
|
||||||
- uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
- ./keys:/app/keys
|
- ./keys:/app/keys
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
dragonfly:
|
dragonfly:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- frontend
|
||||||
|
- backend
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
@@ -25,6 +28,8 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
dragonfly:
|
dragonfly:
|
||||||
image: ghcr.io/dragonflydb/dragonfly:latest
|
image: ghcr.io/dragonflydb/dragonfly:latest
|
||||||
@@ -38,8 +43,33 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
# Use valkey, if your system is too old for DragonflyDB
|
||||||
|
# valkey:
|
||||||
|
# image: valkey/valkey:9
|
||||||
|
# restart: unless-stopped
|
||||||
|
# ulimits:
|
||||||
|
# memlock: -1
|
||||||
|
# volumes:
|
||||||
|
# - valkeydata:/data
|
||||||
|
# healthcheck:
|
||||||
|
# test: ["CMD", "redis-cli", "-p", "6379", "ping"]
|
||||||
|
# interval: 5s
|
||||||
|
# timeout: 5s
|
||||||
|
# retries: 5
|
||||||
|
# networks:
|
||||||
|
# - backend
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
uploads:
|
|
||||||
dragonflydata:
|
dragonflydata:
|
||||||
|
#valkeydata:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
driver: bridge
|
||||||
|
backend:
|
||||||
|
driver: bridge
|
||||||
|
internal: true
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en" data-theme="dark">
|
<html lang="en" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Generated
+2500
-2367
File diff suppressed because it is too large
Load Diff
+25
-19
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "redlight",
|
"name": "redlight",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.4.0",
|
"version": "2.4.0",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -n client,server -c blue,green \"vite\" \"node --watch server/index.js\"",
|
"dev": "concurrently -n client,server -c blue,green \"vite\" \"node --watch server/index.js\"",
|
||||||
@@ -13,34 +14,39 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^11.0.0",
|
"better-sqlite3": "^12.6.2",
|
||||||
"concurrently": "^9.0.0",
|
"concurrently": "^9.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^4.21.0",
|
"exceljs": "^4.4.0",
|
||||||
"express-rate-limit": "^7.5.1",
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.5.2",
|
||||||
|
"flatpickr": "^4.6.13",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.0",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^1.16.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.1.0",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
|
"otpauth": "^9.5.0",
|
||||||
|
"pdfkit": "^0.18.0",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"rate-limit-redis": "^4.3.1",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.0",
|
"rate-limit-redis": "^5.0.0",
|
||||||
"react-dom": "^18.3.0",
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6",
|
||||||
"react-hot-toast": "^2.4.0",
|
"react-hot-toast": "^2.4.0",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^7.15.1",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^14.0.0",
|
||||||
"xml2js": "^0.6.0"
|
"xml2js": "^0.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react": "^19.2.14",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@types/react-dom": "^19.2.3",
|
||||||
"autoprefixer": "^10.4.0",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"vite": "^5.4.0"
|
"vite": "^8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'@tailwindcss/postcss': {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<!-- Speech bubble tail -->
|
||||||
|
<path d="M 185 325 L 148 425 L 248 360 Z" fill="#2B3140" stroke="#2B3140" stroke-width="14" stroke-linejoin="round"/>
|
||||||
|
<!-- Speech bubble outline -->
|
||||||
|
<circle cx="256" cy="240" r="120" fill="none" stroke="#2B3140" stroke-width="34"/>
|
||||||
|
<!-- Red dot -->
|
||||||
|
<circle cx="262" cy="234" r="76" fill="#ED2224"/>
|
||||||
|
<!-- Sound waves right -->
|
||||||
|
<path d="M 423 100 A 218 218 0 0 1 423 380" fill="none" stroke="#ED2224" stroke-width="32" stroke-linecap="round"/>
|
||||||
|
<path d="M 413 160 A 176 176 0 0 1 413 320" fill="none" stroke="#ED2224" stroke-width="30" stroke-linecap="round"/>
|
||||||
|
<!-- Sound waves left -->
|
||||||
|
<path d="M 89 100 A 218 218 0 0 0 89 380" fill="none" stroke="#ED2224" stroke-width="32" stroke-linecap="round"/>
|
||||||
|
<path d="M 99 160 A 176 176 0 0 0 99 320" fill="none" stroke="#ED2224" stroke-width="30" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 934 B |
Binary file not shown.
@@ -1,5 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
|
||||||
<rect width="32" height="32" rx="8" fill="#ef4444"/>
|
|
||||||
<circle cx="16" cy="16" r="8" fill="white" opacity="0.9"/>
|
|
||||||
<circle cx="16" cy="16" r="4" fill="#ef4444"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 268 B |
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 498 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
import { getDb } from './database.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the configured application name.
|
||||||
|
* Resolution order: admin-set 'app_name' setting → APP_NAME env var → 'Redlight'.
|
||||||
|
*
|
||||||
|
* The app name is stored in the settings table under the key 'app_name'
|
||||||
|
* (see routes/branding.js). This helper is the single source of truth so the
|
||||||
|
* configured name is used consistently across emails, the 2FA issuer, etc.
|
||||||
|
*/
|
||||||
|
export async function getAppName() {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const row = await db.get("SELECT value FROM settings WHERE key = 'app_name'");
|
||||||
|
if (row?.value) return row.value;
|
||||||
|
} catch {
|
||||||
|
// fall through to env/default if the DB is unavailable
|
||||||
|
}
|
||||||
|
return process.env.APP_NAME || 'Redlight';
|
||||||
|
}
|
||||||
+41
-8
@@ -1,10 +1,26 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import xml2js from 'xml2js';
|
import xml2js from 'xml2js';
|
||||||
import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParams } from './logger.js';
|
import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParams } from './logger.js';
|
||||||
|
import { t } from './emaili18n.js';
|
||||||
|
import { isTranscriptionEnabled } from './transcription.js';
|
||||||
|
|
||||||
const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/';
|
const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/';
|
||||||
const BBB_SECRET = process.env.BBB_SECRET || '';
|
const BBB_SECRET = process.env.BBB_SECRET || '';
|
||||||
|
|
||||||
|
if (!BBB_SECRET) {
|
||||||
|
log.bbb.warn('WARNING: BBB_SECRET is not set. BBB API calls will use an empty secret.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML-escape for safe embedding in BBB welcome messages
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
function getChecksum(apiCall, params) {
|
function getChecksum(apiCall, params) {
|
||||||
const queryString = new URLSearchParams(params).toString();
|
const queryString = new URLSearchParams(params).toString();
|
||||||
const raw = apiCall + queryString + BBB_SECRET;
|
const raw = apiCall + queryString + BBB_SECRET;
|
||||||
@@ -59,22 +75,22 @@ function getRoomPasswords(uid) {
|
|||||||
return { moderatorPW: modPw, attendeePW: attPw };
|
return { moderatorPW: modPw, attendeePW: attPw };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null) {
|
export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null, analyticsCallbackURL = null, lang = 'en') {
|
||||||
const { moderatorPW, attendeePW } = getRoomPasswords(room.uid);
|
const { moderatorPW, attendeePW } = getRoomPasswords(room.uid);
|
||||||
|
|
||||||
// Build welcome message with guest invite link
|
// Build welcome message with guest invite link
|
||||||
let welcome = room.welcome_message || t('defaultWelcome');
|
// HTML-escape user-controlled content to prevent stored XSS via BBB
|
||||||
|
let welcome = room.welcome_message ? escapeHtml(room.welcome_message) : escapeHtml(t(lang, 'bbb.defaultWelcome'));
|
||||||
if (logoutURL) {
|
if (logoutURL) {
|
||||||
const guestLink = `${logoutURL}/join/${room.uid}`;
|
const guestLink = `${logoutURL}/join/${room.uid}`;
|
||||||
welcome += `<br><br>To invite other participants, share this link:<br><a href="${guestLink}">${guestLink}</a>`;
|
welcome += `<br><br>${escapeHtml(t(lang, 'bbb.inviteHint'))}<br><a href="${escapeHtml(guestLink)}">${escapeHtml(guestLink)}</a>`;
|
||||||
if (room.access_code) {
|
// Access code is intentionally NOT shown in the welcome message to prevent
|
||||||
welcome += `<br>Access Code: <b>${room.access_code}</b>`;
|
// leaking it to all meeting participants.
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
meetingID: room.uid,
|
meetingID: room.uid,
|
||||||
name: room.name,
|
name: room.name.length >= 2 ? room.name : room.name.padEnd(2, ' '),
|
||||||
attendeePW,
|
attendeePW,
|
||||||
moderatorPW,
|
moderatorPW,
|
||||||
welcome,
|
welcome,
|
||||||
@@ -97,6 +113,14 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati
|
|||||||
if (room.access_code) {
|
if (room.access_code) {
|
||||||
params.lockSettingsLockOnJoin = 'true';
|
params.lockSettingsLockOnJoin = 'true';
|
||||||
}
|
}
|
||||||
|
if (analyticsCallbackURL) {
|
||||||
|
params['meta_analytics-callback-url'] = analyticsCallbackURL;
|
||||||
|
}
|
||||||
|
// BBB posts a signed recording-ready callback there once recording formats
|
||||||
|
// are processed; used to trigger the AI summary/transcript integration.
|
||||||
|
if (logoutURL && room.recording_transcript && isTranscriptionEnabled()) {
|
||||||
|
params['meta_bbb-recording-ready-url'] = `${logoutURL}/api/recordings/recording-ready`;
|
||||||
|
}
|
||||||
|
|
||||||
// Build optional presentation XML body - escape URL to prevent XML injection
|
// Build optional presentation XML body - escape URL to prevent XML injection
|
||||||
let xmlBody = null;
|
let xmlBody = null;
|
||||||
@@ -112,7 +136,7 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati
|
|||||||
return apiCall('create', params, xmlBody);
|
return apiCall('create', params, xmlBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function joinMeeting(uid, name, isModerator = false, avatarURL = null) {
|
export async function joinMeeting(uid, name, isModerator = false, avatarURL = null, darkMode = false) {
|
||||||
const { moderatorPW, attendeePW } = getRoomPasswords(uid);
|
const { moderatorPW, attendeePW } = getRoomPasswords(uid);
|
||||||
const params = {
|
const params = {
|
||||||
meetingID: uid,
|
meetingID: uid,
|
||||||
@@ -123,6 +147,11 @@ export async function joinMeeting(uid, name, isModerator = false, avatarURL = nu
|
|||||||
if (avatarURL) {
|
if (avatarURL) {
|
||||||
params.avatarURL = avatarURL;
|
params.avatarURL = avatarURL;
|
||||||
}
|
}
|
||||||
|
if (darkMode) {
|
||||||
|
// Per-user userdata override: start the client in dark theme for users who
|
||||||
|
// have a dark theme active in Redlight. Read by BBB as bbb_prefer_dark_theme.
|
||||||
|
params['userdata-bbb_prefer_dark_theme'] = 'true';
|
||||||
|
}
|
||||||
return buildUrl('join', params);
|
return buildUrl('join', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,4 +203,8 @@ export async function publishRecording(recordID, publish) {
|
|||||||
return apiCall('publishRecordings', { recordID, publish: publish ? 'true' : 'false' });
|
return apiCall('publishRecordings', { recordID, publish: publish ? 'true' : 'false' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAnalyticsToken(uid) {
|
||||||
|
return crypto.createHmac('sha256', BBB_SECRET).update('analytics_' + uid).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
export { getRoomPasswords };
|
export { getRoomPasswords };
|
||||||
|
|||||||
+244
-2
@@ -1,4 +1,4 @@
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { log } from './logger.js';
|
import { log } from './logger.js';
|
||||||
@@ -49,6 +49,12 @@ class SqliteAdapter {
|
|||||||
return !!columns.find(c => c.name === column);
|
return !!columns.find(c => c.name === column);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async columnIsNullable(table, column) {
|
||||||
|
const columns = this.db.pragma(`table_info(${table})`);
|
||||||
|
const col = columns.find(c => c.name === column);
|
||||||
|
return col ? col.notnull === 0 : true;
|
||||||
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.db.close();
|
this.db.close();
|
||||||
}
|
}
|
||||||
@@ -77,7 +83,9 @@ class PostgresAdapter {
|
|||||||
let pgSql = convertPlaceholders(sql);
|
let pgSql = convertPlaceholders(sql);
|
||||||
const isInsert = /^\s*INSERT/i.test(pgSql);
|
const isInsert = /^\s*INSERT/i.test(pgSql);
|
||||||
if (isInsert && !/RETURNING/i.test(pgSql)) {
|
if (isInsert && !/RETURNING/i.test(pgSql)) {
|
||||||
pgSql += ' RETURNING id';
|
// Some tables (e.g. settings, oauth_states) have no "id" column.
|
||||||
|
// Return the inserted row generically and read id only when present.
|
||||||
|
pgSql += ' RETURNING *';
|
||||||
}
|
}
|
||||||
const result = await this.pool.query(pgSql, params);
|
const result = await this.pool.query(pgSql, params);
|
||||||
return {
|
return {
|
||||||
@@ -98,6 +106,14 @@ class PostgresAdapter {
|
|||||||
return result.rows.length > 0;
|
return result.rows.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async columnIsNullable(table, column) {
|
||||||
|
const result = await this.pool.query(
|
||||||
|
'SELECT is_nullable FROM information_schema.columns WHERE table_name = $1 AND column_name = $2',
|
||||||
|
[table, column]
|
||||||
|
);
|
||||||
|
return result.rows.length > 0 ? result.rows[0].is_nullable === 'YES' : true;
|
||||||
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.pool?.end();
|
this.pool?.end();
|
||||||
}
|
}
|
||||||
@@ -368,6 +384,25 @@ export async function initDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Password reset: store a SHA-256 hash of the reset token (never the raw token)
|
||||||
|
if (!(await db.columnExists('users', 'reset_token_hash'))) {
|
||||||
|
await db.exec('ALTER TABLE users ADD COLUMN reset_token_hash TEXT DEFAULT NULL');
|
||||||
|
}
|
||||||
|
if (!(await db.columnExists('users', 'reset_token_expires'))) {
|
||||||
|
if (isPostgres) {
|
||||||
|
await db.exec('ALTER TABLE users ADD COLUMN reset_token_expires TIMESTAMP DEFAULT NULL');
|
||||||
|
} else {
|
||||||
|
await db.exec('ALTER TABLE users ADD COLUMN reset_token_expires DATETIME DEFAULT NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!(await db.columnExists('users', 'reset_requested_at'))) {
|
||||||
|
if (isPostgres) {
|
||||||
|
await db.exec('ALTER TABLE users ADD COLUMN reset_requested_at TIMESTAMP DEFAULT NULL');
|
||||||
|
} else {
|
||||||
|
await db.exec('ALTER TABLE users ADD COLUMN reset_requested_at DATETIME DEFAULT NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Federation sync: add deleted + updated_at to federated_rooms
|
// Federation sync: add deleted + updated_at to federated_rooms
|
||||||
if (!(await db.columnExists('federated_rooms', 'deleted'))) {
|
if (!(await db.columnExists('federated_rooms', 'deleted'))) {
|
||||||
await db.exec('ALTER TABLE federated_rooms ADD COLUMN deleted INTEGER DEFAULT 0');
|
await db.exec('ALTER TABLE federated_rooms ADD COLUMN deleted INTEGER DEFAULT 0');
|
||||||
@@ -513,6 +548,12 @@ export async function initDatabase() {
|
|||||||
if (!(await db.columnExists('calendar_events', 'federated_join_url'))) {
|
if (!(await db.columnExists('calendar_events', 'federated_join_url'))) {
|
||||||
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL');
|
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL');
|
||||||
}
|
}
|
||||||
|
if (!(await db.columnExists('calendar_events', 'reminder_minutes'))) {
|
||||||
|
await db.exec('ALTER TABLE calendar_events ADD COLUMN reminder_minutes INTEGER DEFAULT NULL');
|
||||||
|
}
|
||||||
|
if (!(await db.columnExists('calendar_events', 'reminder_sent_at'))) {
|
||||||
|
await db.exec(`ALTER TABLE calendar_events ADD COLUMN reminder_sent_at ${isPostgres ? 'TIMESTAMP' : 'DATETIME'} DEFAULT NULL`);
|
||||||
|
}
|
||||||
|
|
||||||
// Calendar invitations (federated calendar events that must be accepted first)
|
// Calendar invitations (federated calendar events that must be accepted first)
|
||||||
if (isPostgres) {
|
if (isPostgres) {
|
||||||
@@ -649,6 +690,207 @@ export async function initDatabase() {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CalDAV tokens ────────────────────────────────────────────────────────
|
||||||
|
if (isPostgres) {
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS caldav_tokens (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
last_used_at TIMESTAMP DEFAULT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token);
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS caldav_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
token TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at DATETIME DEFAULT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalDAV: add token_hash column for SHA-256 hashed token lookup
|
||||||
|
if (!(await db.columnExists('caldav_tokens', 'token_hash'))) {
|
||||||
|
await db.exec('ALTER TABLE caldav_tokens ADD COLUMN token_hash TEXT DEFAULT NULL');
|
||||||
|
await db.exec('CREATE INDEX IF NOT EXISTS idx_caldav_tokens_hash ON caldav_tokens(token_hash)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalDAV: make token column nullable (now only token_hash is stored for new tokens)
|
||||||
|
if (!(await db.columnIsNullable('caldav_tokens', 'token'))) {
|
||||||
|
if (isPostgres) {
|
||||||
|
await db.exec('ALTER TABLE caldav_tokens ALTER COLUMN token DROP NOT NULL');
|
||||||
|
} else {
|
||||||
|
// SQLite does not support ALTER COLUMN — recreate the table
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE caldav_tokens_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
token TEXT UNIQUE,
|
||||||
|
token_hash TEXT DEFAULT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at DATETIME DEFAULT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO caldav_tokens_new (id, user_id, token, token_hash, name, created_at, last_used_at)
|
||||||
|
SELECT id, user_id, token, token_hash, name, created_at, last_used_at FROM caldav_tokens;
|
||||||
|
DROP TABLE caldav_tokens;
|
||||||
|
ALTER TABLE caldav_tokens_new RENAME TO caldav_tokens;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_hash ON caldav_tokens(token_hash);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OAuth tables ────────────────────────────────────────────────────────
|
||||||
|
if (isPostgres) {
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_states (
|
||||||
|
state TEXT PRIMARY KEY,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
code_verifier TEXT NOT NULL,
|
||||||
|
return_to TEXT,
|
||||||
|
expires_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at);
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_states (
|
||||||
|
state TEXT PRIMARY KEY,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
code_verifier TEXT NOT NULL,
|
||||||
|
return_to TEXT,
|
||||||
|
expires_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add OAuth columns to users table
|
||||||
|
if (!(await db.columnExists('users', 'oauth_provider'))) {
|
||||||
|
await db.exec('ALTER TABLE users ADD COLUMN oauth_provider TEXT DEFAULT NULL');
|
||||||
|
}
|
||||||
|
if (!(await db.columnExists('users', 'oauth_provider_id'))) {
|
||||||
|
await db.exec('ALTER TABLE users ADD COLUMN oauth_provider_id TEXT DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Learning Analytics table ─────────────────────────────────────────────
|
||||||
|
if (!(await db.columnExists('rooms', 'learning_analytics'))) {
|
||||||
|
await db.exec('ALTER TABLE rooms ADD COLUMN learning_analytics INTEGER DEFAULT 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPostgres) {
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS learning_analytics_data (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||||
|
meeting_id TEXT NOT NULL,
|
||||||
|
meeting_name TEXT,
|
||||||
|
data JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_room_id ON learning_analytics_data(room_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_meeting_id ON learning_analytics_data(meeting_id);
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS learning_analytics_data (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
meeting_id TEXT NOT NULL,
|
||||||
|
meeting_name TEXT,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_room_id ON learning_analytics_data(room_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_meeting_id ON learning_analytics_data(meeting_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TOTP 2FA columns ──────────────────────────────────────────────────────
|
||||||
|
if (!(await db.columnExists('users', 'totp_secret'))) {
|
||||||
|
await db.exec('ALTER TABLE users ADD COLUMN totp_secret TEXT DEFAULT NULL');
|
||||||
|
}
|
||||||
|
if (!(await db.columnExists('users', 'totp_enabled'))) {
|
||||||
|
await db.exec('ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Analytics visibility setting ────────────────────────────────────────
|
||||||
|
if (!(await db.columnExists('rooms', 'analytics_visibility'))) {
|
||||||
|
await db.exec("ALTER TABLE rooms ADD COLUMN analytics_visibility TEXT DEFAULT 'owner'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recordings cache table ────────────────────────────────────────────
|
||||||
|
if (isPostgres) {
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS recordings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
record_id TEXT UNIQUE NOT NULL,
|
||||||
|
meeting_id TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
state TEXT,
|
||||||
|
published INTEGER DEFAULT 1,
|
||||||
|
start_time TEXT,
|
||||||
|
end_time TEXT,
|
||||||
|
participants TEXT,
|
||||||
|
size TEXT,
|
||||||
|
formats TEXT NOT NULL DEFAULT '[]',
|
||||||
|
metadata TEXT DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recordings_record_id ON recordings(record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recordings_meeting_id ON recordings(meeting_id);
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS recordings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
record_id TEXT UNIQUE NOT NULL,
|
||||||
|
meeting_id TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
state TEXT,
|
||||||
|
published INTEGER DEFAULT 1,
|
||||||
|
start_time TEXT,
|
||||||
|
end_time TEXT,
|
||||||
|
participants TEXT,
|
||||||
|
size TEXT,
|
||||||
|
formats TEXT NOT NULL DEFAULT '[]',
|
||||||
|
metadata TEXT DEFAULT '{}',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recordings_record_id ON recordings(record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recordings_meeting_id ON recordings(meeting_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recording transcription / AI summary ────────────────────────────────
|
||||||
|
if (!(await db.columnExists('rooms', 'recording_transcript'))) {
|
||||||
|
await db.exec('ALTER TABLE rooms ADD COLUMN recording_transcript INTEGER DEFAULT 0');
|
||||||
|
}
|
||||||
|
if (!(await db.columnExists('recordings', 'transcript_requested_at'))) {
|
||||||
|
if (isPostgres) {
|
||||||
|
await db.exec('ALTER TABLE recordings ADD COLUMN transcript_requested_at TIMESTAMP DEFAULT NULL');
|
||||||
|
} else {
|
||||||
|
await db.exec('ALTER TABLE recordings ADD COLUMN transcript_requested_at DATETIME DEFAULT NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Default admin (only on very first start) ────────────────────────────
|
// ── Default admin (only on very first start) ────────────────────────────
|
||||||
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
|
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
|
||||||
if (!adminAlreadySeeded) {
|
if (!adminAlreadySeeded) {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import path from 'path';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { log } from './logger.js';
|
import { log } from './logger.js';
|
||||||
|
|
||||||
|
import dns from 'dns';
|
||||||
|
import net from 'net';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
@@ -93,13 +96,69 @@ export function verifyPayload(payload, signature, remotePublicKeyPem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a domain resolves to a private/internal IP address (SSRF protection).
|
||||||
|
* Blocks RFC 1918, loopback, link-local, and cloud metadata IPs.
|
||||||
|
* @param {string} domain
|
||||||
|
* @returns {Promise<void>} throws if domain resolves to a blocked IP
|
||||||
|
*/
|
||||||
|
async function assertPublicDomain(domain) {
|
||||||
|
// Allow localhost only in development
|
||||||
|
if (domain === 'localhost' || domain === '127.0.0.1' || domain === '::1') {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
throw new Error('Federation to localhost is blocked in production');
|
||||||
|
}
|
||||||
|
return; // allow in dev
|
||||||
|
}
|
||||||
|
|
||||||
|
// If domain is a raw IP, check it directly
|
||||||
|
if (net.isIP(domain)) {
|
||||||
|
if (isPrivateIP(domain)) {
|
||||||
|
throw new Error(`Federation blocked: ${domain} resolves to a private IP`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve domain and check all resulting IPs
|
||||||
|
const { resolve4, resolve6 } = dns.promises;
|
||||||
|
const ips = [];
|
||||||
|
try { ips.push(...await resolve4(domain)); } catch {}
|
||||||
|
try { ips.push(...await resolve6(domain)); } catch {}
|
||||||
|
|
||||||
|
if (ips.length === 0) {
|
||||||
|
throw new Error(`Federation blocked: could not resolve ${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ip of ips) {
|
||||||
|
if (isPrivateIP(ip)) {
|
||||||
|
throw new Error(`Federation blocked: ${domain} resolves to a private IP (${ip})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIP(ip) {
|
||||||
|
// IPv4 private ranges
|
||||||
|
if (/^10\./.test(ip)) return true;
|
||||||
|
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
|
||||||
|
if (/^192\.168\./.test(ip)) return true;
|
||||||
|
if (/^127\./.test(ip)) return true;
|
||||||
|
if (/^0\./.test(ip)) return true;
|
||||||
|
if (/^169\.254\./.test(ip)) return true; // link-local
|
||||||
|
if (ip === '::1' || ip === '::' || ip.startsWith('fe80:') || ip.startsWith('fc') || ip.startsWith('fd')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discover a remote Redlight instance's federation API base URL.
|
* Discover a remote Redlight instance's federation API base URL.
|
||||||
* Fetches https://{domain}/.well-known/redlight and caches the result.
|
* Fetches https://{domain}/.well-known/redlight and caches the result.
|
||||||
|
* Includes SSRF protection: blocks private/internal IPs.
|
||||||
* @param {string} domain
|
* @param {string} domain
|
||||||
* @returns {Promise<{ baseUrl: string, publicKey: string }>}
|
* @returns {Promise<{ baseUrl: string, publicKey: string }>}
|
||||||
*/
|
*/
|
||||||
export async function discoverInstance(domain) {
|
export async function discoverInstance(domain) {
|
||||||
|
// SSRF protection: validate domain doesn't resolve to internal IP
|
||||||
|
await assertPublicDomain(domain);
|
||||||
|
|
||||||
const cached = discoveryCache.get(domain);
|
const cached = discoveryCache.get(domain);
|
||||||
if (cached && (Date.now() - cached.cachedAt) < DISCOVERY_TTL_MS) {
|
if (cached && (Date.now() - cached.cachedAt) < DISCOVERY_TTL_MS) {
|
||||||
return cached;
|
return cached;
|
||||||
@@ -112,7 +171,8 @@ export async function discoverInstance(domain) {
|
|||||||
try {
|
try {
|
||||||
response = await fetch(wellKnownUrl, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
response = await fetch(wellKnownUrl, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.message.includes('fetch') && wellKnownUrl.startsWith('https://localhost')) {
|
// HTTP fallback only allowed in development for localhost
|
||||||
|
if (e.message.includes('fetch') && domain === 'localhost' && process.env.NODE_ENV !== 'production') {
|
||||||
response = await fetch(`http://${domain}/.well-known/redlight`, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
response = await fetch(`http://${domain}/.well-known/redlight`, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
||||||
} else throw e;
|
} else throw e;
|
||||||
}
|
}
|
||||||
@@ -128,7 +188,9 @@ export async function discoverInstance(domain) {
|
|||||||
|
|
||||||
const baseUrl = `https://${domain}${data.federation_api || '/api/federation'}`;
|
const baseUrl = `https://${domain}${data.federation_api || '/api/federation'}`;
|
||||||
const result = {
|
const result = {
|
||||||
baseUrl: baseUrl.replace('https://localhost', 'http://localhost'),
|
baseUrl: (domain === 'localhost' && process.env.NODE_ENV !== 'production')
|
||||||
|
? baseUrl.replace('https://localhost', 'http://localhost')
|
||||||
|
: baseUrl,
|
||||||
publicKey: data.public_key,
|
publicKey: data.public_key,
|
||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|||||||
+100
-3
@@ -64,7 +64,6 @@ export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redl
|
|||||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||||
const headerAppName = sanitizeHeaderValue(appName);
|
const headerAppName = sanitizeHeaderValue(appName);
|
||||||
const safeName = escapeHtml(name);
|
const safeName = escapeHtml(name);
|
||||||
const safeAppName = escapeHtml(appName);
|
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: `"${headerAppName}" <${from}>`,
|
from: `"${headerAppName}" <${from}>`,
|
||||||
@@ -93,6 +92,50 @@ export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redl
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a password reset email with a clickable link.
|
||||||
|
* @param {string} to - recipient email
|
||||||
|
* @param {string} name - user's display name
|
||||||
|
* @param {string} resetUrl - full password reset URL
|
||||||
|
* @param {string} appName - branding app name (default "Redlight")
|
||||||
|
* @param {string} lang - language code
|
||||||
|
*/
|
||||||
|
export async function sendPasswordResetEmail(to, name, resetUrl, appName = 'Redlight', lang = 'en') {
|
||||||
|
if (!transporter) {
|
||||||
|
throw new Error('SMTP not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||||
|
const headerAppName = sanitizeHeaderValue(appName);
|
||||||
|
const safeName = escapeHtml(name);
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"${headerAppName}" <${from}>`,
|
||||||
|
to,
|
||||||
|
subject: t(lang, 'email.resetPassword.subject', { appName: headerAppName }),
|
||||||
|
html: `
|
||||||
|
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||||
|
<h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
|
||||||
|
<p>${t(lang, 'email.resetPassword.intro')}</p>
|
||||||
|
<p style="text-align:center;margin:28px 0;">
|
||||||
|
<a href="${resetUrl}"
|
||||||
|
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
|
||||||
|
${t(lang, 'email.resetPassword.button')}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size:13px;color:#7f849c;">
|
||||||
|
${t(lang, 'email.linkHint')}<br/>
|
||||||
|
<a href="${resetUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(resetUrl)}</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size:13px;color:#7f849c;">${t(lang, 'email.resetPassword.validity')}</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||||
|
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.resetPassword.footer')}</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.resetPassword.intro')}\n${resetUrl}\n\n${t(lang, 'email.resetPassword.validity')}\n\n- ${appName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a federation meeting invitation email.
|
* Send a federation meeting invitation email.
|
||||||
* @param {string} to - recipient email
|
* @param {string} to - recipient email
|
||||||
@@ -112,7 +155,6 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
|
|||||||
const safeFromUser = escapeHtml(fromUser);
|
const safeFromUser = escapeHtml(fromUser);
|
||||||
const safeRoomName = escapeHtml(roomName);
|
const safeRoomName = escapeHtml(roomName);
|
||||||
const safeMessage = message ? escapeHtml(message) : null;
|
const safeMessage = message ? escapeHtml(message) : null;
|
||||||
const safeAppName = escapeHtml(appName);
|
|
||||||
|
|
||||||
const introHtml = t(lang, 'email.federationInvite.intro')
|
const introHtml = t(lang, 'email.federationInvite.intro')
|
||||||
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
|
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
|
||||||
@@ -144,6 +186,61 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a guest meeting invitation email with a direct join link.
|
||||||
|
* @param {string} to - recipient email
|
||||||
|
* @param {string} fromUser - sender display name
|
||||||
|
* @param {string} roomName - name of the room
|
||||||
|
* @param {string} message - optional personal message
|
||||||
|
* @param {string} joinUrl - direct guest join URL
|
||||||
|
* @param {string} appName - branding app name
|
||||||
|
* @param {string} lang - language code
|
||||||
|
*/
|
||||||
|
export async function sendGuestInviteEmail(to, fromUser, roomName, message, joinUrl, appName = 'Redlight', lang = 'en') {
|
||||||
|
if (!transporter) {
|
||||||
|
throw new Error('SMTP not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||||
|
const headerAppName = sanitizeHeaderValue(appName);
|
||||||
|
const safeFromUser = escapeHtml(fromUser);
|
||||||
|
const safeRoomName = escapeHtml(roomName);
|
||||||
|
const safeMessage = message ? escapeHtml(message) : null;
|
||||||
|
|
||||||
|
const introHtml = t(lang, 'email.guestInvite.intro')
|
||||||
|
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"${headerAppName}" <${from}>`,
|
||||||
|
to,
|
||||||
|
subject: t(lang, 'email.guestInvite.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;">Meeting Invitation</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;">${t(lang, 'email.guestInvite.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="${joinUrl}"
|
||||||
|
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
|
||||||
|
${t(lang, 'email.guestInvite.joinButton')}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size:13px;color:#7f849c;">
|
||||||
|
${t(lang, 'email.linkHint')}<br/>
|
||||||
|
<a href="${joinUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(joinUrl)}</a>
|
||||||
|
</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||||
|
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.guestInvite.footer')}</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
text: `${t(lang, 'email.guestInvite.intro', { fromUser })}\n${t(lang, 'email.guestInvite.roomLabel')} ${roomName}${message ? `\n"${message}"` : ''}\n\n${t(lang, 'email.guestInvite.joinButton')}: ${joinUrl}\n\n- ${appName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a calendar event invitation email (federated).
|
* Send a calendar event invitation email (federated).
|
||||||
*/
|
*/
|
||||||
@@ -175,7 +272,7 @@ export async function sendCalendarInviteEmail(to, name, fromUser, title, startTi
|
|||||||
<p>${introHtml}</p>
|
<p>${introHtml}</p>
|
||||||
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
|
<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: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>
|
<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>` : ''}
|
${safeDesc ? `<p style="margin:10px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">"${safeDesc}"</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<p style="text-align:center;margin:28px 0;">
|
<p style="text-align:center;margin:28px 0;">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { log } from './logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recording transcription / AI summarization integration.
|
||||||
|
*
|
||||||
|
* When the per-room "recording transcript" setting is enabled, Redlight POSTs
|
||||||
|
* to ${TRANSCRIPTION_API_URL}/prompt with header `X-Api-Key: ${TRANSCRIPTION_API_KEY}`
|
||||||
|
* as soon as the "video" format of a recording becomes available (delivered via
|
||||||
|
* BBB's recording-ready callback). Leave both env vars empty to disable the
|
||||||
|
* integration entirely.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TRANSCRIPTION_API_URL = process.env.TRANSCRIPTION_API_URL || '';
|
||||||
|
const TRANSCRIPTION_API_KEY = process.env.TRANSCRIPTION_API_KEY || '';
|
||||||
|
|
||||||
|
const DEFAULT_PROMPT = 'Summarise the key decisions made in this meeting.';
|
||||||
|
|
||||||
|
export function isTranscriptionEnabled() {
|
||||||
|
return !!(TRANSCRIPTION_API_URL && TRANSCRIPTION_API_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a summary/transcript for a recording. Never throws — failures are
|
||||||
|
* logged and reported via the boolean return value.
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.meetingId Room UID / BBB meeting ID
|
||||||
|
* @param {string} opts.recordingId BBB record ID
|
||||||
|
* @param {string} [opts.prompt] Optional custom prompt
|
||||||
|
* @returns {Promise<boolean>} true if the request was accepted
|
||||||
|
*/
|
||||||
|
export async function requestTranscription({ meetingId, recordingId, prompt }) {
|
||||||
|
if (!isTranscriptionEnabled()) {
|
||||||
|
log.server.warn('Transcription request skipped: TRANSCRIPTION_API_URL or TRANSCRIPTION_API_KEY is not configured');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = TRANSCRIPTION_API_URL.endsWith('/') ? TRANSCRIPTION_API_URL : `${TRANSCRIPTION_API_URL}/`;
|
||||||
|
const url = new URL('prompt', base).toString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Api-Key': TRANSCRIPTION_API_KEY,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: prompt || DEFAULT_PROMPT,
|
||||||
|
meeting_id: meetingId,
|
||||||
|
recording_id: recordingId,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => '');
|
||||||
|
log.server.error(`Transcription request failed: status=${response.status} body=${body.slice(0, 200)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.server.info(`Transcription requested for recording ${recordingId} (meeting: ${meetingId})`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
log.server.error(`Transcription request error: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"email": {
|
"email": {
|
||||||
"greeting": "Hey {name} 👋",
|
"greeting": "Hallo {name},",
|
||||||
"viewInvitation": "Einladung anzeigen",
|
"viewInvitation": "Einladung anzeigen",
|
||||||
"invitationFooter": "Öffne den Link oben, um die Einladung anzunehmen oder abzulehnen.",
|
"invitationFooter": "Öffne den Link oben, um die Einladung anzunehmen oder abzulehnen.",
|
||||||
"linkHint": "Oder kopiere diesen Link in deinen Browser:",
|
"linkHint": "Oder kopiere diesen Link in deinen Browser:",
|
||||||
@@ -11,9 +11,16 @@
|
|||||||
"validity": "Dieser Link ist 24 Stunden gültig.",
|
"validity": "Dieser Link ist 24 Stunden gültig.",
|
||||||
"footer": "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren."
|
"footer": "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren."
|
||||||
},
|
},
|
||||||
|
"resetPassword": {
|
||||||
|
"subject": "{appName} - Passwort zurücksetzen",
|
||||||
|
"intro": "Du hast angefordert, dein Passwort zurückzusetzen. Klicke auf den Button, um ein neues Passwort festzulegen:",
|
||||||
|
"button": "Passwort zurücksetzen",
|
||||||
|
"validity": "Dieser Link ist 1 Stunde gültig.",
|
||||||
|
"footer": "Falls du das nicht angefordert hast, kannst du diese E-Mail ignorieren – dein Passwort bleibt unverändert."
|
||||||
|
},
|
||||||
"invite": {
|
"invite": {
|
||||||
"subject": "{appName} - Du wurdest eingeladen",
|
"subject": "{appName} - Du wurdest eingeladen",
|
||||||
"title": "Du wurdest eingeladen! 🎉",
|
"title": "Du wurdest eingeladen",
|
||||||
"intro": "Du wurdest eingeladen, ein Konto auf {appName} zu erstellen.",
|
"intro": "Du wurdest eingeladen, ein Konto auf {appName} zu erstellen.",
|
||||||
"prompt": "Klicke auf den Button, um dich zu registrieren:",
|
"prompt": "Klicke auf den Button, um dich zu registrieren:",
|
||||||
"button": "Konto erstellen",
|
"button": "Konto erstellen",
|
||||||
@@ -25,6 +32,13 @@
|
|||||||
"intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.",
|
"intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.",
|
||||||
"roomLabel": "Raum:"
|
"roomLabel": "Raum:"
|
||||||
},
|
},
|
||||||
|
"guestInvite": {
|
||||||
|
"subject": "{appName} - Einladung zu einem Meeting",
|
||||||
|
"intro": "{fromUser} hat dich zu einem Meeting eingeladen.",
|
||||||
|
"roomLabel": "Raum:",
|
||||||
|
"joinButton": "Meeting beitreten",
|
||||||
|
"footer": "Klicke auf den Button oben, um dem Meeting beizutreten."
|
||||||
|
},
|
||||||
"calendarInvite": {
|
"calendarInvite": {
|
||||||
"subject": "{appName} - Kalendereinladung von {fromUser}",
|
"subject": "{appName} - Kalendereinladung von {fromUser}",
|
||||||
"intro": "Du hast eine Kalendereinladung von {fromUser} erhalten."
|
"intro": "Du hast eine Kalendereinladung von {fromUser} erhalten."
|
||||||
@@ -35,5 +49,9 @@
|
|||||||
"note": "Der Termin wurde automatisch aus deinem Kalender entfernt.",
|
"note": "Der Termin wurde automatisch aus deinem Kalender entfernt.",
|
||||||
"footer": "Diese Nachricht wurde automatisch von {appName} versendet."
|
"footer": "Diese Nachricht wurde automatisch von {appName} versendet."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"bbb": {
|
||||||
|
"defaultWelcome": "Willkommen im Meeting!",
|
||||||
|
"inviteHint": "Lade weitere Teilnehmer ein, indem du diesen Link teilst:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"email": {
|
"email": {
|
||||||
"greeting": "Hey {name} 👋",
|
"greeting": "Hello {name},",
|
||||||
"viewInvitation": "View Invitation",
|
"viewInvitation": "View Invitation",
|
||||||
"invitationFooter": "Open the link above to accept or decline the invitation.",
|
"invitationFooter": "Open the link above to accept or decline the invitation.",
|
||||||
"linkHint": "Or copy this link in your browser:",
|
"linkHint": "Or copy this link in your browser:",
|
||||||
@@ -11,9 +11,16 @@
|
|||||||
"validity": "This link is valid for 24 hours.",
|
"validity": "This link is valid for 24 hours.",
|
||||||
"footer": "If you didn't register, please ignore this email."
|
"footer": "If you didn't register, please ignore this email."
|
||||||
},
|
},
|
||||||
|
"resetPassword": {
|
||||||
|
"subject": "{appName} - Reset your password",
|
||||||
|
"intro": "You requested to reset your password. Click the button below to set a new one:",
|
||||||
|
"button": "Reset Password",
|
||||||
|
"validity": "This link is valid for 1 hour.",
|
||||||
|
"footer": "If you didn't request this, you can safely ignore this email — your password will remain unchanged."
|
||||||
|
},
|
||||||
"invite": {
|
"invite": {
|
||||||
"subject": "{appName} - You've been invited",
|
"subject": "{appName} - You've been invited",
|
||||||
"title": "You've been invited! 🎉",
|
"title": "You have been invited",
|
||||||
"intro": "You have been invited to create an account on {appName}.",
|
"intro": "You have been invited to create an account on {appName}.",
|
||||||
"prompt": "Click the button below to register:",
|
"prompt": "Click the button below to register:",
|
||||||
"button": "Create Account",
|
"button": "Create Account",
|
||||||
@@ -25,6 +32,13 @@
|
|||||||
"intro": "You have received a meeting invitation from {fromUser}.",
|
"intro": "You have received a meeting invitation from {fromUser}.",
|
||||||
"roomLabel": "Room:"
|
"roomLabel": "Room:"
|
||||||
},
|
},
|
||||||
|
"guestInvite": {
|
||||||
|
"subject": "{appName} - You're invited to a meeting",
|
||||||
|
"intro": "{fromUser} has invited you to a meeting.",
|
||||||
|
"roomLabel": "Room:",
|
||||||
|
"joinButton": "Join Meeting",
|
||||||
|
"footer": "Click the button above to join the meeting."
|
||||||
|
},
|
||||||
"calendarInvite": {
|
"calendarInvite": {
|
||||||
"subject": "{appName} - Calendar invitation from {fromUser}",
|
"subject": "{appName} - Calendar invitation from {fromUser}",
|
||||||
"intro": "You have received a calendar invitation from {fromUser}."
|
"intro": "You have received a calendar invitation from {fromUser}."
|
||||||
@@ -35,5 +49,9 @@
|
|||||||
"note": "The event has been automatically removed from your calendar.",
|
"note": "The event has been automatically removed from your calendar.",
|
||||||
"footer": "This message was sent automatically by {appName}."
|
"footer": "This message was sent automatically by {appName}."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"bbb": {
|
||||||
|
"defaultWelcome": "Welcome to the meeting!",
|
||||||
|
"inviteHint": "To invite other participants, share this link:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-6
@@ -14,8 +14,12 @@ import adminRoutes from './routes/admin.js';
|
|||||||
import brandingRoutes from './routes/branding.js';
|
import brandingRoutes from './routes/branding.js';
|
||||||
import federationRoutes, { wellKnownHandler } from './routes/federation.js';
|
import federationRoutes, { wellKnownHandler } from './routes/federation.js';
|
||||||
import calendarRoutes from './routes/calendar.js';
|
import calendarRoutes from './routes/calendar.js';
|
||||||
|
import caldavRoutes from './routes/caldav.js';
|
||||||
import notificationRoutes from './routes/notifications.js';
|
import notificationRoutes from './routes/notifications.js';
|
||||||
|
import oauthRoutes from './routes/oauth.js';
|
||||||
|
import analyticsRoutes from './routes/analytics.js';
|
||||||
import { startFederationSync } from './jobs/federationSync.js';
|
import { startFederationSync } from './jobs/federationSync.js';
|
||||||
|
import { startCalendarReminders } from './jobs/calendarReminders.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -29,13 +33,32 @@ const rawTrustProxy = process.env.TRUST_PROXY ?? 'loopback';
|
|||||||
const trustProxy = /^\d+$/.test(rawTrustProxy) ? parseInt(rawTrustProxy, 10) : rawTrustProxy;
|
const trustProxy = /^\d+$/.test(rawTrustProxy) ? parseInt(rawTrustProxy, 10) : rawTrustProxy;
|
||||||
app.set('trust proxy', trustProxy);
|
app.set('trust proxy', trustProxy);
|
||||||
|
|
||||||
|
// ── Security headers ───────────────────────────────────────────────────────
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||||
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
// M10: restrict CORS in production; allow all in development
|
// M10: restrict CORS in production; deny cross-origin by default
|
||||||
const corsOptions = process.env.APP_URL
|
const corsOptions = process.env.APP_URL
|
||||||
? { origin: process.env.APP_URL, credentials: true }
|
? { origin: process.env.APP_URL, credentials: true }
|
||||||
: {};
|
: { origin: false };
|
||||||
app.use(cors(corsOptions));
|
app.use(cors(corsOptions));
|
||||||
app.use(express.json());
|
// Global JSON body limit kept tight as a hardening measure. The BBB learning
|
||||||
|
// analytics callback can send much larger payloads, so it is excluded here and
|
||||||
|
// gets its own, more generous limit on the route itself (see routes/analytics.js).
|
||||||
|
const jsonParser = express.json({ limit: '100kb' });
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.path.startsWith('/api/analytics/callback/')) return next();
|
||||||
|
return jsonParser(req, res, next);
|
||||||
|
});
|
||||||
// Request/Response logging (filters sensitive fields)
|
// Request/Response logging (filters sensitive fields)
|
||||||
app.use(requestResponseLogger);
|
app.use(requestResponseLogger);
|
||||||
|
|
||||||
@@ -44,9 +67,10 @@ async function start() {
|
|||||||
await initDatabase();
|
await initDatabase();
|
||||||
initMailer();
|
initMailer();
|
||||||
|
|
||||||
// Serve uploaded files (avatars, presentations)
|
// Serve uploaded files (avatars are served via /api/auth/avatar/:filename)
|
||||||
const uploadsPath = path.join(__dirname, '..', 'uploads');
|
const uploadsPath = path.join(__dirname, '..', 'uploads');
|
||||||
app.use('/uploads', express.static(uploadsPath));
|
app.use('/uploads/branding', express.static(path.join(uploadsPath, 'branding')));
|
||||||
|
// Presentations are served via /api/rooms/presentations/:filename?token=… (HMAC-protected)
|
||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
@@ -57,14 +81,33 @@ async function start() {
|
|||||||
app.use('/api/federation', federationRoutes);
|
app.use('/api/federation', federationRoutes);
|
||||||
app.use('/api/calendar', calendarRoutes);
|
app.use('/api/calendar', calendarRoutes);
|
||||||
app.use('/api/notifications', notificationRoutes);
|
app.use('/api/notifications', notificationRoutes);
|
||||||
|
app.use('/api/oauth', oauthRoutes);
|
||||||
|
app.use('/api/analytics', analyticsRoutes);
|
||||||
|
// CalDAV — mounted outside /api so calendar clients use a clean path
|
||||||
|
app.use('/caldav', caldavRoutes);
|
||||||
// Mount calendar federation receive also under /api/federation for remote instances
|
// Mount calendar federation receive also under /api/federation for remote instances
|
||||||
app.use('/api/federation', calendarRoutes);
|
app.use('/api/federation', calendarRoutes);
|
||||||
app.get('/.well-known/redlight', wellKnownHandler);
|
app.get('/.well-known/redlight', wellKnownHandler);
|
||||||
|
|
||||||
|
// ── CalDAV service discovery (RFC 6764) ──────────────────────────────────
|
||||||
|
// Clients probe /.well-known/caldav then PROPFIND / before they know the
|
||||||
|
// real CalDAV mount point. Redirect them to /caldav/ for all HTTP methods.
|
||||||
|
app.all('/.well-known/caldav', (req, res) => {
|
||||||
|
res.redirect(301, '/caldav/');
|
||||||
|
});
|
||||||
|
// Some clients (e.g. Thunderbird) send PROPFIND / directly at the server root.
|
||||||
|
// Express doesn't register non-standard methods, so intercept via middleware.
|
||||||
|
app.use('/', (req, res, next) => {
|
||||||
|
if (req.method === 'PROPFIND' && req.path === '/') {
|
||||||
|
return res.redirect(301, '/caldav/');
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Serve static files in production
|
// Serve static files in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
app.use(express.static(path.join(__dirname, '..', 'dist')));
|
app.use(express.static(path.join(__dirname, '..', 'dist')));
|
||||||
app.get('*', (req, res) => {
|
app.get('/*splat', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, '..', 'dist', 'index.html'));
|
res.sendFile(path.join(__dirname, '..', 'dist', 'index.html'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -75,6 +118,8 @@ async function start() {
|
|||||||
|
|
||||||
// Start periodic federation sync job (checks remote room settings every 60s)
|
// Start periodic federation sync job (checks remote room settings every 60s)
|
||||||
startFederationSync();
|
startFederationSync();
|
||||||
|
// Start calendar reminder job (sends in-app + browser notifications before events)
|
||||||
|
startCalendarReminders();
|
||||||
}
|
}
|
||||||
|
|
||||||
start().catch(err => {
|
start().catch(err => {
|
||||||
|
|||||||
@@ -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 db = getDb();
|
||||||
const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [decoded.userId]);
|
const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified, oauth_provider, totp_enabled FROM users WHERE id = ?', [decoded.userId]);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(401).json({ error: 'User not found' });
|
return res.status(401).json({ error: 'User not found' });
|
||||||
}
|
}
|
||||||
@@ -57,3 +57,14 @@ export function generateToken(userId) {
|
|||||||
const jti = uuidv4();
|
const jti = uuidv4();
|
||||||
return jwt.sign({ userId, jti }, JWT_SECRET, { expiresIn: '7d' });
|
return jwt.sign({ userId, jti }, JWT_SECRET, { expiresIn: '7d' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the public base URL for the application.
|
||||||
|
* Prefers APP_URL env var. Falls back to X-Forwarded-Proto + Host header
|
||||||
|
* so that links are correct behind a TLS-terminating reverse proxy.
|
||||||
|
*/
|
||||||
|
export function getBaseUrl(req) {
|
||||||
|
if (process.env.APP_URL) return process.env.APP_URL.replace(/\/+$/, '');
|
||||||
|
const proto = req.get('x-forwarded-proto')?.split(',')[0]?.trim() || req.protocol;
|
||||||
|
return `${proto}://${req.get('host')}`;
|
||||||
|
}
|
||||||
|
|||||||
+139
-7
@@ -2,9 +2,16 @@
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { getDb } from '../config/database.js';
|
import { getDb } from '../config/database.js';
|
||||||
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
|
import { authenticateToken, requireAdmin, getBaseUrl } from '../middleware/auth.js';
|
||||||
import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js';
|
import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js';
|
||||||
|
import { getAppName } from '../config/appName.js';
|
||||||
import { log } from '../config/logger.js';
|
import { log } from '../config/logger.js';
|
||||||
|
import {
|
||||||
|
getOAuthConfig,
|
||||||
|
saveOAuthConfig,
|
||||||
|
deleteOAuthConfig,
|
||||||
|
discoverOIDC,
|
||||||
|
} from '../config/oauth.js';
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
|
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
|
||||||
|
|
||||||
@@ -32,6 +39,9 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
|
|||||||
if (password.length < 8) {
|
if (password.length < 8) {
|
||||||
return res.status(400).json({ error: 'Password must be at least 8 characters long' });
|
return res.status(400).json({ error: 'Password must be at least 8 characters long' });
|
||||||
}
|
}
|
||||||
|
if (password.length > 64) {
|
||||||
|
return res.status(400).json({ error: 'Password must not exceed 64 characters' });
|
||||||
|
}
|
||||||
|
|
||||||
// M9: email format validation
|
// M9: email format validation
|
||||||
if (!EMAIL_RE.test(email)) {
|
if (!EMAIL_RE.test(email)) {
|
||||||
@@ -51,7 +61,7 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
|
|||||||
return res.status(409).json({ error: 'Username is already taken' });
|
return res.status(409).json({ error: 'Username is already taken' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = bcrypt.hashSync(password, 12);
|
const hash = await bcrypt.hash(password, 12);
|
||||||
const result = await db.run(
|
const result = await db.run(
|
||||||
'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)',
|
'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)',
|
||||||
[name, display_name, email.toLowerCase(), hash, validRole]
|
[name, display_name, email.toLowerCase(), hash, validRole]
|
||||||
@@ -154,9 +164,12 @@ router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, r
|
|||||||
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
|
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
|
||||||
return res.status(400).json({ error: 'Password must be at least 8 characters long' });
|
return res.status(400).json({ error: 'Password must be at least 8 characters long' });
|
||||||
}
|
}
|
||||||
|
if (newPassword.length > 64) {
|
||||||
|
return res.status(400).json({ error: 'Password must not exceed 64 characters' });
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const hash = bcrypt.hashSync(newPassword, 12);
|
const hash = await bcrypt.hash(newPassword, 12);
|
||||||
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.params.id]);
|
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.params.id]);
|
||||||
|
|
||||||
res.json({ message: 'Password reset' });
|
res.json({ message: 'Password reset' });
|
||||||
@@ -202,12 +215,11 @@ router.post('/invites', authenticateToken, requireAdmin, async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Send invite email if SMTP is configured
|
// Send invite email if SMTP is configured
|
||||||
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
const baseUrl = getBaseUrl(req);
|
||||||
const inviteUrl = `${baseUrl}/register?invite=${token}`;
|
const inviteUrl = `${baseUrl}/register?invite=${token}`;
|
||||||
|
|
||||||
// Load app name
|
// Load configured app name (admin setting → env → default)
|
||||||
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'app_name'");
|
const appName = await getAppName();
|
||||||
const appName = brandingSetting?.value || 'Redlight';
|
|
||||||
|
|
||||||
if (isMailerConfigured()) {
|
if (isMailerConfigured()) {
|
||||||
try {
|
try {
|
||||||
@@ -260,4 +272,124 @@ 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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Room Management (admin only) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/admin/rooms - List all rooms with owner info
|
||||||
|
router.get('/rooms', authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const rooms = await db.all(`
|
||||||
|
SELECT r.id, r.uid, r.name, r.user_id, r.max_participants, r.access_code,
|
||||||
|
r.mute_on_join, r.record_meeting, r.guest_access, r.presentation_file,
|
||||||
|
r.created_at, r.updated_at,
|
||||||
|
COALESCE(NULLIF(u.display_name,''), u.name) as owner_name,
|
||||||
|
u.email as owner_email,
|
||||||
|
(SELECT COUNT(*) FROM room_shares rs WHERE rs.room_id = r.id) as share_count
|
||||||
|
FROM rooms r
|
||||||
|
JOIN users u ON r.user_id = u.id
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
`);
|
||||||
|
res.json({ rooms });
|
||||||
|
} catch (err) {
|
||||||
|
log.admin.error(`List rooms error: ${err.message}`);
|
||||||
|
res.status(500).json({ error: 'Rooms could not be loaded' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { Router, json } 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();
|
||||||
|
|
||||||
|
// Token check runs BEFORE the 5mb body parser so unauthenticated callers
|
||||||
|
// cannot make the server buffer large payloads.
|
||||||
|
// Constant-time comparison to prevent timing attacks. Reject non-string tokens
|
||||||
|
// (e.g. ?token=a&token=b would yield an array and crash Buffer.from).
|
||||||
|
function verifyCallbackToken(req, res, next) {
|
||||||
|
const { token } = req.query;
|
||||||
|
const expectedToken = getAnalyticsToken(req.params.uid);
|
||||||
|
if (typeof token !== 'string' || token.length !== expectedToken.length ||
|
||||||
|
!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) {
|
||||||
|
return res.status(403).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/analytics/callback/:uid?token=... - BBB Learning Analytics callback (token-secured)
|
||||||
|
// Excluded from the global 100kb body limit (see index.js): learning analytics
|
||||||
|
// payloads for large meetings can be several MB.
|
||||||
|
router.post('/callback/:uid', verifyCallbackToken, json({ limit: '5mb' }), async (req, res) => {
|
||||||
|
try {
|
||||||
|
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') {
|
||||||
|
// Prefix-escape values that start with a formula trigger character so that
|
||||||
|
// Excel/LibreOffice do not evaluate them as formulas (CSV injection).
|
||||||
|
const escapeCsv = (val) => {
|
||||||
|
if (val === null || val === undefined) return '';
|
||||||
|
let s = String(val);
|
||||||
|
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
|
||||||
|
if (s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r')) {
|
||||||
|
return '"' + s.replace(/"/g, '""') + '"';
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const header = COLUMNS.map(c => c.header).join(',');
|
||||||
|
const csvRows = rows.map(r =>
|
||||||
|
COLUMNS.map(c => escapeCsv(r[c.key])).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') {
|
||||||
|
// Prefix-escape strings that would otherwise be evaluated as formulas.
|
||||||
|
const sanitizeXlsx = (r) => {
|
||||||
|
const out = {};
|
||||||
|
for (const k of Object.keys(r)) {
|
||||||
|
const v = r[k];
|
||||||
|
out[k] = (typeof v === 'string' && /^[=+\-@\t\r]/.test(v)) ? "'" + v : v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet('Analytics');
|
||||||
|
sheet.columns = COLUMNS;
|
||||||
|
rows.forEach(r => sheet.addRow(sanitizeXlsx(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;
|
||||||
+447
-33
@@ -1,16 +1,20 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import crypto from 'crypto';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { rateLimit } from 'express-rate-limit';
|
import { rateLimit, MemoryStore } from 'express-rate-limit';
|
||||||
import { RedisStore } from 'rate-limit-redis';
|
import { RedisStore } from 'rate-limit-redis';
|
||||||
|
import * as OTPAuth from 'otpauth';
|
||||||
import { getDb } from '../config/database.js';
|
import { getDb } from '../config/database.js';
|
||||||
import redis from '../config/redis.js';
|
import redis from '../config/redis.js';
|
||||||
import { authenticateToken, generateToken } from '../middleware/auth.js';
|
import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js';
|
||||||
import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
|
import { isMailerConfigured, sendVerificationEmail, sendPasswordResetEmail } from '../config/mailer.js';
|
||||||
|
import { getOAuthConfig, discoverOIDC } from '../config/oauth.js';
|
||||||
|
import { getAppName } from '../config/appName.js';
|
||||||
import { log } from '../config/logger.js';
|
import { log } from '../config/logger.js';
|
||||||
|
|
||||||
if (!process.env.JWT_SECRET) {
|
if (!process.env.JWT_SECRET) {
|
||||||
@@ -20,15 +24,65 @@ if (!process.env.JWT_SECRET) {
|
|||||||
const JWT_SECRET = process.env.JWT_SECRET;
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
|
|
||||||
// ── Rate Limiting ────────────────────────────────────────────────────────────
|
// ── Rate Limiting ────────────────────────────────────────────────────────────
|
||||||
function makeRedisStore(prefix) {
|
// Shared Redis store with in-memory fallback. Without the fallback, a dead
|
||||||
try {
|
// Redis connection makes every rate-limited endpoint (incl. login) return 500 —
|
||||||
return new RedisStore({
|
// rate limiting must degrade, not take auth down with it.
|
||||||
sendCommand: (...args) => redis.call(...args),
|
class ResilientStore {
|
||||||
prefix,
|
constructor(prefix) {
|
||||||
});
|
try {
|
||||||
} catch {
|
this.redisStore = new RedisStore({
|
||||||
return undefined; // falls back to in-memory if Redis unavailable
|
sendCommand: (...args) => redis.call(...args),
|
||||||
|
prefix,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this.redisStore = null;
|
||||||
|
}
|
||||||
|
this.memoryStore = new MemoryStore();
|
||||||
}
|
}
|
||||||
|
init(options) {
|
||||||
|
try {
|
||||||
|
// RedisStore.init returns the SCRIPT LOAD promise; catch it so a dead
|
||||||
|
// Redis connection surfaces as a warning instead of an unhandled
|
||||||
|
// rejection that kills the process.
|
||||||
|
const initResult = this.redisStore?.init?.(options);
|
||||||
|
if (typeof initResult?.catch === 'function') {
|
||||||
|
initResult.catch(err => log.auth.warn(`Rate-limit Redis init failed: ${err.message}`));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.auth.warn(`Rate-limit Redis init failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
this.memoryStore.init(options);
|
||||||
|
}
|
||||||
|
async increment(key) {
|
||||||
|
if (this.redisStore && redis.status === 'ready') {
|
||||||
|
try {
|
||||||
|
return await this.redisStore.increment(key);
|
||||||
|
} catch (err) {
|
||||||
|
log.auth.warn(`Rate-limit Redis store failed, using in-memory fallback: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.memoryStore.increment(key);
|
||||||
|
}
|
||||||
|
async decrement(key) {
|
||||||
|
if (this.redisStore && redis.status === 'ready') {
|
||||||
|
try {
|
||||||
|
return await this.redisStore.decrement(key);
|
||||||
|
} catch { /* fall through to memory */ }
|
||||||
|
}
|
||||||
|
return this.memoryStore.decrement(key);
|
||||||
|
}
|
||||||
|
async resetKey(key) {
|
||||||
|
if (this.redisStore && redis.status === 'ready') {
|
||||||
|
try {
|
||||||
|
return await this.redisStore.resetKey(key);
|
||||||
|
} catch { /* fall through to memory */ }
|
||||||
|
}
|
||||||
|
return this.memoryStore.resetKey(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRedisStore(prefix) {
|
||||||
|
return new ResilientStore(prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Validation helpers ─────────────────────────────────────────────────────
|
// ── Validation helpers ─────────────────────────────────────────────────────
|
||||||
@@ -41,6 +95,13 @@ const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;
|
|||||||
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 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;
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
// bcrypt only uses the first 72 bytes; cap input to prevent CPU-DoS on hashing.
|
||||||
|
const MAX_PASSWORD_LENGTH = 64;
|
||||||
|
|
||||||
|
// Pre-computed bcrypt hash of a random string used as a dummy comparison
|
||||||
|
// target when the requested account does not exist. Keeps login timing
|
||||||
|
// roughly constant so we do not leak whether an email is registered.
|
||||||
|
const DUMMY_BCRYPT_HASH = bcrypt.hashSync('dummy-password-for-timing-' + Math.random(), 12);
|
||||||
|
|
||||||
// ── Rate Limiters ────────────────────────────────────────────────────────────
|
// ── Rate Limiters ────────────────────────────────────────────────────────────
|
||||||
const loginLimiter = rateLimit({
|
const loginLimiter = rateLimit({
|
||||||
@@ -98,6 +159,35 @@ const resendVerificationLimiter = rateLimit({
|
|||||||
store: makeRedisStore('rl:resend:'),
|
store: makeRedisStore('rl:resend:'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const twoFaLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Too many 2FA attempts. Please try again later.' },
|
||||||
|
store: makeRedisStore('rl:2fa:'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rate limit forgot-password to prevent SMTP abuse / email enumeration spam
|
||||||
|
const forgotPasswordLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 5,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Too many requests. Please try again later.' },
|
||||||
|
store: makeRedisStore('rl:forgot:'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rate limit reset-password submissions to slow token brute-forcing
|
||||||
|
const resetPasswordLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 10,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Too many attempts. Please try again later.' },
|
||||||
|
store: makeRedisStore('rl:reset:'),
|
||||||
|
});
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars');
|
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars');
|
||||||
@@ -157,8 +247,12 @@ router.post('/register', registerLimiter, async (req, res) => {
|
|||||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||||
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
|
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
|
||||||
}
|
}
|
||||||
|
if (password.length > MAX_PASSWORD_LENGTH) {
|
||||||
|
return res.status(400).json({ error: `Password must not exceed ${MAX_PASSWORD_LENGTH} characters` });
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]);
|
// Emails are stored lowercased, so compare lowercased to catch case-variant duplicates
|
||||||
|
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return res.status(409).json({ error: 'Email is already in use' });
|
return res.status(409).json({ error: 'Email is already in use' });
|
||||||
}
|
}
|
||||||
@@ -168,7 +262,7 @@ router.post('/register', registerLimiter, async (req, res) => {
|
|||||||
return res.status(409).json({ error: 'Username is already taken' });
|
return res.status(409).json({ error: 'Username is already taken' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = bcrypt.hashSync(password, 12);
|
const hash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
// If SMTP is configured, require email verification
|
// If SMTP is configured, require email verification
|
||||||
if (isMailerConfigured()) {
|
if (isMailerConfigured()) {
|
||||||
@@ -189,15 +283,11 @@ router.post('/register', registerLimiter, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build verification URL
|
// Build verification URL
|
||||||
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
const baseUrl = getBaseUrl(req);
|
||||||
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
|
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
|
||||||
|
|
||||||
// Load app name from branding settings
|
// Load configured app name (admin setting → env → default)
|
||||||
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
|
const appName = await getAppName();
|
||||||
let appName = 'Redlight';
|
|
||||||
if (brandingSetting?.value) {
|
|
||||||
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName, 'en');
|
await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName, 'en');
|
||||||
@@ -303,14 +393,10 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
|
|||||||
[verificationToken, expires, now, user.id]
|
[verificationToken, expires, now, user.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
const baseUrl = getBaseUrl(req);
|
||||||
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
|
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
|
||||||
|
|
||||||
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
|
const appName = await getAppName();
|
||||||
let appName = 'Redlight';
|
|
||||||
if (brandingSetting?.value) {
|
|
||||||
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName, user.language || 'en');
|
await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName, user.language || 'en');
|
||||||
@@ -326,6 +412,115 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hash a reset token for storage/lookup (never store the raw token)
|
||||||
|
function hashResetToken(token) {
|
||||||
|
return crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/auth/forgot-password
|
||||||
|
router.post('/forgot-password', forgotPasswordLimiter, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email } = req.body;
|
||||||
|
if (!email || typeof email !== 'string' || !EMAIL_RE.test(email)) {
|
||||||
|
return res.status(400).json({ error: 'A valid email address is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic response used in every branch so we never reveal whether an
|
||||||
|
// account exists, whether it uses SSO, or whether SMTP is configured.
|
||||||
|
const genericResponse = { message: 'If an account exists for that address, a password reset email has been sent.' };
|
||||||
|
|
||||||
|
if (!isMailerConfigured()) {
|
||||||
|
return res.json(genericResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const user = await db.get('SELECT id, name, display_name, email, language, oauth_provider, reset_requested_at FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||||
|
|
||||||
|
// No account, or an SSO-only account that has no local password to reset
|
||||||
|
if (!user || user.oauth_provider) {
|
||||||
|
return res.json(genericResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side 60s throttle per account to limit repeated emails
|
||||||
|
if (user.reset_requested_at) {
|
||||||
|
const secondsAgo = (Date.now() - new Date(user.reset_requested_at).getTime()) / 1000;
|
||||||
|
if (secondsAgo < 60) {
|
||||||
|
return res.json(genericResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetToken = uuidv4();
|
||||||
|
const tokenHash = hashResetToken(resetToken);
|
||||||
|
const expires = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
'UPDATE users SET reset_token_hash = ?, reset_token_expires = ?, reset_requested_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[tokenHash, expires, now, user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseUrl = getBaseUrl(req);
|
||||||
|
const resetUrl = `${baseUrl}/reset-password?token=${resetToken}`;
|
||||||
|
const appName = await getAppName();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendPasswordResetEmail(email.toLowerCase(), user.display_name || user.name, resetUrl, appName, user.language || 'en');
|
||||||
|
} catch (mailErr) {
|
||||||
|
log.auth.error(`Password reset mail failed: ${mailErr.message}`);
|
||||||
|
// Still return generic success — don't leak SMTP state to the client
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(genericResponse);
|
||||||
|
} catch (err) {
|
||||||
|
log.auth.error(`Forgot password error: ${err.message}`);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/reset-password
|
||||||
|
router.post('/reset-password', resetPasswordLimiter, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { token, password } = req.body;
|
||||||
|
if (!token || typeof token !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Invalid or expired reset link.' });
|
||||||
|
}
|
||||||
|
if (!password || typeof password !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'A new password is required' });
|
||||||
|
}
|
||||||
|
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||||
|
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
|
||||||
|
}
|
||||||
|
if (password.length > MAX_PASSWORD_LENGTH) {
|
||||||
|
return res.status(400).json({ error: `Password must not exceed ${MAX_PASSWORD_LENGTH} characters` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const tokenHash = hashResetToken(token);
|
||||||
|
const user = await db.get('SELECT id, reset_token_expires FROM users WHERE reset_token_hash = ?', [tokenHash]);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(400).json({ error: 'Invalid or expired reset link.' });
|
||||||
|
}
|
||||||
|
if (!user.reset_token_expires || new Date(user.reset_token_expires) < new Date()) {
|
||||||
|
return res.status(400).json({ error: 'Invalid or expired reset link.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
// Set the new password, verify the email (proves mailbox ownership), and
|
||||||
|
// clear the reset token so it can't be reused.
|
||||||
|
await db.run(
|
||||||
|
'UPDATE users SET password_hash = ?, email_verified = 1, reset_token_hash = NULL, reset_token_expires = NULL, reset_requested_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[hash, user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: 'Password has been reset successfully. You can now sign in.' });
|
||||||
|
} catch (err) {
|
||||||
|
log.auth.error(`Reset password error: ${err.message}`);
|
||||||
|
res.status(500).json({ error: 'Password could not be reset' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/auth/login
|
// POST /api/auth/login
|
||||||
router.post('/login', loginLimiter, async (req, res) => {
|
router.post('/login', loginLimiter, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -340,10 +535,18 @@ router.post('/login', loginLimiter, async (req, res) => {
|
|||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cap password length to keep bcrypt CPU work bounded.
|
||||||
|
if (typeof password !== 'string' || password.length > MAX_PASSWORD_LENGTH) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
|
const user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||||
|
|
||||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
// Always run bcrypt against either the real hash or a dummy hash so login
|
||||||
|
// timing does not reveal whether the email is registered.
|
||||||
|
const passwordOk = bcrypt.compareSync(password, user?.password_hash || DUMMY_BCRYPT_HASH);
|
||||||
|
if (!user || !passwordOk) {
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,8 +554,14 @@ router.post('/login', loginLimiter, async (req, res) => {
|
|||||||
return res.status(403).json({ error: 'Email address not yet verified. Please check your inbox.', needsVerification: true });
|
return res.status(403).json({ error: 'Email address not yet verified. Please check your inbox.', needsVerification: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 2FA check ────────────────────────────────────────────────────────
|
||||||
|
if (user.totp_enabled) {
|
||||||
|
const tempToken = jwt.sign({ userId: user.id, purpose: '2fa' }, JWT_SECRET, { expiresIn: '5m' });
|
||||||
|
return res.json({ requires2FA: true, tempToken });
|
||||||
|
}
|
||||||
|
|
||||||
const token = generateToken(user.id);
|
const token = generateToken(user.id);
|
||||||
const { password_hash, ...safeUser } = user;
|
const { password_hash, verification_token, verification_token_expires, verification_resend_at, totp_secret, ...safeUser } = user;
|
||||||
|
|
||||||
res.json({ token, user: safeUser });
|
res.json({ token, user: safeUser });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -361,6 +570,53 @@ router.post('/login', loginLimiter, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/login/2fa - Verify TOTP code and complete login
|
||||||
|
router.post('/login/2fa', twoFaLimiter, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { tempToken, code } = req.body;
|
||||||
|
if (!tempToken || !code) {
|
||||||
|
return res.status(400).json({ error: 'Token and code are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded;
|
||||||
|
try {
|
||||||
|
decoded = jwt.verify(tempToken, JWT_SECRET);
|
||||||
|
} catch {
|
||||||
|
return res.status(401).json({ error: 'Invalid or expired token. Please log in again.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded.purpose !== '2fa') {
|
||||||
|
return res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const user = await db.get('SELECT * FROM users WHERE id = ?', [decoded.userId]);
|
||||||
|
if (!user || !user.totp_enabled || !user.totp_secret) {
|
||||||
|
return res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const totp = new OTPAuth.TOTP({
|
||||||
|
secret: OTPAuth.Secret.fromBase32(user.totp_secret),
|
||||||
|
algorithm: 'SHA1',
|
||||||
|
digits: 6,
|
||||||
|
period: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
|
||||||
|
if (delta === null) {
|
||||||
|
return res.status(401).json({ error: 'Invalid 2FA code' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateToken(user.id);
|
||||||
|
const { password_hash, verification_token, verification_token_expires, verification_resend_at, totp_secret, ...safeUser } = user;
|
||||||
|
|
||||||
|
res.json({ token, user: safeUser });
|
||||||
|
} catch (err) {
|
||||||
|
log.auth.error(`2FA login error: ${err.message}`);
|
||||||
|
res.status(500).json({ error: '2FA verification failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/auth/logout - revoke JWT via DragonflyDB blacklist
|
// POST /api/auth/logout - revoke JWT via DragonflyDB blacklist
|
||||||
router.post('/logout', authenticateToken, async (req, res) => {
|
router.post('/logout', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -379,7 +635,31 @@ router.post('/logout', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ message: 'Logged out successfully' });
|
// ── RP-Initiated Logout for OIDC/Keycloak users ──────────────────────
|
||||||
|
let keycloakLogoutUrl = null;
|
||||||
|
if (req.user.oauth_provider === 'oidc') {
|
||||||
|
try {
|
||||||
|
const config = await getOAuthConfig();
|
||||||
|
if (config) {
|
||||||
|
const oidc = await discoverOIDC(config.issuer);
|
||||||
|
if (oidc.end_session_endpoint) {
|
||||||
|
const idToken = await redis.get(`oidc:id_token:${req.user.id}`);
|
||||||
|
await redis.del(`oidc:id_token:${req.user.id}`);
|
||||||
|
const baseUrl = getBaseUrl(req);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
post_logout_redirect_uri: `${baseUrl}/`,
|
||||||
|
client_id: config.clientId,
|
||||||
|
});
|
||||||
|
if (idToken) params.set('id_token_hint', idToken);
|
||||||
|
keycloakLogoutUrl = `${oidc.end_session_endpoint}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (oidcErr) {
|
||||||
|
log.auth.warn(`Could not build Keycloak logout URL: ${oidcErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Logged out successfully', keycloakLogoutUrl });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.auth.error(`Logout error: ${err.message}`);
|
log.auth.error(`Logout error: ${err.message}`);
|
||||||
res.status(500).json({ error: 'Logout failed' });
|
res.status(500).json({ error: 'Logout failed' });
|
||||||
@@ -472,6 +752,9 @@ router.put('/password', authenticateToken, passwordLimiter, async (req, res) =>
|
|||||||
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
|
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
|
||||||
return res.status(400).json({ error: 'Invalid input' });
|
return res.status(400).json({ error: 'Invalid input' });
|
||||||
}
|
}
|
||||||
|
if (currentPassword.length > MAX_PASSWORD_LENGTH || newPassword.length > MAX_PASSWORD_LENGTH) {
|
||||||
|
return res.status(400).json({ error: `Password must not exceed ${MAX_PASSWORD_LENGTH} characters` });
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
@@ -485,7 +768,7 @@ router.put('/password', authenticateToken, passwordLimiter, async (req, res) =>
|
|||||||
return res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
|
return res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = bcrypt.hashSync(newPassword, 12);
|
const hash = await bcrypt.hash(newPassword, 12);
|
||||||
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.user.id]);
|
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.user.id]);
|
||||||
|
|
||||||
res.json({ message: 'Password changed successfully' });
|
res.json({ message: 'Password changed successfully' });
|
||||||
@@ -498,7 +781,7 @@ router.put('/password', authenticateToken, passwordLimiter, async (req, res) =>
|
|||||||
// POST /api/auth/avatar - Upload avatar image
|
// POST /api/auth/avatar - Upload avatar image
|
||||||
router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
|
router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Validate content type
|
// Validate file content by checking magic bytes (file signatures)
|
||||||
const contentType = req.headers['content-type'];
|
const contentType = req.headers['content-type'];
|
||||||
if (!contentType || !contentType.startsWith('image/')) {
|
if (!contentType || !contentType.startsWith('image/')) {
|
||||||
return res.status(400).json({ error: 'Only image files are allowed' });
|
return res.status(400).json({ error: 'Only image files are allowed' });
|
||||||
@@ -528,7 +811,18 @@ router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Image must not exceed 2MB' });
|
return res.status(400).json({ error: 'Image must not exceed 2MB' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = contentType.includes('png') ? 'png' : contentType.includes('gif') ? 'gif' : contentType.includes('webp') ? 'webp' : 'jpg';
|
// Validate magic bytes to prevent Content-Type spoofing
|
||||||
|
const magicBytes = buffer.slice(0, 8);
|
||||||
|
const isJPEG = magicBytes[0] === 0xFF && magicBytes[1] === 0xD8 && magicBytes[2] === 0xFF;
|
||||||
|
const isPNG = magicBytes[0] === 0x89 && magicBytes[1] === 0x50 && magicBytes[2] === 0x4E && magicBytes[3] === 0x47;
|
||||||
|
const isGIF = magicBytes[0] === 0x47 && magicBytes[1] === 0x49 && magicBytes[2] === 0x46;
|
||||||
|
const isWEBP = magicBytes[0] === 0x52 && magicBytes[1] === 0x49 && magicBytes[2] === 0x46 && magicBytes[3] === 0x46
|
||||||
|
&& buffer.length > 11 && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50;
|
||||||
|
if (!isJPEG && !isPNG && !isGIF && !isWEBP) {
|
||||||
|
return res.status(400).json({ error: 'File content does not match a supported image format (JPEG, PNG, GIF, WebP)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = isPNG ? 'png' : isGIF ? 'gif' : isWEBP ? 'webp' : 'jpg';
|
||||||
const filename = `${req.user.id}_${Date.now()}.${ext}`;
|
const filename = `${req.user.id}_${Date.now()}.${ext}`;
|
||||||
const filepath = path.join(uploadsDir, filename);
|
const filepath = path.join(uploadsDir, filename);
|
||||||
|
|
||||||
@@ -634,4 +928,124 @@ router.get('/avatar/:filename', (req, res) => {
|
|||||||
fs.createReadStream(filepath).pipe(res);
|
fs.createReadStream(filepath).pipe(res);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 2FA Management ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/auth/2fa/status
|
||||||
|
router.get('/2fa/status', authenticateToken, async (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const user = await db.get('SELECT totp_enabled FROM users WHERE id = ?', [req.user.id]);
|
||||||
|
res.json({ enabled: !!user?.totp_enabled });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/2fa/setup - Generate TOTP secret + provisioning URI
|
||||||
|
router.post('/2fa/setup', authenticateToken, twoFaLimiter, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const user = await db.get('SELECT totp_enabled FROM users WHERE id = ?', [req.user.id]);
|
||||||
|
if (user?.totp_enabled) {
|
||||||
|
return res.status(400).json({ error: '2FA is already enabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = new OTPAuth.Secret({ size: 20 });
|
||||||
|
|
||||||
|
// Use the configured app name as the TOTP issuer (admin setting → env → default)
|
||||||
|
const issuer = await getAppName();
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
if (typeof password !== 'string' || password.length > MAX_PASSWORD_LENGTH) {
|
||||||
|
return res.status(400).json({ error: 'Invalid password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const user = await db.get('SELECT password_hash, totp_secret, totp_enabled FROM users WHERE id = ?', [req.user.id]);
|
||||||
|
if (!user?.totp_enabled) {
|
||||||
|
return res.status(400).json({ error: '2FA is not enabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bcrypt.compareSync(password, user.password_hash)) {
|
||||||
|
return res.status(401).json({ error: 'Invalid password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const totp = new OTPAuth.TOTP({
|
||||||
|
secret: OTPAuth.Secret.fromBase32(user.totp_secret),
|
||||||
|
algorithm: 'SHA1',
|
||||||
|
digits: 6,
|
||||||
|
period: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
|
||||||
|
if (delta === null) {
|
||||||
|
return res.status(401).json({ error: 'Invalid 2FA code' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.run('UPDATE users SET totp_enabled = 0, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]);
|
||||||
|
res.json({ enabled: false, message: '2FA has been disabled' });
|
||||||
|
} catch (err) {
|
||||||
|
log.auth.error(`2FA disable error: ${err.message}`);
|
||||||
|
res.status(500).json({ error: '2FA could not be disabled' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { fileURLToPath } from 'url';
|
|||||||
import { getDb } from '../config/database.js';
|
import { getDb } from '../config/database.js';
|
||||||
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
|
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
|
||||||
import { log } from '../config/logger.js';
|
import { log } from '../config/logger.js';
|
||||||
|
import { getOAuthConfig } from '../config/oauth.js';
|
||||||
|
import { isTranscriptionEnabled } from '../config/transcription.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -14,6 +16,16 @@ const router = Router();
|
|||||||
|
|
||||||
const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;
|
const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;
|
||||||
|
|
||||||
|
// Validate that a URL uses a safe scheme (http/https only)
|
||||||
|
function isSafeUrl(url) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure uploads/branding directory exists
|
// Ensure uploads/branding directory exists
|
||||||
const brandingDir = path.join(__dirname, '..', '..', 'uploads', 'branding');
|
const brandingDir = path.join(__dirname, '..', '..', 'uploads', 'branding');
|
||||||
if (!fs.existsSync(brandingDir)) {
|
if (!fs.existsSync(brandingDir)) {
|
||||||
@@ -86,6 +98,19 @@ router.get('/', async (req, res) => {
|
|||||||
const imprintUrl = await getSetting('imprint_url');
|
const imprintUrl = await getSetting('imprint_url');
|
||||||
const privacyUrl = await getSetting('privacy_url');
|
const privacyUrl = await getSetting('privacy_url');
|
||||||
|
|
||||||
|
// OAuth: expose whether OAuth is enabled + display name for login page
|
||||||
|
let oauthEnabled = false;
|
||||||
|
let oauthDisplayName = null;
|
||||||
|
try {
|
||||||
|
const oauthConfig = await getOAuthConfig();
|
||||||
|
if (oauthConfig) {
|
||||||
|
oauthEnabled = true;
|
||||||
|
oauthDisplayName = oauthConfig.displayName || 'SSO';
|
||||||
|
}
|
||||||
|
} catch { /* not configured */ }
|
||||||
|
|
||||||
|
const hideAppName = await getSetting('hide_app_name');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
appName: appName || 'Redlight',
|
appName: appName || 'Redlight',
|
||||||
hasLogo: !!logoFile,
|
hasLogo: !!logoFile,
|
||||||
@@ -94,6 +119,10 @@ router.get('/', async (req, res) => {
|
|||||||
registrationMode: registrationMode || 'open',
|
registrationMode: registrationMode || 'open',
|
||||||
imprintUrl: imprintUrl || null,
|
imprintUrl: imprintUrl || null,
|
||||||
privacyUrl: privacyUrl || null,
|
privacyUrl: privacyUrl || null,
|
||||||
|
oauthEnabled,
|
||||||
|
oauthDisplayName,
|
||||||
|
hideAppName: hideAppName === 'true',
|
||||||
|
transcriptionEnabled: isTranscriptionEnabled(),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.branding.error('Get branding error:', err);
|
log.branding.error('Get branding error:', err);
|
||||||
@@ -221,6 +250,9 @@ router.put('/imprint-url', authenticateToken, requireAdmin, async (req, res) =>
|
|||||||
if (imprintUrl && imprintUrl.length > 500) {
|
if (imprintUrl && imprintUrl.length > 500) {
|
||||||
return res.status(400).json({ error: 'URL must not exceed 500 characters' });
|
return res.status(400).json({ error: 'URL must not exceed 500 characters' });
|
||||||
}
|
}
|
||||||
|
if (imprintUrl && imprintUrl.trim() && !isSafeUrl(imprintUrl.trim())) {
|
||||||
|
return res.status(400).json({ error: 'URL must start with http:// or https://' });
|
||||||
|
}
|
||||||
if (imprintUrl && imprintUrl.trim()) {
|
if (imprintUrl && imprintUrl.trim()) {
|
||||||
await setSetting('imprint_url', imprintUrl.trim());
|
await setSetting('imprint_url', imprintUrl.trim());
|
||||||
} else {
|
} else {
|
||||||
@@ -240,6 +272,9 @@ router.put('/privacy-url', authenticateToken, requireAdmin, async (req, res) =>
|
|||||||
if (privacyUrl && privacyUrl.length > 500) {
|
if (privacyUrl && privacyUrl.length > 500) {
|
||||||
return res.status(400).json({ error: 'URL must not exceed 500 characters' });
|
return res.status(400).json({ error: 'URL must not exceed 500 characters' });
|
||||||
}
|
}
|
||||||
|
if (privacyUrl && privacyUrl.trim() && !isSafeUrl(privacyUrl.trim())) {
|
||||||
|
return res.status(400).json({ error: 'URL must start with http:// or https://' });
|
||||||
|
}
|
||||||
if (privacyUrl && privacyUrl.trim()) {
|
if (privacyUrl && privacyUrl.trim()) {
|
||||||
await setSetting('privacy_url', privacyUrl.trim());
|
await setSetting('privacy_url', privacyUrl.trim());
|
||||||
} else {
|
} else {
|
||||||
@@ -252,4 +287,23 @@ router.put('/privacy-url', authenticateToken, requireAdmin, async (req, res) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PUT /api/branding/hide-app-name - Toggle app name visibility (admin only)
|
||||||
|
router.put('/hide-app-name', authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { hideAppName } = req.body;
|
||||||
|
if (typeof hideAppName !== 'boolean') {
|
||||||
|
return res.status(400).json({ error: 'hideAppName must be a boolean' });
|
||||||
|
}
|
||||||
|
if (hideAppName) {
|
||||||
|
await setSetting('hide_app_name', 'true');
|
||||||
|
} else {
|
||||||
|
await deleteSetting('hide_app_name');
|
||||||
|
}
|
||||||
|
res.json({ hideAppName });
|
||||||
|
} catch (err) {
|
||||||
|
log.branding.error('Update hide app name error:', err);
|
||||||
|
res.status(500).json({ error: 'Could not update setting' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -0,0 +1,551 @@
|
|||||||
|
/**
|
||||||
|
* 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 { rateLimit } from 'express-rate-limit';
|
||||||
|
import { getDb } from '../config/database.js';
|
||||||
|
import { log } from '../config/logger.js';
|
||||||
|
import { getBaseUrl } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Brute-force protection for the Basic-Auth tokens: only failed requests count
|
||||||
|
// (skipSuccessfulRequests), so legitimate sync clients polling frequently are
|
||||||
|
// not throttled.
|
||||||
|
router.use(rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 30,
|
||||||
|
skipSuccessfulRequests: true,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: 'Too many failed CalDAV requests. Please try again later.',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── 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();
|
||||||
|
// Emails are stored lowercased
|
||||||
|
const user = await db.get(
|
||||||
|
'SELECT id, name, display_name, email FROM users WHERE email = ?',
|
||||||
|
[email.toLowerCase()],
|
||||||
|
);
|
||||||
|
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).toLowerCase() !== 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('/*splat', (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('/*splat', caldavAuth, (req, res) => {
|
||||||
|
setDAVHeaders(res);
|
||||||
|
res.status(405).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
+120
-11
@@ -1,9 +1,10 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { getDb } from '../config/database.js';
|
import { getDb } from '../config/database.js';
|
||||||
import { authenticateToken } from '../middleware/auth.js';
|
import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
|
||||||
import { log } from '../config/logger.js';
|
import { log } from '../config/logger.js';
|
||||||
import { sendCalendarInviteEmail } from '../config/mailer.js';
|
import { sendCalendarInviteEmail } from '../config/mailer.js';
|
||||||
|
import { getAppName } from '../config/appName.js';
|
||||||
import {
|
import {
|
||||||
isFederationEnabled,
|
isFederationEnabled,
|
||||||
getFederationDomain,
|
getFederationDomain,
|
||||||
@@ -16,6 +17,12 @@ import { rateLimit } from 'express-rate-limit';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// Allowlist for CSS color values - only permits hsl(), hex (#rgb/#rrggbb) and plain names
|
||||||
|
const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
|
||||||
|
|
||||||
|
// Allowed reminder intervals in minutes
|
||||||
|
const VALID_REMINDERS = new Set([5, 15, 30, 60, 120, 1440]);
|
||||||
|
|
||||||
// Rate limit for federation calendar receive
|
// Rate limit for federation calendar receive
|
||||||
const calendarFederationLimiter = rateLimit({
|
const calendarFederationLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
@@ -114,13 +121,18 @@ router.get('/events/:id', authenticateToken, async (req, res) => {
|
|||||||
// ── POST /api/calendar/events — Create event ────────────────────────────────
|
// ── POST /api/calendar/events — Create event ────────────────────────────────
|
||||||
router.post('/events', authenticateToken, async (req, res) => {
|
router.post('/events', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { title, description, start_time, end_time, room_uid, color } = req.body;
|
const { title, description, start_time, end_time, room_uid, color, reminder_minutes } = req.body;
|
||||||
|
|
||||||
if (!title || !title.trim()) return res.status(400).json({ error: 'Title is required' });
|
if (!title || !title.trim()) return res.status(400).json({ error: 'Title is required' });
|
||||||
if (!start_time || !end_time) return res.status(400).json({ error: 'Start and end time are required' });
|
if (!start_time || !end_time) return res.status(400).json({ error: 'Start and end time are required' });
|
||||||
if (title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' });
|
if (title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' });
|
||||||
if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' });
|
if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' });
|
||||||
|
|
||||||
|
// Validate color format
|
||||||
|
if (color && !SAFE_COLOR_RE.test(color)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid color format' });
|
||||||
|
}
|
||||||
|
|
||||||
const startDate = new Date(start_time);
|
const startDate = new Date(start_time);
|
||||||
const endDate = new Date(end_time);
|
const endDate = new Date(end_time);
|
||||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||||
@@ -138,9 +150,12 @@ router.post('/events', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uid = crypto.randomBytes(12).toString('hex');
|
const uid = crypto.randomBytes(12).toString('hex');
|
||||||
|
const validReminder = (reminder_minutes != null && VALID_REMINDERS.has(Number(reminder_minutes)))
|
||||||
|
? Number(reminder_minutes) : null;
|
||||||
|
|
||||||
const result = await db.run(`
|
const result = await db.run(`
|
||||||
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color)
|
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, reminder_minutes)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`, [
|
`, [
|
||||||
uid,
|
uid,
|
||||||
title.trim(),
|
title.trim(),
|
||||||
@@ -150,6 +165,7 @@ router.post('/events', authenticateToken, async (req, res) => {
|
|||||||
room_uid || null,
|
room_uid || null,
|
||||||
req.user.id,
|
req.user.id,
|
||||||
color || '#6366f1',
|
color || '#6366f1',
|
||||||
|
validReminder,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const event = await db.get('SELECT * FROM calendar_events WHERE id = ?', [result.lastInsertRowid]);
|
const event = await db.get('SELECT * FROM calendar_events WHERE id = ?', [result.lastInsertRowid]);
|
||||||
@@ -167,11 +183,16 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
|
|||||||
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
||||||
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
|
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
|
||||||
|
|
||||||
const { title, description, start_time, end_time, room_uid, color } = req.body;
|
const { title, description, start_time, end_time, room_uid, color, reminder_minutes } = req.body;
|
||||||
|
|
||||||
if (title && title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' });
|
if (title && title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' });
|
||||||
if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' });
|
if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' });
|
||||||
|
|
||||||
|
// Validate color format
|
||||||
|
if (color && !SAFE_COLOR_RE.test(color)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid color format' });
|
||||||
|
}
|
||||||
|
|
||||||
if (start_time && end_time) {
|
if (start_time && end_time) {
|
||||||
const s = new Date(start_time);
|
const s = new Date(start_time);
|
||||||
const e = new Date(end_time);
|
const e = new Date(end_time);
|
||||||
@@ -184,6 +205,13 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
|
|||||||
if (!room) return res.status(400).json({ error: 'Linked room not found' });
|
if (!room) return res.status(400).json({ error: 'Linked room not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validReminder = (reminder_minutes !== undefined)
|
||||||
|
? ((reminder_minutes != null && VALID_REMINDERS.has(Number(reminder_minutes))) ? Number(reminder_minutes) : null)
|
||||||
|
: undefined;
|
||||||
|
// Reset reminder_sent_at when start_time or reminder_minutes changes so the job re-fires
|
||||||
|
const resetReminder = (start_time !== undefined && start_time !== event.start_time)
|
||||||
|
|| (reminder_minutes !== undefined && validReminder !== event.reminder_minutes);
|
||||||
|
|
||||||
await db.run(`
|
await db.run(`
|
||||||
UPDATE calendar_events SET
|
UPDATE calendar_events SET
|
||||||
title = COALESCE(?, title),
|
title = COALESCE(?, title),
|
||||||
@@ -192,6 +220,8 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
|
|||||||
end_time = COALESCE(?, end_time),
|
end_time = COALESCE(?, end_time),
|
||||||
room_uid = ?,
|
room_uid = ?,
|
||||||
color = COALESCE(?, color),
|
color = COALESCE(?, color),
|
||||||
|
reminder_minutes = ?,
|
||||||
|
reminder_sent_at = ${resetReminder ? 'NULL' : 'reminder_sent_at'},
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`, [
|
`, [
|
||||||
@@ -201,6 +231,9 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
|
|||||||
end_time || null,
|
end_time || null,
|
||||||
room_uid !== undefined ? (room_uid || null) : event.room_uid,
|
room_uid !== undefined ? (room_uid || null) : event.room_uid,
|
||||||
color || null,
|
color || null,
|
||||||
|
// Not in payload → keep existing; present (incl. null) → set/clear directly.
|
||||||
|
// COALESCE can't be used here: it would treat an explicit null as "keep".
|
||||||
|
validReminder !== undefined ? validReminder : event.reminder_minutes,
|
||||||
req.params.id,
|
req.params.id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -291,9 +324,9 @@ router.post('/events/:id/share', authenticateToken, async (req, res) => {
|
|||||||
// Send notification email (fire-and-forget)
|
// Send notification email (fire-and-forget)
|
||||||
const targetUser = await db.get('SELECT name, display_name, email, language FROM users WHERE id = ?', [user_id]);
|
const targetUser = await db.get('SELECT name, display_name, email, language FROM users WHERE id = ?', [user_id]);
|
||||||
if (targetUser?.email) {
|
if (targetUser?.email) {
|
||||||
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
const appUrl = getBaseUrl(req);
|
||||||
const inboxUrl = `${appUrl}/federation/inbox`;
|
const inboxUrl = `${appUrl}/federation/inbox`;
|
||||||
const appName = process.env.APP_NAME || 'Redlight';
|
const appName = await getAppName();
|
||||||
const senderUser = await db.get('SELECT name, display_name FROM users WHERE id = ?', [req.user.id]);
|
const senderUser = await db.get('SELECT name, display_name FROM users WHERE id = ?', [req.user.id]);
|
||||||
const fromDisplay = (senderUser?.display_name && senderUser.display_name !== '') ? senderUser.display_name : (senderUser?.name || req.user.name);
|
const fromDisplay = (senderUser?.display_name && senderUser.display_name !== '') ? senderUser.display_name : (senderUser?.name || req.user.name);
|
||||||
sendCalendarInviteEmail(
|
sendCalendarInviteEmail(
|
||||||
@@ -456,7 +489,7 @@ router.get('/events/:id/ics', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build room join URL if linked
|
// Build room join URL if linked
|
||||||
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
const baseUrl = getBaseUrl(req);
|
||||||
let location = '';
|
let location = '';
|
||||||
if (event.room_uid) {
|
if (event.room_uid) {
|
||||||
location = `${baseUrl}/join/${event.room_uid}`;
|
location = `${baseUrl}/join/${event.room_uid}`;
|
||||||
@@ -493,7 +526,7 @@ router.post('/events/:id/federation', authenticateToken, async (req, res) => {
|
|||||||
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
||||||
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
|
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
|
||||||
|
|
||||||
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
const baseUrl = getBaseUrl(req);
|
||||||
let joinUrl = null;
|
let joinUrl = null;
|
||||||
if (event.room_uid) {
|
if (event.room_uid) {
|
||||||
joinUrl = `${baseUrl}/join/${event.room_uid}`;
|
joinUrl = `${baseUrl}/join/${event.room_uid}`;
|
||||||
@@ -613,9 +646,9 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as
|
|||||||
|
|
||||||
// Send notification email (fire-and-forget)
|
// Send notification email (fire-and-forget)
|
||||||
if (targetUser.email) {
|
if (targetUser.email) {
|
||||||
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
const appUrl = getBaseUrl(req);
|
||||||
const inboxUrl = `${appUrl}/federation/inbox`;
|
const inboxUrl = `${appUrl}/federation/inbox`;
|
||||||
const appName = process.env.APP_NAME || 'Redlight';
|
const appName = await getAppName();
|
||||||
sendCalendarInviteEmail(
|
sendCalendarInviteEmail(
|
||||||
targetUser.email, targetUser.name, from_user,
|
targetUser.email, targetUser.name, from_user,
|
||||||
title, start_time, end_time, description || null,
|
title, start_time, end_time, description || null,
|
||||||
@@ -673,8 +706,84 @@ function generateICS(event, location, prodIdDomain) {
|
|||||||
ics.push(`ORGANIZER;CN=${escapeICS(event.organizer_name)}:mailto:${event.organizer_email}`);
|
ics.push(`ORGANIZER;CN=${escapeICS(event.organizer_name)}:mailto:${event.organizer_email}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.reminder_minutes) {
|
||||||
|
ics.push(
|
||||||
|
'BEGIN:VALARM',
|
||||||
|
'ACTION:DISPLAY',
|
||||||
|
`DESCRIPTION:Reminder: ${escapeICS(event.title)}`,
|
||||||
|
`TRIGGER:-PT${event.reminder_minutes}M`,
|
||||||
|
'END:VALARM',
|
||||||
|
);
|
||||||
|
}
|
||||||
ics.push('END:VEVENT', 'END:VCALENDAR');
|
ics.push('END:VEVENT', 'END:VCALENDAR');
|
||||||
return ics.join('\r\n');
|
return ics.join('\r\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CalDAV token management ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/calendar/caldav-tokens
|
||||||
|
router.get('/caldav-tokens', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const tokens = await db.all(
|
||||||
|
'SELECT id, name, created_at, last_used_at FROM caldav_tokens WHERE user_id = ? ORDER BY created_at DESC',
|
||||||
|
[req.user.id],
|
||||||
|
);
|
||||||
|
res.json({ tokens });
|
||||||
|
} catch (err) {
|
||||||
|
log.server.error(`CalDAV list tokens error: ${err.message}`);
|
||||||
|
res.status(500).json({ error: 'Could not load tokens' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/calendar/caldav-tokens
|
||||||
|
router.post('/caldav-tokens', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.body;
|
||||||
|
if (!name || !name.trim()) {
|
||||||
|
return res.status(400).json({ error: 'Token name is required' });
|
||||||
|
}
|
||||||
|
const db = getDb();
|
||||||
|
const count = await db.get(
|
||||||
|
'SELECT COUNT(*) as c FROM caldav_tokens WHERE user_id = ?',
|
||||||
|
[req.user.id],
|
||||||
|
);
|
||||||
|
if (count.c >= 10) {
|
||||||
|
return res.status(400).json({ error: 'Maximum of 10 tokens allowed' });
|
||||||
|
}
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
const result = await db.run(
|
||||||
|
// Store only the hash — never the plaintext — to limit exposure on DB breach.
|
||||||
|
'INSERT INTO caldav_tokens (user_id, token_hash, name) VALUES (?, ?, ?)',
|
||||||
|
[req.user.id, tokenHash, name.trim()],
|
||||||
|
);
|
||||||
|
res.status(201).json({
|
||||||
|
token: { id: result.lastInsertRowid, name: name.trim() },
|
||||||
|
plainToken: token,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log.server.error(`CalDAV create token error: ${err.message}`);
|
||||||
|
res.status(500).json({ error: 'Could not create token' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/calendar/caldav-tokens/:id
|
||||||
|
router.delete('/caldav-tokens/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const result = await db.run(
|
||||||
|
'DELETE FROM caldav_tokens WHERE id = ? AND user_id = ?',
|
||||||
|
[req.params.id, req.user.id],
|
||||||
|
);
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Token not found' });
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
log.server.error(`CalDAV delete token error: ${err.message}`);
|
||||||
|
res.status(500).json({ error: 'Could not delete token' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
+50
-34
@@ -2,18 +2,19 @@
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { rateLimit } from 'express-rate-limit';
|
import { rateLimit } from 'express-rate-limit';
|
||||||
import { getDb } from '../config/database.js';
|
import { getDb } from '../config/database.js';
|
||||||
import { authenticateToken } from '../middleware/auth.js';
|
import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
|
||||||
import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js';
|
import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js';
|
||||||
import { log } from '../config/logger.js';
|
import { log } from '../config/logger.js';
|
||||||
import { createNotification } from '../config/notifications.js';
|
import { createNotification } from '../config/notifications.js';
|
||||||
|
import { getAppName } from '../config/appName.js';
|
||||||
|
|
||||||
// M13: rate limit the unauthenticated federation receive endpoint
|
// M13: rate limit the unauthenticated federation receive endpoint
|
||||||
const federationReceiveLimiter = rateLimit({
|
const federationReceiveLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
max: 100,
|
max: 100,
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
message: { error: 'Too many federation requests. Please try again later.' },
|
message: { error: 'Too many federation requests. Please try again later.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -40,7 +41,7 @@ export function wellKnownHandler(req, res) {
|
|||||||
federation_api: '/api/federation',
|
federation_api: '/api/federation',
|
||||||
public_key: getPublicKey(),
|
public_key: getPublicKey(),
|
||||||
software: 'Redlight',
|
software: 'Redlight',
|
||||||
version: '1.4.0',
|
version: '2.4.0',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ router.post('/invite', authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
// Build guest join URL for the remote user
|
// Build guest join URL for the remote user
|
||||||
// If the room has an access code, embed it so the recipient can join without manual entry
|
// If the room has an access code, embed it so the recipient can join without manual entry
|
||||||
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
const baseUrl = getBaseUrl(req);
|
||||||
const joinUrl = room.access_code
|
const joinUrl = room.access_code
|
||||||
? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}`
|
? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}`
|
||||||
: `${baseUrl}/join/${room.uid}`;
|
: `${baseUrl}/join/${room.uid}`;
|
||||||
@@ -161,6 +162,16 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Incomplete invitation payload' });
|
return res.status(400).json({ error: 'Incomplete invitation payload' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate join_url scheme to prevent javascript: or other malicious URIs
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(join_url);
|
||||||
|
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
|
||||||
|
return res.status(400).json({ error: 'join_url must use https:// or http://' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: 'Invalid join_url format' });
|
||||||
|
}
|
||||||
|
|
||||||
// S4: validate field lengths from remote to prevent oversized DB entries
|
// S4: validate field lengths from remote to prevent oversized DB entries
|
||||||
if (invite_id.length > 100 || from_user.length > 200 || to_user.length > 200 ||
|
if (invite_id.length > 100 || from_user.length > 200 || to_user.length > 200 ||
|
||||||
room_name.length > 200 || join_url.length > 2000 || (message && message.length > 5000)) {
|
room_name.length > 200 || join_url.length > 2000 || (message && message.length > 5000)) {
|
||||||
@@ -188,7 +199,7 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
|
|||||||
|
|
||||||
// Look up user by name (case-insensitive)
|
// Look up user by name (case-insensitive)
|
||||||
const targetUser = await db.get(
|
const targetUser = await db.get(
|
||||||
'SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)',
|
'SELECT id, name, email, language FROM users WHERE LOWER(name) = LOWER(?)',
|
||||||
[username]
|
[username]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -226,24 +237,24 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
|
|||||||
|
|
||||||
// Send notification email (truly fire-and-forget - never blocks the response)
|
// Send notification email (truly fire-and-forget - never blocks the response)
|
||||||
if (targetUser.email) {
|
if (targetUser.email) {
|
||||||
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
const appUrl = getBaseUrl(req);
|
||||||
const inboxUrl = `${appUrl}/federation/inbox`;
|
const inboxUrl = `${appUrl}/federation/inbox`;
|
||||||
const appName = process.env.APP_NAME || 'Redlight';
|
const appName = await getAppName();
|
||||||
sendFederationInviteEmail(
|
sendFederationInviteEmail(
|
||||||
targetUser.email, targetUser.name, from_user,
|
targetUser.email, targetUser.name, from_user,
|
||||||
room_name, message || null, inboxUrl, appName, targetUser.language || 'en'
|
room_name, message || null, inboxUrl, appName, targetUser.language || 'en'
|
||||||
).catch(mailErr => {
|
).catch(mailErr => {
|
||||||
log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message);
|
log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-app notification
|
// In-app notification
|
||||||
await createNotification(
|
await createNotification(
|
||||||
targetUser.id,
|
targetUser.id,
|
||||||
'federation_invite_received',
|
'federation_invite_received',
|
||||||
from_user,
|
from_user,
|
||||||
room_name,
|
room_name,
|
||||||
'/federation/inbox',
|
'/federation/inbox',
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@@ -567,6 +578,9 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res
|
|||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
|
// Escape LIKE special characters in originDomain to prevent wildcard injection.
|
||||||
|
const safeDomain = originDomain.replace(/[\\%_]/g, '\\$&');
|
||||||
|
|
||||||
// Collect all affected users before deleting (for email notifications)
|
// Collect all affected users before deleting (for email notifications)
|
||||||
let affectedUsers = [];
|
let affectedUsers = [];
|
||||||
try {
|
try {
|
||||||
@@ -575,16 +589,16 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res
|
|||||||
`SELECT u.email, u.name, u.language, ci.title, ci.from_user
|
`SELECT u.email, u.name, u.language, ci.title, ci.from_user
|
||||||
FROM calendar_invitations ci
|
FROM calendar_invitations ci
|
||||||
JOIN users u ON ci.to_user_id = u.id
|
JOIN users u ON ci.to_user_id = u.id
|
||||||
WHERE ci.event_uid = ? AND ci.from_user LIKE ?`,
|
WHERE ci.event_uid = ? AND ci.from_user LIKE ? ESCAPE '\\'`,
|
||||||
[event_uid, `%@${originDomain}`]
|
[event_uid, `%@${safeDomain}`]
|
||||||
);
|
);
|
||||||
// Users who already accepted (event in their calendar)
|
// Users who already accepted (event in their calendar)
|
||||||
const calUsers = await db.all(
|
const calUsers = await db.all(
|
||||||
`SELECT u.email, u.name, u.language, ce.title, ce.federated_from AS from_user
|
`SELECT u.email, u.name, u.language, ce.title, ce.federated_from AS from_user
|
||||||
FROM calendar_events ce
|
FROM calendar_events ce
|
||||||
JOIN users u ON ce.user_id = u.id
|
JOIN users u ON ce.user_id = u.id
|
||||||
WHERE ce.uid = ? AND ce.federated_from LIKE ?`,
|
WHERE ce.uid = ? AND ce.federated_from LIKE ? ESCAPE '\\'`,
|
||||||
[event_uid, `%@${originDomain}`]
|
[event_uid, `%@${safeDomain}`]
|
||||||
);
|
);
|
||||||
// Merge, deduplicate by email
|
// Merge, deduplicate by email
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -599,22 +613,22 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res
|
|||||||
// Remove from calendar_invitations for all users on this instance
|
// Remove from calendar_invitations for all users on this instance
|
||||||
await db.run(
|
await db.run(
|
||||||
`DELETE FROM calendar_invitations
|
`DELETE FROM calendar_invitations
|
||||||
WHERE event_uid = ? AND from_user LIKE ?`,
|
WHERE event_uid = ? AND from_user LIKE ? ESCAPE '\\'`,
|
||||||
[event_uid, `%@${originDomain}`]
|
[event_uid, `%@${safeDomain}`]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove from calendar_events (accepted invitations) for all users on this instance
|
// Remove from calendar_events (accepted invitations) for all users on this instance
|
||||||
await db.run(
|
await db.run(
|
||||||
`DELETE FROM calendar_events
|
`DELETE FROM calendar_events
|
||||||
WHERE uid = ? AND federated_from LIKE ?`,
|
WHERE uid = ? AND federated_from LIKE ? ESCAPE '\\'`,
|
||||||
[event_uid, `%@${originDomain}`]
|
[event_uid, `%@${safeDomain}`]
|
||||||
);
|
);
|
||||||
|
|
||||||
log.federation.info(`Calendar event ${event_uid} deleted (origin: ${originDomain})`);
|
log.federation.info(`Calendar event ${event_uid} deleted (origin: ${originDomain})`);
|
||||||
|
|
||||||
// Notify affected users by email (fire-and-forget)
|
// Notify affected users by email (fire-and-forget)
|
||||||
if (affectedUsers.length > 0) {
|
if (affectedUsers.length > 0) {
|
||||||
const appName = process.env.APP_NAME || 'Redlight';
|
const appName = await getAppName();
|
||||||
for (const u of affectedUsers) {
|
for (const u of affectedUsers) {
|
||||||
sendCalendarEventDeletedEmail(u.email, u.name, u.from_user, u.title, appName, u.language || 'en')
|
sendCalendarEventDeletedEmail(u.email, u.name, u.from_user, u.title, appName, u.language || 'en')
|
||||||
.catch(mailErr => {
|
.catch(mailErr => {
|
||||||
@@ -657,11 +671,13 @@ router.post('/room-deleted', federationReceiveLimiter, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
// Escape LIKE special characters in originDomain to prevent wildcard injection.
|
||||||
|
const safeDomain = originDomain.replace(/[\\%_]/g, '\\$&');
|
||||||
// Mark all federated_rooms with this meet_id (room_uid) from this origin as deleted
|
// Mark all federated_rooms with this meet_id (room_uid) from this origin as deleted
|
||||||
await db.run(
|
await db.run(
|
||||||
`UPDATE federated_rooms SET deleted = 1, updated_at = CURRENT_TIMESTAMP
|
`UPDATE federated_rooms SET deleted = 1, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE meet_id = ? AND from_user LIKE ?`,
|
WHERE meet_id = ? AND from_user LIKE ? ESCAPE '\\'`,
|
||||||
[room_uid, `%@${originDomain}`]
|
[room_uid, `%@${safeDomain}`]
|
||||||
);
|
);
|
||||||
|
|
||||||
log.federation.info(`Room ${room_uid} marked as deleted (origin: ${originDomain})`);
|
log.federation.info(`Room ${room_uid} marked as deleted (origin: ${originDomain})`);
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
// Only auto-link to an existing local account if the IdP has actually
|
||||||
|
// verified the email. Otherwise an attacker who registers at the IdP
|
||||||
|
// with someone else's email could take over the local account.
|
||||||
|
if (userInfo.email_verified !== true) {
|
||||||
|
log.auth.warn(`OAuth account-linking blocked: provider did not assert email_verified=true for ${email}`);
|
||||||
|
return errorRedirect('Your OAuth provider has not verified this email address. Please verify it with the provider before logging in here.');
|
||||||
|
}
|
||||||
|
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;
|
||||||
+265
-64
@@ -1,7 +1,9 @@
|
|||||||
import { Router } from 'express';
|
import { Router, urlencoded } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
import { authenticateToken } from '../middleware/auth.js';
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
import { getDb } from '../config/database.js';
|
import { getDb } from '../config/database.js';
|
||||||
import { log } from '../config/logger.js';
|
import { log } from '../config/logger.js';
|
||||||
|
import { isTranscriptionEnabled, requestTranscription } from '../config/transcription.js';
|
||||||
import {
|
import {
|
||||||
getRecordings,
|
getRecordings,
|
||||||
getRecordingByRecordId,
|
getRecordingByRecordId,
|
||||||
@@ -9,8 +11,234 @@ import {
|
|||||||
publishRecording,
|
publishRecording,
|
||||||
} from '../config/bbb.js';
|
} from '../config/bbb.js';
|
||||||
|
|
||||||
|
const BBB_SECRET = process.env.BBB_SECRET || '';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a raw BBB recording into a normalised object.
|
||||||
|
*/
|
||||||
|
function formatBbbRecording(rec, fallbackName) {
|
||||||
|
const playback = rec.playback?.format;
|
||||||
|
let formats = [];
|
||||||
|
if (playback) {
|
||||||
|
formats = Array.isArray(playback) ? playback : [playback];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
recordID: rec.recordID,
|
||||||
|
meetingID: rec.meetingID,
|
||||||
|
name: rec.name || fallbackName || 'Recording',
|
||||||
|
state: rec.state,
|
||||||
|
published: rec.published === 'true',
|
||||||
|
startTime: rec.startTime,
|
||||||
|
endTime: rec.endTime,
|
||||||
|
participants: rec.participants,
|
||||||
|
size: rec.size,
|
||||||
|
formats: formats.map(f => ({
|
||||||
|
type: f.type,
|
||||||
|
url: f.url,
|
||||||
|
length: f.length,
|
||||||
|
size: f.size,
|
||||||
|
})),
|
||||||
|
metadata: rec.metadata || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a DB row into the same shape the frontend expects.
|
||||||
|
*/
|
||||||
|
function formatDbRecording(row) {
|
||||||
|
return {
|
||||||
|
recordID: row.record_id,
|
||||||
|
meetingID: row.meeting_id,
|
||||||
|
name: row.name || 'Recording',
|
||||||
|
state: row.state,
|
||||||
|
published: row.published === 1,
|
||||||
|
startTime: row.start_time,
|
||||||
|
endTime: row.end_time,
|
||||||
|
participants: row.participants,
|
||||||
|
size: row.size,
|
||||||
|
formats: JSON.parse(row.formats || '[]'),
|
||||||
|
metadata: JSON.parse(row.metadata || '{}'),
|
||||||
|
fromCache: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a single formatted recording into the DB.
|
||||||
|
* - If the record_id already exists AND is the same recording, merge new formats.
|
||||||
|
* - If it's a new recording or a different one with the same record_id, overwrite.
|
||||||
|
*/
|
||||||
|
async function upsertRecording(db, rec) {
|
||||||
|
const existing = await db.get('SELECT * FROM recordings WHERE record_id = ?', [rec.recordID]);
|
||||||
|
const formatsJson = JSON.stringify(rec.formats);
|
||||||
|
const metadataJson = JSON.stringify(rec.metadata || {});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Verify it's still the same recording (same startTime)
|
||||||
|
if (String(rec.startTime) === String(existing.start_time)) {
|
||||||
|
// Merge formats: keep existing formats, add any new types
|
||||||
|
const existingFormats = JSON.parse(existing.formats || '[]');
|
||||||
|
const existingTypes = new Set(existingFormats.map(f => f.type));
|
||||||
|
const mergedFormats = [...existingFormats];
|
||||||
|
|
||||||
|
for (const f of rec.formats) {
|
||||||
|
if (existingTypes.has(f.type)) {
|
||||||
|
// Update URL for existing format type (server may have changed)
|
||||||
|
const idx = mergedFormats.findIndex(ef => ef.type === f.type);
|
||||||
|
if (idx !== -1) mergedFormats[idx] = f;
|
||||||
|
} else {
|
||||||
|
// New format type added
|
||||||
|
mergedFormats.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`UPDATE recordings SET name = ?, state = ?, published = ?, end_time = ?,
|
||||||
|
participants = ?, size = ?, formats = ?, metadata = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE record_id = ?`,
|
||||||
|
[rec.name, rec.state, rec.published ? 1 : 0, rec.endTime,
|
||||||
|
rec.participants, rec.size, JSON.stringify(mergedFormats), metadataJson,
|
||||||
|
rec.recordID]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Different recording with same record_id – overwrite completely
|
||||||
|
await db.run(
|
||||||
|
`UPDATE recordings SET meeting_id = ?, name = ?, state = ?, published = ?,
|
||||||
|
start_time = ?, end_time = ?, participants = ?, size = ?, formats = ?,
|
||||||
|
metadata = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE record_id = ?`,
|
||||||
|
[rec.meetingID, rec.name, rec.state, rec.published ? 1 : 0,
|
||||||
|
rec.startTime, rec.endTime, rec.participants, rec.size,
|
||||||
|
formatsJson, metadataJson, rec.recordID]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Completely new recording – insert
|
||||||
|
await db.run(
|
||||||
|
`INSERT INTO recordings (record_id, meeting_id, name, state, published, start_time,
|
||||||
|
end_time, participants, size, formats, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[rec.recordID, rec.meetingID, rec.name, rec.state, rec.published ? 1 : 0,
|
||||||
|
rec.startTime, rec.endTime, rec.participants, rec.size,
|
||||||
|
formatsJson, metadataJson]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core logic: fetch recordings from BBB, sync with DB, fall back to DB on error.
|
||||||
|
* Returns an array of formatted recordings.
|
||||||
|
*/
|
||||||
|
async function fetchAndSyncRecordings(meetingID, fallbackName) {
|
||||||
|
const db = getDb();
|
||||||
|
let bbbRecordings = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await getRecordings(meetingID || undefined);
|
||||||
|
bbbRecordings = raw.map(rec => formatBbbRecording(rec, fallbackName));
|
||||||
|
} catch (err) {
|
||||||
|
log.recordings.warn(`BBB API unreachable, falling back to cached recordings: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bbbRecordings !== null) {
|
||||||
|
// BBB API was reachable – sync each recording into the DB
|
||||||
|
for (const rec of bbbRecordings) {
|
||||||
|
try {
|
||||||
|
await upsertRecording(db, rec);
|
||||||
|
} catch (err) {
|
||||||
|
log.recordings.error(`Failed to cache recording ${rec.recordID}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For recordings in DB that were NOT returned by BBB (possibly deleted on server
|
||||||
|
// or server changed), keep them in DB with their cached URLs – don't delete.
|
||||||
|
// However, we return the BBB result merged with any DB-only recordings.
|
||||||
|
const bbbIds = new Set(bbbRecordings.map(r => r.recordID));
|
||||||
|
const params = meetingID ? [meetingID] : [];
|
||||||
|
const query = meetingID
|
||||||
|
? 'SELECT * FROM recordings WHERE meeting_id = ?'
|
||||||
|
: 'SELECT * FROM recordings';
|
||||||
|
const dbRows = await db.all(query, params);
|
||||||
|
|
||||||
|
// Add DB-only recordings (not on current BBB server) with cached URLs
|
||||||
|
for (const row of dbRows) {
|
||||||
|
if (!bbbIds.has(row.record_id)) {
|
||||||
|
bbbRecordings.push(formatDbRecording(row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bbbRecordings;
|
||||||
|
} else {
|
||||||
|
// BBB API unreachable – serve everything from DB
|
||||||
|
const params = meetingID ? [meetingID] : [];
|
||||||
|
const query = meetingID
|
||||||
|
? 'SELECT * FROM recordings WHERE meeting_id = ?'
|
||||||
|
: 'SELECT * FROM recordings';
|
||||||
|
const rows = await db.all(query, params);
|
||||||
|
return rows.map(formatDbRecording);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/recordings/recording-ready - BBB recording-ready callback (unauthenticated)
|
||||||
|
// BBB posts `signed_parameters`, a JWT signed with the shared BBB secret.
|
||||||
|
// Fires once per playback format; the transcription request is only sent when
|
||||||
|
// the "video" format first becomes available and the room has the setting on.
|
||||||
|
router.post('/recording-ready', urlencoded({ extended: false }), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const signed = req.body?.signed_parameters;
|
||||||
|
if (!signed || typeof signed !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'signed_parameters is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = jwt.verify(signed, BBB_SECRET, { algorithms: ['HS256'] });
|
||||||
|
} catch {
|
||||||
|
return res.status(403).json({ error: 'Invalid signature' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordId = payload.record_id;
|
||||||
|
const meetingId = payload.meeting_id;
|
||||||
|
if (!recordId || !meetingId) {
|
||||||
|
return res.status(400).json({ error: 'record_id and meeting_id are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always answer 200 from here on so BBB does not keep retrying —
|
||||||
|
// "nothing to do" is a valid outcome for this callback.
|
||||||
|
const db = getDb();
|
||||||
|
const room = await db.get('SELECT id, uid, name, recording_transcript FROM rooms WHERE uid = ?', [meetingId]);
|
||||||
|
if (!room || !room.recording_transcript || !isTranscriptionEnabled()) {
|
||||||
|
return res.json({});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rec = await getRecordingByRecordId(recordId).catch(() => null);
|
||||||
|
if (!rec) return res.json({});
|
||||||
|
|
||||||
|
const formatted = formatBbbRecording(rec, room.name);
|
||||||
|
const hasVideo = formatted.formats.some(f => f.type === 'video');
|
||||||
|
|
||||||
|
// Cache the recording so the dedupe marker below has a row to live on
|
||||||
|
await upsertRecording(db, formatted);
|
||||||
|
|
||||||
|
if (!hasVideo) return res.json({});
|
||||||
|
|
||||||
|
// Dedupe: BBB fires the callback once per format
|
||||||
|
const cached = await db.get('SELECT transcript_requested_at FROM recordings WHERE record_id = ?', [recordId]);
|
||||||
|
if (cached?.transcript_requested_at) return res.json({});
|
||||||
|
|
||||||
|
const ok = await requestTranscription({ meetingId, recordingId: recordId });
|
||||||
|
if (ok) {
|
||||||
|
await db.run('UPDATE recordings SET transcript_requested_at = CURRENT_TIMESTAMP WHERE record_id = ?', [recordId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({});
|
||||||
|
} catch (err) {
|
||||||
|
log.recordings.error(`Recording-ready callback error: ${err.message}`);
|
||||||
|
res.status(500).json({ error: 'Error processing recording-ready callback' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/recordings - Get recordings for a room (by meetingID/uid)
|
// GET /api/recordings - Get recordings for a room (by meetingID/uid)
|
||||||
router.get('/', authenticateToken, async (req, res) => {
|
router.get('/', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -34,36 +262,7 @@ router.get('/', authenticateToken, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'meetingID query parameter is required' });
|
return res.status(400).json({ error: 'meetingID query parameter is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordings = await getRecordings(meetingID || undefined);
|
const formatted = await fetchAndSyncRecordings(meetingID);
|
||||||
|
|
||||||
// Format recordings
|
|
||||||
const formatted = recordings.map(rec => {
|
|
||||||
const playback = rec.playback?.format;
|
|
||||||
let formats = [];
|
|
||||||
if (playback) {
|
|
||||||
formats = Array.isArray(playback) ? playback : [playback];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
recordID: rec.recordID,
|
|
||||||
meetingID: rec.meetingID,
|
|
||||||
name: rec.name || 'Recording',
|
|
||||||
state: rec.state,
|
|
||||||
published: rec.published === 'true',
|
|
||||||
startTime: rec.startTime,
|
|
||||||
endTime: rec.endTime,
|
|
||||||
participants: rec.participants,
|
|
||||||
size: rec.size,
|
|
||||||
formats: formats.map(f => ({
|
|
||||||
type: f.type,
|
|
||||||
url: f.url,
|
|
||||||
length: f.length,
|
|
||||||
size: f.size,
|
|
||||||
})),
|
|
||||||
metadata: rec.metadata || {},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ recordings: formatted });
|
res.json({ recordings: formatted });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.recordings.error(`Get recordings error: ${err.message}`);
|
log.recordings.error(`Get recordings error: ${err.message}`);
|
||||||
@@ -89,33 +288,7 @@ router.get('/room/:uid', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordings = await getRecordings(room.uid);
|
const formatted = await fetchAndSyncRecordings(room.uid, room.name);
|
||||||
const formatted = recordings.map(rec => {
|
|
||||||
const playback = rec.playback?.format;
|
|
||||||
let formats = [];
|
|
||||||
if (playback) {
|
|
||||||
formats = Array.isArray(playback) ? playback : [playback];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
recordID: rec.recordID,
|
|
||||||
meetingID: rec.meetingID,
|
|
||||||
name: rec.name || room.name,
|
|
||||||
state: rec.state,
|
|
||||||
published: rec.published === 'true',
|
|
||||||
startTime: rec.startTime,
|
|
||||||
endTime: rec.endTime,
|
|
||||||
participants: rec.participants,
|
|
||||||
size: rec.size,
|
|
||||||
formats: formats.map(f => ({
|
|
||||||
type: f.type,
|
|
||||||
url: f.url,
|
|
||||||
length: f.length,
|
|
||||||
size: f.size,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ recordings: formatted });
|
res.json({ recordings: formatted });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.recordings.error(`Get room recordings error: ${err.message}`);
|
log.recordings.error(`Get room recordings error: ${err.message}`);
|
||||||
@@ -126,14 +299,21 @@ router.get('/room/:uid', authenticateToken, async (req, res) => {
|
|||||||
// DELETE /api/recordings/:recordID
|
// DELETE /api/recordings/:recordID
|
||||||
router.delete('/:recordID', authenticateToken, async (req, res) => {
|
router.delete('/:recordID', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const db = getDb();
|
||||||
// M14 fix: look up the recording from BBB to find its meetingID (room UID),
|
// M14 fix: look up the recording from BBB to find its meetingID (room UID),
|
||||||
// then verify the user owns or shares that room.
|
// then verify the user owns or shares that room.
|
||||||
if (req.user.role !== 'admin') {
|
if (req.user.role !== 'admin') {
|
||||||
const rec = await getRecordingByRecordId(req.params.recordID);
|
// Try BBB first, fall back to DB cache
|
||||||
|
let rec = await getRecordingByRecordId(req.params.recordID).catch(() => null);
|
||||||
|
if (!rec) {
|
||||||
|
const dbRow = await db.get('SELECT * FROM recordings WHERE record_id = ?', [req.params.recordID]);
|
||||||
|
if (dbRow) {
|
||||||
|
rec = { meetingID: dbRow.meeting_id };
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!rec) {
|
if (!rec) {
|
||||||
return res.status(404).json({ error: 'Recording not found' });
|
return res.status(404).json({ error: 'Recording not found' });
|
||||||
}
|
}
|
||||||
const db = getDb();
|
|
||||||
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]);
|
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]);
|
||||||
if (!room) {
|
if (!room) {
|
||||||
return res.status(404).json({ error: 'Room not found' });
|
return res.status(404).json({ error: 'Room not found' });
|
||||||
@@ -145,7 +325,14 @@ router.delete('/:recordID', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await deleteRecording(req.params.recordID);
|
// Try to delete on BBB (may fail if server changed – that's OK)
|
||||||
|
try {
|
||||||
|
await deleteRecording(req.params.recordID);
|
||||||
|
} catch (err) {
|
||||||
|
log.recordings.warn(`BBB deleteRecording failed (may already be gone): ${err.message}`);
|
||||||
|
}
|
||||||
|
// Always remove from local cache
|
||||||
|
await db.run('DELETE FROM recordings WHERE record_id = ?', [req.params.recordID]);
|
||||||
res.json({ message: 'Recording deleted' });
|
res.json({ message: 'Recording deleted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.recordings.error(`Delete recording error: ${err.message}`);
|
log.recordings.error(`Delete recording error: ${err.message}`);
|
||||||
@@ -156,14 +343,20 @@ router.delete('/:recordID', authenticateToken, async (req, res) => {
|
|||||||
// PUT /api/recordings/:recordID/publish
|
// PUT /api/recordings/:recordID/publish
|
||||||
router.put('/:recordID/publish', authenticateToken, async (req, res) => {
|
router.put('/:recordID/publish', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const db = getDb();
|
||||||
// M14 fix: look up the recording from BBB to find its meetingID (room UID),
|
// M14 fix: look up the recording from BBB to find its meetingID (room UID),
|
||||||
// then verify the user owns or shares that room.
|
// then verify the user owns or shares that room.
|
||||||
if (req.user.role !== 'admin') {
|
if (req.user.role !== 'admin') {
|
||||||
const rec = await getRecordingByRecordId(req.params.recordID);
|
let rec = await getRecordingByRecordId(req.params.recordID).catch(() => null);
|
||||||
|
if (!rec) {
|
||||||
|
const dbRow = await db.get('SELECT * FROM recordings WHERE record_id = ?', [req.params.recordID]);
|
||||||
|
if (dbRow) {
|
||||||
|
rec = { meetingID: dbRow.meeting_id };
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!rec) {
|
if (!rec) {
|
||||||
return res.status(404).json({ error: 'Recording not found' });
|
return res.status(404).json({ error: 'Recording not found' });
|
||||||
}
|
}
|
||||||
const db = getDb();
|
|
||||||
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]);
|
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]);
|
||||||
if (!room) {
|
if (!room) {
|
||||||
return res.status(404).json({ error: 'Room not found' });
|
return res.status(404).json({ error: 'Room not found' });
|
||||||
@@ -176,7 +369,15 @@ router.put('/:recordID/publish', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { publish } = req.body;
|
const { publish } = req.body;
|
||||||
await publishRecording(req.params.recordID, publish);
|
// Try BBB API
|
||||||
|
try {
|
||||||
|
await publishRecording(req.params.recordID, publish);
|
||||||
|
} catch (err) {
|
||||||
|
log.recordings.warn(`BBB publishRecording failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
// Update DB cache
|
||||||
|
await db.run('UPDATE recordings SET published = ?, updated_at = CURRENT_TIMESTAMP WHERE record_id = ?',
|
||||||
|
[publish ? 1 : 0, req.params.recordID]);
|
||||||
res.json({ message: publish ? 'Recording published' : 'Recording unpublished' });
|
res.json({ message: publish ? 'Recording published' : 'Recording unpublished' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.recordings.error(`Publish recording error: ${err.message}`);
|
log.recordings.error(`Publish recording error: ${err.message}`);
|
||||||
|
|||||||
+240
-30
@@ -5,15 +5,18 @@ import path from 'path';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { rateLimit } from 'express-rate-limit';
|
import { rateLimit } from 'express-rate-limit';
|
||||||
import { getDb } from '../config/database.js';
|
import { getDb } from '../config/database.js';
|
||||||
import { authenticateToken } from '../middleware/auth.js';
|
import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
|
||||||
import { log } from '../config/logger.js';
|
import { log } from '../config/logger.js';
|
||||||
import { createNotification } from '../config/notifications.js';
|
import { createNotification } from '../config/notifications.js';
|
||||||
|
import { sendGuestInviteEmail } from '../config/mailer.js';
|
||||||
|
import { getAppName } from '../config/appName.js';
|
||||||
import {
|
import {
|
||||||
createMeeting,
|
createMeeting,
|
||||||
joinMeeting,
|
joinMeeting,
|
||||||
endMeeting,
|
endMeeting,
|
||||||
getMeetingInfo,
|
getMeetingInfo,
|
||||||
isMeetingRunning,
|
isMeetingRunning,
|
||||||
|
getAnalyticsToken,
|
||||||
} from '../config/bbb.js';
|
} from '../config/bbb.js';
|
||||||
import {
|
import {
|
||||||
isFederationEnabled,
|
isFederationEnabled,
|
||||||
@@ -22,6 +25,14 @@ import {
|
|||||||
discoverInstance,
|
discoverInstance,
|
||||||
} from '../config/federation.js';
|
} from '../config/federation.js';
|
||||||
|
|
||||||
|
// Avatar image filenames are produced by the upload endpoint as
|
||||||
|
// "<userId>_<timestamp>.<ext>". Reject anything that doesn't match this shape
|
||||||
|
// so a guest can't smuggle path traversal or arbitrary URL fragments through
|
||||||
|
// the unauthenticated guest-join endpoint.
|
||||||
|
const SAFE_AVATAR_FILENAME_RE = /^[0-9]+_[0-9]+\.(jpg|png|gif|webp)$/;
|
||||||
|
// Mirrors auth.js: only allow hex/hsl/named CSS colors.
|
||||||
|
const SAFE_AVATAR_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})$/;
|
||||||
|
|
||||||
// L6: constant-time string comparison for access/moderator codes
|
// L6: constant-time string comparison for access/moderator codes
|
||||||
function timingSafeEqual(a, b) {
|
function timingSafeEqual(a, b) {
|
||||||
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
||||||
@@ -36,6 +47,17 @@ const __dirname = path.dirname(__filename);
|
|||||||
const presentationsDir = path.join(__dirname, '..', '..', 'uploads', 'presentations');
|
const presentationsDir = path.join(__dirname, '..', '..', 'uploads', 'presentations');
|
||||||
if (!fs.existsSync(presentationsDir)) fs.mkdirSync(presentationsDir, { recursive: true });
|
if (!fs.existsSync(presentationsDir)) fs.mkdirSync(presentationsDir, { recursive: true });
|
||||||
|
|
||||||
|
const PRESENTATION_TOKEN_SECRET = process.env.BBB_SECRET || crypto.randomBytes(32).toString('hex');
|
||||||
|
const PRESENTATION_TOKEN_TTL = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
|
function signPresentationUrl(roomUid, filename) {
|
||||||
|
const expires = Date.now() + PRESENTATION_TOKEN_TTL;
|
||||||
|
const token = crypto.createHmac('sha256', PRESENTATION_TOKEN_SECRET)
|
||||||
|
.update(`${roomUid}/${filename}:${expires}`)
|
||||||
|
.digest('hex');
|
||||||
|
return { token, expires };
|
||||||
|
}
|
||||||
|
|
||||||
// M8: rate limit unauthenticated guest-join to prevent access_code brute-force
|
// M8: rate limit unauthenticated guest-join to prevent access_code brute-force
|
||||||
const guestJoinLimiter = rateLimit({
|
const guestJoinLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
@@ -45,11 +67,21 @@ const guestJoinLimiter = rateLimit({
|
|||||||
message: { error: 'Too many join attempts. Please try again later.' },
|
message: { error: 'Too many join attempts. Please try again later.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rate limit email invitations: each request may carry up to 50 addresses, so
|
||||||
|
// without a cap any registered account could be abused as an SMTP spam relay.
|
||||||
|
const inviteEmailLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 10,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Too many email invitations. Please try again later.' },
|
||||||
|
});
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Build avatar URL for a user (uploaded image or generated initials)
|
// Build avatar URL for a user (uploaded image or generated initials)
|
||||||
function getUserAvatarURL(req, user) {
|
function getUserAvatarURL(req, user) {
|
||||||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
const baseUrl = getBaseUrl(req);
|
||||||
if (user.avatar_image) {
|
if (user.avatar_image) {
|
||||||
return `${baseUrl}/api/auth/avatar/${user.avatar_image}`;
|
return `${baseUrl}/api/auth/avatar/${user.avatar_image}`;
|
||||||
}
|
}
|
||||||
@@ -161,11 +193,15 @@ router.post('/', authenticateToken, async (req, res) => {
|
|||||||
record_meeting,
|
record_meeting,
|
||||||
guest_access,
|
guest_access,
|
||||||
moderator_code,
|
moderator_code,
|
||||||
|
recording_transcript,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!name || name.trim().length === 0) {
|
if (!name || name.trim().length === 0) {
|
||||||
return res.status(400).json({ error: 'Room name is required' });
|
return res.status(400).json({ error: 'Room name is required' });
|
||||||
}
|
}
|
||||||
|
if (name.trim().length < 2) {
|
||||||
|
return res.status(400).json({ error: 'Room name must be at least 2 characters' });
|
||||||
|
}
|
||||||
|
|
||||||
// M7: field length limits
|
// M7: field length limits
|
||||||
if (name.trim().length > 100) {
|
if (name.trim().length > 100) {
|
||||||
@@ -192,8 +228,8 @@ router.post('/', authenticateToken, async (req, res) => {
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
const result = await db.run(`
|
const result = await db.run(`
|
||||||
INSERT INTO rooms (uid, name, user_id, welcome_message, max_participants, access_code, mute_on_join, require_approval, anyone_can_start, all_join_moderator, record_meeting, guest_access, moderator_code)
|
INSERT INTO rooms (uid, name, user_id, welcome_message, max_participants, access_code, mute_on_join, require_approval, anyone_can_start, all_join_moderator, record_meeting, guest_access, moderator_code, recording_transcript)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`, [
|
`, [
|
||||||
uid,
|
uid,
|
||||||
name.trim(),
|
name.trim(),
|
||||||
@@ -208,6 +244,7 @@ router.post('/', authenticateToken, async (req, res) => {
|
|||||||
record_meeting !== false ? 1 : 0,
|
record_meeting !== false ? 1 : 0,
|
||||||
guest_access ? 1 : 0,
|
guest_access ? 1 : 0,
|
||||||
moderator_code || null,
|
moderator_code || null,
|
||||||
|
recording_transcript ? 1 : 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const room = await db.get('SELECT * FROM rooms WHERE id = ?', [result.lastInsertRowid]);
|
const room = await db.get('SELECT * FROM rooms WHERE id = ?', [result.lastInsertRowid]);
|
||||||
@@ -240,9 +277,15 @@ router.put('/:uid', authenticateToken, async (req, res) => {
|
|||||||
record_meeting,
|
record_meeting,
|
||||||
guest_access,
|
guest_access,
|
||||||
moderator_code,
|
moderator_code,
|
||||||
|
learning_analytics,
|
||||||
|
analytics_visibility,
|
||||||
|
recording_transcript,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// M12: field length limits (same as create)
|
// M12: field length limits (same as create)
|
||||||
|
if (name && name.trim().length < 2) {
|
||||||
|
return res.status(400).json({ error: 'Room name must be at least 2 characters' });
|
||||||
|
}
|
||||||
if (name && name.trim().length > 100) {
|
if (name && name.trim().length > 100) {
|
||||||
return res.status(400).json({ error: 'Room name must not exceed 100 characters' });
|
return res.status(400).json({ error: 'Room name must not exceed 100 characters' });
|
||||||
}
|
}
|
||||||
@@ -276,6 +319,9 @@ router.put('/:uid', authenticateToken, async (req, res) => {
|
|||||||
record_meeting = COALESCE(?, record_meeting),
|
record_meeting = COALESCE(?, record_meeting),
|
||||||
guest_access = COALESCE(?, guest_access),
|
guest_access = COALESCE(?, guest_access),
|
||||||
moderator_code = ?,
|
moderator_code = ?,
|
||||||
|
learning_analytics = COALESCE(?, learning_analytics),
|
||||||
|
analytics_visibility = COALESCE(?, analytics_visibility),
|
||||||
|
recording_transcript = COALESCE(?, recording_transcript),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE uid = ?
|
WHERE uid = ?
|
||||||
`, [
|
`, [
|
||||||
@@ -290,6 +336,9 @@ router.put('/:uid', authenticateToken, async (req, res) => {
|
|||||||
record_meeting !== undefined ? (record_meeting ? 1 : 0) : null,
|
record_meeting !== undefined ? (record_meeting ? 1 : 0) : null,
|
||||||
guest_access !== undefined ? (guest_access ? 1 : 0) : null,
|
guest_access !== undefined ? (guest_access ? 1 : 0) : null,
|
||||||
moderator_code !== undefined ? (moderator_code || null) : room.moderator_code,
|
moderator_code !== undefined ? (moderator_code || null) : room.moderator_code,
|
||||||
|
learning_analytics !== undefined ? (learning_analytics ? 1 : 0) : null,
|
||||||
|
analytics_visibility && ['owner', 'shared'].includes(analytics_visibility) ? analytics_visibility : null,
|
||||||
|
recording_transcript !== undefined ? (recording_transcript ? 1 : 0) : null,
|
||||||
req.params.uid,
|
req.params.uid,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -391,6 +440,10 @@ router.post('/:uid/shares', authenticateToken, async (req, res) => {
|
|||||||
if (user_id === req.user.id) {
|
if (user_id === req.user.id) {
|
||||||
return res.status(400).json({ error: 'You cannot share the room with yourself' });
|
return res.status(400).json({ error: 'You cannot share the room with yourself' });
|
||||||
}
|
}
|
||||||
|
const targetUser = await db.get('SELECT id FROM users WHERE id = ?', [user_id]);
|
||||||
|
if (!targetUser) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
// Check if already shared
|
// Check if already shared
|
||||||
const existing = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, user_id]);
|
const existing = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, user_id]);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -460,24 +513,32 @@ router.post('/:uid/start', authenticateToken, async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Room not found' });
|
return res.status(404).json({ error: 'Room not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access: owner or shared
|
// Check access: owner, admin, shared, or anyone_can_start
|
||||||
const isOwner = room.user_id === req.user.id;
|
const isOwner = room.user_id === req.user.id;
|
||||||
if (!isOwner) {
|
const isAdmin = req.user.role === 'admin';
|
||||||
|
if (!isOwner && !isAdmin && !room.anyone_can_start) {
|
||||||
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||||
if (!share) {
|
if (!share) {
|
||||||
return res.status(403).json({ error: 'No permission' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
const baseUrl = getBaseUrl(req);
|
||||||
const loginURL = `${baseUrl}/join/${room.uid}`;
|
const loginURL = `${baseUrl}/join/${room.uid}`;
|
||||||
const presentationUrl = room.presentation_file
|
let presentationUrl = null;
|
||||||
? `${baseUrl}/uploads/presentations/${room.presentation_file}`
|
if (room.presentation_file) {
|
||||||
|
const { token, expires } = signPresentationUrl(room.uid, room.presentation_file);
|
||||||
|
presentationUrl = `${baseUrl}/api/rooms/presentations/${token}/${expires}/${room.uid}/${encodeURIComponent(room.presentation_file)}`;
|
||||||
|
}
|
||||||
|
const analyticsCallbackURL = room.learning_analytics
|
||||||
|
? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}`
|
||||||
: null;
|
: null;
|
||||||
await createMeeting(room, baseUrl, loginURL, presentationUrl);
|
// Localise BBB's default welcome message in the room owner's language
|
||||||
|
const owner = await db.get('SELECT language FROM users WHERE id = ?', [room.user_id]);
|
||||||
|
await createMeeting(room, baseUrl, loginURL, presentationUrl, analyticsCallbackURL, owner?.language || 'en');
|
||||||
const avatarURL = getUserAvatarURL(req, req.user);
|
const avatarURL = getUserAvatarURL(req, req.user);
|
||||||
const displayName = req.user.display_name || req.user.name;
|
const displayName = req.user.display_name || req.user.name;
|
||||||
const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL);
|
const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL, !!req.body.dark_mode);
|
||||||
res.json({ joinUrl });
|
res.json({ joinUrl });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.rooms.error(`Start meeting error: ${err.message}`);
|
log.rooms.error(`Start meeting error: ${err.message}`);
|
||||||
@@ -511,7 +572,7 @@ router.post('/:uid/join', authenticateToken, async (req, res) => {
|
|||||||
const isShared = !isOwner && await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
const isShared = !isOwner && await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||||
const isModerator = isOwner || !!isShared || room.all_join_moderator;
|
const isModerator = isOwner || !!isShared || room.all_join_moderator;
|
||||||
const avatarURL = getUserAvatarURL(req, req.user);
|
const avatarURL = getUserAvatarURL(req, req.user);
|
||||||
const joinUrl = await joinMeeting(room.uid, req.user.display_name || req.user.name, isModerator, avatarURL);
|
const joinUrl = await joinMeeting(room.uid, req.user.display_name || req.user.name, isModerator, avatarURL, !!req.body.dark_mode);
|
||||||
res.json({ joinUrl });
|
res.json({ joinUrl });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.rooms.error(`Join meeting error: ${err.message}`);
|
log.rooms.error(`Join meeting error: ${err.message}`);
|
||||||
@@ -529,9 +590,10 @@ router.post('/:uid/end', authenticateToken, async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Room not found' });
|
return res.status(404).json({ error: 'Room not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access: owner or shared user
|
// Check access: owner, admin, or shared user
|
||||||
const isOwner = room.user_id === req.user.id;
|
const isOwner = room.user_id === req.user.id;
|
||||||
if (!isOwner) {
|
const isAdmin = req.user.role === 'admin';
|
||||||
|
if (!isOwner && !isAdmin) {
|
||||||
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||||
if (!share) {
|
if (!share) {
|
||||||
return res.status(403).json({ error: 'No permission' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
@@ -586,7 +648,7 @@ router.get('/:uid/public', async (req, res) => {
|
|||||||
// POST /api/rooms/:uid/guest-join - Join meeting as guest (no auth needed)
|
// POST /api/rooms/:uid/guest-join - Join meeting as guest (no auth needed)
|
||||||
router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
|
router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, access_code, moderator_code } = req.body;
|
const { name, access_code, moderator_code, avatar_image, avatar_color, dark_mode } = req.body;
|
||||||
|
|
||||||
if (!name || name.trim().length === 0) {
|
if (!name || name.trim().length === 0) {
|
||||||
return res.status(400).json({ error: 'Name is required' });
|
return res.status(400).json({ error: 'Name is required' });
|
||||||
@@ -617,9 +679,14 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
|
|||||||
|
|
||||||
// If meeting not running but anyone_can_start, create it
|
// If meeting not running but anyone_can_start, create it
|
||||||
if (!running && room.anyone_can_start) {
|
if (!running && room.anyone_can_start) {
|
||||||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
const baseUrl = getBaseUrl(req);
|
||||||
const loginURL = `${baseUrl}/join/${room.uid}`;
|
const loginURL = `${baseUrl}/join/${room.uid}`;
|
||||||
await createMeeting(room, baseUrl, loginURL);
|
const analyticsCallbackURL = room.learning_analytics
|
||||||
|
? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}`
|
||||||
|
: null;
|
||||||
|
// Localise BBB's default welcome message in the room owner's language
|
||||||
|
const owner = await db.get('SELECT language FROM users WHERE id = ?', [room.user_id]);
|
||||||
|
await createMeeting(room, baseUrl, loginURL, null, analyticsCallbackURL, owner?.language || 'en');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check moderator code
|
// Check moderator code
|
||||||
@@ -628,9 +695,22 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
|
|||||||
isModerator = true;
|
isModerator = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
const baseUrl = getBaseUrl(req);
|
||||||
const guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
|
// Validate client-supplied avatar fields before embedding them in a URL
|
||||||
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL);
|
// that BBB will fetch — guest-join is unauthenticated.
|
||||||
|
const safeAvatarImage = (typeof avatar_image === 'string' && SAFE_AVATAR_FILENAME_RE.test(avatar_image))
|
||||||
|
? avatar_image : null;
|
||||||
|
const safeAvatarColor = (typeof avatar_color === 'string' && SAFE_AVATAR_COLOR_RE.test(avatar_color))
|
||||||
|
? avatar_color : null;
|
||||||
|
let guestAvatarURL;
|
||||||
|
if (safeAvatarImage) {
|
||||||
|
guestAvatarURL = `${baseUrl}/api/auth/avatar/${encodeURIComponent(safeAvatarImage)}`;
|
||||||
|
} else if (safeAvatarColor) {
|
||||||
|
guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}?color=${encodeURIComponent(safeAvatarColor)}`;
|
||||||
|
} else {
|
||||||
|
guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
|
||||||
|
}
|
||||||
|
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL, !!dark_mode);
|
||||||
res.json({ joinUrl });
|
res.json({ joinUrl });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.rooms.error(`Guest join error: ${err.message}`);
|
log.rooms.error(`Guest join error: ${err.message}`);
|
||||||
@@ -660,6 +740,46 @@ router.get('/:uid/status', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/rooms/presentations/:token/:expires/:roomUid/:filename - Serve presentation file (token-protected for BBB)
|
||||||
|
// Token and expires are path segments so the URL ends with the original filename,
|
||||||
|
// allowing BBB to detect the file type from the extension.
|
||||||
|
router.get('/presentations/:token/:expires/:roomUid/:filename', (req, res) => {
|
||||||
|
const { token, expires, roomUid, filename } = req.params;
|
||||||
|
|
||||||
|
if (!token || !expires) {
|
||||||
|
return res.status(401).json({ error: 'Missing token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresNum = Number(expires);
|
||||||
|
if (isNaN(expiresNum) || Date.now() > expiresNum) {
|
||||||
|
return res.status(403).json({ error: 'Token expired' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = crypto.createHmac('sha256', PRESENTATION_TOKEN_SECRET)
|
||||||
|
.update(`${roomUid}/${filename}:${expires}`)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
// timingSafeEqual throws on length mismatch, so compare lengths first
|
||||||
|
const tokenBuf = Buffer.from(token);
|
||||||
|
const expectedBuf = Buffer.from(expected);
|
||||||
|
if (tokenBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(tokenBuf, expectedBuf)) {
|
||||||
|
return res.status(403).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// S8: prevent path traversal
|
||||||
|
const roomDir = path.resolve(presentationsDir, roomUid);
|
||||||
|
const filepath = path.resolve(roomDir, filename);
|
||||||
|
if (!filepath.startsWith(presentationsDir + path.sep) || !filepath.startsWith(roomDir + path.sep)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid filename' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(filepath)) {
|
||||||
|
return res.status(404).json({ error: 'File not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.sendFile(filepath);
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/rooms/:uid/presentation - Upload a presentation file for the room
|
// POST /api/rooms/:uid/presentation - Upload a presentation file for the room
|
||||||
router.post('/:uid/presentation', authenticateToken, async (req, res) => {
|
router.post('/:uid/presentation', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -703,24 +823,39 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => {
|
|||||||
const ext = extMap[contentType];
|
const ext = extMap[contentType];
|
||||||
if (!ext) return res.status(400).json({ error: 'Unsupported file type. Allowed: PDF, PPT, PPTX, ODP, DOC, DOCX' });
|
if (!ext) return res.status(400).json({ error: 'Unsupported file type. Allowed: PDF, PPT, PPTX, ODP, DOC, DOCX' });
|
||||||
|
|
||||||
|
// Validate magic bytes to prevent Content-Type spoofing
|
||||||
|
const magic = buffer.slice(0, 8);
|
||||||
|
const isPDF = magic[0] === 0x25 && magic[1] === 0x50 && magic[2] === 0x44 && magic[3] === 0x46; // %PDF
|
||||||
|
const isZip = magic[0] === 0x50 && magic[1] === 0x4B && magic[2] === 0x03 && magic[3] === 0x04; // PK (PPTX, DOCX, ODP, etc.)
|
||||||
|
const isOle = magic[0] === 0xD0 && magic[1] === 0xCF && magic[2] === 0x11 && magic[3] === 0xE0; // OLE2 (PPT, DOC)
|
||||||
|
if (ext === 'pdf' && !isPDF) return res.status(400).json({ error: 'File content does not match PDF format' });
|
||||||
|
if (['pptx', 'docx', 'odp'].includes(ext) && !isZip) return res.status(400).json({ error: 'File content does not match expected archive format' });
|
||||||
|
if (['ppt', 'doc'].includes(ext) && !isOle) return res.status(400).json({ error: 'File content does not match expected document format' });
|
||||||
|
|
||||||
// Preserve original filename (sent as X-Filename header)
|
// Preserve original filename (sent as X-Filename header)
|
||||||
const rawName = req.headers['x-filename'];
|
const rawName = req.headers['x-filename'];
|
||||||
const originalName = rawName
|
const filename = rawName
|
||||||
? decodeURIComponent(rawName).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, 200)
|
? decodeURIComponent(rawName).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, 200)
|
||||||
: `presentation.${ext}`;
|
: `presentation.${ext}`;
|
||||||
|
|
||||||
const filename = `${room.uid}_${Date.now()}.${ext}`;
|
// Each room gets its own folder: uploads/presentations/{roomUID}/
|
||||||
const filepath = path.join(presentationsDir, filename);
|
const roomDir = path.join(presentationsDir, room.uid);
|
||||||
|
if (!fs.existsSync(roomDir)) fs.mkdirSync(roomDir, { recursive: true });
|
||||||
|
const filepath = path.join(roomDir, filename);
|
||||||
|
|
||||||
|
// S8: defense-in-depth path traversal check
|
||||||
|
if (!path.resolve(filepath).startsWith(roomDir + path.sep)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid filename' });
|
||||||
|
}
|
||||||
|
|
||||||
// Remove old presentation file if exists
|
// Remove old presentation file if exists
|
||||||
if (room.presentation_file) {
|
if (room.presentation_file) {
|
||||||
// S8: defense-in-depth path traversal check
|
const oldPath = path.resolve(roomDir, room.presentation_file);
|
||||||
const oldPath = path.resolve(presentationsDir, room.presentation_file);
|
if (oldPath.startsWith(roomDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||||
if (oldPath.startsWith(presentationsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(filepath, buffer);
|
fs.writeFileSync(filepath, buffer);
|
||||||
await db.run('UPDATE rooms SET presentation_file = ?, presentation_name = ?, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [filename, originalName, req.params.uid]);
|
await db.run('UPDATE rooms SET presentation_file = ?, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [filename, req.params.uid]);
|
||||||
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||||
res.json({ room: updated });
|
res.json({ room: updated });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -738,11 +873,14 @@ router.delete('/:uid/presentation', authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
if (room.presentation_file) {
|
if (room.presentation_file) {
|
||||||
// S8: defense-in-depth path traversal check
|
// S8: defense-in-depth path traversal check
|
||||||
const filepath = path.resolve(presentationsDir, room.presentation_file);
|
const roomDir = path.join(presentationsDir, room.uid);
|
||||||
if (filepath.startsWith(presentationsDir + path.sep) && fs.existsSync(filepath)) fs.unlinkSync(filepath);
|
const filepath = path.resolve(roomDir, room.presentation_file);
|
||||||
|
if (filepath.startsWith(roomDir + path.sep) && fs.existsSync(filepath)) fs.unlinkSync(filepath);
|
||||||
|
// Remove empty room folder
|
||||||
|
if (fs.existsSync(roomDir) && fs.readdirSync(roomDir).length === 0) fs.rmdirSync(roomDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.run('UPDATE rooms SET presentation_file = NULL, presentation_name = NULL, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [req.params.uid]);
|
await db.run('UPDATE rooms SET presentation_file = NULL, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [req.params.uid]);
|
||||||
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||||
res.json({ room: updated });
|
res.json({ room: updated });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -751,4 +889,76 @@ router.delete('/:uid/presentation', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── POST /api/rooms/invite-email — Send email invitation to guest(s) ────────
|
||||||
|
router.post('/invite-email', authenticateToken, inviteEmailLimiter, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { room_uid, emails, message } = req.body;
|
||||||
|
if (!room_uid || !emails || !emails.length) {
|
||||||
|
return res.status(400).json({ error: 'room_uid and emails are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emails.length > 50) {
|
||||||
|
return res.status(400).json({ error: 'Maximum 50 email addresses allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all emails
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
for (const email of emails) {
|
||||||
|
if (!emailRegex.test(email) || email.length > 254) {
|
||||||
|
return res.status(400).json({ error: `Invalid email address: ${email}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message && message.length > 2000) {
|
||||||
|
return res.status(400).json({ error: 'Message must not exceed 2000 characters' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Verify room exists and user has access
|
||||||
|
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [room_uid]);
|
||||||
|
if (!room) {
|
||||||
|
return res.status(404).json({ error: 'Room not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = room.user_id === req.user.id;
|
||||||
|
if (!isOwner) {
|
||||||
|
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 invite from this room' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build guest join URL
|
||||||
|
const baseUrl = getBaseUrl(req);
|
||||||
|
const joinUrl = room.access_code
|
||||||
|
? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}`
|
||||||
|
: `${baseUrl}/join/${room.uid}`;
|
||||||
|
|
||||||
|
const appName = await getAppName();
|
||||||
|
const fromUser = req.user.display_name || req.user.name;
|
||||||
|
const lang = req.user.language || 'en';
|
||||||
|
|
||||||
|
// Send emails (in parallel but collect errors)
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
emails.map(email =>
|
||||||
|
sendGuestInviteEmail(email, fromUser, room.name, message || null, joinUrl, appName, lang)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const failed = results.filter(r => r.status === 'rejected');
|
||||||
|
if (failed.length === emails.length) {
|
||||||
|
return res.status(500).json({ error: 'Failed to send all email invitations' });
|
||||||
|
}
|
||||||
|
if (failed.length > 0) {
|
||||||
|
log.rooms.warn(`${failed.length}/${emails.length} email invitations failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, sent: emails.length - failed.length, failed: failed.length });
|
||||||
|
} catch (err) {
|
||||||
|
log.rooms.error('Email invite error:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to send email invitations' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
+9
-2
@@ -9,6 +9,8 @@ import Home from './pages/Home';
|
|||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
import VerifyEmail from './pages/VerifyEmail';
|
import VerifyEmail from './pages/VerifyEmail';
|
||||||
|
import ForgotPassword from './pages/ForgotPassword';
|
||||||
|
import ResetPassword from './pages/ResetPassword';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import RoomDetail from './pages/RoomDetail';
|
import RoomDetail from './pages/RoomDetail';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
@@ -17,6 +19,8 @@ import GuestJoin from './pages/GuestJoin';
|
|||||||
import FederationInbox from './pages/FederationInbox';
|
import FederationInbox from './pages/FederationInbox';
|
||||||
import FederatedRoomDetail from './pages/FederatedRoomDetail';
|
import FederatedRoomDetail from './pages/FederatedRoomDetail';
|
||||||
import Calendar from './pages/Calendar';
|
import Calendar from './pages/Calendar';
|
||||||
|
import OAuthCallback from './pages/OAuthCallback';
|
||||||
|
import NotFound from './pages/NotFound';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -50,6 +54,9 @@ export default function App() {
|
|||||||
<Route path="/login" element={user ? <Navigate to="/dashboard" /> : <Login />} />
|
<Route path="/login" element={user ? <Navigate to="/dashboard" /> : <Login />} />
|
||||||
<Route path="/register" element={user ? <Navigate to="/dashboard" /> : <Register />} />
|
<Route path="/register" element={user ? <Navigate to="/dashboard" /> : <Register />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
|
<Route path="/forgot-password" element={user ? <Navigate to="/dashboard" /> : <ForgotPassword />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
|
<Route path="/oauth/callback" element={<OAuthCallback />} />
|
||||||
<Route path="/join/:uid" element={<GuestJoin />} />
|
<Route path="/join/:uid" element={<GuestJoin />} />
|
||||||
|
|
||||||
{/* Protected routes */}
|
{/* Protected routes */}
|
||||||
@@ -63,8 +70,8 @@ export default function App() {
|
|||||||
<Route path="/federation/rooms/:id" element={<FederatedRoomDetail />} />
|
<Route path="/federation/rooms/:id" element={<FederatedRoomDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Catch all */}
|
{/* 404 */}
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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';
|
import { useBranding } from '../contexts/BrandingContext';
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
sm: { box: 'w-8 h-8', rounded: 'rounded-lg', icon: 16, text: 'text-lg' },
|
sm: { box: 'w-8 h-8', h: 'h-8', maxW: 'max-w-32', rounded: 'rounded-lg', icon: 16, text: 'text-lg' },
|
||||||
md: { box: 'w-9 h-9', rounded: 'rounded-lg', icon: 20, text: 'text-xl' },
|
md: { box: 'w-9 h-9', h: 'h-12', maxW: 'max-w-40', rounded: 'rounded-lg', icon: 20, text: 'text-xl' },
|
||||||
lg: { box: 'w-10 h-10', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' },
|
lg: { box: 'w-10 h-10', h: 'h-10', maxW: 'max-w-48', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BrandLogo({ size = 'md', className = '' }) {
|
export default function BrandLogo({ size = 'md', className = '' }) {
|
||||||
const { appName, hasLogo, logoUrl } = useBranding();
|
const { appName, hasLogo, logoUrl, hideAppName } = useBranding();
|
||||||
const s = sizes[size] || sizes.md;
|
const s = sizes[size] || sizes.md;
|
||||||
|
|
||||||
if (hasLogo && logoUrl) {
|
if (hasLogo && logoUrl) {
|
||||||
|
// When the app name is hidden, let the logo expand to its natural aspect
|
||||||
|
// ratio (w-auto) rather than being clamped into a tiny square.
|
||||||
|
const imgClass = hideAppName
|
||||||
|
? `${s.h} w-auto ${s.maxW} ${s.rounded} object-contain`
|
||||||
|
: `${s.box} ${s.rounded} object-contain`;
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center gap-2.5 ${className}`}>
|
<div className={`flex items-center ${hideAppName ? 'justify-center' : 'gap-2.5'} ${className}`}>
|
||||||
<img
|
<img src={logoUrl} alt={appName} className={imgClass} />
|
||||||
src={logoUrl}
|
{!hideAppName && <span className={`${s.text} font-bold gradient-text`}>{appName}</span>}
|
||||||
alt={appName}
|
|
||||||
className={`${s.box} ${s.rounded} object-contain`}
|
|
||||||
/>
|
|
||||||
<span className={`${s.text} font-bold gradient-text`}>{appName}</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,22 +30,35 @@ export default function FederatedRoomCard({ room, onRemove }) {
|
|||||||
|
|
||||||
const recordingOn = room.allow_recording === 1 || room.allow_recording === true;
|
const recordingOn = room.allow_recording === 1 || room.allow_recording === true;
|
||||||
|
|
||||||
|
const openRoom = () => navigate(`/federation/rooms/${room.id}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`card-hover group p-5 cursor-pointer ${isDeleted ? 'opacity-60' : ''}`} onClick={() => navigate(`/federation/rooms/${room.id}`)}>
|
<div
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={room.room_name}
|
||||||
|
onClick={openRoom}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.target !== e.currentTarget) return;
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openRoom(); }
|
||||||
|
}}
|
||||||
|
className={`card-hover group p-5 cursor-pointer focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2 ${isDeleted ? 'opacity-60' : ''}`}
|
||||||
|
style={{ '--tw-ring-offset-color': 'var(--bg-primary)' }}
|
||||||
|
>
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Globe size={14} className="text-th-accent flex-shrink-0" />
|
<Globe size={14} className="text-th-accent shrink-0" />
|
||||||
<h3 className="text-base font-semibold text-th-text truncate group-hover:text-th-accent transition-colors">
|
<h3 className="text-base font-semibold text-th-text truncate group-hover:text-th-accent transition-colors">
|
||||||
{room.room_name}
|
{room.room_name}
|
||||||
</h3>
|
</h3>
|
||||||
{isDeleted ? (
|
{isDeleted ? (
|
||||||
<span className="flex-shrink-0 px-2 py-0.5 bg-red-500/15 text-red-500 rounded-full text-xs font-medium flex items-center gap-1">
|
<span className="shrink-0 px-2 py-0.5 bg-red-500/15 text-red-500 rounded-full text-xs font-medium flex items-center gap-1">
|
||||||
<AlertTriangle size={10} />
|
<AlertTriangle size={10} />
|
||||||
{t('federation.roomDeleted')}
|
{t('federation.roomDeleted')}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex-shrink-0 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
|
<span className="shrink-0 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
|
||||||
{t('federation.federated')}
|
{t('federation.federated')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -60,12 +73,12 @@ export default function FederatedRoomCard({ room, onRemove }) {
|
|||||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||||
{room.meet_id && (
|
{room.meet_id && (
|
||||||
<div className="flex items-center gap-1.5 text-xs text-th-text-s">
|
<div className="flex items-center gap-1.5 text-xs text-th-text-s">
|
||||||
<Hash size={12} className="text-th-accent flex-shrink-0" />
|
<Hash size={12} className="text-th-accent shrink-0" />
|
||||||
<span className="truncate font-mono" title={room.meet_id}>{room.meet_id.slice(0, 10)}…</span>
|
<span className="truncate font-mono" title={room.meet_id}>{room.meet_id.slice(0, 10)}…</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1.5 text-xs text-th-text-s">
|
<div className="flex items-center gap-1.5 text-xs text-th-text-s">
|
||||||
<Users size={12} className="text-th-accent flex-shrink-0" />
|
<Users size={12} className="text-th-accent shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{t('federation.maxParticipants')}:{' '}
|
{t('federation.maxParticipants')}:{' '}
|
||||||
<span className="text-th-text font-medium">
|
<span className="text-th-text font-medium">
|
||||||
@@ -76,12 +89,12 @@ export default function FederatedRoomCard({ room, onRemove }) {
|
|||||||
<div className="flex items-center gap-1.5 text-xs col-span-2">
|
<div className="flex items-center gap-1.5 text-xs col-span-2">
|
||||||
{recordingOn ? (
|
{recordingOn ? (
|
||||||
<>
|
<>
|
||||||
<Video size={12} className="text-amber-500 flex-shrink-0" />
|
<Video size={12} className="text-amber-500 shrink-0" />
|
||||||
<span className="text-amber-500 font-medium">{t('federation.recordingOn')}</span>
|
<span className="text-amber-500 font-medium">{t('federation.recordingOn')}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<VideoOff size={12} className="text-th-text-s flex-shrink-0" />
|
<VideoOff size={12} className="text-th-text-s shrink-0" />
|
||||||
<span className="text-th-text-s">{t('federation.recordingOff')}</span>
|
<span className="text-th-text-s">{t('federation.recordingOff')}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -107,6 +120,7 @@ export default function FederatedRoomCard({ room, onRemove }) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleRemove}
|
onClick={handleRemove}
|
||||||
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"
|
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"
|
||||||
|
aria-label={t('federation.removeRoom')}
|
||||||
title={t('federation.removeRoom')}
|
title={t('federation.removeRoom')}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function Layout() {
|
|||||||
{/* Email verification banner */}
|
{/* Email verification banner */}
|
||||||
{user && user.email_verified === 0 && (
|
{user && user.email_verified === 0 && (
|
||||||
<div className="bg-amber-500/15 border-b border-amber-500/30 px-4 py-2.5 flex items-center justify-center gap-3 text-sm">
|
<div className="bg-amber-500/15 border-b border-amber-500/30 px-4 py-2.5 flex items-center justify-center gap-3 text-sm">
|
||||||
<AlertTriangle size={15} className="text-amber-400 flex-shrink-0" />
|
<AlertTriangle size={15} className="text-amber-400 shrink-0" />
|
||||||
<span className="text-amber-200">{t('auth.emailVerificationBanner')}</span>
|
<span className="text-amber-200">{t('auth.emailVerificationBanner')}</span>
|
||||||
<button
|
<button
|
||||||
onClick={handleResendVerification}
|
onClick={handleResendVerification}
|
||||||
|
|||||||
@@ -1,15 +1,56 @@
|
|||||||
|
import { useEffect, useId, useRef } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
export default function Modal({ title, children, onClose, maxWidth = 'max-w-lg' }) {
|
export default function Modal({ title, children, onClose, maxWidth = 'max-w-lg' }) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const titleId = useId();
|
||||||
|
const dialogRef = useRef(null);
|
||||||
|
const previouslyFocused = useRef(null);
|
||||||
|
|
||||||
|
// Keep the latest onClose in a ref so the mount-only effect below can call it
|
||||||
|
// without listing onClose as a dependency. Callers pass a fresh inline arrow
|
||||||
|
// each render; depending on it would re-run the effect on every keystroke and
|
||||||
|
// steal focus back to the dialog (kicking the user out of input fields).
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
previouslyFocused.current = document.activeElement;
|
||||||
|
const handleKey = (e) => {
|
||||||
|
if (e.key === 'Escape') onCloseRef.current?.();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKey);
|
||||||
|
// Focus the dialog so screen readers announce it and keyboard focus lands inside
|
||||||
|
dialogRef.current?.focus();
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKey);
|
||||||
|
previouslyFocused.current?.focus?.();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
<div
|
||||||
<div className={`relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full ${maxWidth} overflow-hidden`}>
|
className="fixed inset-0 bg-black/60 backdrop-blur-xs"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
tabIndex={-1}
|
||||||
|
className={`relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full ${maxWidth} focus:outline-hidden`}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-th-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-th-border rounded-t-2xl">
|
||||||
<h2 className="text-lg font-semibold text-th-text">{title}</h2>
|
<h2 id={titleId} className="text-lg font-semibold text-th-text">{title}</h2>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
aria-label={t('common.close')}
|
||||||
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
|
|||||||
@@ -38,12 +38,13 @@ export default function Navbar({ onMenuClick }) {
|
|||||||
: '?';
|
: '?';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-20 bg-th-nav border-b border-th-border backdrop-blur-sm">
|
<header className="sticky top-0 z-20 bg-th-nav border-b border-th-border backdrop-blur-xs">
|
||||||
<div className="flex items-center justify-between h-16 px-4 md:px-6">
|
<div className="flex items-center justify-between h-16 px-4 md:px-6">
|
||||||
{/* Left section */}
|
{/* Left section */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
|
aria-label={t('nav.navigation')}
|
||||||
className="lg:hidden p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
className="lg:hidden p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||||
>
|
>
|
||||||
<Menu size={20} />
|
<Menu size={20} />
|
||||||
@@ -59,6 +60,9 @@ export default function Navbar({ onMenuClick }) {
|
|||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
|
aria-label={user?.display_name || user?.name}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={dropdownOpen}
|
||||||
className="flex items-center gap-2 p-1.5 rounded-lg hover:bg-th-hover transition-colors"
|
className="flex items-center gap-2 p-1.5 rounded-lg hover:bg-th-hover transition-colors"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -68,7 +72,7 @@ export default function Navbar({ onMenuClick }) {
|
|||||||
{user?.avatar_image ? (
|
{user?.avatar_image ? (
|
||||||
<img
|
<img
|
||||||
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
|
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
|
||||||
alt="Avatar"
|
alt=""
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ export default function NotificationBell() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setOpen(prev => !prev)}
|
onClick={() => setOpen(prev => !prev)}
|
||||||
className="relative p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
className="relative p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||||
|
aria-label={unreadCount > 0
|
||||||
|
? `${t('notifications.bell')} (${unreadCount})`
|
||||||
|
: t('notifications.bell')}
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={open}
|
||||||
title={t('notifications.bell')}
|
title={t('notifications.bell')}
|
||||||
>
|
>
|
||||||
<Bell size={20} />
|
<Bell size={20} />
|
||||||
@@ -148,7 +153,7 @@ export default function NotificationBell() {
|
|||||||
${n.read ? 'hover:bg-th-hover' : 'bg-th-accent/5 hover:bg-th-accent/10'}`}
|
${n.read ? 'hover:bg-th-hover' : 'bg-th-accent/5 hover:bg-th-accent/10'}`}
|
||||||
>
|
>
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<span className="text-lg flex-shrink-0 mt-0.5">{notificationIcon(n.type)}</span>
|
<span className="text-lg shrink-0 mt-0.5">{notificationIcon(n.type)}</span>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -164,7 +169,7 @@ export default function NotificationBell() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side: unread dot, link icon, delete button */}
|
{/* Right side: unread dot, link icon, delete button */}
|
||||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||||
{!n.read && (
|
{!n.read && (
|
||||||
<span className="w-2 h-2 rounded-full bg-th-accent mt-1" />
|
<span className="w-2 h-2 rounded-full bg-th-accent mt-1" />
|
||||||
)}
|
)}
|
||||||
@@ -173,7 +178,8 @@ export default function NotificationBell() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleDelete(e, n.id)}
|
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"
|
className="opacity-0 group-hover:opacity-100 p-0.5 rounded-sm hover:text-th-error transition-all text-th-text-s/50"
|
||||||
|
aria-label={t('notifications.delete')}
|
||||||
title={t('notifications.delete')}
|
title={t('notifications.delete')}
|
||||||
>
|
>
|
||||||
<X size={13} />
|
<X size={13} />
|
||||||
|
|||||||
@@ -122,9 +122,9 @@ export default function RecordingList({ recordings, onRefresh }) {
|
|||||||
href={format.url}
|
href={format.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-th-accent/10 text-th-accent text-xs font-medium hover:bg-th-accent/20 transition-colors"
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-th-accent/10 text-th-accent text-sm font-medium hover:bg-th-accent/20 transition-colors"
|
||||||
>
|
>
|
||||||
<Play size={12} />
|
<Play size={14} />
|
||||||
{format.type === 'presentation' ? t('recordings.presentation') : format.type}
|
{format.type === 'presentation' ? t('recordings.presentation') : format.type}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
@@ -133,7 +133,7 @@ export default function RecordingList({ recordings, onRefresh }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePublish(rec.recordID, !rec.published)}
|
onClick={() => handlePublish(rec.recordID, !rec.published)}
|
||||||
disabled={loading[rec.recordID] === 'publishing'}
|
disabled={loading[rec.recordID] === 'publishing'}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Users, Play, Trash2, Radio, Loader2, Share2 } from 'lucide-react';
|
import { Users, Play, Trash2, Radio, Loader2, Share2, Copy, Link } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
import { isCurrentThemeDark } from '../themes';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
@@ -10,6 +11,24 @@ export default function RoomCard({ room, onDelete }) {
|
|||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [status, setStatus] = useState({ running: false, participantCount: 0 });
|
const [status, setStatus] = useState({ running: false, participantCount: 0 });
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
|
const [showCopyMenu, setShowCopyMenu] = useState(false);
|
||||||
|
const copyMenuRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (copyMenuRef.current && !copyMenuRef.current.contains(e.target)) {
|
||||||
|
setShowCopyMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const copyToClipboard = (url) => {
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
toast.success(t('room.linkCopied'));
|
||||||
|
setShowCopyMenu(false);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkStatus = async () => {
|
const checkStatus = async () => {
|
||||||
@@ -25,8 +44,21 @@ export default function RoomCard({ room, onDelete }) {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [room.uid]);
|
}, [room.uid]);
|
||||||
|
|
||||||
|
const openRoom = () => navigate(`/rooms/${room.uid}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card-hover group p-5 cursor-pointer" onClick={() => navigate(`/rooms/${room.uid}`)}>
|
<div
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={room.name}
|
||||||
|
onClick={openRoom}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.target !== e.currentTarget) return;
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openRoom(); }
|
||||||
|
}}
|
||||||
|
className="card-hover group p-5 cursor-pointer focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2"
|
||||||
|
style={{ '--tw-ring-offset-color': 'var(--bg-primary)' }}
|
||||||
|
>
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -62,25 +94,26 @@ export default function RoomCard({ room, onDelete }) {
|
|||||||
<span>Max: {room.max_participants}</span>
|
<span>Max: {room.max_participants}</span>
|
||||||
)}
|
)}
|
||||||
{room.access_code && (
|
{room.access_code && (
|
||||||
<span className="px-1.5 py-0.5 bg-th-warning/15 text-th-warning rounded text-[10px] font-medium">
|
<span className="px-1.5 py-0.5 bg-th-warning/15 text-th-warning rounded-sm text-[10px] font-medium">
|
||||||
{t('common.protected')}
|
{t('common.protected')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2 pt-3 border-t border-th-border">
|
<div className="flex items-center gap-2 pt-3 border-t border-th-border" onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setStarting(true);
|
setStarting(true);
|
||||||
try {
|
try {
|
||||||
if (status.running) {
|
if (status.running) {
|
||||||
const data = room.access_code ? { access_code: prompt(t('room.enterAccessCode')) } : {};
|
const data = { dark_mode: isCurrentThemeDark() };
|
||||||
|
if (room.access_code) data.access_code = prompt(t('room.enterAccessCode'));
|
||||||
const res = await api.post(`/rooms/${room.uid}/join`, data);
|
const res = await api.post(`/rooms/${room.uid}/join`, data);
|
||||||
if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank');
|
if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank');
|
||||||
} else {
|
} else {
|
||||||
const res = await api.post(`/rooms/${room.uid}/start`);
|
const res = await api.post(`/rooms/${room.uid}/start`, { dark_mode: isCurrentThemeDark() });
|
||||||
if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank');
|
if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank');
|
||||||
toast.success(t('room.meetingStarted'));
|
toast.success(t('room.meetingStarted'));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -99,10 +132,41 @@ export default function RoomCard({ room, onDelete }) {
|
|||||||
{starting ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
|
{starting ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
|
||||||
{status.running ? t('room.join') : t('room.startMeeting')}
|
{status.running ? t('room.join') : t('room.startMeeting')}
|
||||||
</button>
|
</button>
|
||||||
|
<div className="relative" ref={copyMenuRef}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowCopyMenu(v => !v); }}
|
||||||
|
className="btn-ghost text-xs py-1.5 px-2"
|
||||||
|
aria-label={t('room.copyLink')}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={showCopyMenu}
|
||||||
|
title={t('room.copyLink')}
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
</button>
|
||||||
|
{showCopyMenu && (
|
||||||
|
<div className="absolute bottom-full right-0 mb-2 bg-th-surface border border-th-border rounded-lg shadow-lg z-50 min-w-[150px] py-1">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); copyToClipboard(`${window.location.origin}/rooms/${room.uid}`); }}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Link size={12} />
|
||||||
|
{t('room.copyRoomLink')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); copyToClipboard(`${window.location.origin}/join/${room.uid}`); }}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Users size={12} />
|
||||||
|
{t('room.copyGuestLink')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{onDelete && !room.shared && (
|
{onDelete && !room.shared && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete(room); }}
|
onClick={(e) => { e.stopPropagation(); onDelete(room); }}
|
||||||
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"
|
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"
|
||||||
|
aria-label={t('common.delete')}
|
||||||
title={t('common.delete')}
|
title={t('common.delete')}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function Sidebar({ open, onClose }) {
|
|||||||
|
|
||||||
const linkClasses = ({ isActive }) =>
|
const linkClasses = ({ isActive }) =>
|
||||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive
|
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive
|
||||||
? 'bg-th-accent text-th-accent-t shadow-sm'
|
? 'bg-th-accent text-th-accent-t shadow-xs'
|
||||||
: 'text-th-text-s hover:text-th-text hover:bg-th-hover'
|
: 'text-th-text-s hover:text-th-text hover:bg-th-hover'
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
@@ -57,9 +57,10 @@ export default function Sidebar({ open, onClose }) {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center justify-between h-16 px-4 border-b border-th-border">
|
<div className="flex items-center justify-between h-16 px-4 border-b border-th-border">
|
||||||
<BrandLogo size="sm" />
|
<BrandLogo size="md" className="flex-1 min-w-0" />
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
aria-label={t('common.close')}
|
||||||
className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
@@ -106,17 +107,17 @@ export default function Sidebar({ open, onClose }) {
|
|||||||
<div className="p-4 border-t border-th-border">
|
<div className="p-4 border-t border-th-border">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0 overflow-hidden"
|
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold shrink-0 overflow-hidden"
|
||||||
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
|
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
|
||||||
>
|
>
|
||||||
{user?.avatar_image ? (
|
{user?.avatar_image ? (
|
||||||
<img
|
<img
|
||||||
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
|
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
|
||||||
alt="Avatar"
|
alt=""
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
(user?.display_name || user?.name)?.[0]?.toUpperCase() || '?'
|
(user?.display_name || user?.name)?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) || '?'
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -10,17 +10,23 @@ export default function ThemeSelector({ onClose }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-xs" onClick={onClose} aria-hidden="true" />
|
||||||
|
|
||||||
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="theme-selector-title"
|
||||||
|
className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden"
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-th-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-th-border">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-th-text">{t('themes.selectTheme')}</h2>
|
<h2 id="theme-selector-title" className="text-lg font-semibold text-th-text">{t('themes.selectTheme')}</h2>
|
||||||
<p className="text-sm text-th-text-s mt-0.5">{t('themes.selectThemeSubtitle')}</p>
|
<p className="text-sm text-th-text-s mt-0.5">{t('themes.selectThemeSubtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
aria-label={t('common.close')}
|
||||||
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ export function AuthProvider({ children }) {
|
|||||||
|
|
||||||
const login = useCallback(async (email, password) => {
|
const login = useCallback(async (email, password) => {
|
||||||
const res = await api.post('/auth/login', { email, password });
|
const res = await api.post('/auth/login', { email, password });
|
||||||
|
if (res.data.requires2FA) {
|
||||||
|
return { requires2FA: true, tempToken: res.data.tempToken };
|
||||||
|
}
|
||||||
|
localStorage.setItem('token', res.data.token);
|
||||||
|
setUser(res.data.user);
|
||||||
|
return res.data.user;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const verify2FA = useCallback(async (tempToken, code) => {
|
||||||
|
const res = await api.post('/auth/login/2fa', { tempToken, code });
|
||||||
localStorage.setItem('token', res.data.token);
|
localStorage.setItem('token', res.data.token);
|
||||||
setUser(res.data.user);
|
setUser(res.data.user);
|
||||||
return res.data.user;
|
return res.data.user;
|
||||||
@@ -41,21 +51,41 @@ export function AuthProvider({ children }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
|
let keycloakLogoutUrl = null;
|
||||||
try {
|
try {
|
||||||
await api.post('/auth/logout');
|
const res = await api.post('/auth/logout');
|
||||||
|
keycloakLogoutUrl = res.data?.keycloakLogoutUrl || null;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — token is removed locally regardless
|
// ignore — token is removed locally regardless
|
||||||
}
|
}
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
|
if (keycloakLogoutUrl) {
|
||||||
|
// Redirect to Keycloak BEFORE clearing React state to avoid
|
||||||
|
// flash-rendering the login page while the redirect is pending.
|
||||||
|
window.location.href = keycloakLogoutUrl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loginWithOAuth = useCallback(async (token) => {
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
try {
|
||||||
|
const res = await api.get('/auth/me');
|
||||||
|
setUser(res.data.user);
|
||||||
|
return res.data.user;
|
||||||
|
} catch (err) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const updateUser = useCallback((updatedUser) => {
|
const updateUser = useCallback((updatedUser) => {
|
||||||
setUser(updatedUser);
|
setUser(updatedUser);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, register, logout, updateUser }}>
|
<AuthContext.Provider value={{ user, loading, login, verify2FA, register, logout, loginWithOAuth, updateUser }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export function BrandingProvider({ children }) {
|
|||||||
defaultTheme: null,
|
defaultTheme: null,
|
||||||
imprintUrl: null,
|
imprintUrl: null,
|
||||||
privacyUrl: null,
|
privacyUrl: null,
|
||||||
|
hideAppName: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchBranding = useCallback(async () => {
|
const fetchBranding = useCallback(async () => {
|
||||||
|
|||||||
@@ -49,14 +49,20 @@ export function NotificationProvider({ children }) {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [notifications, setNotifications] = useState([]);
|
const [notifications, setNotifications] = useState([]);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const activeUserId = useRef(null);
|
||||||
// Track seen IDs to detect genuinely new arrivals and show toasts
|
// Track seen IDs to detect genuinely new arrivals and show toasts
|
||||||
const seenIds = useRef(new Set());
|
const seenIds = useRef(new Set());
|
||||||
const initialized = useRef(false);
|
const initialized = useRef(false);
|
||||||
|
|
||||||
const fetch = useCallback(async () => {
|
const fetch = useCallback(async () => {
|
||||||
if (!user) return;
|
const requestUserId = user?.id;
|
||||||
|
if (!requestUserId) return;
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/notifications');
|
const res = await api.get('/notifications');
|
||||||
|
|
||||||
|
// Ignore stale responses that arrived after logout or account switch.
|
||||||
|
if (activeUserId.current !== requestUserId) return;
|
||||||
|
|
||||||
const incoming = res.data.notifications || [];
|
const incoming = res.data.notifications || [];
|
||||||
setNotifications(incoming);
|
setNotifications(incoming);
|
||||||
setUnreadCount(res.data.unreadCount || 0);
|
setUnreadCount(res.data.unreadCount || 0);
|
||||||
@@ -77,16 +83,25 @@ export function NotificationProvider({ children }) {
|
|||||||
seenIds.current.add(n.id);
|
seenIds.current.add(n.id);
|
||||||
const icon = notificationIcon(n.type);
|
const icon = notificationIcon(n.type);
|
||||||
toast(`${icon} ${n.title}`, { duration: 5000 });
|
toast(`${icon} ${n.title}`, { duration: 5000 });
|
||||||
|
// Browser notification for calendar reminders
|
||||||
|
if (n.type === 'calendar_reminder' && 'Notification' in window) {
|
||||||
|
const fire = () => new Notification(n.title, { body: n.body || '', icon: '/favicon.ico' });
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
fire();
|
||||||
|
} else if (Notification.permission !== 'denied') {
|
||||||
|
Notification.requestPermission().then(p => { if (p === 'granted') fire(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
/* silent – server may not be reachable */
|
/* silent – server may not be reachable */
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
// Unlock audio playback on the first real user interaction.
|
// Unlock audio playback only for authenticated sessions.
|
||||||
// Browsers block audio from timer callbacks unless the element was previously
|
// This avoids any audio interaction while logged out (e.g. anonymous/incognito tabs).
|
||||||
// "touched" inside a gesture handler — this one-time listener does exactly that.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!user?.id) return;
|
||||||
const events = ['click', 'keydown', 'pointerdown'];
|
const events = ['click', 'keydown', 'pointerdown'];
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
unlockAudio();
|
unlockAudio();
|
||||||
@@ -94,10 +109,12 @@ export function NotificationProvider({ children }) {
|
|||||||
};
|
};
|
||||||
events.forEach(e => window.addEventListener(e, handler, { once: true }));
|
events.forEach(e => window.addEventListener(e, handler, { once: true }));
|
||||||
return () => events.forEach(e => window.removeEventListener(e, handler));
|
return () => events.forEach(e => window.removeEventListener(e, handler));
|
||||||
}, []);
|
}, [user?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
activeUserId.current = user?.id ?? null;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
_audioUnlocked = false;
|
||||||
setNotifications([]);
|
setNotifications([]);
|
||||||
setUnreadCount(0);
|
setUnreadCount(0);
|
||||||
seenIds.current = new Set();
|
seenIds.current = new Set();
|
||||||
@@ -166,6 +183,7 @@ function notificationIcon(type) {
|
|||||||
case 'room_share_added': return '🔗';
|
case 'room_share_added': return '🔗';
|
||||||
case 'room_share_removed': return '🚫';
|
case 'room_share_removed': return '🚫';
|
||||||
case 'federation_invite_received': return '📩';
|
case 'federation_invite_received': return '📩';
|
||||||
|
case 'calendar_reminder': return '🔔';
|
||||||
default: return '🔔';
|
default: return '🔔';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+209
-44
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"appName": "Redlight",
|
"appName": "Redlight",
|
||||||
"loading": "Laden...",
|
"loading": "Laden...",
|
||||||
@@ -23,7 +23,9 @@
|
|||||||
"protected": "Geschützt",
|
"protected": "Geschützt",
|
||||||
"live": "Live",
|
"live": "Live",
|
||||||
"error": "Fehler",
|
"error": "Fehler",
|
||||||
"success": "Erfolg"
|
"success": "Erfolg",
|
||||||
|
"gridView": "Rasteransicht",
|
||||||
|
"listView": "Listenansicht"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -62,16 +64,16 @@
|
|||||||
"passwordMismatch": "Passwörter stimmen nicht überein",
|
"passwordMismatch": "Passwörter stimmen nicht überein",
|
||||||
"passwordTooShort": "Passwort muss mindestens 6 Zeichen lang sein",
|
"passwordTooShort": "Passwort muss mindestens 6 Zeichen lang sein",
|
||||||
"loginSuccess": "Willkommen zurück!",
|
"loginSuccess": "Willkommen zurück!",
|
||||||
"registerSuccess": "Registrierung erfolgreich!",
|
"registerSuccess": "Registrierung erfolgreich",
|
||||||
"loginFailed": "Anmeldung fehlgeschlagen",
|
"loginFailed": "Anmeldung fehlgeschlagen",
|
||||||
"registerFailed": "Registrierung fehlgeschlagen",
|
"registerFailed": "Registrierung fehlgeschlagen",
|
||||||
"allFieldsRequired": "Alle Felder sind erforderlich",
|
"allFieldsRequired": "Alle Felder sind erforderlich",
|
||||||
"verificationSent": "Verifizierungs-E-Mail wurde gesendet!",
|
"verificationSent": "Verifizierungs-E-Mail wurde gesendet",
|
||||||
"verificationSentDesc": "Wir haben dir eine E-Mail mit einem Bestätigungslink geschickt. Bitte klicke auf den Link, um dein Konto zu aktivieren.",
|
"verificationSentDesc": "Wir haben dir eine E-Mail mit einem Bestätigungslink geschickt. Bitte klicke auf den Link, um dein Konto zu aktivieren.",
|
||||||
"checkYourEmail": "Prüfe dein Postfach",
|
"checkYourEmail": "Prüfe dein Postfach",
|
||||||
"verifying": "E-Mail wird verifiziert...",
|
"verifying": "E-Mail wird verifiziert...",
|
||||||
"verifySuccess": "Deine E-Mail-Adresse wurde erfolgreich bestätigt. Du kannst dich jetzt anmelden.",
|
"verifySuccess": "Deine E-Mail-Adresse wurde erfolgreich bestätigt. Du kannst dich jetzt anmelden.",
|
||||||
"verifySuccessTitle": "E-Mail bestätigt!",
|
"verifySuccessTitle": "E-Mail bestätigt",
|
||||||
"verifyFailed": "Verifizierung fehlgeschlagen",
|
"verifyFailed": "Verifizierung fehlgeschlagen",
|
||||||
"verifyFailedTitle": "Verifizierung fehlgeschlagen",
|
"verifyFailedTitle": "Verifizierung fehlgeschlagen",
|
||||||
"verifyTokenMissing": "Kein Verifizierungstoken vorhanden.",
|
"verifyTokenMissing": "Kein Verifizierungstoken vorhanden.",
|
||||||
@@ -88,34 +90,60 @@
|
|||||||
"emailVerificationBanner": "Deine E-Mail-Adresse wurde noch nicht verifiziert.",
|
"emailVerificationBanner": "Deine E-Mail-Adresse wurde noch nicht verifiziert.",
|
||||||
"emailVerificationResend": "Hier klicken um eine neue Verifizierungsmail zu erhalten",
|
"emailVerificationResend": "Hier klicken um eine neue Verifizierungsmail zu erhalten",
|
||||||
"emailVerificationResendCooldown": "Erneut senden in {seconds}s",
|
"emailVerificationResendCooldown": "Erneut senden in {seconds}s",
|
||||||
"emailVerificationResendSuccess": "Verifizierungsmail wurde gesendet!",
|
"emailVerificationResendSuccess": "Verifizierungsmail wurde gesendet",
|
||||||
"emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden",
|
"emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden",
|
||||||
"inviteOnly": "Nur mit Einladung",
|
"inviteOnly": "Nur mit Einladung",
|
||||||
"inviteOnlyDesc": "Die Registrierung ist derzeit eingeschränkt. Sie benötigen einen Einladungslink von einem Administrator, um ein Konto zu erstellen."
|
"inviteOnlyDesc": "Die Registrierung ist derzeit eingeschränkt. Sie benötigen einen Einladungslink von einem Administrator, um ein Konto zu erstellen.",
|
||||||
|
"orContinueWith": "oder weiter mit",
|
||||||
|
"loginWithOAuth": "Anmelden mit {provider}",
|
||||||
|
"registerWithOAuth": "Registrieren mit {provider}",
|
||||||
|
"backToLogin": "Zurück zum Login",
|
||||||
|
"oauthError": "Anmeldung fehlgeschlagen",
|
||||||
|
"oauthNoToken": "Kein Authentifizierungstoken erhalten.",
|
||||||
|
"oauthLoginFailed": "Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.",
|
||||||
|
"oauthRedirecting": "Du wirst angemeldet...",
|
||||||
|
"forgotPassword": "Passwort vergessen?",
|
||||||
|
"forgotPasswordTitle": "Passwort zurücksetzen",
|
||||||
|
"forgotPasswordSubtitle": "Gib deine E-Mail-Adresse ein. Wir senden dir einen Link zum Zurücksetzen deines Passworts.",
|
||||||
|
"forgotPasswordSubmit": "Link senden",
|
||||||
|
"forgotPasswordSent": "Wenn ein Konto für diese Adresse existiert, haben wir dir eine E-Mail zum Zurücksetzen des Passworts gesendet.",
|
||||||
|
"forgotPasswordSentTitle": "Prüfe dein Postfach",
|
||||||
|
"forgotPasswordFailed": "Anfrage konnte nicht gesendet werden. Bitte versuche es erneut.",
|
||||||
|
"resetPasswordTitle": "Neues Passwort festlegen",
|
||||||
|
"resetPasswordSubtitle": "Wähle ein neues Passwort für dein Konto.",
|
||||||
|
"newPassword": "Neues Passwort",
|
||||||
|
"resetPasswordSubmit": "Passwort zurücksetzen",
|
||||||
|
"resetPasswordSuccess": "Dein Passwort wurde zurückgesetzt. Du kannst dich jetzt anmelden.",
|
||||||
|
"resetPasswordSuccessTitle": "Passwort zurückgesetzt",
|
||||||
|
"resetPasswordFailed": "Passwort konnte nicht zurückgesetzt werden.",
|
||||||
|
"resetTokenMissing": "Kein Token zum Zurücksetzen vorhanden.",
|
||||||
|
"passwordMinLength": "Passwort muss mindestens 8 Zeichen lang sein",
|
||||||
|
"2fa": {
|
||||||
|
"title": "Zwei-Faktor-Authentifizierung",
|
||||||
|
"prompt": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
|
||||||
|
"codeLabel": "Bestätigungscode",
|
||||||
|
"verify": "Bestätigen",
|
||||||
|
"verifyFailed": "Überprüfung fehlgeschlagen",
|
||||||
|
"backToLogin": "← Zurück zum Login"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"poweredBy": "Powered by BigBlueButton",
|
"heroTitle": "Selbst gehostetes Frontend für BigBlueButton",
|
||||||
"heroTitle": "Meetings neu ",
|
"heroSubtitle": "Redlight verwaltet Räume, Aufnahmen und Termine für deinen BigBlueButton-Server – mit Federation zwischen Instanzen, CalDAV-Sync und SSO-Anbindung.",
|
||||||
"heroTitleHighlight": "definiert",
|
"getStarted": "Konto erstellen",
|
||||||
"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.",
|
"features": "Funktionen",
|
||||||
"getStarted": "Jetzt starten",
|
|
||||||
"features": "Alles was Sie brauchen",
|
|
||||||
"featuresSubtitle": "Redlight bietet alle Funktionen, die Sie für professionelle Videokonferenzen benötigen.",
|
|
||||||
"featureVideoTitle": "Videokonferenzen",
|
|
||||||
"featureVideoDesc": "Erstellen und verwalten Sie Meetings direkt über BigBlueButton.",
|
|
||||||
"featureRoomsTitle": "Raumverwaltung",
|
"featureRoomsTitle": "Raumverwaltung",
|
||||||
"featureRoomsDesc": "Unbegrenzte Räume mit individuellen Einstellungen und Zugangscodes.",
|
"featureRoomsDesc": "Räume mit eigenen Einstellungen, Zugangscodes und geteiltem Zugriff für Kolleginnen und Kollegen.",
|
||||||
"featureUsersTitle": "Benutzerverwaltung",
|
|
||||||
"featureUsersDesc": "Registrierung, Login und Rollenverwaltung für Ihre Organisation.",
|
|
||||||
"featureThemesTitle": "15+ Themes",
|
|
||||||
"featureThemesDesc": "Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox und viele mehr.",
|
|
||||||
"featureRecordingsTitle": "Aufnahmen",
|
"featureRecordingsTitle": "Aufnahmen",
|
||||||
"featureRecordingsDesc": "Alle Aufnahmen pro Raum einsehen, veröffentlichen oder löschen.",
|
"featureRecordingsDesc": "Aufnahmen pro Raum einsehen, veröffentlichen oder löschen.",
|
||||||
"featureOpenSourceTitle": "Open Source",
|
"featureFederationTitle": "Federation",
|
||||||
"featureOpenSourceDesc": "Vollständig quelloffen und selbst gehostet. Ihre Daten bleiben bei Ihnen.",
|
"featureFederationDesc": "Nutzer anderer Redlight-Instanzen einladen und instanzübergreifende Einladungen annehmen.",
|
||||||
"statThemes": "Themes",
|
"featureCalendarTitle": "Kalender",
|
||||||
"statRooms": "Räume",
|
"featureCalendarDesc": "Meetings planen, per CalDAV mit Thunderbird, Apple Calendar oder DAVx⁵ synchronisieren, Erinnerungen erhalten.",
|
||||||
"statOpenSource": "Open Source",
|
"featureVideoTitle": "Videokonferenzen",
|
||||||
|
"featureVideoDesc": "Meetings direkt über BigBlueButton erstellen, starten und verwalten.",
|
||||||
|
"featureAnalyticsTitle": "Learning Analytics",
|
||||||
|
"featureAnalyticsDesc": "Teilnahme- und Aktivitätsdaten aus Meetings einsehen und als CSV, Excel oder PDF exportieren.",
|
||||||
"footer": "© {year} Redlight. Ein Open-Source BigBlueButton Frontend."
|
"footer": "© {year} Redlight. Ein Open-Source BigBlueButton Frontend."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
@@ -136,7 +164,7 @@
|
|||||||
"accessCode": "Zugangscode",
|
"accessCode": "Zugangscode",
|
||||||
"muteOnJoin": "Teilnehmer beim Beitritt stummschalten",
|
"muteOnJoin": "Teilnehmer beim Beitritt stummschalten",
|
||||||
"allowRecording": "Aufnahme erlauben",
|
"allowRecording": "Aufnahme erlauben",
|
||||||
"roomCreated": "Raum erstellt!",
|
"roomCreated": "Raum erstellt",
|
||||||
"roomCreateFailed": "Raum konnte nicht erstellt werden",
|
"roomCreateFailed": "Raum konnte nicht erstellt werden",
|
||||||
"roomDeleted": "Raum gelöscht",
|
"roomDeleted": "Raum gelöscht",
|
||||||
"roomDeleteFailed": "Raum konnte nicht gelöscht werden",
|
"roomDeleteFailed": "Raum konnte nicht gelöscht werden",
|
||||||
@@ -158,7 +186,9 @@
|
|||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"participants": "{count} Teilnehmer",
|
"participants": "{count} Teilnehmer",
|
||||||
"copyLink": "Link kopieren",
|
"copyLink": "Link kopieren",
|
||||||
"linkCopied": "Link kopiert!",
|
"copyRoomLink": "Raum-Link",
|
||||||
|
"copyGuestLink": "Gast-Link",
|
||||||
|
"linkCopied": "Link kopiert",
|
||||||
"meetingDetails": "Meeting-Details",
|
"meetingDetails": "Meeting-Details",
|
||||||
"meetingId": "Meeting ID",
|
"meetingId": "Meeting ID",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@@ -179,11 +209,12 @@
|
|||||||
"anyoneCanStart": "Jeder kann das Meeting starten",
|
"anyoneCanStart": "Jeder kann das Meeting starten",
|
||||||
"allJoinModerator": "Alle Teilnehmer als Moderator",
|
"allJoinModerator": "Alle Teilnehmer als Moderator",
|
||||||
"allowRecording": "Aufnahme erlauben",
|
"allowRecording": "Aufnahme erlauben",
|
||||||
|
"enableTranscript": "KI-Zusammenfassung / Transkript für Aufnahmen",
|
||||||
"noAccessCode": "Kein Zugangscode",
|
"noAccessCode": "Kein Zugangscode",
|
||||||
"emptyNoCode": "Leer = kein Code",
|
"emptyNoCode": "Leer = kein Code",
|
||||||
"settingsSaved": "Einstellungen gespeichert",
|
"settingsSaved": "Einstellungen gespeichert",
|
||||||
"settingsSaveFailed": "Einstellungen konnten nicht gespeichert werden",
|
"settingsSaveFailed": "Einstellungen konnten nicht gespeichert werden",
|
||||||
"meetingStarted": "Meeting gestartet!",
|
"meetingStarted": "Meeting gestartet",
|
||||||
"meetingStartFailed": "Meeting konnte nicht gestartet werden",
|
"meetingStartFailed": "Meeting konnte nicht gestartet werden",
|
||||||
"meetingEnded": "Meeting beendet",
|
"meetingEnded": "Meeting beendet",
|
||||||
"meetingEndFailed": "Meeting konnte nicht beendet werden",
|
"meetingEndFailed": "Meeting konnte nicht beendet werden",
|
||||||
@@ -198,7 +229,7 @@
|
|||||||
"moderatorCodeHint": "Optionaler Code für Moderator-Rechte",
|
"moderatorCodeHint": "Optionaler Code für Moderator-Rechte",
|
||||||
"moderatorCodeDesc": "Gäste, die diesen Code eingeben, erhalten Moderator-Rechte.",
|
"moderatorCodeDesc": "Gäste, die diesen Code eingeben, erhalten Moderator-Rechte.",
|
||||||
"guestLink": "Gast-Einladungslink",
|
"guestLink": "Gast-Einladungslink",
|
||||||
"guestLinkCopied": "Gast-Link kopiert!",
|
"guestLinkCopied": "Gast-Link kopiert",
|
||||||
"guestJoinTitle": "Meeting beitreten",
|
"guestJoinTitle": "Meeting beitreten",
|
||||||
"guestCreatedBy": "Erstellt von",
|
"guestCreatedBy": "Erstellt von",
|
||||||
"guestMeetingRunning": "Meeting läuft",
|
"guestMeetingRunning": "Meeting läuft",
|
||||||
@@ -212,6 +243,11 @@
|
|||||||
"guestModeratorPlaceholder": "Nur wenn Sie Moderator sind",
|
"guestModeratorPlaceholder": "Nur wenn Sie Moderator sind",
|
||||||
"guestJoinButton": "Meeting beitreten",
|
"guestJoinButton": "Meeting beitreten",
|
||||||
"guestWaitingMessage": "Das Meeting wurde noch nicht gestartet. Bitte warten Sie, bis der Moderator es startet.",
|
"guestWaitingMessage": "Das Meeting wurde noch nicht gestartet. Bitte warten Sie, bis der Moderator es startet.",
|
||||||
|
"guestWaitingTitle": "Warte auf Meeting-Start...",
|
||||||
|
"guestWaitingHint": "Du wirst automatisch beigetreten, sobald das Meeting gestartet wird.",
|
||||||
|
"guestCancelWaiting": "Abbrechen",
|
||||||
|
"guestMeetingStartedJoining": "Meeting gestartet! Trete jetzt bei...",
|
||||||
|
"waitingToJoin": "Warten...",
|
||||||
"guestAccessDenied": "Zugang nicht möglich",
|
"guestAccessDenied": "Zugang nicht möglich",
|
||||||
"guestNameRequired": "Name ist erforderlich",
|
"guestNameRequired": "Name ist erforderlich",
|
||||||
"guestJoinFailed": "Beitritt fehlgeschlagen",
|
"guestJoinFailed": "Beitritt fehlgeschlagen",
|
||||||
@@ -240,7 +276,14 @@
|
|||||||
"shareRemoved": "Freigabe entfernt",
|
"shareRemoved": "Freigabe entfernt",
|
||||||
"shareFailed": "Freigabe fehlgeschlagen",
|
"shareFailed": "Freigabe fehlgeschlagen",
|
||||||
"shareRemove": "Freigabe entfernen",
|
"shareRemove": "Freigabe entfernen",
|
||||||
"defaultWelcome": "Willkommen zum Meeting!"
|
"defaultWelcome": "Willkommen zum Meeting!",
|
||||||
|
"analytics": "Lernanalyse",
|
||||||
|
"enableAnalytics": "Lernanalyse aktivieren",
|
||||||
|
"enableAnalyticsHint": "Sammelt Engagement-Daten der Teilnehmer nach jedem Meeting.",
|
||||||
|
"analyticsVisibility": "Wer kann die Analyse sehen?",
|
||||||
|
"analyticsOwnerOnly": "Nur Raumbesitzer",
|
||||||
|
"analyticsSharedUsers": "Alle geteilten Benutzer",
|
||||||
|
"analyticsVisibilityHint": "Legt fest, wer die Analysedaten dieses Raums einsehen und exportieren kann."
|
||||||
},
|
},
|
||||||
"recordings": {
|
"recordings": {
|
||||||
"title": "Aufnahmen",
|
"title": "Aufnahmen",
|
||||||
@@ -258,6 +301,30 @@
|
|||||||
"publish": "Veröffentlichen",
|
"publish": "Veröffentlichen",
|
||||||
"loadFailed": "Aufnahmen konnten nicht geladen werden"
|
"loadFailed": "Aufnahmen konnten nicht geladen werden"
|
||||||
},
|
},
|
||||||
|
"analytics": {
|
||||||
|
"title": "Lernanalyse",
|
||||||
|
"noData": "Keine Analysedaten vorhanden",
|
||||||
|
"participants": "Teilnehmer",
|
||||||
|
"messages": "Nachrichten",
|
||||||
|
"expand": "Details anzeigen",
|
||||||
|
"collapse": "Details ausblenden",
|
||||||
|
"deleteConfirm": "Analysedaten wirklich löschen?",
|
||||||
|
"deleted": "Analysedaten gelöscht",
|
||||||
|
"deleteFailed": "Fehler beim Löschen",
|
||||||
|
"userName": "Name",
|
||||||
|
"role": "Rolle",
|
||||||
|
"moderator": "Moderator",
|
||||||
|
"viewer": "Teilnehmer",
|
||||||
|
"talkTime": "Sprechzeit",
|
||||||
|
"webcamTime": "Webcam-Zeit",
|
||||||
|
"duration": "Dauer",
|
||||||
|
"meetingDuration": "Meeting-Dauer",
|
||||||
|
"raiseHand": "Handheben",
|
||||||
|
"reactions": "Reaktionen",
|
||||||
|
"export": "Herunterladen",
|
||||||
|
"exportSuccess": "Download gestartet",
|
||||||
|
"exportFailed": "Fehler beim Herunterladen"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Einstellungen",
|
"title": "Einstellungen",
|
||||||
"subtitle": "Verwalten Sie Ihr Profil und Ihre Einstellungen",
|
"subtitle": "Verwalten Sie Ihr Profil und Ihre Einstellungen",
|
||||||
@@ -287,7 +354,49 @@
|
|||||||
"passwordChanged": "Passwort geändert",
|
"passwordChanged": "Passwort geändert",
|
||||||
"passwordChangeFailed": "Fehler beim Ändern",
|
"passwordChangeFailed": "Fehler beim Ändern",
|
||||||
"passwordMismatch": "Passwörter stimmen nicht überein",
|
"passwordMismatch": "Passwörter stimmen nicht überein",
|
||||||
"selectLanguage": "Sprache auswählen"
|
"selectLanguage": "Sprache auswählen",
|
||||||
|
"security": {
|
||||||
|
"title": "Sicherheit",
|
||||||
|
"subtitle": "Schütze dein Konto mit Zwei-Faktor-Authentifizierung (2FA). Nach der Aktivierung benötigst du sowohl dein Passwort als auch einen Code aus deiner Authenticator-App zum Anmelden.",
|
||||||
|
"statusEnabled": "2FA ist aktiviert",
|
||||||
|
"statusEnabledDesc": "Dein Konto ist durch Zwei-Faktor-Authentifizierung geschützt.",
|
||||||
|
"statusDisabled": "2FA ist nicht aktiviert",
|
||||||
|
"statusDisabledDesc": "Aktiviere die Zwei-Faktor-Authentifizierung für zusätzliche Sicherheit.",
|
||||||
|
"enable": "2FA aktivieren",
|
||||||
|
"disable": "2FA deaktivieren",
|
||||||
|
"enabled": "Zwei-Faktor-Authentifizierung aktiviert",
|
||||||
|
"disabled": "Zwei-Faktor-Authentifizierung deaktiviert.",
|
||||||
|
"enableFailed": "2FA konnte nicht aktiviert werden",
|
||||||
|
"disableFailed": "2FA konnte nicht deaktiviert werden",
|
||||||
|
"setupFailed": "2FA-Einrichtung konnte nicht gestartet werden",
|
||||||
|
"scanQR": "Scanne diesen QR-Code mit deiner Authenticator-App (Google Authenticator, Authy, etc.).",
|
||||||
|
"manualKey": "Oder gib diesen Schlüssel manuell ein:",
|
||||||
|
"verifyCode": "Gib den Code aus deiner App zur Überprüfung ein",
|
||||||
|
"codeLabel": "6-stelliger Code",
|
||||||
|
"disableConfirm": "Gib dein Passwort und einen aktuellen 2FA-Code ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren."
|
||||||
|
},
|
||||||
|
"caldav": {
|
||||||
|
"title": "CalDAV",
|
||||||
|
"subtitle": "Verbinde deine Kalender-App (z. B. Apple Kalender, Thunderbird, DAVx⁵) über das CalDAV-Protokoll. Verwende deine E-Mail-Adresse und ein App-Token als Passwort.",
|
||||||
|
"serverUrl": "Server-URL",
|
||||||
|
"username": "Benutzername (E-Mail)",
|
||||||
|
"hint": "Gib niemals dein echtes Redlight-Passwort in einer Kalender-App ein. Verwende stattdessen ein App-Token.",
|
||||||
|
"newToken": "Neues App-Token generieren",
|
||||||
|
"tokenNamePlaceholder": "z. B. \"iPhone\" oder \"Thunderbird\"",
|
||||||
|
"generate": "Generieren",
|
||||||
|
"existingTokens": "Aktive Tokens",
|
||||||
|
"noTokens": "Noch keine Tokens erstellt.",
|
||||||
|
"created": "Erstellt",
|
||||||
|
"lastUsed": "Zuletzt verwendet",
|
||||||
|
"revoke": "Widerrufen",
|
||||||
|
"revokeConfirm": "Dieses Token wirklich widerrufen? Alle Kalender-Apps, die dieses Token verwenden, verlieren den Zugriff.",
|
||||||
|
"revoked": "Token widerrufen",
|
||||||
|
"revokeFailed": "Token konnte nicht widerrufen werden",
|
||||||
|
"createFailed": "Token konnte nicht erstellt werden",
|
||||||
|
"newTokenCreated": "Token erstellt — jetzt kopieren",
|
||||||
|
"newTokenHint": "Dieses Token wird nur einmal angezeigt. Kopiere es und trage es als Passwort in deiner Kalender-App ein.",
|
||||||
|
"dismiss": "Ich habe das Token kopiert"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"themes": {
|
"themes": {
|
||||||
"selectTheme": "Theme auswählen",
|
"selectTheme": "Theme auswählen",
|
||||||
@@ -336,6 +445,9 @@
|
|||||||
"appNameLabel": "App-Name",
|
"appNameLabel": "App-Name",
|
||||||
"appNameUpdated": "App-Name aktualisiert",
|
"appNameUpdated": "App-Name aktualisiert",
|
||||||
"appNameUpdateFailed": "App-Name konnte nicht aktualisiert werden",
|
"appNameUpdateFailed": "App-Name konnte nicht aktualisiert werden",
|
||||||
|
"hideAppNameLabel": "App-Namen ausblenden",
|
||||||
|
"hideAppNameHint": "Nur das Logo anzeigen, den App-Namen daneben ausblenden.",
|
||||||
|
"hideAppNameFailed": "Einstellung konnte nicht gespeichert werden",
|
||||||
"defaultThemeLabel": "Standard-Theme",
|
"defaultThemeLabel": "Standard-Theme",
|
||||||
"defaultThemeDesc": "Wird für nicht angemeldete Seiten (Gast-Join, Login, Startseite) verwendet, wenn keine persönliche Einstellung gesetzt ist.",
|
"defaultThemeDesc": "Wird für nicht angemeldete Seiten (Gast-Join, Login, Startseite) verwendet, wenn keine persönliche Einstellung gesetzt ist.",
|
||||||
"defaultThemeSaved": "Standard-Theme gespeichert",
|
"defaultThemeSaved": "Standard-Theme gespeichert",
|
||||||
@@ -349,11 +461,11 @@
|
|||||||
"inviteTitle": "Benutzer-Einladungen",
|
"inviteTitle": "Benutzer-Einladungen",
|
||||||
"inviteDescription": "Laden Sie neue Benutzer per E-Mail ein. Sie erhalten einen Registrierungslink, der 7 Tage gültig ist.",
|
"inviteDescription": "Laden Sie neue Benutzer per E-Mail ein. Sie erhalten einen Registrierungslink, der 7 Tage gültig ist.",
|
||||||
"sendInvite": "Einladung senden",
|
"sendInvite": "Einladung senden",
|
||||||
"inviteSent": "Einladung gesendet!",
|
"inviteSent": "Einladung gesendet",
|
||||||
"inviteFailed": "Einladung konnte nicht gesendet werden",
|
"inviteFailed": "Einladung konnte nicht gesendet werden",
|
||||||
"inviteDeleted": "Einladung gelöscht",
|
"inviteDeleted": "Einladung gelöscht",
|
||||||
"inviteDeleteFailed": "Einladung konnte nicht gelöscht werden",
|
"inviteDeleteFailed": "Einladung konnte nicht gelöscht werden",
|
||||||
"inviteLinkCopied": "Einladungslink kopiert!",
|
"inviteLinkCopied": "Einladungslink kopiert",
|
||||||
"copyInviteLink": "Einladungslink kopieren",
|
"copyInviteLink": "Einladungslink kopieren",
|
||||||
"inviteExpired": "Abgelaufen",
|
"inviteExpired": "Abgelaufen",
|
||||||
"inviteUsedBy": "Verwendet von",
|
"inviteUsedBy": "Verwendet von",
|
||||||
@@ -366,7 +478,40 @@
|
|||||||
"imprintUrlSaved": "Impressum-URL gespeichert",
|
"imprintUrlSaved": "Impressum-URL gespeichert",
|
||||||
"privacyUrlSaved": "Datenschutz-URL gespeichert",
|
"privacyUrlSaved": "Datenschutz-URL gespeichert",
|
||||||
"imprintUrlFailed": "Impressum-URL konnte nicht gespeichert werden",
|
"imprintUrlFailed": "Impressum-URL konnte nicht gespeichert werden",
|
||||||
"privacyUrlFailed": "Datenschutz-URL konnte nicht gespeichert werden"
|
"privacyUrlFailed": "Datenschutz-URL konnte nicht gespeichert werden",
|
||||||
|
"oauthTitle": "OAuth / SSO",
|
||||||
|
"oauthDescription": "OpenID-Connect-Anbieter verbinden (z. B. Keycloak, Authentik, Google) für Single Sign-On.",
|
||||||
|
"oauthIssuer": "Issuer-URL",
|
||||||
|
"oauthIssuerHint": "Die OIDC-Issuer-URL, z. B. https://auth.example.com/realms/main",
|
||||||
|
"oauthClientId": "Client-ID",
|
||||||
|
"oauthClientSecret": "Client-Secret",
|
||||||
|
"oauthClientSecretHint": "Leer lassen, um das bestehende Secret beizubehalten",
|
||||||
|
"oauthDisplayName": "Button-Beschriftung",
|
||||||
|
"oauthDisplayNameHint": "Wird auf der Login-Seite angezeigt, z. B. Firmen-SSO",
|
||||||
|
"oauthAutoRegister": "Neue Benutzer automatisch registrieren",
|
||||||
|
"oauthAutoRegisterHint": "Erstellt automatisch Konten für Benutzer, die sich zum ersten Mal per OAuth anmelden.",
|
||||||
|
"oauthSaved": "OAuth-Konfiguration gespeichert",
|
||||||
|
"oauthSaveFailed": "OAuth-Konfiguration konnte nicht gespeichert werden",
|
||||||
|
"oauthRemoved": "OAuth-Konfiguration entfernt",
|
||||||
|
"oauthRemoveFailed": "OAuth-Konfiguration konnte nicht entfernt werden",
|
||||||
|
"oauthRemoveConfirm": "OAuth-Konfiguration wirklich entfernen? Benutzer können sich dann nicht mehr per SSO anmelden.",
|
||||||
|
"oauthNotConfigured": "OAuth ist noch nicht konfiguriert.",
|
||||||
|
"oauthSave": "OAuth speichern",
|
||||||
|
"oauthRemove": "OAuth entfernen",
|
||||||
|
"roomsTitle": "Raumverwaltung",
|
||||||
|
"roomsDescription": "Alle Räume der Instanz einsehen, verwalten und bei Bedarf löschen.",
|
||||||
|
"searchRooms": "Räume suchen...",
|
||||||
|
"roomName": "Name",
|
||||||
|
"roomOwner": "Besitzer",
|
||||||
|
"roomShares": "Geteilt",
|
||||||
|
"roomCreated": "Erstellt",
|
||||||
|
"roomView": "Raum öffnen",
|
||||||
|
"deleteRoom": "Raum löschen",
|
||||||
|
"deleteRoomConfirm": "Raum \"{name}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||||
|
"roomDeleted": "Raum gelöscht",
|
||||||
|
"roomDeleteFailed": "Raum konnte nicht gelöscht werden",
|
||||||
|
"noRoomsFound": "Keine Räume vorhanden",
|
||||||
|
"showAllRooms": "Alle {count} Räume anzeigen"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"bell": "Benachrichtigungen",
|
"bell": "Benachrichtigungen",
|
||||||
@@ -382,14 +527,19 @@
|
|||||||
"inbox": "Einladungen",
|
"inbox": "Einladungen",
|
||||||
"inboxSubtitle": "Meeting-Einladungen von anderen Redlight-Instanzen",
|
"inboxSubtitle": "Meeting-Einladungen von anderen Redlight-Instanzen",
|
||||||
"inviteTitle": "Remote-Benutzer einladen",
|
"inviteTitle": "Remote-Benutzer einladen",
|
||||||
"inviteSubtitle": "Einen Benutzer von einer anderen Redlight-Instanz zu diesem Meeting einladen.",
|
"inviteSubtitle": "Du kannst entweder einen Benutzer von einer anderen Redlight-Instanz über seine Adresse einladen oder direkt eine E-Mail-Einladung senden. Es kann nur eine Option gleichzeitig verwendet werden.",
|
||||||
"addressLabel": "Benutzeradresse",
|
"addressLabel": "Benutzeradresse",
|
||||||
"addressPlaceholder": "@benutzer@andere-instanz.com",
|
"addressPlaceholder": "@benutzer@andere-instanz.com",
|
||||||
"addressHint": "Format: @Benutzername@Domain der Redlight-Instanz",
|
"addressHint": "Format: @Benutzername@Domain der Redlight-Instanz",
|
||||||
|
"emailLabel": "Per E-Mail einladen",
|
||||||
|
"emailPlaceholder": "name@beispiel.de, name2@beispiel.de",
|
||||||
|
"emailHint": "Eine oder mehrere E-Mail-Adressen, durch Komma getrennt",
|
||||||
"messageLabel": "Nachricht (optional)",
|
"messageLabel": "Nachricht (optional)",
|
||||||
"messagePlaceholder": "Hallo, ich lade dich zu unserem Meeting ein!",
|
"messagePlaceholder": "Hallo, ich lade dich zu unserem Meeting ein!",
|
||||||
"send": "Einladung senden",
|
"send": "Einladung senden",
|
||||||
"sent": "Einladung gesendet!",
|
"sent": "Einladung gesendet",
|
||||||
|
"emailSent": "E-Mail-Einladung(en) gesendet",
|
||||||
|
"emailSendFailed": "E-Mail-Einladung konnte nicht gesendet werden",
|
||||||
"sendFailed": "Einladung konnte nicht gesendet werden",
|
"sendFailed": "Einladung konnte nicht gesendet werden",
|
||||||
"from": "Von",
|
"from": "Von",
|
||||||
"accept": "Annehmen",
|
"accept": "Annehmen",
|
||||||
@@ -414,7 +564,7 @@
|
|||||||
"removeRoomConfirm": "Raum wirklich entfernen?",
|
"removeRoomConfirm": "Raum wirklich entfernen?",
|
||||||
"roomRemoved": "Raum entfernt",
|
"roomRemoved": "Raum entfernt",
|
||||||
"roomRemoveFailed": "Raum konnte nicht entfernt werden",
|
"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",
|
"meetingId": "Meeting ID",
|
||||||
"maxParticipants": "Max. Teilnehmer",
|
"maxParticipants": "Max. Teilnehmer",
|
||||||
"recordingOn": "Aufnahme aktiviert",
|
"recordingOn": "Aufnahme aktiviert",
|
||||||
@@ -430,9 +580,9 @@
|
|||||||
"roomDeleted": "Gelöscht",
|
"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",
|
"calendarEvent": "Kalendereinladung",
|
||||||
"calendarAccepted": "Kalender-Event angenommen und in deinen Kalender eingetragen!",
|
"calendarAccepted": "Kalender-Event angenommen und in deinen Kalender eingetragen",
|
||||||
"localCalendarEvent": "Lokale Kalendereinladung",
|
"localCalendarEvent": "Lokale Kalendereinladung",
|
||||||
"calendarLocalAccepted": "Einladung angenommen - Event wurde in deinen Kalender eingetragen!",
|
"calendarLocalAccepted": "Einladung angenommen - Event wurde in deinen Kalender eingetragen",
|
||||||
"invitationRemoved": "Einladung entfernt",
|
"invitationRemoved": "Einladung entfernt",
|
||||||
"removeInvitation": "Einladung entfernen"
|
"removeInvitation": "Einladung entfernen"
|
||||||
},
|
},
|
||||||
@@ -451,9 +601,18 @@
|
|||||||
"linkedRoom": "Verknüpfter Raum",
|
"linkedRoom": "Verknüpfter Raum",
|
||||||
"noRoom": "Kein Raum (kein Videomeeting)",
|
"noRoom": "Kein Raum (kein Videomeeting)",
|
||||||
"linkedRoomHint": "Verknüpfe einen Raum, um die Beitritts-URL automatisch ins Event einzufügen.",
|
"linkedRoomHint": "Verknüpfe einen Raum, um die Beitritts-URL automatisch ins Event einzufügen.",
|
||||||
|
"reminderLabel": "Erinnerung",
|
||||||
|
"reminderNone": "Keine Erinnerung",
|
||||||
|
"reminder5": "5 Minuten vorher",
|
||||||
|
"reminder15": "15 Minuten vorher",
|
||||||
|
"reminder30": "30 Minuten vorher",
|
||||||
|
"reminder60": "1 Stunde vorher",
|
||||||
|
"reminder120": "2 Stunden vorher",
|
||||||
|
"reminder1440": "1 Tag vorher",
|
||||||
|
"timezone": "Zeitzone",
|
||||||
"color": "Farbe",
|
"color": "Farbe",
|
||||||
"eventCreated": "Event erstellt!",
|
"eventCreated": "Event erstellt",
|
||||||
"eventUpdated": "Event aktualisiert!",
|
"eventUpdated": "Event aktualisiert",
|
||||||
"eventDeleted": "Event gelöscht",
|
"eventDeleted": "Event gelöscht",
|
||||||
"saveFailed": "Event konnte nicht gespeichert werden",
|
"saveFailed": "Event konnte nicht gespeichert werden",
|
||||||
"deleteFailed": "Event konnte nicht gelöscht werden",
|
"deleteFailed": "Event konnte nicht gelöscht werden",
|
||||||
@@ -480,7 +639,7 @@
|
|||||||
"shareAdded": "Benutzer zum Event hinzugefügt",
|
"shareAdded": "Benutzer zum Event hinzugefügt",
|
||||||
"shareRemoved": "Freigabe entfernt",
|
"shareRemoved": "Freigabe entfernt",
|
||||||
"shareFailed": "Event konnte nicht geteilt werden",
|
"shareFailed": "Event konnte nicht geteilt werden",
|
||||||
"invitationSent": "Einladung gesendet!",
|
"invitationSent": "Einladung gesendet",
|
||||||
"invitationCancelled": "Einladung widerrufen",
|
"invitationCancelled": "Einladung widerrufen",
|
||||||
"invitationPending": "Einladung ausstehend",
|
"invitationPending": "Einladung ausstehend",
|
||||||
"pendingInvitations": "Ausstehende Einladungen",
|
"pendingInvitations": "Ausstehende Einladungen",
|
||||||
@@ -532,5 +691,11 @@
|
|||||||
"note": "Der Termin wurde automatisch aus deinem Kalender entfernt.",
|
"note": "Der Termin wurde automatisch aus deinem Kalender entfernt.",
|
||||||
"footer": "Diese Nachricht wurde automatisch von {appName} versendet."
|
"footer": "Diese Nachricht wurde automatisch von {appName} versendet."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"notFound": {
|
||||||
|
"title": "Seite nicht gefunden",
|
||||||
|
"description": "Die Seite, die du suchst, existiert nicht oder wurde verschoben.",
|
||||||
|
"goBack": "Zurück",
|
||||||
|
"goHome": "Zur Startseite"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+210
-45
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"appName": "Redlight",
|
"appName": "Redlight",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@@ -23,7 +23,9 @@
|
|||||||
"protected": "Protected",
|
"protected": "Protected",
|
||||||
"live": "Live",
|
"live": "Live",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"success": "Success"
|
"success": "Success",
|
||||||
|
"gridView": "Grid view",
|
||||||
|
"listView": "List view"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -62,16 +64,16 @@
|
|||||||
"passwordMismatch": "Passwords do not match",
|
"passwordMismatch": "Passwords do not match",
|
||||||
"passwordTooShort": "Password must be at least 6 characters",
|
"passwordTooShort": "Password must be at least 6 characters",
|
||||||
"loginSuccess": "Welcome back!",
|
"loginSuccess": "Welcome back!",
|
||||||
"registerSuccess": "Registration successful!",
|
"registerSuccess": "Registration successful",
|
||||||
"loginFailed": "Login failed",
|
"loginFailed": "Login failed",
|
||||||
"registerFailed": "Registration failed",
|
"registerFailed": "Registration failed",
|
||||||
"allFieldsRequired": "All fields are required",
|
"allFieldsRequired": "All fields are required",
|
||||||
"verificationSent": "Verification email sent!",
|
"verificationSent": "Verification email sent",
|
||||||
"verificationSentDesc": "We've sent you an email with a verification link. Please click the link to activate your account.",
|
"verificationSentDesc": "We've sent you an email with a verification link. Please click the link to activate your account.",
|
||||||
"checkYourEmail": "Check your inbox",
|
"checkYourEmail": "Check your inbox",
|
||||||
"verifying": "Verifying your email...",
|
"verifying": "Verifying your email...",
|
||||||
"verifySuccess": "Your email has been verified successfully. You can now sign in.",
|
"verifySuccess": "Your email has been verified successfully. You can now sign in.",
|
||||||
"verifySuccessTitle": "Email verified!",
|
"verifySuccessTitle": "Email verified",
|
||||||
"verifyFailed": "Verification failed",
|
"verifyFailed": "Verification failed",
|
||||||
"verifyFailedTitle": "Verification failed",
|
"verifyFailedTitle": "Verification failed",
|
||||||
"verifyTokenMissing": "No verification token provided.",
|
"verifyTokenMissing": "No verification token provided.",
|
||||||
@@ -88,34 +90,60 @@
|
|||||||
"emailVerificationBanner": "Your email address has not been verified yet.",
|
"emailVerificationBanner": "Your email address has not been verified yet.",
|
||||||
"emailVerificationResend": "Click here to receive a new verification email",
|
"emailVerificationResend": "Click here to receive a new verification email",
|
||||||
"emailVerificationResendCooldown": "Resend in {seconds}s",
|
"emailVerificationResendCooldown": "Resend in {seconds}s",
|
||||||
"emailVerificationResendSuccess": "Verification email sent!",
|
"emailVerificationResendSuccess": "Verification email sent",
|
||||||
"emailVerificationResendFailed": "Could not send verification email",
|
"emailVerificationResendFailed": "Could not send verification email",
|
||||||
"inviteOnly": "Invite Only",
|
"inviteOnly": "Invite Only",
|
||||||
"inviteOnlyDesc": "Registration is currently restricted. You need an invitation link from an administrator to create an account."
|
"inviteOnlyDesc": "Registration is currently restricted. You need an invitation link from an administrator to create an account.",
|
||||||
|
"orContinueWith": "or continue with",
|
||||||
|
"loginWithOAuth": "Sign in with {provider}",
|
||||||
|
"registerWithOAuth": "Sign up with {provider}",
|
||||||
|
"backToLogin": "Back to login",
|
||||||
|
"oauthError": "Authentication failed",
|
||||||
|
"oauthNoToken": "No authentication token received.",
|
||||||
|
"oauthLoginFailed": "Could not complete sign in. Please try again.",
|
||||||
|
"oauthRedirecting": "Signing you in...",
|
||||||
|
"forgotPassword": "Forgot password?",
|
||||||
|
"forgotPasswordTitle": "Reset your password",
|
||||||
|
"forgotPasswordSubtitle": "Enter your email address and we'll send you a link to reset your password.",
|
||||||
|
"forgotPasswordSubmit": "Send link",
|
||||||
|
"forgotPasswordSent": "If an account exists for that address, we've sent you a password reset email.",
|
||||||
|
"forgotPasswordSentTitle": "Check your inbox",
|
||||||
|
"forgotPasswordFailed": "Could not send the request. Please try again.",
|
||||||
|
"resetPasswordTitle": "Set a new password",
|
||||||
|
"resetPasswordSubtitle": "Choose a new password for your account.",
|
||||||
|
"newPassword": "New password",
|
||||||
|
"resetPasswordSubmit": "Reset password",
|
||||||
|
"resetPasswordSuccess": "Your password has been reset. You can now sign in.",
|
||||||
|
"resetPasswordSuccessTitle": "Password reset",
|
||||||
|
"resetPasswordFailed": "Could not reset password.",
|
||||||
|
"resetTokenMissing": "No reset token provided.",
|
||||||
|
"passwordMinLength": "Password must be at least 8 characters",
|
||||||
|
"2fa": {
|
||||||
|
"title": "Two-Factor Authentication",
|
||||||
|
"prompt": "Enter the 6-digit code from your authenticator app.",
|
||||||
|
"codeLabel": "Verification code",
|
||||||
|
"verify": "Verify",
|
||||||
|
"verifyFailed": "Verification failed",
|
||||||
|
"backToLogin": "← Back to login"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"poweredBy": "Powered by BigBlueButton",
|
"heroTitle": "Self-hosted frontend for BigBlueButton",
|
||||||
"heroTitle": "Meetings re",
|
"heroSubtitle": "Redlight manages rooms, recordings and scheduling for your BigBlueButton server – with federation between instances, CalDAV sync and SSO support.",
|
||||||
"heroTitleHighlight": "defined",
|
"getStarted": "Create account",
|
||||||
"heroSubtitle": "The modern, self-hosted BigBlueButton frontend. Create rooms, manage recordings and enjoy a beautiful interface with over 15 themes.",
|
"features": "Features",
|
||||||
"getStarted": "Get started",
|
"featureRoomsTitle": "Room management",
|
||||||
"features": "Everything you need",
|
"featureRoomsDesc": "Rooms with individual settings, access codes and shared access for colleagues.",
|
||||||
"featuresSubtitle": "Redlight provides all the features you need for professional video conferencing.",
|
|
||||||
"featureVideoTitle": "Video Conferencing",
|
|
||||||
"featureVideoDesc": "Create and manage meetings directly via BigBlueButton.",
|
|
||||||
"featureRoomsTitle": "Room Management",
|
|
||||||
"featureRoomsDesc": "Unlimited rooms with individual settings and access codes.",
|
|
||||||
"featureUsersTitle": "User Management",
|
|
||||||
"featureUsersDesc": "Registration, login and role management for your organization.",
|
|
||||||
"featureThemesTitle": "15+ Themes",
|
|
||||||
"featureThemesDesc": "Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox and many more.",
|
|
||||||
"featureRecordingsTitle": "Recordings",
|
"featureRecordingsTitle": "Recordings",
|
||||||
"featureRecordingsDesc": "View, publish or delete all recordings per room.",
|
"featureRecordingsDesc": "View, publish or delete recordings per room.",
|
||||||
"featureOpenSourceTitle": "Open Source",
|
"featureFederationTitle": "Federation",
|
||||||
"featureOpenSourceDesc": "Fully open source and self-hosted. Your data stays with you.",
|
"featureFederationDesc": "Invite users from other Redlight instances and accept cross-instance invitations.",
|
||||||
"statThemes": "Themes",
|
"featureCalendarTitle": "Calendar",
|
||||||
"statRooms": "Rooms",
|
"featureCalendarDesc": "Schedule meetings, sync via CalDAV with Thunderbird, Apple Calendar or DAVx⁵, get reminders.",
|
||||||
"statOpenSource": "Open Source",
|
"featureVideoTitle": "Video conferencing",
|
||||||
|
"featureVideoDesc": "Create, start and manage meetings directly via BigBlueButton.",
|
||||||
|
"featureAnalyticsTitle": "Learning analytics",
|
||||||
|
"featureAnalyticsDesc": "Review attendance and activity data from meetings and export it as CSV, Excel or PDF.",
|
||||||
"footer": "© {year} Redlight. An open source BigBlueButton frontend."
|
"footer": "© {year} Redlight. An open source BigBlueButton frontend."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
@@ -136,7 +164,7 @@
|
|||||||
"accessCode": "Access code",
|
"accessCode": "Access code",
|
||||||
"muteOnJoin": "Mute participants on join",
|
"muteOnJoin": "Mute participants on join",
|
||||||
"allowRecording": "Allow recording",
|
"allowRecording": "Allow recording",
|
||||||
"roomCreated": "Room created!",
|
"roomCreated": "Room created",
|
||||||
"roomCreateFailed": "Room could not be created",
|
"roomCreateFailed": "Room could not be created",
|
||||||
"roomDeleted": "Room deleted",
|
"roomDeleted": "Room deleted",
|
||||||
"roomDeleteFailed": "Room could not be deleted",
|
"roomDeleteFailed": "Room could not be deleted",
|
||||||
@@ -158,7 +186,9 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"participants": "{count} participants",
|
"participants": "{count} participants",
|
||||||
"copyLink": "Copy link",
|
"copyLink": "Copy link",
|
||||||
"linkCopied": "Link copied!",
|
"copyRoomLink": "Room Link",
|
||||||
|
"copyGuestLink": "Guest Link",
|
||||||
|
"linkCopied": "Link copied",
|
||||||
"meetingDetails": "Meeting details",
|
"meetingDetails": "Meeting details",
|
||||||
"meetingId": "Meeting ID",
|
"meetingId": "Meeting ID",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@@ -179,11 +209,12 @@
|
|||||||
"anyoneCanStart": "Anyone can start the meeting",
|
"anyoneCanStart": "Anyone can start the meeting",
|
||||||
"allJoinModerator": "All participants as moderator",
|
"allJoinModerator": "All participants as moderator",
|
||||||
"allowRecording": "Allow recording",
|
"allowRecording": "Allow recording",
|
||||||
|
"enableTranscript": "AI summary / transcript for recordings",
|
||||||
"noAccessCode": "No access code",
|
"noAccessCode": "No access code",
|
||||||
"emptyNoCode": "Empty = no code",
|
"emptyNoCode": "Empty = no code",
|
||||||
"settingsSaved": "Settings saved",
|
"settingsSaved": "Settings saved",
|
||||||
"settingsSaveFailed": "Settings could not be saved",
|
"settingsSaveFailed": "Settings could not be saved",
|
||||||
"meetingStarted": "Meeting started!",
|
"meetingStarted": "Meeting started",
|
||||||
"meetingStartFailed": "Meeting could not be started",
|
"meetingStartFailed": "Meeting could not be started",
|
||||||
"meetingEnded": "Meeting ended",
|
"meetingEnded": "Meeting ended",
|
||||||
"meetingEndFailed": "Meeting could not be ended",
|
"meetingEndFailed": "Meeting could not be ended",
|
||||||
@@ -198,7 +229,7 @@
|
|||||||
"moderatorCodeHint": "Optional code for moderator rights",
|
"moderatorCodeHint": "Optional code for moderator rights",
|
||||||
"moderatorCodeDesc": "Guests who enter this code will receive moderator rights.",
|
"moderatorCodeDesc": "Guests who enter this code will receive moderator rights.",
|
||||||
"guestLink": "Guest Invite Link",
|
"guestLink": "Guest Invite Link",
|
||||||
"guestLinkCopied": "Guest link copied!",
|
"guestLinkCopied": "Guest link copied",
|
||||||
"guestJoinTitle": "Join Meeting",
|
"guestJoinTitle": "Join Meeting",
|
||||||
"guestCreatedBy": "Created by",
|
"guestCreatedBy": "Created by",
|
||||||
"guestMeetingRunning": "Meeting in progress",
|
"guestMeetingRunning": "Meeting in progress",
|
||||||
@@ -212,6 +243,11 @@
|
|||||||
"guestModeratorPlaceholder": "Only if you are a moderator",
|
"guestModeratorPlaceholder": "Only if you are a moderator",
|
||||||
"guestJoinButton": "Join meeting",
|
"guestJoinButton": "Join meeting",
|
||||||
"guestWaitingMessage": "The meeting has not started yet. Please wait for the moderator to start it.",
|
"guestWaitingMessage": "The meeting has not started yet. Please wait for the moderator to start it.",
|
||||||
|
"guestWaitingTitle": "Waiting for meeting to start...",
|
||||||
|
"guestWaitingHint": "You will be joined automatically as soon as the meeting starts.",
|
||||||
|
"guestCancelWaiting": "Cancel",
|
||||||
|
"guestMeetingStartedJoining": "Meeting started! Joining now...",
|
||||||
|
"waitingToJoin": "Waiting...",
|
||||||
"guestAccessDenied": "Access denied",
|
"guestAccessDenied": "Access denied",
|
||||||
"guestNameRequired": "Name is required",
|
"guestNameRequired": "Name is required",
|
||||||
"guestJoinFailed": "Join failed",
|
"guestJoinFailed": "Join failed",
|
||||||
@@ -240,7 +276,14 @@
|
|||||||
"shareRemoved": "Share removed",
|
"shareRemoved": "Share removed",
|
||||||
"shareFailed": "Share failed",
|
"shareFailed": "Share failed",
|
||||||
"shareRemove": "Remove share",
|
"shareRemove": "Remove share",
|
||||||
"defaultWelcome": "Welcome to the meeting!"
|
"defaultWelcome": "Welcome to the meeting!",
|
||||||
|
"analytics": "Learning Analytics",
|
||||||
|
"enableAnalytics": "Enable learning analytics",
|
||||||
|
"enableAnalyticsHint": "Collects participant engagement data after each meeting.",
|
||||||
|
"analyticsVisibility": "Who can see analytics?",
|
||||||
|
"analyticsOwnerOnly": "Room owner only",
|
||||||
|
"analyticsSharedUsers": "All shared users",
|
||||||
|
"analyticsVisibilityHint": "Controls who can view and export analytics data for this room."
|
||||||
},
|
},
|
||||||
"recordings": {
|
"recordings": {
|
||||||
"title": "Recordings",
|
"title": "Recordings",
|
||||||
@@ -258,6 +301,30 @@
|
|||||||
"publish": "Publish",
|
"publish": "Publish",
|
||||||
"loadFailed": "Recordings could not be loaded"
|
"loadFailed": "Recordings could not be loaded"
|
||||||
},
|
},
|
||||||
|
"analytics": {
|
||||||
|
"title": "Learning Analytics",
|
||||||
|
"noData": "No analytics data available",
|
||||||
|
"participants": "Participants",
|
||||||
|
"messages": "Messages",
|
||||||
|
"expand": "Show details",
|
||||||
|
"collapse": "Hide details",
|
||||||
|
"deleteConfirm": "Really delete analytics data?",
|
||||||
|
"deleted": "Analytics data deleted",
|
||||||
|
"deleteFailed": "Error deleting data",
|
||||||
|
"userName": "Name",
|
||||||
|
"role": "Role",
|
||||||
|
"moderator": "Moderator",
|
||||||
|
"viewer": "Viewer",
|
||||||
|
"talkTime": "Talk time",
|
||||||
|
"webcamTime": "Webcam time",
|
||||||
|
"duration": "Duration",
|
||||||
|
"meetingDuration": "Meeting duration",
|
||||||
|
"raiseHand": "Raise hand",
|
||||||
|
"reactions": "Reactions",
|
||||||
|
"export": "Download",
|
||||||
|
"exportSuccess": "Download started",
|
||||||
|
"exportFailed": "Error downloading data"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"subtitle": "Manage your profile and settings",
|
"subtitle": "Manage your profile and settings",
|
||||||
@@ -287,7 +354,49 @@
|
|||||||
"passwordChanged": "Password changed",
|
"passwordChanged": "Password changed",
|
||||||
"passwordChangeFailed": "Error changing password",
|
"passwordChangeFailed": "Error changing password",
|
||||||
"passwordMismatch": "Passwords do not match",
|
"passwordMismatch": "Passwords do not match",
|
||||||
"selectLanguage": "Select language"
|
"selectLanguage": "Select language",
|
||||||
|
"security": {
|
||||||
|
"title": "Security",
|
||||||
|
"subtitle": "Protect your account with two-factor authentication (2FA). After enabling, you will need both your password and a code from your authenticator app to sign in.",
|
||||||
|
"statusEnabled": "2FA is enabled",
|
||||||
|
"statusEnabledDesc": "Your account is protected with two-factor authentication.",
|
||||||
|
"statusDisabled": "2FA is not enabled",
|
||||||
|
"statusDisabledDesc": "Enable two-factor authentication for an extra layer of security.",
|
||||||
|
"enable": "Enable 2FA",
|
||||||
|
"disable": "Disable 2FA",
|
||||||
|
"enabled": "Two-factor authentication enabled",
|
||||||
|
"disabled": "Two-factor authentication disabled.",
|
||||||
|
"enableFailed": "Could not enable 2FA",
|
||||||
|
"disableFailed": "Could not disable 2FA",
|
||||||
|
"setupFailed": "Could not start 2FA setup",
|
||||||
|
"scanQR": "Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.).",
|
||||||
|
"manualKey": "Or enter this key manually:",
|
||||||
|
"verifyCode": "Enter the code from your app to verify",
|
||||||
|
"codeLabel": "6-digit code",
|
||||||
|
"disableConfirm": "Enter your password and a current 2FA code to disable two-factor authentication."
|
||||||
|
},
|
||||||
|
"caldav": {
|
||||||
|
"title": "CalDAV",
|
||||||
|
"subtitle": "Connect your calendar app (e.g. Apple Calendar, Thunderbird, DAVx⁵) using the CalDAV protocol. Use your email address and an app token as password.",
|
||||||
|
"serverUrl": "Server URL",
|
||||||
|
"username": "Username (Email)",
|
||||||
|
"hint": "Never enter your real Redlight password in a calendar app. Use an app token instead.",
|
||||||
|
"newToken": "Generate new app token",
|
||||||
|
"tokenNamePlaceholder": "e.g. \"iPhone\" or \"Thunderbird\"",
|
||||||
|
"generate": "Generate",
|
||||||
|
"existingTokens": "Active tokens",
|
||||||
|
"noTokens": "No tokens created yet.",
|
||||||
|
"created": "Created",
|
||||||
|
"lastUsed": "Last used",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"revokeConfirm": "Really revoke this token? All connected calendar apps using this token will lose access.",
|
||||||
|
"revoked": "Token revoked",
|
||||||
|
"revokeFailed": "Could not revoke token",
|
||||||
|
"createFailed": "Could not create token",
|
||||||
|
"newTokenCreated": "Token created — copy it now",
|
||||||
|
"newTokenHint": "This token will only be shown once. Copy it and enter it as the password in your calendar app.",
|
||||||
|
"dismiss": "I have copied the token"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"themes": {
|
"themes": {
|
||||||
"selectTheme": "Select theme",
|
"selectTheme": "Select theme",
|
||||||
@@ -336,6 +445,9 @@
|
|||||||
"appNameLabel": "App name",
|
"appNameLabel": "App name",
|
||||||
"appNameUpdated": "App name updated",
|
"appNameUpdated": "App name updated",
|
||||||
"appNameUpdateFailed": "Could not update app name",
|
"appNameUpdateFailed": "Could not update app name",
|
||||||
|
"hideAppNameLabel": "Hide app name",
|
||||||
|
"hideAppNameHint": "Only show the logo, hide the app name text next to it.",
|
||||||
|
"hideAppNameFailed": "Could not update setting",
|
||||||
"defaultThemeLabel": "Default Theme",
|
"defaultThemeLabel": "Default Theme",
|
||||||
"defaultThemeDesc": "Applied to unauthenticated pages (guest join, login, home) when no personal preference is set.",
|
"defaultThemeDesc": "Applied to unauthenticated pages (guest join, login, home) when no personal preference is set.",
|
||||||
"defaultThemeSaved": "Default theme saved",
|
"defaultThemeSaved": "Default theme saved",
|
||||||
@@ -349,11 +461,11 @@
|
|||||||
"inviteTitle": "User Invitations",
|
"inviteTitle": "User Invitations",
|
||||||
"inviteDescription": "Invite new users by email. They will receive a registration link valid for 7 days.",
|
"inviteDescription": "Invite new users by email. They will receive a registration link valid for 7 days.",
|
||||||
"sendInvite": "Send invite",
|
"sendInvite": "Send invite",
|
||||||
"inviteSent": "Invitation sent!",
|
"inviteSent": "Invitation sent",
|
||||||
"inviteFailed": "Could not send invitation",
|
"inviteFailed": "Could not send invitation",
|
||||||
"inviteDeleted": "Invitation deleted",
|
"inviteDeleted": "Invitation deleted",
|
||||||
"inviteDeleteFailed": "Could not delete invitation",
|
"inviteDeleteFailed": "Could not delete invitation",
|
||||||
"inviteLinkCopied": "Invite link copied!",
|
"inviteLinkCopied": "Invite link copied",
|
||||||
"copyInviteLink": "Copy invite link",
|
"copyInviteLink": "Copy invite link",
|
||||||
"inviteExpired": "Expired",
|
"inviteExpired": "Expired",
|
||||||
"inviteUsedBy": "Used by",
|
"inviteUsedBy": "Used by",
|
||||||
@@ -366,7 +478,40 @@
|
|||||||
"imprintUrlSaved": "Imprint URL saved",
|
"imprintUrlSaved": "Imprint URL saved",
|
||||||
"privacyUrlSaved": "Privacy Policy URL saved",
|
"privacyUrlSaved": "Privacy Policy URL saved",
|
||||||
"imprintUrlFailed": "Could not save Imprint URL",
|
"imprintUrlFailed": "Could not save Imprint URL",
|
||||||
"privacyUrlFailed": "Could not save Privacy Policy URL"
|
"privacyUrlFailed": "Could not save Privacy Policy URL",
|
||||||
|
"oauthTitle": "OAuth / SSO",
|
||||||
|
"oauthDescription": "Connect an OpenID Connect provider (e.g. Keycloak, Authentik, Google) to allow Single Sign-On.",
|
||||||
|
"oauthIssuer": "Issuer URL",
|
||||||
|
"oauthIssuerHint": "The OIDC issuer URL, e.g. https://auth.example.com/realms/main",
|
||||||
|
"oauthClientId": "Client ID",
|
||||||
|
"oauthClientSecret": "Client Secret",
|
||||||
|
"oauthClientSecretHint": "Leave blank to keep the existing secret",
|
||||||
|
"oauthDisplayName": "Button label",
|
||||||
|
"oauthDisplayNameHint": "Shown on the login page, e.g. \"Company SSO\"",
|
||||||
|
"oauthAutoRegister": "Auto-register new users",
|
||||||
|
"oauthAutoRegisterHint": "Automatically create accounts for users signing in via OAuth for the first time.",
|
||||||
|
"oauthSaved": "OAuth configuration saved",
|
||||||
|
"oauthSaveFailed": "Could not save OAuth configuration",
|
||||||
|
"oauthRemoved": "OAuth configuration removed",
|
||||||
|
"oauthRemoveFailed": "Could not remove OAuth configuration",
|
||||||
|
"oauthRemoveConfirm": "Really remove OAuth configuration? Users will no longer be able to sign in with SSO.",
|
||||||
|
"oauthNotConfigured": "OAuth is not configured yet.",
|
||||||
|
"oauthSave": "Save OAuth",
|
||||||
|
"oauthRemove": "Remove OAuth",
|
||||||
|
"roomsTitle": "Room Management",
|
||||||
|
"roomsDescription": "View, manage, and delete all rooms on this instance.",
|
||||||
|
"searchRooms": "Search rooms...",
|
||||||
|
"roomName": "Name",
|
||||||
|
"roomOwner": "Owner",
|
||||||
|
"roomShares": "Shared",
|
||||||
|
"roomCreated": "Created",
|
||||||
|
"roomView": "View room",
|
||||||
|
"deleteRoom": "Delete room",
|
||||||
|
"deleteRoomConfirm": "Really delete room \"{name}\"? This cannot be undone.",
|
||||||
|
"roomDeleted": "Room deleted",
|
||||||
|
"roomDeleteFailed": "Room could not be deleted",
|
||||||
|
"noRoomsFound": "No rooms found",
|
||||||
|
"showAllRooms": "Show all {count} rooms"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"bell": "Notifications",
|
"bell": "Notifications",
|
||||||
@@ -382,14 +527,19 @@
|
|||||||
"inbox": "Invitations",
|
"inbox": "Invitations",
|
||||||
"inboxSubtitle": "Meeting invitations from other Redlight instances",
|
"inboxSubtitle": "Meeting invitations from other Redlight instances",
|
||||||
"inviteTitle": "Invite Remote User",
|
"inviteTitle": "Invite Remote User",
|
||||||
"inviteSubtitle": "Invite a user from another Redlight instance to this meeting.",
|
"inviteSubtitle": "You can either invite a user from another Redlight instance by their address, or send an email invitation directly. Only one option can be used at a time.",
|
||||||
"addressLabel": "User address",
|
"addressLabel": "User address",
|
||||||
"addressPlaceholder": "@user@other-instance.com",
|
"addressPlaceholder": "@user@other-instance.com",
|
||||||
"addressHint": "Format: @username@domain of the Redlight instance",
|
"addressHint": "Format: @username@domain of the Redlight instance",
|
||||||
|
"emailLabel": "Invite by email",
|
||||||
|
"emailPlaceholder": "name@example.com, name2@example.com",
|
||||||
|
"emailHint": "Enter one or more email addresses, separated by commas",
|
||||||
"messageLabel": "Message (optional)",
|
"messageLabel": "Message (optional)",
|
||||||
"messagePlaceholder": "Hi, I'd like to invite you to our meeting!",
|
"messagePlaceholder": "Hi, I'd like to invite you to our meeting!",
|
||||||
"send": "Send invitation",
|
"send": "Send invitation",
|
||||||
"sent": "Invitation sent!",
|
"sent": "Invitation sent",
|
||||||
|
"emailSent": "Email invitation(s) sent",
|
||||||
|
"emailSendFailed": "Could not send email invitation",
|
||||||
"sendFailed": "Could not send invitation",
|
"sendFailed": "Could not send invitation",
|
||||||
"from": "From",
|
"from": "From",
|
||||||
"accept": "Accept",
|
"accept": "Accept",
|
||||||
@@ -414,7 +564,7 @@
|
|||||||
"removeRoomConfirm": "Really remove this room?",
|
"removeRoomConfirm": "Really remove this room?",
|
||||||
"roomRemoved": "Room removed",
|
"roomRemoved": "Room removed",
|
||||||
"roomRemoveFailed": "Could not remove room",
|
"roomRemoveFailed": "Could not remove room",
|
||||||
"acceptedSaved": "Invitation accepted - room saved to your dashboard!",
|
"acceptedSaved": "Invitation accepted - room saved to your dashboard",
|
||||||
"meetingId": "Meeting ID",
|
"meetingId": "Meeting ID",
|
||||||
"maxParticipants": "Max. participants",
|
"maxParticipants": "Max. participants",
|
||||||
"recordingOn": "Recording enabled",
|
"recordingOn": "Recording enabled",
|
||||||
@@ -430,9 +580,9 @@
|
|||||||
"roomDeleted": "Deleted",
|
"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",
|
"calendarEvent": "Calendar Invitation",
|
||||||
"calendarAccepted": "Calendar event accepted and added to your calendar!",
|
"calendarAccepted": "Calendar event accepted and added to your calendar",
|
||||||
"localCalendarEvent": "Local Calendar Invitation",
|
"localCalendarEvent": "Local Calendar Invitation",
|
||||||
"calendarLocalAccepted": "Invitation accepted - event added to your calendar!",
|
"calendarLocalAccepted": "Invitation accepted - event added to your calendar",
|
||||||
"invitationRemoved": "Invitation removed",
|
"invitationRemoved": "Invitation removed",
|
||||||
"removeInvitation": "Remove invitation"
|
"removeInvitation": "Remove invitation"
|
||||||
},
|
},
|
||||||
@@ -451,9 +601,18 @@
|
|||||||
"linkedRoom": "Linked Room",
|
"linkedRoom": "Linked Room",
|
||||||
"noRoom": "No room (no video meeting)",
|
"noRoom": "No room (no video meeting)",
|
||||||
"linkedRoomHint": "Link a room to automatically include the join-URL in the event.",
|
"linkedRoomHint": "Link a room to automatically include the join-URL in the event.",
|
||||||
|
"reminderLabel": "Reminder",
|
||||||
|
"reminderNone": "No reminder",
|
||||||
|
"reminder5": "5 minutes before",
|
||||||
|
"reminder15": "15 minutes before",
|
||||||
|
"reminder30": "30 minutes before",
|
||||||
|
"reminder60": "1 hour before",
|
||||||
|
"reminder120": "2 hours before",
|
||||||
|
"reminder1440": "1 day before",
|
||||||
|
"timezone": "Timezone",
|
||||||
"color": "Color",
|
"color": "Color",
|
||||||
"eventCreated": "Event created!",
|
"eventCreated": "Event created",
|
||||||
"eventUpdated": "Event updated!",
|
"eventUpdated": "Event updated",
|
||||||
"eventDeleted": "Event deleted",
|
"eventDeleted": "Event deleted",
|
||||||
"saveFailed": "Could not save event",
|
"saveFailed": "Could not save event",
|
||||||
"deleteFailed": "Could not delete event",
|
"deleteFailed": "Could not delete event",
|
||||||
@@ -480,7 +639,7 @@
|
|||||||
"shareAdded": "User added to event",
|
"shareAdded": "User added to event",
|
||||||
"shareRemoved": "Share removed",
|
"shareRemoved": "Share removed",
|
||||||
"shareFailed": "Could not share event",
|
"shareFailed": "Could not share event",
|
||||||
"invitationSent": "Invitation sent!",
|
"invitationSent": "Invitation sent",
|
||||||
"invitationCancelled": "Invitation cancelled",
|
"invitationCancelled": "Invitation cancelled",
|
||||||
"invitationPending": "Invitation pending",
|
"invitationPending": "Invitation pending",
|
||||||
"pendingInvitations": "Pending Invitations",
|
"pendingInvitations": "Pending Invitations",
|
||||||
@@ -532,5 +691,11 @@
|
|||||||
"note": "The event has been automatically removed from your calendar.",
|
"note": "The event has been automatically removed from your calendar.",
|
||||||
"footer": "This message was sent automatically by {appName}."
|
"footer": "This message was sent automatically by {appName}."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"notFound": {
|
||||||
|
"title": "Page not found",
|
||||||
|
"description": "The page you are looking for doesn't exist or has been moved.",
|
||||||
|
"goBack": "Go back",
|
||||||
|
"goHome": "Back to home"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+578
-63
@@ -1,11 +1,139 @@
|
|||||||
@tailwind base;
|
@import 'tailwindcss';
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
@theme {
|
||||||
|
--color-th-bg: var(--bg-primary);
|
||||||
|
--color-th-bg-s: var(--bg-secondary);
|
||||||
|
--color-th-bg-t: var(--bg-tertiary);
|
||||||
|
--color-th-text: var(--text-primary);
|
||||||
|
--color-th-text-s: var(--text-secondary);
|
||||||
|
--color-th-accent: var(--accent);
|
||||||
|
--color-th-accent-h: var(--accent-hover);
|
||||||
|
--color-th-accent-t: var(--accent-text);
|
||||||
|
--color-th-border: var(--border);
|
||||||
|
--color-th-card: var(--card-bg);
|
||||||
|
--color-th-input: var(--input-bg);
|
||||||
|
--color-th-input-b: var(--input-border);
|
||||||
|
--color-th-nav: var(--nav-bg);
|
||||||
|
--color-th-side: var(--sidebar-bg);
|
||||||
|
--color-th-hover: var(--hover-bg);
|
||||||
|
--color-th-success: var(--success);
|
||||||
|
--color-th-warning: var(--warning);
|
||||||
|
--color-th-error: var(--error);
|
||||||
|
--color-th-ring: var(--ring);
|
||||||
|
|
||||||
|
--font-sans: Inter, system-ui, -apple-system, sans-serif;
|
||||||
|
|
||||||
|
--shadow-th:
|
||||||
|
0 1px 3px 0 var(--shadow-color), 0 1px 2px -1px var(--shadow-color);
|
||||||
|
--shadow-th-lg:
|
||||||
|
0 10px 15px -3px var(--shadow-color), 0 4px 6px -4px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
|
color utility to any element that depends on these defaults.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Restore Tailwind v3 default: buttons get a pointer cursor */
|
||||||
|
button:not(:disabled),
|
||||||
|
[role="button"]:not([aria-disabled="true"]) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility btn-primary {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
|
||||||
|
bg-th-accent text-th-accent-t hover:bg-th-accent-h
|
||||||
|
transition-all duration-200 ease-out
|
||||||
|
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2
|
||||||
|
cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
--tw-ring-offset-color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility btn-secondary {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
|
||||||
|
bg-th-bg-s text-th-text border border-th-border
|
||||||
|
hover:bg-th-hover transition-all duration-200 ease-out
|
||||||
|
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2
|
||||||
|
cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
--tw-ring-offset-color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility btn-danger {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
|
||||||
|
bg-th-error text-white hover:opacity-90
|
||||||
|
transition-all duration-200 ease-out
|
||||||
|
focus:outline-hidden focus:ring-2 focus:ring-th-error focus:ring-offset-2
|
||||||
|
cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
--tw-ring-offset-color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility btn-ghost {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
|
||||||
|
text-th-text-s hover:bg-th-hover hover:text-th-text
|
||||||
|
transition-all duration-200 ease-out
|
||||||
|
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2
|
||||||
|
cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
--tw-ring-offset-color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility input-field {
|
||||||
|
@apply w-full px-4 py-2.5 rounded-lg
|
||||||
|
bg-th-input text-th-text placeholder-th-text-s
|
||||||
|
border border-th-input-b
|
||||||
|
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:border-transparent
|
||||||
|
transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility card {
|
||||||
|
@apply bg-th-card rounded-xl border border-th-border
|
||||||
|
shadow-th transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility card-hover {
|
||||||
|
@apply card hover:shadow-th-lg cursor-pointer;
|
||||||
|
&:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility gradient-text {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--gradient-start),
|
||||||
|
var(--gradient-end)
|
||||||
|
);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility gradient-bg {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--gradient-start),
|
||||||
|
var(--gradient-end)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
/* ===== DEFAULT LIGHT ===== */
|
/* ===== DEFAULT LIGHT ===== */
|
||||||
:root,
|
:root,
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--picker-icon-filter: none;
|
||||||
--bg-primary: #ffffff;
|
--bg-primary: #ffffff;
|
||||||
--bg-secondary: #f8fafc;
|
--bg-secondary: #f8fafc;
|
||||||
--bg-tertiary: #f1f5f9;
|
--bg-tertiary: #f1f5f9;
|
||||||
@@ -32,6 +160,8 @@
|
|||||||
|
|
||||||
/* ===== DEFAULT DARK ===== */
|
/* ===== DEFAULT DARK ===== */
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--picker-icon-filter: invert(0.8);
|
||||||
--bg-primary: #0f172a;
|
--bg-primary: #0f172a;
|
||||||
--bg-secondary: #1e293b;
|
--bg-secondary: #1e293b;
|
||||||
--bg-tertiary: #334155;
|
--bg-tertiary: #334155;
|
||||||
@@ -58,6 +188,8 @@
|
|||||||
|
|
||||||
/* ===== DRACULA ===== */
|
/* ===== DRACULA ===== */
|
||||||
[data-theme="dracula"] {
|
[data-theme="dracula"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--picker-icon-filter: invert(0.8);
|
||||||
--bg-primary: #282a36;
|
--bg-primary: #282a36;
|
||||||
--bg-secondary: #44475a;
|
--bg-secondary: #44475a;
|
||||||
--bg-tertiary: #383a4c;
|
--bg-tertiary: #383a4c;
|
||||||
@@ -84,6 +216,8 @@
|
|||||||
|
|
||||||
/* ===== CATPPUCCIN MOCHA ===== */
|
/* ===== CATPPUCCIN MOCHA ===== */
|
||||||
[data-theme="mocha"] {
|
[data-theme="mocha"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--picker-icon-filter: invert(0.8);
|
||||||
--bg-primary: #1e1e2e;
|
--bg-primary: #1e1e2e;
|
||||||
--bg-secondary: #313244;
|
--bg-secondary: #313244;
|
||||||
--bg-tertiary: #45475a;
|
--bg-tertiary: #45475a;
|
||||||
@@ -110,6 +244,8 @@
|
|||||||
|
|
||||||
/* ===== CATPPUCCIN LATTE (Light) ===== */
|
/* ===== CATPPUCCIN LATTE (Light) ===== */
|
||||||
[data-theme="latte"] {
|
[data-theme="latte"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--picker-icon-filter: none;
|
||||||
--bg-primary: #eff1f5;
|
--bg-primary: #eff1f5;
|
||||||
--bg-secondary: #e6e9ef;
|
--bg-secondary: #e6e9ef;
|
||||||
--bg-tertiary: #dce0e8;
|
--bg-tertiary: #dce0e8;
|
||||||
@@ -136,6 +272,8 @@
|
|||||||
|
|
||||||
/* ===== NORD ===== */
|
/* ===== NORD ===== */
|
||||||
[data-theme="nord"] {
|
[data-theme="nord"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--picker-icon-filter: invert(0.8);
|
||||||
--bg-primary: #2e3440;
|
--bg-primary: #2e3440;
|
||||||
--bg-secondary: #3b4252;
|
--bg-secondary: #3b4252;
|
||||||
--bg-tertiary: #434c5e;
|
--bg-tertiary: #434c5e;
|
||||||
@@ -162,6 +300,8 @@
|
|||||||
|
|
||||||
/* ===== TOKYO NIGHT ===== */
|
/* ===== TOKYO NIGHT ===== */
|
||||||
[data-theme="tokyo-night"] {
|
[data-theme="tokyo-night"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--picker-icon-filter: invert(0.8);
|
||||||
--bg-primary: #1a1b26;
|
--bg-primary: #1a1b26;
|
||||||
--bg-secondary: #24283b;
|
--bg-secondary: #24283b;
|
||||||
--bg-tertiary: #2f3349;
|
--bg-tertiary: #2f3349;
|
||||||
@@ -188,6 +328,8 @@
|
|||||||
|
|
||||||
/* ===== GRUVBOX DARK ===== */
|
/* ===== GRUVBOX DARK ===== */
|
||||||
[data-theme="gruvbox-dark"] {
|
[data-theme="gruvbox-dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--picker-icon-filter: invert(0.8);
|
||||||
--bg-primary: #282828;
|
--bg-primary: #282828;
|
||||||
--bg-secondary: #3c3836;
|
--bg-secondary: #3c3836;
|
||||||
--bg-tertiary: #504945;
|
--bg-tertiary: #504945;
|
||||||
@@ -214,6 +356,8 @@
|
|||||||
|
|
||||||
/* ===== GRUVBOX LIGHT ===== */
|
/* ===== GRUVBOX LIGHT ===== */
|
||||||
[data-theme="gruvbox-light"] {
|
[data-theme="gruvbox-light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--picker-icon-filter: none;
|
||||||
--bg-primary: #fbf1c7;
|
--bg-primary: #fbf1c7;
|
||||||
--bg-secondary: #ebdbb2;
|
--bg-secondary: #ebdbb2;
|
||||||
--bg-tertiary: #d5c4a1;
|
--bg-tertiary: #d5c4a1;
|
||||||
@@ -240,6 +384,8 @@
|
|||||||
|
|
||||||
/* ===== ROSE PINE ===== */
|
/* ===== ROSE PINE ===== */
|
||||||
[data-theme="rose-pine"] {
|
[data-theme="rose-pine"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--picker-icon-filter: invert(0.8);
|
||||||
--bg-primary: #191724;
|
--bg-primary: #191724;
|
||||||
--bg-secondary: #1f1d2e;
|
--bg-secondary: #1f1d2e;
|
||||||
--bg-tertiary: #26233a;
|
--bg-tertiary: #26233a;
|
||||||
@@ -266,6 +412,8 @@
|
|||||||
|
|
||||||
/* ===== ROSE PINE DAWN (Light) ===== */
|
/* ===== ROSE PINE DAWN (Light) ===== */
|
||||||
[data-theme="rose-pine-dawn"] {
|
[data-theme="rose-pine-dawn"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--picker-icon-filter: none;
|
||||||
--bg-primary: #faf4ed;
|
--bg-primary: #faf4ed;
|
||||||
--bg-secondary: #fffaf3;
|
--bg-secondary: #fffaf3;
|
||||||
--bg-tertiary: #f2e9e1;
|
--bg-tertiary: #f2e9e1;
|
||||||
@@ -292,6 +440,8 @@
|
|||||||
|
|
||||||
/* ===== SOLARIZED DARK ===== */
|
/* ===== SOLARIZED DARK ===== */
|
||||||
[data-theme="solarized-dark"] {
|
[data-theme="solarized-dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--picker-icon-filter: invert(0.8);
|
||||||
--bg-primary: #002b36;
|
--bg-primary: #002b36;
|
||||||
--bg-secondary: #073642;
|
--bg-secondary: #073642;
|
||||||
--bg-tertiary: #0a4050;
|
--bg-tertiary: #0a4050;
|
||||||
@@ -318,6 +468,8 @@
|
|||||||
|
|
||||||
/* ===== SOLARIZED LIGHT ===== */
|
/* ===== SOLARIZED LIGHT ===== */
|
||||||
[data-theme="solarized-light"] {
|
[data-theme="solarized-light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--picker-icon-filter: none;
|
||||||
--bg-primary: #fdf6e3;
|
--bg-primary: #fdf6e3;
|
||||||
--bg-secondary: #eee8d5;
|
--bg-secondary: #eee8d5;
|
||||||
--bg-tertiary: #e4ddc8;
|
--bg-tertiary: #e4ddc8;
|
||||||
@@ -344,6 +496,8 @@
|
|||||||
|
|
||||||
/* ===== ONE DARK ===== */
|
/* ===== ONE DARK ===== */
|
||||||
[data-theme="one-dark"] {
|
[data-theme="one-dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--picker-icon-filter: invert(0.8);
|
||||||
--bg-primary: #282c34;
|
--bg-primary: #282c34;
|
||||||
--bg-secondary: #2c313a;
|
--bg-secondary: #2c313a;
|
||||||
--bg-tertiary: #353b45;
|
--bg-tertiary: #353b45;
|
||||||
@@ -370,6 +524,8 @@
|
|||||||
|
|
||||||
/* ===== GITHUB DARK ===== */
|
/* ===== GITHUB DARK ===== */
|
||||||
[data-theme="github-dark"] {
|
[data-theme="github-dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--picker-icon-filter: invert(0.8);
|
||||||
--bg-primary: #0d1117;
|
--bg-primary: #0d1117;
|
||||||
--bg-secondary: #161b22;
|
--bg-secondary: #161b22;
|
||||||
--bg-tertiary: #21262d;
|
--bg-tertiary: #21262d;
|
||||||
@@ -414,8 +570,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
/* ===== SCRUNKLY.CAT DARK ===== */
|
/* ===== SCRUNKLY.CAT DARK ===== */
|
||||||
[data-theme="scrunkly-cat"] {
|
[data-theme='scrunkly-cat'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--picker-icon-filter: invert(0.8);
|
||||||
--bg-primary: #161924;
|
--bg-primary: #161924;
|
||||||
--bg-secondary: #161924;
|
--bg-secondary: #161924;
|
||||||
--bg-tertiary: #1b2130;
|
--bg-tertiary: #1b2130;
|
||||||
@@ -440,8 +599,10 @@
|
|||||||
--gradient-end: #d6336a;
|
--gradient-end: #d6336a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== RED MODULAR LIGHT ===== */
|
/* ===== RED MODULAR LIGHT ===== */
|
||||||
[data-theme="red-modular-light"] {
|
[data-theme='red-modular-light'] {
|
||||||
|
color-scheme: light;
|
||||||
|
--picker-icon-filter: none;
|
||||||
--bg-primary: #ffffff;
|
--bg-primary: #ffffff;
|
||||||
--bg-secondary: #f8fafc;
|
--bg-secondary: #f8fafc;
|
||||||
--bg-tertiary: #f1f5f9;
|
--bg-tertiary: #f1f5f9;
|
||||||
@@ -460,80 +621,434 @@
|
|||||||
--success: #86b300;
|
--success: #86b300;
|
||||||
--warning: #ecb637;
|
--warning: #ecb637;
|
||||||
--error: #ec4137;
|
--error: #ec4137;
|
||||||
--ring: #b30051;
|
--ring: #e60000;
|
||||||
--shadow-color: rgba(0, 0, 0, 0.3);
|
--shadow-color: rgba(0, 0, 0, 0.3);
|
||||||
--gradient-start: #b30051;
|
--gradient-start: #e60000;
|
||||||
--gradient-end: #d6336a;
|
--gradient-end: #ff3333;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== EVERFOREST DARK ===== */
|
||||||
@layer components {
|
[data-theme='everforest-dark'] {
|
||||||
.btn-primary {
|
color-scheme: dark;
|
||||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
|
--picker-icon-filter: invert(0.8);
|
||||||
bg-th-accent text-th-accent-t hover:bg-th-accent-h
|
--bg-primary: #2d353b;
|
||||||
transition-all duration-200 ease-out
|
--bg-secondary: #343f44;
|
||||||
focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-2
|
--bg-tertiary: #3d484d;
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
--text-primary: #d3c6aa;
|
||||||
--tw-ring-offset-color: var(--bg-primary);
|
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
/* ===== EVERFOREST LIGHT ===== */
|
||||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
|
[data-theme='everforest-light'] {
|
||||||
bg-th-bg-s text-th-text border border-th-border
|
color-scheme: light;
|
||||||
hover:bg-th-hover transition-all duration-200 ease-out
|
--picker-icon-filter: none;
|
||||||
focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-2
|
--bg-primary: #fdf6e3;
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
--bg-secondary: #f4f0d9;
|
||||||
--tw-ring-offset-color: var(--bg-primary);
|
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
/* ===== KANAGAWA ===== */
|
||||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
|
[data-theme='kanagawa'] {
|
||||||
bg-th-error text-white hover:opacity-90
|
color-scheme: dark;
|
||||||
transition-all duration-200 ease-out
|
--picker-icon-filter: invert(0.8);
|
||||||
focus:outline-none focus:ring-2 focus:ring-th-error focus:ring-offset-2
|
--bg-primary: #1f1f28;
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
--bg-secondary: #2a2a37;
|
||||||
--tw-ring-offset-color: var(--bg-primary);
|
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
/* ===== AYU DARK ===== */
|
||||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
|
[data-theme='ayu-dark'] {
|
||||||
text-th-text-s hover:bg-th-hover hover:text-th-text
|
color-scheme: dark;
|
||||||
transition-all duration-200 ease-out
|
--picker-icon-filter: invert(0.8);
|
||||||
focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-2
|
--bg-primary: #0d1017;
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
--bg-secondary: #131721;
|
||||||
--tw-ring-offset-color: var(--bg-primary);
|
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-field {
|
/* ===== MOONLIGHT ===== */
|
||||||
@apply w-full px-4 py-2.5 rounded-lg
|
[data-theme='moonlight'] {
|
||||||
bg-th-input text-th-text placeholder-th-text-s
|
color-scheme: dark;
|
||||||
border border-th-input-b
|
--picker-icon-filter: invert(0.8);
|
||||||
focus:outline-none focus:ring-2 focus:ring-th-ring focus:border-transparent
|
--bg-primary: #212337;
|
||||||
transition-all duration-200;
|
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
/* ===== CYBERPUNK ===== */
|
||||||
@apply bg-th-card rounded-xl border border-th-border
|
[data-theme='cyberpunk'] {
|
||||||
shadow-th transition-all duration-200;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-hover {
|
/* ===== COTTON CANDY LIGHT ===== */
|
||||||
@apply card hover:shadow-th-lg cursor-pointer;
|
[data-theme='cotton-candy-light'] {
|
||||||
}
|
color-scheme: light;
|
||||||
.card-hover:hover {
|
--picker-icon-filter: none;
|
||||||
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
|
--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 utilities {
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
.gradient-text {
|
FLATPICKR THEMED OVERRIDES — fully driven by CSS variables
|
||||||
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
|
═══════════════════════════════════════════════════════════════ */
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gradient-bg {
|
/* Calendar container — appended to body */
|
||||||
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
|||||||
<App />
|
<App />
|
||||||
<Toaster
|
<Toaster
|
||||||
position="top-right"
|
position="top-right"
|
||||||
|
containerStyle={{ top: 70 }}
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
style: {
|
style: {
|
||||||
|
|||||||
+517
-38
@@ -4,7 +4,7 @@ import {
|
|||||||
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
||||||
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
||||||
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
|
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
|
||||||
ShieldCheck, Globe, Link as LinkIcon,
|
ShieldCheck, Globe, Link as LinkIcon, LogIn, DoorOpen, Eye, ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
@@ -16,7 +16,7 @@ import toast from 'react-hot-toast';
|
|||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { t, language } = useLanguage();
|
const { t, language } = useLanguage();
|
||||||
const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, imprintUrl, privacyUrl, refreshBranding } = useBranding();
|
const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, imprintUrl, privacyUrl, hideAppName, refreshBranding } = useBranding();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -47,6 +47,21 @@ export default function Admin() {
|
|||||||
const [savingImprintUrl, setSavingImprintUrl] = useState(false);
|
const [savingImprintUrl, setSavingImprintUrl] = useState(false);
|
||||||
const [editPrivacyUrl, setEditPrivacyUrl] = useState('');
|
const [editPrivacyUrl, setEditPrivacyUrl] = useState('');
|
||||||
const [savingPrivacyUrl, setSavingPrivacyUrl] = useState(false);
|
const [savingPrivacyUrl, setSavingPrivacyUrl] = useState(false);
|
||||||
|
const [savingHideAppName, setSavingHideAppName] = useState(false);
|
||||||
|
|
||||||
|
// OAuth state
|
||||||
|
const [oauthConfig, setOauthConfig] = useState(null);
|
||||||
|
const [oauthLoading, setOauthLoading] = useState(true);
|
||||||
|
const [oauthForm, setOauthForm] = useState({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true });
|
||||||
|
const [savingOauth, setSavingOauth] = useState(false);
|
||||||
|
|
||||||
|
// Rooms state
|
||||||
|
const [adminRooms, setAdminRooms] = useState([]);
|
||||||
|
const [adminRoomsLoading, setAdminRoomsLoading] = useState(true);
|
||||||
|
const [roomSearch, setRoomSearch] = useState('');
|
||||||
|
const [roomsExpanded, setRoomsExpanded] = useState(false);
|
||||||
|
const [showAllRoomsModal, setShowAllRoomsModal] = useState(false);
|
||||||
|
const [allRoomsSearch, setAllRoomsSearch] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.role !== 'admin') {
|
if (user?.role !== 'admin') {
|
||||||
@@ -55,6 +70,8 @@ export default function Admin() {
|
|||||||
}
|
}
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
fetchInvites();
|
fetchInvites();
|
||||||
|
fetchOauthConfig();
|
||||||
|
fetchAdminRooms();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -93,6 +110,29 @@ export default function Admin() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchAdminRooms = async () => {
|
||||||
|
setAdminRoomsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get('/admin/rooms');
|
||||||
|
setAdminRooms(res.data.rooms);
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setAdminRoomsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdminDeleteRoom = async (uid, name) => {
|
||||||
|
if (!confirm(t('admin.deleteRoomConfirm', { name }))) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/rooms/${uid}`);
|
||||||
|
toast.success(t('admin.roomDeleted'));
|
||||||
|
fetchAdminRooms();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('admin.roomDeleteFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRoleChange = async (userId, newRole) => {
|
const handleRoleChange = async (userId, newRole) => {
|
||||||
try {
|
try {
|
||||||
await api.put(`/admin/users/${userId}/role`, { role: newRole });
|
await api.put(`/admin/users/${userId}/role`, { role: newRole });
|
||||||
@@ -161,6 +201,18 @@ export default function Admin() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleHideAppNameToggle = async (value) => {
|
||||||
|
setSavingHideAppName(true);
|
||||||
|
try {
|
||||||
|
await api.put('/branding/hide-app-name', { hideAppName: value });
|
||||||
|
refreshBranding();
|
||||||
|
} catch {
|
||||||
|
toast.error(t('admin.hideAppNameFailed'));
|
||||||
|
} finally {
|
||||||
|
setSavingHideAppName(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAppNameSave = async () => {
|
const handleAppNameSave = async () => {
|
||||||
if (!editAppName.trim()) return;
|
if (!editAppName.trim()) return;
|
||||||
setSavingName(true);
|
setSavingName(true);
|
||||||
@@ -275,6 +327,58 @@ export default function Admin() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── OAuth handlers ──────────────────────────────────────────────────────
|
||||||
|
const fetchOauthConfig = async () => {
|
||||||
|
setOauthLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get('/admin/oauth');
|
||||||
|
if (res.data.configured) {
|
||||||
|
setOauthConfig(res.data.config);
|
||||||
|
setOauthForm({
|
||||||
|
issuer: res.data.config.issuer || '',
|
||||||
|
clientId: res.data.config.clientId || '',
|
||||||
|
clientSecret: '',
|
||||||
|
displayName: res.data.config.displayName || 'SSO',
|
||||||
|
autoRegister: res.data.config.autoRegister ?? true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setOauthConfig(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setOauthLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOauthSave = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSavingOauth(true);
|
||||||
|
try {
|
||||||
|
await api.put('/admin/oauth', oauthForm);
|
||||||
|
toast.success(t('admin.oauthSaved'));
|
||||||
|
fetchOauthConfig();
|
||||||
|
refreshBranding();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('admin.oauthSaveFailed'));
|
||||||
|
} finally {
|
||||||
|
setSavingOauth(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOauthRemove = async () => {
|
||||||
|
if (!confirm(t('admin.oauthRemoveConfirm'))) return;
|
||||||
|
try {
|
||||||
|
await api.delete('/admin/oauth');
|
||||||
|
toast.success(t('admin.oauthRemoved'));
|
||||||
|
setOauthConfig(null);
|
||||||
|
setOauthForm({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true });
|
||||||
|
refreshBranding();
|
||||||
|
} catch {
|
||||||
|
toast.error(t('admin.oauthRemoveFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredUsers = users.filter(u =>
|
const filteredUsers = users.filter(u =>
|
||||||
(u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ||
|
(u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ||
|
||||||
u.email.toLowerCase().includes(search.toLowerCase())
|
u.email.toLowerCase().includes(search.toLowerCase())
|
||||||
@@ -319,17 +423,18 @@ export default function Admin() {
|
|||||||
<div className="grid gap-6 sm:grid-cols-2">
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
{/* Logo upload */}
|
{/* Logo upload */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-2">{t('admin.logoLabel')}</label>
|
<p className="block text-sm font-medium text-th-text mb-2">{t('admin.logoLabel')}</p>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{hasLogo && logoUrl ? (
|
{hasLogo && logoUrl ? (
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<img
|
<img
|
||||||
src={`${logoUrl}?t=${Date.now()}`}
|
src={`${logoUrl}?t=${Date.now()}`}
|
||||||
alt="Logo"
|
alt=""
|
||||||
className="w-14 h-14 rounded-xl object-contain border border-th-border bg-th-bg p-1"
|
className="w-14 h-14 rounded-xl object-contain border border-th-border bg-th-bg p-1"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogoRemove}
|
onClick={handleLogoRemove}
|
||||||
|
aria-label={t('common.delete')}
|
||||||
className="absolute -top-2 -right-2 w-5 h-5 bg-th-error text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
className="absolute -top-2 -right-2 w-5 h-5 bg-th-error text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
<XIcon size={12} />
|
<XIcon size={12} />
|
||||||
@@ -367,11 +472,12 @@ export default function Admin() {
|
|||||||
|
|
||||||
{/* App name */}
|
{/* App name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-2">{t('admin.appNameLabel')}</label>
|
<label htmlFor="admin-app-name" className="block text-sm font-medium text-th-text mb-2">{t('admin.appNameLabel')}</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Type size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<Type size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
|
id="admin-app-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={editAppName}
|
value={editAppName}
|
||||||
onChange={e => setEditAppName(e.target.value)}
|
onChange={e => setEditAppName(e.target.value)}
|
||||||
@@ -388,18 +494,41 @@ export default function Admin() {
|
|||||||
{savingName ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
{savingName ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{hasLogo && (
|
||||||
|
<div className="flex items-center justify-between mt-3 p-3 rounded-lg bg-th-bg-s border border-th-border">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-th-text">{t('admin.hideAppNameLabel')}</p>
|
||||||
|
<p className="text-xs text-th-text-s mt-0.5">{t('admin.hideAppNameHint')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={savingHideAppName}
|
||||||
|
onClick={() => handleHideAppNameToggle(!hideAppName)}
|
||||||
|
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-1 disabled:opacity-50 ml-4 ${
|
||||||
|
hideAppName ? 'bg-th-accent' : 'bg-th-border'
|
||||||
|
}`}
|
||||||
|
aria-checked={hideAppName}
|
||||||
|
role="switch"
|
||||||
|
>
|
||||||
|
<span className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
hideAppName ? 'translate-x-4' : 'translate-x-0'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Default theme */}
|
{/* Default theme */}
|
||||||
<div className="mt-6 pt-6 border-t border-th-border">
|
<div className="mt-6 pt-6 border-t border-th-border">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Palette size={16} className="text-th-accent" />
|
<Palette size={16} className="text-th-accent" aria-hidden="true" />
|
||||||
<label className="block text-sm font-medium text-th-text">{t('admin.defaultThemeLabel')}</label>
|
<label htmlFor="admin-default-theme" className="block text-sm font-medium text-th-text">{t('admin.defaultThemeLabel')}</label>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-th-text-s mb-3">{t('admin.defaultThemeDesc')}</p>
|
<p className="text-xs text-th-text-s mb-3">{t('admin.defaultThemeDesc')}</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
|
id="admin-default-theme"
|
||||||
value={editDefaultTheme}
|
value={editDefaultTheme}
|
||||||
onChange={e => setEditDefaultTheme(e.target.value)}
|
onChange={e => setEditDefaultTheme(e.target.value)}
|
||||||
className="input-field text-sm flex-1"
|
className="input-field text-sm flex-1"
|
||||||
@@ -413,7 +542,7 @@ export default function Admin() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleDefaultThemeSave}
|
onClick={handleDefaultThemeSave}
|
||||||
disabled={savingDefaultTheme || editDefaultTheme === (defaultTheme || 'dark')}
|
disabled={savingDefaultTheme || editDefaultTheme === (defaultTheme || 'dark')}
|
||||||
className="btn-primary text-sm px-4 flex-shrink-0"
|
className="btn-primary text-sm px-4 shrink-0"
|
||||||
>
|
>
|
||||||
{savingDefaultTheme ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
{savingDefaultTheme ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -423,16 +552,17 @@ export default function Admin() {
|
|||||||
{/* Legal links */}
|
{/* Legal links */}
|
||||||
<div className="mt-6 pt-6 border-t border-th-border">
|
<div className="mt-6 pt-6 border-t border-th-border">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<LinkIcon size={16} className="text-th-accent" />
|
<LinkIcon size={16} className="text-th-accent" aria-hidden="true" />
|
||||||
<label className="block text-sm font-medium text-th-text">{t('admin.legalLinksTitle')}</label>
|
<p className="block text-sm font-medium text-th-text">{t('admin.legalLinksTitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-th-text-s mb-4">{t('admin.legalLinksDesc')}</p>
|
<p className="text-xs text-th-text-s mb-4">{t('admin.legalLinksDesc')}</p>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{/* Imprint */}
|
{/* Imprint */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.imprintUrl')}</label>
|
<label htmlFor="admin-imprint-url" className="block text-xs font-medium text-th-text-s mb-1">{t('admin.imprintUrl')}</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
id="admin-imprint-url"
|
||||||
type="url"
|
type="url"
|
||||||
value={editImprintUrl}
|
value={editImprintUrl}
|
||||||
onChange={e => setEditImprintUrl(e.target.value)}
|
onChange={e => setEditImprintUrl(e.target.value)}
|
||||||
@@ -443,7 +573,7 @@ export default function Admin() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleImprintUrlSave}
|
onClick={handleImprintUrlSave}
|
||||||
disabled={savingImprintUrl || editImprintUrl === (imprintUrl || '')}
|
disabled={savingImprintUrl || editImprintUrl === (imprintUrl || '')}
|
||||||
className="btn-primary text-sm px-4 flex-shrink-0"
|
className="btn-primary text-sm px-4 shrink-0"
|
||||||
>
|
>
|
||||||
{savingImprintUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
{savingImprintUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -451,9 +581,10 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Privacy Policy */}
|
{/* Privacy Policy */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.privacyUrl')}</label>
|
<label htmlFor="admin-privacy-url" className="block text-xs font-medium text-th-text-s mb-1">{t('admin.privacyUrl')}</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
id="admin-privacy-url"
|
||||||
type="url"
|
type="url"
|
||||||
value={editPrivacyUrl}
|
value={editPrivacyUrl}
|
||||||
onChange={e => setEditPrivacyUrl(e.target.value)}
|
onChange={e => setEditPrivacyUrl(e.target.value)}
|
||||||
@@ -464,7 +595,7 @@ export default function Admin() {
|
|||||||
<button
|
<button
|
||||||
onClick={handlePrivacyUrlSave}
|
onClick={handlePrivacyUrlSave}
|
||||||
disabled={savingPrivacyUrl || editPrivacyUrl === (privacyUrl || '')}
|
disabled={savingPrivacyUrl || editPrivacyUrl === (privacyUrl || '')}
|
||||||
className="btn-primary text-sm px-4 flex-shrink-0"
|
className="btn-primary text-sm px-4 shrink-0"
|
||||||
>
|
>
|
||||||
{savingPrivacyUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
{savingPrivacyUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -521,20 +652,21 @@ export default function Admin() {
|
|||||||
{/* Send invite form */}
|
{/* Send invite form */}
|
||||||
<form onSubmit={handleSendInvite} className="flex items-center gap-2 mb-6">
|
<form onSubmit={handleSendInvite} className="flex items-center gap-2 mb-6">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={inviteEmail}
|
value={inviteEmail}
|
||||||
onChange={e => setInviteEmail(e.target.value)}
|
onChange={e => setInviteEmail(e.target.value)}
|
||||||
className="input-field pl-9 text-sm"
|
className="input-field pl-9 text-sm"
|
||||||
placeholder={t('auth.emailPlaceholder')}
|
placeholder={t('auth.emailPlaceholder')}
|
||||||
|
aria-label={t('auth.email')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={sendingInvite || !inviteEmail.trim()}
|
disabled={sendingInvite || !inviteEmail.trim()}
|
||||||
className="btn-primary text-sm px-4 flex-shrink-0"
|
className="btn-primary text-sm px-4 shrink-0"
|
||||||
>
|
>
|
||||||
{sendingInvite ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
|
{sendingInvite ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
|
||||||
{t('admin.sendInvite')}
|
{t('admin.sendInvite')}
|
||||||
@@ -550,7 +682,7 @@ export default function Admin() {
|
|||||||
return (
|
return (
|
||||||
<div key={inv.id} className="flex items-center justify-between gap-3 p-3 rounded-xl bg-th-bg border border-th-border">
|
<div key={inv.id} className="flex items-center justify-between gap-3 p-3 rounded-xl bg-th-bg border border-th-border">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
|
||||||
isUsed ? 'bg-green-500/15 text-green-400' : isExpired ? 'bg-red-500/15 text-red-400' : 'bg-th-accent/15 text-th-accent'
|
isUsed ? 'bg-green-500/15 text-green-400' : isExpired ? 'bg-red-500/15 text-red-400' : 'bg-th-accent/15 text-th-accent'
|
||||||
}`}>
|
}`}>
|
||||||
{isUsed ? <Check size={14} /> : isExpired ? <XIcon size={14} /> : <Clock size={14} />}
|
{isUsed ? <Check size={14} /> : isExpired ? <XIcon size={14} /> : <Clock size={14} />}
|
||||||
@@ -567,7 +699,7 @@ export default function Admin() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{!isUsed && !isExpired && (
|
{!isUsed && !isExpired && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCopyInviteLink(inv.token)}
|
onClick={() => handleCopyInviteLink(inv.token)}
|
||||||
@@ -596,16 +728,238 @@ export default function Admin() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* OAuth / SSO Configuration */}
|
||||||
|
<div className="card p-6 mb-8">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<LogIn size={20} className="text-th-accent" />
|
||||||
|
<h2 className="text-lg font-semibold text-th-text">{t('admin.oauthTitle')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-th-text-s mb-5">{t('admin.oauthDescription')}</p>
|
||||||
|
|
||||||
|
{oauthLoading ? (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Loader2 size={20} className="animate-spin text-th-accent" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleOauthSave} className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="admin-oauth-issuer" className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthIssuer')}</label>
|
||||||
|
<input
|
||||||
|
id="admin-oauth-issuer"
|
||||||
|
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 htmlFor="admin-oauth-client-id" className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientId')}</label>
|
||||||
|
<input
|
||||||
|
id="admin-oauth-client-id"
|
||||||
|
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 htmlFor="admin-oauth-client-secret" className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientSecret')}</label>
|
||||||
|
<input
|
||||||
|
id="admin-oauth-client-secret"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-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 htmlFor="admin-oauth-display-name" className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthDisplayName')}</label>
|
||||||
|
<input
|
||||||
|
id="admin-oauth-display-name"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Room Management */}
|
||||||
|
<div className="card p-6 mb-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRoomsExpanded(v => !v)}
|
||||||
|
className="flex items-center justify-between w-full text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DoorOpen size={20} className="text-th-accent" />
|
||||||
|
<h2 className="text-lg font-semibold text-th-text">{t('admin.roomsTitle')}</h2>
|
||||||
|
<span className="text-sm text-th-text-s">({adminRooms.length})</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown size={18} className={`text-th-text-s transition-transform duration-200 ${roomsExpanded ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{roomsExpanded && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-sm text-th-text-s mb-5">{t('admin.roomsDescription')}</p>
|
||||||
|
|
||||||
|
{adminRoomsLoading ? (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Loader2 size={20} className="animate-spin text-th-accent" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{adminRooms.length > 0 && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-th-border">
|
||||||
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5">
|
||||||
|
{t('admin.roomName')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden sm:table-cell">
|
||||||
|
{t('admin.roomOwner')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden md:table-cell">
|
||||||
|
{t('admin.roomShares')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden lg:table-cell">
|
||||||
|
{t('admin.roomCreated')}
|
||||||
|
</th>
|
||||||
|
<th className="text-right text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5">
|
||||||
|
{t('admin.actions')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{adminRooms.slice(0, 10).map(r => (
|
||||||
|
<tr key={r.id} className="border-b border-th-border last:border-0 hover:bg-th-hover transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-th-text">{r.name}</p>
|
||||||
|
<p className="text-xs text-th-text-s font-mono">{r.uid}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 hidden sm:table-cell">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-th-text">{r.owner_name}</p>
|
||||||
|
<p className="text-xs text-th-text-s">{r.owner_email}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-th-text hidden md:table-cell">
|
||||||
|
{r.share_count}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-th-text-s hidden lg:table-cell">
|
||||||
|
{new Date(r.created_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/rooms/${r.uid}`)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||||
|
title={t('admin.roomView')}
|
||||||
|
>
|
||||||
|
<Eye size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAdminDeleteRoom(r.uid, r.name)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-th-hover text-th-error transition-colors"
|
||||||
|
title={t('admin.deleteRoom')}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adminRooms.length > 10 && (
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => { setAllRoomsSearch(''); setShowAllRoomsModal(true); }}
|
||||||
|
className="btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
{t('admin.showAllRooms', { count: adminRooms.length })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adminRooms.length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<DoorOpen size={36} className="mx-auto text-th-text-s/40 mb-2" />
|
||||||
|
<p className="text-th-text-s text-sm">{t('admin.noRoomsFound')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="card p-4 mb-6">
|
<div className="card p-4 mb-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<Search size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="search"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
placeholder={t('admin.searchUsers')}
|
placeholder={t('admin.searchUsers')}
|
||||||
|
aria-label={t('admin.searchUsers')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -639,7 +993,7 @@ export default function Admin() {
|
|||||||
<td className="px-5 py-4">
|
<td className="px-5 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0 overflow-hidden"
|
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold shrink-0 overflow-hidden"
|
||||||
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
||||||
>
|
>
|
||||||
{u.avatar_image ? (
|
{u.avatar_image ? (
|
||||||
@@ -757,14 +1111,16 @@ export default function Admin() {
|
|||||||
{/* Reset password modal */}
|
{/* Reset password modal */}
|
||||||
{resetPwModal && (
|
{resetPwModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setResetPwModal(null)} />
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-xs" onClick={() => setResetPwModal(null)} aria-hidden="true" />
|
||||||
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-sm p-6">
|
<div role="dialog" aria-modal="true" aria-labelledby="reset-pw-title" className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-sm p-6">
|
||||||
<h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.resetPasswordTitle')}</h3>
|
<h3 id="reset-pw-title" className="text-lg font-semibold text-th-text mb-4">{t('admin.resetPasswordTitle')}</h3>
|
||||||
<form onSubmit={handleResetPassword}>
|
<form onSubmit={handleResetPassword}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('admin.newPasswordLabel')}</label>
|
<label htmlFor="admin-reset-pw" className="block text-sm font-medium text-th-text mb-1.5">{t('admin.newPasswordLabel')}</label>
|
||||||
<input
|
<input
|
||||||
|
id="admin-reset-pw"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={e => setNewPassword(e.target.value)}
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
@@ -789,16 +1145,18 @@ export default function Admin() {
|
|||||||
{/* Create user modal */}
|
{/* Create user modal */}
|
||||||
{showCreateUser && (
|
{showCreateUser && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowCreateUser(false)} />
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-xs" onClick={() => setShowCreateUser(false)} aria-hidden="true" />
|
||||||
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-md p-6">
|
<div role="dialog" aria-modal="true" aria-labelledby="create-user-title" className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-md p-6">
|
||||||
<h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.createUserTitle')}</h3>
|
<h3 id="create-user-title" className="text-lg font-semibold text-th-text mb-4">{t('admin.createUserTitle')}</h3>
|
||||||
<form onSubmit={handleCreateUser} className="space-y-4">
|
<form onSubmit={handleCreateUser} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
|
<label htmlFor="admin-new-username" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User 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" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
|
id="admin-new-username"
|
||||||
type="text"
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
value={newUser.name}
|
value={newUser.name}
|
||||||
onChange={e => setNewUser({ ...newUser, name: e.target.value })}
|
onChange={e => setNewUser({ ...newUser, name: e.target.value })}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
@@ -809,11 +1167,13 @@ export default function Admin() {
|
|||||||
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
|
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
|
<label htmlFor="admin-new-display-name" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User 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" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
|
id="admin-new-display-name"
|
||||||
type="text"
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
value={newUser.display_name}
|
value={newUser.display_name}
|
||||||
onChange={e => setNewUser({ ...newUser, display_name: e.target.value })}
|
onChange={e => setNewUser({ ...newUser, display_name: e.target.value })}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
@@ -822,11 +1182,13 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
<label htmlFor="admin-new-email" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
|
id="admin-new-email"
|
||||||
type="email"
|
type="email"
|
||||||
|
autoComplete="off"
|
||||||
value={newUser.email}
|
value={newUser.email}
|
||||||
onChange={e => setNewUser({ ...newUser, email: e.target.value })}
|
onChange={e => setNewUser({ ...newUser, email: e.target.value })}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
@@ -836,11 +1198,13 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
|
<label htmlFor="admin-new-password" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
|
id="admin-new-password"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
value={newUser.password}
|
value={newUser.password}
|
||||||
onChange={e => setNewUser({ ...newUser, password: e.target.value })}
|
onChange={e => setNewUser({ ...newUser, password: e.target.value })}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
@@ -851,8 +1215,9 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('admin.role')}</label>
|
<label htmlFor="admin-new-role" className="block text-sm font-medium text-th-text mb-1.5">{t('admin.role')}</label>
|
||||||
<select
|
<select
|
||||||
|
id="admin-new-role"
|
||||||
value={newUser.role}
|
value={newUser.role}
|
||||||
onChange={e => setNewUser({ ...newUser, role: e.target.value })}
|
onChange={e => setNewUser({ ...newUser, role: e.target.value })}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
@@ -873,6 +1238,120 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* All rooms modal */}
|
||||||
|
{showAllRoomsModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-xs" onClick={() => setShowAllRoomsModal(false)} />
|
||||||
|
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-th-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DoorOpen size={20} className="text-th-accent" />
|
||||||
|
<h3 className="text-lg font-semibold text-th-text">{t('admin.roomsTitle')}</h3>
|
||||||
|
<span className="text-sm text-th-text-s">({adminRooms.length})</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowAllRoomsModal(false)} aria-label={t('common.close')} className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors">
|
||||||
|
<XIcon size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-th-border">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={allRoomsSearch}
|
||||||
|
onChange={e => setAllRoomsSearch(e.target.value)}
|
||||||
|
className="input-field pl-9 text-sm"
|
||||||
|
placeholder={t('admin.searchRooms')}
|
||||||
|
aria-label={t('admin.searchRooms')}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1 p-0">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="sticky top-0 bg-th-card z-10">
|
||||||
|
<tr className="border-b border-th-border">
|
||||||
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5">
|
||||||
|
{t('admin.roomName')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden sm:table-cell">
|
||||||
|
{t('admin.roomOwner')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden md:table-cell">
|
||||||
|
{t('admin.roomShares')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden lg:table-cell">
|
||||||
|
{t('admin.roomCreated')}
|
||||||
|
</th>
|
||||||
|
<th className="text-right text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5">
|
||||||
|
{t('admin.actions')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{adminRooms
|
||||||
|
.filter(r =>
|
||||||
|
r.name.toLowerCase().includes(allRoomsSearch.toLowerCase()) ||
|
||||||
|
r.owner_name.toLowerCase().includes(allRoomsSearch.toLowerCase()) ||
|
||||||
|
r.uid.toLowerCase().includes(allRoomsSearch.toLowerCase())
|
||||||
|
)
|
||||||
|
.map(r => (
|
||||||
|
<tr key={r.id} className="border-b border-th-border last:border-0 hover:bg-th-hover transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-th-text">{r.name}</p>
|
||||||
|
<p className="text-xs text-th-text-s font-mono">{r.uid}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 hidden sm:table-cell">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-th-text">{r.owner_name}</p>
|
||||||
|
<p className="text-xs text-th-text-s">{r.owner_email}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-th-text hidden md:table-cell">
|
||||||
|
{r.share_count}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-th-text-s hidden lg:table-cell">
|
||||||
|
{new Date(r.created_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowAllRoomsModal(false); navigate(`/rooms/${r.uid}`); }}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||||
|
title={t('admin.roomView')}
|
||||||
|
>
|
||||||
|
<Eye size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAdminDeleteRoom(r.uid, r.name)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-th-hover text-th-error transition-colors"
|
||||||
|
title={t('admin.deleteRoom')}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{adminRooms.filter(r =>
|
||||||
|
r.name.toLowerCase().includes(allRoomsSearch.toLowerCase()) ||
|
||||||
|
r.owner_name.toLowerCase().includes(allRoomsSearch.toLowerCase()) ||
|
||||||
|
r.uid.toLowerCase().includes(allRoomsSearch.toLowerCase())
|
||||||
|
).length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<DoorOpen size={36} className="mx-auto text-th-text-s/40 mb-2" />
|
||||||
|
<p className="text-th-text-s text-sm">{t('admin.noRoomsFound')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-38
@@ -1,12 +1,13 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
ChevronLeft, ChevronRight, Plus, Calendar as CalendarIcon, Clock, Video,
|
ChevronLeft, ChevronRight, Plus, Clock, Video, Bell,
|
||||||
Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send, ExternalLink,
|
Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send, ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import Modal from '../components/Modal';
|
import Modal from '../components/Modal';
|
||||||
|
import DateTimePicker from '../components/DateTimePicker';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
const COLORS = ['#6366f1', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6'];
|
const COLORS = ['#6366f1', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||||
@@ -31,7 +32,7 @@ export default function Calendar() {
|
|||||||
// Create/Edit form
|
// Create/Edit form
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: '', description: '', start_time: '', end_time: '',
|
title: '', description: '', start_time: '', end_time: '',
|
||||||
room_uid: '', color: '#6366f1',
|
room_uid: '', color: '#6366f1', reminder_minutes: null,
|
||||||
});
|
});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
@@ -111,10 +112,10 @@ export default function Calendar() {
|
|||||||
}, [currentDate]);
|
}, [currentDate]);
|
||||||
|
|
||||||
const eventsForDay = (day) => {
|
const eventsForDay = (day) => {
|
||||||
const dayStr = day.toISOString().split('T')[0];
|
const dayStr = toLocalDateStr(day);
|
||||||
return events.filter(ev => {
|
return events.filter(ev => {
|
||||||
const start = ev.start_time.split('T')[0];
|
const start = toLocalDateStr(new Date(ev.start_time));
|
||||||
const end = ev.end_time.split('T')[0];
|
const end = toLocalDateStr(new Date(ev.end_time));
|
||||||
return dayStr >= start && dayStr <= end;
|
return dayStr >= start && dayStr <= end;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -156,7 +157,7 @@ export default function Calendar() {
|
|||||||
title: '', description: '',
|
title: '', description: '',
|
||||||
start_time: toLocalDateTimeStr(start),
|
start_time: toLocalDateTimeStr(start),
|
||||||
end_time: toLocalDateTimeStr(end),
|
end_time: toLocalDateTimeStr(end),
|
||||||
room_uid: '', color: '#6366f1',
|
room_uid: '', color: '#6366f1', reminder_minutes: null,
|
||||||
});
|
});
|
||||||
setEditingEvent(null);
|
setEditingEvent(null);
|
||||||
setShowCreate(true);
|
setShowCreate(true);
|
||||||
@@ -170,6 +171,7 @@ export default function Calendar() {
|
|||||||
end_time: toLocalDateTimeStr(new Date(ev.end_time)),
|
end_time: toLocalDateTimeStr(new Date(ev.end_time)),
|
||||||
room_uid: ev.room_uid || '',
|
room_uid: ev.room_uid || '',
|
||||||
color: ev.color || '#6366f1',
|
color: ev.color || '#6366f1',
|
||||||
|
reminder_minutes: ev.reminder_minutes ?? null,
|
||||||
});
|
});
|
||||||
setEditingEvent(ev);
|
setEditingEvent(ev);
|
||||||
setShowDetail(null);
|
setShowDetail(null);
|
||||||
@@ -452,7 +454,7 @@ export default function Calendar() {
|
|||||||
<div
|
<div
|
||||||
key={ev.id}
|
key={ev.id}
|
||||||
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
|
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"
|
className="text-[10px] leading-tight px-1.5 py-0.5 rounded-sm truncate text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
style={{ backgroundColor: ev.color || '#6366f1' }}
|
style={{ backgroundColor: ev.color || '#6366f1' }}
|
||||||
title={ev.title}
|
title={ev.title}
|
||||||
>
|
>
|
||||||
@@ -490,10 +492,13 @@ export default function Calendar() {
|
|||||||
<div
|
<div
|
||||||
key={ev.id}
|
key={ev.id}
|
||||||
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
|
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"
|
className="text-xs px-2 py-1.5 rounded-sm text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
style={{ backgroundColor: ev.color || '#6366f1' }}
|
style={{ backgroundColor: ev.color || '#6366f1' }}
|
||||||
>
|
>
|
||||||
<div className="truncate">{ev.title}</div>
|
<div className="flex items-center gap-1 truncate">
|
||||||
|
{ev.reminder_minutes && <Bell size={9} className="shrink-0 opacity-70" />}
|
||||||
|
<span className="truncate">{ev.title}</span>
|
||||||
|
</div>
|
||||||
<div className="opacity-80 text-[10px]">{formatTime(ev.start_time)} - {formatTime(ev.end_time)}</div>
|
<div className="opacity-80 text-[10px]">{formatTime(ev.start_time)} - {formatTime(ev.end_time)}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -510,8 +515,9 @@ export default function Calendar() {
|
|||||||
<Modal title={editingEvent ? t('calendar.editEvent') : t('calendar.createEvent')} onClose={() => { setShowCreate(false); setEditingEvent(null); }}>
|
<Modal title={editingEvent ? t('calendar.editEvent') : t('calendar.createEvent')} onClose={() => { setShowCreate(false); setEditingEvent(null); }}>
|
||||||
<form onSubmit={handleSave} className="space-y-4">
|
<form onSubmit={handleSave} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.eventTitle')} *</label>
|
<label htmlFor="calendar-event-title" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.eventTitle')} *</label>
|
||||||
<input
|
<input
|
||||||
|
id="calendar-event-title"
|
||||||
type="text"
|
type="text"
|
||||||
value={form.title}
|
value={form.title}
|
||||||
onChange={e => setForm({ ...form, title: e.target.value })}
|
onChange={e => setForm({ ...form, title: e.target.value })}
|
||||||
@@ -522,8 +528,9 @@ export default function Calendar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.description')}</label>
|
<label htmlFor="calendar-event-description" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.description')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="calendar-event-description"
|
||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={e => setForm({ ...form, description: e.target.value })}
|
onChange={e => setForm({ ...form, description: e.target.value })}
|
||||||
className="input-field resize-none"
|
className="input-field resize-none"
|
||||||
@@ -533,31 +540,31 @@ export default function Calendar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<DateTimePicker
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.startTime')} *</label>
|
label={t('calendar.startTime')}
|
||||||
<input
|
value={form.start_time}
|
||||||
type="datetime-local"
|
onChange={v => setForm({ ...form, start_time: v })}
|
||||||
value={form.start_time}
|
required
|
||||||
onChange={e => setForm({ ...form, start_time: e.target.value })}
|
icon="calendar"
|
||||||
className="input-field"
|
/>
|
||||||
required
|
<DateTimePicker
|
||||||
/>
|
label={t('calendar.endTime')}
|
||||||
</div>
|
value={form.end_time}
|
||||||
<div>
|
onChange={v => setForm({ ...form, end_time: v })}
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.endTime')} *</label>
|
required
|
||||||
<input
|
icon="clock"
|
||||||
type="datetime-local"
|
minDate={form.start_time ? new Date(form.start_time) : null}
|
||||||
value={form.end_time}
|
/>
|
||||||
onChange={e => setForm({ ...form, end_time: e.target.value })}
|
</div>
|
||||||
className="input-field"
|
<div className="flex items-center gap-1.5 -mt-2 text-xs text-th-text-s">
|
||||||
required
|
<Globe size={12} className="shrink-0" />
|
||||||
/>
|
<span>{getLocalTimezone()}</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.linkedRoom')}</label>
|
<label htmlFor="calendar-event-room" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.linkedRoom')}</label>
|
||||||
<select
|
<select
|
||||||
|
id="calendar-event-room"
|
||||||
value={form.room_uid}
|
value={form.room_uid}
|
||||||
onChange={e => setForm({ ...form, room_uid: e.target.value })}
|
onChange={e => setForm({ ...form, room_uid: e.target.value })}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
@@ -571,13 +578,37 @@ export default function Calendar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.color')}</label>
|
<label htmlFor="calendar-event-reminder" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.reminderLabel')}</label>
|
||||||
<div className="flex gap-2">
|
<div className="relative">
|
||||||
|
<Bell size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none" aria-hidden="true" />
|
||||||
|
<select
|
||||||
|
id="calendar-event-reminder"
|
||||||
|
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>
|
||||||
|
<p id="calendar-event-color-label" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.color')}</p>
|
||||||
|
<div role="radiogroup" aria-labelledby="calendar-event-color-label" className="flex gap-2">
|
||||||
{COLORS.map(c => (
|
{COLORS.map(c => (
|
||||||
<button
|
<button
|
||||||
key={c}
|
key={c}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setForm({ ...form, color: c })}
|
onClick={() => setForm({ ...form, color: c })}
|
||||||
|
role="radio"
|
||||||
|
aria-checked={form.color === c}
|
||||||
|
aria-label={c}
|
||||||
className={`w-7 h-7 rounded-full border-2 transition-all ${form.color === c ? 'border-th-text scale-110' : 'border-transparent'}`}
|
className={`w-7 h-7 rounded-full border-2 transition-all ${form.color === c ? 'border-th-text scale-110' : 'border-transparent'}`}
|
||||||
style={{ backgroundColor: c }}
|
style={{ backgroundColor: c }}
|
||||||
/>
|
/>
|
||||||
@@ -607,6 +638,10 @@ export default function Calendar() {
|
|||||||
{new Date(showDetail.start_time).toLocaleString()} - {new Date(showDetail.end_time).toLocaleString()}
|
{new Date(showDetail.start_time).toLocaleString()} - {new Date(showDetail.end_time).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-th-text-s opacity-70 -mt-2">
|
||||||
|
<Globe size={12} />
|
||||||
|
<span>{getLocalTimezone()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showDetail.description && (
|
{showDetail.description && (
|
||||||
<p className="text-sm text-th-text">{showDetail.description}</p>
|
<p className="text-sm text-th-text">{showDetail.description}</p>
|
||||||
@@ -722,7 +757,7 @@ export default function Calendar() {
|
|||||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
|
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
|
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0"
|
||||||
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
||||||
>
|
>
|
||||||
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||||
@@ -744,7 +779,7 @@ export default function Calendar() {
|
|||||||
<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 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="flex items-center gap-3 min-w-0">
|
||||||
<div
|
<div
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
|
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0"
|
||||||
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
||||||
>
|
>
|
||||||
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||||
@@ -771,7 +806,7 @@ export default function Calendar() {
|
|||||||
<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 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="flex items-center gap-3 min-w-0">
|
||||||
<div
|
<div
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
|
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0"
|
||||||
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
||||||
>
|
>
|
||||||
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||||
@@ -798,8 +833,9 @@ export default function Calendar() {
|
|||||||
<p className="text-sm text-th-text-s mb-4">{t('calendar.sendFederatedDesc')}</p>
|
<p className="text-sm text-th-text-s mb-4">{t('calendar.sendFederatedDesc')}</p>
|
||||||
<form onSubmit={handleFedSend} className="space-y-4">
|
<form onSubmit={handleFedSend} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
|
<label htmlFor="calendar-fed-share-address" className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
|
||||||
<input
|
<input
|
||||||
|
id="calendar-fed-share-address"
|
||||||
type="text"
|
type="text"
|
||||||
value={fedAddress}
|
value={fedAddress}
|
||||||
onChange={e => setFedAddress(e.target.value)}
|
onChange={e => setFedAddress(e.target.value)}
|
||||||
@@ -826,6 +862,13 @@ export default function Calendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
|
function toLocalDateStr(date) {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
function toLocalDateTimeStr(date) {
|
function toLocalDateTimeStr(date) {
|
||||||
const y = date.getFullYear();
|
const y = date.getFullYear();
|
||||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
@@ -839,3 +882,15 @@ function formatTime(dateStr) {
|
|||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLocalTimezone() {
|
||||||
|
try {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
} catch {
|
||||||
|
const offset = -new Date().getTimezoneOffset();
|
||||||
|
const sign = offset >= 0 ? '+' : '-';
|
||||||
|
const h = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0');
|
||||||
|
const m = String(Math.abs(offset) % 60).padStart(2, '0');
|
||||||
|
return `UTC${sign}${h}:${m}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+16
-7
@@ -103,9 +103,11 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* View toggle */}
|
{/* View toggle */}
|
||||||
<div className="hidden sm:flex items-center bg-th-bg-s rounded-lg border border-th-border p-0.5">
|
<div role="group" aria-label={t('common.gridView') + ' / ' + t('common.listView')} className="hidden sm:flex items-center bg-th-bg-s rounded-lg border border-th-border p-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('grid')}
|
onClick={() => setViewMode('grid')}
|
||||||
|
aria-label={t('common.gridView')}
|
||||||
|
aria-pressed={viewMode === 'grid'}
|
||||||
className={`p-1.5 rounded-md transition-colors ${
|
className={`p-1.5 rounded-md transition-colors ${
|
||||||
viewMode === 'grid' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
|
viewMode === 'grid' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
|
||||||
}`}
|
}`}
|
||||||
@@ -114,6 +116,8 @@ export default function Dashboard() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('list')}
|
onClick={() => setViewMode('list')}
|
||||||
|
aria-label={t('common.listView')}
|
||||||
|
aria-pressed={viewMode === 'list'}
|
||||||
className={`p-1.5 rounded-md transition-colors ${
|
className={`p-1.5 rounded-md transition-colors ${
|
||||||
viewMode === 'list' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
|
viewMode === 'list' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
|
||||||
}`}
|
}`}
|
||||||
@@ -197,20 +201,23 @@ export default function Dashboard() {
|
|||||||
<Modal title={t('dashboard.createRoom')} onClose={() => setShowCreate(false)}>
|
<Modal title={t('dashboard.createRoom')} onClose={() => setShowCreate(false)}>
|
||||||
<form onSubmit={handleCreate} className="space-y-4">
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')} *</label>
|
<label htmlFor="create-room-name" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')} *</label>
|
||||||
<input
|
<input
|
||||||
|
id="create-room-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={newRoom.name}
|
value={newRoom.name}
|
||||||
onChange={e => setNewRoom({ ...newRoom, name: e.target.value })}
|
onChange={e => setNewRoom({ ...newRoom, name: e.target.value })}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
placeholder={t('dashboard.roomNamePlaceholder')}
|
placeholder={t('dashboard.roomNamePlaceholder')}
|
||||||
required
|
required
|
||||||
|
minLength={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.welcomeMessage')}</label>
|
<label htmlFor="create-room-welcome" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.welcomeMessage')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="create-room-welcome"
|
||||||
value={newRoom.welcome_message}
|
value={newRoom.welcome_message}
|
||||||
onChange={e => setNewRoom({ ...newRoom, welcome_message: e.target.value })}
|
onChange={e => setNewRoom({ ...newRoom, welcome_message: e.target.value })}
|
||||||
className="input-field resize-none"
|
className="input-field resize-none"
|
||||||
@@ -221,8 +228,9 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.maxParticipants')}</label>
|
<label htmlFor="create-room-max" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.maxParticipants')}</label>
|
||||||
<input
|
<input
|
||||||
|
id="create-room-max"
|
||||||
type="number"
|
type="number"
|
||||||
value={newRoom.max_participants}
|
value={newRoom.max_participants}
|
||||||
onChange={e => setNewRoom({ ...newRoom, max_participants: parseInt(e.target.value) || 0 })}
|
onChange={e => setNewRoom({ ...newRoom, max_participants: parseInt(e.target.value) || 0 })}
|
||||||
@@ -232,8 +240,9 @@ export default function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.accessCode')}</label>
|
<label htmlFor="create-room-access-code" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.accessCode')}</label>
|
||||||
<input
|
<input
|
||||||
|
id="create-room-access-code"
|
||||||
type="text"
|
type="text"
|
||||||
value={newRoom.access_code}
|
value={newRoom.access_code}
|
||||||
onChange={e => setNewRoom({ ...newRoom, access_code: e.target.value })}
|
onChange={e => setNewRoom({ ...newRoom, access_code: e.target.value })}
|
||||||
@@ -249,7 +258,7 @@ export default function Dashboard() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={newRoom.mute_on_join}
|
checked={newRoom.mute_on_join}
|
||||||
onChange={e => setNewRoom({ ...newRoom, mute_on_join: e.target.checked })}
|
onChange={e => setNewRoom({ ...newRoom, mute_on_join: e.target.checked })}
|
||||||
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
|
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-th-text">{t('dashboard.muteOnJoin')}</span>
|
<span className="text-sm text-th-text">{t('dashboard.muteOnJoin')}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -258,7 +267,7 @@ export default function Dashboard() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={newRoom.record_meeting}
|
checked={newRoom.record_meeting}
|
||||||
onChange={e => setNewRoom({ ...newRoom, record_meeting: e.target.checked })}
|
onChange={e => setNewRoom({ ...newRoom, record_meeting: e.target.checked })}
|
||||||
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
|
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-th-text">{t('dashboard.allowRecording')}</span>
|
<span className="text-sm text-th-text">{t('dashboard.allowRecording')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -39,7 +39,17 @@ export default function FederatedRoomDetail() {
|
|||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const handleJoin = () => {
|
const handleJoin = () => {
|
||||||
window.open(room.join_url, '_blank');
|
// Validate URL scheme to prevent javascript: or other malicious URIs
|
||||||
|
try {
|
||||||
|
const url = new URL(room.join_url);
|
||||||
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||||
|
toast.error(t('federation.invalidJoinUrl'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.open(room.join_url, '_blank');
|
||||||
|
} catch {
|
||||||
|
toast.error(t('federation.invalidJoinUrl'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemove = async () => {
|
const handleRemove = async () => {
|
||||||
@@ -83,7 +93,7 @@ export default function FederatedRoomDetail() {
|
|||||||
{isDeleted && (
|
{isDeleted && (
|
||||||
<div className="card p-4 mb-4 border-red-500/30 bg-red-500/10">
|
<div className="card p-4 mb-4 border-red-500/30 bg-red-500/10">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<AlertTriangle size={20} className="text-red-500 flex-shrink-0" />
|
<AlertTriangle size={20} className="text-red-500 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-red-500">{t('federation.roomDeleted')}</p>
|
<p className="text-sm font-semibold text-red-500">{t('federation.roomDeleted')}</p>
|
||||||
<p className="text-xs text-th-text-s mt-0.5">{t('federation.roomDeletedNotice')}</p>
|
<p className="text-xs text-th-text-s mt-0.5">{t('federation.roomDeletedNotice')}</p>
|
||||||
@@ -96,7 +106,7 @@ export default function FederatedRoomDetail() {
|
|||||||
<div className="card p-6 mb-4">
|
<div className="card p-6 mb-4">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||||
<div className="flex items-start gap-3 min-w-0">
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${isDeleted ? 'bg-red-500/15' : 'bg-th-accent/15'}`}>
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 mt-0.5 ${isDeleted ? 'bg-red-500/15' : 'bg-th-accent/15'}`}>
|
||||||
{isDeleted ? <AlertTriangle size={20} className="text-red-500" /> : <Globe size={20} className="text-th-accent" />}
|
{isDeleted ? <AlertTriangle size={20} className="text-red-500" /> : <Globe size={20} className="text-th-accent" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -118,7 +128,7 @@ export default function FederatedRoomDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{!isDeleted && (
|
{!isDeleted && (
|
||||||
<button
|
<button
|
||||||
onClick={handleJoin}
|
onClick={handleJoin}
|
||||||
@@ -179,7 +189,7 @@ export default function FederatedRoomDetail() {
|
|||||||
|
|
||||||
{room.meet_id && (
|
{room.meet_id && (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Hash size={16} className="text-th-accent flex-shrink-0 mt-0.5" />
|
<Hash size={16} className="text-th-accent shrink-0 mt-0.5" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs text-th-text-s mb-0.5">{t('federation.meetingId')}</p>
|
<p className="text-xs text-th-text-s mb-0.5">{t('federation.meetingId')}</p>
|
||||||
<p className="text-sm font-mono text-th-text break-all">{room.meet_id}</p>
|
<p className="text-sm font-mono text-th-text break-all">{room.meet_id}</p>
|
||||||
@@ -188,7 +198,7 @@ export default function FederatedRoomDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Link2 size={16} className="text-th-accent flex-shrink-0 mt-0.5" />
|
<Link2 size={16} className="text-th-accent shrink-0 mt-0.5" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs text-th-text-s mb-0.5">{t('federation.joinUrl')}</p>
|
<p className="text-xs text-th-text-s mb-0.5">{t('federation.joinUrl')}</p>
|
||||||
<p className="text-sm font-mono text-th-text break-all opacity-60 select-all">{room.join_url}</p>
|
<p className="text-sm font-mono text-th-text break-all opacity-60 select-all">{room.join_url}</p>
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export default function FederationInbox() {
|
|||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Mail size={16} className="text-th-accent flex-shrink-0" />
|
<Mail size={16} className="text-th-accent shrink-0" />
|
||||||
<h3 className="text-base font-semibold text-th-text truncate">{inv.room_name}</h3>
|
<h3 className="text-base font-semibold text-th-text truncate">{inv.room_name}</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-th-text-s">
|
<p className="text-sm text-th-text-s">
|
||||||
@@ -180,7 +180,7 @@ export default function FederationInbox() {
|
|||||||
{new Date(inv.created_at).toLocaleString()}
|
{new Date(inv.created_at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 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} />
|
<Check size={16} />
|
||||||
{t('federation.accept')}
|
{t('federation.accept')}
|
||||||
@@ -200,7 +200,7 @@ export default function FederationInbox() {
|
|||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Calendar size={16} className="text-th-success flex-shrink-0" />
|
<Calendar size={16} className="text-th-success shrink-0" />
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-th-success mr-1">
|
<span className="text-xs font-semibold uppercase tracking-wide text-th-success mr-1">
|
||||||
{t('federation.calendarEvent')}
|
{t('federation.calendarEvent')}
|
||||||
</span>
|
</span>
|
||||||
@@ -219,7 +219,7 @@ export default function FederationInbox() {
|
|||||||
{new Date(inv.created_at).toLocaleString()}
|
{new Date(inv.created_at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<button onClick={() => handleCalAccept(inv.id)} className="btn-primary text-sm">
|
<button onClick={() => handleCalAccept(inv.id)} className="btn-primary text-sm">
|
||||||
<Check size={16} />
|
<Check size={16} />
|
||||||
{t('federation.accept')}
|
{t('federation.accept')}
|
||||||
@@ -239,7 +239,7 @@ export default function FederationInbox() {
|
|||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Calendar size={16} className="text-th-accent flex-shrink-0" />
|
<Calendar size={16} className="text-th-accent shrink-0" />
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-th-accent mr-1">
|
<span className="text-xs font-semibold uppercase tracking-wide text-th-accent mr-1">
|
||||||
{t('federation.localCalendarEvent')}
|
{t('federation.localCalendarEvent')}
|
||||||
</span>
|
</span>
|
||||||
@@ -258,7 +258,7 @@ export default function FederationInbox() {
|
|||||||
{new Date(inv.created_at).toLocaleString()}
|
{new Date(inv.created_at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<button onClick={() => handleLocalCalAccept(inv.id)} className="btn-primary text-sm">
|
<button onClick={() => handleLocalCalAccept(inv.id)} className="btn-primary text-sm">
|
||||||
<Check size={16} />
|
<Check size={16} />
|
||||||
{t('federation.accept')}
|
{t('federation.accept')}
|
||||||
@@ -287,13 +287,13 @@ export default function FederationInbox() {
|
|||||||
<div key={`room-past-${inv.id}`} className="card p-4 opacity-70">
|
<div key={`room-past-${inv.id}`} className="card p-4 opacity-70">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="min-w-0 flex items-center gap-2">
|
<div className="min-w-0 flex items-center gap-2">
|
||||||
<Mail size={14} className="text-th-text-s flex-shrink-0" />
|
<Mail size={14} className="text-th-text-s shrink-0" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3>
|
<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>
|
<p className="text-xs text-th-text-s">{inv.from_user}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
|
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
|
||||||
? 'bg-th-success/15 text-th-success'
|
? 'bg-th-success/15 text-th-success'
|
||||||
: 'bg-th-error/15 text-th-error'
|
: 'bg-th-error/15 text-th-error'
|
||||||
@@ -326,13 +326,13 @@ export default function FederationInbox() {
|
|||||||
<div key={`cal-past-${inv.id}`} className="card p-4 opacity-70">
|
<div key={`cal-past-${inv.id}`} className="card p-4 opacity-70">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="min-w-0 flex items-center gap-2">
|
<div className="min-w-0 flex items-center gap-2">
|
||||||
<Calendar size={14} className="text-th-text-s flex-shrink-0" />
|
<Calendar size={14} className="text-th-text-s shrink-0" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
|
<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>
|
<p className="text-xs text-th-text-s">{inv.from_user} · {new Date(inv.start_time).toLocaleDateString()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
|
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
|
||||||
? 'bg-th-success/15 text-th-success'
|
? 'bg-th-success/15 text-th-success'
|
||||||
: 'bg-th-error/15 text-th-error'
|
: 'bg-th-error/15 text-th-error'
|
||||||
@@ -365,13 +365,13 @@ export default function FederationInbox() {
|
|||||||
<div key={`localcal-past-${inv.id}`} className="card p-4 opacity-70">
|
<div key={`localcal-past-${inv.id}`} className="card p-4 opacity-70">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="min-w-0 flex items-center gap-2">
|
<div className="min-w-0 flex items-center gap-2">
|
||||||
<Calendar size={14} className="text-th-text-s flex-shrink-0" />
|
<Calendar size={14} className="text-th-text-s shrink-0" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
|
<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>
|
<p className="text-xs text-th-text-s">{inv.from_name} · {new Date(inv.start_time).toLocaleDateString()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
|
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
|
||||||
? 'bg-th-success/15 text-th-success'
|
? 'bg-th-success/15 text-th-success'
|
||||||
: 'bg-th-error/15 text-th-error'
|
: 'bg-th-error/15 text-th-error'
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { Mail, ArrowRight, Loader2, CheckCircle } from 'lucide-react';
|
||||||
|
import BrandLogo from '../components/BrandLogo';
|
||||||
|
import api from '../services/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function ForgotPassword() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.post('/auth/forgot-password', { email });
|
||||||
|
setSent(true);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('auth.forgotPasswordFailed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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-30">
|
||||||
|
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-pink-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '4s' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
|
<BrandLogo size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sent ? (
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<CheckCircle size={48} className="mx-auto text-green-400" />
|
||||||
|
<h2 className="text-2xl font-bold text-th-text">{t('auth.forgotPasswordSentTitle')}</h2>
|
||||||
|
<p className="text-th-text-s">{t('auth.forgotPasswordSent')}</p>
|
||||||
|
<Link to="/login" className="btn-primary inline-flex items-center gap-2 mt-4">
|
||||||
|
{t('auth.login')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.forgotPasswordTitle')}</h2>
|
||||||
|
<p className="text-th-text-s">{t('auth.forgotPasswordSubtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="forgot-email" 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" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
id="forgot-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
className="input-field pl-11"
|
||||||
|
placeholder={t('auth.emailPlaceholder')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="btn-primary w-full py-3"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t('auth.forgotPasswordSubmit')}
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link to="/login" className="block mt-6 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
||||||
|
{t('auth.backToLogin')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+188
-115
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useParams, Link, useSearchParams } from 'react-router-dom';
|
import { useParams, Link, useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText } from 'lucide-react';
|
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText, Clock, X } from 'lucide-react';
|
||||||
import BrandLogo from '../components/BrandLogo';
|
import BrandLogo from '../components/BrandLogo';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
import { isCurrentThemeDark } from '../themes';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
@@ -11,6 +12,7 @@ import { useBranding } from '../contexts/BrandingContext';
|
|||||||
export default function GuestJoin() {
|
export default function GuestJoin() {
|
||||||
const { uid } = useParams();
|
const { uid } = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { imprintUrl, privacyUrl } = useBranding();
|
const { imprintUrl, privacyUrl } = useBranding();
|
||||||
@@ -19,18 +21,64 @@ export default function GuestJoin() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [joining, setJoining] = useState(false);
|
const [joining, setJoining] = useState(false);
|
||||||
const [name, setName] = useState(user?.name || '');
|
const [name, setName] = useState(user?.display_name || user?.name || '');
|
||||||
const [accessCode, setAccessCode] = useState(searchParams.get('ac') || '');
|
const [accessCode, setAccessCode] = useState(searchParams.get('ac') || '');
|
||||||
const [moderatorCode, setModeratorCode] = useState('');
|
const [moderatorCode, setModeratorCode] = useState('');
|
||||||
const [status, setStatus] = useState({ running: false });
|
const [status, setStatus] = useState({ running: false });
|
||||||
const [recordingConsent, setRecordingConsent] = useState(false);
|
const [recordingConsent, setRecordingConsent] = useState(false);
|
||||||
|
const [waiting, setWaiting] = useState(false);
|
||||||
|
const prevRunningRef = useRef(false);
|
||||||
|
|
||||||
|
const joinMeeting = async () => {
|
||||||
|
setJoining(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: name.trim(),
|
||||||
|
access_code: accessCode || undefined,
|
||||||
|
moderator_code: moderatorCode || undefined,
|
||||||
|
dark_mode: isCurrentThemeDark(),
|
||||||
|
};
|
||||||
|
// If logged in, send avatar data
|
||||||
|
if (isLoggedIn && user) {
|
||||||
|
if (user.avatar_image) payload.avatar_image = user.avatar_image;
|
||||||
|
if (user.avatar_color) payload.avatar_color = user.avatar_color;
|
||||||
|
}
|
||||||
|
const res = await api.post(`/rooms/${uid}/guest-join`, payload);
|
||||||
|
if (res.data.joinUrl) {
|
||||||
|
window.location.href = res.data.joinUrl;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errStatus = err.response?.status;
|
||||||
|
if (errStatus === 403) {
|
||||||
|
toast.error(t('room.guestWrongAccessCode'));
|
||||||
|
setWaiting(false);
|
||||||
|
} else {
|
||||||
|
toast.error(t('room.guestJoinFailed'));
|
||||||
|
setWaiting(false);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setJoining(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchRoom = async () => {
|
const fetchRoom = async () => {
|
||||||
|
// If logged in, check if user owns or has access to this room
|
||||||
|
if (isLoggedIn) {
|
||||||
|
try {
|
||||||
|
await api.get(`/rooms/${uid}`);
|
||||||
|
navigate(`/rooms/${uid}`, { replace: true });
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// User doesn't have access — continue as guest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/rooms/${uid}/public`);
|
const res = await api.get(`/rooms/${uid}/public`);
|
||||||
setRoomInfo(res.data.room);
|
setRoomInfo(res.data.room);
|
||||||
setStatus({ running: res.data.running });
|
setStatus({ running: res.data.running });
|
||||||
|
prevRunningRef.current = res.data.running;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const status = err.response?.status;
|
const status = err.response?.status;
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
@@ -53,45 +101,36 @@ export default function GuestJoin() {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 5000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [uid]);
|
}, [uid]);
|
||||||
|
|
||||||
|
// Auto-join when meeting starts while waiting
|
||||||
|
useEffect(() => {
|
||||||
|
if (!prevRunningRef.current && status.running && waiting) {
|
||||||
|
new Audio('/sounds/meeting-started.mp3').play().catch(() => { });
|
||||||
|
toast.success(t('room.guestMeetingStartedJoining'));
|
||||||
|
joinMeeting();
|
||||||
|
}
|
||||||
|
prevRunningRef.current = status.running;
|
||||||
|
}, [status.running]);
|
||||||
|
|
||||||
const handleJoin = async (e) => {
|
const handleJoin = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
toast.error(t('room.guestNameRequired'));
|
toast.error(t('room.guestNameRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roomInfo?.allow_recording && !recordingConsent) {
|
if (roomInfo?.allow_recording && !recordingConsent) {
|
||||||
toast.error(t('room.guestRecordingConsent'));
|
toast.error(t('room.guestRecordingConsent'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!status.running && !roomInfo?.anyone_can_start) {
|
||||||
setJoining(true);
|
setWaiting(true);
|
||||||
try {
|
return;
|
||||||
const res = await api.post(`/rooms/${uid}/guest-join`, {
|
|
||||||
name: name.trim(),
|
|
||||||
access_code: accessCode || undefined,
|
|
||||||
moderator_code: moderatorCode || undefined,
|
|
||||||
});
|
|
||||||
if (res.data.joinUrl) {
|
|
||||||
window.location.href = res.data.joinUrl;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const status = err.response?.status;
|
|
||||||
if (status === 403) {
|
|
||||||
toast.error(t('room.guestWrongAccessCode'));
|
|
||||||
} else if (status === 400) {
|
|
||||||
toast.error(t('room.guestWaitingMessage'));
|
|
||||||
} else {
|
|
||||||
toast.error(t('room.guestJoinFailed'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setJoining(false);
|
|
||||||
}
|
}
|
||||||
|
await joinMeeting();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -164,97 +203,131 @@ export default function GuestJoin() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Join form */}
|
{/* Join form */}
|
||||||
<form onSubmit={handleJoin} className="space-y-4">
|
{waiting ? (
|
||||||
<div>
|
<div className="flex flex-col items-center gap-5 py-4">
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label>
|
<div className="w-16 h-16 rounded-full flex items-center justify-center bg-th-accent/10">
|
||||||
<div className="relative">
|
<Clock size={28} className="text-th-accent animate-pulse" />
|
||||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={e => !isLoggedIn && setName(e.target.value)}
|
|
||||||
readOnly={isLoggedIn}
|
|
||||||
className={`input-field pl-11 ${isLoggedIn ? 'opacity-70 cursor-not-allowed' : ''}`}
|
|
||||||
placeholder={t('room.guestNamePlaceholder')}
|
|
||||||
required
|
|
||||||
autoFocus={!isLoggedIn}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="text-center">
|
||||||
|
<p className="font-semibold text-th-text mb-1">{t('room.guestWaitingTitle')}</p>
|
||||||
{roomInfo.has_access_code && (
|
<p className="text-sm text-th-text-s">{t('room.guestWaitingHint')}</p>
|
||||||
<div>
|
</div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</label>
|
{joining && (
|
||||||
<div className="relative">
|
<div className="flex items-center gap-2 text-sm text-th-success font-medium">
|
||||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<Loader2 size={16} className="animate-spin" />
|
||||||
<input
|
{t('room.guestMeetingStartedJoining')}
|
||||||
type="text"
|
|
||||||
value={accessCode}
|
|
||||||
onChange={e => setAccessCode(e.target.value)}
|
|
||||||
className="input-field pl-11"
|
|
||||||
placeholder={t('room.guestAccessCodePlaceholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
|
||||||
</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>
|
{!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>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleJoin} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="guest-name" 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" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
id="guest-name"
|
||||||
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
|
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>
|
||||||
|
|
||||||
{!status.running && (
|
{roomInfo.has_access_code && (
|
||||||
<p className="text-xs text-th-text-s text-center">
|
<div>
|
||||||
{t('room.guestWaitingMessage')}
|
<label htmlFor="guest-access-code" className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</label>
|
||||||
</p>
|
<div className="relative">
|
||||||
)}
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
|
||||||
</form>
|
<input
|
||||||
|
id="guest-access-code"
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
value={accessCode}
|
||||||
|
onChange={e => setAccessCode(e.target.value)}
|
||||||
|
className="input-field pl-11"
|
||||||
|
placeholder={t('room.guestAccessCodePlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="guest-moderator-code" 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" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
id="guest-moderator-code"
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
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 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-sm accent-amber-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-th-text">{t('room.guestRecordingConsent')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={joining || (roomInfo.allow_recording && !recordingConsent)}
|
||||||
|
className="btn-primary w-full py-3"
|
||||||
|
>
|
||||||
|
{joining ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t('room.guestJoinButton')}
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!status.running && !roomInfo?.anyone_can_start && (
|
||||||
|
<p className="text-xs text-th-text-s text-center">
|
||||||
|
{t('room.guestWaitingMessage')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isLoggedIn && (
|
{!isLoggedIn && (
|
||||||
<div className="mt-6 pt-4 border-t border-th-border text-center">
|
<div className="mt-6 pt-4 border-t border-th-border text-center">
|
||||||
|
|||||||
+39
-72
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe, FileText, Lock } from 'lucide-react';
|
import { Video, Users, FileVideo, Network, CalendarDays, BarChart2, ArrowRight, FileText, Lock } from 'lucide-react';
|
||||||
import BrandLogo from '../components/BrandLogo';
|
import BrandLogo from '../components/BrandLogo';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { useBranding } from '../contexts/BrandingContext';
|
import { useBranding } from '../contexts/BrandingContext';
|
||||||
@@ -21,37 +21,35 @@ export default function Home() {
|
|||||||
desc: t('home.featureRoomsDesc'),
|
desc: t('home.featureRoomsDesc'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Shield,
|
icon: FileVideo,
|
||||||
title: t('home.featureUsersTitle'),
|
|
||||||
desc: t('home.featureUsersDesc'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Palette,
|
|
||||||
title: t('home.featureThemesTitle'),
|
|
||||||
desc: t('home.featureThemesDesc'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Zap,
|
|
||||||
title: t('home.featureRecordingsTitle'),
|
title: t('home.featureRecordingsTitle'),
|
||||||
desc: t('home.featureRecordingsDesc'),
|
desc: t('home.featureRecordingsDesc'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Globe,
|
icon: Network,
|
||||||
title: t('home.featureOpenSourceTitle'),
|
title: t('home.featureFederationTitle'),
|
||||||
desc: t('home.featureOpenSourceDesc'),
|
desc: t('home.featureFederationDesc'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: CalendarDays,
|
||||||
|
title: t('home.featureCalendarTitle'),
|
||||||
|
desc: t('home.featureCalendarDesc'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: BarChart2,
|
||||||
|
title: t('home.featureAnalyticsTitle'),
|
||||||
|
desc: t('home.featureAnalyticsDesc'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-th-bg">
|
<div className="min-h-screen bg-th-bg">
|
||||||
{/* Hero Section */}
|
|
||||||
<div className="relative overflow-hidden">
|
<div className="relative overflow-hidden">
|
||||||
{/* Background gradient */}
|
{/* Background gradient */}
|
||||||
<div className="absolute inset-0 gradient-bg opacity-5" />
|
<div className="absolute inset-0 gradient-bg opacity-5" />
|
||||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[600px] gradient-bg opacity-10 blur-3xl rounded-full" />
|
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[600px] gradient-bg opacity-10 blur-3xl rounded-full" />
|
||||||
|
|
||||||
{/* Navbar */}
|
<nav className="relative z-10 flex items-center justify-between px-6 md:px-12 py-4 border-b border-th-border">
|
||||||
<nav className="relative z-10 flex items-center justify-between px-6 md:px-12 py-4">
|
|
||||||
<BrandLogo size="md" />
|
<BrandLogo size="md" />
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link to="/login" className="btn-ghost text-sm">
|
<Link to="/login" className="btn-ghost text-sm">
|
||||||
@@ -60,81 +58,50 @@ export default function Home() {
|
|||||||
{!isInviteOnly && (
|
{!isInviteOnly && (
|
||||||
<Link to="/register" className="btn-primary text-sm">
|
<Link to="/register" className="btn-primary text-sm">
|
||||||
{t('auth.register')}
|
{t('auth.register')}
|
||||||
<ArrowRight size={16} />
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Hero content */}
|
{/* Hero */}
|
||||||
<div className="relative z-10 max-w-4xl mx-auto text-center px-6 pt-20 pb-32">
|
<div className="relative z-10 max-w-3xl mx-auto px-6 pt-24 pb-20">
|
||||||
<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">
|
<h1 className="text-3xl md:text-4xl font-bold text-th-text mb-4">
|
||||||
<Zap size={14} />
|
{t('home.heroTitle')}
|
||||||
{t('home.poweredBy')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-4xl md:text-6xl lg:text-7xl font-extrabold text-th-text mb-6 leading-tight tracking-tight">
|
|
||||||
{t('home.heroTitle')}{' '}
|
|
||||||
<span className="gradient-text">{t('home.heroTitleHighlight')}</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className="text-lg text-th-text-s mb-8 leading-relaxed">
|
||||||
<p className="text-lg md:text-xl text-th-text-s max-w-2xl mx-auto mb-10 leading-relaxed">
|
|
||||||
{t('home.heroSubtitle')}
|
{t('home.heroSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-4 justify-center">
|
|
||||||
{!isInviteOnly && (
|
{!isInviteOnly && (
|
||||||
<Link to="/register" className="btn-primary text-base px-8 py-3">
|
<Link to="/register" className="btn-primary px-6 py-2.5">
|
||||||
{t('home.getStarted')}
|
{t('home.getStarted')}
|
||||||
<ArrowRight size={18} />
|
<ArrowRight size={16} />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link to="/login" className={`${isInviteOnly ? 'btn-primary' : 'btn-secondary'} text-base px-8 py-3`}>
|
<Link to="/login" className={`${isInviteOnly ? 'btn-primary' : 'btn-secondary'} px-6 py-2.5`}>
|
||||||
{t('auth.login')}
|
{t('auth.login')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="flex items-center justify-center gap-8 md:gap-16 mt-16">
|
|
||||||
{[
|
|
||||||
{ value: '15+', label: t('home.statThemes') },
|
|
||||||
{ value: '∞', label: t('home.statRooms') },
|
|
||||||
{ value: '100%', label: t('home.statOpenSource') },
|
|
||||||
].map(stat => (
|
|
||||||
<div key={stat.label} className="text-center">
|
|
||||||
<div className="text-2xl md:text-3xl font-bold gradient-text">{stat.value}</div>
|
|
||||||
<div className="text-sm text-th-text-s mt-1">{stat.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Features Section */}
|
{/* Features */}
|
||||||
<div className="max-w-6xl mx-auto px-6 py-20">
|
<div className="border-t border-th-border">
|
||||||
<div className="text-center mb-16">
|
<div className="max-w-5xl mx-auto px-6 py-16">
|
||||||
<h2 className="text-3xl md:text-4xl font-bold text-th-text mb-4">
|
<h2 className="text-xl font-semibold text-th-text mb-10">
|
||||||
{t('home.features')}
|
{t('home.features')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-th-text-s max-w-2xl mx-auto">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{t('home.featuresSubtitle')}
|
{features.map(feature => (
|
||||||
</p>
|
<div key={feature.title} className="card p-6 hover:shadow-th-lg transition-shadow">
|
||||||
</div>
|
<div className="w-12 h-12 rounded-xl bg-th-accent/10 flex items-center justify-center mb-4">
|
||||||
|
<feature.icon size={24} className="text-th-accent" />
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
</div>
|
||||||
{features.map((feature, idx) => (
|
<h3 className="text-base font-semibold text-th-text mb-2">{feature.title}</h3>
|
||||||
<div key={idx} className="card p-6 hover:shadow-th-lg transition-all duration-300 group">
|
<p className="text-sm text-th-text-s leading-relaxed">{feature.desc}</p>
|
||||||
<div className="w-12 h-12 rounded-xl gradient-bg/10 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform"
|
|
||||||
style={{ background: `linear-gradient(135deg, var(--gradient-start), var(--gradient-end))`, opacity: 0.15 }}>
|
|
||||||
<feature.icon size={24} className="text-th-accent" style={{ opacity: 1 }} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center mb-4 -mt-16 relative">
|
))}
|
||||||
<feature.icon size={24} className="text-th-accent" />
|
</div>
|
||||||
</div>
|
|
||||||
<h3 className="text-base font-semibold text-th-text mb-2">{feature.title}</h3>
|
|
||||||
<p className="text-sm text-th-text-s leading-relaxed">{feature.desc}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+209
-79
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { useBranding } from '../contexts/BrandingContext';
|
import { useBranding } from '../contexts/BrandingContext';
|
||||||
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw } from 'lucide-react';
|
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw, LogIn, ShieldCheck } from 'lucide-react';
|
||||||
import BrandLogo from '../components/BrandLogo';
|
import BrandLogo from '../components/BrandLogo';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
@@ -15,9 +15,16 @@ export default function Login() {
|
|||||||
const [needsVerification, setNeedsVerification] = useState(false);
|
const [needsVerification, setNeedsVerification] = useState(false);
|
||||||
const [resendCooldown, setResendCooldown] = useState(0);
|
const [resendCooldown, setResendCooldown] = useState(0);
|
||||||
const [resending, setResending] = useState(false);
|
const [resending, setResending] = useState(false);
|
||||||
const { login } = useAuth();
|
// 2FA state
|
||||||
|
const [needs2FA, setNeeds2FA] = useState(false);
|
||||||
|
const [tempToken, setTempToken] = useState('');
|
||||||
|
const [totpCode, setTotpCode] = useState('');
|
||||||
|
const [verifying2FA, setVerifying2FA] = useState(false);
|
||||||
|
const totpInputRef = useRef(null);
|
||||||
|
|
||||||
|
const { login, verify2FA } = useAuth();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { registrationMode } = useBranding();
|
const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -26,6 +33,13 @@ export default function Login() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [resendCooldown]);
|
}, [resendCooldown]);
|
||||||
|
|
||||||
|
// Auto-focus TOTP input when 2FA screen appears
|
||||||
|
useEffect(() => {
|
||||||
|
if (needs2FA && totpInputRef.current) {
|
||||||
|
totpInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [needs2FA]);
|
||||||
|
|
||||||
const handleResend = async () => {
|
const handleResend = async () => {
|
||||||
if (resendCooldown > 0 || resending) return;
|
if (resendCooldown > 0 || resending) return;
|
||||||
setResending(true);
|
setResending(true);
|
||||||
@@ -48,7 +62,13 @@ export default function Login() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(email, password);
|
const result = await login(email, password);
|
||||||
|
if (result?.requires2FA) {
|
||||||
|
setTempToken(result.tempToken);
|
||||||
|
setNeeds2FA(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.success(t('auth.loginSuccess'));
|
toast.success(t('auth.loginSuccess'));
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -62,6 +82,27 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handle2FASubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setVerifying2FA(true);
|
||||||
|
try {
|
||||||
|
await verify2FA(tempToken, totpCode);
|
||||||
|
toast.success(t('auth.loginSuccess'));
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('auth.2fa.verifyFailed'));
|
||||||
|
setTotpCode('');
|
||||||
|
} finally {
|
||||||
|
setVerifying2FA(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setNeeds2FA(false);
|
||||||
|
setTempToken('');
|
||||||
|
setTotpCode('');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
||||||
{/* Animated background */}
|
{/* Animated background */}
|
||||||
@@ -81,91 +122,180 @@ export default function Login() {
|
|||||||
<BrandLogo size="lg" />
|
<BrandLogo size="lg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8">
|
{needs2FA ? (
|
||||||
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.welcomeBack')}</h2>
|
<>
|
||||||
<p className="text-th-text-s">
|
{/* 2FA verification step */}
|
||||||
{t('auth.loginSubtitle')}
|
<div className="mb-8 text-center">
|
||||||
</p>
|
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-th-accent/10 mb-4">
|
||||||
</div>
|
<ShieldCheck size={28} className="text-th-accent" />
|
||||||
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.2fa.title')}</h2>
|
||||||
<div>
|
<p className="text-th-text-s text-sm">
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
{t('auth.2fa.prompt')}
|
||||||
<div className="relative">
|
</p>
|
||||||
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={e => setEmail(e.target.value)}
|
|
||||||
className="input-field pl-11"
|
|
||||||
placeholder={t('auth.emailPlaceholder')}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<form onSubmit={handle2FASubmit} className="space-y-5">
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
|
<div>
|
||||||
<div className="relative">
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.2fa.codeLabel')}</label>
|
||||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<div className="relative">
|
||||||
<input
|
<ShieldCheck size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||||
type="password"
|
<input
|
||||||
value={password}
|
ref={totpInputRef}
|
||||||
onChange={e => setPassword(e.target.value)}
|
type="text"
|
||||||
className="input-field pl-11"
|
inputMode="numeric"
|
||||||
placeholder={t('auth.passwordPlaceholder')}
|
autoComplete="one-time-code"
|
||||||
required
|
value={totpCode}
|
||||||
/>
|
onChange={e => setTotpCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
|
||||||
|
className="input-field pl-11 text-center text-lg tracking-[0.3em] font-mono"
|
||||||
|
placeholder="000 000"
|
||||||
|
required
|
||||||
|
maxLength={7}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={verifying2FA || totpCode.replace(/\s/g, '').length < 6}
|
||||||
|
className="btn-primary w-full py-3"
|
||||||
|
>
|
||||||
|
{verifying2FA ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t('auth.2fa.verify')}
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
className="block mt-4 w-full text-center text-sm text-th-text-s hover:text-th-text transition-colors"
|
||||||
|
>
|
||||||
|
{t('auth.2fa.backToLogin')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.welcomeBack')}</h2>
|
||||||
|
<p className="text-th-text-s">
|
||||||
|
{t('auth.loginSubtitle')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
type="submit"
|
<div>
|
||||||
disabled={loading}
|
<label htmlFor="login-email" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
||||||
className="btn-primary w-full py-3"
|
<div className="relative">
|
||||||
>
|
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
|
||||||
{loading ? (
|
<input
|
||||||
<Loader2 size={18} className="animate-spin" />
|
id="login-email"
|
||||||
) : (
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
className="input-field pl-11"
|
||||||
|
placeholder={t('auth.emailPlaceholder')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="login-password" 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" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
id="login-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
className="input-field pl-11"
|
||||||
|
placeholder={t('auth.passwordPlaceholder')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 text-right">
|
||||||
|
<Link to="/forgot-password" className="text-sm text-th-accent hover:underline">
|
||||||
|
{t('auth.forgotPassword')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="btn-primary w-full py-3"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t('auth.login')}
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{oauthEnabled && (
|
||||||
<>
|
<>
|
||||||
{t('auth.login')}
|
<div className="relative my-6">
|
||||||
<ArrowRight size={18} />
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-th-border" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs">
|
||||||
|
<span className="px-3 bg-th-card/80 text-th-text-s">{t('auth.orContinueWith')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/api/oauth/authorize"
|
||||||
|
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<LogIn size={18} />
|
||||||
|
{t('auth.loginWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
|
||||||
|
</a>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{needsVerification && (
|
{needsVerification && (
|
||||||
<div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2">
|
<div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<AlertTriangle size={16} className="text-amber-400 flex-shrink-0 mt-0.5" />
|
<AlertTriangle size={16} className="text-amber-400 shrink-0 mt-0.5" />
|
||||||
<p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p>
|
<p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleResend}
|
onClick={handleResend}
|
||||||
disabled={resendCooldown > 0 || resending}
|
disabled={resendCooldown > 0 || resending}
|
||||||
className="flex items-center gap-1.5 text-sm text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors disabled:opacity-60 disabled:no-underline disabled:cursor-not-allowed"
|
className="flex items-center gap-1.5 text-sm text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors disabled:opacity-60 disabled:no-underline disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<RefreshCw size={13} className={resending ? 'animate-spin' : ''} />
|
<RefreshCw size={13} className={resending ? 'animate-spin' : ''} />
|
||||||
{resendCooldown > 0
|
{resendCooldown > 0
|
||||||
? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown)
|
? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown)
|
||||||
: t('auth.emailVerificationResend')}
|
: t('auth.emailVerificationResend')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{registrationMode !== 'invite' && (
|
{registrationMode !== 'invite' && (
|
||||||
<p className="mt-6 text-center text-sm text-th-text-s">
|
<p className="mt-6 text-center text-sm text-th-text-s">
|
||||||
{t('auth.noAccount')}{' '}
|
{t('auth.noAccount')}{' '}
|
||||||
<Link to="/register" className="text-th-accent hover:underline font-medium">
|
<Link to="/register" className="text-th-accent hover:underline font-medium">
|
||||||
{t('auth.signUpNow')}
|
{t('auth.signUpNow')}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
||||||
|
{t('auth.backToHome')}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
|
||||||
{t('auth.backToHome')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+43
-13
@@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
|||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { useBranding } from '../contexts/BrandingContext';
|
import { useBranding } from '../contexts/BrandingContext';
|
||||||
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle, ShieldAlert } from 'lucide-react';
|
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle, ShieldAlert, LogIn } from 'lucide-react';
|
||||||
import BrandLogo from '../components/BrandLogo';
|
import BrandLogo from '../components/BrandLogo';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export default function Register() {
|
|||||||
const [needsVerification, setNeedsVerification] = useState(false);
|
const [needsVerification, setNeedsVerification] = useState(false);
|
||||||
const { register } = useAuth();
|
const { register } = useAuth();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { registrationMode } = useBranding();
|
const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Invite-only mode without a token → show blocked message
|
// Invite-only mode without a token → show blocked message
|
||||||
@@ -33,7 +33,7 @@ export default function Register() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
if (password.length < 8) {
|
||||||
toast.error(t('auth.passwordTooShort'));
|
toast.error(t('auth.passwordTooShort'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -104,11 +104,13 @@ export default function Register() {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
|
<label htmlFor="register-username" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User 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" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
|
id="register-username"
|
||||||
type="text"
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={e => setUsername(e.target.value)}
|
onChange={e => setUsername(e.target.value)}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
@@ -120,11 +122,13 @@ export default function Register() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
|
<label htmlFor="register-display-name" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User 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" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
|
id="register-display-name"
|
||||||
type="text"
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={e => setDisplayName(e.target.value)}
|
onChange={e => setDisplayName(e.target.value)}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
@@ -135,11 +139,13 @@ export default function Register() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
<label htmlFor="register-email" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
|
id="register-email"
|
||||||
type="email"
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={e => setEmail(e.target.value)}
|
onChange={e => setEmail(e.target.value)}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
@@ -150,11 +156,13 @@ export default function Register() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
|
<label htmlFor="register-password" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
|
id="register-password"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
@@ -166,11 +174,13 @@ export default function Register() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.confirmPassword')}</label>
|
<label htmlFor="register-confirm-password" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.confirmPassword')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
|
id="register-confirm-password"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={e => setConfirmPassword(e.target.value)}
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
@@ -197,6 +207,26 @@ export default function Register() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{oauthEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="relative my-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-th-border" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs">
|
||||||
|
<span className="px-3 bg-th-card/80 text-th-text-s">{t('auth.orContinueWith')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/api/oauth/authorize"
|
||||||
|
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<LogIn size={18} />
|
||||||
|
{t('auth.registerWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-th-text-s">
|
<p className="mt-6 text-center text-sm text-th-text-s">
|
||||||
{t('auth.hasAccount')}{' '}
|
{t('auth.hasAccount')}{' '}
|
||||||
<Link to="/login" className="text-th-accent hover:underline font-medium">
|
<Link to="/login" className="text-th-accent hover:underline font-medium">
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { Lock, ArrowRight, Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
import BrandLogo from '../components/BrandLogo';
|
||||||
|
import api from '../services/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
|
export default function ResetPassword() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||||
|
toast.error(t('auth.passwordMinLength'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirm) {
|
||||||
|
toast.error(t('auth.passwordMismatch'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.post('/auth/reset-password', { token, password });
|
||||||
|
setDone(true);
|
||||||
|
toast.success(t('auth.resetPasswordSuccess'));
|
||||||
|
setTimeout(() => navigate('/login'), 2500);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('auth.resetPasswordFailed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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-30">
|
||||||
|
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-pink-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '4s' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
|
<BrandLogo size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!token ? (
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<XCircle size={48} className="mx-auto text-red-400" />
|
||||||
|
<h2 className="text-2xl font-bold text-th-text">{t('auth.resetPasswordFailed')}</h2>
|
||||||
|
<p className="text-th-text-s">{t('auth.resetTokenMissing')}</p>
|
||||||
|
<Link to="/forgot-password" className="btn-primary inline-flex items-center gap-2 mt-4">
|
||||||
|
{t('auth.forgotPasswordTitle')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : done ? (
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<CheckCircle size={48} className="mx-auto text-green-400" />
|
||||||
|
<h2 className="text-2xl font-bold text-th-text">{t('auth.resetPasswordSuccessTitle')}</h2>
|
||||||
|
<p className="text-th-text-s">{t('auth.resetPasswordSuccess')}</p>
|
||||||
|
<Link to="/login" className="btn-primary inline-flex items-center gap-2 mt-4">
|
||||||
|
{t('auth.login')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.resetPasswordTitle')}</h2>
|
||||||
|
<p className="text-th-text-s">{t('auth.resetPasswordSubtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="reset-password" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.newPassword')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
id="reset-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
className="input-field pl-11"
|
||||||
|
placeholder={t('auth.passwordPlaceholder')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="reset-confirm" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.confirmPassword')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
id="reset-confirm"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={e => setConfirm(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.resetPasswordSubmit')}
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link to="/login" className="block mt-6 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
||||||
|
{t('auth.backToLogin')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+231
-50
@@ -4,24 +4,29 @@ import {
|
|||||||
ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio,
|
ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio,
|
||||||
Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck,
|
Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck,
|
||||||
Shield, Save, UserPlus, X, Share2, Globe, Send,
|
Shield, Save, UserPlus, X, Share2, Globe, Send,
|
||||||
FileText, Upload, Trash2,
|
FileText, Upload, Trash2, Link, BarChart3,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Modal from '../components/Modal';
|
import Modal from '../components/Modal';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
import { isCurrentThemeDark } from '../themes';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { useBranding } from '../contexts/BrandingContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import RecordingList from '../components/RecordingList';
|
import RecordingList from '../components/RecordingList';
|
||||||
|
import AnalyticsList from '../components/AnalyticsList';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export default function RoomDetail() {
|
export default function RoomDetail() {
|
||||||
const { uid } = useParams();
|
const { uid } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { transcriptionEnabled } = useBranding();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
const [room, setRoom] = useState(null);
|
const [room, setRoom] = useState(null);
|
||||||
const [status, setStatus] = useState({ running: false, participantCount: 0, moderatorCount: 0 });
|
const [status, setStatus] = useState({ running: false, participantCount: 0, moderatorCount: 0 });
|
||||||
const [recordings, setRecordings] = useState([]);
|
const [recordings, setRecordings] = useState([]);
|
||||||
|
const [analytics, setAnalytics] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [actionLoading, setActionLoading] = useState(null);
|
const [actionLoading, setActionLoading] = useState(null);
|
||||||
const [activeTab, setActiveTab] = useState('overview');
|
const [activeTab, setActiveTab] = useState('overview');
|
||||||
@@ -31,10 +36,25 @@ export default function RoomDetail() {
|
|||||||
const [shareSearch, setShareSearch] = useState('');
|
const [shareSearch, setShareSearch] = useState('');
|
||||||
const [shareResults, setShareResults] = useState([]);
|
const [shareResults, setShareResults] = useState([]);
|
||||||
const [shareSearching, setShareSearching] = useState(false);
|
const [shareSearching, setShareSearching] = useState(false);
|
||||||
|
const [waitingToJoin, setWaitingToJoin] = useState(false);
|
||||||
|
const prevRunningRef = useRef(false);
|
||||||
|
const [showCopyMenu, setShowCopyMenu] = useState(false);
|
||||||
|
const copyMenuRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (copyMenuRef.current && !copyMenuRef.current.contains(e.target)) {
|
||||||
|
setShowCopyMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Federation invite state
|
// Federation invite state
|
||||||
const [showFedInvite, setShowFedInvite] = useState(false);
|
const [showFedInvite, setShowFedInvite] = useState(false);
|
||||||
const [fedAddress, setFedAddress] = useState('');
|
const [fedAddress, setFedAddress] = useState('');
|
||||||
|
const [fedEmails, setFedEmails] = useState('');
|
||||||
const [fedMessage, setFedMessage] = useState('');
|
const [fedMessage, setFedMessage] = useState('');
|
||||||
const [fedSending, setFedSending] = useState(false);
|
const [fedSending, setFedSending] = useState(false);
|
||||||
|
|
||||||
@@ -45,7 +65,10 @@ export default function RoomDetail() {
|
|||||||
|
|
||||||
const isOwner = room && user && room.user_id === user.id;
|
const isOwner = room && user && room.user_id === user.id;
|
||||||
const isShared = room && !!room.shared;
|
const isShared = room && !!room.shared;
|
||||||
|
const isAdmin = user?.role === 'admin';
|
||||||
const canManage = isOwner || isShared;
|
const canManage = isOwner || isShared;
|
||||||
|
const canStart = canManage || isAdmin || !!room?.anyone_can_start;
|
||||||
|
const canEnd = canManage || isAdmin;
|
||||||
|
|
||||||
const fetchRoom = async () => {
|
const fetchRoom = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -79,18 +102,43 @@ export default function RoomDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchAnalytics = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/analytics/room/${uid}`);
|
||||||
|
setAnalytics(res.data.analytics || []);
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRoom();
|
fetchRoom();
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
fetchRecordings();
|
fetchRecordings();
|
||||||
|
fetchAnalytics();
|
||||||
const interval = setInterval(fetchStatus, 10000);
|
const interval = setInterval(fetchStatus, 10000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [uid]);
|
}, [uid]);
|
||||||
|
|
||||||
|
// Auto-join when meeting starts while waiting
|
||||||
|
useEffect(() => {
|
||||||
|
if (!prevRunningRef.current && status.running && waitingToJoin) {
|
||||||
|
new Audio('/sounds/meeting-started.mp3').play().catch(() => {});
|
||||||
|
toast.success(t('room.meetingStarted'));
|
||||||
|
setWaitingToJoin(false);
|
||||||
|
setActionLoading('join');
|
||||||
|
api.post(`/rooms/${uid}/join`, { dark_mode: isCurrentThemeDark() })
|
||||||
|
.then(res => { if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank'); })
|
||||||
|
.catch(err => toast.error(err.response?.data?.error || t('room.joinFailed')))
|
||||||
|
.finally(() => setActionLoading(null));
|
||||||
|
}
|
||||||
|
prevRunningRef.current = status.running;
|
||||||
|
}, [status.running]);
|
||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
setActionLoading('start');
|
setActionLoading('start');
|
||||||
try {
|
try {
|
||||||
const res = await api.post(`/rooms/${uid}/start`);
|
const res = await api.post(`/rooms/${uid}/start`, { dark_mode: isCurrentThemeDark() });
|
||||||
if (res.data.joinUrl) {
|
if (res.data.joinUrl) {
|
||||||
window.open(res.data.joinUrl, '_blank');
|
window.open(res.data.joinUrl, '_blank');
|
||||||
}
|
}
|
||||||
@@ -104,9 +152,16 @@ export default function RoomDetail() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleJoin = async () => {
|
const handleJoin = async () => {
|
||||||
|
if (!status.running) {
|
||||||
|
setWaitingToJoin(true);
|
||||||
|
toast(t('room.guestWaitingTitle'), { icon: '🕐' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWaitingToJoin(false);
|
||||||
setActionLoading('join');
|
setActionLoading('join');
|
||||||
try {
|
try {
|
||||||
const data = room.access_code ? { access_code: prompt(t('room.enterAccessCode')) } : {};
|
const data = { dark_mode: isCurrentThemeDark() };
|
||||||
|
if (room.access_code) data.access_code = prompt(t('room.enterAccessCode'));
|
||||||
const res = await api.post(`/rooms/${uid}/join`, data);
|
const res = await api.post(`/rooms/${uid}/join`, data);
|
||||||
if (res.data.joinUrl) {
|
if (res.data.joinUrl) {
|
||||||
window.open(res.data.joinUrl, '_blank');
|
window.open(res.data.joinUrl, '_blank');
|
||||||
@@ -148,6 +203,9 @@ export default function RoomDetail() {
|
|||||||
record_meeting: !!editRoom.record_meeting,
|
record_meeting: !!editRoom.record_meeting,
|
||||||
guest_access: !!editRoom.guest_access,
|
guest_access: !!editRoom.guest_access,
|
||||||
moderator_code: editRoom.moderator_code,
|
moderator_code: editRoom.moderator_code,
|
||||||
|
learning_analytics: !!editRoom.learning_analytics,
|
||||||
|
analytics_visibility: editRoom.analytics_visibility || 'owner',
|
||||||
|
recording_transcript: !!editRoom.recording_transcript,
|
||||||
});
|
});
|
||||||
setRoom(res.data.room);
|
setRoom(res.data.room);
|
||||||
setEditRoom(res.data.room);
|
setEditRoom(res.data.room);
|
||||||
@@ -159,9 +217,10 @@ export default function RoomDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyLink = () => {
|
const copyToClipboard = (url) => {
|
||||||
navigator.clipboard.writeText(`${window.location.origin}/rooms/${uid}`);
|
navigator.clipboard.writeText(url);
|
||||||
toast.success(t('room.linkCopied'));
|
toast.success(t('room.linkCopied'));
|
||||||
|
setShowCopyMenu(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Federation invite handler
|
// Federation invite handler
|
||||||
@@ -216,25 +275,51 @@ export default function RoomDetail() {
|
|||||||
|
|
||||||
const handleFedInvite = async (e) => {
|
const handleFedInvite = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Accept @user@domain or user@domain — must have a domain part
|
const hasAddress = fedAddress.trim().length > 0;
|
||||||
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
|
const hasEmails = fedEmails.trim().length > 0;
|
||||||
if (!normalized.includes('@') || normalized.endsWith('@')) {
|
|
||||||
|
if (!hasAddress && !hasEmails) {
|
||||||
toast.error(t('federation.addressHint'));
|
toast.error(t('federation.addressHint'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFedSending(true);
|
setFedSending(true);
|
||||||
try {
|
try {
|
||||||
await api.post('/federation/invite', {
|
if (hasAddress) {
|
||||||
room_uid: uid,
|
// Federation address mode
|
||||||
to: fedAddress,
|
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
|
||||||
message: fedMessage || undefined,
|
if (!normalized.includes('@') || normalized.endsWith('@')) {
|
||||||
});
|
toast.error(t('federation.addressHint'));
|
||||||
toast.success(t('federation.sent'));
|
setFedSending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api.post('/federation/invite', {
|
||||||
|
room_uid: uid,
|
||||||
|
to: fedAddress,
|
||||||
|
message: fedMessage || undefined,
|
||||||
|
});
|
||||||
|
toast.success(t('federation.sent'));
|
||||||
|
} else {
|
||||||
|
// Email mode
|
||||||
|
const emailList = fedEmails.split(',').map(e => e.trim()).filter(Boolean);
|
||||||
|
if (emailList.length === 0) {
|
||||||
|
toast.error(t('federation.emailHint'));
|
||||||
|
setFedSending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api.post('/rooms/invite-email', {
|
||||||
|
room_uid: uid,
|
||||||
|
emails: emailList,
|
||||||
|
message: fedMessage || undefined,
|
||||||
|
});
|
||||||
|
toast.success(t('federation.emailSent'));
|
||||||
|
}
|
||||||
setShowFedInvite(false);
|
setShowFedInvite(false);
|
||||||
setFedAddress('');
|
setFedAddress('');
|
||||||
|
setFedEmails('');
|
||||||
setFedMessage('');
|
setFedMessage('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.response?.data?.error || t('federation.sendFailed'));
|
toast.error(err.response?.data?.error || t(hasAddress ? 'federation.sendFailed' : 'federation.emailSendFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setFedSending(false);
|
setFedSending(false);
|
||||||
}
|
}
|
||||||
@@ -295,6 +380,7 @@ export default function RoomDetail() {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'overview', label: t('room.overview'), icon: Play },
|
{ id: 'overview', label: t('room.overview'), icon: Play },
|
||||||
{ id: 'recordings', label: t('room.recordings'), icon: FileVideo, count: recordings.length },
|
{ id: 'recordings', label: t('room.recordings'), icon: FileVideo, count: recordings.length },
|
||||||
|
{ id: 'analytics', label: t('room.analytics'), icon: BarChart3, count: analytics.length, hidden: !room.learning_analytics || (isShared && room.analytics_visibility !== 'shared') },
|
||||||
...(isOwner ? [{ id: 'settings', label: t('room.settings'), icon: Settings }] : []),
|
...(isOwner ? [{ id: 'settings', label: t('room.settings'), icon: Settings }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -333,10 +419,33 @@ export default function RoomDetail() {
|
|||||||
{t('common.protected')}
|
{t('common.protected')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button onClick={copyLink} className="flex items-center gap-1 hover:text-th-accent transition-colors">
|
<div className="relative" ref={copyMenuRef}>
|
||||||
<Copy size={14} />
|
<button
|
||||||
{t('room.copyLink')}
|
onClick={() => setShowCopyMenu(v => !v)}
|
||||||
</button>
|
className="flex items-center gap-1 hover:text-th-accent transition-colors"
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
{t('room.copyLink')}
|
||||||
|
</button>
|
||||||
|
{showCopyMenu && (
|
||||||
|
<div className="absolute bottom-full left-0 mb-2 bg-th-surface border border-th-border rounded-lg shadow-lg z-50 min-w-[160px] py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(`${window.location.origin}/rooms/${uid}`)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Link size={12} />
|
||||||
|
{t('room.copyRoomLink')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(`${window.location.origin}/join/${uid}`)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Users size={12} />
|
||||||
|
{t('room.copyGuestLink')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -351,19 +460,22 @@ export default function RoomDetail() {
|
|||||||
<span className="hidden sm:inline">{t('federation.inviteRemote')}</span>
|
<span className="hidden sm:inline">{t('federation.inviteRemote')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canManage && !status.running && (
|
{canStart && !status.running && !waitingToJoin && (
|
||||||
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
|
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
|
||||||
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
|
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
|
||||||
{t('room.start')}
|
{t('room.start')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{status.running && (
|
<button
|
||||||
<button onClick={handleJoin} disabled={actionLoading === 'join'} className="btn-primary">
|
onClick={waitingToJoin ? () => setWaitingToJoin(false) : handleJoin}
|
||||||
{actionLoading === 'join' ? <Loader2 size={16} className="animate-spin" /> : <ExternalLink size={16} />}
|
disabled={actionLoading === 'join'}
|
||||||
{t('room.join')}
|
className={waitingToJoin ? 'btn-ghost' : 'btn-primary'}
|
||||||
</button>
|
title={waitingToJoin ? t('room.guestCancelWaiting') : undefined}
|
||||||
)}
|
>
|
||||||
{canManage && status.running && (
|
{(actionLoading === 'join' || waitingToJoin) ? <Loader2 size={16} className="animate-spin" /> : <ExternalLink size={16} />}
|
||||||
|
{waitingToJoin ? t('room.waitingToJoin') : t('room.join')}
|
||||||
|
</button>
|
||||||
|
{canEnd && status.running && (
|
||||||
<button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger">
|
<button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger">
|
||||||
{actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />}
|
{actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />}
|
||||||
{t('room.end')}
|
{t('room.end')}
|
||||||
@@ -375,7 +487,7 @@ export default function RoomDetail() {
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex items-center gap-1 mb-6 border-b border-th-border">
|
<div className="flex items-center gap-1 mb-6 border-b border-th-border">
|
||||||
{tabs.map(tab => (
|
{tabs.filter(tab => !tab.hidden).map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
@@ -404,7 +516,7 @@ export default function RoomDetail() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-th-text-s">{t('room.meetingId')}</span>
|
<span className="text-th-text-s">{t('room.meetingId')}</span>
|
||||||
<code className="bg-th-bg-s px-2 py-0.5 rounded text-xs text-th-text font-mono">{room.uid}</code>
|
<code className="bg-th-bg-s px-2 py-0.5 rounded-sm text-xs text-th-text font-mono">{room.uid}</code>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-th-text-s">{t('room.status')}</span>
|
<span className="text-th-text-s">{t('room.status')}</span>
|
||||||
@@ -466,22 +578,29 @@ export default function RoomDetail() {
|
|||||||
<RecordingList recordings={recordings} onRefresh={fetchRecordings} />
|
<RecordingList recordings={recordings} onRefresh={fetchRecordings} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'analytics' && (
|
||||||
|
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} isOwner={isOwner} />
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'settings' && isOwner && editRoom && (
|
{activeTab === 'settings' && isOwner && editRoom && (
|
||||||
<form onSubmit={handleSaveSettings} className="card p-6 space-y-5 max-w-2xl">
|
<form onSubmit={handleSaveSettings} className="card p-6 space-y-5 max-w-2xl">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')}</label>
|
<label htmlFor="room-settings-name" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')}</label>
|
||||||
<input
|
<input
|
||||||
|
id="room-settings-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={editRoom.name}
|
value={editRoom.name}
|
||||||
onChange={e => setEditRoom({ ...editRoom, name: e.target.value })}
|
onChange={e => setEditRoom({ ...editRoom, name: e.target.value })}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
required
|
required
|
||||||
|
minLength={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.welcomeMsg')}</label>
|
<label htmlFor="room-settings-welcome" className="block text-sm font-medium text-th-text mb-1.5">{t('room.welcomeMsg')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="room-settings-welcome"
|
||||||
value={editRoom.welcome_message || ''}
|
value={editRoom.welcome_message || ''}
|
||||||
onChange={e => setEditRoom({ ...editRoom, welcome_message: e.target.value })}
|
onChange={e => setEditRoom({ ...editRoom, welcome_message: e.target.value })}
|
||||||
className="input-field resize-none"
|
className="input-field resize-none"
|
||||||
@@ -491,8 +610,9 @@ export default function RoomDetail() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.maxParticipants')}</label>
|
<label htmlFor="room-settings-max" className="block text-sm font-medium text-th-text mb-1.5">{t('room.maxParticipants')}</label>
|
||||||
<input
|
<input
|
||||||
|
id="room-settings-max"
|
||||||
type="number"
|
type="number"
|
||||||
value={editRoom.max_participants}
|
value={editRoom.max_participants}
|
||||||
onChange={e => setEditRoom({ ...editRoom, max_participants: parseInt(e.target.value) || 0 })}
|
onChange={e => setEditRoom({ ...editRoom, max_participants: parseInt(e.target.value) || 0 })}
|
||||||
@@ -501,8 +621,9 @@ export default function RoomDetail() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.accessCode')}</label>
|
<label htmlFor="room-settings-access-code" className="block text-sm font-medium text-th-text mb-1.5">{t('room.accessCode')}</label>
|
||||||
<input
|
<input
|
||||||
|
id="room-settings-access-code"
|
||||||
type="text"
|
type="text"
|
||||||
value={editRoom.access_code || ''}
|
value={editRoom.access_code || ''}
|
||||||
onChange={e => setEditRoom({ ...editRoom, access_code: e.target.value })}
|
onChange={e => setEditRoom({ ...editRoom, access_code: e.target.value })}
|
||||||
@@ -518,7 +639,7 @@ export default function RoomDetail() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!!editRoom.mute_on_join}
|
checked={!!editRoom.mute_on_join}
|
||||||
onChange={e => setEditRoom({ ...editRoom, mute_on_join: e.target.checked })}
|
onChange={e => setEditRoom({ ...editRoom, mute_on_join: e.target.checked })}
|
||||||
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
|
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-th-text">{t('room.muteOnJoin')}</span>
|
<span className="text-sm text-th-text">{t('room.muteOnJoin')}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -527,7 +648,7 @@ export default function RoomDetail() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!!editRoom.require_approval}
|
checked={!!editRoom.require_approval}
|
||||||
onChange={e => setEditRoom({ ...editRoom, require_approval: e.target.checked })}
|
onChange={e => setEditRoom({ ...editRoom, require_approval: e.target.checked })}
|
||||||
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
|
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-th-text">{t('room.requireApproval')}</span>
|
<span className="text-sm text-th-text">{t('room.requireApproval')}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -536,7 +657,7 @@ export default function RoomDetail() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!!editRoom.anyone_can_start}
|
checked={!!editRoom.anyone_can_start}
|
||||||
onChange={e => setEditRoom({ ...editRoom, anyone_can_start: e.target.checked })}
|
onChange={e => setEditRoom({ ...editRoom, anyone_can_start: e.target.checked })}
|
||||||
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
|
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-th-text">{t('room.anyoneCanStart')}</span>
|
<span className="text-sm text-th-text">{t('room.anyoneCanStart')}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -545,7 +666,7 @@ export default function RoomDetail() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!!editRoom.all_join_moderator}
|
checked={!!editRoom.all_join_moderator}
|
||||||
onChange={e => setEditRoom({ ...editRoom, all_join_moderator: e.target.checked })}
|
onChange={e => setEditRoom({ ...editRoom, all_join_moderator: e.target.checked })}
|
||||||
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
|
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-th-text">{t('room.allJoinModerator')}</span>
|
<span className="text-sm text-th-text">{t('room.allJoinModerator')}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -554,10 +675,46 @@ export default function RoomDetail() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!!editRoom.record_meeting}
|
checked={!!editRoom.record_meeting}
|
||||||
onChange={e => setEditRoom({ ...editRoom, record_meeting: e.target.checked })}
|
onChange={e => setEditRoom({ ...editRoom, record_meeting: e.target.checked })}
|
||||||
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
|
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-th-text">{t('room.allowRecording')}</span>
|
<span className="text-sm text-th-text">{t('room.allowRecording')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
{transcriptionEnabled && (
|
||||||
|
<label className={`flex items-center gap-3 ${editRoom.record_meeting ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!editRoom.recording_transcript}
|
||||||
|
disabled={!editRoom.record_meeting}
|
||||||
|
onChange={e => setEditRoom({ ...editRoom, recording_transcript: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-th-text">{t('room.enableTranscript')}</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-sm 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 htmlFor="room-settings-analytics-visibility" className="block text-xs font-medium text-th-text-s mb-1">{t('room.analyticsVisibility')}</label>
|
||||||
|
<select
|
||||||
|
id="room-settings-analytics-visibility"
|
||||||
|
value={editRoom.analytics_visibility || 'owner'}
|
||||||
|
onChange={e => setEditRoom({ ...editRoom, analytics_visibility: e.target.value })}
|
||||||
|
className="input-field text-sm py-1.5 max-w-xs"
|
||||||
|
>
|
||||||
|
<option value="owner">{t('room.analyticsOwnerOnly')}</option>
|
||||||
|
<option value="shared">{t('room.analyticsSharedUsers')}</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-th-text-s mt-1">{t('room.analyticsVisibilityHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Guest access section */}
|
{/* Guest access section */}
|
||||||
@@ -565,8 +722,9 @@ export default function RoomDetail() {
|
|||||||
<h3 className="text-sm font-semibold text-th-text">{t('room.guestAccessTitle')}</h3>
|
<h3 className="text-sm font-semibold text-th-text">{t('room.guestAccessTitle')}</h3>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.moderatorCode')}</label>
|
<label htmlFor="room-settings-moderator-code" className="block text-sm font-medium text-th-text mb-1.5">{t('room.moderatorCode')}</label>
|
||||||
<input
|
<input
|
||||||
|
id="room-settings-moderator-code"
|
||||||
type="text"
|
type="text"
|
||||||
value={editRoom.moderator_code || ''}
|
value={editRoom.moderator_code || ''}
|
||||||
onChange={e => setEditRoom({ ...editRoom, moderator_code: e.target.value })}
|
onChange={e => setEditRoom({ ...editRoom, moderator_code: e.target.value })}
|
||||||
@@ -608,11 +766,11 @@ export default function RoomDetail() {
|
|||||||
{room.presentation_file ? (
|
{room.presentation_file ? (
|
||||||
<div 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 justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<FileText size={16} className="text-th-accent flex-shrink-0" />
|
<FileText size={16} className="text-th-accent shrink-0" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs text-th-text-s">{t('room.presentationCurrent')}</p>
|
<p className="text-xs text-th-text-s">{t('room.presentationCurrent')}</p>
|
||||||
<p className="text-sm text-th-text font-medium truncate">
|
<p className="text-sm text-th-text font-medium truncate">
|
||||||
{room.presentation_name || `presentation.${room.presentation_file?.split('.').pop()}`}
|
{room.presentation_file}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -620,7 +778,7 @@ export default function RoomDetail() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handlePresentationRemove}
|
onClick={handlePresentationRemove}
|
||||||
disabled={removingPresentation}
|
disabled={removingPresentation}
|
||||||
className="btn-ghost text-th-error hover:bg-th-error/10 flex-shrink-0 text-xs py-1.5 px-3"
|
className="btn-ghost text-th-error hover:bg-th-error/10 shrink-0 text-xs py-1.5 px-3"
|
||||||
>
|
>
|
||||||
{removingPresentation ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
|
{removingPresentation ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
|
||||||
{t('room.presentationRemove')}
|
{t('room.presentationRemove')}
|
||||||
@@ -679,7 +837,7 @@ export default function RoomDetail() {
|
|||||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
|
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden"
|
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0 overflow-hidden"
|
||||||
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
||||||
>
|
>
|
||||||
{u.avatar_image ? (
|
{u.avatar_image ? (
|
||||||
@@ -705,7 +863,7 @@ export default function RoomDetail() {
|
|||||||
<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 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="flex items-center gap-3 min-w-0">
|
||||||
<div
|
<div
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden"
|
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0 overflow-hidden"
|
||||||
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
||||||
>
|
>
|
||||||
{u.avatar_image ? (
|
{u.avatar_image ? (
|
||||||
@@ -722,7 +880,7 @@ export default function RoomDetail() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleUnshare(u.id)}
|
onClick={() => handleUnshare(u.id)}
|
||||||
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors flex-shrink-0"
|
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors shrink-0"
|
||||||
title={t('room.shareRemove')}
|
title={t('room.shareRemove')}
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
@@ -748,20 +906,43 @@ export default function RoomDetail() {
|
|||||||
<p className="text-sm text-th-text-s mb-4">{t('federation.inviteSubtitle')}</p>
|
<p className="text-sm text-th-text-s mb-4">{t('federation.inviteSubtitle')}</p>
|
||||||
<form onSubmit={handleFedInvite} className="space-y-4">
|
<form onSubmit={handleFedInvite} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
|
<label htmlFor="fed-invite-address" className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
|
||||||
<input
|
<input
|
||||||
|
id="fed-invite-address"
|
||||||
type="text"
|
type="text"
|
||||||
value={fedAddress}
|
value={fedAddress}
|
||||||
onChange={e => setFedAddress(e.target.value)}
|
onChange={e => { setFedAddress(e.target.value); if (e.target.value) setFedEmails(''); }}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
placeholder={t('federation.addressPlaceholder')}
|
placeholder={t('federation.addressPlaceholder')}
|
||||||
required
|
disabled={fedEmails.trim().length > 0}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
|
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 my-2">
|
||||||
|
<div className="flex-1 border-t border-th-border" />
|
||||||
|
<span className="text-xs text-th-text-s uppercase">{t('common.or')}</span>
|
||||||
|
<div className="flex-1 border-t border-th-border" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label>
|
<label htmlFor="fed-invite-emails" className="block text-sm font-medium text-th-text mb-1.5">{t('federation.emailLabel')}</label>
|
||||||
|
<input
|
||||||
|
id="fed-invite-emails"
|
||||||
|
type="text"
|
||||||
|
value={fedEmails}
|
||||||
|
onChange={e => { setFedEmails(e.target.value); if (e.target.value) setFedAddress(''); }}
|
||||||
|
className="input-field"
|
||||||
|
placeholder={t('federation.emailPlaceholder')}
|
||||||
|
disabled={fedAddress.trim().length > 0}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-th-text-s mt-1">{t('federation.emailHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="fed-invite-message" className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="fed-invite-message"
|
||||||
value={fedMessage}
|
value={fedMessage}
|
||||||
onChange={e => setFedMessage(e.target.value)}
|
onChange={e => setFedMessage(e.target.value)}
|
||||||
className="input-field resize-none"
|
className="input-field resize-none"
|
||||||
@@ -773,7 +954,7 @@ export default function RoomDetail() {
|
|||||||
<button type="button" onClick={() => setShowFedInvite(false)} className="btn-secondary flex-1">
|
<button type="button" onClick={() => setShowFedInvite(false)} className="btn-secondary flex-1">
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" disabled={fedSending} className="btn-primary flex-1">
|
<button type="submit" disabled={fedSending || (!fedAddress.trim() && !fedEmails.trim())} className="btn-primary flex-1">
|
||||||
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||||
{t('federation.send')}
|
{t('federation.send')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+418
-21
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X } from 'lucide-react';
|
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X, Calendar, Plus, Trash2, Copy, Eye, EyeOff, Shield, ShieldCheck, ShieldOff } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
@@ -38,6 +38,121 @@ export default function Settings() {
|
|||||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
// CalDAV token state
|
||||||
|
const [caldavTokens, setCaldavTokens] = useState([]);
|
||||||
|
const [caldavLoading, setCaldavLoading] = useState(false);
|
||||||
|
const [newTokenName, setNewTokenName] = useState('');
|
||||||
|
const [creatingToken, setCreatingToken] = useState(false);
|
||||||
|
const [newlyCreatedToken, setNewlyCreatedToken] = useState(null);
|
||||||
|
const [tokenVisible, setTokenVisible] = useState(false);
|
||||||
|
|
||||||
|
// 2FA state
|
||||||
|
const [twoFaEnabled, setTwoFaEnabled] = useState(!!user?.totp_enabled);
|
||||||
|
const [twoFaLoading, setTwoFaLoading] = useState(false);
|
||||||
|
const [twoFaSetupData, setTwoFaSetupData] = useState(null); // { secret, uri, qrDataUrl }
|
||||||
|
const [twoFaCode, setTwoFaCode] = useState('');
|
||||||
|
const [twoFaEnabling, setTwoFaEnabling] = useState(false);
|
||||||
|
const [twoFaDisablePassword, setTwoFaDisablePassword] = useState('');
|
||||||
|
const [twoFaDisableCode, setTwoFaDisableCode] = useState('');
|
||||||
|
const [twoFaDisabling, setTwoFaDisabling] = useState(false);
|
||||||
|
const [showDisableForm, setShowDisableForm] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeSection === 'caldav') {
|
||||||
|
setCaldavLoading(true);
|
||||||
|
api.get('/calendar/caldav-tokens')
|
||||||
|
.then(r => setCaldavTokens(r.data.tokens || []))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setCaldavLoading(false));
|
||||||
|
}
|
||||||
|
if (activeSection === 'security') {
|
||||||
|
setTwoFaLoading(true);
|
||||||
|
api.get('/auth/2fa/status')
|
||||||
|
.then(r => setTwoFaEnabled(r.data.enabled))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setTwoFaLoading(false));
|
||||||
|
}
|
||||||
|
}, [activeSection]);
|
||||||
|
|
||||||
|
const handleCreateToken = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newTokenName.trim()) return;
|
||||||
|
setCreatingToken(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post('/calendar/caldav-tokens', { name: newTokenName.trim() });
|
||||||
|
setNewlyCreatedToken(res.data.plainToken);
|
||||||
|
setTokenVisible(false);
|
||||||
|
setNewTokenName('');
|
||||||
|
const r = await api.get('/calendar/caldav-tokens');
|
||||||
|
setCaldavTokens(r.data.tokens || []);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('settings.caldav.createFailed'));
|
||||||
|
} finally {
|
||||||
|
setCreatingToken(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeToken = async (id) => {
|
||||||
|
if (!confirm(t('settings.caldav.revokeConfirm'))) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/calendar/caldav-tokens/${id}`);
|
||||||
|
setCaldavTokens(prev => prev.filter(tk => tk.id !== id));
|
||||||
|
toast.success(t('settings.caldav.revoked'));
|
||||||
|
} catch {
|
||||||
|
toast.error(t('settings.caldav.revokeFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2FA handlers
|
||||||
|
const handleSetup2FA = async () => {
|
||||||
|
setTwoFaLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post('/auth/2fa/setup');
|
||||||
|
// Generate QR code data URL client-side
|
||||||
|
const QRCode = (await import('qrcode')).default;
|
||||||
|
const qrDataUrl = await QRCode.toDataURL(res.data.uri, { width: 200, margin: 2, color: { dark: '#000000', light: '#ffffff' } });
|
||||||
|
setTwoFaSetupData({ secret: res.data.secret, uri: res.data.uri, qrDataUrl });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('settings.security.setupFailed'));
|
||||||
|
} finally {
|
||||||
|
setTwoFaLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnable2FA = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setTwoFaEnabling(true);
|
||||||
|
try {
|
||||||
|
await api.post('/auth/2fa/enable', { code: twoFaCode });
|
||||||
|
setTwoFaEnabled(true);
|
||||||
|
setTwoFaSetupData(null);
|
||||||
|
setTwoFaCode('');
|
||||||
|
toast.success(t('settings.security.enabled'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('settings.security.enableFailed'));
|
||||||
|
setTwoFaCode('');
|
||||||
|
} finally {
|
||||||
|
setTwoFaEnabling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisable2FA = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setTwoFaDisabling(true);
|
||||||
|
try {
|
||||||
|
await api.post('/auth/2fa/disable', { password: twoFaDisablePassword, code: twoFaDisableCode });
|
||||||
|
setTwoFaEnabled(false);
|
||||||
|
setShowDisableForm(false);
|
||||||
|
setTwoFaDisablePassword('');
|
||||||
|
setTwoFaDisableCode('');
|
||||||
|
toast.success(t('settings.security.disabled'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('settings.security.disableFailed'));
|
||||||
|
} finally {
|
||||||
|
setTwoFaDisabling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const groups = getThemeGroups();
|
const groups = getThemeGroups();
|
||||||
|
|
||||||
const avatarColors = [
|
const avatarColors = [
|
||||||
@@ -137,8 +252,10 @@ export default function Settings() {
|
|||||||
const sections = [
|
const sections = [
|
||||||
{ id: 'profile', label: t('settings.profile'), icon: User },
|
{ id: 'profile', label: t('settings.profile'), icon: User },
|
||||||
{ id: 'password', label: t('settings.password'), icon: Lock },
|
{ id: 'password', label: t('settings.password'), icon: Lock },
|
||||||
|
{ id: 'security', label: t('settings.security.title'), icon: Shield },
|
||||||
{ id: 'language', label: t('settings.language'), icon: Globe },
|
{ id: 'language', label: t('settings.language'), icon: Globe },
|
||||||
{ id: 'themes', label: t('settings.themes'), icon: Palette },
|
{ id: 'themes', label: t('settings.themes'), icon: Palette },
|
||||||
|
{ id: 'caldav', label: t('settings.caldav.title'), icon: Calendar },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -150,7 +267,7 @@ export default function Settings() {
|
|||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-6">
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
{/* Section nav */}
|
{/* Section nav */}
|
||||||
<div className="md:w-56 flex-shrink-0">
|
<div className="md:w-56 shrink-0">
|
||||||
<nav className="flex md:flex-col gap-1">
|
<nav className="flex md:flex-col gap-1">
|
||||||
{sections.map(s => (
|
{sections.map(s => (
|
||||||
<button
|
<button
|
||||||
@@ -184,7 +301,7 @@ export default function Settings() {
|
|||||||
{user?.avatar_image ? (
|
{user?.avatar_image ? (
|
||||||
<img
|
<img
|
||||||
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
|
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
|
||||||
alt="Avatar"
|
alt=""
|
||||||
className="w-16 h-16 rounded-full object-cover"
|
className="w-16 h-16 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -199,6 +316,7 @@ export default function Settings() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={uploadingAvatar}
|
disabled={uploadingAvatar}
|
||||||
|
aria-label={t('settings.uploadImage')}
|
||||||
className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||||
>
|
>
|
||||||
{uploadingAvatar ? (
|
{uploadingAvatar ? (
|
||||||
@@ -243,12 +361,15 @@ export default function Settings() {
|
|||||||
|
|
||||||
{/* Avatar Color (fallback) */}
|
{/* Avatar Color (fallback) */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<label className="block text-sm font-medium text-th-text mb-3">{t('settings.avatarColor')}</label>
|
<p id="avatar-color-label" className="block text-sm font-medium text-th-text mb-3">{t('settings.avatarColor')}</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div role="radiogroup" aria-labelledby="avatar-color-label" className="flex flex-wrap gap-2">
|
||||||
{avatarColors.map(color => (
|
{avatarColors.map(color => (
|
||||||
<button
|
<button
|
||||||
key={color}
|
key={color}
|
||||||
onClick={() => handleAvatarColor(color)}
|
onClick={() => handleAvatarColor(color)}
|
||||||
|
role="radio"
|
||||||
|
aria-checked={user?.avatar_color === color}
|
||||||
|
aria-label={color}
|
||||||
className={`w-7 h-7 rounded-full ring-2 ring-offset-2 transition-all ${
|
className={`w-7 h-7 rounded-full ring-2 ring-offset-2 transition-all ${
|
||||||
user?.avatar_color === color ? 'ring-th-accent' : 'ring-transparent hover:ring-th-border'
|
user?.avatar_color === color ? 'ring-th-accent' : 'ring-transparent hover:ring-th-border'
|
||||||
}`}
|
}`}
|
||||||
@@ -261,11 +382,13 @@ export default function Settings() {
|
|||||||
|
|
||||||
<form onSubmit={handleProfileSave} className="space-y-4">
|
<form onSubmit={handleProfileSave} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
|
<label htmlFor="settings-username" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User 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" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
|
id="settings-username"
|
||||||
type="text"
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
value={profile.name}
|
value={profile.name}
|
||||||
onChange={e => setProfile({ ...profile, name: e.target.value })}
|
onChange={e => setProfile({ ...profile, name: e.target.value })}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
@@ -275,11 +398,13 @@ export default function Settings() {
|
|||||||
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
|
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
|
<label htmlFor="settings-display-name" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User 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" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
|
id="settings-display-name"
|
||||||
type="text"
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
value={profile.display_name}
|
value={profile.display_name}
|
||||||
onChange={e => setProfile({ ...profile, display_name: e.target.value })}
|
onChange={e => setProfile({ ...profile, display_name: e.target.value })}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
@@ -287,11 +412,13 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
<label htmlFor="settings-email" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
|
id="settings-email"
|
||||||
type="email"
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
value={profile.email}
|
value={profile.email}
|
||||||
onChange={e => setProfile({ ...profile, email: e.target.value })}
|
onChange={e => setProfile({ ...profile, email: e.target.value })}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
@@ -313,11 +440,13 @@ export default function Settings() {
|
|||||||
<h2 className="text-lg font-semibold text-th-text mb-6">{t('settings.changePassword')}</h2>
|
<h2 className="text-lg font-semibold text-th-text mb-6">{t('settings.changePassword')}</h2>
|
||||||
<form onSubmit={handlePasswordSave} className="space-y-4">
|
<form onSubmit={handlePasswordSave} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
|
<label htmlFor="settings-current-password" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
|
id="settings-current-password"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
value={passwords.currentPassword}
|
value={passwords.currentPassword}
|
||||||
onChange={e => setPasswords({ ...passwords, currentPassword: e.target.value })}
|
onChange={e => setPasswords({ ...passwords, currentPassword: e.target.value })}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
@@ -326,11 +455,13 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.newPassword')}</label>
|
<label htmlFor="settings-new-password" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.newPassword')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
|
id="settings-new-password"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
value={passwords.newPassword}
|
value={passwords.newPassword}
|
||||||
onChange={e => setPasswords({ ...passwords, newPassword: e.target.value })}
|
onChange={e => setPasswords({ ...passwords, newPassword: e.target.value })}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
@@ -341,11 +472,13 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.confirmNewPassword')}</label>
|
<label htmlFor="settings-confirm-new-password" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.confirmNewPassword')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
|
id="settings-confirm-new-password"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
value={passwords.confirmPassword}
|
value={passwords.confirmPassword}
|
||||||
onChange={e => setPasswords({ ...passwords, confirmPassword: e.target.value })}
|
onChange={e => setPasswords({ ...passwords, confirmPassword: e.target.value })}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
@@ -363,6 +496,152 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Security / 2FA section */}
|
||||||
|
{activeSection === 'security' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="card p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-th-text mb-1">{t('settings.security.title')}</h2>
|
||||||
|
<p className="text-sm text-th-text-s mb-6">{t('settings.security.subtitle')}</p>
|
||||||
|
|
||||||
|
{twoFaLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 size={24} className="animate-spin text-th-text-s" />
|
||||||
|
</div>
|
||||||
|
) : twoFaEnabled ? (
|
||||||
|
/* 2FA is enabled */
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 mb-5">
|
||||||
|
<ShieldCheck size={22} className="text-emerald-400 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 htmlFor="twofa-disable-password" 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" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
id="twofa-disable-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={twoFaDisablePassword}
|
||||||
|
onChange={e => setTwoFaDisablePassword(e.target.value)}
|
||||||
|
className="input-field pl-11"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="twofa-disable-code" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.codeLabel')}</label>
|
||||||
|
<input
|
||||||
|
id="twofa-disable-code"
|
||||||
|
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 shrink-0"
|
||||||
|
aria-label={t('room.copyLink')}
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleEnable2FA} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="twofa-enable-code" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.verifyCode')}</label>
|
||||||
|
<input
|
||||||
|
id="twofa-enable-code"
|
||||||
|
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 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-th-text">{t('settings.security.statusDisabled')}</p>
|
||||||
|
<p className="text-xs text-th-text-s">{t('settings.security.statusDisabledDesc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleSetup2FA} disabled={twoFaLoading} className="btn-primary">
|
||||||
|
{twoFaLoading ? <Loader2 size={16} className="animate-spin" /> : <Shield size={16} />}
|
||||||
|
{t('settings.security.enable')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Language section */}
|
{/* Language section */}
|
||||||
{activeSection === 'language' && (
|
{activeSection === 'language' && (
|
||||||
<div className="card p-6">
|
<div className="card p-6">
|
||||||
@@ -409,7 +688,7 @@ export default function Settings() {
|
|||||||
>
|
>
|
||||||
{/* Color preview */}
|
{/* Color preview */}
|
||||||
<div
|
<div
|
||||||
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 border"
|
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 border"
|
||||||
style={{ backgroundColor: th.colors.bg, borderColor: th.colors.accent + '40' }}
|
style={{ backgroundColor: th.colors.bg, borderColor: th.colors.accent + '40' }}
|
||||||
>
|
>
|
||||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: th.colors.accent }} />
|
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: th.colors.accent }} />
|
||||||
@@ -425,8 +704,126 @@ export default function Settings() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
{/* CalDAV section */}
|
||||||
</div>
|
{activeSection === 'caldav' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Info Card */}
|
||||||
|
<div className="card p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-th-text mb-1">{t('settings.caldav.title')}</h2>
|
||||||
|
<p className="text-sm text-th-text-s mb-5">{t('settings.caldav.subtitle')}</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-th-text-s mb-1">{t('settings.caldav.serverUrl')}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg text-th-accent font-mono truncate">
|
||||||
|
{`${window.location.origin}/caldav/`}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={() => { navigator.clipboard.writeText(`${window.location.origin}/caldav/`); toast.success(t('room.linkCopied')); }}
|
||||||
|
className="btn-ghost py-1.5 px-2 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 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 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 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 shrink-0"
|
||||||
|
title={t('settings.caldav.revoke')}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div> </div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -118,12 +118,73 @@ export const themes = [
|
|||||||
group: 'Community',
|
group: 'Community',
|
||||||
colors: { bg: '#ffffff', accent: '#e60000', text: '#000000' },
|
colors: { bg: '#ffffff', accent: '#e60000', text: '#000000' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'everforest-dark',
|
||||||
|
name: 'Everforest Dark',
|
||||||
|
type: 'dark',
|
||||||
|
group: 'Everforest',
|
||||||
|
colors: { bg: '#2d353b', accent: '#a7c080', text: '#d3c6aa' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'everforest-light',
|
||||||
|
name: 'Everforest Light',
|
||||||
|
type: 'light',
|
||||||
|
group: 'Everforest',
|
||||||
|
colors: { bg: '#fdf6e3', accent: '#8da101', text: '#5c6a72' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kanagawa',
|
||||||
|
name: 'Kanagawa',
|
||||||
|
type: 'dark',
|
||||||
|
group: 'Community',
|
||||||
|
colors: { bg: '#1f1f28', accent: '#7e9cd8', text: '#dcd7ba' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ayu-dark',
|
||||||
|
name: 'Ayu Dark',
|
||||||
|
type: 'dark',
|
||||||
|
group: 'Ayu',
|
||||||
|
colors: { bg: '#0d1017', accent: '#39bae6', text: '#bfbdb6' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'moonlight',
|
||||||
|
name: 'Moonlight',
|
||||||
|
type: 'dark',
|
||||||
|
group: 'Community',
|
||||||
|
colors: { bg: '#212337', accent: '#82aaff', text: '#c8d3f5' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cyberpunk',
|
||||||
|
name: 'Cyberpunk',
|
||||||
|
type: 'dark',
|
||||||
|
group: 'Community',
|
||||||
|
colors: { bg: '#0a0a0f', accent: '#ff0080', text: '#e0e0ff' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cotton-candy-light',
|
||||||
|
name: 'Cotton Candy Light',
|
||||||
|
type: 'light',
|
||||||
|
group: 'Community',
|
||||||
|
colors: { bg: '#fff5f9', accent: '#ff85a2', text: '#8b2635' },
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getThemeById(id) {
|
export function getThemeById(id) {
|
||||||
return themes.find(t => t.id === id) || themes[1]; // default to dark
|
return themes.find(t => t.id === id) || themes[1]; // default to dark
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether the currently applied theme is a dark one. Reads the resolved theme
|
||||||
|
// from the <html data-theme> attribute (set by ThemeContext) with a localStorage
|
||||||
|
// fallback, so it works without React context — handy for one-off API payloads
|
||||||
|
// such as the BBB join call that forwards dark mode to the meeting.
|
||||||
|
export function isCurrentThemeDark() {
|
||||||
|
const id = (typeof document !== 'undefined'
|
||||||
|
&& document.documentElement.getAttribute('data-theme'))
|
||||||
|
|| (typeof localStorage !== 'undefined' && localStorage.getItem('theme'))
|
||||||
|
|| 'dark';
|
||||||
|
return getThemeById(id).type === 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
export function getThemeGroups() {
|
export function getThemeGroups() {
|
||||||
const groups = {};
|
const groups = {};
|
||||||
themes.forEach(theme => {
|
themes.forEach(theme => {
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
th: {
|
|
||||||
bg: 'var(--bg-primary)',
|
|
||||||
'bg-s': 'var(--bg-secondary)',
|
|
||||||
'bg-t': 'var(--bg-tertiary)',
|
|
||||||
text: 'var(--text-primary)',
|
|
||||||
'text-s': 'var(--text-secondary)',
|
|
||||||
accent: 'var(--accent)',
|
|
||||||
'accent-h': 'var(--accent-hover)',
|
|
||||||
'accent-t': 'var(--accent-text)',
|
|
||||||
border: 'var(--border)',
|
|
||||||
card: 'var(--card-bg)',
|
|
||||||
input: 'var(--input-bg)',
|
|
||||||
'input-b': 'var(--input-border)',
|
|
||||||
nav: 'var(--nav-bg)',
|
|
||||||
side: 'var(--sidebar-bg)',
|
|
||||||
hover: 'var(--hover-bg)',
|
|
||||||
success: 'var(--success)',
|
|
||||||
warning: 'var(--warning)',
|
|
||||||
error: 'var(--error)',
|
|
||||||
ring: 'var(--ring)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
'th': '0 1px 3px 0 var(--shadow-color), 0 1px 2px -1px var(--shadow-color)',
|
|
||||||
'th-lg': '0 10px 15px -3px var(--shadow-color), 0 4px 6px -4px var(--shadow-color)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user