22 Commits
1.2.0 ... 1.4.0

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

fix(federation): handle room deletion and update settings during sync process

feat(federation): enhance FederatedRoomCard and FederatedRoomDetail components to display deleted rooms

i18n: add translations for room deletion messages in English and German
2026-03-01 12:20:14 +01:00
51 changed files with 4919 additions and 567 deletions

View File

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

View File

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

216
README.md
View File

@@ -1,4 +1,4 @@
# 🔴 Redlight
# 🔴 Redlight
A modern, self-hosted BigBlueButton frontend with beautiful themes, federation, and powerful features.
@@ -10,52 +10,52 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
## ✨ Features
### Core Features
- 🎥 **Video Conferencing** Integrated BigBlueButton support for professional video meetings
- 🎨 **15+ Themes** Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox, and more
- 📝 **Room Management** Create unlimited rooms with custom settings, access codes, and moderator codes
- 🔐 **User Management** Registration, login, role-based access control (Admin/User)
- 📹 **Recording Management** View, publish, and delete meeting recordings per room
- 🌍 **Multi-Language Support** German (Deutsch) and English built-in, easily extensible
- ✉️ **Email Verification** Optional SMTP-based email verification for user registration
- 👤 **User Profiles** Customizable avatars, themes, and language preferences
- 📱 **Responsive Design** Works seamlessly on mobile, tablet, and desktop
- 🌐 **Federation** Invite users from remote Redlight instances via Ed25519-signed messages
- 🐉 **DragonflyDB / Redis** JWT blacklisting for secure token revocation on logout
- 🎥 **Video Conferencing** - Integrated BigBlueButton support for professional video meetings
- 🎨 **15+ Themes** - Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox, and more
- 📝 **Room Management** - Create unlimited rooms with custom settings, access codes, and moderator codes
- 🔐 **User Management** - Registration, login, role-based access control (Admin/User)
- 📹 **Recording Management** - View, publish, and delete meeting recordings per room
- 🌍 **Multi-Language Support** - German (Deutsch) and English built-in, easily extensible
- ✉️ **Email Verification** - Optional SMTP-based email verification for user registration
- 👤 **User Profiles** - Customizable avatars, themes, and language preferences
- 📱 **Responsive Design** - Works seamlessly on mobile, tablet, and desktop
- 🌐 **Federation** - Invite users from remote Redlight instances via Ed25519-signed messages
- 🐉 **DragonflyDB / Redis** - JWT blacklisting for secure token revocation on logout
### Admin Features
- 👥 **User Administration** Manage users and roles
- 🏢 **Branding Customization** Custom app name, logos, and default theme
- 📊 **Dashboard** Overview of system statistics
- 🔧 **Settings Management** System-wide configuration
- 👥 **User Administration** - Manage users and roles
- 🏢 **Branding Customization** - Custom app name, logos, and default theme
- 📊 **Dashboard** - Overview of system statistics
- 🔧 **Settings Management** - System-wide configuration
### Room Features
- 🔑 **Access Codes** Restrict room access with optional passwords
- 🔐 **Moderator Codes** Separate code to grant moderator privileges
- 🚪 **Guest Access** Allow unauthenticated users to join meetings (rate-limited)
- ⏱️ **Max Participants** Set limits on concurrent participants
- 🎤 **Mute on Join** Automatically mute new participants
-**Approval Mode** Require moderator approval for participants
- 🎙️ **Anyone Can Start** Allow participants to start the meeting
- 📹 **Recording Settings** Control whether meetings are recorded
- 📊 **Presentation Upload** Upload PDF, PPTX, ODP, or image files as default slides
- 🤝 **Room Sharing** Share rooms with other registered users
- 🔑 **Access Codes** - Restrict room access with optional passwords
- 🔐 **Moderator Codes** - Separate code to grant moderator privileges
- 🚪 **Guest Access** - Allow unauthenticated users to join meetings (rate-limited)
- ⏱️ **Max Participants** - Set limits on concurrent participants
- 🎤 **Mute on Join** - Automatically mute new participants
-**Approval Mode** - Require moderator approval for participants
- 🎙️ **Anyone Can Start** - Allow participants to start the meeting
- 📹 **Recording Settings** - Control whether meetings are recorded
- 📊 **Presentation Upload** - Upload PDF, PPTX, ODP, or image files as default slides
- 🤝 **Room Sharing** - Share rooms with other registered users
### Security
- 🛡️ **Comprehensive Rate Limiting** Login, register, profile, avatar, guest-join, and federation endpoints
- 🔒 **Input Validation** Email format, field length limits, ID format checks, color format validation
- 🕐 **Timing-Safe Comparisons** Access codes and moderator codes compared with `crypto.timingSafeEqual`
- 📏 **Streaming Upload Limits** Avatar (5 MB) and presentation (50 MB) uploads reject early without buffering
- 🧹 **XSS Prevention** HTML-escaped emails, XML-escaped BBB parameters, SVG sanitization
- 🔐 **JWT Blacklist** Token revocation via DragonflyDB/Redis on logout
- 🌐 **CORS Restriction** Locked to `APP_URL` in production
- ⚙️ **Configurable Trust Proxy** `TRUST_PROXY` env var for reverse proxy setups
- 🛡️ **Comprehensive Rate Limiting** - Login, register, profile, avatar, guest-join, and federation endpoints
- 🔒 **Input Validation** - Email format, field length limits, ID format checks, color format validation
- 🕐 **Timing-Safe Comparisons** - Access codes and moderator codes compared with `crypto.timingSafeEqual`
- 📏 **Streaming Upload Limits** - Avatar (5 MB) and presentation (50 MB) uploads reject early without buffering
- 🧹 **XSS Prevention** - HTML-escaped emails, XML-escaped BBB parameters, SVG sanitization
- 🔐 **JWT Blacklist** - Token revocation via DragonflyDB/Redis on logout
- 🌐 **CORS Restriction** - Locked to `APP_URL` in production
- ⚙️ **Configurable Trust Proxy** - `TRUST_PROXY` env var for reverse proxy setups
### Developer Features
- 🐳 **Docker Support** Easy deployment with Docker Compose (includes PostgreSQL + DragonflyDB)
- 🗄️ **Database Flexibility** SQLite (default) or PostgreSQL support
- 🔌 **REST API** Comprehensive API for custom integrations
- 📦 **Open Source** Full source code transparency
- 🛠️ **Self-Hosted** Complete data privacy and control
- 🐳 **Docker Support** - Easy deployment with Docker Compose (includes PostgreSQL + DragonflyDB)
- 🗄️ **Database Flexibility** - SQLite (default) or PostgreSQL support
- 🔌 **REST API** - Comprehensive API for custom integrations
- 📦 **Open Source** - Full source code transparency
- 🛠️ **Self-Hosted** - Complete data privacy and control
---
@@ -103,7 +103,7 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
```env
BBB_URL=https://your-bbb-server.com/bigbluebutton/api/
BBB_SECRET=your-bbb-shared-secret
JWT_SECRET=your-secret-key # REQUIRED app won't start without this
JWT_SECRET=your-secret-key # REQUIRED - app won't start without this
APP_URL=https://your-domain.com # Used for CORS and email links
DATABASE_URL=postgres://user:password@postgres:5432/redlight
@@ -165,7 +165,7 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
- **Frontend**: React 18, Tailwind CSS, React Router, Lucide Icons
- **Backend**: Node.js 20, Express, JWT, Bcrypt
- **Database**: SQLite / PostgreSQL with better-sqlite3 / pg
- **Cache**: DragonflyDB / Redis (ioredis) JWT blacklisting
- **Cache**: DragonflyDB / Redis (ioredis) - JWT blacklisting
- **Email**: Nodemailer
- **Build**: Vite
@@ -199,77 +199,77 @@ redlight/
## 🔐 Security
- **JWT Authentication** Secure token-based auth with 7-day expiration and `jti`-based blacklisting via DragonflyDB/Redis
- **Mandatory JWT Secret** Server refuses to start without a `JWT_SECRET` env var
- **HTTPS Ready** Configure behind reverse proxy (nginx, Caddy); trust proxy via `TRUST_PROXY` env
- **Password Hashing** bcryptjs with salt rounds 12, minimum 8-character passwords
- **Email Verification** Optional SMTP-based email verification with resend support
- **CORS Protection** Restricted to `APP_URL` in production, open in development
- **Rate Limiting** Login, register, profile, password, avatar, guest-join, and federation endpoints
- **Input Validation** Email regex, field length limits, ID format checks, hex-color format checks
- **Timing-Safe Comparisons** Access codes and moderator codes compared via `crypto.timingSafeEqual`
- **Upload Safety** Streaming body size limits (avatar 5 MB, presentation 50 MB) abort early without buffering
- **XSS / Injection Prevention** HTML-escaped emails, XML-escaped BBB API parameters, SVG logos served as `attachment`
- **Admin Isolation** Role-based access control with strict admin checks
- **JWT Authentication** - Secure token-based auth with 7-day expiration and `jti`-based blacklisting via DragonflyDB/Redis
- **Mandatory JWT Secret** - Server refuses to start without a `JWT_SECRET` env var
- **HTTPS Ready** - Configure behind reverse proxy (nginx, Caddy); trust proxy via `TRUST_PROXY` env
- **Password Hashing** - bcryptjs with salt rounds 12, minimum 8-character passwords
- **Email Verification** - Optional SMTP-based email verification with resend support
- **CORS Protection** - Restricted to `APP_URL` in production, open in development
- **Rate Limiting** - Login, register, profile, password, avatar, guest-join, and federation endpoints
- **Input Validation** - Email regex, field length limits, ID format checks, hex-color format checks
- **Timing-Safe Comparisons** - Access codes and moderator codes compared via `crypto.timingSafeEqual`
- **Upload Safety** - Streaming body size limits (avatar 5 MB, presentation 50 MB) abort early without buffering
- **XSS / Injection Prevention** - HTML-escaped emails, XML-escaped BBB API parameters, SVG logos served as `attachment`
- **Admin Isolation** - Role-based access control with strict admin checks
---
## 📦 API Endpoints
### Authentication
- `POST /api/auth/register` Register new user
- `POST /api/auth/login` Login user
- `POST /api/auth/logout` Logout (blacklists JWT)
- `GET /api/auth/verify-email?token=...` Verify email with token
- `POST /api/auth/resend-verification` Resend verification email
- `GET /api/auth/me` Get current user info
- `PUT /api/auth/profile` Update profile (theme, language, display name)
- `PUT /api/auth/password` Change password
- `POST /api/auth/avatar` Upload avatar image
- `DELETE /api/auth/avatar` Remove avatar image
- `POST /api/auth/register` - Register new user
- `POST /api/auth/login` - Login user
- `POST /api/auth/logout` - Logout (blacklists JWT)
- `GET /api/auth/verify-email?token=...` - Verify email with token
- `POST /api/auth/resend-verification` - Resend verification email
- `GET /api/auth/me` - Get current user info
- `PUT /api/auth/profile` - Update profile (theme, language, display name)
- `PUT /api/auth/password` - Change password
- `POST /api/auth/avatar` - Upload avatar image
- `DELETE /api/auth/avatar` - Remove avatar image
### Rooms
- `GET /api/rooms` List user's rooms (owned + shared)
- `POST /api/rooms` Create new room
- `GET /api/rooms/:uid` Get room details
- `PUT /api/rooms/:uid` Update room
- `DELETE /api/rooms/:uid` Delete room
- `POST /api/rooms/:uid/start` Start meeting
- `POST /api/rooms/:uid/join` Join meeting as authenticated user
- `POST /api/rooms/:uid/guest-join` Join meeting as guest (rate-limited)
- `POST /api/rooms/:uid/end` End meeting
- `GET /api/rooms/:uid/running` Check if meeting is running
- `GET /api/rooms/:uid/shares` List shared users
- `POST /api/rooms/:uid/shares` Share room with user
- `DELETE /api/rooms/:uid/shares/:userId` Remove share
- `POST /api/rooms/:uid/presentation` Upload default presentation (PDF, PPTX, ODP, images)
- `DELETE /api/rooms/:uid/presentation` Remove presentation
- `GET /api/rooms` - List user's rooms (owned + shared)
- `POST /api/rooms` - Create new room
- `GET /api/rooms/:uid` - Get room details
- `PUT /api/rooms/:uid` - Update room
- `DELETE /api/rooms/:uid` - Delete room
- `POST /api/rooms/:uid/start` - Start meeting
- `POST /api/rooms/:uid/join` - Join meeting as authenticated user
- `POST /api/rooms/:uid/guest-join` - Join meeting as guest (rate-limited)
- `POST /api/rooms/:uid/end` - End meeting
- `GET /api/rooms/:uid/running` - Check if meeting is running
- `GET /api/rooms/:uid/shares` - List shared users
- `POST /api/rooms/:uid/shares` - Share room with user
- `DELETE /api/rooms/:uid/shares/:userId` - Remove share
- `POST /api/rooms/:uid/presentation` - Upload default presentation (PDF, PPTX, ODP, images)
- `DELETE /api/rooms/:uid/presentation` - Remove presentation
### Recordings
- `GET /api/recordings/:roomUid` List room recordings
- `PUT /api/recordings/:recordingId` Publish/unpublish recording
- `DELETE /api/recordings/:recordingId` Delete recording
- `GET /api/recordings/:roomUid` - List room recordings
- `PUT /api/recordings/:recordingId` - Publish/unpublish recording
- `DELETE /api/recordings/:recordingId` - Delete recording
### Admin
- `GET /api/admin/users` List all users
- `GET /api/admin/stats` System statistics
- `POST /api/admin/users` Create user (admin)
- `PUT /api/admin/users/:id` Update user
- `DELETE /api/admin/users/:id` Delete user
- `GET /api/admin/users` - List all users
- `GET /api/admin/stats` - System statistics
- `POST /api/admin/users` - Create user (admin)
- `PUT /api/admin/users/:id` - Update user
- `DELETE /api/admin/users/:id` - Delete user
### Branding
- `GET /api/branding` Get branding settings
- `PUT /api/branding` Update branding (admin only)
- `POST /api/branding/logo` Upload custom logo
- `DELETE /api/branding/logo` Remove custom logo
- `GET /api/branding` - Get branding settings
- `PUT /api/branding` - Update branding (admin only)
- `POST /api/branding/logo` - Upload custom logo
- `DELETE /api/branding/logo` - Remove custom logo
### Federation
- `GET /.well-known/redlight` Instance discovery (domain, public key)
- `POST /api/federation/invite` Send invitation to remote user
- `POST /api/federation/receive` Receive invitation from remote instance (rate-limited)
- `GET /api/federation/invitations` List received invitations
- `PUT /api/federation/invitations/:id` Accept / decline invitation
- `DELETE /api/federation/invitations/:id` Delete invitation
- `GET /.well-known/redlight` - Instance discovery (domain, public key)
- `POST /api/federation/invite` - Send invitation to remote user
- `POST /api/federation/receive` - Receive invitation from remote instance (rate-limited)
- `GET /api/federation/invitations` - List received invitations
- `PUT /api/federation/invitations/:id` - Accept / decline invitation
- `DELETE /api/federation/invitations/:id` - Delete invitation
---
@@ -313,26 +313,26 @@ docker-compose up -d
```
Services:
- **redlight** Node.js application
- **postgres** PostgreSQL database
- **dragonfly** DragonflyDB (Redis-compatible) for JWT blacklisting
- **redlight** - Node.js application
- **postgres** - PostgreSQL database
- **dragonfly** - DragonflyDB (Redis-compatible) for JWT blacklisting
### Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `BBB_URL` | Yes | | BigBlueButton API URL |
| `BBB_SECRET` | Yes | | BigBlueButton shared secret |
| `JWT_SECRET` | Yes | | Secret for signing JWTs (server won't start without it) |
| `APP_URL` | Recommended | | Public URL of the app (used for CORS + email links) |
| `BBB_URL` | Yes | - | BigBlueButton API URL |
| `BBB_SECRET` | Yes | - | BigBlueButton shared secret |
| `JWT_SECRET` | Yes | - | Secret for signing JWTs (server won't start without it) |
| `APP_URL` | Recommended | - | Public URL of the app (used for CORS + email links) |
| `DATABASE_URL` | No | SQLite | PostgreSQL connection string |
| `REDIS_URL` | No | `redis://localhost:6379` | DragonflyDB / Redis URL |
| `TRUST_PROXY` | No | `loopback` | Express trust proxy setting (number or string) |
| `SMTP_HOST` | No | | SMTP server for email verification |
| `SMTP_HOST` | No | - | SMTP server for email verification |
| `SMTP_PORT` | No | `587` | SMTP port |
| `SMTP_USER` | No | | SMTP username |
| `SMTP_PASS` | No | | SMTP password |
| `FEDERATION_DOMAIN` | No | | Domain for federation (enables cross-instance invites) |
| `SMTP_USER` | No | - | SMTP username |
| `SMTP_PASS` | No | - | SMTP password |
| `FEDERATION_DOMAIN` | No | - | Domain for federation (enables cross-instance invites) |
### Production Deployment
@@ -419,7 +419,7 @@ curl "https://your-bbb-server/bigbluebutton/api/getMeetings?checksum=..."
## 📝 License
This project is licensed under the MIT License see [LICENSE](LICENSE) file for details.
This project is licensed under the MIT License - see [LICENSE](LICENSE) file for details.
---

View File

@@ -7,6 +7,7 @@ services:
env_file: ".env"
volumes:
- uploads:/app/uploads
- ./keys:/app/keys
depends_on:
postgres:
condition: service_healthy

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "redlight",
"version": "1.2.0",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "redlight",
"version": "1.2.0",
"version": "1.4.0",
"dependencies": {
"axios": "^1.7.0",
"bcryptjs": "^2.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "redlight",
"private": true,
"version": "1.2.0",
"version": "1.4.0",
"type": "module",
"scripts": {
"dev": "concurrently -n client,server -c blue,green \"vite\" \"node --watch server/index.js\"",

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

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

Binary file not shown.

View File

@@ -1,5 +1,6 @@
import crypto from 'crypto';
import crypto from 'crypto';
import xml2js from 'xml2js';
import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParams } from './logger.js';
const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/';
const BBB_SECRET = process.env.BBB_SECRET || '';
@@ -18,39 +19,7 @@ function buildUrl(apiCall, params = {}) {
async function apiCall(apiCallName, params = {}, xmlBody = null) {
const url = buildUrl(apiCallName, params);
// Logging: compact key=value style, filter sensitive params
function formatUTC(d) {
const pad = n => String(n).padStart(2, '0');
const Y = d.getUTCFullYear();
const M = pad(d.getUTCMonth() + 1);
const D = pad(d.getUTCDate());
const h = pad(d.getUTCHours());
const m = pad(d.getUTCMinutes());
const s = pad(d.getUTCSeconds());
return `${Y}-${M}-${D} ${h}:${m}:${s} UTC`;
}
const SENSITIVE_KEYS = [/pass/i, /pwd/i, /password/i, /token/i, /jwt/i, /secret/i, /authorization/i, /auth/i, /api[_-]?key/i];
const isSensitive = key => SENSITIVE_KEYS.some(rx => rx.test(key));
function sanitizeParams(p) {
try {
const out = [];
for (const k of Object.keys(p || {})) {
if (k.toLowerCase() === 'checksum') continue; // never log checksum
if (isSensitive(k)) {
out.push(`${k}=[FILTERED]`);
} else {
let v = p[k];
if (typeof v === 'string' && v.length > 100) v = v.slice(0, 100) + '...[truncated]';
out.push(`${k}=${String(v)}`);
}
}
return out.join('&') || '-';
} catch (e) {
return '-';
}
}
const method = xmlBody ? 'POST' : 'GET';
const start = Date.now();
try {
@@ -65,38 +34,20 @@ async function apiCall(apiCallName, params = {}, xmlBody = null) {
trim: true,
});
// Compact log: time=... method=GET path=getMeetings format=xml status=200 duration=12.34 bbb_returncode=SUCCESS params=meetingID=123
try {
const tokens = [];
tokens.push(`time=${formatUTC(new Date())}`);
tokens.push(`method=${xmlBody ? 'POST' : 'GET'}`);
// include standard BBB api base path
let apiBasePath = '/bigbluebutton/api';
try {
const u = new URL(BBB_URL);
apiBasePath = (u.pathname || '/bigbluebutton/api').replace(/\/$/, '');
} catch (e) {
// keep default
}
// ensure single slash separation
const fullPath = `${apiBasePath}/${apiCallName}`.replace(/\/\/+/, '/');
tokens.push(`path=${fullPath}`);
tokens.push(`format=xml`);
tokens.push(`status=${response.status}`);
tokens.push(`duration=${(duration).toFixed(2)}`);
const returnCode = result && result.response && result.response.returncode ? result.response.returncode : '-';
tokens.push(`returncode=${returnCode}`);
const safeParams = sanitizeParams(params);
tokens.push(`params=${safeParams}`);
console.info(tokens.join(' '));
} catch (e) {
// ignore logging errors
}
const returncode = result?.response?.returncode || '-';
const paramStr = sanitizeBBBParams(params);
// Greenlight-style: method action → status returncode (duration) params
log.bbb.info(
`${fmtMethod(method)} ${apiCallName}${fmtStatus(response.status)} ${fmtReturncode(returncode)} (${fmtDuration(duration)}) ${paramStr}`
);
return result.response;
} catch (error) {
const duration = Date.now() - start;
console.error(`BBB API error (${apiCallName}) status=error duration=${(duration).toFixed(2)} err=${error.message}`);
log.bbb.error(
`${fmtMethod(method)} ${apiCallName} ✗ FAILED (${fmtDuration(duration)}) ${error.message}`
);
throw error;
}
}
@@ -147,7 +98,7 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati
params.lockSettingsLockOnJoin = 'true';
}
// Build optional presentation XML body escape URL to prevent XML injection
// Build optional presentation XML body - escape URL to prevent XML injection
let xmlBody = null;
if (presentationUrl) {
const safeUrl = presentationUrl

View File

@@ -1,6 +1,7 @@
import bcrypt from 'bcryptjs';
import bcrypt from 'bcryptjs';
import path from 'path';
import { fileURLToPath } from 'url';
import { log } from './logger.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -105,7 +106,7 @@ class PostgresAdapter {
// ── Public API ──────────────────────────────────────────────────────────────
export function getDb() {
if (!db) {
throw new Error('Database not initialised call initDatabase() first');
throw new Error('Database not initialised - call initDatabase() first');
}
return db;
}
@@ -113,10 +114,10 @@ export function getDb() {
export async function initDatabase() {
// Create the right adapter
if (isPostgres) {
console.log('📦 Using PostgreSQL database');
log.db.info('Using PostgreSQL database');
db = new PostgresAdapter();
} else {
console.log('📦 Using SQLite database');
log.db.info('Using SQLite database');
db = new SqliteAdapter();
}
await db.init();
@@ -367,17 +368,304 @@ export async function initDatabase() {
}
}
// ── Default admin ───────────────────────────────────────────────────────
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
// Federation sync: add deleted + updated_at to federated_rooms
if (!(await db.columnExists('federated_rooms', 'deleted'))) {
await db.exec('ALTER TABLE federated_rooms ADD COLUMN deleted INTEGER DEFAULT 0');
}
if (!(await db.columnExists('federated_rooms', 'updated_at'))) {
if (isPostgres) {
await db.exec('ALTER TABLE federated_rooms ADD COLUMN updated_at TIMESTAMP DEFAULT NOW()');
} else {
await db.exec('ALTER TABLE federated_rooms ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP');
}
}
const existingAdmin = await db.get('SELECT id FROM users WHERE email = ?', [adminEmail]);
if (!existingAdmin) {
const hash = bcrypt.hashSync(adminPassword, 12);
await db.run(
'INSERT INTO users (name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, 1)',
['Administrator', adminEmail, hash, 'admin']
);
console.log(`✅ Default admin created: ${adminEmail}`);
// Track outbound federation invites for deletion propagation
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS federation_outbound_invites (
id SERIAL PRIMARY KEY,
room_uid TEXT NOT NULL,
remote_domain TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(room_uid, remote_domain)
);
CREATE INDEX IF NOT EXISTS idx_fed_out_room_uid ON federation_outbound_invites(room_uid);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS federation_outbound_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_uid TEXT NOT NULL,
remote_domain TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(room_uid, remote_domain)
);
CREATE INDEX IF NOT EXISTS idx_fed_out_room_uid ON federation_outbound_invites(room_uid);
`);
}
// User invite tokens (invite-only registration)
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS user_invites (
id SERIAL PRIMARY KEY,
token TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
used_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
used_at TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_invites_token ON user_invites(token);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS user_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
created_by INTEGER NOT NULL,
used_by INTEGER,
used_at DATETIME,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_user_invites_token ON user_invites(token);
`);
}
// ── Calendar tables ──────────────────────────────────────────────────────
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_events (
id SERIAL PRIMARY KEY,
uid TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
room_uid TEXT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
color TEXT DEFAULT '#6366f1',
federated_from TEXT DEFAULT NULL,
federated_join_url TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cal_events_user_id ON calendar_events(user_id);
CREATE INDEX IF NOT EXISTS idx_cal_events_uid ON calendar_events(uid);
CREATE INDEX IF NOT EXISTS idx_cal_events_start ON calendar_events(start_time);
CREATE TABLE IF NOT EXISTS calendar_event_shares (
id SERIAL PRIMARY KEY,
event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(event_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_cal_shares_event ON calendar_event_shares(event_id);
CREATE INDEX IF NOT EXISTS idx_cal_shares_user ON calendar_event_shares(user_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
room_uid TEXT,
user_id INTEGER NOT NULL,
color TEXT DEFAULT '#6366f1',
federated_from TEXT DEFAULT NULL,
federated_join_url TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_cal_events_user_id ON calendar_events(user_id);
CREATE INDEX IF NOT EXISTS idx_cal_events_uid ON calendar_events(uid);
CREATE INDEX IF NOT EXISTS idx_cal_events_start ON calendar_events(start_time);
CREATE TABLE IF NOT EXISTS calendar_event_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(event_id, user_id),
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_cal_shares_event ON calendar_event_shares(event_id);
CREATE INDEX IF NOT EXISTS idx_cal_shares_user ON calendar_event_shares(user_id);
`);
}
// Calendar migrations: add federated columns if missing
if (!(await db.columnExists('calendar_events', 'federated_from'))) {
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_from TEXT DEFAULT NULL');
}
if (!(await db.columnExists('calendar_events', 'federated_join_url'))) {
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL');
}
// Calendar invitations (federated calendar events that must be accepted first)
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_invitations (
id SERIAL PRIMARY KEY,
event_uid TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
room_uid TEXT,
join_url TEXT,
from_user TEXT NOT NULL,
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
color TEXT DEFAULT '#6366f1',
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cal_inv_to_user ON calendar_invitations(to_user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_cal_inv_uid_user ON calendar_invitations(event_uid, to_user_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_invitations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_uid TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
room_uid TEXT,
join_url TEXT,
from_user TEXT NOT NULL,
to_user_id INTEGER NOT NULL,
color TEXT DEFAULT '#6366f1',
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(event_uid, to_user_id)
);
CREATE INDEX IF NOT EXISTS idx_cal_inv_to_user ON calendar_invitations(to_user_id);
`);
}
// Track outbound calendar event federation sends for deletion propagation
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_event_outbound (
id SERIAL PRIMARY KEY,
event_uid TEXT NOT NULL,
remote_domain TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(event_uid, remote_domain)
);
CREATE INDEX IF NOT EXISTS idx_cal_out_event_uid ON calendar_event_outbound(event_uid);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_event_outbound (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_uid TEXT NOT NULL,
remote_domain TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(event_uid, remote_domain)
);
CREATE INDEX IF NOT EXISTS idx_cal_out_event_uid ON calendar_event_outbound(event_uid);
`);
}
// Local calendar event invitations (share-with-acceptance flow)
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_local_invitations (
id SERIAL PRIMARY KEY,
event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(event_id, to_user_id)
);
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_to_user ON calendar_local_invitations(to_user_id);
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_event ON calendar_local_invitations(event_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_local_invitations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL,
from_user_id INTEGER NOT NULL,
to_user_id INTEGER NOT NULL,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(event_id, to_user_id),
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_to_user ON calendar_local_invitations(to_user_id);
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_event ON calendar_local_invitations(event_id);
`);
}
// ── Notifications table ──────────────────────────────────────────────────
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS notifications (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT,
link TEXT,
read INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT,
link TEXT,
read INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
`);
}
// ── Default admin (only on very first start) ────────────────────────────
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
if (!adminAlreadySeeded) {
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
// Check if admin already exists (upgrade from older version without the flag)
const existing = await db.get('SELECT id FROM users WHERE email = ?', [adminEmail]);
if (!existing) {
const hash = bcrypt.hashSync(adminPassword, 12);
await db.run(
'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)',
['Administrator', 'Administrator', adminEmail, hash, 'admin']
);
log.db.info(`Default admin created: ${adminEmail}`);
}
// Mark as seeded so it never runs again, even if the admin email is changed
await db.run("INSERT INTO settings (key, value) VALUES ('admin_seeded', '1') RETURNING key");
}
}

View File

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

View File

@@ -2,6 +2,7 @@ import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { log } from './logger.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -12,21 +13,25 @@ let publicKeyPem = '';
// Load or generate Ed25519 keys
if (FEDERATION_DOMAIN) {
const keyPath = path.join(__dirname, 'federation_key.pem');
const keyPath = process.env.FEDERATION_KEY_PATH || '/app/keys/federation_key.pem';
const keyDir = path.dirname(keyPath);
if (!fs.existsSync(keyDir)) {
fs.mkdirSync(keyDir, { recursive: true });
}
if (!privateKeyPem && fs.existsSync(keyPath)) {
privateKeyPem = fs.readFileSync(keyPath, 'utf8');
}
if (!privateKeyPem) {
console.log('Generating new Ed25519 federation key pair...');
log.federation.info('Generating new Ed25519 key pair...');
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
privateKeyPem = privateKey;
fs.writeFileSync(keyPath, privateKeyPem, 'utf8');
console.log(`Saved new federation private key to ${keyPath}`);
log.federation.info(`Saved new private key to ${keyPath}`);
}
// Derive public key from the loaded private key
@@ -83,7 +88,7 @@ export function verifyPayload(payload, signature, remotePublicKeyPem) {
const data = Buffer.from(JSON.stringify(payload));
return crypto.verify(null, data, remotePublicKeyPem, Buffer.from(signature, 'base64'));
} catch (e) {
console.error('Signature verification error:', e.message);
log.federation.error(`Signature verification error: ${e.message}`);
return false;
}
}
@@ -131,7 +136,7 @@ export async function discoverInstance(domain) {
discoveryCache.set(domain, result);
return result;
} catch (error) {
console.error(`Federation discovery failed for ${domain}:`, error.message);
log.federation.error(`Discovery failed for ${domain}: ${error.message}`);
throw new Error(`Could not discover Redlight instance at ${domain}: ${error.message}`);
}
}

157
server/config/logger.js Normal file
View File

@@ -0,0 +1,157 @@
/**
* Centralized logger for Redlight server.
*
* Produces clean, colorized, tagged log lines inspired by Greenlight/Rails lograge style.
*
* Format: TIMESTAMP LEVEL [TAG] message
* Example: 2026-03-01 12:00:00 INFO [BBB] GET getMeetings → 200 SUCCESS (45ms)
*/
// ── ANSI colors ─────────────────────────────────────────────────────────────
const C = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
gray: '\x1b[90m',
};
const USE_COLOR = process.env.NO_COLOR ? false : true;
const c = (color, text) => USE_COLOR ? `${color}${text}${C.reset}` : text;
// ── Timestamp ───────────────────────────────────────────────────────────────
function ts() {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
const ms = String(d.getMilliseconds()).padStart(3, '0');
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}.${ms}`;
}
// ── Level formatting ────────────────────────────────────────────────────────
const LEVEL_STYLE = {
DEBUG: { color: C.gray, label: 'DEBUG' },
INFO: { color: C.green, label: ' INFO' },
WARN: { color: C.yellow, label: ' WARN' },
ERROR: { color: C.red, label: 'ERROR' },
};
// ── Tag colors ──────────────────────────────────────────────────────────────
const TAG_COLORS = {
BBB: C.magenta,
HTTP: C.cyan,
Federation: C.blue,
FedSync: C.blue,
DB: C.yellow,
Auth: C.green,
Server: C.white,
Mailer: C.cyan,
Redis: C.magenta,
Admin: C.yellow,
Rooms: C.green,
Recordings: C.cyan,
Branding: C.white,
};
function formatLine(level, tag, message) {
const lvl = LEVEL_STYLE[level] || LEVEL_STYLE.INFO;
const tagColor = TAG_COLORS[tag] || C.white;
const timestamp = c(C.gray, ts());
const levelStr = c(lvl.color, lvl.label);
const tagStr = c(tagColor, `[${tag}]`);
return `${timestamp} ${levelStr} ${tagStr} ${message}`;
}
// ── Public API ──────────────────────────────────────────────────────────────
/**
* Create a tagged logger.
* @param {string} tag - e.g. 'BBB', 'HTTP', 'Federation'
*/
export function createLogger(tag) {
return {
debug: (msg, ...args) => console.debug(formatLine('DEBUG', tag, msg), ...args),
info: (msg, ...args) => console.info(formatLine('INFO', tag, msg), ...args),
warn: (msg, ...args) => console.warn(formatLine('WARN', tag, msg), ...args),
error: (msg, ...args) => console.error(formatLine('ERROR', tag, msg), ...args),
};
}
// ── Pre-built loggers for common subsystems ─────────────────────────────────
export const log = {
bbb: createLogger('BBB'),
http: createLogger('HTTP'),
federation: createLogger('Federation'),
fedSync: createLogger('FedSync'),
db: createLogger('DB'),
auth: createLogger('Auth'),
server: createLogger('Server'),
mailer: createLogger('Mailer'),
redis: createLogger('Redis'),
admin: createLogger('Admin'),
rooms: createLogger('Rooms'),
recordings: createLogger('Recordings'),
branding: createLogger('Branding'),
};
// ── Helpers ─────────────────────────────────────────────────────────────────
/** Format duration with unit and color. */
export function fmtDuration(ms) {
const num = Number(ms);
if (num < 100) return c(C.green, `${num.toFixed(0)}ms`);
if (num < 1000) return c(C.yellow, `${num.toFixed(0)}ms`);
return c(C.red, `${(num / 1000).toFixed(2)}s`);
}
/** Format HTTP status with color. */
export function fmtStatus(status) {
const s = Number(status);
if (s < 300) return c(C.green, String(s));
if (s < 400) return c(C.cyan, String(s));
if (s < 500) return c(C.yellow, String(s));
return c(C.red, String(s));
}
/** Format HTTP method with color. */
export function fmtMethod(method) {
const m = String(method).toUpperCase();
const colors = { GET: C.green, POST: C.cyan, PUT: C.yellow, PATCH: C.yellow, DELETE: C.red };
return c(colors[m] || C.white, m.padEnd(6));
}
/** Format BBB returncode with color. */
export function fmtReturncode(code) {
if (code === 'SUCCESS') return c(C.green, code);
if (code === 'FAILED') return c(C.red, code);
return c(C.yellow, code || '-');
}
// ── Sensitive value filtering ───────────────────────────────────────────────
const SENSITIVE_KEYS = [/pass/i, /pwd/i, /password/i, /token/i, /jwt/i, /secret/i, /authorization/i, /api[_-]?key/i];
export function isSensitiveKey(key) {
return SENSITIVE_KEYS.some(rx => rx.test(key));
}
/**
* Sanitize BBB params for logging: filter sensitive values, truncate long strings, omit checksum.
*/
export function sanitizeBBBParams(params) {
const parts = [];
for (const k of Object.keys(params || {})) {
if (k.toLowerCase() === 'checksum') continue;
if (isSensitiveKey(k)) {
parts.push(`${k}=${c(C.dim, '[FILTERED]')}`);
} else {
let v = params[k];
if (typeof v === 'string' && v.length > 80) v = v.slice(0, 80) + '…';
parts.push(`${c(C.gray, k)}=${v}`);
}
}
return parts.join(' ') || '-';
}

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import Redis from 'ioredis';
import { log } from './logger.js';
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
@@ -13,12 +14,12 @@ const redis = new Redis(REDIS_URL, {
redis.on('error', (err) => {
// Suppress ECONNREFUSED noise after initial failure — only warn
if (err.code !== 'ECONNREFUSED') {
console.warn('⚠️ DragonflyDB error:', err.message);
log.redis.warn(`DragonflyDB error: ${err.message}`);
}
});
redis.on('connect', () => {
console.log('🐉 DragonflyDB connected');
log.redis.info('DragonflyDB connected');
});
export default redis;

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

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

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

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

View File

@@ -1,8 +1,9 @@
import 'dotenv/config';
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import { log } from './config/logger.js';
import requestResponseLogger from './middleware/logging.js';
import { initDatabase } from './config/database.js';
import { initMailer } from './config/mailer.js';
@@ -12,6 +13,9 @@ import recordingRoutes from './routes/recordings.js';
import adminRoutes from './routes/admin.js';
import brandingRoutes from './routes/branding.js';
import federationRoutes, { wellKnownHandler } from './routes/federation.js';
import calendarRoutes from './routes/calendar.js';
import notificationRoutes from './routes/notifications.js';
import { startFederationSync } from './jobs/federationSync.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -19,7 +23,7 @@ const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3001;
// Trust proxy configurable via TRUST_PROXY env var (default: 1 = one local reverse proxy)
// Trust proxy - configurable via TRUST_PROXY env var (default: 1 = one local reverse proxy)
// Use a number to trust that many hops, or a string like 'loopback' / an IP/CIDR.
const rawTrustProxy = process.env.TRUST_PROXY ?? 'loopback';
const trustProxy = /^\d+$/.test(rawTrustProxy) ? parseInt(rawTrustProxy, 10) : rawTrustProxy;
@@ -51,6 +55,10 @@ async function start() {
app.use('/api/admin', adminRoutes);
app.use('/api/branding', brandingRoutes);
app.use('/api/federation', federationRoutes);
app.use('/api/calendar', calendarRoutes);
app.use('/api/notifications', notificationRoutes);
// Mount calendar federation receive also under /api/federation for remote instances
app.use('/api/federation', calendarRoutes);
app.get('/.well-known/redlight', wellKnownHandler);
// Serve static files in production
@@ -62,11 +70,14 @@ async function start() {
}
app.listen(PORT, () => {
console.log(`🔴 Redlight server running on http://localhost:${PORT}`);
log.server.info(`Redlight server running on http://localhost:${PORT}`);
});
// Start periodic federation sync job (checks remote room settings every 60s)
startFederationSync();
}
start().catch(err => {
console.error('❌ Failed to start server:', err);
log.server.error(`Failed to start server: ${err.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,154 @@
import { getDb } from '../config/database.js';
import { log, fmtDuration } from '../config/logger.js';
import {
isFederationEnabled,
getFederationDomain,
signPayload,
discoverInstance,
parseAddress,
} from '../config/federation.js';
const SYNC_INTERVAL_MS = 60_000; // 1 minute
let syncTimer = null;
/**
* Periodic federation sync job.
* Groups federated rooms by origin domain, then batch-queries each origin
* for current room settings. Updates local records if settings changed or
* if the room was deleted on the origin.
*/
async function runSync() {
if (!isFederationEnabled()) return;
const syncStart = Date.now();
let totalUpdated = 0;
let totalDeleted = 0;
let totalRooms = 0;
try {
const db = getDb();
// Fetch all non-deleted federated rooms
const rooms = await db.all(
'SELECT id, meet_id, from_user, room_name, max_participants, allow_recording FROM federated_rooms WHERE deleted = 0'
);
if (rooms.length === 0) return;
totalRooms = rooms.length;
// Group by origin domain
const byDomain = new Map();
for (const room of rooms) {
if (!room.meet_id) continue; // no room UID, can't sync
const { domain } = parseAddress(room.from_user);
if (!domain) continue;
if (!byDomain.has(domain)) byDomain.set(domain, []);
byDomain.get(domain).push(room);
}
// Query each origin domain
for (const [domain, domainRooms] of byDomain) {
try {
const roomUids = [...new Set(domainRooms.map(r => r.meet_id))];
const payload = {
room_uids: roomUids,
timestamp: new Date().toISOString(),
};
const signature = signPayload(payload);
const { baseUrl: remoteApi } = await discoverInstance(domain);
const response = await fetch(`${remoteApi}/room-sync`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Federation-Signature': signature,
'X-Federation-Origin': getFederationDomain(),
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
log.fedSync.warn(`${domain} responded with status ${response.status}`);
continue;
}
const data = await response.json();
const remoteRooms = data.rooms || {};
// Update local records
for (const localRoom of domainRooms) {
const remote = remoteRooms[localRoom.meet_id];
if (!remote) continue; // UID not in response, skip
if (remote.deleted) {
// Room was deleted on origin
await db.run(
'UPDATE federated_rooms SET deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[localRoom.id]
);
totalDeleted++;
log.fedSync.info(`Room ${localRoom.meet_id} deleted on origin ${domain}`);
} else {
// Check if settings changed
const changed =
localRoom.room_name !== remote.room_name ||
(localRoom.max_participants ?? 0) !== (remote.max_participants ?? 0) ||
(localRoom.allow_recording ?? 1) !== (remote.allow_recording ?? 1);
if (changed) {
await db.run(
`UPDATE federated_rooms
SET room_name = ?, max_participants = ?, allow_recording = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[remote.room_name, remote.max_participants ?? 0, remote.allow_recording ?? 1, localRoom.id]
);
totalUpdated++;
log.fedSync.info(`Room ${localRoom.meet_id} settings updated from ${domain}`);
}
}
}
} catch (err) {
log.fedSync.warn(`Sync with ${domain} failed: ${err.message}`);
}
}
// Summary log (only if something happened)
if (totalUpdated > 0 || totalDeleted > 0) {
log.fedSync.info(
`Sync complete: ${totalRooms} rooms, ${totalUpdated} updated, ${totalDeleted} deleted (${fmtDuration(Date.now() - syncStart)})`
);
}
} catch (err) {
log.fedSync.error(`Sync job failed: ${err.message}`);
}
}
/**
* Start the periodic federation sync job.
*/
export function startFederationSync() {
if (!isFederationEnabled()) {
log.fedSync.info('Disabled (federation not configured)');
return;
}
// Run first sync after a short delay to let the server fully start
setTimeout(() => {
runSync();
syncTimer = setInterval(runSync, SYNC_INTERVAL_MS);
log.fedSync.info('Started (interval: 60s)');
}, 5_000);
}
/**
* Stop the periodic federation sync job.
*/
export function stopFederationSync() {
if (syncTimer) {
clearInterval(syncTimer);
syncTimer = null;
}
}

