Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df82316097 | |||
| e4001cb33f | |||
| 4a4ec0a3a3 | |||
| 9be3be7712 | |||
| dc7a78badb | |||
| 272c5dc2cc | |||
| c13090bc80 | |||
| 304349fce8 | |||
| b5218046c9 | |||
| c2c10f9a4b | |||
| d989e1291d | |||
| 62a3812424 | |||
| 2a8ded5211 | |||
| 9275c20d19 | |||
| af7540eb8c | |||
| 13c60ba052 | |||
| fae46c8395 | |||
| bfec8de195 | |||
| 25b13b4078 |
@@ -43,7 +43,8 @@ SMTP_FROM=noreply@example.com
|
||||
# TRUST_PROXY=loopback
|
||||
|
||||
# Federation (inter-instance meeting invitations)
|
||||
# Set both values to enable federation between Redlight instances
|
||||
# Set FEDERATION_DOMAIN to enable federation between Redlight instances
|
||||
# FEDERATION_DOMAIN=redlight.example.com
|
||||
# RSA Private Key for signing outbound invitations (automatically generated if missing on startup)
|
||||
# FEDERATION_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgk...\n-----END PRIVATE KEY-----"
|
||||
# The Ed25519 key pair is auto-generated on first start and stored at ./keys/federation_key.pem
|
||||
# Override the path with FEDERATION_KEY_PATH if needed
|
||||
# FEDERATION_KEY_PATH=/app/keys/federation_key.pem
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- federation-try
|
||||
- develop-calendar
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
|
||||
216
README.md
216
README.md
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "redlight",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "redlight",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "redlight",
|
||||
"private": true,
|
||||
"version": "1.3.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
13
public/sounds/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Notification Sound
|
||||
|
||||
Pop-up Sound by BeezleFM -- https://freesound.org/s/512135/ -- License: Attribution 4.0
|
||||
|
||||
Place your notification sound file here as:
|
||||
|
||||
`notification.mp3`
|
||||
|
||||
The file is served at `/sounds/notification.mp3` and played automatically
|
||||
whenever a new in-app notification arrives.
|
||||
|
||||
Supported formats: MP3, OGG, WAV — MP3 recommended for broadest browser support.
|
||||
Keep the file short (< 2 s) and not too loud.
|
||||
BIN
public/sounds/notification.mp3
Normal file
BIN
public/sounds/notification.mp3
Normal file
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
import crypto from 'crypto';
|
||||
import crypto from 'crypto';
|
||||
import xml2js from 'xml2js';
|
||||
import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParams } from './logger.js';
|
||||
|
||||
@@ -98,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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { log } from './logger.js';
|
||||
@@ -106,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;
|
||||
}
|
||||
@@ -438,19 +438,234 @@ export async function initDatabase() {
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Calendar tables ──────────────────────────────────────────────────────
|
||||
if (isPostgres) {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
uid TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
start_time TIMESTAMP NOT NULL,
|
||||
end_time TIMESTAMP NOT NULL,
|
||||
room_uid TEXT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
color TEXT DEFAULT '#6366f1',
|
||||
federated_from TEXT DEFAULT NULL,
|
||||
federated_join_url TEXT DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_events_user_id ON calendar_events(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_events_uid ON calendar_events(uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_events_start ON calendar_events(start_time);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_event_shares (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(event_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_shares_event ON calendar_event_shares(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_shares_user ON calendar_event_shares(user_id);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uid TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME NOT NULL,
|
||||
room_uid TEXT,
|
||||
user_id INTEGER NOT NULL,
|
||||
color TEXT DEFAULT '#6366f1',
|
||||
federated_from TEXT DEFAULT NULL,
|
||||
federated_join_url TEXT DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_events_user_id ON calendar_events(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_events_uid ON calendar_events(uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_events_start ON calendar_events(start_time);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_event_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(event_id, user_id),
|
||||
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_shares_event ON calendar_event_shares(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_shares_user ON calendar_event_shares(user_id);
|
||||
`);
|
||||
}
|
||||
|
||||
// Calendar migrations: add federated columns if missing
|
||||
if (!(await db.columnExists('calendar_events', 'federated_from'))) {
|
||||
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_from TEXT DEFAULT NULL');
|
||||
}
|
||||
if (!(await db.columnExists('calendar_events', 'federated_join_url'))) {
|
||||
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL');
|
||||
}
|
||||
|
||||
// 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';
|
||||
|
||||
const hash = bcrypt.hashSync(adminPassword, 12);
|
||||
await db.run(
|
||||
'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)',
|
||||
['Administrator', 'Administrator', adminEmail, hash, 'admin']
|
||||
);
|
||||
// Check if admin already exists (upgrade from older version without the flag)
|
||||
const existing = await db.get('SELECT id FROM users WHERE email = ?', [adminEmail]);
|
||||
if (!existing) {
|
||||
const hash = bcrypt.hashSync(adminPassword, 12);
|
||||
await db.run(
|
||||
'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)',
|
||||
['Administrator', 'Administrator', adminEmail, hash, 'admin']
|
||||
);
|
||||
log.db.info(`Default admin created: ${adminEmail}`);
|
||||
}
|
||||
// Mark as seeded so it never runs again, even if the admin email is changed
|
||||
await db.run("INSERT INTO settings (key, value) VALUES ('admin_seeded', '1')");
|
||||
log.db.info(`Default admin created: ${adminEmail}`);
|
||||
await db.run("INSERT INTO settings (key, value) VALUES ('admin_seeded', '1') RETURNING key");
|
||||
}
|
||||
}
|
||||
|
||||
52
server/config/emaili18n.js
Normal file
52
server/config/emaili18n.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { createRequire } from 'module';
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const cache = {};
|
||||
|
||||
function load(lang) {
|
||||
if (cache[lang]) return cache[lang];
|
||||
try {
|
||||
cache[lang] = require(path.resolve(__dirname, '../i18n', `${lang}.json`));
|
||||
return cache[lang];
|
||||
} catch {
|
||||
if (lang !== 'en') return load('en');
|
||||
cache[lang] = {};
|
||||
return cache[lang];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a dot-separated key for the given language.
|
||||
* Interpolates {placeholder} tokens from params.
|
||||
* Unresolved tokens are left as-is so callers can do HTML substitution afterwards.
|
||||
*
|
||||
* @param {string} lang Language code, e.g. 'en', 'de'
|
||||
* @param {string} keyPath Dot-separated key, e.g. 'email.verify.subject'
|
||||
* @param {Record<string,string>} [params] Values to interpolate
|
||||
* @returns {string}
|
||||
*/
|
||||
export function t(lang, keyPath, params = {}) {
|
||||
const keys = keyPath.split('.');
|
||||
|
||||
function resolve(dict) {
|
||||
let val = dict;
|
||||
for (const k of keys) {
|
||||
val = val?.[k];
|
||||
}
|
||||
return typeof val === 'string' ? val : undefined;
|
||||
}
|
||||
|
||||
let value = resolve(load(lang));
|
||||
// Fallback to English
|
||||
if (value === undefined) value = resolve(load('en'));
|
||||
if (value === undefined) return keyPath;
|
||||
|
||||
return value.replace(/\{(\w+)\}/g, (match, k) =>
|
||||
params[k] !== undefined ? String(params[k]) : match
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,11 @@ let publicKeyPem = '';
|
||||
|
||||
// Load or generate Ed25519 keys
|
||||
if (FEDERATION_DOMAIN) {
|
||||
const keyPath = path.join(__dirname, 'federation_key.pem');
|
||||
const keyPath = process.env.FEDERATION_KEY_PATH || '/app/keys/federation_key.pem';
|
||||
const keyDir = path.dirname(keyPath);
|
||||
if (!fs.existsSync(keyDir)) {
|
||||
fs.mkdirSync(keyDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (!privateKeyPem && fs.existsSync(keyPath)) {
|
||||
privateKeyPem = fs.readFileSync(keyPath, 'utf8');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { log } from './logger.js';
|
||||
import { t } from './emaili18n.js';
|
||||
|
||||
let transporter;
|
||||
|
||||
@@ -21,7 +22,7 @@ export function initMailer() {
|
||||
const pass = process.env.SMTP_PASS;
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
log.mailer.warn('SMTP not configured – email verification disabled');
|
||||
log.mailer.warn('SMTP not configured - email verification disabled');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -45,17 +46,17 @@ export function isMailerConfigured() {
|
||||
|
||||
/**
|
||||
* Send the verification email with a clickable link.
|
||||
* @param {string} to – recipient email
|
||||
* @param {string} name – user's display name
|
||||
* @param {string} verifyUrl – full verification URL
|
||||
* @param {string} appName – branding app name (default "Redlight")
|
||||
* @param {string} to - recipient email
|
||||
* @param {string} name - user's display name
|
||||
* @param {string} verifyUrl - full verification URL
|
||||
* @param {string} appName - branding app name (default "Redlight")
|
||||
*/
|
||||
// S3: sanitize name for use in email From header (strip quotes, newlines, control chars)
|
||||
function sanitizeHeaderValue(str) {
|
||||
return String(str).replace(/["\\\r\n\x00-\x1f]/g, '').trim().slice(0, 100);
|
||||
}
|
||||
|
||||
export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight') {
|
||||
export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight', lang = 'en') {
|
||||
if (!transporter) {
|
||||
throw new Error('SMTP not configured');
|
||||
}
|
||||
@@ -68,41 +69,41 @@ export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redl
|
||||
await transporter.sendMail({
|
||||
from: `"${headerAppName}" <${from}>`,
|
||||
to,
|
||||
subject: `${headerAppName} – Verify your email`,
|
||||
subject: t(lang, 'email.verify.subject', { appName: headerAppName }),
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2>
|
||||
<p>Please verify your email address by clicking the button below:</p>
|
||||
<h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
|
||||
<p>${t(lang, 'email.verify.intro')}</p>
|
||||
<p style="text-align:center;margin:28px 0;">
|
||||
<a href="${verifyUrl}"
|
||||
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
|
||||
Verify Email
|
||||
${t(lang, 'email.verify.button')}
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">
|
||||
Or copy this link in your browser:<br/>
|
||||
${t(lang, 'email.linkHint')}<br/>
|
||||
<a href="${verifyUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(verifyUrl)}</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">This link is valid for 24 hours.</p>
|
||||
<p style="font-size:13px;color:#7f849c;">${t(lang, 'email.verify.validity')}</p>
|
||||
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||
<p style="font-size:12px;color:#585b70;">If you didn't register, please ignore this email.</p>
|
||||
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.verify.footer')}</p>
|
||||
</div>
|
||||
`,
|
||||
text: `Hey ${name},\n\nPlease verify your email: ${verifyUrl}\n\nThis link is valid for 24 hours.\n\n– ${appName}`,
|
||||
text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.verify.intro')}\n${verifyUrl}\n\n${t(lang, 'email.verify.validity')}\n\n- ${appName}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a federation meeting invitation email.
|
||||
* @param {string} to – recipient email
|
||||
* @param {string} name – recipient display name
|
||||
* @param {string} fromUser – sender federated address (user@domain)
|
||||
* @param {string} roomName – name of the invited room
|
||||
* @param {string} message – optional personal message
|
||||
* @param {string} inboxUrl – URL to the federation inbox
|
||||
* @param {string} appName – branding app name (default "Redlight")
|
||||
* @param {string} to - recipient email
|
||||
* @param {string} name - recipient display name
|
||||
* @param {string} fromUser - sender federated address (user@domain)
|
||||
* @param {string} roomName - name of the invited room
|
||||
* @param {string} message - optional personal message
|
||||
* @param {string} inboxUrl - URL to the federation inbox
|
||||
* @param {string} appName - branding app name (default "Redlight")
|
||||
*/
|
||||
export async function sendFederationInviteEmail(to, name, fromUser, roomName, message, inboxUrl, appName = 'Redlight') {
|
||||
export async function sendFederationInviteEmail(to, name, fromUser, roomName, message, inboxUrl, appName = 'Redlight', lang = 'en') {
|
||||
if (!transporter) return; // silently skip if SMTP not configured
|
||||
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
@@ -113,40 +114,126 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
|
||||
const safeMessage = message ? escapeHtml(message) : null;
|
||||
const safeAppName = escapeHtml(appName);
|
||||
|
||||
const introHtml = t(lang, 'email.federationInvite.intro')
|
||||
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${headerAppName}" <${from}>`,
|
||||
to,
|
||||
subject: `${headerAppName} – Meeting invitation from ${sanitizeHeaderValue(fromUser)}`,
|
||||
subject: t(lang, 'email.federationInvite.subject', { appName: headerAppName, fromUser: sanitizeHeaderValue(fromUser) }),
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2>
|
||||
<p>You have received a meeting invitation from <strong style="color:#cdd6f4;">${safeFromUser}</strong>.</p>
|
||||
<h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
|
||||
<p>${introHtml}</p>
|
||||
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
|
||||
<p style="margin:0 0 8px 0;font-size:13px;color:#7f849c;">Room:</p>
|
||||
<p style="margin:0 0 8px 0;font-size:13px;color:#7f849c;">${t(lang, 'email.federationInvite.roomLabel')}</p>
|
||||
<p style="margin:0;font-size:16px;font-weight:bold;color:#cdd6f4;">${safeRoomName}</p>
|
||||
${safeMessage ? `<p style="margin:12px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">"${safeMessage}"</p>` : ''}
|
||||
</div>
|
||||
<p style="text-align:center;margin:28px 0;">
|
||||
<a href="${inboxUrl}"
|
||||
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
|
||||
View Invitation
|
||||
${t(lang, 'email.viewInvitation')}
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||
<p style="font-size:12px;color:#585b70;">Open the link above to accept or decline the invitation.</p>
|
||||
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.invitationFooter')}</p>
|
||||
</div>
|
||||
`,
|
||||
text: `Hey ${name},\n\nYou have received a meeting invitation from ${fromUser}.\nRoom: ${roomName}${message ? `\nMessage: "${message}"` : ''}\n\nView invitation: ${inboxUrl}\n\n– ${appName}`,
|
||||
text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.federationInvite.intro', { fromUser })}\n${t(lang, 'email.federationInvite.roomLabel')} ${roomName}${message ? `\n"${message}"` : ''}\n\n${t(lang, 'email.viewInvitation')}: ${inboxUrl}\n\n- ${appName}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a calendar event invitation email (federated).
|
||||
*/
|
||||
export async function sendCalendarInviteEmail(to, name, fromUser, title, startTime, endTime, description, inboxUrl, appName = 'Redlight', lang = 'en') {
|
||||
if (!transporter) return;
|
||||
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
const headerAppName = sanitizeHeaderValue(appName);
|
||||
const safeName = escapeHtml(name);
|
||||
const safeFromUser = escapeHtml(fromUser);
|
||||
const safeTitle = escapeHtml(title);
|
||||
const safeDesc = description ? escapeHtml(description) : null;
|
||||
|
||||
const formatDate = (iso) => {
|
||||
try { return new Date(iso).toLocaleString(lang === 'de' ? 'de-DE' : 'en-GB', { dateStyle: 'full', timeStyle: 'short' }); }
|
||||
catch { return iso; }
|
||||
};
|
||||
|
||||
const introHtml = t(lang, 'email.calendarInvite.intro')
|
||||
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${headerAppName}" <${from}>`,
|
||||
to,
|
||||
subject: t(lang, 'email.calendarInvite.subject', { appName: headerAppName, fromUser: sanitizeHeaderValue(fromUser) }),
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||
<h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
|
||||
<p>${introHtml}</p>
|
||||
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
|
||||
<p style="margin:0 0 4px 0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
|
||||
<p style="margin:6px 0 0 0;font-size:13px;color:#a6adc8;">🕐 ${escapeHtml(formatDate(startTime))} - ${escapeHtml(formatDate(endTime))}</p>
|
||||
${safeDesc ? `<p style="margin:10px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">"${safeDesc}"</p>` : ''}
|
||||
</div>
|
||||
<p style="text-align:center;margin:28px 0;">
|
||||
<a href="${inboxUrl}"
|
||||
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
|
||||
${t(lang, 'email.viewInvitation')}
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.invitationFooter')}</p>
|
||||
</div>
|
||||
`,
|
||||
text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.calendarInvite.intro', { fromUser })}\n${safeTitle}\n${formatDate(startTime)} \u2013 ${formatDate(endTime)}${description ? `\n\n"${description}"` : ''}\n\n${t(lang, 'email.viewInvitation')}: ${inboxUrl}\n\n\u2013 ${appName}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify a user that a federated calendar event they received was deleted by the organiser.
|
||||
*/
|
||||
export async function sendCalendarEventDeletedEmail(to, name, fromUser, title, appName = 'Redlight', lang = 'en') {
|
||||
if (!transporter) return;
|
||||
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
const headerAppName = sanitizeHeaderValue(appName);
|
||||
const safeName = escapeHtml(name);
|
||||
const safeFromUser = escapeHtml(fromUser);
|
||||
const safeTitle = escapeHtml(title);
|
||||
|
||||
const introHtml = t(lang, 'email.calendarDeleted.intro')
|
||||
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${headerAppName}" <${from}>`,
|
||||
to,
|
||||
subject: t(lang, 'email.calendarDeleted.subject', { appName: headerAppName, title: sanitizeHeaderValue(title) }),
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||
<h2 style="color:#f38ba8;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
|
||||
<p>${introHtml}</p>
|
||||
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;border-left:4px solid #f38ba8;">
|
||||
<p style="margin:0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
|
||||
</div>
|
||||
<p style="font-size:13px;color:#7f849c;">${t(lang, 'email.calendarDeleted.note')}</p>
|
||||
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.calendarDeleted.footer', { appName: escapeHtml(appName) })}</p>
|
||||
</div>
|
||||
`,
|
||||
text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.calendarDeleted.intro', { fromUser })}\n"${title}"\n\n${t(lang, 'email.calendarDeleted.note')}\n\n\u2013 ${appName}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a user registration invite email.
|
||||
* @param {string} to – recipient email
|
||||
* @param {string} inviteUrl – full invite registration URL
|
||||
* @param {string} appName – branding app name (default "Redlight")
|
||||
* @param {string} to - recipient email
|
||||
* @param {string} inviteUrl - full invite registration URL
|
||||
* @param {string} appName - branding app name (default "Redlight")
|
||||
*/
|
||||
export async function sendInviteEmail(to, inviteUrl, appName = 'Redlight') {
|
||||
export async function sendInviteEmail(to, inviteUrl, appName = 'Redlight', lang = 'en') {
|
||||
if (!transporter) {
|
||||
throw new Error('SMTP not configured');
|
||||
}
|
||||
@@ -155,30 +242,33 @@ export async function sendInviteEmail(to, inviteUrl, appName = 'Redlight') {
|
||||
const headerAppName = sanitizeHeaderValue(appName);
|
||||
const safeAppName = escapeHtml(appName);
|
||||
|
||||
const introHtml = t(lang, 'email.invite.intro')
|
||||
.replace('{appName}', `<strong style="color:#cdd6f4;">${safeAppName}</strong>`);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${headerAppName}" <${from}>`,
|
||||
to,
|
||||
subject: `${headerAppName} – You've been invited`,
|
||||
subject: t(lang, 'email.invite.subject', { appName: headerAppName }),
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||
<h2 style="color:#cba6f7;margin-top:0;">You've been invited! 🎉</h2>
|
||||
<p>You have been invited to create an account on <strong style="color:#cdd6f4;">${safeAppName}</strong>.</p>
|
||||
<p>Click the button below to register:</p>
|
||||
<h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.invite.title')}</h2>
|
||||
<p>${introHtml}</p>
|
||||
<p>${t(lang, 'email.invite.prompt')}</p>
|
||||
<p style="text-align:center;margin:28px 0;">
|
||||
<a href="${inviteUrl}"
|
||||
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
|
||||
Create Account
|
||||
${t(lang, 'email.invite.button')}
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">
|
||||
Or copy this link in your browser:<br/>
|
||||
${t(lang, 'email.linkHint')}<br/>
|
||||
<a href="${inviteUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(inviteUrl)}</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">This link is valid for 7 days.</p>
|
||||
<p style="font-size:13px;color:#7f849c;">${t(lang, 'email.invite.validity')}</p>
|
||||
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||
<p style="font-size:12px;color:#585b70;">If you didn't expect this invitation, you can safely ignore this email.</p>
|
||||
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.invite.footer')}</p>
|
||||
</div>
|
||||
`,
|
||||
text: `You've been invited to create an account on ${appName}.\n\nRegister here: ${inviteUrl}\n\nThis link is valid for 7 days.\n\n– ${appName}`,
|
||||
text: `${t(lang, 'email.invite.title')}\n\n${t(lang, 'email.invite.intro', { appName })}\n\n${t(lang, 'email.invite.prompt')}\n${inviteUrl}\n\n${t(lang, 'email.invite.validity')}\n\n- ${appName}`,
|
||||
});
|
||||
}
|
||||
|
||||
23
server/config/notifications.js
Normal file
23
server/config/notifications.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getDb } from './database.js';
|
||||
|
||||
/**
|
||||
* Create an in-app notification for a user.
|
||||
* Non-fatal — exceptions are swallowed so that the main operation is never blocked.
|
||||
*
|
||||
* @param {number} userId - Recipient user ID
|
||||
* @param {string} type - Notification type (room_share_added | room_share_removed | federation_invite_received)
|
||||
* @param {string} title - Short title (e.g. room name or "from" address)
|
||||
* @param {string|null} body - Optional longer message
|
||||
* @param {string|null} link - Optional frontend path to navigate to when clicked
|
||||
*/
|
||||
export async function createNotification(userId, type, title, body = null, link = null) {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.run(
|
||||
'INSERT INTO notifications (user_id, type, title, body, link) VALUES (?, ?, ?, ?, ?)',
|
||||
[userId, type, title, body, link],
|
||||
);
|
||||
} catch {
|
||||
// Notifications are non-critical — never break main functionality
|
||||
}
|
||||
}
|
||||
39
server/i18n/de.json
Normal file
39
server/i18n/de.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"email": {
|
||||
"greeting": "Hey {name} 👋",
|
||||
"viewInvitation": "Einladung anzeigen",
|
||||
"invitationFooter": "Öffne den Link oben, um die Einladung anzunehmen oder abzulehnen.",
|
||||
"linkHint": "Oder kopiere diesen Link in deinen Browser:",
|
||||
"verify": {
|
||||
"subject": "{appName} - E-Mail-Adresse bestätigen",
|
||||
"intro": "Bitte bestätige deine E-Mail-Adresse, indem du auf den Button klickst:",
|
||||
"button": "E-Mail bestätigen",
|
||||
"validity": "Dieser Link ist 24 Stunden gültig.",
|
||||
"footer": "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren."
|
||||
},
|
||||
"invite": {
|
||||
"subject": "{appName} - Du wurdest eingeladen",
|
||||
"title": "Du wurdest eingeladen! 🎉",
|
||||
"intro": "Du wurdest eingeladen, ein Konto auf {appName} zu erstellen.",
|
||||
"prompt": "Klicke auf den Button, um dich zu registrieren:",
|
||||
"button": "Konto erstellen",
|
||||
"validity": "Dieser Link ist 7 Tage gültig.",
|
||||
"footer": "Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren."
|
||||
},
|
||||
"federationInvite": {
|
||||
"subject": "{appName} - Meeting-Einladung von {fromUser}",
|
||||
"intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.",
|
||||
"roomLabel": "Raum:"
|
||||
},
|
||||
"calendarInvite": {
|
||||
"subject": "{appName} - Kalendereinladung von {fromUser}",
|
||||
"intro": "Du hast eine Kalendereinladung von {fromUser} erhalten."
|
||||
},
|
||||
"calendarDeleted": {
|
||||
"subject": "{appName} - Kalendereintrag abgesagt: {title}",
|
||||
"intro": "Der folgende Kalendereintrag wurde vom Organisator ({fromUser}) gelöscht und ist nicht mehr verfügbar:",
|
||||
"note": "Der Termin wurde automatisch aus deinem Kalender entfernt.",
|
||||
"footer": "Diese Nachricht wurde automatisch von {appName} versendet."
|
||||
}
|
||||
}
|
||||
}
|
||||
39
server/i18n/en.json
Normal file
39
server/i18n/en.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"email": {
|
||||
"greeting": "Hey {name} 👋",
|
||||
"viewInvitation": "View Invitation",
|
||||
"invitationFooter": "Open the link above to accept or decline the invitation.",
|
||||
"linkHint": "Or copy this link in your browser:",
|
||||
"verify": {
|
||||
"subject": "{appName} - Verify your email",
|
||||
"intro": "Please verify your email address by clicking the button below:",
|
||||
"button": "Verify Email",
|
||||
"validity": "This link is valid for 24 hours.",
|
||||
"footer": "If you didn't register, please ignore this email."
|
||||
},
|
||||
"invite": {
|
||||
"subject": "{appName} - You've been invited",
|
||||
"title": "You've been invited! 🎉",
|
||||
"intro": "You have been invited to create an account on {appName}.",
|
||||
"prompt": "Click the button below to register:",
|
||||
"button": "Create Account",
|
||||
"validity": "This link is valid for 7 days.",
|
||||
"footer": "If you didn't expect this invitation, you can safely ignore this email."
|
||||
},
|
||||
"federationInvite": {
|
||||
"subject": "{appName} - Meeting invitation from {fromUser}",
|
||||
"intro": "You have received a meeting invitation from {fromUser}.",
|
||||
"roomLabel": "Room:"
|
||||
},
|
||||
"calendarInvite": {
|
||||
"subject": "{appName} - Calendar invitation from {fromUser}",
|
||||
"intro": "You have received a calendar invitation from {fromUser}."
|
||||
},
|
||||
"calendarDeleted": {
|
||||
"subject": "{appName} - Calendar event cancelled: {title}",
|
||||
"intro": "The following calendar event was deleted by the organiser ({fromUser}) and is no longer available:",
|
||||
"note": "The event has been automatically removed from your calendar.",
|
||||
"footer": "This message was sent automatically by {appName}."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'dotenv/config';
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
@@ -13,6 +13,8 @@ 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);
|
||||
@@ -21,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;
|
||||
@@ -53,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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Router } from 'express';
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getDb } from '../config/database.js';
|
||||
@@ -26,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 - (3–30 chars)' });
|
||||
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
@@ -211,7 +211,7 @@ router.post('/invites', authenticateToken, requireAdmin, async (req, res) => {
|
||||
|
||||
if (isMailerConfigured()) {
|
||||
try {
|
||||
await sendInviteEmail(email.toLowerCase(), inviteUrl, appName);
|
||||
await sendInviteEmail(email.toLowerCase(), inviteUrl, appName, 'en');
|
||||
} catch (mailErr) {
|
||||
log.admin.warn(`Invite email failed (non-fatal): ${mailErr.message}`);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -37,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;
|
||||
@@ -145,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 - (3–30 chars)' });
|
||||
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
|
||||
}
|
||||
|
||||
// M1: email format
|
||||
@@ -200,7 +200,7 @@ router.post('/register', registerLimiter, async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName);
|
||||
await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName, 'en');
|
||||
} catch (mailErr) {
|
||||
log.auth.error(`Verification mail failed: ${mailErr.message}`);
|
||||
// Account is created but email failed — user can resend from login page
|
||||
@@ -210,7 +210,7 @@ router.post('/register', registerLimiter, async (req, res) => {
|
||||
return res.status(201).json({ needsVerification: true, message: 'Verification email has been sent' });
|
||||
}
|
||||
|
||||
// No SMTP configured – register and login immediately (legacy behaviour)
|
||||
// No SMTP configured - register and login immediately (legacy behaviour)
|
||||
const result = await db.run(
|
||||
'INSERT INTO users (name, display_name, email, password_hash, email_verified) VALUES (?, ?, ?, ?, 1)',
|
||||
[username, display_name, email.toLowerCase(), hash]
|
||||
@@ -278,7 +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
|
||||
@@ -313,7 +313,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
|
||||
}
|
||||
|
||||
try {
|
||||
await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName);
|
||||
await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName, user.language || 'en');
|
||||
} catch (mailErr) {
|
||||
log.auth.error(`Resend verification mail failed: ${mailErr.message}`);
|
||||
return res.status(502).json({ error: 'Email could not be sent. Please check your SMTP configuration.' });
|
||||
@@ -335,7 +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' });
|
||||
}
|
||||
@@ -361,7 +361,7 @@ router.post('/login', loginLimiter, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
@@ -432,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 - (3–30 chars)' });
|
||||
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
|
||||
}
|
||||
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?) AND id != ?', [name, req.user.id]);
|
||||
if (existingUsername) {
|
||||
@@ -504,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 = [];
|
||||
|
||||
@@ -83,6 +83,8 @@ router.get('/', async (req, res) => {
|
||||
const logoFile = findLogoFile();
|
||||
|
||||
const registrationMode = await getSetting('registration_mode');
|
||||
const imprintUrl = await getSetting('imprint_url');
|
||||
const privacyUrl = await getSetting('privacy_url');
|
||||
|
||||
res.json({
|
||||
appName: appName || 'Redlight',
|
||||
@@ -90,6 +92,8 @@ router.get('/', async (req, res) => {
|
||||
logoUrl: logoFile ? '/api/branding/logo' : null,
|
||||
defaultTheme: defaultTheme || null,
|
||||
registrationMode: registrationMode || 'open',
|
||||
imprintUrl: imprintUrl || null,
|
||||
privacyUrl: privacyUrl || null,
|
||||
});
|
||||
} catch (err) {
|
||||
log.branding.error('Get branding error:', err);
|
||||
@@ -210,4 +214,42 @@ router.put('/registration-mode', authenticateToken, requireAdmin, async (req, re
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/branding/imprint-url - Set imprint URL (admin only)
|
||||
router.put('/imprint-url', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { imprintUrl } = req.body;
|
||||
if (imprintUrl && imprintUrl.length > 500) {
|
||||
return res.status(400).json({ error: 'URL must not exceed 500 characters' });
|
||||
}
|
||||
if (imprintUrl && imprintUrl.trim()) {
|
||||
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
680
server/routes/calendar.js
Normal 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;
|
||||
@@ -1,10 +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({
|
||||
@@ -39,7 +40,7 @@ export function wellKnownHandler(req, res) {
|
||||
federation_api: '/api/federation',
|
||||
public_key: getPublicKey(),
|
||||
software: 'Redlight',
|
||||
version: '1.3.0',
|
||||
version: '1.4.0',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,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();
|
||||
@@ -220,19 +224,28 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
|
||||
} catch { /* column may not exist on very old installs */ }
|
||||
}
|
||||
|
||||
// Send notification email (truly fire-and-forget – never blocks the response)
|
||||
// Send notification email (truly fire-and-forget - never blocks the response)
|
||||
if (targetUser.email) {
|
||||
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const inboxUrl = `${appUrl}/federation/inbox`;
|
||||
const appName = process.env.APP_NAME || 'Redlight';
|
||||
sendFederationInviteEmail(
|
||||
targetUser.email, targetUser.name, from_user,
|
||||
room_name, message || null, inboxUrl, appName
|
||||
room_name, message || null, inboxUrl, appName, targetUser.language || 'en'
|
||||
).catch(mailErr => {
|
||||
log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message);
|
||||
});
|
||||
}
|
||||
|
||||
// In-app notification
|
||||
await createNotification(
|
||||
targetUser.id,
|
||||
'federation_invite_received',
|
||||
from_user,
|
||||
room_name,
|
||||
'/federation/inbox',
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.federation.error('Federation receive error:', err);
|
||||
@@ -261,12 +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 });
|
||||
}
|
||||
@@ -338,6 +367,94 @@ router.delete('/invitations/:id', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/federation/calendar-invitations — List calendar invitations ─────
|
||||
router.get('/calendar-invitations', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const invitations = await db.all(
|
||||
`SELECT * FROM calendar_invitations
|
||||
WHERE to_user_id = ?
|
||||
ORDER BY created_at DESC`,
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ invitations });
|
||||
} catch (err) {
|
||||
log.federation.error('List calendar invitations error:', err);
|
||||
res.status(500).json({ error: 'Failed to load calendar invitations' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/federation/calendar-invitations/:id/accept ─────────────────────
|
||||
router.post('/calendar-invitations/:id/accept', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const inv = await db.get(
|
||||
`SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`,
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' });
|
||||
if (inv.status === 'accepted') return res.status(400).json({ error: 'Already accepted' });
|
||||
|
||||
await db.run(
|
||||
`UPDATE calendar_invitations SET status = 'accepted' WHERE id = ?`,
|
||||
[inv.id]
|
||||
);
|
||||
|
||||
// Check if event was already previously accepted (duplicate guard)
|
||||
const existing = await db.get(
|
||||
'SELECT id FROM calendar_events WHERE uid = ? AND user_id = ?',
|
||||
[inv.event_uid, req.user.id]
|
||||
);
|
||||
if (!existing) {
|
||||
await db.run(`
|
||||
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, federated_from, federated_join_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
inv.event_uid,
|
||||
inv.title,
|
||||
inv.description || null,
|
||||
inv.start_time,
|
||||
inv.end_time,
|
||||
inv.room_uid || null,
|
||||
req.user.id,
|
||||
inv.color || '#6366f1',
|
||||
inv.from_user,
|
||||
inv.join_url || null,
|
||||
]);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.federation.error('Accept calendar invitation error:', err);
|
||||
res.status(500).json({ error: 'Failed to accept calendar invitation' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── DELETE /api/federation/calendar-invitations/:id — Decline/dismiss ────────
|
||||
router.delete('/calendar-invitations/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const inv = await db.get(
|
||||
`SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`,
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' });
|
||||
|
||||
if (inv.status === 'pending') {
|
||||
// mark as declined
|
||||
await db.run(`UPDATE calendar_invitations SET status = 'declined' WHERE id = ?`, [inv.id]);
|
||||
} else {
|
||||
// accepted or declined — permanently remove from inbox
|
||||
await db.run('DELETE FROM calendar_invitations WHERE id = ?', [inv.id]);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.federation.error('Delete calendar invitation error:', err);
|
||||
res.status(500).json({ error: 'Failed to remove calendar invitation' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/federation/federated-rooms — List saved federated rooms ────────
|
||||
router.get('/federated-rooms', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
@@ -423,6 +540,96 @@ router.post('/room-sync', federationReceiveLimiter, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/federation/calendar-event-deleted — Receive calendar deletion ─
|
||||
router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res) => {
|
||||
try {
|
||||
if (!isFederationEnabled()) {
|
||||
return res.status(400).json({ error: 'Federation is not configured on this instance' });
|
||||
}
|
||||
|
||||
const signature = req.headers['x-federation-signature'];
|
||||
const originDomain = req.headers['x-federation-origin'];
|
||||
const payload = req.body || {};
|
||||
|
||||
if (!signature || !originDomain) {
|
||||
return res.status(401).json({ error: 'Missing federation signature or origin' });
|
||||
}
|
||||
|
||||
const { publicKey } = await discoverInstance(originDomain);
|
||||
if (!publicKey || !verifyPayload(payload, signature, publicKey)) {
|
||||
return res.status(403).json({ error: 'Invalid federation signature' });
|
||||
}
|
||||
|
||||
const { event_uid } = payload;
|
||||
if (!event_uid || typeof event_uid !== 'string') {
|
||||
return res.status(400).json({ error: 'event_uid is required' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// 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) => {
|
||||
|
||||
74
server/routes/notifications.js
Normal file
74
server/routes/notifications.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Router } from 'express';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/notifications — List recent notifications for the current user
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const notifications = await db.all(
|
||||
`SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50`,
|
||||
[req.user.id],
|
||||
);
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
res.json({ notifications, unreadCount });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Failed to load notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/notifications/read-all — Mark all notifications as read
|
||||
// NOTE: Must be declared before /:id/read to avoid routing collision
|
||||
router.post('/read-all', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.run('UPDATE notifications SET read = 1 WHERE user_id = ?', [req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Failed to update notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/notifications/:id/read — Mark a single notification as read
|
||||
router.post('/:id/read', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.run(
|
||||
'UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?',
|
||||
[req.params.id, req.user.id],
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Failed to update notification' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/notifications/all — Delete all notifications for current user
|
||||
// NOTE: Declared before /:id to avoid routing collision
|
||||
router.delete('/all', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.run('DELETE FROM notifications WHERE user_id = ?', [req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Failed to delete notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/notifications/:id — Delete a single notification
|
||||
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.run(
|
||||
'DELETE FROM notifications WHERE id = ? AND user_id = ?',
|
||||
[req.params.id, req.user.id],
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Failed to delete notification' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Router } from 'express';
|
||||
import { Router } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
@@ -402,6 +403,15 @@ router.post('/:uid/shares', authenticateToken, async (req, res) => {
|
||||
JOIN users u ON rs.user_id = u.id
|
||||
WHERE rs.room_id = ?
|
||||
`, [room.id]);
|
||||
// Notify the user who was given access
|
||||
const sharerName = req.user.display_name || req.user.name;
|
||||
await createNotification(
|
||||
user_id,
|
||||
'room_share_added',
|
||||
room.name,
|
||||
sharerName,
|
||||
`/rooms/${room.uid}`,
|
||||
);
|
||||
res.json({ shares });
|
||||
} catch (err) {
|
||||
log.rooms.error(`Share room error: ${err.message}`);
|
||||
@@ -417,13 +427,22 @@ router.delete('/:uid/shares/:userId', authenticateToken, async (req, res) => {
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Room not found or no permission' });
|
||||
}
|
||||
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, parseInt(req.params.userId)]);
|
||||
const removedUserId = parseInt(req.params.userId);
|
||||
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, removedUserId]);
|
||||
const shares = await db.all(`
|
||||
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
|
||||
FROM room_shares rs
|
||||
JOIN users u ON rs.user_id = u.id
|
||||
WHERE rs.room_id = ?
|
||||
`, [room.id]);
|
||||
// Notify the user whose access was removed
|
||||
await createNotification(
|
||||
removedUserId,
|
||||
'room_share_removed',
|
||||
room.name,
|
||||
null,
|
||||
'/dashboard',
|
||||
);
|
||||
res.json({ shares });
|
||||
} catch (err) {
|
||||
log.rooms.error(`Remove share error: ${err.message}`);
|
||||
@@ -648,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 = [];
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
import NotificationBell from './NotificationBell';
|
||||
|
||||
export default function Navbar({ onMenuClick }) {
|
||||
const { user, logout } = useAuth();
|
||||
@@ -51,6 +52,9 @@ export default function Navbar({ onMenuClick }) {
|
||||
|
||||
{/* Right section */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Notification bell */}
|
||||
<NotificationBell />
|
||||
|
||||
{/* User dropdown */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
|
||||
191
src/components/NotificationBell.jsx
Normal file
191
src/components/NotificationBell.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { Bell, BellOff, CheckCheck, ExternalLink, Trash2, X } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNotifications } from '../contexts/NotificationContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
function timeAgo(dateStr, lang) {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const m = Math.floor(diff / 60_000);
|
||||
const h = Math.floor(diff / 3_600_000);
|
||||
const d = Math.floor(diff / 86_400_000);
|
||||
if (lang === 'de') {
|
||||
if (m < 1) return 'gerade eben';
|
||||
if (m < 60) return `vor ${m} Min.`;
|
||||
if (h < 24) return `vor ${h} Std.`;
|
||||
return `vor ${d} Tag${d !== 1 ? 'en' : ''}`;
|
||||
}
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m}m ago`;
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${d}d ago`;
|
||||
}
|
||||
|
||||
function notificationIcon(type) {
|
||||
switch (type) {
|
||||
case 'room_share_added': return '🔗';
|
||||
case 'room_share_removed': return '🚫';
|
||||
case 'federation_invite_received': return '📩';
|
||||
default: return '🔔';
|
||||
}
|
||||
}
|
||||
|
||||
function notificationSubtitle(n, t, lang) {
|
||||
switch (n.type) {
|
||||
case 'room_share_added':
|
||||
return n.body
|
||||
? (lang === 'de' ? `Geteilt von ${n.body}` : `Shared by ${n.body}`)
|
||||
: t('notifications.roomShareAdded');
|
||||
case 'room_share_removed':
|
||||
return t('notifications.roomShareRemoved');
|
||||
case 'federation_invite_received':
|
||||
return n.body
|
||||
? (lang === 'de' ? `Raum: ${n.body}` : `Room: ${n.body}`)
|
||||
: t('notifications.federationInviteReceived');
|
||||
default:
|
||||
return n.body || '';
|
||||
}
|
||||
}
|
||||
|
||||
export default function NotificationBell() {
|
||||
const { notifications, unreadCount, markRead, markAllRead, deleteNotification, clearAll } = useNotifications();
|
||||
const { t, language } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutsideClick(e) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
return () => document.removeEventListener('mousedown', handleOutsideClick);
|
||||
}, []);
|
||||
|
||||
const handleNotificationClick = async (n) => {
|
||||
if (!n.read) await markRead(n.id);
|
||||
if (n.link) navigate(n.link);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (e, id) => {
|
||||
e.stopPropagation();
|
||||
await deleteNotification(id);
|
||||
};
|
||||
|
||||
const recent = notifications.slice(0, 20);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={containerRef}>
|
||||
{/* Bell button */}
|
||||
<button
|
||||
onClick={() => setOpen(prev => !prev)}
|
||||
className="relative p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||
title={t('notifications.bell')}
|
||||
>
|
||||
<Bell size={20} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 min-w-[16px] h-4 px-0.5 flex items-center justify-center rounded-full bg-th-accent text-th-accent-t text-[10px] font-bold leading-none">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-2 w-80 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden z-50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-th-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell size={16} className="text-th-accent" />
|
||||
<span className="text-sm font-semibold text-th-text">{t('notifications.bell')}</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-th-accent text-th-accent-t text-xs font-bold">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||
title={t('notifications.markAllRead')}
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
{t('notifications.markAllRead')}
|
||||
</button>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-error transition-colors"
|
||||
title={t('notifications.clearAll')}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{t('notifications.clearAll')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{recent.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-th-text-s gap-2">
|
||||
<BellOff size={24} />
|
||||
<span className="text-sm">{t('notifications.noNotifications')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{recent.map(n => (
|
||||
<li
|
||||
key={n.id}
|
||||
onClick={() => handleNotificationClick(n)}
|
||||
className={`group flex items-start gap-3 px-4 py-3 cursor-pointer transition-colors border-b border-th-border/50 last:border-0
|
||||
${n.read ? 'hover:bg-th-hover' : 'bg-th-accent/5 hover:bg-th-accent/10'}`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<span className="text-lg flex-shrink-0 mt-0.5">{notificationIcon(n.type)}</span>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm truncate ${n.read ? 'text-th-text-s' : 'text-th-text font-medium'}`}>
|
||||
{n.title}
|
||||
</p>
|
||||
<p className="text-xs text-th-text-s truncate">
|
||||
{notificationSubtitle(n, t, language)}
|
||||
</p>
|
||||
<p className="text-xs text-th-text-s/70 mt-0.5">
|
||||
{timeAgo(n.created_at, language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right side: unread dot, link icon, delete button */}
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
{!n.read && (
|
||||
<span className="w-2 h-2 rounded-full bg-th-accent mt-1" />
|
||||
)}
|
||||
{n.link && (
|
||||
<ExternalLink size={12} className="text-th-text-s/50" />
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => handleDelete(e, n.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:text-th-error transition-all text-th-text-s/50"
|
||||
title={t('notifications.delete')}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -11,6 +11,8 @@ export function BrandingProvider({ children }) {
|
||||
hasLogo: false,
|
||||
logoUrl: null,
|
||||
defaultTheme: null,
|
||||
imprintUrl: null,
|
||||
privacyUrl: null,
|
||||
});
|
||||
|
||||
const fetchBranding = useCallback(async () => {
|
||||
|
||||
171
src/contexts/NotificationContext.jsx
Normal file
171
src/contexts/NotificationContext.jsx
Normal 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 '🔔';
|
||||
}
|
||||
}
|
||||
139
src/i18n/de.json
139
src/i18n/de.json
@@ -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,11 +78,11 @@
|
||||
"emailNotVerified": "E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach.",
|
||||
"username": "Benutzername",
|
||||
"usernamePlaceholder": "z.B. maxmuster",
|
||||
"usernameHint": "Nur Buchstaben, Zahlen, _ und - erlaubt (3–30 Zeichen)",
|
||||
"usernameHint": "Nur Buchstaben, Zahlen, _ und - erlaubt (3-30 Zeichen)",
|
||||
"displayName": "Anzeigename",
|
||||
"displayNamePlaceholder": "Max Mustermann",
|
||||
"usernameTaken": "Benutzername ist bereits vergeben",
|
||||
"usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, _ und - enthalten (3–30 Zeichen)",
|
||||
"usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, _ und - enthalten (3-30 Zeichen)",
|
||||
"usernameRequired": "Benutzername ist erforderlich",
|
||||
"displayNameRequired": "Anzeigename ist erforderlich",
|
||||
"emailVerificationBanner": "Deine E-Mail-Adresse wurde noch nicht verifiziert.",
|
||||
@@ -230,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",
|
||||
@@ -354,7 +358,25 @@
|
||||
"inviteExpired": "Abgelaufen",
|
||||
"inviteUsedBy": "Verwendet von",
|
||||
"inviteExpiresAt": "Läuft ab am",
|
||||
"noInvites": "Noch keine Einladungen"
|
||||
"noInvites": "Noch keine Einladungen",
|
||||
"legalLinksTitle": "Rechtliche Links",
|
||||
"legalLinksDesc": "Impressum- und Datenschutz-Links am unteren Rand der Seitenleiste anzeigen. Leer lassen zum Ausblenden.",
|
||||
"imprintUrl": "Impressum-URL",
|
||||
"privacyUrl": "Datenschutz-URL",
|
||||
"imprintUrlSaved": "Impressum-URL gespeichert",
|
||||
"privacyUrlSaved": "Datenschutz-URL gespeichert",
|
||||
"imprintUrlFailed": "Impressum-URL konnte nicht gespeichert werden",
|
||||
"privacyUrlFailed": "Datenschutz-URL konnte nicht gespeichert werden"
|
||||
},
|
||||
"notifications": {
|
||||
"bell": "Benachrichtigungen",
|
||||
"markAllRead": "Alle gelesen",
|
||||
"clearAll": "Alle löschen",
|
||||
"delete": "Löschen",
|
||||
"noNotifications": "Keine Benachrichtigungen",
|
||||
"roomShareAdded": "Raum wurde mit dir geteilt",
|
||||
"roomShareRemoved": "Raumzugriff wurde entfernt",
|
||||
"federationInviteReceived": "Neue Meeting-Einladung"
|
||||
},
|
||||
"federation": {
|
||||
"inbox": "Einladungen",
|
||||
@@ -392,7 +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",
|
||||
@@ -406,6 +428,109 @@
|
||||
"roomDetails": "Raumdetails",
|
||||
"joinUrl": "Beitritts-URL",
|
||||
"roomDeleted": "Gelöscht",
|
||||
"roomDeletedNotice": "Dieser Raum wurde vom Besitzer auf der Ursprungsinstanz gelöscht und ist nicht mehr verfügbar."
|
||||
"roomDeletedNotice": "Dieser Raum wurde vom Besitzer auf der Ursprungsinstanz gelöscht und ist nicht mehr verfügbar.",
|
||||
"calendarEvent": "Kalendereinladung",
|
||||
"calendarAccepted": "Kalender-Event angenommen und in deinen Kalender eingetragen!",
|
||||
"localCalendarEvent": "Lokale Kalendereinladung",
|
||||
"calendarLocalAccepted": "Einladung angenommen - Event wurde in deinen Kalender eingetragen!",
|
||||
"invitationRemoved": "Einladung entfernt",
|
||||
"removeInvitation": "Einladung entfernen"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalender",
|
||||
"subtitle": "Meetings planen und verwalten",
|
||||
"newEvent": "Neues Event",
|
||||
"createEvent": "Event erstellen",
|
||||
"editEvent": "Event bearbeiten",
|
||||
"eventTitle": "Titel",
|
||||
"eventTitlePlaceholder": "z.B. Team Meeting",
|
||||
"description": "Beschreibung",
|
||||
"descriptionPlaceholder": "Beschreibung hinzufügen...",
|
||||
"startTime": "Beginn",
|
||||
"endTime": "Ende",
|
||||
"linkedRoom": "Verknüpfter Raum",
|
||||
"noRoom": "Kein Raum (kein Videomeeting)",
|
||||
"linkedRoomHint": "Verknüpfe einen Raum, um die Beitritts-URL automatisch ins Event einzufügen.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
139
src/i18n/en.json
139
src/i18n/en.json
@@ -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,11 +78,11 @@
|
||||
"emailNotVerified": "Email not yet verified. Please check your inbox.",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "e.g. johndoe",
|
||||
"usernameHint": "Letters, numbers, _ and - only (3–30 chars)",
|
||||
"usernameHint": "Letters, numbers, _ and - only (3-30 chars)",
|
||||
"displayName": "Display Name",
|
||||
"displayNamePlaceholder": "John Doe",
|
||||
"usernameTaken": "Username is already taken",
|
||||
"usernameInvalid": "Username may only contain letters, numbers, _ and - (3–30 chars)",
|
||||
"usernameInvalid": "Username may only contain letters, numbers, _ and - (3-30 chars)",
|
||||
"usernameRequired": "Username is required",
|
||||
"displayNameRequired": "Display name is required",
|
||||
"emailVerificationBanner": "Your email address has not been verified yet.",
|
||||
@@ -230,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",
|
||||
@@ -354,7 +358,25 @@
|
||||
"inviteExpired": "Expired",
|
||||
"inviteUsedBy": "Used by",
|
||||
"inviteExpiresAt": "Expires",
|
||||
"noInvites": "No invitations yet"
|
||||
"noInvites": "No invitations yet",
|
||||
"legalLinksTitle": "Legal Links",
|
||||
"legalLinksDesc": "Show Imprint and Privacy Policy links at the bottom of the sidebar. Leave blank to hide.",
|
||||
"imprintUrl": "Imprint URL",
|
||||
"privacyUrl": "Privacy Policy URL",
|
||||
"imprintUrlSaved": "Imprint URL saved",
|
||||
"privacyUrlSaved": "Privacy Policy URL saved",
|
||||
"imprintUrlFailed": "Could not save Imprint URL",
|
||||
"privacyUrlFailed": "Could not save Privacy Policy URL"
|
||||
},
|
||||
"notifications": {
|
||||
"bell": "Notifications",
|
||||
"markAllRead": "Mark all read",
|
||||
"clearAll": "Clear all",
|
||||
"delete": "Delete",
|
||||
"noNotifications": "No notifications yet",
|
||||
"roomShareAdded": "Room shared with you",
|
||||
"roomShareRemoved": "Room access removed",
|
||||
"federationInviteReceived": "New meeting invitation"
|
||||
},
|
||||
"federation": {
|
||||
"inbox": "Invitations",
|
||||
@@ -392,7 +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",
|
||||
@@ -406,6 +428,109 @@
|
||||
"roomDetails": "Room Details",
|
||||
"joinUrl": "Join URL",
|
||||
"roomDeleted": "Deleted",
|
||||
"roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available."
|
||||
"roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available.",
|
||||
"calendarEvent": "Calendar Invitation",
|
||||
"calendarAccepted": "Calendar event accepted and added to your calendar!",
|
||||
"localCalendarEvent": "Local Calendar Invitation",
|
||||
"calendarLocalAccepted": "Invitation accepted - event added to your calendar!",
|
||||
"invitationRemoved": "Invitation removed",
|
||||
"removeInvitation": "Remove invitation"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendar",
|
||||
"subtitle": "Plan and manage your meetings",
|
||||
"newEvent": "New Event",
|
||||
"createEvent": "Create Event",
|
||||
"editEvent": "Edit Event",
|
||||
"eventTitle": "Title",
|
||||
"eventTitlePlaceholder": "e.g. Team Meeting",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Add a description...",
|
||||
"startTime": "Start",
|
||||
"endTime": "End",
|
||||
"linkedRoom": "Linked Room",
|
||||
"noRoom": "No room (no video meeting)",
|
||||
"linkedRoomHint": "Link a room to automatically include the join-URL in the event.",
|
||||
"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}."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
27
src/main.jsx
27
src/main.jsx
@@ -7,6 +7,7 @@ import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import { BrandingProvider } from './contexts/BrandingContext';
|
||||
import { NotificationProvider } from './contexts/NotificationContext';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
@@ -16,18 +17,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>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
||||
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
||||
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
|
||||
ShieldCheck, Globe,
|
||||
ShieldCheck, Globe, Link as LinkIcon,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
@@ -16,7 +16,7 @@ import toast from 'react-hot-toast';
|
||||
export default function Admin() {
|
||||
const { user } = useAuth();
|
||||
const { t, language } = useLanguage();
|
||||
const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, refreshBranding } = useBranding();
|
||||
const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, imprintUrl, privacyUrl, refreshBranding } = useBranding();
|
||||
const navigate = useNavigate();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -27,6 +27,8 @@ export default function Admin() {
|
||||
const [showCreateUser, setShowCreateUser] = useState(false);
|
||||
const [creatingUser, setCreatingUser] = useState(false);
|
||||
const [newUser, setNewUser] = useState({ name: '', display_name: '', email: '', password: '', role: 'user' });
|
||||
const menuBtnRefs = useRef({});
|
||||
const [menuPos, setMenuPos] = useState(null);
|
||||
|
||||
// Invite state
|
||||
const [invites, setInvites] = useState([]);
|
||||
@@ -41,6 +43,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') {
|
||||
@@ -59,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');
|
||||
@@ -88,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) => {
|
||||
@@ -100,6 +115,7 @@ export default function Admin() {
|
||||
toast.error(err.response?.data?.error || t('admin.userDeleteFailed'));
|
||||
}
|
||||
setOpenMenu(null);
|
||||
setMenuPos(null);
|
||||
};
|
||||
|
||||
const handleResetPassword = async (e) => {
|
||||
@@ -233,6 +249,32 @@ export default function Admin() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImprintUrlSave = async () => {
|
||||
setSavingImprintUrl(true);
|
||||
try {
|
||||
await api.put('/branding/imprint-url', { imprintUrl: editImprintUrl.trim() });
|
||||
toast.success(t('admin.imprintUrlSaved'));
|
||||
refreshBranding();
|
||||
} catch {
|
||||
toast.error(t('admin.imprintUrlFailed'));
|
||||
} finally {
|
||||
setSavingImprintUrl(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrivacyUrlSave = async () => {
|
||||
setSavingPrivacyUrl(true);
|
||||
try {
|
||||
await api.put('/branding/privacy-url', { privacyUrl: editPrivacyUrl.trim() });
|
||||
toast.success(t('admin.privacyUrlSaved'));
|
||||
refreshBranding();
|
||||
} catch {
|
||||
toast.error(t('admin.privacyUrlFailed'));
|
||||
} finally {
|
||||
setSavingPrivacyUrl(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(u =>
|
||||
(u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase())
|
||||
@@ -377,6 +419,59 @@ export default function Admin() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legal links */}
|
||||
<div className="mt-6 pt-6 border-t border-th-border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<LinkIcon size={16} className="text-th-accent" />
|
||||
<label className="block text-sm font-medium text-th-text">{t('admin.legalLinksTitle')}</label>
|
||||
</div>
|
||||
<p className="text-xs text-th-text-s mb-4">{t('admin.legalLinksDesc')}</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Imprint */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.imprintUrl')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={editImprintUrl}
|
||||
onChange={e => setEditImprintUrl(e.target.value)}
|
||||
className="input-field text-sm flex-1"
|
||||
placeholder="https://example.com/imprint"
|
||||
maxLength={500}
|
||||
/>
|
||||
<button
|
||||
onClick={handleImprintUrlSave}
|
||||
disabled={savingImprintUrl || editImprintUrl === (imprintUrl || '')}
|
||||
className="btn-primary text-sm px-4 flex-shrink-0"
|
||||
>
|
||||
{savingImprintUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Privacy Policy */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.privacyUrl')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={editPrivacyUrl}
|
||||
onChange={e => setEditPrivacyUrl(e.target.value)}
|
||||
className="input-field text-sm flex-1"
|
||||
placeholder="https://example.com/privacy"
|
||||
maxLength={500}
|
||||
/>
|
||||
<button
|
||||
onClick={handlePrivacyUrlSave}
|
||||
disabled={savingPrivacyUrl || editPrivacyUrl === (privacyUrl || '')}
|
||||
className="btn-primary text-sm px-4 flex-shrink-0"
|
||||
>
|
||||
{savingPrivacyUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration Mode */}
|
||||
@@ -580,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 bottom-full mb-1 z-20 w-48 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => handleRoleChange(u.id, u.role === 'admin' ? 'user' : 'admin')}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||
>
|
||||
{u.role === 'admin' ? <UserX size={14} /> : <UserCheck size={14} />}
|
||||
{u.role === 'admin' ? t('admin.makeUser') : t('admin.makeAdmin')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setResetPwModal(u.id); setOpenMenu(null); }}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||
>
|
||||
<Key size={14} />
|
||||
{t('admin.resetPassword')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(u.id, u.name)}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-error hover:bg-th-hover transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('admin.deleteUser')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -633,6 +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
841
src/pages/Calendar.jsx
Normal 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' });
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Globe, Mail, Check, X, ExternalLink, Loader2, Inbox } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Globe, Mail, Check, X, ExternalLink, Loader2, Inbox, Calendar, Trash2 } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -7,12 +7,20 @@ import toast from 'react-hot-toast';
|
||||
export default function FederationInbox() {
|
||||
const { t } = useLanguage();
|
||||
const [invitations, setInvitations] = useState([]);
|
||||
const [calendarInvitations, setCalendarInvitations] = useState([]);
|
||||
const [localCalInvitations, setLocalCalInvitations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchInvitations = async () => {
|
||||
try {
|
||||
const res = await api.get('/federation/invitations');
|
||||
setInvitations(res.data.invitations || []);
|
||||
const [roomRes, calRes, localCalRes] = await Promise.all([
|
||||
api.get('/federation/invitations'),
|
||||
api.get('/federation/calendar-invitations').catch(() => ({ data: { invitations: [] } })),
|
||||
api.get('/calendar/local-invitations').catch(() => ({ data: { invitations: [] } })),
|
||||
]);
|
||||
setInvitations(roomRes.data.invitations || []);
|
||||
setCalendarInvitations(calRes.data.invitations || []);
|
||||
setLocalCalInvitations(localCalRes.data.invitations || []);
|
||||
} catch {
|
||||
toast.error(t('federation.loadFailed'));
|
||||
} finally {
|
||||
@@ -24,6 +32,7 @@ export default function FederationInbox() {
|
||||
fetchInvitations();
|
||||
}, []);
|
||||
|
||||
// ── Room invitation actions ──────────────────────────────────────────────
|
||||
const handleAccept = async (id) => {
|
||||
try {
|
||||
await api.post(`/federation/invitations/${id}/accept`);
|
||||
@@ -44,6 +53,78 @@ export default function FederationInbox() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteInvitation = async (id) => {
|
||||
try {
|
||||
await api.delete(`/federation/invitations/${id}`);
|
||||
toast.success(t('federation.invitationRemoved'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.declineFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// ── Calendar invitation actions ──────────────────────────────────────────
|
||||
const handleCalAccept = async (id) => {
|
||||
try {
|
||||
await api.post(`/federation/calendar-invitations/${id}/accept`);
|
||||
toast.success(t('federation.calendarAccepted'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.acceptFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCalDecline = async (id) => {
|
||||
try {
|
||||
await api.delete(`/federation/calendar-invitations/${id}`);
|
||||
toast.success(t('federation.declined'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.declineFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCalDelete = async (id) => {
|
||||
try {
|
||||
await api.delete(`/federation/calendar-invitations/${id}`);
|
||||
toast.success(t('federation.invitationRemoved'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.declineFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// ── Local calendar invitation actions ───────────────────────────────────
|
||||
const handleLocalCalAccept = async (id) => {
|
||||
try {
|
||||
await api.post(`/calendar/local-invitations/${id}/accept`);
|
||||
toast.success(t('federation.calendarLocalAccepted'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.acceptFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalCalDecline = async (id) => {
|
||||
try {
|
||||
await api.delete(`/calendar/local-invitations/${id}`);
|
||||
toast.success(t('federation.declined'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.declineFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalCalDelete = async (id) => {
|
||||
try {
|
||||
await api.delete(`/calendar/local-invitations/${id}`);
|
||||
toast.success(t('federation.invitationRemoved'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.declineFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -52,8 +133,15 @@ export default function FederationInbox() {
|
||||
);
|
||||
}
|
||||
|
||||
const pending = invitations.filter(i => i.status === 'pending');
|
||||
const past = invitations.filter(i => i.status !== 'pending');
|
||||
const pendingRooms = invitations.filter(i => i.status === 'pending');
|
||||
const pastRooms = invitations.filter(i => i.status !== 'pending');
|
||||
const pendingCal = calendarInvitations.filter(i => i.status === 'pending');
|
||||
const pastCal = calendarInvitations.filter(i => i.status !== 'pending');
|
||||
const pendingLocalCal = localCalInvitations.filter(i => i.status === 'pending');
|
||||
const pastLocalCal = localCalInvitations.filter(i => i.status !== 'pending');
|
||||
|
||||
const totalPending = pendingRooms.length + pendingCal.length + pendingLocalCal.length;
|
||||
const totalPast = pastRooms.length + pastCal.length + pastLocalCal.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -67,14 +155,15 @@ export default function FederationInbox() {
|
||||
</div>
|
||||
|
||||
{/* Pending invitations */}
|
||||
{pending.length > 0 && (
|
||||
{totalPending > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
|
||||
{t('federation.pending')} ({pending.length})
|
||||
{t('federation.pending')} ({totalPending})
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{pending.map(inv => (
|
||||
<div key={inv.id} className="card p-5 border-l-4 border-l-th-accent">
|
||||
{/* Pending room invitations */}
|
||||
{pendingRooms.map(inv => (
|
||||
<div key={`room-${inv.id}`} className="card p-5 border-l-4 border-l-th-accent">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
@@ -92,17 +181,89 @@ export default function FederationInbox() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleAccept(inv.id)}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
<button onClick={() => handleAccept(inv.id)} className="btn-primary text-sm">
|
||||
<Check size={16} />
|
||||
{t('federation.accept')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDecline(inv.id)}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
<button onClick={() => handleDecline(inv.id)} className="btn-secondary text-sm">
|
||||
<X size={16} />
|
||||
{t('federation.decline')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Pending calendar invitations */}
|
||||
{pendingCal.map(inv => (
|
||||
<div key={`cal-${inv.id}`} className="card p-5 border-l-4 border-l-th-success">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Calendar size={16} className="text-th-success flex-shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-th-success mr-1">
|
||||
{t('federation.calendarEvent')}
|
||||
</span>
|
||||
<h3 className="text-base font-semibold text-th-text truncate">{inv.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s">
|
||||
{t('federation.from')}: <span className="font-medium text-th-text">{inv.from_user}</span>
|
||||
</p>
|
||||
<p className="text-sm text-th-text-s mt-1">
|
||||
{new Date(inv.start_time).toLocaleString()} - {new Date(inv.end_time).toLocaleString()}
|
||||
</p>
|
||||
{inv.description && (
|
||||
<p className="text-sm text-th-text-s mt-1 italic">"{inv.description}"</p>
|
||||
)}
|
||||
<p className="text-xs text-th-text-s mt-1">
|
||||
{new Date(inv.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button onClick={() => handleCalAccept(inv.id)} className="btn-primary text-sm">
|
||||
<Check size={16} />
|
||||
{t('federation.accept')}
|
||||
</button>
|
||||
<button onClick={() => handleCalDecline(inv.id)} className="btn-secondary text-sm">
|
||||
<X size={16} />
|
||||
{t('federation.decline')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Pending local calendar invitations */}
|
||||
{pendingLocalCal.map(inv => (
|
||||
<div key={`localcal-${inv.id}`} className="card p-5 border-l-4 border-l-th-accent">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Calendar size={16} className="text-th-accent flex-shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-th-accent mr-1">
|
||||
{t('federation.localCalendarEvent')}
|
||||
</span>
|
||||
<h3 className="text-base font-semibold text-th-text truncate">{inv.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s">
|
||||
{t('federation.from')}: <span className="font-medium text-th-text">{inv.from_name}</span>
|
||||
</p>
|
||||
<p className="text-sm text-th-text-s mt-1">
|
||||
{new Date(inv.start_time).toLocaleString()} - {new Date(inv.end_time).toLocaleString()}
|
||||
</p>
|
||||
{inv.description && (
|
||||
<p className="text-sm text-th-text-s mt-1 italic">"{inv.description}"</p>
|
||||
)}
|
||||
<p className="text-xs text-th-text-s mt-1">
|
||||
{new Date(inv.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button onClick={() => handleLocalCalAccept(inv.id)} className="btn-primary text-sm">
|
||||
<Check size={16} />
|
||||
{t('federation.accept')}
|
||||
</button>
|
||||
<button onClick={() => handleLocalCalDecline(inv.id)} className="btn-secondary text-sm">
|
||||
<X size={16} />
|
||||
{t('federation.decline')}
|
||||
</button>
|
||||
@@ -115,24 +276,28 @@ export default function FederationInbox() {
|
||||
)}
|
||||
|
||||
{/* Past invitations */}
|
||||
{past.length > 0 && (
|
||||
{totalPast > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
|
||||
{t('federation.previousInvites')}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{past.map(inv => (
|
||||
<div key={inv.id} className="card p-4 opacity-60">
|
||||
{/* Past room invitations */}
|
||||
{pastRooms.map(inv => (
|
||||
<div key={`room-past-${inv.id}`} className="card p-4 opacity-70">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3>
|
||||
<p className="text-xs text-th-text-s">{inv.from_user}</p>
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<Mail size={14} className="text-th-text-s flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3>
|
||||
<p className="text-xs text-th-text-s">{inv.from_user}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
|
||||
? 'bg-th-success/15 text-th-success'
|
||||
: 'bg-th-error/15 text-th-error'
|
||||
}`}>
|
||||
? 'bg-th-success/15 text-th-success'
|
||||
: 'bg-th-error/15 text-th-error'
|
||||
}`}>
|
||||
{inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')}
|
||||
</span>
|
||||
{inv.status === 'accepted' && (
|
||||
@@ -144,6 +309,82 @@ export default function FederationInbox() {
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteInvitation(inv.id)}
|
||||
className="btn-ghost text-xs py-1.5 px-2 text-th-text-s hover:text-th-error"
|
||||
title={t('federation.removeInvitation')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Past calendar invitations */}
|
||||
{pastCal.map(inv => (
|
||||
<div key={`cal-past-${inv.id}`} className="card p-4 opacity-70">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<Calendar size={14} className="text-th-text-s flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
|
||||
<p className="text-xs text-th-text-s">{inv.from_user} · {new Date(inv.start_time).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
|
||||
? 'bg-th-success/15 text-th-success'
|
||||
: 'bg-th-error/15 text-th-error'
|
||||
}`}>
|
||||
{inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')}
|
||||
</span>
|
||||
{inv.status === 'accepted' && inv.join_url && (
|
||||
<button
|
||||
onClick={() => window.open(inv.join_url, '_blank')}
|
||||
className="btn-ghost text-xs py-1.5 px-2"
|
||||
title={t('federation.openLink')}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleCalDelete(inv.id)}
|
||||
className="btn-ghost text-xs py-1.5 px-2 text-th-text-s hover:text-th-error"
|
||||
title={t('federation.removeInvitation')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Past local calendar invitations */}
|
||||
{pastLocalCal.map(inv => (
|
||||
<div key={`localcal-past-${inv.id}`} className="card p-4 opacity-70">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<Calendar size={14} className="text-th-text-s flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
|
||||
<p className="text-xs text-th-text-s">{inv.from_name} · {new Date(inv.start_time).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
|
||||
? 'bg-th-success/15 text-th-success'
|
||||
: 'bg-th-error/15 text-th-error'
|
||||
}`}>
|
||||
{inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleLocalCalDelete(inv.id)}
|
||||
className="btn-ghost text-xs py-1.5 px-2 text-th-text-s hover:text-th-error"
|
||||
title={t('federation.removeInvitation')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,7 +394,7 @@ export default function FederationInbox() {
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{invitations.length === 0 && (
|
||||
{totalPending === 0 && totalPast === 0 && (
|
||||
<div className="card p-12 text-center">
|
||||
<Inbox size={48} className="mx-auto text-th-text-s/40 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-th-text mb-2">{t('federation.noInvitations')}</h3>
|
||||
@@ -163,3 +404,4 @@ export default function FederationInbox() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,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>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe } from 'lucide-react';
|
||||
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe, FileText, Lock } from 'lucide-react';
|
||||
import BrandLogo from '../components/BrandLogo';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useBranding } from '../contexts/BrandingContext';
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useLanguage();
|
||||
const { registrationMode } = useBranding();
|
||||
const { registrationMode, imprintUrl, privacyUrl } = useBranding();
|
||||
const isInviteOnly = registrationMode === 'invite';
|
||||
|
||||
const features = [
|
||||
@@ -143,6 +143,35 @@ export default function Home() {
|
||||
<p className="text-sm text-th-text-s">
|
||||
{t('home.footer', { year: new Date().getFullYear() })}
|
||||
</p>
|
||||
{(imprintUrl || privacyUrl) && (
|
||||
<div className="flex items-center justify-center gap-4 mt-3">
|
||||
{imprintUrl && (
|
||||
<a
|
||||
href={imprintUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||
>
|
||||
<FileText size={12} />
|
||||
{t('nav.imprint')}
|
||||
</a>
|
||||
)}
|
||||
{imprintUrl && privacyUrl && (
|
||||
<span className="text-th-border text-xs">·</span>
|
||||
)}
|
||||
{privacyUrl && (
|
||||
<a
|
||||
href={privacyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||
>
|
||||
<Lock size={12} />
|
||||
{t('nav.privacy')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user