View File

@@ -2,9 +2,10 @@ import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js';
import redis from '../config/redis.js';
import { log } from '../config/logger.js';
if (!process.env.JWT_SECRET) {
console.error('FATAL: JWT_SECRET environment variable is not set. ');
log.auth.error('FATAL: JWT_SECRET environment variable is not set.');
process.exit(1);
}
const JWT_SECRET = process.env.JWT_SECRET;
@@ -29,7 +30,7 @@ export async function authenticateToken(req, res, next) {
}
} catch (redisErr) {
// Graceful degradation: if Redis is unavailable, allow the request
console.warn('Redis blacklist check skipped:', redisErr.message);
log.auth.warn(`Redis blacklist check skipped: ${redisErr.message}`);
}
}

View File

@@ -1,118 +1,25 @@
import util from 'util';
const SENSITIVE_KEYS = [/pass/i, /pwd/i, /password/i, /token/i, /jwt/i, /secret/i, /authorization/i, /auth/i, /api[_-]?key/i];
const MAX_LOG_BODY_LENGTH = 200000; // 200 KB
function isSensitiveKey(key) {
return SENSITIVE_KEYS.some(rx => rx.test(key));
}
function filterValue(key, value, depth = 0) {
if (depth > 5) return '[MAX_DEPTH]';
if (key && isSensitiveKey(key)) return '[FILTERED]';
if (value === null || value === undefined) return value;
if (typeof value === 'string') {
if (value.length > MAX_LOG_BODY_LENGTH) return '[TOO_LARGE]';
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') return value;
if (Array.isArray(value)) return value.map(v => filterValue(null, v, depth + 1));
if (typeof value === 'object') {
const out = {};
for (const k of Object.keys(value)) {
out[k] = filterValue(k, value[k], depth + 1);
}
return out;
}
return typeof value;
}
function filterHeaders(headers) {
const out = {};
for (const k of Object.keys(headers || {})) {
if (/^authorization$/i.test(k) || /^cookie$/i.test(k)) {
out[k] = '[FILTERED]';
continue;
}
if (isSensitiveKey(k)) {
out[k] = '[FILTERED]';
continue;
}
out[k] = headers[k];
}
return out;
}
function formatUTC(d) {
const pad = n => String(n).padStart(2, '0');
const Y = d.getUTCFullYear();
const M = pad(d.getUTCMonth() + 1);
const D = pad(d.getUTCDate());
const h = pad(d.getUTCHours());
const m = pad(d.getUTCMinutes());
const s = pad(d.getUTCSeconds());
return `${Y}-${M}-${D} ${h}:${m}:${s} UTC`;
}
import { log, fmtDuration, fmtStatus, fmtMethod } from '../config/logger.js';
export default function requestResponseLogger(req, res, next) {
try {
const start = Date.now();
const { method, originalUrl } = req;
const ip = req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress;
const start = Date.now();
const { method, originalUrl } = req;
const reqHeaders = filterHeaders(req.headers);
res.on('finish', () => {
try {
const duration = Date.now() - start;
const status = res.statusCode;
const contentType = (res.getHeader?.('content-type') || '').toString().toLowerCase();
const format = contentType.includes('json') ? 'json' : contentType.includes('html') ? 'html' : '';
const formatStr = format ? ` ${format}` : '';
let reqBody = '[not-logged]';
const contentType = (req.headers['content-type'] || '').toLowerCase();
if (contentType.includes('multipart/form-data')) {
reqBody = '[multipart/form-data]';
} else if (req.body) {
try {
reqBody = filterValue(null, req.body);
} catch (e) {
reqBody = '[unserializable]';
}
// METHOD /path → status (duration)
log.http.info(
`${fmtMethod(method)} ${originalUrl}${fmtStatus(status)}${formatStr} (${fmtDuration(duration)})`
);
} catch {
// never break the request pipeline
}
// Capture response body by wrapping res.send
const oldSend = res.send.bind(res);
let responseBody = undefined;
res.send = function sendOverWrite(body) {
responseBody = body;
return oldSend(body);
};
res.on('finish', () => {
try {
const duration = Date.now() - start; // ms
const resContentType = (res.getHeader && (res.getHeader('content-type') || '')).toString().toLowerCase();
// Compact key=value log (no bodies, sensitive data filtered)
const tokens = [];
tokens.push(`time=${formatUTC(new Date())}`);
tokens.push(`method=${method}`);
tokens.push(`path=${originalUrl.replace(/\s/g, '%20')}`);
const fmt = resContentType.includes('json') ? 'json' : (resContentType.includes('html') ? 'html' : 'other');
tokens.push(`format=${fmt}`);
tokens.push(`status=${res.statusCode}`);
tokens.push(`duration=${(duration).toFixed(2)}`);
// Optional: content-length if available
try {
const cl = res.getHeader && (res.getHeader('content-length') || res.getHeader('Content-Length'));
if (cl) tokens.push(`length=${String(cl)}`);
} catch (e) {
// ignore
}
console.info(tokens.join(' '));
} catch (e) {
console.error('RequestLogger error:', e);
}
});
} catch (e) {
console.error('RequestLogger setup failure:', e);
}
});
return next();
}

View File

@@ -1,7 +1,10 @@
import { Router } from 'express';
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js';
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js';
import { log } from '../config/logger.js';
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
@@ -23,7 +26,7 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(name)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
}
if (password.length < 8) {
@@ -57,7 +60,7 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
const user = await db.get('SELECT id, name, display_name, email, role, created_at FROM users WHERE id = ?', [result.lastInsertRowid]);
res.status(201).json({ user });
} catch (err) {
console.error('Create user error:', err);
log.admin.error(`Create user error: ${err.message}`);
res.status(500).json({ error: 'User could not be created' });
}
});
@@ -75,7 +78,7 @@ router.get('/users', authenticateToken, requireAdmin, async (req, res) => {
res.json({ users });
} catch (err) {
console.error('List users error:', err);
log.admin.error(`List users error: ${err.message}`);
res.status(500).json({ error: 'Users could not be loaded' });
}
});
@@ -109,7 +112,7 @@ router.put('/users/:id/role', authenticateToken, requireAdmin, async (req, res)
res.json({ user: updated });
} catch (err) {
console.error('Update role error:', err);
log.admin.error(`Update role error: ${err.message}`);
res.status(500).json({ error: 'Role could not be updated' });
}
});
@@ -139,7 +142,7 @@ router.delete('/users/:id', authenticateToken, requireAdmin, async (req, res) =>
await db.run('DELETE FROM users WHERE id = ?', [req.params.id]);
res.json({ message: 'User deleted' });
} catch (err) {
console.error('Delete user error:', err);
log.admin.error(`Delete user error: ${err.message}`);
res.status(500).json({ error: 'User could not be deleted' });
}
});
@@ -158,9 +161,103 @@ router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, r
res.json({ message: 'Password reset' });
} catch (err) {
console.error('Reset password error:', err);
log.admin.error(`Reset password error: ${err.message}`);
res.status(500).json({ error: 'Password could not be reset' });
}
});
// ── User Invite System ─────────────────────────────────────────────────────
// POST /api/admin/invites - Create and send an invite
router.post('/invites', authenticateToken, requireAdmin, async (req, res) => {
try {
const { email } = req.body;
if (!email || !EMAIL_RE.test(email)) {
return res.status(400).json({ error: 'A valid email address is required' });
}
const db = getDb();
// Check if user with this email already exists
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existing) {
return res.status(409).json({ error: 'A user with this email already exists' });
}
// Check if there's already a pending invite for this email
const existingInvite = await db.get(
'SELECT id FROM user_invites WHERE email = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP',
[email.toLowerCase()]
);
if (existingInvite) {
return res.status(409).json({ error: 'There is already a pending invite for this email' });
}
const token = uuidv4();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days
await db.run(
'INSERT INTO user_invites (token, email, created_by, expires_at) VALUES (?, ?, ?, ?)',
[token, email.toLowerCase(), req.user.id, expiresAt]
);
// Send invite email if SMTP is configured
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const inviteUrl = `${baseUrl}/register?invite=${token}`;
// Load app name
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'app_name'");
const appName = brandingSetting?.value || 'Redlight';
if (isMailerConfigured()) {
try {
await sendInviteEmail(email.toLowerCase(), inviteUrl, appName, 'en');
} catch (mailErr) {
log.admin.warn(`Invite email failed (non-fatal): ${mailErr.message}`);
}
}
res.status(201).json({ invite: { token, email: email.toLowerCase(), expiresAt, inviteUrl } });
} catch (err) {
log.admin.error(`Create invite error: ${err.message}`);
res.status(500).json({ error: 'Invite could not be created' });
}
});
// GET /api/admin/invites - List all invites
router.get('/invites', authenticateToken, requireAdmin, async (req, res) => {
try {
const db = getDb();
const invites = await db.all(`
SELECT ui.id, ui.token, ui.email, ui.expires_at, ui.created_at, ui.used_at,
creator.name as created_by_name,
used_user.name as used_by_name
FROM user_invites ui
LEFT JOIN users creator ON creator.id = ui.created_by
LEFT JOIN users used_user ON used_user.id = ui.used_by
ORDER BY ui.created_at DESC
`);
res.json({ invites });
} catch (err) {
log.admin.error(`List invites error: ${err.message}`);
res.status(500).json({ error: 'Invites could not be loaded' });
}
});
// DELETE /api/admin/invites/:id - Delete an invite
router.delete('/invites/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
const db = getDb();
const invite = await db.get('SELECT id FROM user_invites WHERE id = ?', [req.params.id]);
if (!invite) {
return res.status(404).json({ error: 'Invite not found' });
}
await db.run('DELETE FROM user_invites WHERE id = ?', [req.params.id]);
res.json({ message: 'Invite deleted' });
} catch (err) {
log.admin.error(`Delete invite error: ${err.message}`);
res.status(500).json({ error: 'Invite could not be deleted' });
}
});
export default router;

View File

@@ -1,4 +1,4 @@
import { Router } from 'express';
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
@@ -11,9 +11,10 @@ import { getDb } from '../config/database.js';
import redis from '../config/redis.js';
import { authenticateToken, generateToken } from '../middleware/auth.js';
import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
import { log } from '../config/logger.js';
if (!process.env.JWT_SECRET) {
console.error('FATAL: JWT_SECRET environment variable is not set.');
log.auth.error('FATAL: JWT_SECRET environment variable is not set.');
process.exit(1);
}
const JWT_SECRET = process.env.JWT_SECRET;
@@ -36,7 +37,7 @@ const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
// Simple format check for theme/language IDs (actual validation happens on the frontend)
const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;
// Allowlist for CSS color values only permits hsl(), hex (#rgb/#rrggbb) and plain names
// Allowlist for CSS color values - only permits hsl(), hex (#rgb/#rrggbb) and plain names
const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
const MIN_PASSWORD_LENGTH = 8;
@@ -111,7 +112,27 @@ const router = Router();
// POST /api/auth/register
router.post('/register', registerLimiter, async (req, res) => {
try {
const { username, display_name, email, password } = req.body;
const { username, display_name, email, password, invite_token } = req.body;
// Check registration mode
const db = getDb();
const regModeSetting = await db.get("SELECT value FROM settings WHERE key = 'registration_mode'");
const registrationMode = regModeSetting?.value || 'open';
let validatedInvite = null;
if (registrationMode === 'invite') {
if (!invite_token) {
return res.status(403).json({ error: 'Registration is currently invite-only. You need an invitation link to register.' });
}
// Validate the invite token
validatedInvite = await db.get(
'SELECT * FROM user_invites WHERE token = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP',
[invite_token]
);
if (!validatedInvite) {
return res.status(403).json({ error: 'Invalid or expired invitation link.' });
}
}
if (!username || !display_name || !email || !password) {
return res.status(400).json({ error: 'All fields are required' });
@@ -124,7 +145,7 @@ router.post('/register', registerLimiter, async (req, res) => {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(username)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
}
// M1: email format
@@ -137,7 +158,6 @@ router.post('/register', registerLimiter, async (req, res) => {
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
}
const db = getDb();
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]);
if (existing) {
return res.status(409).json({ error: 'Email is already in use' });
@@ -160,6 +180,14 @@ router.post('/register', registerLimiter, async (req, res) => {
[username, display_name, email.toLowerCase(), hash, verificationToken, expires]
);
// Mark invite as used if applicable
if (validatedInvite) {
const newUser = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (newUser) {
await db.run('UPDATE user_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?', [newUser.id, validatedInvite.id]);
}
}
// Build verification URL
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
@@ -172,9 +200,9 @@ router.post('/register', registerLimiter, async (req, res) => {
}
try {
await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName);
await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName, 'en');
} catch (mailErr) {
console.error('Verification mail failed:', mailErr.message);
log.auth.error(`Verification mail failed: ${mailErr.message}`);
// Account is created but email failed — user can resend from login page
return res.status(201).json({ needsVerification: true, emailFailed: true, message: 'Account created but verification email could not be sent. Please try resending.' });
}
@@ -182,18 +210,23 @@ router.post('/register', registerLimiter, async (req, res) => {
return res.status(201).json({ needsVerification: true, message: 'Verification email has been sent' });
}
// No SMTP configured register and login immediately (legacy behaviour)
// No SMTP configured - register and login immediately (legacy behaviour)
const result = await db.run(
'INSERT INTO users (name, display_name, email, password_hash, email_verified) VALUES (?, ?, ?, ?, 1)',
[username, display_name, email.toLowerCase(), hash]
);
// Mark invite as used if applicable
if (validatedInvite) {
await db.run('UPDATE user_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?', [result.lastInsertRowid, validatedInvite.id]);
}
const token = generateToken(result.lastInsertRowid);
const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [result.lastInsertRowid]);
res.status(201).json({ token, user });
} catch (err) {
console.error('Register error:', err);
log.auth.error(`Register error: ${err.message}`);
res.status(500).json({ error: 'Registration failed' });
}
});
@@ -227,7 +260,7 @@ router.get('/verify-email', async (req, res) => {
res.json({ verified: true, message: 'Email verified successfully' });
} catch (err) {
console.error('Verify email error:', err);
log.auth.error(`Verify email error: ${err.message}`);
res.status(500).json({ error: 'Verification failed' });
}
});
@@ -245,7 +278,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
}
const db = getDb();
const user = await db.get('SELECT id, name, display_name, email_verified, verification_resend_at FROM users WHERE email = ?', [email.toLowerCase()]);
const user = await db.get('SELECT id, name, display_name, language, email_verified, verification_resend_at FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user || user.email_verified) {
// Don't reveal whether account exists
@@ -280,15 +313,15 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
}
try {
await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName);
await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName, user.language || 'en');
} catch (mailErr) {
console.error('Resend verification mail failed:', mailErr.message);
log.auth.error(`Resend verification mail failed: ${mailErr.message}`);
return res.status(502).json({ error: 'Email could not be sent. Please check your SMTP configuration.' });
}
res.json({ message: 'If an account exists, a new email has been sent.' });
} catch (err) {
console.error('Resend verification error:', err);
log.auth.error(`Resend verification error: ${err.message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
@@ -302,7 +335,7 @@ router.post('/login', loginLimiter, async (req, res) => {
return res.status(400).json({ error: 'Email and password are required' });
}
// M1: basic email format check invalid format can never match a real account
// M1: basic email format check - invalid format can never match a real account
if (!EMAIL_RE.test(email)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
@@ -323,12 +356,12 @@ router.post('/login', loginLimiter, async (req, res) => {
res.json({ token, user: safeUser });
} catch (err) {
console.error('Login error:', err);
log.auth.error(`Login error: ${err.message}`);
res.status(500).json({ error: 'Login 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) => {
try {
const authHeader = req.headers.authorization;
@@ -341,14 +374,14 @@ router.post('/logout', authenticateToken, async (req, res) => {
try {
await redis.setex(`blacklist:${decoded.jti}`, ttl, '1');
} catch (redisErr) {
console.warn('Redis blacklist write failed:', redisErr.message);
log.auth.warn(`Redis blacklist write failed: ${redisErr.message}`);
}
}
}
res.json({ message: 'Logged out successfully' });
} catch (err) {
console.error('Logout error:', err);
log.auth.error(`Logout error: ${err.message}`);
res.status(500).json({ error: 'Logout failed' });
}
});
@@ -399,7 +432,7 @@ router.put('/profile', authenticateToken, profileLimiter, async (req, res) => {
if (name && name !== req.user.name) {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(name)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
}
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?) AND id != ?', [name, req.user.id]);
if (existingUsername) {
@@ -422,7 +455,7 @@ router.put('/profile', authenticateToken, profileLimiter, async (req, res) => {
const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]);
res.json({ user: updated });
} catch (err) {
console.error('Profile update error:', err);
log.auth.error(`Profile update error: ${err.message}`);
res.status(500).json({ error: 'Profile could not be updated' });
}
});
@@ -457,7 +490,7 @@ router.put('/password', authenticateToken, passwordLimiter, async (req, res) =>
res.json({ message: 'Password changed successfully' });
} catch (err) {
console.error('Password change error:', err);
log.auth.error(`Password change error: ${err.message}`);
res.status(500).json({ error: 'Password could not be changed' });
}
});
@@ -471,7 +504,7 @@ router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
return res.status(400).json({ error: 'Only image files are allowed' });
}
// M15: stream-level size limit abort as soon as 2 MB is exceeded
// M15: stream-level size limit - abort as soon as 2 MB is exceeded
const MAX_AVATAR_SIZE = 2 * 1024 * 1024;
const buffer = await new Promise((resolve, reject) => {
const chunks = [];
@@ -514,7 +547,7 @@ router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]);
res.json({ user: updated });
} catch (err) {
console.error('Avatar upload error:', err);
log.auth.error(`Avatar upload error: ${err.message}`);
res.status(500).json({ error: 'Avatar could not be uploaded' });
}
});
@@ -533,7 +566,7 @@ router.delete('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]);
res.json({ user: updated });
} catch (err) {
console.error('Avatar delete error:', err);
log.auth.error(`Avatar delete error: ${err.message}`);
res.status(500).json({ error: 'Avatar could not be removed' });
}
});

View File

@@ -5,6 +5,7 @@ import fs from 'fs';
import { fileURLToPath } from 'url';
import { getDb } from '../config/database.js';
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
import { log } from '../config/logger.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -81,14 +82,21 @@ router.get('/', async (req, res) => {
const defaultTheme = await getSetting('default_theme');
const logoFile = findLogoFile();
const registrationMode = await getSetting('registration_mode');
const imprintUrl = await getSetting('imprint_url');
const privacyUrl = await getSetting('privacy_url');
res.json({
appName: appName || 'Redlight',
hasLogo: !!logoFile,
logoUrl: logoFile ? '/api/branding/logo' : null,
defaultTheme: defaultTheme || null,
registrationMode: registrationMode || 'open',
imprintUrl: imprintUrl || null,
privacyUrl: privacyUrl || null,
});
} catch (err) {
console.error('Get branding error:', err);
log.branding.error('Get branding error:', err);
res.status(500).json({ error: 'Could not load branding' });
}
});
@@ -149,7 +157,7 @@ router.delete('/logo', authenticateToken, requireAdmin, async (req, res) => {
}
res.json({ message: 'Logo removed' });
} catch (err) {
console.error('Delete logo error:', err);
log.branding.error('Delete logo error:', err);
res.status(500).json({ error: 'Could not remove logo' });
}
});
@@ -167,7 +175,7 @@ router.put('/name', authenticateToken, requireAdmin, async (req, res) => {
await setSetting('app_name', appName.trim());
res.json({ appName: appName.trim() });
} catch (err) {
console.error('Update app name error:', err);
log.branding.error('Update app name error:', err);
res.status(500).json({ error: 'Could not update app name' });
}
});
@@ -186,9 +194,62 @@ router.put('/default-theme', authenticateToken, requireAdmin, async (req, res) =
await setSetting('default_theme', defaultTheme.trim());
res.json({ defaultTheme: defaultTheme.trim() });
} catch (err) {
console.error('Update default theme error:', err);
log.branding.error('Update default theme error:', err);
res.status(500).json({ error: 'Could not update default theme' });
}
});
// PUT /api/branding/registration-mode - Set registration mode (admin only)
router.put('/registration-mode', authenticateToken, requireAdmin, async (req, res) => {
try {
const { registrationMode } = req.body;
if (!registrationMode || !['open', 'invite'].includes(registrationMode)) {
return res.status(400).json({ error: 'registrationMode must be "open" or "invite"' });
}
await setSetting('registration_mode', registrationMode);
res.json({ registrationMode });
} catch (err) {
log.branding.error('Update registration mode error:', err);
res.status(500).json({ error: 'Could not update registration mode' });
}
});
// PUT /api/branding/imprint-url - Set imprint URL (admin only)
router.put('/imprint-url', authenticateToken, requireAdmin, async (req, res) => {
try {
const { imprintUrl } = req.body;
if (imprintUrl && imprintUrl.length > 500) {
return res.status(400).json({ error: 'URL must not exceed 500 characters' });
}
if (imprintUrl && imprintUrl.trim()) {
await setSetting('imprint_url', imprintUrl.trim());
} else {
await deleteSetting('imprint_url');
}
res.json({ imprintUrl: imprintUrl?.trim() || null });
} catch (err) {
log.branding.error('Update imprint URL error:', err);
res.status(500).json({ error: 'Could not update imprint URL' });
}
});
// PUT /api/branding/privacy-url - Set privacy policy URL (admin only)
router.put('/privacy-url', authenticateToken, requireAdmin, async (req, res) => {
try {
const { privacyUrl } = req.body;
if (privacyUrl && privacyUrl.length > 500) {
return res.status(400).json({ error: 'URL must not exceed 500 characters' });
}
if (privacyUrl && privacyUrl.trim()) {
await setSetting('privacy_url', privacyUrl.trim());
} else {
await deleteSetting('privacy_url');
}
res.json({ privacyUrl: privacyUrl?.trim() || null });
} catch (err) {
log.branding.error('Update privacy URL error:', err);
res.status(500).json({ error: 'Could not update privacy URL' });
}
});
export default router;

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

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

View File

@@ -1,9 +1,11 @@
import { Router } from 'express';
import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { rateLimit } from 'express-rate-limit';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
import { sendFederationInviteEmail } from '../config/mailer.js';
import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js';
import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js';
// M13: rate limit the unauthenticated federation receive endpoint
const federationReceiveLimiter = rateLimit({
@@ -38,7 +40,7 @@ export function wellKnownHandler(req, res) {
federation_api: '/api/federation',
public_key: getPublicKey(),
software: 'Redlight',
version: '1.2.0',
version: '1.4.0',
});
}
@@ -81,8 +83,11 @@ router.post('/invite', authenticateToken, async (req, res) => {
}
// 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
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const joinUrl = `${baseUrl}/join/${room.uid}`;
const joinUrl = room.access_code
? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}`
: `${baseUrl}/join/${room.uid}`;
// Build invitation payload
const inviteId = uuidv4();
@@ -119,9 +124,18 @@ router.post('/invite', authenticateToken, async (req, res) => {
throw new Error(data.error || `Remote server responded with ${response.status}`);
}
// Track outbound invite for deletion propagation
try {
await db.run(
`INSERT INTO federation_outbound_invites (room_uid, remote_domain) VALUES (?, ?)
ON CONFLICT(room_uid, remote_domain) DO NOTHING`,
[room.uid, domain]
);
} catch { /* table may not exist yet on upgrade */ }
res.json({ success: true, invite_id: inviteId });
} catch (err) {
console.error('Federation invite error:', err);
log.federation.error('Federation invite error:', err);
res.status(500).json({ error: err.message || 'Failed to send federation invite' });
}
});
@@ -210,22 +224,31 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
} catch { /* column may not exist on very old installs */ }
}
// Send notification email (truly fire-and-forget never blocks the response)
// Send notification email (truly fire-and-forget - never blocks the response)
if (targetUser.email) {
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight';
sendFederationInviteEmail(
targetUser.email, targetUser.name, from_user,
room_name, message || null, inboxUrl, appName
room_name, message || null, inboxUrl, appName, targetUser.language || 'en'
).catch(mailErr => {
console.warn('Federation invite mail failed (non-fatal):', mailErr.message);
log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message);
});
}
// In-app notification
await createNotification(
targetUser.id,
'federation_invite_received',
from_user,
room_name,
'/federation/inbox',
);
res.json({ success: true });
} catch (err) {
console.error('Federation receive error:', err);
log.federation.error('Federation receive error:', err);
res.status(500).json({ error: 'Failed to process federation invitation' });
}
});
@@ -242,7 +265,7 @@ router.get('/invitations', authenticateToken, async (req, res) => {
);
res.json({ invitations });
} catch (err) {
console.error('List federation invitations error:', err);
log.federation.error('List invitations error:', err);
res.status(500).json({ error: 'Failed to load invitations' });
}
});
@@ -251,12 +274,28 @@ router.get('/invitations', authenticateToken, async (req, res) => {
router.get('/invitations/pending-count', authenticateToken, async (req, res) => {
try {
const db = getDb();
const result = await db.get(
const roomResult = await db.get(
`SELECT COUNT(*) as count FROM federation_invitations
WHERE to_user_id = ? AND status = 'pending'`,
[req.user.id]
);
res.json({ count: result?.count || 0 });
let calResult = { count: 0 };
try {
calResult = await db.get(
`SELECT COUNT(*) as count FROM calendar_invitations
WHERE to_user_id = ? AND status = 'pending'`,
[req.user.id]
);
} catch { /* table may not exist yet */ }
let localCalResult = { count: 0 };
try {
localCalResult = await db.get(
`SELECT COUNT(*) as count FROM calendar_local_invitations
WHERE to_user_id = ? AND status = 'pending'`,
[req.user.id]
);
} catch { /* table may not exist yet */ }
res.json({ count: (roomResult?.count || 0) + (calResult?.count || 0) + (localCalResult?.count || 0) });
} catch (err) {
res.json({ count: 0 });
}
@@ -301,7 +340,7 @@ router.post('/invitations/:id/accept', authenticateToken, async (req, res) => {
res.json({ success: true, join_url: invitation.join_url });
} catch (err) {
console.error('Accept invitation error:', err);
log.federation.error('Accept invitation error:', err);
res.status(500).json({ error: 'Failed to accept invitation' });
}
});
@@ -323,11 +362,99 @@ router.delete('/invitations/:id', authenticateToken, async (req, res) => {
res.json({ success: true });
} catch (err) {
console.error('Decline invitation error:', err);
log.federation.error('Decline invitation error:', err);
res.status(500).json({ error: 'Failed to decline invitation' });
}
});
// ── GET /api/federation/calendar-invitations — List calendar invitations ─────
router.get('/calendar-invitations', authenticateToken, async (req, res) => {
try {
const db = getDb();
const invitations = await db.all(
`SELECT * FROM calendar_invitations
WHERE to_user_id = ?
ORDER BY created_at DESC`,
[req.user.id]
);
res.json({ invitations });
} catch (err) {
log.federation.error('List calendar invitations error:', err);
res.status(500).json({ error: 'Failed to load calendar invitations' });
}
});
// ── POST /api/federation/calendar-invitations/:id/accept ─────────────────────
router.post('/calendar-invitations/:id/accept', authenticateToken, async (req, res) => {
try {
const db = getDb();
const inv = await db.get(
`SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`,
[req.params.id, req.user.id]
);
if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' });
if (inv.status === 'accepted') return res.status(400).json({ error: 'Already accepted' });
await db.run(
`UPDATE calendar_invitations SET status = 'accepted' WHERE id = ?`,
[inv.id]
);
// Check if event was already previously accepted (duplicate guard)
const existing = await db.get(
'SELECT id FROM calendar_events WHERE uid = ? AND user_id = ?',
[inv.event_uid, req.user.id]
);
if (!existing) {
await db.run(`
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, federated_from, federated_join_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
inv.event_uid,
inv.title,
inv.description || null,
inv.start_time,
inv.end_time,
inv.room_uid || null,
req.user.id,
inv.color || '#6366f1',
inv.from_user,
inv.join_url || null,
]);
}
res.json({ success: true });
} catch (err) {
log.federation.error('Accept calendar invitation error:', err);
res.status(500).json({ error: 'Failed to accept calendar invitation' });
}
});
// ── DELETE /api/federation/calendar-invitations/:id — Decline/dismiss ────────
router.delete('/calendar-invitations/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const inv = await db.get(
`SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`,
[req.params.id, req.user.id]
);
if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' });
if (inv.status === 'pending') {
// mark as declined
await db.run(`UPDATE calendar_invitations SET status = 'declined' WHERE id = ?`, [inv.id]);
} else {
// accepted or declined — permanently remove from inbox
await db.run('DELETE FROM calendar_invitations WHERE id = ?', [inv.id]);
}
res.json({ success: true });
} catch (err) {
log.federation.error('Delete calendar invitation error:', err);
res.status(500).json({ error: 'Failed to remove calendar invitation' });
}
});
// ── GET /api/federation/federated-rooms — List saved federated rooms ────────
router.get('/federated-rooms', authenticateToken, async (req, res) => {
try {
@@ -338,7 +465,7 @@ router.get('/federated-rooms', authenticateToken, async (req, res) => {
);
res.json({ rooms });
} catch (err) {
console.error('List federated rooms error:', err);
log.federation.error('List federated rooms error:', err);
res.status(500).json({ error: 'Failed to load federated rooms' });
}
});
@@ -355,9 +482,194 @@ router.delete('/federated-rooms/:id', authenticateToken, async (req, res) => {
await db.run('DELETE FROM federated_rooms WHERE id = ?', [room.id]);
res.json({ success: true });
} catch (err) {
console.error('Delete federated room error:', err);
log.federation.error('Delete federated room error:', err);
res.status(500).json({ error: 'Failed to remove room' });
}
});
// ── POST /api/federation/room-sync — Remote instances query room settings ───
// Called by federated instances to pull current room info for one or more UIDs.
// Signed request from remote, no auth token needed.
router.post('/room-sync', federationReceiveLimiter, async (req, res) => {
try {
if (!isFederationEnabled()) {
return res.status(400).json({ error: 'Federation is not configured on this instance' });
}
const signature = req.headers['x-federation-signature'];
const originDomain = req.headers['x-federation-origin'];
const payload = req.body || {};
if (!signature || !originDomain) {
return res.status(401).json({ error: 'Missing federation signature or origin' });
}
// Verify signature using the remote instance's public key
const { publicKey } = await discoverInstance(originDomain);
if (!publicKey || !verifyPayload(payload, signature, publicKey)) {
return res.status(403).json({ error: 'Invalid federation signature' });
}
const { room_uids } = payload;
if (!Array.isArray(room_uids) || room_uids.length === 0 || room_uids.length > 100) {
return res.status(400).json({ error: 'room_uids must be an array of 1-100 UIDs' });
}
const db = getDb();
const result = {};
for (const uid of room_uids) {
if (typeof uid !== 'string' || uid.length > 100) continue;
const room = await db.get('SELECT uid, name, max_participants, record_meeting FROM rooms WHERE uid = ?', [uid]);
if (room) {
result[uid] = {
room_name: room.name,
max_participants: room.max_participants ?? 0,
allow_recording: room.record_meeting ?? 1,
deleted: false,
};
} else {
result[uid] = { deleted: true };
}
}
res.json({ rooms: result });
} catch (err) {
log.federation.error('Room-sync error:', err);
res.status(500).json({ error: 'Failed to process room sync request' });
}
});
// ── POST /api/federation/calendar-event-deleted — Receive calendar deletion ─
router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res) => {
try {
if (!isFederationEnabled()) {
return res.status(400).json({ error: 'Federation is not configured on this instance' });
}
const signature = req.headers['x-federation-signature'];
const originDomain = req.headers['x-federation-origin'];
const payload = req.body || {};
if (!signature || !originDomain) {
return res.status(401).json({ error: 'Missing federation signature or origin' });
}
const { publicKey } = await discoverInstance(originDomain);
if (!publicKey || !verifyPayload(payload, signature, publicKey)) {
return res.status(403).json({ error: 'Invalid federation signature' });
}
const { event_uid } = payload;
if (!event_uid || typeof event_uid !== 'string') {
return res.status(400).json({ error: 'event_uid is required' });
}
const db = getDb();
// Collect all affected users before deleting (for email notifications)
let affectedUsers = [];
try {
// Users with pending/declined invitations
const invUsers = await db.all(
`SELECT u.email, u.name, u.language, ci.title, ci.from_user
FROM calendar_invitations ci
JOIN users u ON ci.to_user_id = u.id
WHERE ci.event_uid = ? AND ci.from_user LIKE ?`,
[event_uid, `%@${originDomain}`]
);
// Users who already accepted (event in their calendar)
const calUsers = await db.all(
`SELECT u.email, u.name, u.language, ce.title, ce.federated_from AS from_user
FROM calendar_events ce
JOIN users u ON ce.user_id = u.id
WHERE ce.uid = ? AND ce.federated_from LIKE ?`,
[event_uid, `%@${originDomain}`]
);
// Merge, deduplicate by email
const seen = new Set();
for (const row of [...invUsers, ...calUsers]) {
if (row.email && !seen.has(row.email)) {
seen.add(row.email);
affectedUsers.push(row);
}
}
} catch { /* non-fatal */ }
// Remove from calendar_invitations for all users on this instance
await db.run(
`DELETE FROM calendar_invitations
WHERE event_uid = ? AND from_user LIKE ?`,
[event_uid, `%@${originDomain}`]
);
// Remove from calendar_events (accepted invitations) for all users on this instance
await db.run(
`DELETE FROM calendar_events
WHERE uid = ? AND federated_from LIKE ?`,
[event_uid, `%@${originDomain}`]
);
log.federation.info(`Calendar event ${event_uid} deleted (origin: ${originDomain})`);
// Notify affected users by email (fire-and-forget)
if (affectedUsers.length > 0) {
const appName = process.env.APP_NAME || 'Redlight';
for (const u of affectedUsers) {
sendCalendarEventDeletedEmail(u.email, u.name, u.from_user, u.title, appName, u.language || 'en')
.catch(mailErr => {
log.federation.warn(`Calendar deletion mail to ${u.email} failed (non-fatal): ${mailErr.message}`);
});
}
}
res.json({ success: true });
} catch (err) {
log.federation.error('Calendar-event-deleted error:', err);
res.status(500).json({ error: 'Failed to process calendar event deletion' });
}
});
// ── POST /api/federation/room-deleted — Receive deletion notification ───────
// Origin instance pushes this to notify that a room has been deleted.
router.post('/room-deleted', federationReceiveLimiter, async (req, res) => {
try {
if (!isFederationEnabled()) {
return res.status(400).json({ error: 'Federation is not configured on this instance' });
}
const signature = req.headers['x-federation-signature'];
const originDomain = req.headers['x-federation-origin'];
const payload = req.body || {};
if (!signature || !originDomain) {
return res.status(401).json({ error: 'Missing federation signature or origin' });
}
const { publicKey } = await discoverInstance(originDomain);
if (!publicKey || !verifyPayload(payload, signature, publicKey)) {
return res.status(403).json({ error: 'Invalid federation signature' });
}
const { room_uid } = payload;
if (!room_uid || typeof room_uid !== 'string') {
return res.status(400).json({ error: 'room_uid is required' });
}
const db = getDb();
// Mark all federated_rooms with this meet_id (room_uid) from this origin as deleted
await db.run(
`UPDATE federated_rooms SET deleted = 1, updated_at = CURRENT_TIMESTAMP
WHERE meet_id = ? AND from_user LIKE ?`,
[room_uid, `%@${originDomain}`]
);
log.federation.info(`Room ${room_uid} marked as deleted (origin: ${originDomain})`);
res.json({ success: true });
} catch (err) {
log.federation.error('Room-deleted error:', err);
res.status(500).json({ error: 'Failed to process deletion notification' });
}
});
export default router;

View File

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

View File

@@ -1,6 +1,7 @@
import { Router } from 'express';
import { authenticateToken } from '../middleware/auth.js';
import { getDb } from '../config/database.js';
import { log } from '../config/logger.js';
import {
getRecordings,
getRecordingByRecordId,
@@ -65,7 +66,7 @@ router.get('/', authenticateToken, async (req, res) => {
res.json({ recordings: formatted });
} catch (err) {
console.error('Get recordings error:', err);
log.recordings.error(`Get recordings error: ${err.message}`);
res.status(500).json({ error: 'Recordings could not be loaded', recordings: [] });
}
});
@@ -117,7 +118,7 @@ router.get('/room/:uid', authenticateToken, async (req, res) => {
res.json({ recordings: formatted });
} catch (err) {
console.error('Get room recordings error:', err);
log.recordings.error(`Get room recordings error: ${err.message}`);
res.status(500).json({ error: 'Recordings could not be loaded', recordings: [] });
}
});
@@ -147,7 +148,7 @@ router.delete('/:recordID', authenticateToken, async (req, res) => {
await deleteRecording(req.params.recordID);
res.json({ message: 'Recording deleted' });
} catch (err) {
console.error('Delete recording error:', err);
log.recordings.error(`Delete recording error: ${err.message}`);
res.status(500).json({ error: 'Recording could not be deleted' });
}
});
@@ -178,7 +179,7 @@ router.put('/:recordID/publish', authenticateToken, async (req, res) => {
await publishRecording(req.params.recordID, publish);
res.json({ message: publish ? 'Recording published' : 'Recording unpublished' });
} catch (err) {
console.error('Publish recording error:', err);
log.recordings.error(`Publish recording error: ${err.message}`);
res.status(500).json({ error: 'Recording could not be updated' });
}
});

View File

@@ -1,4 +1,4 @@
import { Router } from 'express';
import { Router } from 'express';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
@@ -6,6 +6,8 @@ import { fileURLToPath } from 'url';
import { rateLimit } from 'express-rate-limit';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js';
import {
createMeeting,
joinMeeting,
@@ -13,6 +15,12 @@ import {
getMeetingInfo,
isMeetingRunning,
} from '../config/bbb.js';
import {
isFederationEnabled,
getFederationDomain,
signPayload,
discoverInstance,
} from '../config/federation.js';
// L6: constant-time string comparison for access/moderator codes
function timingSafeEqual(a, b) {
@@ -72,7 +80,7 @@ router.get('/', authenticateToken, async (req, res) => {
res.json({ rooms: [...ownRooms, ...sharedRooms] });
} catch (err) {
console.error('List rooms error:', err);
log.rooms.error(`List rooms error: ${err.message}`);
res.status(500).json({ error: 'Rooms could not be loaded' });
}
});
@@ -94,7 +102,7 @@ router.get('/users/search', authenticateToken, async (req, res) => {
`, [searchTerm, searchTerm, searchTerm, req.user.id]);
res.json({ users });
} catch (err) {
console.error('Search users error:', err);
log.rooms.error(`Search users error: ${err.message}`);
res.status(500).json({ error: 'User search failed' });
}
});
@@ -133,7 +141,7 @@ router.get('/:uid', authenticateToken, async (req, res) => {
res.json({ room, sharedUsers });
} catch (err) {
console.error('Get room error:', err);
log.rooms.error(`Get room error: ${err.message}`);
res.status(500).json({ error: 'Room could not be loaded' });
}
});
@@ -205,7 +213,7 @@ router.post('/', authenticateToken, async (req, res) => {
const room = await db.get('SELECT * FROM rooms WHERE id = ?', [result.lastInsertRowid]);
res.status(201).json({ room });
} catch (err) {
console.error('Create room error:', err);
log.rooms.error(`Create room error: ${err.message}`);
res.status(500).json({ error: 'Room could not be created' });
}
});
@@ -288,7 +296,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
res.json({ room: updated });
} catch (err) {
console.error('Update room error:', err);
log.rooms.error(`Update room error: ${err.message}`);
res.status(500).json({ error: 'Room could not be updated' });
}
});
@@ -307,10 +315,43 @@ router.delete('/:uid', authenticateToken, async (req, res) => {
return res.status(403).json({ error: 'No permission' });
}
// Notify federated instances about deletion (fire-and-forget)
if (isFederationEnabled()) {
try {
const outbound = await db.all(
'SELECT remote_domain FROM federation_outbound_invites WHERE room_uid = ?',
[room.uid]
);
for (const { remote_domain } of outbound) {
const payload = {
room_uid: room.uid,
timestamp: new Date().toISOString(),
};
const signature = signPayload(payload);
discoverInstance(remote_domain).then(({ baseUrl: remoteApi }) => {
fetch(`${remoteApi}/room-deleted`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Federation-Signature': signature,
'X-Federation-Origin': getFederationDomain(),
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10_000),
}).catch(err => log.federation.warn(`Delete notify to ${remote_domain} failed: ${err.message}`));
}).catch(err => log.federation.warn(`Discovery for ${remote_domain} failed: ${err.message}`));
}
// Clean up outbound records
await db.run('DELETE FROM federation_outbound_invites WHERE room_uid = ?', [room.uid]);
} catch (fedErr) {
log.federation.warn(`Delete notification error (non-fatal): ${fedErr.message}`);
}
}
await db.run('DELETE FROM rooms WHERE uid = ?', [req.params.uid]);
res.json({ message: 'Room deleted successfully' });
} catch (err) {
console.error('Delete room error:', err);
log.rooms.error(`Delete room error: ${err.message}`);
res.status(500).json({ error: 'Room could not be deleted' });
}
});
@@ -330,7 +371,7 @@ router.get('/:uid/shares', authenticateToken, async (req, res) => {
`, [room.id]);
res.json({ shares });
} catch (err) {
console.error('Get shares error:', err);
log.rooms.error(`Get shares error: ${err.message}`);
res.status(500).json({ error: 'Error loading shares' });
}
});
@@ -362,9 +403,18 @@ router.post('/:uid/shares', authenticateToken, async (req, res) => {
JOIN users u ON rs.user_id = u.id
WHERE rs.room_id = ?
`, [room.id]);
// Notify the user who was given access
const sharerName = req.user.display_name || req.user.name;
await createNotification(
user_id,
'room_share_added',
room.name,
sharerName,
`/rooms/${room.uid}`,
);
res.json({ shares });
} catch (err) {
console.error('Share room error:', err);
log.rooms.error(`Share room error: ${err.message}`);
res.status(500).json({ error: 'Error sharing room' });
}
});
@@ -377,16 +427,25 @@ router.delete('/:uid/shares/:userId', authenticateToken, async (req, res) => {
if (!room) {
return res.status(404).json({ error: 'Room not found or no permission' });
}
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, parseInt(req.params.userId)]);
const removedUserId = parseInt(req.params.userId);
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, removedUserId]);
const shares = await db.all(`
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM room_shares rs
JOIN users u ON rs.user_id = u.id
WHERE rs.room_id = ?
`, [room.id]);
// Notify the user whose access was removed
await createNotification(
removedUserId,
'room_share_removed',
room.name,
null,
'/dashboard',
);
res.json({ shares });
} catch (err) {
console.error('Remove share error:', err);
log.rooms.error(`Remove share error: ${err.message}`);
res.status(500).json({ error: 'Error removing share' });
}
});
@@ -421,7 +480,7 @@ router.post('/:uid/start', authenticateToken, async (req, res) => {
const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL);
res.json({ joinUrl });
} catch (err) {
console.error('Start meeting error:', err);
log.rooms.error(`Start meeting error: ${err.message}`);
res.status(500).json({ error: 'Meeting could not be started' });
}
});
@@ -455,7 +514,7 @@ router.post('/:uid/join', authenticateToken, async (req, res) => {
const joinUrl = await joinMeeting(room.uid, req.user.display_name || req.user.name, isModerator, avatarURL);
res.json({ joinUrl });
} catch (err) {
console.error('Join meeting error:', err);
log.rooms.error(`Join meeting error: ${err.message}`);
res.status(500).json({ error: 'Could not join meeting' });
}
});
@@ -482,7 +541,7 @@ router.post('/:uid/end', authenticateToken, async (req, res) => {
await endMeeting(room.uid);
res.json({ message: 'Meeting ended' });
} catch (err) {
console.error('End meeting error:', err);
log.rooms.error(`End meeting error: ${err.message}`);
res.status(500).json({ error: 'Meeting could not be ended' });
}
});
@@ -519,7 +578,7 @@ router.get('/:uid/public', async (req, res) => {
running,
});
} catch (err) {
console.error('Public room info error:', err);
log.rooms.error(`Public room info error: ${err.message}`);
res.status(500).json({ error: 'Room info could not be loaded' });
}
});
@@ -574,7 +633,7 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL);
res.json({ joinUrl });
} catch (err) {
console.error('Guest join error:', err);
log.rooms.error(`Guest join error: ${err.message}`);
res.status(500).json({ error: 'Guest join failed' });
}
});
@@ -608,7 +667,7 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => {
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
if (!room) return res.status(404).json({ error: 'Room not found or no permission' });
// M16: stream-level size limit abort as soon as 50 MB is exceeded
// M16: stream-level size limit - abort as soon as 50 MB is exceeded
const MAX_PRESENTATION_SIZE = 50 * 1024 * 1024;
const buffer = await new Promise((resolve, reject) => {
const chunks = [];
@@ -665,7 +724,7 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => {
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
res.json({ room: updated });
} catch (err) {
console.error('Presentation upload error:', err);
log.rooms.error(`Presentation upload error: ${err.message}`);
res.status(500).json({ error: 'Presentation could not be uploaded' });
}
});
@@ -687,7 +746,7 @@ router.delete('/:uid/presentation', authenticateToken, async (req, res) => {
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
res.json({ room: updated });
} catch (err) {
console.error('Presentation delete error:', err);
log.rooms.error(`Presentation delete error: ${err.message}`);
res.status(500).json({ error: 'Presentation could not be removed' });
}
});

View File

@@ -16,6 +16,7 @@ import Admin from './pages/Admin';
import GuestJoin from './pages/GuestJoin';
import FederationInbox from './pages/FederationInbox';
import FederatedRoomDetail from './pages/FederatedRoomDetail';
import Calendar from './pages/Calendar';
export default function App() {
const { user, loading } = useAuth();
@@ -54,6 +55,7 @@ export default function App() {
{/* Protected routes */}
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/calendar" element={<Calendar />} />
<Route path="/rooms/:uid" element={<RoomDetail />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} />

View File

@@ -1,4 +1,4 @@
import { Globe, Trash2, ExternalLink, Hash, Users, Video, VideoOff } from 'lucide-react';
import { Globe, Trash2, ExternalLink, Hash, Users, Video, VideoOff, AlertTriangle } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import api from '../services/api';
@@ -8,8 +8,11 @@ export default function FederatedRoomCard({ room, onRemove }) {
const { t } = useLanguage();
const navigate = useNavigate();
const isDeleted = room.deleted === 1 || room.deleted === true;
const handleJoin = (e) => {
e.stopPropagation();
if (isDeleted) return;
window.open(room.join_url, '_blank');
};
@@ -28,7 +31,7 @@ export default function FederatedRoomCard({ room, onRemove }) {
const recordingOn = room.allow_recording === 1 || room.allow_recording === true;
return (
<div className="card-hover group p-5 cursor-pointer" onClick={() => navigate(`/federation/rooms/${room.id}`)}>
<div className={`card-hover group p-5 cursor-pointer ${isDeleted ? 'opacity-60' : ''}`} onClick={() => navigate(`/federation/rooms/${room.id}`)}>
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
@@ -36,9 +39,16 @@ export default function FederatedRoomCard({ room, onRemove }) {
<h3 className="text-base font-semibold text-th-text truncate group-hover:text-th-accent transition-colors">
{room.room_name}
</h3>
<span className="flex-shrink-0 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
{t('federation.federated')}
</span>
{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">
<AlertTriangle size={10} />
{t('federation.roomDeleted')}
</span>
) : (
<span className="flex-shrink-0 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
{t('federation.federated')}
</span>
)}
</div>
<p className="text-sm text-th-text-s mt-0.5 truncate">
{t('federation.from')}: <span className="font-medium">{room.from_user}</span>
@@ -79,17 +89,21 @@ export default function FederatedRoomCard({ room, onRemove }) {
</div>
{/* Read-only notice */}
<p className="text-xs text-th-text-s mb-4 italic">{t('federation.readOnlyNotice')}</p>
<p className="text-xs text-th-text-s mb-4 italic">
{isDeleted ? t('federation.roomDeletedNotice') : t('federation.readOnlyNotice')}
</p>
{/* Actions */}
<div className="flex items-center gap-2 pt-3 border-t border-th-border">
<button
onClick={handleJoin}
className="btn-primary text-xs py-1.5 px-3 flex-1"
>
<ExternalLink size={14} />
{t('federation.joinMeeting')}
</button>
{!isDeleted && (
<button
onClick={handleJoin}
className="btn-primary text-xs py-1.5 px-3 flex-1"
>
<ExternalLink size={14} />
{t('federation.joinMeeting')}
</button>
)}
<button
onClick={handleRemove}
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"

View File

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

View File

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

View File

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

View File

@@ -28,8 +28,10 @@ export function AuthProvider({ children }) {
return res.data.user;
}, []);
const register = useCallback(async (username, displayName, email, password) => {
const res = await api.post('/auth/register', { username, display_name: displayName, email, password });
const register = useCallback(async (username, displayName, email, password, inviteToken) => {
const payload = { username, display_name: displayName, email, password };
if (inviteToken) payload.invite_token = inviteToken;
const res = await api.post('/auth/register', payload);
if (res.data.needsVerification) {
return { needsVerification: true };
}

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
{
{
"common": {
"appName": "Redlight",
"loading": "Loading...",
@@ -32,7 +32,10 @@
"appearance": "Appearance",
"changeTheme": "Change theme",
"navigation": "Navigation",
"federation": "Invitations"
"calendar": "Calendar",
"federation": "Invitations",
"imprint": "Imprint",
"privacy": "Privacy Policy"
},
"auth": {
"login": "Sign in",
@@ -75,18 +78,20 @@
"emailNotVerified": "Email not yet verified. Please check your inbox.",
"username": "Username",
"usernamePlaceholder": "e.g. johndoe",
"usernameHint": "Letters, numbers, _ and - only (330 chars)",
"usernameHint": "Letters, numbers, _ and - only (3-30 chars)",
"displayName": "Display Name",
"displayNamePlaceholder": "John Doe",
"usernameTaken": "Username is already taken",
"usernameInvalid": "Username may only contain letters, numbers, _ and - (330 chars)",
"usernameInvalid": "Username may only contain letters, numbers, _ and - (3-30 chars)",
"usernameRequired": "Username is required",
"displayNameRequired": "Display name is required",
"emailVerificationBanner": "Your email address has not been verified yet.",
"emailVerificationResend": "Click here to receive a new verification email",
"emailVerificationResendCooldown": "Resend in {seconds}s",
"emailVerificationResendSuccess": "Verification email sent!",
"emailVerificationResendFailed": "Could not send verification email"
"emailVerificationResendFailed": "Could not send verification email",
"inviteOnly": "Invite Only",
"inviteOnlyDesc": "Registration is currently restricted. You need an invitation link from an administrator to create an account."
},
"home": {
"poweredBy": "Powered by BigBlueButton",
@@ -228,6 +233,7 @@
"presentationRemoveFailed": "Could not remove presentation",
"presentationAllowedTypes": "PDF, PPT, PPTX, ODP, DOC, DOCX · max. 50 MB",
"presentationCurrent": "Current:",
"shareTitle": "Share Room",
"shareDescription": "Share this room with other users so they can see it in their dashboard and join meetings.",
"shareSearchPlaceholder": "Search users (name or email)...",
"shareAdded": "User added",
@@ -333,7 +339,44 @@
"defaultThemeLabel": "Default Theme",
"defaultThemeDesc": "Applied to unauthenticated pages (guest join, login, home) when no personal preference is set.",
"defaultThemeSaved": "Default theme saved",
"defaultThemeUpdateFailed": "Could not update default theme"
"defaultThemeUpdateFailed": "Could not update default theme",
"regModeTitle": "Registration Mode",
"regModeDescription": "Control how new users can register. \"Open\" allows everyone to sign up. \"Invite only\" requires an invitation link.",
"regModeOpen": "Open registration",
"regModeInvite": "Invite only",
"regModeSaved": "Registration mode updated",
"regModeFailed": "Could not update registration mode",
"inviteTitle": "User Invitations",
"inviteDescription": "Invite new users by email. They will receive a registration link valid for 7 days.",
"sendInvite": "Send invite",
"inviteSent": "Invitation sent!",
"inviteFailed": "Could not send invitation",
"inviteDeleted": "Invitation deleted",
"inviteDeleteFailed": "Could not delete invitation",
"inviteLinkCopied": "Invite link copied!",
"copyInviteLink": "Copy invite link",
"inviteExpired": "Expired",
"inviteUsedBy": "Used by",
"inviteExpiresAt": "Expires",
"noInvites": "No invitations yet",
"legalLinksTitle": "Legal Links",
"legalLinksDesc": "Show Imprint and Privacy Policy links at the bottom of the sidebar. Leave blank to hide.",
"imprintUrl": "Imprint URL",
"privacyUrl": "Privacy Policy URL",
"imprintUrlSaved": "Imprint URL saved",
"privacyUrlSaved": "Privacy Policy URL saved",
"imprintUrlFailed": "Could not save Imprint URL",
"privacyUrlFailed": "Could not save Privacy Policy URL"
},
"notifications": {
"bell": "Notifications",
"markAllRead": "Mark all read",
"clearAll": "Clear all",
"delete": "Delete",
"noNotifications": "No notifications yet",
"roomShareAdded": "Room shared with you",
"roomShareRemoved": "Room access removed",
"federationInviteReceived": "New meeting invitation"
},
"federation": {
"inbox": "Invitations",
@@ -371,7 +414,7 @@
"removeRoomConfirm": "Really remove this room?",
"roomRemoved": "Room removed",
"roomRemoveFailed": "Could not remove room",
"acceptedSaved": "Invitation accepted room saved to your dashboard!",
"acceptedSaved": "Invitation accepted - room saved to your dashboard!",
"meetingId": "Meeting ID",
"maxParticipants": "Max. participants",
"recordingOn": "Recording enabled",
@@ -383,6 +426,111 @@
"recordingOnHint": "Meetings in this room may be recorded",
"recordingOffHint": "Meetings in this room will not be recorded",
"roomDetails": "Room Details",
"joinUrl": "Join URL"
"joinUrl": "Join URL",
"roomDeleted": "Deleted",
"roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available.",
"calendarEvent": "Calendar Invitation",
"calendarAccepted": "Calendar event accepted and added to your calendar!",
"localCalendarEvent": "Local Calendar Invitation",
"calendarLocalAccepted": "Invitation accepted - event added to your calendar!",
"invitationRemoved": "Invitation removed",
"removeInvitation": "Remove invitation"
},
"calendar": {
"title": "Calendar",
"subtitle": "Plan and manage your meetings",
"newEvent": "New Event",
"createEvent": "Create Event",
"editEvent": "Edit Event",
"eventTitle": "Title",
"eventTitlePlaceholder": "e.g. Team Meeting",
"description": "Description",
"descriptionPlaceholder": "Add a description...",
"startTime": "Start",
"endTime": "End",
"linkedRoom": "Linked Room",
"noRoom": "No room (no video meeting)",
"linkedRoomHint": "Link a room to automatically include the join-URL in the event.",
"color": "Color",
"eventCreated": "Event created!",
"eventUpdated": "Event updated!",
"eventDeleted": "Event deleted",
"saveFailed": "Could not save event",
"deleteFailed": "Could not delete event",
"deleteConfirm": "Really delete this event?",
"loadFailed": "Events could not be loaded",
"today": "Today",
"month": "Month",
"week": "Week",
"more": "more",
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat",
"sun": "Sun",
"downloadICS": "Download ICS",
"addToOutlook": "Add to Outlook",
"addToGoogleCalendar": "Google Calendar",
"icsDownloaded": "ICS file downloaded",
"icsFailed": "Could not download ICS file",
"share": "Share",
"shareEvent": "Share Event",
"shareAdded": "User added to event",
"shareRemoved": "Share removed",
"shareFailed": "Could not share event",
"invitationSent": "Invitation sent!",
"invitationCancelled": "Invitation cancelled",
"invitationPending": "Invitation pending",
"pendingInvitations": "Pending Invitations",
"accepted": "Accepted",
"sendFederated": "Send to remote",
"sendFederatedTitle": "Send Event to Remote Instance",
"sendFederatedDesc": "Send this calendar event to a user on another Redlight instance. The recipient must accept the invitation before the event appears in their calendar.",
"send": "Send",
"fedSent": "Calendar invitation sent! The recipient must accept it first.",
"fedFailed": "Could not send event to remote instance",
"openRoom": "Open linked room",
"organizer": "Organizer",
"federatedFrom": "From remote instance",
"joinFederatedMeeting": "Join remote meeting"
},
"email": {
"greeting": "Hey {name} 👋",
"viewInvitation": "View Invitation",
"invitationFooter": "Open the link above to accept or decline the invitation.",
"linkHint": "Or copy this link in your browser:",
"verify": {
"subject": "{appName} - Verify your email",
"intro": "Please verify your email address by clicking the button below:",
"button": "Verify Email",
"validity": "This link is valid for 24 hours.",
"footer": "If you didn't register, please ignore this email."
},
"invite": {
"subject": "{appName} - You've been invited",
"title": "You've been invited! 🎉",
"intro": "You have been invited to create an account on {appName}.",
"prompt": "Click the button below to register:",
"button": "Create Account",
"validity": "This link is valid for 7 days.",
"footer": "If you didn't expect this invitation, you can safely ignore this email."
},
"federationInvite": {
"subject": "{appName} - Meeting invitation from {fromUser}",
"intro": "You have received a meeting invitation from {fromUser}.",
"roomLabel": "Room:"
},
"calendarInvite": {
"subject": "{appName} - Calendar invitation from {fromUser}",
"intro": "You have received a calendar invitation from {fromUser}."
},
"calendarDeleted": {
"subject": "{appName} - Calendar event cancelled: {title}",
"intro": "The following calendar event was deleted by the organiser ({fromUser}) and is no longer available:",
"note": "The event has been automatically removed from your calendar.",
"footer": "This message was sent automatically by {appName}."
}
}
}

View File

@@ -440,6 +440,32 @@
--gradient-end: #d6336a;
}
/* ===== RED MODULAR LIGHT ===== */
[data-theme="red-modular-light"] {
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
--text-primary: #000000;
--text-secondary: #333333;
--accent: #e60000;
--accent-hover: #ff3333;
--accent-text: #ffffff;
--border: #e2e8f0;
--card-bg: #ffffff;
--input-bg: #ffffff;
--input-border: #cbd5e1;
--nav-bg: #ffffff;
--sidebar-bg: #f8fafc;
--hover-bg: #f1f5f9;
--success: #86b300;
--warning: #ecb637;
--error: #ec4137;
--ring: #b30051;
--shadow-color: rgba(0, 0, 0, 0.3);
--gradient-start: #b30051;
--gradient-end: #d6336a;
}
@layer components {
.btn-primary {

View File

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

View File

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

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

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

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
ArrowLeft, Globe, ExternalLink, Trash2, Hash, Users,
Video, VideoOff, Loader2, Link2,
Video, VideoOff, Loader2, Link2, AlertTriangle,
} from 'lucide-react';
import api from '../services/api';
import { useLanguage } from '../contexts/LanguageContext';
@@ -66,6 +66,7 @@ export default function FederatedRoomDetail() {
if (!room) return null;
const recordingOn = room.allow_recording === 1 || room.allow_recording === true;
const isDeleted = room.deleted === 1 || room.deleted === true;
return (
<div className="max-w-3xl mx-auto">
@@ -78,19 +79,38 @@ export default function FederatedRoomDetail() {
{t('federation.backToDashboard')}
</button>
{/* Deleted banner */}
{isDeleted && (
<div className="card p-4 mb-4 border-red-500/30 bg-red-500/10">
<div className="flex items-center gap-3">
<AlertTriangle size={20} className="text-red-500 flex-shrink-0" />
<div>
<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>
</div>
</div>
</div>
)}
{/* Header */}
<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 items-start gap-3 min-w-0">
<div className="w-10 h-10 rounded-lg bg-th-accent/15 flex items-center justify-center flex-shrink-0 mt-0.5">
<Globe size={20} className="text-th-accent" />
<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'}`}>
{isDeleted ? <AlertTriangle size={20} className="text-red-500" /> : <Globe size={20} className="text-th-accent" />}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-bold text-th-text truncate">{room.room_name}</h1>
<span className="px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
{t('federation.federated')}
</span>
{isDeleted ? (
<span className="px-2 py-0.5 bg-red-500/15 text-red-500 rounded-full text-xs font-medium">
{t('federation.roomDeleted')}
</span>
) : (
<span className="px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
{t('federation.federated')}
</span>
)}
</div>
<p className="text-sm text-th-text-s mt-1">
{t('federation.from')}: <span className="font-medium text-th-text">{room.from_user}</span>
@@ -99,13 +119,15 @@ export default function FederatedRoomDetail() {
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={handleJoin}
className="btn-primary"
>
<ExternalLink size={16} />
{t('federation.joinMeeting')}
</button>
{!isDeleted && (
<button
onClick={handleJoin}
className="btn-primary"
>
<ExternalLink size={16} />
{t('federation.joinMeeting')}
</button>
)}
<button
onClick={handleRemove}
disabled={removing}
@@ -176,7 +198,7 @@ export default function FederatedRoomDetail() {
{/* Read-only notice */}
<p className="text-xs text-th-text-s mt-4 text-center italic">
{t('federation.readOnlyNotice')}
{isDeleted ? t('federation.roomDeletedNotice') : t('federation.readOnlyNotice')}
</p>
</div>
);

View File

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

View File

@@ -1,23 +1,26 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle } from 'lucide-react';
import { useParams, Link, useSearchParams } from 'react-router-dom';
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import api from '../services/api';
import toast from 'react-hot-toast';
import { useLanguage } from '../contexts/LanguageContext';
import { useAuth } from '../contexts/AuthContext';
import { useBranding } from '../contexts/BrandingContext';
export default function GuestJoin() {
const { uid } = useParams();
const [searchParams] = useSearchParams();
const { t } = useLanguage();
const { user } = useAuth();
const { imprintUrl, privacyUrl } = useBranding();
const isLoggedIn = !!user;
const [roomInfo, setRoomInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [joining, setJoining] = useState(false);
const [name, setName] = useState(user?.name || '');
const [accessCode, setAccessCode] = useState('');
const [accessCode, setAccessCode] = useState(searchParams.get('ac') || '');
const [moderatorCode, setModeratorCode] = useState('');
const [status, setStatus] = useState({ running: false });
const [recordingConsent, setRecordingConsent] = useState(false);
@@ -260,6 +263,36 @@ export default function GuestJoin() {
</Link>
</div>
)}
{(imprintUrl || privacyUrl) && (
<div className="flex items-center justify-center gap-4 mt-4 pt-4 border-t border-th-border/60">
{imprintUrl && (
<a
href={imprintUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
>
<FileText size={11} />
{t('nav.imprint')}
</a>
)}
{imprintUrl && privacyUrl && (
<span className="text-th-border text-xs">·</span>
)}
{privacyUrl && (
<a
href={privacyUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
>
<Lock size={11} />
{t('nav.privacy')}
</a>
)}
</div>
)}
</div>
</div>
</div>

View File

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

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useBranding } from '../contexts/BrandingContext';
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import api from '../services/api';
@@ -16,6 +17,7 @@ export default function Login() {
const [resending, setResending] = useState(false);
const { login } = useAuth();
const { t } = useLanguage();
const { registrationMode } = useBranding();
const navigate = useNavigate();
useEffect(() => {
@@ -152,12 +154,14 @@ export default function Login() {
</div>
)}
<p className="mt-6 text-center text-sm text-th-text-s">
{t('auth.noAccount')}{' '}
<Link to="/register" className="text-th-accent hover:underline font-medium">
{t('auth.signUpNow')}
</Link>
</p>
{registrationMode !== 'invite' && (
<p className="mt-6 text-center text-sm text-th-text-s">
{t('auth.noAccount')}{' '}
<Link to="/register" className="text-th-accent hover:underline font-medium">
{t('auth.signUpNow')}
</Link>
</p>
)}
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
{t('auth.backToHome')}

View File

@@ -1,12 +1,15 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle } from 'lucide-react';
import { useBranding } from '../contexts/BrandingContext';
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle, ShieldAlert } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import toast from 'react-hot-toast';
export default function Register() {
const [searchParams] = useSearchParams();
const inviteToken = searchParams.get('invite') || '';
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
@@ -16,8 +19,12 @@ export default function Register() {
const [needsVerification, setNeedsVerification] = useState(false);
const { register } = useAuth();
const { t } = useLanguage();
const { registrationMode } = useBranding();
const navigate = useNavigate();
// Invite-only mode without a token → show blocked message
const isBlocked = registrationMode === 'invite' && !inviteToken;
const handleSubmit = async (e) => {
e.preventDefault();
@@ -33,7 +40,7 @@ export default function Register() {
setLoading(true);
try {
const result = await register(username, displayName, email, password);
const result = await register(username, displayName, email, password, inviteToken);
if (result?.needsVerification) {
setNeedsVerification(true);
toast.success(t('auth.verificationSent'));
@@ -77,6 +84,15 @@ export default function Register() {
{t('auth.login')}
</Link>
</div>
) : isBlocked ? (
<div className="text-center space-y-4">
<ShieldAlert size={48} className="mx-auto text-amber-400" />
<h2 className="text-2xl font-bold text-th-text">{t('auth.inviteOnly')}</h2>
<p className="text-th-text-s">{t('auth.inviteOnlyDesc')}</p>
<Link to="/login" className="btn-primary inline-flex items-center gap-2 mt-4">
{t('auth.login')}
</Link>
</div>
) : (
<>
<div className="mb-8">

View File

@@ -111,6 +111,13 @@ export const themes = [
group: 'Community',
colors: { bg: '#161924', accent: '#b30051', text: '#dadada' },
},
{
id: 'red-modular-light',
name: 'Red Modular Light',
type: 'light',
group: 'Community',
colors: { bg: '#ffffff', accent: '#e60000', text: '#000000' },
},
];
export function getThemeById(id) {