36 Commits
Author SHA1 Message Date
MichelleandClaude Opus 4.8 849f54db7d chore: update version to 2.4.1
Build & Push Docker Image / build (push) Canceled after 0s
Build & Push Docker Image / build (release) Successful in 4m16s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:03:40 +02:00
MichelleandClaude Opus 4.8 4e89d9d0fe feat: disable transcript/summary recording formats when transcription is off
Build & Push Docker Image / build (push) Successful in 4m16s
When the per-room AI summary/transcript setting is disabled (or the
integration is not configured), pass
meta_bbb-disable-recording-formats=transcript,summary on meeting create
so BBB skips processing and publishing those formats entirely
(bigbluebutton/bigbluebutton#25277).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:01:18 +02:00
MichelleandClaude Opus 4.8 4aeca36a9c fix: log underlying network error cause for failed transcription requests
Build & Push Docker Image / build (push) Successful in 4m5s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:30:17 +02:00
MichelleandClaude Opus 4.8 51b42cfee5 feat: add AI summary / transcript integration for recordings
Build & Push Docker Image / build (push) Successful in 4m17s
Build & Push Docker Image / build (release) Successful in 3m57s
Ported from greenlight-tm: when TRANSCRIPTION_API_URL and
TRANSCRIPTION_API_KEY are set in the .env and the per-room
"AI summary / transcript" setting is enabled, Redlight POSTs to
${TRANSCRIPTION_API_URL}/prompt (header X-Api-Key) as soon as the
"video" format of a recording becomes available.

- transcription.js: service with env gating, timeout and logging
- bbb.js: pass meta_bbb-recording-ready-url on meeting create when the
  room has the setting enabled
- recordings.js: unauthenticated /recording-ready callback that
  verifies BBB's signed_parameters JWT (HS256, shared secret), caches
  the recording and requests the transcription once per recording
  (deduped via transcript_requested_at)
- rooms: new recording_transcript column, accepted on create/update
- branding endpoint exposes transcriptionEnabled so the UI only shows
  the toggle when the server is configured
- RoomDetail: toggle under "Allow recording" (disabled when recording
  is off), de/en i18n
- chore: bump version to 2.4.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:56:45 +02:00
MichelleandClaude Opus 4.8 dffb68efe5 fix: move resetPassword email locales to server i18n
Build & Push Docker Image / build (push) Successful in 4m9s
The password reset email is rendered server-side via emaili18n.js,
which loads translations from server/i18n. The email.resetPassword
keys only existed in the frontend src/i18n locales, so t() returned
the raw key paths in the sent email. Move the EN/DE block to the
server locales and drop the unused copy from the frontend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:28:37 +02:00
MichelleandClaude Opus 4.8 db82cd944f feat: add password reset ("forgot password") flow
Build & Push Docker Image / build (push) Successful in 4m12s
Add a self-service password reset to the login flow:

- Login page now shows a "Passwort vergessen?" link under the password field
- New /forgot-password page requests a reset email by address
- New /reset-password page sets a new password from an emailed token
- Backend: POST /auth/forgot-password and /auth/reset-password with
  dedicated rate limiters; tokens stored as SHA-256 hashes with a 1h expiry
- Generic responses avoid leaking account existence or SMTP/SSO state;
  SSO-only accounts are skipped
- New sendPasswordResetEmail mailer + email/auth i18n keys (de + en)
- DB migration: reset_token_hash, reset_token_expires, reset_requested_at

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 23:40:11 +02:00
Michelle 2f65e53a24 fix: wrap screenshots in collapsible details section in README
Build & Push Docker Image / build (push) Successful in 4m6s
2026-06-21 22:17:58 +02:00
Michelle 1518645145 Merge branch 'main' of https://git.scrunkly.cat/Michelle/redlight
Build & Push Docker Image / build (push) Successful in 4m13s
2026-06-21 22:16:43 +02:00
Michelle 64a1ba2548 Add screenshots to README 2026-06-21 22:16:42 +02:00
MichelleandClaude Opus 4.8 03098eaf2b chore: update version to 2.3.0
Build & Push Docker Image / build (push) Successful in 4m5s
Build & Push Docker Image / build (release) Successful in 4m13s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:18:48 +02:00
MichelleandClaude Opus 4.8 7dd834cd35 Harden server security, rework landing page and refresh branding
Build & Push Docker Image / build (push) Successful in 4m3s
Security:
- rooms: rate-limit /invite-email (SMTP spam relay), validate share
  target user exists, guard timingSafeEqual against length mismatch
  in the presentation route (500 -> 403)
- analytics: verify callback token before parsing the 5mb body so
  unauthenticated callers cannot buffer large payloads
- caldav: rate-limit failed Basic-Auth attempts (token brute force),
  lowercase email lookup, case-insensitive principal check
- auth: fall back to the in-memory rate-limit store when Redis is
  unavailable; previously every rate-limited endpoint (incl. login)
  returned 500 when the Redis connection was down

UI/copy:
- Home: factual hero copy and feature cards (6 instead of 9), fix
  double-rendered feature icon, remove fake stats row and pill badge;
  keep the background gradient and card layout
- i18n: consistent informal tone, drop trailing exclamation marks
  from status toasts, remove emoji from transactional emails
- new favicon (logo.svg), restore theme-based default brand logo

Chore:
- gitignore SQLite WAL/SHM files

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:14:36 +02:00
Michelle 4621010bd7 feat: add dark mode support for meeting join and start processes
Build & Push Docker Image / build (push) Successful in 4m14s
2026-06-11 09:27:34 +02:00
Michelle fbbcd79719 fix: prevent focus loss by using ref for onClose in Modal component
Build & Push Docker Image / build (push) Successful in 4m6s
Build & Push Docker Image / build (release) Successful in 4m12s
2026-06-03 10:40:38 +02:00
Michelle 10f0ffd2e5 chore: update version to 2.2.0
Build & Push Docker Image / build (push) Successful in 4m2s
Build & Push Docker Image / build (release) Successful in 4m6s
2026-06-02 09:21:42 +02:00
Michelle 4aea069295 fix: resolve server bugs and unify app-name handling
Build & Push Docker Image / build (push) Successful in 4m6s
Bug fixes:
- bbb.js: replace undefined t('defaultWelcome') call that threw a
  ReferenceError when a room had an empty welcome message, breaking
  meeting creation. Default welcome and the guest-invite hint are now
  localised via the i18n system (new "bbb" namespace in de/en).
- auth.js: app name was read from the never-written 'branding' settings
  key, so custom names never appeared in verification emails or the TOTP
  issuer. Now resolved through a shared getAppName() helper.
- auth.js: lowercase the email in the registration duplicate check so
  case-variant duplicates return a clean 409 instead of a 500 (UNIQUE
  violation).
- federation.js: select the user's "language" column so federation
  invite emails respect the recipient's language instead of always
  defaulting to English.
- calendar.js: a set reminder could not be cleared. COALESCE treated an
  explicit reminder_minutes: null as "keep existing"; use a direct
  assignment that distinguishes "omitted" (keep) from "null" (clear).
- index.js / analytics.js: exclude the BBB learning-analytics callback
  from the global 100kb body limit and give it its own 5mb limit, since
  analytics payloads for large meetings can be several MB.

Cleanup:
- Add server/config/appName.js as the single source of truth for the
  app name (admin setting -> APP_NAME env -> 'Redlight') and use it in
  auth, admin, rooms, calendar and federation, replacing the previous
  mix of wrong DB key, direct app_name reads and bare process.env reads.
- Localise the BBB default welcome message in the room owner's language.
- Remove two unused safeAppName variables in mailer.js.
2026-06-02 09:19:21 +02:00
Michelle 9fc51bdfc5 chore: update version to 2.1.4
Build & Push Docker Image / build (push) Has been cancelled
Build & Push Docker Image / build (release) Successful in 4m14s
2026-05-28 10:14:41 +02:00
Michelle 7f48685717 Enhance accessibility and improve form semantics across multiple pages
Build & Push Docker Image / build (push) Successful in 4m4s
- Added `htmlFor` attributes to labels for better accessibility in Calendar, Dashboard, GuestJoin, Login, Register, RoomDetail, and Settings pages.
- Included `aria-hidden` attributes for icons to improve screen reader experience.
- Set `autoComplete` attributes for input fields to enhance user experience during form filling.
- Implemented `role` and `aria` attributes for radio groups and buttons to improve accessibility compliance.
2026-05-28 10:07:19 +02:00
Michelle cff5398ebd fix: restore pointer cursor for buttons in index.css
Build & Push Docker Image / build (push) Successful in 4m10s
2026-05-28 09:22:50 +02:00
Michelle 109e75d976 fix: add .claude/ to .gitignore and update cursor styles for button utilities in index.css
Build & Push Docker Image / build (push) Failing after 19m25s
2026-05-28 09:08:17 +02:00
Michelle 24024cd1dd chore: update version to 2.1.3 in package.json and federation.js
Build & Push Docker Image / build (push) Successful in 4m18s
Build & Push Docker Image / build (release) Successful in 4m18s
2026-05-18 13:17:02 +02:00
Michelle 4028e913c4 refactor: update class names for consistency and improve styling
Build & Push Docker Image / build (push) Successful in 4m21s
- Changed `flex-shrink-0` to `shrink-0` in multiple components for better consistency.
- Updated button and checkbox classes to use `rounded-sm` for a more uniform appearance.
- Adjusted backdrop blur classes for modals to `backdrop-blur-xs` for a subtler effect.
- Removed unused Tailwind CSS configuration file.
2026-05-18 13:07:26 +02:00
Michelle aba7819f12 chore: update dependencies and versions in package-lock.json
Build & Push Docker Image / build (push) Successful in 4m5s
2026-04-25 20:34:23 +02:00
Michelle 45fdbe4883 feat: enforce maximum password length of 64 characters in user registration and password update
Build & Push Docker Image / build (push) Successful in 4m19s
2026-04-25 20:30:29 +02:00
Michelle de696d422a fix: anyone_can_start not working as Admin
Build & Push Docker Image / build (push) Successful in 4m24s
2026-04-23 09:32:55 +02:00
Michelle 995d6eabf7 feat: remove unnecessary footer text from home page
Build & Push Docker Image / build (push) Successful in 4m19s
2026-04-09 14:01:01 +02:00
Michelle 9ee4f84d84 feat: update home page features with federation, calendar, notifications, OAuth, and analytics support
Build & Push Docker Image / build (push) Successful in 4m24s
2026-04-09 13:52:33 +02:00
Michelle df1aa20e45 feat: add recordings table and implement fetch and sync functionality for recordings
Build & Push Docker Image / build (push) Successful in 4m18s
2026-04-08 00:10:25 +02:00
Michelle e0ce354eda feat: improve email invitation label for clarity and add navigation on guest join
Build & Push Docker Image / build (push) Successful in 4m19s
2026-04-02 01:03:18 +02:00
Michelle 1690a74c19 feat: add email invitation functionality for guests with support for multiple addresses
Build & Push Docker Image / build (push) Successful in 4m21s
2026-04-02 00:54:57 +02:00
Michelle 61585d8c63 feat: add functionality to display all rooms with search and modal support in admin panel
Build & Push Docker Image / build (push) Successful in 4m17s
2026-04-01 12:05:51 +02:00
Michelle d04793148a feat: add room management functionality for admins with listing and deletion options
Build & Push Docker Image / build (push) Successful in 4m12s
2026-04-01 11:54:10 +02:00
Michelle 9bf4228d04 fix: update presentation display to show filename instead of name
Build & Push Docker Image / build (push) Successful in 4m21s
2026-04-01 11:34:48 +02:00
Michelle c058ba3bf1 fix: update presentation URL structure for token protection
Build & Push Docker Image / build (push) Successful in 4m13s
2026-04-01 09:43:32 +02:00
Michelle b3b559e164 feat: implement token-based access for presentation files and add serving endpoint
Build & Push Docker Image / build (push) Successful in 4m43s
2026-04-01 08:52:43 +02:00
Michelle 0db9227c20 chore: update package-lock.json with dependency version bumps
Build & Push Docker Image / build (push) Successful in 4m18s
2026-03-31 13:11:56 +02:00
Michelle 30f106a7ea fix: guest join doesn't add avatar when logged in
Build & Push Docker Image / build (push) Successful in 4m27s
2026-03-31 09:36:13 +02:00
62 changed files with 3940 additions and 1947 deletions
+1
View File
@@ -9,3 +9,4 @@ uploads/
.gitignore .gitignore
.vscode/ .vscode/
*.md *.md
screenshots/
+7
View File
@@ -37,6 +37,13 @@ SMTP_FROM=noreply@example.com
# App URL (used for verification links, auto-detected if not set) # App URL (used for verification links, auto-detected if not set)
# APP_URL=https://your-domain.com # APP_URL=https://your-domain.com
# Recording Transcription / AI Summarization
# When the per-room "AI summary / transcript" setting is enabled, Redlight will POST to
# ${TRANSCRIPTION_API_URL}/prompt with header `X-Api-Key: ${TRANSCRIPTION_API_KEY}` as soon as
# the "video" format of the recording is available. Leave empty to disable the integration entirely.
# TRANSCRIPTION_API_URL=
# TRANSCRIPTION_API_KEY=
# Reverse Proxy trust depth (express 'trust proxy' setting) # Reverse Proxy trust depth (express 'trust proxy' setting)
# loopback = trust only 127.0.0.1 / ::1 (default) # loopback = trust only 127.0.0.1 / ::1 (default)
# Use a number for proxy hops (e.g. 1), or a specific IP/CIDR. # Use a number for proxy hops (e.g. 1), or a specific IP/CIDR.
+3
View File
@@ -1,5 +1,8 @@
node_modules/ node_modules/
dist/ dist/
redlight.db redlight.db
redlight.db-wal
redlight.db-shm
uploads/ uploads/
.env .env
.claude/
+11
View File
@@ -9,6 +9,17 @@ A modern, self-hosted BigBlueButton frontend with 25+ themes, federation, calend
![License](https://img.shields.io/badge/License-GPL-pink) ![License](https://img.shields.io/badge/License-GPL-pink)
![BigBlueButton](https://img.shields.io/badge/BigBlueButton-Compatible-red) ![BigBlueButton](https://img.shields.io/badge/BigBlueButton-Compatible-red)
## 🖼️ Pictures
<details>
<summary>Click to expand screenshots</summary>
![Home](screenshots/home.png)
![Dashboard](screenshots/dashboard.png)
![Rooms](screenshots/rooms.png)
</details>
## ✨ Features ## ✨ Features
### Core Features ### Core Features
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en" data-theme="dark"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+1402 -1268
View File
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -1,7 +1,7 @@
{ {
"name": "redlight", "name": "redlight",
"private": true, "private": true,
"version": "2.1.2", "version": "2.4.1",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -20,33 +20,33 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"express": "^4.21.0", "express": "^5.2.1",
"express-rate-limit": "^7.5.1", "express-rate-limit": "^8.5.2",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"lucide-react": "^0.460.0", "lucide-react": "^1.16.0",
"multer": "^2.1.0", "multer": "^2.1.0",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"otpauth": "^9.5.0", "otpauth": "^9.5.0",
"pdfkit": "^0.17.2", "pdfkit": "^0.18.0",
"pg": "^8.18.0", "pg": "^8.18.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"rate-limit-redis": "^4.3.1", "rate-limit-redis": "^5.0.0",
"react": "^18.3.0", "react": "^19.2.6",
"react-dom": "^18.3.0", "react-dom": "^19.2.6",
"react-hot-toast": "^2.4.0", "react-hot-toast": "^2.4.0",
"react-router-dom": "^6.28.0", "react-router-dom": "^7.15.1",
"uuid": "^13.0.0", "uuid": "^14.0.0",
"xml2js": "^0.6.0" "xml2js": "^0.6.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.0", "@tailwindcss/postcss": "^4.3.0",
"@types/react-dom": "^18.3.0", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0", "postcss": "^8.4.0",
"tailwindcss": "^3.4.0", "tailwindcss": "^4.3.0",
"vite": "^8.0.0" "vite": "^8.0.0"
} }
} }
+1 -2
View File
@@ -1,6 +1,5 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, '@tailwindcss/postcss': {},
autoprefixer: {},
}, },
}; };
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Speech bubble tail -->
<path d="M 185 325 L 148 425 L 248 360 Z" fill="#2B3140" stroke="#2B3140" stroke-width="14" stroke-linejoin="round"/>
<!-- Speech bubble outline -->
<circle cx="256" cy="240" r="120" fill="none" stroke="#2B3140" stroke-width="34"/>
<!-- Red dot -->
<circle cx="262" cy="234" r="76" fill="#ED2224"/>
<!-- Sound waves right -->
<path d="M 423 100 A 218 218 0 0 1 423 380" fill="none" stroke="#ED2224" stroke-width="32" stroke-linecap="round"/>
<path d="M 413 160 A 176 176 0 0 1 413 320" fill="none" stroke="#ED2224" stroke-width="30" stroke-linecap="round"/>
<!-- Sound waves left -->
<path d="M 89 100 A 218 218 0 0 0 89 380" fill="none" stroke="#ED2224" stroke-width="32" stroke-linecap="round"/>
<path d="M 99 160 A 176 176 0 0 0 99 320" fill="none" stroke="#ED2224" stroke-width="30" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 934 B

-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#ef4444"/>
<circle cx="16" cy="16" r="8" fill="white" opacity="0.9"/>
<circle cx="16" cy="16" r="4" fill="#ef4444"/>
</svg>

Before

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

+20
View File
@@ -0,0 +1,20 @@
import { getDb } from './database.js';
/**
* Resolve the configured application name.
* Resolution order: admin-set 'app_name' setting → APP_NAME env var → 'Redlight'.
*
* The app name is stored in the settings table under the key 'app_name'
* (see routes/branding.js). This helper is the single source of truth so the
* configured name is used consistently across emails, the 2FA issuer, etc.
*/
export async function getAppName() {
try {
const db = getDb();
const row = await db.get("SELECT value FROM settings WHERE key = 'app_name'");
if (row?.value) return row.value;
} catch {
// fall through to env/default if the DB is unavailable
}
return process.env.APP_NAME || 'Redlight';
}
+22 -4
View File
@@ -1,6 +1,8 @@
import crypto from 'crypto'; import crypto from 'crypto';
import xml2js from 'xml2js'; import xml2js from 'xml2js';
import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParams } from './logger.js'; import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParams } from './logger.js';
import { t } from './emaili18n.js';
import { isTranscriptionEnabled } from './transcription.js';
const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/'; const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/';
const BBB_SECRET = process.env.BBB_SECRET || ''; const BBB_SECRET = process.env.BBB_SECRET || '';
@@ -73,15 +75,15 @@ function getRoomPasswords(uid) {
return { moderatorPW: modPw, attendeePW: attPw }; return { moderatorPW: modPw, attendeePW: attPw };
} }
export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null, analyticsCallbackURL = null) { export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null, analyticsCallbackURL = null, lang = 'en') {
const { moderatorPW, attendeePW } = getRoomPasswords(room.uid); const { moderatorPW, attendeePW } = getRoomPasswords(room.uid);
// Build welcome message with guest invite link // Build welcome message with guest invite link
// HTML-escape user-controlled content to prevent stored XSS via BBB // HTML-escape user-controlled content to prevent stored XSS via BBB
let welcome = room.welcome_message ? escapeHtml(room.welcome_message) : t('defaultWelcome'); let welcome = room.welcome_message ? escapeHtml(room.welcome_message) : escapeHtml(t(lang, 'bbb.defaultWelcome'));
if (logoutURL) { if (logoutURL) {
const guestLink = `${logoutURL}/join/${room.uid}`; const guestLink = `${logoutURL}/join/${room.uid}`;
welcome += `<br><br>To invite other participants, share this link:<br><a href="${escapeHtml(guestLink)}">${escapeHtml(guestLink)}</a>`; welcome += `<br><br>${escapeHtml(t(lang, 'bbb.inviteHint'))}<br><a href="${escapeHtml(guestLink)}">${escapeHtml(guestLink)}</a>`;
// Access code is intentionally NOT shown in the welcome message to prevent // Access code is intentionally NOT shown in the welcome message to prevent
// leaking it to all meeting participants. // leaking it to all meeting participants.
} }
@@ -114,6 +116,17 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati
if (analyticsCallbackURL) { if (analyticsCallbackURL) {
params['meta_analytics-callback-url'] = analyticsCallbackURL; params['meta_analytics-callback-url'] = analyticsCallbackURL;
} }
if (room.recording_transcript && isTranscriptionEnabled()) {
// BBB posts a signed recording-ready callback there once recording formats
// are processed; used to trigger the AI summary/transcript integration.
if (logoutURL) {
params['meta_bbb-recording-ready-url'] = `${logoutURL}/api/recordings/recording-ready`;
}
} else {
// Transcription/summary is off for this room — tell BBB to skip processing
// and publishing those recording formats entirely (BBB PR #25277).
params['meta_bbb-disable-recording-formats'] = 'transcript,summary';
}
// Build optional presentation XML body - escape URL to prevent XML injection // Build optional presentation XML body - escape URL to prevent XML injection
let xmlBody = null; let xmlBody = null;
@@ -129,7 +142,7 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati
return apiCall('create', params, xmlBody); return apiCall('create', params, xmlBody);
} }
export async function joinMeeting(uid, name, isModerator = false, avatarURL = null) { export async function joinMeeting(uid, name, isModerator = false, avatarURL = null, darkMode = false) {
const { moderatorPW, attendeePW } = getRoomPasswords(uid); const { moderatorPW, attendeePW } = getRoomPasswords(uid);
const params = { const params = {
meetingID: uid, meetingID: uid,
@@ -140,6 +153,11 @@ export async function joinMeeting(uid, name, isModerator = false, avatarURL = nu
if (avatarURL) { if (avatarURL) {
params.avatarURL = avatarURL; params.avatarURL = avatarURL;
} }
if (darkMode) {
// Per-user userdata override: start the client in dark theme for users who
// have a dark theme active in Redlight. Read by BBB as bbb_prefer_dark_theme.
params['userdata-bbb_prefer_dark_theme'] = 'true';
}
return buildUrl('join', params); return buildUrl('join', params);
} }
+76
View File
@@ -384,6 +384,25 @@ export async function initDatabase() {
} }
} }
// Password reset: store a SHA-256 hash of the reset token (never the raw token)
if (!(await db.columnExists('users', 'reset_token_hash'))) {
await db.exec('ALTER TABLE users ADD COLUMN reset_token_hash TEXT DEFAULT NULL');
}
if (!(await db.columnExists('users', 'reset_token_expires'))) {
if (isPostgres) {
await db.exec('ALTER TABLE users ADD COLUMN reset_token_expires TIMESTAMP DEFAULT NULL');
} else {
await db.exec('ALTER TABLE users ADD COLUMN reset_token_expires DATETIME DEFAULT NULL');
}
}
if (!(await db.columnExists('users', 'reset_requested_at'))) {
if (isPostgres) {
await db.exec('ALTER TABLE users ADD COLUMN reset_requested_at TIMESTAMP DEFAULT NULL');
} else {
await db.exec('ALTER TABLE users ADD COLUMN reset_requested_at DATETIME DEFAULT NULL');
}
}
// Federation sync: add deleted + updated_at to federated_rooms // Federation sync: add deleted + updated_at to federated_rooms
if (!(await db.columnExists('federated_rooms', 'deleted'))) { if (!(await db.columnExists('federated_rooms', 'deleted'))) {
await db.exec('ALTER TABLE federated_rooms ADD COLUMN deleted INTEGER DEFAULT 0'); await db.exec('ALTER TABLE federated_rooms ADD COLUMN deleted INTEGER DEFAULT 0');
@@ -815,6 +834,63 @@ export async function initDatabase() {
await db.exec("ALTER TABLE rooms ADD COLUMN analytics_visibility TEXT DEFAULT 'owner'"); await db.exec("ALTER TABLE rooms ADD COLUMN analytics_visibility TEXT DEFAULT 'owner'");
} }
// ── Recordings cache table ────────────────────────────────────────────
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS recordings (
id SERIAL PRIMARY KEY,
record_id TEXT UNIQUE NOT NULL,
meeting_id TEXT NOT NULL,
name TEXT,
state TEXT,
published INTEGER DEFAULT 1,
start_time TEXT,
end_time TEXT,
participants TEXT,
size TEXT,
formats TEXT NOT NULL DEFAULT '[]',
metadata TEXT DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_recordings_record_id ON recordings(record_id);
CREATE INDEX IF NOT EXISTS idx_recordings_meeting_id ON recordings(meeting_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_id TEXT UNIQUE NOT NULL,
meeting_id TEXT NOT NULL,
name TEXT,
state TEXT,
published INTEGER DEFAULT 1,
start_time TEXT,
end_time TEXT,
participants TEXT,
size TEXT,
formats TEXT NOT NULL DEFAULT '[]',
metadata TEXT DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_recordings_record_id ON recordings(record_id);
CREATE INDEX IF NOT EXISTS idx_recordings_meeting_id ON recordings(meeting_id);
`);
}
// ── Recording transcription / AI summary ────────────────────────────────
if (!(await db.columnExists('rooms', 'recording_transcript'))) {
await db.exec('ALTER TABLE rooms ADD COLUMN recording_transcript INTEGER DEFAULT 0');
}
if (!(await db.columnExists('recordings', 'transcript_requested_at'))) {
if (isPostgres) {
await db.exec('ALTER TABLE recordings ADD COLUMN transcript_requested_at TIMESTAMP DEFAULT NULL');
} else {
await db.exec('ALTER TABLE recordings ADD COLUMN transcript_requested_at DATETIME DEFAULT NULL');
}
}
// ── Default admin (only on very first start) ──────────────────────────── // ── Default admin (only on very first start) ────────────────────────────
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'"); const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
if (!adminAlreadySeeded) { if (!adminAlreadySeeded) {
+100 -3
View File
@@ -64,7 +64,6 @@ export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redl
const from = process.env.SMTP_FROM || process.env.SMTP_USER; const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName); const headerAppName = sanitizeHeaderValue(appName);
const safeName = escapeHtml(name); const safeName = escapeHtml(name);
const safeAppName = escapeHtml(appName);
await transporter.sendMail({ await transporter.sendMail({
from: `"${headerAppName}" <${from}>`, from: `"${headerAppName}" <${from}>`,
@@ -93,6 +92,50 @@ export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redl
}); });
} }
/**
* Send a password reset email with a clickable link.
* @param {string} to - recipient email
* @param {string} name - user's display name
* @param {string} resetUrl - full password reset URL
* @param {string} appName - branding app name (default "Redlight")
* @param {string} lang - language code
*/
export async function sendPasswordResetEmail(to, name, resetUrl, appName = 'Redlight', lang = 'en') {
if (!transporter) {
throw new Error('SMTP not configured');
}
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeName = escapeHtml(name);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: t(lang, 'email.resetPassword.subject', { appName: headerAppName }),
html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
<p>${t(lang, 'email.resetPassword.intro')}</p>
<p style="text-align:center;margin:28px 0;">
<a href="${resetUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
${t(lang, 'email.resetPassword.button')}
</a>
</p>
<p style="font-size:13px;color:#7f849c;">
${t(lang, 'email.linkHint')}<br/>
<a href="${resetUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(resetUrl)}</a>
</p>
<p style="font-size:13px;color:#7f849c;">${t(lang, 'email.resetPassword.validity')}</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.resetPassword.footer')}</p>
</div>
`,
text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.resetPassword.intro')}\n${resetUrl}\n\n${t(lang, 'email.resetPassword.validity')}\n\n- ${appName}`,
});
}
/** /**
* Send a federation meeting invitation email. * Send a federation meeting invitation email.
* @param {string} to - recipient email * @param {string} to - recipient email
@@ -112,7 +155,6 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
const safeFromUser = escapeHtml(fromUser); const safeFromUser = escapeHtml(fromUser);
const safeRoomName = escapeHtml(roomName); const safeRoomName = escapeHtml(roomName);
const safeMessage = message ? escapeHtml(message) : null; const safeMessage = message ? escapeHtml(message) : null;
const safeAppName = escapeHtml(appName);
const introHtml = t(lang, 'email.federationInvite.intro') const introHtml = t(lang, 'email.federationInvite.intro')
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`); .replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
@@ -144,6 +186,61 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
}); });
} }
/**
* Send a guest meeting invitation email with a direct join link.
* @param {string} to - recipient email
* @param {string} fromUser - sender display name
* @param {string} roomName - name of the room
* @param {string} message - optional personal message
* @param {string} joinUrl - direct guest join URL
* @param {string} appName - branding app name
* @param {string} lang - language code
*/
export async function sendGuestInviteEmail(to, fromUser, roomName, message, joinUrl, appName = 'Redlight', lang = 'en') {
if (!transporter) {
throw new Error('SMTP not configured');
}
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeFromUser = escapeHtml(fromUser);
const safeRoomName = escapeHtml(roomName);
const safeMessage = message ? escapeHtml(message) : null;
const introHtml = t(lang, 'email.guestInvite.intro')
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: t(lang, 'email.guestInvite.subject', { appName: headerAppName, fromUser: sanitizeHeaderValue(fromUser) }),
html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">Meeting Invitation</h2>
<p>${introHtml}</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 8px 0;font-size:13px;color:#7f849c;">${t(lang, 'email.guestInvite.roomLabel')}</p>
<p style="margin:0;font-size:16px;font-weight:bold;color:#cdd6f4;">${safeRoomName}</p>
${safeMessage ? `<p style="margin:12px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeMessage}&quot;</p>` : ''}
</div>
<p style="text-align:center;margin:28px 0;">
<a href="${joinUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
${t(lang, 'email.guestInvite.joinButton')}
</a>
</p>
<p style="font-size:13px;color:#7f849c;">
${t(lang, 'email.linkHint')}<br/>
<a href="${joinUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(joinUrl)}</a>
</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.guestInvite.footer')}</p>
</div>
`,
text: `${t(lang, 'email.guestInvite.intro', { fromUser })}\n${t(lang, 'email.guestInvite.roomLabel')} ${roomName}${message ? `\n"${message}"` : ''}\n\n${t(lang, 'email.guestInvite.joinButton')}: ${joinUrl}\n\n- ${appName}`,
});
}
/** /**
* Send a calendar event invitation email (federated). * Send a calendar event invitation email (federated).
*/ */
@@ -175,7 +272,7 @@ export async function sendCalendarInviteEmail(to, name, fromUser, title, startTi
<p>${introHtml}</p> <p>${introHtml}</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;"> <div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 4px 0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p> <p style="margin:0 0 4px 0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
<p style="margin:6px 0 0 0;font-size:13px;color:#a6adc8;">🕐 ${escapeHtml(formatDate(startTime))} - ${escapeHtml(formatDate(endTime))}</p> <p style="margin:6px 0 0 0;font-size:13px;color:#a6adc8;">${escapeHtml(formatDate(startTime))} - ${escapeHtml(formatDate(endTime))}</p>
${safeDesc ? `<p style="margin:10px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeDesc}&quot;</p>` : ''} ${safeDesc ? `<p style="margin:10px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeDesc}&quot;</p>` : ''}
</div> </div>
<p style="text-align:center;margin:28px 0;"> <p style="text-align:center;margin:28px 0;">
+71
View File
@@ -0,0 +1,71 @@
import { log } from './logger.js';
/**
* Recording transcription / AI summarization integration.
*
* When the per-room "recording transcript" setting is enabled, Redlight POSTs
* to ${TRANSCRIPTION_API_URL}/prompt with header `X-Api-Key: ${TRANSCRIPTION_API_KEY}`
* as soon as the "video" format of a recording becomes available (delivered via
* BBB's recording-ready callback). Leave both env vars empty to disable the
* integration entirely.
*/
const TRANSCRIPTION_API_URL = process.env.TRANSCRIPTION_API_URL || '';
const TRANSCRIPTION_API_KEY = process.env.TRANSCRIPTION_API_KEY || '';
const DEFAULT_PROMPT = 'Summarise the key decisions made in this meeting.';
export function isTranscriptionEnabled() {
return !!(TRANSCRIPTION_API_URL && TRANSCRIPTION_API_KEY);
}
/**
* Request a summary/transcript for a recording. Never throws — failures are
* logged and reported via the boolean return value.
*
* @param {object} opts
* @param {string} opts.meetingId Room UID / BBB meeting ID
* @param {string} opts.recordingId BBB record ID
* @param {string} [opts.prompt] Optional custom prompt
* @returns {Promise<boolean>} true if the request was accepted
*/
export async function requestTranscription({ meetingId, recordingId, prompt }) {
if (!isTranscriptionEnabled()) {
log.server.warn('Transcription request skipped: TRANSCRIPTION_API_URL or TRANSCRIPTION_API_KEY is not configured');
return false;
}
const base = TRANSCRIPTION_API_URL.endsWith('/') ? TRANSCRIPTION_API_URL : `${TRANSCRIPTION_API_URL}/`;
const url = new URL('prompt', base).toString();
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': TRANSCRIPTION_API_KEY,
},
body: JSON.stringify({
prompt: prompt || DEFAULT_PROMPT,
meeting_id: meetingId,
recording_id: recordingId,
}),
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
log.server.error(`Transcription request failed: status=${response.status} body=${body.slice(0, 200)}`);
return false;
}
log.server.info(`Transcription requested for recording ${recordingId} (meeting: ${meetingId})`);
return true;
} catch (err) {
// undici wraps the real network error (ECONNREFUSED, ENOTFOUND, timeout, …)
// in err.cause — surface it, "fetch failed" alone is undiagnosable.
const cause = err.cause ? ` (${err.cause.code || ''} ${err.cause.message || ''})`.trimEnd() : '';
log.server.error(`Transcription request to ${url} failed: ${err.message}${cause}`);
return false;
}
}
+20 -2
View File
@@ -1,6 +1,6 @@
{ {
"email": { "email": {
"greeting": "Hey {name} 👋", "greeting": "Hallo {name},",
"viewInvitation": "Einladung anzeigen", "viewInvitation": "Einladung anzeigen",
"invitationFooter": "Öffne den Link oben, um die Einladung anzunehmen oder abzulehnen.", "invitationFooter": "Öffne den Link oben, um die Einladung anzunehmen oder abzulehnen.",
"linkHint": "Oder kopiere diesen Link in deinen Browser:", "linkHint": "Oder kopiere diesen Link in deinen Browser:",
@@ -11,9 +11,16 @@
"validity": "Dieser Link ist 24 Stunden gültig.", "validity": "Dieser Link ist 24 Stunden gültig.",
"footer": "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren." "footer": "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren."
}, },
"resetPassword": {
"subject": "{appName} - Passwort zurücksetzen",
"intro": "Du hast angefordert, dein Passwort zurückzusetzen. Klicke auf den Button, um ein neues Passwort festzulegen:",
"button": "Passwort zurücksetzen",
"validity": "Dieser Link ist 1 Stunde gültig.",
"footer": "Falls du das nicht angefordert hast, kannst du diese E-Mail ignorieren dein Passwort bleibt unverändert."
},
"invite": { "invite": {
"subject": "{appName} - Du wurdest eingeladen", "subject": "{appName} - Du wurdest eingeladen",
"title": "Du wurdest eingeladen! 🎉", "title": "Du wurdest eingeladen",
"intro": "Du wurdest eingeladen, ein Konto auf {appName} zu erstellen.", "intro": "Du wurdest eingeladen, ein Konto auf {appName} zu erstellen.",
"prompt": "Klicke auf den Button, um dich zu registrieren:", "prompt": "Klicke auf den Button, um dich zu registrieren:",
"button": "Konto erstellen", "button": "Konto erstellen",
@@ -25,6 +32,13 @@
"intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.", "intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.",
"roomLabel": "Raum:" "roomLabel": "Raum:"
}, },
"guestInvite": {
"subject": "{appName} - Einladung zu einem Meeting",
"intro": "{fromUser} hat dich zu einem Meeting eingeladen.",
"roomLabel": "Raum:",
"joinButton": "Meeting beitreten",
"footer": "Klicke auf den Button oben, um dem Meeting beizutreten."
},
"calendarInvite": { "calendarInvite": {
"subject": "{appName} - Kalendereinladung von {fromUser}", "subject": "{appName} - Kalendereinladung von {fromUser}",
"intro": "Du hast eine Kalendereinladung von {fromUser} erhalten." "intro": "Du hast eine Kalendereinladung von {fromUser} erhalten."
@@ -35,5 +49,9 @@
"note": "Der Termin wurde automatisch aus deinem Kalender entfernt.", "note": "Der Termin wurde automatisch aus deinem Kalender entfernt.",
"footer": "Diese Nachricht wurde automatisch von {appName} versendet." "footer": "Diese Nachricht wurde automatisch von {appName} versendet."
} }
},
"bbb": {
"defaultWelcome": "Willkommen im Meeting!",
"inviteHint": "Lade weitere Teilnehmer ein, indem du diesen Link teilst:"
} }
} }
+20 -2
View File
@@ -1,6 +1,6 @@
{ {
"email": { "email": {
"greeting": "Hey {name} 👋", "greeting": "Hello {name},",
"viewInvitation": "View Invitation", "viewInvitation": "View Invitation",
"invitationFooter": "Open the link above to accept or decline the invitation.", "invitationFooter": "Open the link above to accept or decline the invitation.",
"linkHint": "Or copy this link in your browser:", "linkHint": "Or copy this link in your browser:",
@@ -11,9 +11,16 @@
"validity": "This link is valid for 24 hours.", "validity": "This link is valid for 24 hours.",
"footer": "If you didn't register, please ignore this email." "footer": "If you didn't register, please ignore this email."
}, },
"resetPassword": {
"subject": "{appName} - Reset your password",
"intro": "You requested to reset your password. Click the button below to set a new one:",
"button": "Reset Password",
"validity": "This link is valid for 1 hour.",
"footer": "If you didn't request this, you can safely ignore this email — your password will remain unchanged."
},
"invite": { "invite": {
"subject": "{appName} - You've been invited", "subject": "{appName} - You've been invited",
"title": "You've been invited! 🎉", "title": "You have been invited",
"intro": "You have been invited to create an account on {appName}.", "intro": "You have been invited to create an account on {appName}.",
"prompt": "Click the button below to register:", "prompt": "Click the button below to register:",
"button": "Create Account", "button": "Create Account",
@@ -25,6 +32,13 @@
"intro": "You have received a meeting invitation from {fromUser}.", "intro": "You have received a meeting invitation from {fromUser}.",
"roomLabel": "Room:" "roomLabel": "Room:"
}, },
"guestInvite": {
"subject": "{appName} - You're invited to a meeting",
"intro": "{fromUser} has invited you to a meeting.",
"roomLabel": "Room:",
"joinButton": "Join Meeting",
"footer": "Click the button above to join the meeting."
},
"calendarInvite": { "calendarInvite": {
"subject": "{appName} - Calendar invitation from {fromUser}", "subject": "{appName} - Calendar invitation from {fromUser}",
"intro": "You have received a calendar invitation from {fromUser}." "intro": "You have received a calendar invitation from {fromUser}."
@@ -35,5 +49,9 @@
"note": "The event has been automatically removed from your calendar.", "note": "The event has been automatically removed from your calendar.",
"footer": "This message was sent automatically by {appName}." "footer": "This message was sent automatically by {appName}."
} }
},
"bbb": {
"defaultWelcome": "Welcome to the meeting!",
"inviteHint": "To invite other participants, share this link:"
} }
} }
+11 -3
View File
@@ -51,7 +51,14 @@ const corsOptions = process.env.APP_URL
? { origin: process.env.APP_URL, credentials: true } ? { origin: process.env.APP_URL, credentials: true }
: { origin: false }; : { origin: false };
app.use(cors(corsOptions)); app.use(cors(corsOptions));
app.use(express.json({ limit: '100kb' })); // Global JSON body limit kept tight as a hardening measure. The BBB learning
// analytics callback can send much larger payloads, so it is excluded here and
// gets its own, more generous limit on the route itself (see routes/analytics.js).
const jsonParser = express.json({ limit: '100kb' });
app.use((req, res, next) => {
if (req.path.startsWith('/api/analytics/callback/')) return next();
return jsonParser(req, res, next);
});
// Request/Response logging (filters sensitive fields) // Request/Response logging (filters sensitive fields)
app.use(requestResponseLogger); app.use(requestResponseLogger);
@@ -60,9 +67,10 @@ async function start() {
await initDatabase(); await initDatabase();
initMailer(); initMailer();
// Serve uploaded files (branding only — avatars served via /api/auth/avatar/:filename, presentations require auth) // Serve uploaded files (avatars are served via /api/auth/avatar/:filename)
const uploadsPath = path.join(__dirname, '..', 'uploads'); const uploadsPath = path.join(__dirname, '..', 'uploads');
app.use('/uploads/branding', express.static(path.join(uploadsPath, 'branding'))); app.use('/uploads/branding', express.static(path.join(uploadsPath, 'branding')));
// Presentations are served via /api/rooms/presentations/:filename?token=… (HMAC-protected)
// API Routes // API Routes
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
@@ -99,7 +107,7 @@ async function start() {
// Serve static files in production // Serve static files in production
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, '..', 'dist'))); app.use(express.static(path.join(__dirname, '..', 'dist')));
app.get('*', (req, res) => { app.get('/*splat', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'dist', 'index.html')); res.sendFile(path.join(__dirname, '..', 'dist', 'index.html'));
}); });
} }
+33 -3
View File
@@ -4,6 +4,7 @@ import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js'; import { getDb } from '../config/database.js';
import { authenticateToken, requireAdmin, getBaseUrl } from '../middleware/auth.js'; import { authenticateToken, requireAdmin, getBaseUrl } from '../middleware/auth.js';
import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js'; import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js';
import { getAppName } from '../config/appName.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
import { import {
getOAuthConfig, getOAuthConfig,
@@ -38,6 +39,9 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
if (password.length < 8) { if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters long' }); return res.status(400).json({ error: 'Password must be at least 8 characters long' });
} }
if (password.length > 64) {
return res.status(400).json({ error: 'Password must not exceed 64 characters' });
}
// M9: email format validation // M9: email format validation
if (!EMAIL_RE.test(email)) { if (!EMAIL_RE.test(email)) {
@@ -160,6 +164,9 @@ router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, r
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) { if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters long' }); return res.status(400).json({ error: 'Password must be at least 8 characters long' });
} }
if (newPassword.length > 64) {
return res.status(400).json({ error: 'Password must not exceed 64 characters' });
}
const db = getDb(); const db = getDb();
const hash = await bcrypt.hash(newPassword, 12); const hash = await bcrypt.hash(newPassword, 12);
@@ -211,9 +218,8 @@ router.post('/invites', authenticateToken, requireAdmin, async (req, res) => {
const baseUrl = getBaseUrl(req); const baseUrl = getBaseUrl(req);
const inviteUrl = `${baseUrl}/register?invite=${token}`; const inviteUrl = `${baseUrl}/register?invite=${token}`;
// Load app name // Load configured app name (admin setting → env → default)
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'app_name'"); const appName = await getAppName();
const appName = brandingSetting?.value || 'Redlight';
if (isMailerConfigured()) { if (isMailerConfigured()) {
try { try {
@@ -362,4 +368,28 @@ router.delete('/oauth', authenticateToken, requireAdmin, async (req, res) => {
} }
}); });
// ── Room Management (admin only) ────────────────────────────────────────────
// GET /api/admin/rooms - List all rooms with owner info
router.get('/rooms', authenticateToken, requireAdmin, async (req, res) => {
try {
const db = getDb();
const rooms = await db.all(`
SELECT r.id, r.uid, r.name, r.user_id, r.max_participants, r.access_code,
r.mute_on_join, r.record_meeting, r.guest_access, r.presentation_file,
r.created_at, r.updated_at,
COALESCE(NULLIF(u.display_name,''), u.name) as owner_name,
u.email as owner_email,
(SELECT COUNT(*) FROM room_shares rs WHERE rs.room_id = r.id) as share_count
FROM rooms r
JOIN users u ON r.user_id = u.id
ORDER BY r.created_at DESC
`);
res.json({ rooms });
} catch (err) {
log.admin.error(`List rooms error: ${err.message}`);
res.status(500).json({ error: 'Rooms could not be loaded' });
}
});
export default router; export default router;
+36 -15
View File
@@ -1,4 +1,4 @@
import { Router } from 'express'; import { Router, json } from 'express';
import crypto from 'crypto'; import crypto from 'crypto';
import ExcelJS from 'exceljs'; import ExcelJS from 'exceljs';
import PDFDocument from 'pdfkit'; import PDFDocument from 'pdfkit';
@@ -9,18 +9,25 @@ import { getAnalyticsToken } from '../config/bbb.js';
const router = Router(); const router = Router();
// POST /api/analytics/callback/:uid?token=... - BBB Learning Analytics callback (token-secured) // Token check runs BEFORE the 5mb body parser so unauthenticated callers
router.post('/callback/:uid', async (req, res) => { // cannot make the server buffer large payloads.
try { // Constant-time comparison to prevent timing attacks. Reject non-string tokens
// (e.g. ?token=a&token=b would yield an array and crash Buffer.from).
function verifyCallbackToken(req, res, next) {
const { token } = req.query; const { token } = req.query;
const expectedToken = getAnalyticsToken(req.params.uid); const expectedToken = getAnalyticsToken(req.params.uid);
if (typeof token !== 'string' || token.length !== expectedToken.length ||
// Constant-time comparison to prevent timing attacks
if (!token || token.length !== expectedToken.length ||
!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) { !crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) {
return res.status(403).json({ error: 'Invalid token' }); return res.status(403).json({ error: 'Invalid token' });
} }
next();
}
// POST /api/analytics/callback/:uid?token=... - BBB Learning Analytics callback (token-secured)
// Excluded from the global 100kb body limit (see index.js): learning analytics
// payloads for large meetings can be several MB.
router.post('/callback/:uid', verifyCallbackToken, json({ limit: '5mb' }), async (req, res) => {
try {
const db = getDb(); const db = getDb();
const room = await db.get('SELECT id, uid, learning_analytics FROM rooms WHERE uid = ?', [req.params.uid]); const room = await db.get('SELECT id, uid, learning_analytics FROM rooms WHERE uid = ?', [req.params.uid]);
@@ -216,15 +223,20 @@ router.get('/:id/export/:format', authenticateToken, async (req, res) => {
const safeName = (entry.meeting_name || 'analytics').replace(/[^a-zA-Z0-9_-]/g, '_'); const safeName = (entry.meeting_name || 'analytics').replace(/[^a-zA-Z0-9_-]/g, '_');
if (format === 'csv') { if (format === 'csv') {
// Prefix-escape values that start with a formula trigger character so that
// Excel/LibreOffice do not evaluate them as formulas (CSV injection).
const escapeCsv = (val) => {
if (val === null || val === undefined) return '';
let s = String(val);
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
if (s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r')) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
};
const header = COLUMNS.map(c => c.header).join(','); const header = COLUMNS.map(c => c.header).join(',');
const csvRows = rows.map(r => const csvRows = rows.map(r =>
COLUMNS.map(c => { COLUMNS.map(c => escapeCsv(r[c.key])).join(',')
const val = r[c.key];
if (typeof val === 'string' && (val.includes(',') || val.includes('"') || val.includes('\n'))) {
return '"' + val.replace(/"/g, '""') + '"';
}
return val;
}).join(',')
); );
const csv = [header, ...csvRows].join('\n'); const csv = [header, ...csvRows].join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Type', 'text/csv; charset=utf-8');
@@ -233,10 +245,19 @@ router.get('/:id/export/:format', authenticateToken, async (req, res) => {
} }
if (format === 'xlsx') { if (format === 'xlsx') {
// Prefix-escape strings that would otherwise be evaluated as formulas.
const sanitizeXlsx = (r) => {
const out = {};
for (const k of Object.keys(r)) {
const v = r[k];
out[k] = (typeof v === 'string' && /^[=+\-@\t\r]/.test(v)) ? "'" + v : v;
}
return out;
};
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Analytics'); const sheet = workbook.addWorksheet('Analytics');
sheet.columns = COLUMNS; sheet.columns = COLUMNS;
rows.forEach(r => sheet.addRow(r)); rows.forEach(r => sheet.addRow(sanitizeXlsx(r)));
// Style header row // Style header row
sheet.getRow(1).font = { bold: true }; sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
+218 -24
View File
@@ -2,17 +2,19 @@ import { Router } from 'express';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { rateLimit } from 'express-rate-limit'; import { rateLimit, MemoryStore } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis'; import { RedisStore } from 'rate-limit-redis';
import * as OTPAuth from 'otpauth'; import * as OTPAuth from 'otpauth';
import { getDb } from '../config/database.js'; import { getDb } from '../config/database.js';
import redis from '../config/redis.js'; import redis from '../config/redis.js';
import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js'; import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js';
import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js'; import { isMailerConfigured, sendVerificationEmail, sendPasswordResetEmail } from '../config/mailer.js';
import { getOAuthConfig, discoverOIDC } from '../config/oauth.js'; import { getOAuthConfig, discoverOIDC } from '../config/oauth.js';
import { getAppName } from '../config/appName.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
@@ -22,15 +24,65 @@ if (!process.env.JWT_SECRET) {
const JWT_SECRET = process.env.JWT_SECRET; const JWT_SECRET = process.env.JWT_SECRET;
// ── Rate Limiting ──────────────────────────────────────────────────────────── // ── Rate Limiting ────────────────────────────────────────────────────────────
function makeRedisStore(prefix) { // Shared Redis store with in-memory fallback. Without the fallback, a dead
// Redis connection makes every rate-limited endpoint (incl. login) return 500 —
// rate limiting must degrade, not take auth down with it.
class ResilientStore {
constructor(prefix) {
try { try {
return new RedisStore({ this.redisStore = new RedisStore({
sendCommand: (...args) => redis.call(...args), sendCommand: (...args) => redis.call(...args),
prefix, prefix,
}); });
} catch { } catch {
return undefined; // falls back to in-memory if Redis unavailable this.redisStore = null;
} }
this.memoryStore = new MemoryStore();
}
init(options) {
try {
// RedisStore.init returns the SCRIPT LOAD promise; catch it so a dead
// Redis connection surfaces as a warning instead of an unhandled
// rejection that kills the process.
const initResult = this.redisStore?.init?.(options);
if (typeof initResult?.catch === 'function') {
initResult.catch(err => log.auth.warn(`Rate-limit Redis init failed: ${err.message}`));
}
} catch (err) {
log.auth.warn(`Rate-limit Redis init failed: ${err.message}`);
}
this.memoryStore.init(options);
}
async increment(key) {
if (this.redisStore && redis.status === 'ready') {
try {
return await this.redisStore.increment(key);
} catch (err) {
log.auth.warn(`Rate-limit Redis store failed, using in-memory fallback: ${err.message}`);
}
}
return this.memoryStore.increment(key);
}
async decrement(key) {
if (this.redisStore && redis.status === 'ready') {
try {
return await this.redisStore.decrement(key);
} catch { /* fall through to memory */ }
}
return this.memoryStore.decrement(key);
}
async resetKey(key) {
if (this.redisStore && redis.status === 'ready') {
try {
return await this.redisStore.resetKey(key);
} catch { /* fall through to memory */ }
}
return this.memoryStore.resetKey(key);
}
}
function makeRedisStore(prefix) {
return new ResilientStore(prefix);
} }
// ── Validation helpers ───────────────────────────────────────────────────── // ── Validation helpers ─────────────────────────────────────────────────────
@@ -43,6 +95,13 @@ const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;
const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/; const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
const MIN_PASSWORD_LENGTH = 8; const MIN_PASSWORD_LENGTH = 8;
// bcrypt only uses the first 72 bytes; cap input to prevent CPU-DoS on hashing.
const MAX_PASSWORD_LENGTH = 64;
// Pre-computed bcrypt hash of a random string used as a dummy comparison
// target when the requested account does not exist. Keeps login timing
// roughly constant so we do not leak whether an email is registered.
const DUMMY_BCRYPT_HASH = bcrypt.hashSync('dummy-password-for-timing-' + Math.random(), 12);
// ── Rate Limiters ──────────────────────────────────────────────────────────── // ── Rate Limiters ────────────────────────────────────────────────────────────
const loginLimiter = rateLimit({ const loginLimiter = rateLimit({
@@ -109,6 +168,26 @@ const twoFaLimiter = rateLimit({
store: makeRedisStore('rl:2fa:'), store: makeRedisStore('rl:2fa:'),
}); });
// Rate limit forgot-password to prevent SMTP abuse / email enumeration spam
const forgotPasswordLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests. Please try again later.' },
store: makeRedisStore('rl:forgot:'),
});
// Rate limit reset-password submissions to slow token brute-forcing
const resetPasswordLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many attempts. Please try again later.' },
store: makeRedisStore('rl:reset:'),
});
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars'); const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars');
@@ -168,8 +247,12 @@ router.post('/register', registerLimiter, async (req, res) => {
if (password.length < MIN_PASSWORD_LENGTH) { if (password.length < MIN_PASSWORD_LENGTH) {
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` }); return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
} }
if (password.length > MAX_PASSWORD_LENGTH) {
return res.status(400).json({ error: `Password must not exceed ${MAX_PASSWORD_LENGTH} characters` });
}
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]); // Emails are stored lowercased, so compare lowercased to catch case-variant duplicates
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existing) { if (existing) {
return res.status(409).json({ error: 'Email is already in use' }); return res.status(409).json({ error: 'Email is already in use' });
} }
@@ -203,12 +286,8 @@ router.post('/register', registerLimiter, async (req, res) => {
const baseUrl = getBaseUrl(req); const baseUrl = getBaseUrl(req);
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`; const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
// Load app name from branding settings // Load configured app name (admin setting → env → default)
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'"); const appName = await getAppName();
let appName = 'Redlight';
if (brandingSetting?.value) {
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
}
try { try {
await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName, 'en'); await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName, 'en');
@@ -317,11 +396,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
const baseUrl = getBaseUrl(req); const baseUrl = getBaseUrl(req);
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`; const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'"); const appName = await getAppName();
let appName = 'Redlight';
if (brandingSetting?.value) {
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
}
try { try {
await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName, user.language || 'en'); await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName, user.language || 'en');
@@ -337,6 +412,115 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
} }
}); });
// Hash a reset token for storage/lookup (never store the raw token)
function hashResetToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
// POST /api/auth/forgot-password
router.post('/forgot-password', forgotPasswordLimiter, async (req, res) => {
try {
const { email } = req.body;
if (!email || typeof email !== 'string' || !EMAIL_RE.test(email)) {
return res.status(400).json({ error: 'A valid email address is required' });
}
// Generic response used in every branch so we never reveal whether an
// account exists, whether it uses SSO, or whether SMTP is configured.
const genericResponse = { message: 'If an account exists for that address, a password reset email has been sent.' };
if (!isMailerConfigured()) {
return res.json(genericResponse);
}
const db = getDb();
const user = await db.get('SELECT id, name, display_name, email, language, oauth_provider, reset_requested_at FROM users WHERE email = ?', [email.toLowerCase()]);
// No account, or an SSO-only account that has no local password to reset
if (!user || user.oauth_provider) {
return res.json(genericResponse);
}
// Server-side 60s throttle per account to limit repeated emails
if (user.reset_requested_at) {
const secondsAgo = (Date.now() - new Date(user.reset_requested_at).getTime()) / 1000;
if (secondsAgo < 60) {
return res.json(genericResponse);
}
}
const resetToken = uuidv4();
const tokenHash = hashResetToken(resetToken);
const expires = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
const now = new Date().toISOString();
await db.run(
'UPDATE users SET reset_token_hash = ?, reset_token_expires = ?, reset_requested_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[tokenHash, expires, now, user.id]
);
const baseUrl = getBaseUrl(req);
const resetUrl = `${baseUrl}/reset-password?token=${resetToken}`;
const appName = await getAppName();
try {
await sendPasswordResetEmail(email.toLowerCase(), user.display_name || user.name, resetUrl, appName, user.language || 'en');
} catch (mailErr) {
log.auth.error(`Password reset mail failed: ${mailErr.message}`);
// Still return generic success — don't leak SMTP state to the client
}
res.json(genericResponse);
} catch (err) {
log.auth.error(`Forgot password error: ${err.message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/auth/reset-password
router.post('/reset-password', resetPasswordLimiter, async (req, res) => {
try {
const { token, password } = req.body;
if (!token || typeof token !== 'string') {
return res.status(400).json({ error: 'Invalid or expired reset link.' });
}
if (!password || typeof password !== 'string') {
return res.status(400).json({ error: 'A new password is required' });
}
if (password.length < MIN_PASSWORD_LENGTH) {
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
}
if (password.length > MAX_PASSWORD_LENGTH) {
return res.status(400).json({ error: `Password must not exceed ${MAX_PASSWORD_LENGTH} characters` });
}
const db = getDb();
const tokenHash = hashResetToken(token);
const user = await db.get('SELECT id, reset_token_expires FROM users WHERE reset_token_hash = ?', [tokenHash]);
if (!user) {
return res.status(400).json({ error: 'Invalid or expired reset link.' });
}
if (!user.reset_token_expires || new Date(user.reset_token_expires) < new Date()) {
return res.status(400).json({ error: 'Invalid or expired reset link.' });
}
const hash = await bcrypt.hash(password, 12);
// Set the new password, verify the email (proves mailbox ownership), and
// clear the reset token so it can't be reused.
await db.run(
'UPDATE users SET password_hash = ?, email_verified = 1, reset_token_hash = NULL, reset_token_expires = NULL, reset_requested_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[hash, user.id]
);
res.json({ message: 'Password has been reset successfully. You can now sign in.' });
} catch (err) {
log.auth.error(`Reset password error: ${err.message}`);
res.status(500).json({ error: 'Password could not be reset' });
}
});
// POST /api/auth/login // POST /api/auth/login
router.post('/login', loginLimiter, async (req, res) => { router.post('/login', loginLimiter, async (req, res) => {
try { try {
@@ -351,10 +535,18 @@ router.post('/login', loginLimiter, async (req, res) => {
return res.status(401).json({ error: 'Invalid credentials' }); return res.status(401).json({ error: 'Invalid credentials' });
} }
// Cap password length to keep bcrypt CPU work bounded.
if (typeof password !== 'string' || password.length > MAX_PASSWORD_LENGTH) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const db = getDb(); const db = getDb();
const user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]); const user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user || !bcrypt.compareSync(password, user.password_hash)) { // Always run bcrypt against either the real hash or a dummy hash so login
// timing does not reveal whether the email is registered.
const passwordOk = bcrypt.compareSync(password, user?.password_hash || DUMMY_BCRYPT_HASH);
if (!user || !passwordOk) {
return res.status(401).json({ error: 'Invalid credentials' }); return res.status(401).json({ error: 'Invalid credentials' });
} }
@@ -560,6 +752,9 @@ router.put('/password', authenticateToken, passwordLimiter, async (req, res) =>
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') { if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
return res.status(400).json({ error: 'Invalid input' }); return res.status(400).json({ error: 'Invalid input' });
} }
if (currentPassword.length > MAX_PASSWORD_LENGTH || newPassword.length > MAX_PASSWORD_LENGTH) {
return res.status(400).json({ error: `Password must not exceed ${MAX_PASSWORD_LENGTH} characters` });
}
const db = getDb(); const db = getDb();
@@ -753,12 +948,8 @@ router.post('/2fa/setup', authenticateToken, twoFaLimiter, async (req, res) => {
const secret = new OTPAuth.Secret({ size: 20 }); const secret = new OTPAuth.Secret({ size: 20 });
// Load app name from branding settings // Use the configured app name as the TOTP issuer (admin setting → env → default)
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'"); const issuer = await getAppName();
let issuer = 'Redlight';
if (brandingSetting?.value) {
try { issuer = JSON.parse(brandingSetting.value).appName || issuer; } catch {}
}
const totp = new OTPAuth.TOTP({ const totp = new OTPAuth.TOTP({
issuer, issuer,
@@ -823,6 +1014,9 @@ router.post('/2fa/disable', authenticateToken, twoFaLimiter, async (req, res) =>
if (!password || !code) { if (!password || !code) {
return res.status(400).json({ error: 'Password and code are required' }); return res.status(400).json({ error: 'Password and code are required' });
} }
if (typeof password !== 'string' || password.length > MAX_PASSWORD_LENGTH) {
return res.status(400).json({ error: 'Invalid password' });
}
const db = getDb(); const db = getDb();
const user = await db.get('SELECT password_hash, totp_secret, totp_enabled FROM users WHERE id = ?', [req.user.id]); const user = await db.get('SELECT password_hash, totp_secret, totp_enabled FROM users WHERE id = ?', [req.user.id]);
+2
View File
@@ -7,6 +7,7 @@ import { getDb } from '../config/database.js';
import { authenticateToken, requireAdmin } from '../middleware/auth.js'; import { authenticateToken, requireAdmin } from '../middleware/auth.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
import { getOAuthConfig } from '../config/oauth.js'; import { getOAuthConfig } from '../config/oauth.js';
import { isTranscriptionEnabled } from '../config/transcription.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -121,6 +122,7 @@ router.get('/', async (req, res) => {
oauthEnabled, oauthEnabled,
oauthDisplayName, oauthDisplayName,
hideAppName: hideAppName === 'true', hideAppName: hideAppName === 'true',
transcriptionEnabled: isTranscriptionEnabled(),
}); });
} catch (err) { } catch (err) {
log.branding.error('Get branding error:', err); log.branding.error('Get branding error:', err);
+18 -4
View File
@@ -12,12 +12,25 @@
import { Router, text } from 'express'; import { Router, text } from 'express';
import crypto from 'crypto'; import crypto from 'crypto';
import { rateLimit } from 'express-rate-limit';
import { getDb } from '../config/database.js'; import { getDb } from '../config/database.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
import { getBaseUrl } from '../middleware/auth.js'; import { getBaseUrl } from '../middleware/auth.js';
const router = Router(); const router = Router();
// Brute-force protection for the Basic-Auth tokens: only failed requests count
// (skipSuccessfulRequests), so legitimate sync clients polling frequently are
// not throttled.
router.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
skipSuccessfulRequests: true,
standardHeaders: true,
legacyHeaders: false,
message: 'Too many failed CalDAV requests. Please try again later.',
}));
// ── Body parsing for XML and iCalendar payloads ──────────────────────────── // ── Body parsing for XML and iCalendar payloads ────────────────────────────
router.use(text({ type: ['application/xml', 'text/xml', 'text/calendar', 'application/octet-stream'] })); router.use(text({ type: ['application/xml', 'text/xml', 'text/calendar', 'application/octet-stream'] }));
@@ -194,9 +207,10 @@ async function caldavAuth(req, res, next) {
const token = decoded.slice(colonIdx + 1); const token = decoded.slice(colonIdx + 1);
const db = getDb(); const db = getDb();
// Emails are stored lowercased
const user = await db.get( const user = await db.get(
'SELECT id, name, display_name, email FROM users WHERE email = ?', 'SELECT id, name, display_name, email FROM users WHERE email = ?',
[email], [email.toLowerCase()],
); );
if (!user) { if (!user) {
res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"'); res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"');
@@ -243,7 +257,7 @@ function setDAVHeaders(res) {
// ── CalDAV username authorization ────────────────────────────────────────── // ── CalDAV username authorization ──────────────────────────────────────────
// Ensures the :username param matches the authenticated user's email // Ensures the :username param matches the authenticated user's email
function validateCalDAVUser(req, res, next) { function validateCalDAVUser(req, res, next) {
if (req.params.username && decodeURIComponent(req.params.username) !== req.caldavUser.email) { if (req.params.username && decodeURIComponent(req.params.username).toLowerCase() !== req.caldavUser.email) {
return res.status(403).end(); return res.status(403).end();
} }
next(); next();
@@ -278,7 +292,7 @@ ${propXml}
} }
// ── OPTIONS ──────────────────────────────────────────────────────────────── // ── OPTIONS ────────────────────────────────────────────────────────────────
router.options('*', (req, res) => { router.options('/*splat', (req, res) => {
setDAVHeaders(res); setDAVHeaders(res);
res.status(200).end(); res.status(200).end();
}); });
@@ -529,7 +543,7 @@ router.delete('/:username/calendar/:filename', caldavAuth, validateCalDAVUser, a
}); });
// ── Fallback ─────────────────────────────────────────────────────────────── // ── Fallback ───────────────────────────────────────────────────────────────
router.all('*', caldavAuth, (req, res) => { router.all('/*splat', caldavAuth, (req, res) => {
setDAVHeaders(res); setDAVHeaders(res);
res.status(405).end(); res.status(405).end();
}); });
+7 -4
View File
@@ -4,6 +4,7 @@ import { getDb } from '../config/database.js';
import { authenticateToken, getBaseUrl } from '../middleware/auth.js'; import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
import { sendCalendarInviteEmail } from '../config/mailer.js'; import { sendCalendarInviteEmail } from '../config/mailer.js';
import { getAppName } from '../config/appName.js';
import { import {
isFederationEnabled, isFederationEnabled,
getFederationDomain, getFederationDomain,
@@ -219,7 +220,7 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
end_time = COALESCE(?, end_time), end_time = COALESCE(?, end_time),
room_uid = ?, room_uid = ?,
color = COALESCE(?, color), color = COALESCE(?, color),
reminder_minutes = COALESCE(?, reminder_minutes), reminder_minutes = ?,
reminder_sent_at = ${resetReminder ? 'NULL' : 'reminder_sent_at'}, reminder_sent_at = ${resetReminder ? 'NULL' : 'reminder_sent_at'},
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
@@ -230,7 +231,9 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
end_time || null, end_time || null,
room_uid !== undefined ? (room_uid || null) : event.room_uid, room_uid !== undefined ? (room_uid || null) : event.room_uid,
color || null, color || null,
validReminder !== undefined ? validReminder : null, // Not in payload → keep existing; present (incl. null) → set/clear directly.
// COALESCE can't be used here: it would treat an explicit null as "keep".
validReminder !== undefined ? validReminder : event.reminder_minutes,
req.params.id, req.params.id,
]); ]);
@@ -323,7 +326,7 @@ router.post('/events/:id/share', authenticateToken, async (req, res) => {
if (targetUser?.email) { if (targetUser?.email) {
const appUrl = getBaseUrl(req); const appUrl = getBaseUrl(req);
const inboxUrl = `${appUrl}/federation/inbox`; const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight'; const appName = await getAppName();
const senderUser = await db.get('SELECT name, display_name FROM users WHERE id = ?', [req.user.id]); const senderUser = await db.get('SELECT name, display_name FROM users WHERE id = ?', [req.user.id]);
const fromDisplay = (senderUser?.display_name && senderUser.display_name !== '') ? senderUser.display_name : (senderUser?.name || req.user.name); const fromDisplay = (senderUser?.display_name && senderUser.display_name !== '') ? senderUser.display_name : (senderUser?.name || req.user.name);
sendCalendarInviteEmail( sendCalendarInviteEmail(
@@ -645,7 +648,7 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as
if (targetUser.email) { if (targetUser.email) {
const appUrl = getBaseUrl(req); const appUrl = getBaseUrl(req);
const inboxUrl = `${appUrl}/federation/inbox`; const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight'; const appName = await getAppName();
sendCalendarInviteEmail( sendCalendarInviteEmail(
targetUser.email, targetUser.name, from_user, targetUser.email, targetUser.name, from_user,
title, start_time, end_time, description || null, title, start_time, end_time, description || null,
+5 -4
View File
@@ -6,6 +6,7 @@ import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js'; import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js'; import { createNotification } from '../config/notifications.js';
import { getAppName } from '../config/appName.js';
// M13: rate limit the unauthenticated federation receive endpoint // M13: rate limit the unauthenticated federation receive endpoint
const federationReceiveLimiter = rateLimit({ const federationReceiveLimiter = rateLimit({
@@ -40,7 +41,7 @@ export function wellKnownHandler(req, res) {
federation_api: '/api/federation', federation_api: '/api/federation',
public_key: getPublicKey(), public_key: getPublicKey(),
software: 'Redlight', software: 'Redlight',
version: '2.1.2', version: '2.4.1',
}); });
} }
@@ -198,7 +199,7 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
// Look up user by name (case-insensitive) // Look up user by name (case-insensitive)
const targetUser = await db.get( const targetUser = await db.get(
'SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)', 'SELECT id, name, email, language FROM users WHERE LOWER(name) = LOWER(?)',
[username] [username]
); );
@@ -238,7 +239,7 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
if (targetUser.email) { if (targetUser.email) {
const appUrl = getBaseUrl(req); const appUrl = getBaseUrl(req);
const inboxUrl = `${appUrl}/federation/inbox`; const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight'; const appName = await getAppName();
sendFederationInviteEmail( sendFederationInviteEmail(
targetUser.email, targetUser.name, from_user, targetUser.email, targetUser.name, from_user,
room_name, message || null, inboxUrl, appName, targetUser.language || 'en' room_name, message || null, inboxUrl, appName, targetUser.language || 'en'
@@ -627,7 +628,7 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res
// Notify affected users by email (fire-and-forget) // Notify affected users by email (fire-and-forget)
if (affectedUsers.length > 0) { if (affectedUsers.length > 0) {
const appName = process.env.APP_NAME || 'Redlight'; const appName = await getAppName();
for (const u of affectedUsers) { for (const u of affectedUsers) {
sendCalendarEventDeletedEmail(u.email, u.name, u.from_user, u.title, appName, u.language || 'en') sendCalendarEventDeletedEmail(u.email, u.name, u.from_user, u.title, appName, u.language || 'en')
.catch(mailErr => { .catch(mailErr => {
+7 -1
View File
@@ -200,7 +200,13 @@ router.get('/callback', callbackLimiter, async (req, res) => {
); );
if (user) { if (user) {
// Link OAuth to existing account // Only auto-link to an existing local account if the IdP has actually
// verified the email. Otherwise an attacker who registers at the IdP
// with someone else's email could take over the local account.
if (userInfo.email_verified !== true) {
log.auth.warn(`OAuth account-linking blocked: provider did not assert email_verified=true for ${email}`);
return errorRedirect('Your OAuth provider has not verified this email address. Please verify it with the provider before logging in here.');
}
await db.run( await db.run(
'UPDATE users SET oauth_provider = ?, oauth_provider_id = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', 'UPDATE users SET oauth_provider = ?, oauth_provider_id = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
['oidc', sub, user.id], ['oidc', sub, user.id],
+263 -62
View File
@@ -1,7 +1,9 @@
import { Router } from 'express'; import { Router, urlencoded } from 'express';
import jwt from 'jsonwebtoken';
import { authenticateToken } from '../middleware/auth.js'; import { authenticateToken } from '../middleware/auth.js';
import { getDb } from '../config/database.js'; import { getDb } from '../config/database.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
import { isTranscriptionEnabled, requestTranscription } from '../config/transcription.js';
import { import {
getRecordings, getRecordings,
getRecordingByRecordId, getRecordingByRecordId,
@@ -9,8 +11,234 @@ import {
publishRecording, publishRecording,
} from '../config/bbb.js'; } from '../config/bbb.js';
const BBB_SECRET = process.env.BBB_SECRET || '';
const router = Router(); const router = Router();
/**
* Format a raw BBB recording into a normalised object.
*/
function formatBbbRecording(rec, fallbackName) {
const playback = rec.playback?.format;
let formats = [];
if (playback) {
formats = Array.isArray(playback) ? playback : [playback];
}
return {
recordID: rec.recordID,
meetingID: rec.meetingID,
name: rec.name || fallbackName || 'Recording',
state: rec.state,
published: rec.published === 'true',
startTime: rec.startTime,
endTime: rec.endTime,
participants: rec.participants,
size: rec.size,
formats: formats.map(f => ({
type: f.type,
url: f.url,
length: f.length,
size: f.size,
})),
metadata: rec.metadata || {},
};
}
/**
* Format a DB row into the same shape the frontend expects.
*/
function formatDbRecording(row) {
return {
recordID: row.record_id,
meetingID: row.meeting_id,
name: row.name || 'Recording',
state: row.state,
published: row.published === 1,
startTime: row.start_time,
endTime: row.end_time,
participants: row.participants,
size: row.size,
formats: JSON.parse(row.formats || '[]'),
metadata: JSON.parse(row.metadata || '{}'),
fromCache: true,
};
}
/**
* Upsert a single formatted recording into the DB.
* - If the record_id already exists AND is the same recording, merge new formats.
* - If it's a new recording or a different one with the same record_id, overwrite.
*/
async function upsertRecording(db, rec) {
const existing = await db.get('SELECT * FROM recordings WHERE record_id = ?', [rec.recordID]);
const formatsJson = JSON.stringify(rec.formats);
const metadataJson = JSON.stringify(rec.metadata || {});
if (existing) {
// Verify it's still the same recording (same startTime)
if (String(rec.startTime) === String(existing.start_time)) {
// Merge formats: keep existing formats, add any new types
const existingFormats = JSON.parse(existing.formats || '[]');
const existingTypes = new Set(existingFormats.map(f => f.type));
const mergedFormats = [...existingFormats];
for (const f of rec.formats) {
if (existingTypes.has(f.type)) {
// Update URL for existing format type (server may have changed)
const idx = mergedFormats.findIndex(ef => ef.type === f.type);
if (idx !== -1) mergedFormats[idx] = f;
} else {
// New format type added
mergedFormats.push(f);
}
}
await db.run(
`UPDATE recordings SET name = ?, state = ?, published = ?, end_time = ?,
participants = ?, size = ?, formats = ?, metadata = ?, updated_at = CURRENT_TIMESTAMP
WHERE record_id = ?`,
[rec.name, rec.state, rec.published ? 1 : 0, rec.endTime,
rec.participants, rec.size, JSON.stringify(mergedFormats), metadataJson,
rec.recordID]
);
} else {
// Different recording with same record_id overwrite completely
await db.run(
`UPDATE recordings SET meeting_id = ?, name = ?, state = ?, published = ?,
start_time = ?, end_time = ?, participants = ?, size = ?, formats = ?,
metadata = ?, updated_at = CURRENT_TIMESTAMP
WHERE record_id = ?`,
[rec.meetingID, rec.name, rec.state, rec.published ? 1 : 0,
rec.startTime, rec.endTime, rec.participants, rec.size,
formatsJson, metadataJson, rec.recordID]
);
}
} else {
// Completely new recording insert
await db.run(
`INSERT INTO recordings (record_id, meeting_id, name, state, published, start_time,
end_time, participants, size, formats, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[rec.recordID, rec.meetingID, rec.name, rec.state, rec.published ? 1 : 0,
rec.startTime, rec.endTime, rec.participants, rec.size,
formatsJson, metadataJson]
);
}
}
/**
* Core logic: fetch recordings from BBB, sync with DB, fall back to DB on error.
* Returns an array of formatted recordings.
*/
async function fetchAndSyncRecordings(meetingID, fallbackName) {
const db = getDb();
let bbbRecordings = null;
try {
const raw = await getRecordings(meetingID || undefined);
bbbRecordings = raw.map(rec => formatBbbRecording(rec, fallbackName));
} catch (err) {
log.recordings.warn(`BBB API unreachable, falling back to cached recordings: ${err.message}`);
}
if (bbbRecordings !== null) {
// BBB API was reachable sync each recording into the DB
for (const rec of bbbRecordings) {
try {
await upsertRecording(db, rec);
} catch (err) {
log.recordings.error(`Failed to cache recording ${rec.recordID}: ${err.message}`);
}
}
// For recordings in DB that were NOT returned by BBB (possibly deleted on server
// or server changed), keep them in DB with their cached URLs don't delete.
// However, we return the BBB result merged with any DB-only recordings.
const bbbIds = new Set(bbbRecordings.map(r => r.recordID));
const params = meetingID ? [meetingID] : [];
const query = meetingID
? 'SELECT * FROM recordings WHERE meeting_id = ?'
: 'SELECT * FROM recordings';
const dbRows = await db.all(query, params);
// Add DB-only recordings (not on current BBB server) with cached URLs
for (const row of dbRows) {
if (!bbbIds.has(row.record_id)) {
bbbRecordings.push(formatDbRecording(row));
}
}
return bbbRecordings;
} else {
// BBB API unreachable serve everything from DB
const params = meetingID ? [meetingID] : [];
const query = meetingID
? 'SELECT * FROM recordings WHERE meeting_id = ?'
: 'SELECT * FROM recordings';
const rows = await db.all(query, params);
return rows.map(formatDbRecording);
}
}
// POST /api/recordings/recording-ready - BBB recording-ready callback (unauthenticated)
// BBB posts `signed_parameters`, a JWT signed with the shared BBB secret.
// Fires once per playback format; the transcription request is only sent when
// the "video" format first becomes available and the room has the setting on.
router.post('/recording-ready', urlencoded({ extended: false }), async (req, res) => {
try {
const signed = req.body?.signed_parameters;
if (!signed || typeof signed !== 'string') {
return res.status(400).json({ error: 'signed_parameters is required' });
}
let payload;
try {
payload = jwt.verify(signed, BBB_SECRET, { algorithms: ['HS256'] });
} catch {
return res.status(403).json({ error: 'Invalid signature' });
}
const recordId = payload.record_id;
const meetingId = payload.meeting_id;
if (!recordId || !meetingId) {
return res.status(400).json({ error: 'record_id and meeting_id are required' });
}
// Always answer 200 from here on so BBB does not keep retrying —
// "nothing to do" is a valid outcome for this callback.
const db = getDb();
const room = await db.get('SELECT id, uid, name, recording_transcript FROM rooms WHERE uid = ?', [meetingId]);
if (!room || !room.recording_transcript || !isTranscriptionEnabled()) {
return res.json({});
}
const rec = await getRecordingByRecordId(recordId).catch(() => null);
if (!rec) return res.json({});
const formatted = formatBbbRecording(rec, room.name);
const hasVideo = formatted.formats.some(f => f.type === 'video');
// Cache the recording so the dedupe marker below has a row to live on
await upsertRecording(db, formatted);
if (!hasVideo) return res.json({});
// Dedupe: BBB fires the callback once per format
const cached = await db.get('SELECT transcript_requested_at FROM recordings WHERE record_id = ?', [recordId]);
if (cached?.transcript_requested_at) return res.json({});
const ok = await requestTranscription({ meetingId, recordingId: recordId });
if (ok) {
await db.run('UPDATE recordings SET transcript_requested_at = CURRENT_TIMESTAMP WHERE record_id = ?', [recordId]);
}
res.json({});
} catch (err) {
log.recordings.error(`Recording-ready callback error: ${err.message}`);
res.status(500).json({ error: 'Error processing recording-ready callback' });
}
});
// GET /api/recordings - Get recordings for a room (by meetingID/uid) // GET /api/recordings - Get recordings for a room (by meetingID/uid)
router.get('/', authenticateToken, async (req, res) => { router.get('/', authenticateToken, async (req, res) => {
try { try {
@@ -34,36 +262,7 @@ router.get('/', authenticateToken, async (req, res) => {
return res.status(400).json({ error: 'meetingID query parameter is required' }); return res.status(400).json({ error: 'meetingID query parameter is required' });
} }
const recordings = await getRecordings(meetingID || undefined); const formatted = await fetchAndSyncRecordings(meetingID);
// Format recordings
const formatted = recordings.map(rec => {
const playback = rec.playback?.format;
let formats = [];
if (playback) {
formats = Array.isArray(playback) ? playback : [playback];
}
return {
recordID: rec.recordID,
meetingID: rec.meetingID,
name: rec.name || 'Recording',
state: rec.state,
published: rec.published === 'true',
startTime: rec.startTime,
endTime: rec.endTime,
participants: rec.participants,
size: rec.size,
formats: formats.map(f => ({
type: f.type,
url: f.url,
length: f.length,
size: f.size,
})),
metadata: rec.metadata || {},
};
});
res.json({ recordings: formatted }); res.json({ recordings: formatted });
} catch (err) { } catch (err) {
log.recordings.error(`Get recordings error: ${err.message}`); log.recordings.error(`Get recordings error: ${err.message}`);
@@ -89,33 +288,7 @@ router.get('/room/:uid', authenticateToken, async (req, res) => {
} }
} }
const recordings = await getRecordings(room.uid); const formatted = await fetchAndSyncRecordings(room.uid, room.name);
const formatted = recordings.map(rec => {
const playback = rec.playback?.format;
let formats = [];
if (playback) {
formats = Array.isArray(playback) ? playback : [playback];
}
return {
recordID: rec.recordID,
meetingID: rec.meetingID,
name: rec.name || room.name,
state: rec.state,
published: rec.published === 'true',
startTime: rec.startTime,
endTime: rec.endTime,
participants: rec.participants,
size: rec.size,
formats: formats.map(f => ({
type: f.type,
url: f.url,
length: f.length,
size: f.size,
})),
};
});
res.json({ recordings: formatted }); res.json({ recordings: formatted });
} catch (err) { } catch (err) {
log.recordings.error(`Get room recordings error: ${err.message}`); log.recordings.error(`Get room recordings error: ${err.message}`);
@@ -126,14 +299,21 @@ router.get('/room/:uid', authenticateToken, async (req, res) => {
// DELETE /api/recordings/:recordID // DELETE /api/recordings/:recordID
router.delete('/:recordID', authenticateToken, async (req, res) => { router.delete('/:recordID', authenticateToken, async (req, res) => {
try { try {
const db = getDb();
// M14 fix: look up the recording from BBB to find its meetingID (room UID), // M14 fix: look up the recording from BBB to find its meetingID (room UID),
// then verify the user owns or shares that room. // then verify the user owns or shares that room.
if (req.user.role !== 'admin') { if (req.user.role !== 'admin') {
const rec = await getRecordingByRecordId(req.params.recordID); // Try BBB first, fall back to DB cache
let rec = await getRecordingByRecordId(req.params.recordID).catch(() => null);
if (!rec) {
const dbRow = await db.get('SELECT * FROM recordings WHERE record_id = ?', [req.params.recordID]);
if (dbRow) {
rec = { meetingID: dbRow.meeting_id };
}
}
if (!rec) { if (!rec) {
return res.status(404).json({ error: 'Recording not found' }); return res.status(404).json({ error: 'Recording not found' });
} }
const db = getDb();
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]); const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]);
if (!room) { if (!room) {
return res.status(404).json({ error: 'Room not found' }); return res.status(404).json({ error: 'Room not found' });
@@ -145,7 +325,14 @@ router.delete('/:recordID', authenticateToken, async (req, res) => {
} }
} }
} }
// Try to delete on BBB (may fail if server changed that's OK)
try {
await deleteRecording(req.params.recordID); await deleteRecording(req.params.recordID);
} catch (err) {
log.recordings.warn(`BBB deleteRecording failed (may already be gone): ${err.message}`);
}
// Always remove from local cache
await db.run('DELETE FROM recordings WHERE record_id = ?', [req.params.recordID]);
res.json({ message: 'Recording deleted' }); res.json({ message: 'Recording deleted' });
} catch (err) { } catch (err) {
log.recordings.error(`Delete recording error: ${err.message}`); log.recordings.error(`Delete recording error: ${err.message}`);
@@ -156,14 +343,20 @@ router.delete('/:recordID', authenticateToken, async (req, res) => {
// PUT /api/recordings/:recordID/publish // PUT /api/recordings/:recordID/publish
router.put('/:recordID/publish', authenticateToken, async (req, res) => { router.put('/:recordID/publish', authenticateToken, async (req, res) => {
try { try {
const db = getDb();
// M14 fix: look up the recording from BBB to find its meetingID (room UID), // M14 fix: look up the recording from BBB to find its meetingID (room UID),
// then verify the user owns or shares that room. // then verify the user owns or shares that room.
if (req.user.role !== 'admin') { if (req.user.role !== 'admin') {
const rec = await getRecordingByRecordId(req.params.recordID); let rec = await getRecordingByRecordId(req.params.recordID).catch(() => null);
if (!rec) {
const dbRow = await db.get('SELECT * FROM recordings WHERE record_id = ?', [req.params.recordID]);
if (dbRow) {
rec = { meetingID: dbRow.meeting_id };
}
}
if (!rec) { if (!rec) {
return res.status(404).json({ error: 'Recording not found' }); return res.status(404).json({ error: 'Recording not found' });
} }
const db = getDb();
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]); const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]);
if (!room) { if (!room) {
return res.status(404).json({ error: 'Room not found' }); return res.status(404).json({ error: 'Room not found' });
@@ -176,7 +369,15 @@ router.put('/:recordID/publish', authenticateToken, async (req, res) => {
} }
} }
const { publish } = req.body; const { publish } = req.body;
// Try BBB API
try {
await publishRecording(req.params.recordID, publish); await publishRecording(req.params.recordID, publish);
} catch (err) {
log.recordings.warn(`BBB publishRecording failed: ${err.message}`);
}
// Update DB cache
await db.run('UPDATE recordings SET published = ?, updated_at = CURRENT_TIMESTAMP WHERE record_id = ?',
[publish ? 1 : 0, req.params.recordID]);
res.json({ message: publish ? 'Recording published' : 'Recording unpublished' }); res.json({ message: publish ? 'Recording published' : 'Recording unpublished' });
} catch (err) { } catch (err) {
log.recordings.error(`Publish recording error: ${err.message}`); log.recordings.error(`Publish recording error: ${err.message}`);
+208 -26
View File
@@ -8,6 +8,8 @@ import { getDb } from '../config/database.js';
import { authenticateToken, getBaseUrl } from '../middleware/auth.js'; import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js'; import { createNotification } from '../config/notifications.js';
import { sendGuestInviteEmail } from '../config/mailer.js';
import { getAppName } from '../config/appName.js';
import { import {
createMeeting, createMeeting,
joinMeeting, joinMeeting,
@@ -23,6 +25,14 @@ import {
discoverInstance, discoverInstance,
} from '../config/federation.js'; } from '../config/federation.js';
// Avatar image filenames are produced by the upload endpoint as
// "<userId>_<timestamp>.<ext>". Reject anything that doesn't match this shape
// so a guest can't smuggle path traversal or arbitrary URL fragments through
// the unauthenticated guest-join endpoint.
const SAFE_AVATAR_FILENAME_RE = /^[0-9]+_[0-9]+\.(jpg|png|gif|webp)$/;
// Mirrors auth.js: only allow hex/hsl/named CSS colors.
const SAFE_AVATAR_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
// L6: constant-time string comparison for access/moderator codes // L6: constant-time string comparison for access/moderator codes
function timingSafeEqual(a, b) { function timingSafeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false; if (typeof a !== 'string' || typeof b !== 'string') return false;
@@ -37,6 +47,17 @@ const __dirname = path.dirname(__filename);
const presentationsDir = path.join(__dirname, '..', '..', 'uploads', 'presentations'); const presentationsDir = path.join(__dirname, '..', '..', 'uploads', 'presentations');
if (!fs.existsSync(presentationsDir)) fs.mkdirSync(presentationsDir, { recursive: true }); if (!fs.existsSync(presentationsDir)) fs.mkdirSync(presentationsDir, { recursive: true });
const PRESENTATION_TOKEN_SECRET = process.env.BBB_SECRET || crypto.randomBytes(32).toString('hex');
const PRESENTATION_TOKEN_TTL = 60 * 60 * 1000; // 1 hour
function signPresentationUrl(roomUid, filename) {
const expires = Date.now() + PRESENTATION_TOKEN_TTL;
const token = crypto.createHmac('sha256', PRESENTATION_TOKEN_SECRET)
.update(`${roomUid}/${filename}:${expires}`)
.digest('hex');
return { token, expires };
}
// M8: rate limit unauthenticated guest-join to prevent access_code brute-force // M8: rate limit unauthenticated guest-join to prevent access_code brute-force
const guestJoinLimiter = rateLimit({ const guestJoinLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes windowMs: 15 * 60 * 1000, // 15 minutes
@@ -46,6 +67,16 @@ const guestJoinLimiter = rateLimit({
message: { error: 'Too many join attempts. Please try again later.' }, message: { error: 'Too many join attempts. Please try again later.' },
}); });
// Rate limit email invitations: each request may carry up to 50 addresses, so
// without a cap any registered account could be abused as an SMTP spam relay.
const inviteEmailLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many email invitations. Please try again later.' },
});
const router = Router(); const router = Router();
// Build avatar URL for a user (uploaded image or generated initials) // Build avatar URL for a user (uploaded image or generated initials)
@@ -162,6 +193,7 @@ router.post('/', authenticateToken, async (req, res) => {
record_meeting, record_meeting,
guest_access, guest_access,
moderator_code, moderator_code,
recording_transcript,
} = req.body; } = req.body;
if (!name || name.trim().length === 0) { if (!name || name.trim().length === 0) {
@@ -196,8 +228,8 @@ router.post('/', authenticateToken, async (req, res) => {
const db = getDb(); const db = getDb();
const result = await db.run(` const result = await db.run(`
INSERT INTO rooms (uid, name, user_id, welcome_message, max_participants, access_code, mute_on_join, require_approval, anyone_can_start, all_join_moderator, record_meeting, guest_access, moderator_code) INSERT INTO rooms (uid, name, user_id, welcome_message, max_participants, access_code, mute_on_join, require_approval, anyone_can_start, all_join_moderator, record_meeting, guest_access, moderator_code, recording_transcript)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [ `, [
uid, uid,
name.trim(), name.trim(),
@@ -212,6 +244,7 @@ router.post('/', authenticateToken, async (req, res) => {
record_meeting !== false ? 1 : 0, record_meeting !== false ? 1 : 0,
guest_access ? 1 : 0, guest_access ? 1 : 0,
moderator_code || null, moderator_code || null,
recording_transcript ? 1 : 0,
]); ]);
const room = await db.get('SELECT * FROM rooms WHERE id = ?', [result.lastInsertRowid]); const room = await db.get('SELECT * FROM rooms WHERE id = ?', [result.lastInsertRowid]);
@@ -246,6 +279,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
moderator_code, moderator_code,
learning_analytics, learning_analytics,
analytics_visibility, analytics_visibility,
recording_transcript,
} = req.body; } = req.body;
// M12: field length limits (same as create) // M12: field length limits (same as create)
@@ -287,6 +321,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
moderator_code = ?, moderator_code = ?,
learning_analytics = COALESCE(?, learning_analytics), learning_analytics = COALESCE(?, learning_analytics),
analytics_visibility = COALESCE(?, analytics_visibility), analytics_visibility = COALESCE(?, analytics_visibility),
recording_transcript = COALESCE(?, recording_transcript),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE uid = ? WHERE uid = ?
`, [ `, [
@@ -303,6 +338,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
moderator_code !== undefined ? (moderator_code || null) : room.moderator_code, moderator_code !== undefined ? (moderator_code || null) : room.moderator_code,
learning_analytics !== undefined ? (learning_analytics ? 1 : 0) : null, learning_analytics !== undefined ? (learning_analytics ? 1 : 0) : null,
analytics_visibility && ['owner', 'shared'].includes(analytics_visibility) ? analytics_visibility : null, analytics_visibility && ['owner', 'shared'].includes(analytics_visibility) ? analytics_visibility : null,
recording_transcript !== undefined ? (recording_transcript ? 1 : 0) : null,
req.params.uid, req.params.uid,
]); ]);
@@ -404,6 +440,10 @@ router.post('/:uid/shares', authenticateToken, async (req, res) => {
if (user_id === req.user.id) { if (user_id === req.user.id) {
return res.status(400).json({ error: 'You cannot share the room with yourself' }); return res.status(400).json({ error: 'You cannot share the room with yourself' });
} }
const targetUser = await db.get('SELECT id FROM users WHERE id = ?', [user_id]);
if (!targetUser) {
return res.status(404).json({ error: 'User not found' });
}
// Check if already shared // Check if already shared
const existing = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, user_id]); const existing = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, user_id]);
if (existing) { if (existing) {
@@ -473,9 +513,10 @@ router.post('/:uid/start', authenticateToken, async (req, res) => {
return res.status(404).json({ error: 'Room not found' }); return res.status(404).json({ error: 'Room not found' });
} }
// Check access: owner or shared // Check access: owner, admin, shared, or anyone_can_start
const isOwner = room.user_id === req.user.id; const isOwner = room.user_id === req.user.id;
if (!isOwner) { const isAdmin = req.user.role === 'admin';
if (!isOwner && !isAdmin && !room.anyone_can_start) {
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
if (!share) { if (!share) {
return res.status(403).json({ error: 'No permission' }); return res.status(403).json({ error: 'No permission' });
@@ -484,16 +525,20 @@ router.post('/:uid/start', authenticateToken, async (req, res) => {
const baseUrl = getBaseUrl(req); const baseUrl = getBaseUrl(req);
const loginURL = `${baseUrl}/join/${room.uid}`; const loginURL = `${baseUrl}/join/${room.uid}`;
const presentationUrl = room.presentation_file let presentationUrl = null;
? `${baseUrl}/uploads/presentations/${room.presentation_file}` if (room.presentation_file) {
: null; const { token, expires } = signPresentationUrl(room.uid, room.presentation_file);
presentationUrl = `${baseUrl}/api/rooms/presentations/${token}/${expires}/${room.uid}/${encodeURIComponent(room.presentation_file)}`;
}
const analyticsCallbackURL = room.learning_analytics const analyticsCallbackURL = room.learning_analytics
? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}` ? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}`
: null; : null;
await createMeeting(room, baseUrl, loginURL, presentationUrl, analyticsCallbackURL); // Localise BBB's default welcome message in the room owner's language
const owner = await db.get('SELECT language FROM users WHERE id = ?', [room.user_id]);
await createMeeting(room, baseUrl, loginURL, presentationUrl, analyticsCallbackURL, owner?.language || 'en');
const avatarURL = getUserAvatarURL(req, req.user); const avatarURL = getUserAvatarURL(req, req.user);
const displayName = req.user.display_name || req.user.name; const displayName = req.user.display_name || req.user.name;
const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL); const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL, !!req.body.dark_mode);
res.json({ joinUrl }); res.json({ joinUrl });
} catch (err) { } catch (err) {
log.rooms.error(`Start meeting error: ${err.message}`); log.rooms.error(`Start meeting error: ${err.message}`);
@@ -527,7 +572,7 @@ router.post('/:uid/join', authenticateToken, async (req, res) => {
const isShared = !isOwner && await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); const isShared = !isOwner && await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
const isModerator = isOwner || !!isShared || room.all_join_moderator; const isModerator = isOwner || !!isShared || room.all_join_moderator;
const avatarURL = getUserAvatarURL(req, req.user); const avatarURL = getUserAvatarURL(req, req.user);
const joinUrl = await joinMeeting(room.uid, req.user.display_name || req.user.name, isModerator, avatarURL); const joinUrl = await joinMeeting(room.uid, req.user.display_name || req.user.name, isModerator, avatarURL, !!req.body.dark_mode);
res.json({ joinUrl }); res.json({ joinUrl });
} catch (err) { } catch (err) {
log.rooms.error(`Join meeting error: ${err.message}`); log.rooms.error(`Join meeting error: ${err.message}`);
@@ -545,9 +590,10 @@ router.post('/:uid/end', authenticateToken, async (req, res) => {
return res.status(404).json({ error: 'Room not found' }); return res.status(404).json({ error: 'Room not found' });
} }
// Check access: owner or shared user // Check access: owner, admin, or shared user
const isOwner = room.user_id === req.user.id; const isOwner = room.user_id === req.user.id;
if (!isOwner) { const isAdmin = req.user.role === 'admin';
if (!isOwner && !isAdmin) {
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
if (!share) { if (!share) {
return res.status(403).json({ error: 'No permission' }); return res.status(403).json({ error: 'No permission' });
@@ -602,7 +648,7 @@ router.get('/:uid/public', async (req, res) => {
// POST /api/rooms/:uid/guest-join - Join meeting as guest (no auth needed) // POST /api/rooms/:uid/guest-join - Join meeting as guest (no auth needed)
router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => { router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
try { try {
const { name, access_code, moderator_code } = req.body; const { name, access_code, moderator_code, avatar_image, avatar_color, dark_mode } = req.body;
if (!name || name.trim().length === 0) { if (!name || name.trim().length === 0) {
return res.status(400).json({ error: 'Name is required' }); return res.status(400).json({ error: 'Name is required' });
@@ -638,7 +684,9 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
const analyticsCallbackURL = room.learning_analytics const analyticsCallbackURL = room.learning_analytics
? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}` ? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}`
: null; : null;
await createMeeting(room, baseUrl, loginURL, null, analyticsCallbackURL); // Localise BBB's default welcome message in the room owner's language
const owner = await db.get('SELECT language FROM users WHERE id = ?', [room.user_id]);
await createMeeting(room, baseUrl, loginURL, null, analyticsCallbackURL, owner?.language || 'en');
} }
// Check moderator code // Check moderator code
@@ -648,8 +696,21 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
} }
const baseUrl = getBaseUrl(req); const baseUrl = getBaseUrl(req);
const guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`; // Validate client-supplied avatar fields before embedding them in a URL
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL); // that BBB will fetch — guest-join is unauthenticated.
const safeAvatarImage = (typeof avatar_image === 'string' && SAFE_AVATAR_FILENAME_RE.test(avatar_image))
? avatar_image : null;
const safeAvatarColor = (typeof avatar_color === 'string' && SAFE_AVATAR_COLOR_RE.test(avatar_color))
? avatar_color : null;
let guestAvatarURL;
if (safeAvatarImage) {
guestAvatarURL = `${baseUrl}/api/auth/avatar/${encodeURIComponent(safeAvatarImage)}`;
} else if (safeAvatarColor) {
guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}?color=${encodeURIComponent(safeAvatarColor)}`;
} else {
guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
}
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL, !!dark_mode);
res.json({ joinUrl }); res.json({ joinUrl });
} catch (err) { } catch (err) {
log.rooms.error(`Guest join error: ${err.message}`); log.rooms.error(`Guest join error: ${err.message}`);
@@ -679,6 +740,46 @@ router.get('/:uid/status', async (req, res) => {
} }
}); });
// GET /api/rooms/presentations/:token/:expires/:roomUid/:filename - Serve presentation file (token-protected for BBB)
// Token and expires are path segments so the URL ends with the original filename,
// allowing BBB to detect the file type from the extension.
router.get('/presentations/:token/:expires/:roomUid/:filename', (req, res) => {
const { token, expires, roomUid, filename } = req.params;
if (!token || !expires) {
return res.status(401).json({ error: 'Missing token' });
}
const expiresNum = Number(expires);
if (isNaN(expiresNum) || Date.now() > expiresNum) {
return res.status(403).json({ error: 'Token expired' });
}
const expected = crypto.createHmac('sha256', PRESENTATION_TOKEN_SECRET)
.update(`${roomUid}/${filename}:${expires}`)
.digest('hex');
// timingSafeEqual throws on length mismatch, so compare lengths first
const tokenBuf = Buffer.from(token);
const expectedBuf = Buffer.from(expected);
if (tokenBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(tokenBuf, expectedBuf)) {
return res.status(403).json({ error: 'Invalid token' });
}
// S8: prevent path traversal
const roomDir = path.resolve(presentationsDir, roomUid);
const filepath = path.resolve(roomDir, filename);
if (!filepath.startsWith(presentationsDir + path.sep) || !filepath.startsWith(roomDir + path.sep)) {
return res.status(400).json({ error: 'Invalid filename' });
}
if (!fs.existsSync(filepath)) {
return res.status(404).json({ error: 'File not found' });
}
res.sendFile(filepath);
});
// POST /api/rooms/:uid/presentation - Upload a presentation file for the room // POST /api/rooms/:uid/presentation - Upload a presentation file for the room
router.post('/:uid/presentation', authenticateToken, async (req, res) => { router.post('/:uid/presentation', authenticateToken, async (req, res) => {
try { try {
@@ -733,22 +834,28 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => {
// Preserve original filename (sent as X-Filename header) // Preserve original filename (sent as X-Filename header)
const rawName = req.headers['x-filename']; const rawName = req.headers['x-filename'];
const originalName = rawName const filename = rawName
? decodeURIComponent(rawName).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, 200) ? decodeURIComponent(rawName).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, 200)
: `presentation.${ext}`; : `presentation.${ext}`;
const filename = `${room.uid}_${Date.now()}.${ext}`; // Each room gets its own folder: uploads/presentations/{roomUID}/
const filepath = path.join(presentationsDir, filename); const roomDir = path.join(presentationsDir, room.uid);
if (!fs.existsSync(roomDir)) fs.mkdirSync(roomDir, { recursive: true });
const filepath = path.join(roomDir, filename);
// S8: defense-in-depth path traversal check
if (!path.resolve(filepath).startsWith(roomDir + path.sep)) {
return res.status(400).json({ error: 'Invalid filename' });
}
// Remove old presentation file if exists // Remove old presentation file if exists
if (room.presentation_file) { if (room.presentation_file) {
// S8: defense-in-depth path traversal check const oldPath = path.resolve(roomDir, room.presentation_file);
const oldPath = path.resolve(presentationsDir, room.presentation_file); if (oldPath.startsWith(roomDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
if (oldPath.startsWith(presentationsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
} }
fs.writeFileSync(filepath, buffer); fs.writeFileSync(filepath, buffer);
await db.run('UPDATE rooms SET presentation_file = ?, presentation_name = ?, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [filename, originalName, req.params.uid]); await db.run('UPDATE rooms SET presentation_file = ?, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [filename, req.params.uid]);
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]); const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
res.json({ room: updated }); res.json({ room: updated });
} catch (err) { } catch (err) {
@@ -766,11 +873,14 @@ router.delete('/:uid/presentation', authenticateToken, async (req, res) => {
if (room.presentation_file) { if (room.presentation_file) {
// S8: defense-in-depth path traversal check // S8: defense-in-depth path traversal check
const filepath = path.resolve(presentationsDir, room.presentation_file); const roomDir = path.join(presentationsDir, room.uid);
if (filepath.startsWith(presentationsDir + path.sep) && fs.existsSync(filepath)) fs.unlinkSync(filepath); const filepath = path.resolve(roomDir, room.presentation_file);
if (filepath.startsWith(roomDir + path.sep) && fs.existsSync(filepath)) fs.unlinkSync(filepath);
// Remove empty room folder
if (fs.existsSync(roomDir) && fs.readdirSync(roomDir).length === 0) fs.rmdirSync(roomDir);
} }
await db.run('UPDATE rooms SET presentation_file = NULL, presentation_name = NULL, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [req.params.uid]); await db.run('UPDATE rooms SET presentation_file = NULL, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [req.params.uid]);
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]); const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
res.json({ room: updated }); res.json({ room: updated });
} catch (err) { } catch (err) {
@@ -779,4 +889,76 @@ router.delete('/:uid/presentation', authenticateToken, async (req, res) => {
} }
}); });
// ── POST /api/rooms/invite-email — Send email invitation to guest(s) ────────
router.post('/invite-email', authenticateToken, inviteEmailLimiter, async (req, res) => {
try {
const { room_uid, emails, message } = req.body;
if (!room_uid || !emails || !emails.length) {
return res.status(400).json({ error: 'room_uid and emails are required' });
}
if (emails.length > 50) {
return res.status(400).json({ error: 'Maximum 50 email addresses allowed' });
}
// Validate all emails
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
for (const email of emails) {
if (!emailRegex.test(email) || email.length > 254) {
return res.status(400).json({ error: `Invalid email address: ${email}` });
}
}
if (message && message.length > 2000) {
return res.status(400).json({ error: 'Message must not exceed 2000 characters' });
}
const db = getDb();
// Verify room exists and user has access
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [room_uid]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
}
const isOwner = room.user_id === req.user.id;
if (!isOwner) {
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
if (!share) {
return res.status(403).json({ error: 'No permission to invite from this room' });
}
}
// Build guest join URL
const baseUrl = getBaseUrl(req);
const joinUrl = room.access_code
? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}`
: `${baseUrl}/join/${room.uid}`;
const appName = await getAppName();
const fromUser = req.user.display_name || req.user.name;
const lang = req.user.language || 'en';
// Send emails (in parallel but collect errors)
const results = await Promise.allSettled(
emails.map(email =>
sendGuestInviteEmail(email, fromUser, room.name, message || null, joinUrl, appName, lang)
)
);
const failed = results.filter(r => r.status === 'rejected');
if (failed.length === emails.length) {
return res.status(500).json({ error: 'Failed to send all email invitations' });
}
if (failed.length > 0) {
log.rooms.warn(`${failed.length}/${emails.length} email invitations failed`);
}
res.json({ success: true, sent: emails.length - failed.length, failed: failed.length });
} catch (err) {
log.rooms.error('Email invite error:', err);
res.status(500).json({ error: err.message || 'Failed to send email invitations' });
}
});
export default router; export default router;
+4
View File
@@ -9,6 +9,8 @@ import Home from './pages/Home';
import Login from './pages/Login'; import Login from './pages/Login';
import Register from './pages/Register'; import Register from './pages/Register';
import VerifyEmail from './pages/VerifyEmail'; import VerifyEmail from './pages/VerifyEmail';
import ForgotPassword from './pages/ForgotPassword';
import ResetPassword from './pages/ResetPassword';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import RoomDetail from './pages/RoomDetail'; import RoomDetail from './pages/RoomDetail';
import Settings from './pages/Settings'; import Settings from './pages/Settings';
@@ -52,6 +54,8 @@ export default function App() {
<Route path="/login" element={user ? <Navigate to="/dashboard" /> : <Login />} /> <Route path="/login" element={user ? <Navigate to="/dashboard" /> : <Login />} />
<Route path="/register" element={user ? <Navigate to="/dashboard" /> : <Register />} /> <Route path="/register" element={user ? <Navigate to="/dashboard" /> : <Register />} />
<Route path="/verify-email" element={<VerifyEmail />} /> <Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/forgot-password" element={user ? <Navigate to="/dashboard" /> : <ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/oauth/callback" element={<OAuthCallback />} /> <Route path="/oauth/callback" element={<OAuthCallback />} />
<Route path="/join/:uid" element={<GuestJoin />} /> <Route path="/join/:uid" element={<GuestJoin />} />
+1 -1
View File
@@ -152,7 +152,7 @@ export default function AnalyticsList({ analytics, onRefresh, isOwner = true })
</div> </div>
</div> </div>
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 shrink-0">
<div className="relative"> <div className="relative">
<button <button
onClick={() => toggleExportMenu(entry.id)} onClick={() => toggleExportMenu(entry.id)}
+3 -3
View File
@@ -2,9 +2,9 @@ import { Video } from 'lucide-react';
import { useBranding } from '../contexts/BrandingContext'; import { useBranding } from '../contexts/BrandingContext';
const sizes = { const sizes = {
sm: { box: 'w-8 h-8', h: 'h-8', maxW: 'max-w-[8rem]', rounded: 'rounded-lg', icon: 16, text: 'text-lg' }, sm: { box: 'w-8 h-8', h: 'h-8', maxW: 'max-w-32', rounded: 'rounded-lg', icon: 16, text: 'text-lg' },
md: { box: 'w-9 h-9', h: 'h-12', maxW: 'max-w-[10rem]', rounded: 'rounded-lg', icon: 20, text: 'text-xl' }, md: { box: 'w-9 h-9', h: 'h-12', maxW: 'max-w-40', rounded: 'rounded-lg', icon: 20, text: 'text-xl' },
lg: { box: 'w-10 h-10', h: 'h-10', maxW: 'max-w-[12rem]', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' }, lg: { box: 'w-10 h-10', h: 'h-10', maxW: 'max-w-48', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' },
}; };
export default function BrandLogo({ size = 'md', className = '' }) { export default function BrandLogo({ size = 'md', className = '' }) {
+1 -1
View File
@@ -90,7 +90,7 @@ export default function DateTimePicker({
</label> </label>
)} )}
<div className="relative"> <div className="relative">
<Icon size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none z-[1]" /> <Icon size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none z-1" />
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
+22 -8
View File
@@ -30,22 +30,35 @@ export default function FederatedRoomCard({ room, onRemove }) {
const recordingOn = room.allow_recording === 1 || room.allow_recording === true; const recordingOn = room.allow_recording === 1 || room.allow_recording === true;
const openRoom = () => navigate(`/federation/rooms/${room.id}`);
return ( return (
<div className={`card-hover group p-5 cursor-pointer ${isDeleted ? 'opacity-60' : ''}`} onClick={() => navigate(`/federation/rooms/${room.id}`)}> <div
role="link"
tabIndex={0}
aria-label={room.room_name}
onClick={openRoom}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openRoom(); }
}}
className={`card-hover group p-5 cursor-pointer focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2 ${isDeleted ? 'opacity-60' : ''}`}
style={{ '--tw-ring-offset-color': 'var(--bg-primary)' }}
>
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Globe size={14} className="text-th-accent flex-shrink-0" /> <Globe size={14} className="text-th-accent shrink-0" />
<h3 className="text-base font-semibold text-th-text truncate group-hover:text-th-accent transition-colors"> <h3 className="text-base font-semibold text-th-text truncate group-hover:text-th-accent transition-colors">
{room.room_name} {room.room_name}
</h3> </h3>
{isDeleted ? ( {isDeleted ? (
<span className="flex-shrink-0 px-2 py-0.5 bg-red-500/15 text-red-500 rounded-full text-xs font-medium flex items-center gap-1"> <span className="shrink-0 px-2 py-0.5 bg-red-500/15 text-red-500 rounded-full text-xs font-medium flex items-center gap-1">
<AlertTriangle size={10} /> <AlertTriangle size={10} />
{t('federation.roomDeleted')} {t('federation.roomDeleted')}
</span> </span>
) : ( ) : (
<span className="flex-shrink-0 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium"> <span className="shrink-0 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
{t('federation.federated')} {t('federation.federated')}
</span> </span>
)} )}
@@ -60,12 +73,12 @@ export default function FederatedRoomCard({ room, onRemove }) {
<div className="grid grid-cols-2 gap-2 mb-4"> <div className="grid grid-cols-2 gap-2 mb-4">
{room.meet_id && ( {room.meet_id && (
<div className="flex items-center gap-1.5 text-xs text-th-text-s"> <div className="flex items-center gap-1.5 text-xs text-th-text-s">
<Hash size={12} className="text-th-accent flex-shrink-0" /> <Hash size={12} className="text-th-accent shrink-0" />
<span className="truncate font-mono" title={room.meet_id}>{room.meet_id.slice(0, 10)}</span> <span className="truncate font-mono" title={room.meet_id}>{room.meet_id.slice(0, 10)}</span>
</div> </div>
)} )}
<div className="flex items-center gap-1.5 text-xs text-th-text-s"> <div className="flex items-center gap-1.5 text-xs text-th-text-s">
<Users size={12} className="text-th-accent flex-shrink-0" /> <Users size={12} className="text-th-accent shrink-0" />
<span> <span>
{t('federation.maxParticipants')}:{' '} {t('federation.maxParticipants')}:{' '}
<span className="text-th-text font-medium"> <span className="text-th-text font-medium">
@@ -76,12 +89,12 @@ export default function FederatedRoomCard({ room, onRemove }) {
<div className="flex items-center gap-1.5 text-xs col-span-2"> <div className="flex items-center gap-1.5 text-xs col-span-2">
{recordingOn ? ( {recordingOn ? (
<> <>
<Video size={12} className="text-amber-500 flex-shrink-0" /> <Video size={12} className="text-amber-500 shrink-0" />
<span className="text-amber-500 font-medium">{t('federation.recordingOn')}</span> <span className="text-amber-500 font-medium">{t('federation.recordingOn')}</span>
</> </>
) : ( ) : (
<> <>
<VideoOff size={12} className="text-th-text-s flex-shrink-0" /> <VideoOff size={12} className="text-th-text-s shrink-0" />
<span className="text-th-text-s">{t('federation.recordingOff')}</span> <span className="text-th-text-s">{t('federation.recordingOff')}</span>
</> </>
)} )}
@@ -107,6 +120,7 @@ export default function FederatedRoomCard({ room, onRemove }) {
<button <button
onClick={handleRemove} onClick={handleRemove}
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error" className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"
aria-label={t('federation.removeRoom')}
title={t('federation.removeRoom')} title={t('federation.removeRoom')}
> >
<Trash2 size={14} /> <Trash2 size={14} />
+1 -1
View File
@@ -52,7 +52,7 @@ export default function Layout() {
{/* Email verification banner */} {/* Email verification banner */}
{user && user.email_verified === 0 && ( {user && user.email_verified === 0 && (
<div className="bg-amber-500/15 border-b border-amber-500/30 px-4 py-2.5 flex items-center justify-center gap-3 text-sm"> <div className="bg-amber-500/15 border-b border-amber-500/30 px-4 py-2.5 flex items-center justify-center gap-3 text-sm">
<AlertTriangle size={15} className="text-amber-400 flex-shrink-0" /> <AlertTriangle size={15} className="text-amber-400 shrink-0" />
<span className="text-amber-200">{t('auth.emailVerificationBanner')}</span> <span className="text-amber-200">{t('auth.emailVerificationBanner')}</span>
<button <button
onClick={handleResendVerification} onClick={handleResendVerification}
+44 -3
View File
@@ -1,15 +1,56 @@
import { useEffect, useId, useRef } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
export default function Modal({ title, children, onClose, maxWidth = 'max-w-lg' }) { export default function Modal({ title, children, onClose, maxWidth = 'max-w-lg' }) {
const { t } = useLanguage();
const titleId = useId();
const dialogRef = useRef(null);
const previouslyFocused = useRef(null);
// Keep the latest onClose in a ref so the mount-only effect below can call it
// without listing onClose as a dependency. Callers pass a fresh inline arrow
// each render; depending on it would re-run the effect on every keystroke and
// steal focus back to the dialog (kicking the user out of input fields).
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
previouslyFocused.current = document.activeElement;
const handleKey = (e) => {
if (e.key === 'Escape') onCloseRef.current?.();
};
document.addEventListener('keydown', handleKey);
// Focus the dialog so screen readers announce it and keyboard focus lands inside
dialogRef.current?.focus();
return () => {
document.removeEventListener('keydown', handleKey);
previouslyFocused.current?.focus?.();
};
}, []);
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} /> <div
<div className={`relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full ${maxWidth}`}> className="fixed inset-0 bg-black/60 backdrop-blur-xs"
onClick={onClose}
aria-hidden="true"
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
className={`relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full ${maxWidth} focus:outline-hidden`}
>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-th-border rounded-t-2xl"> <div className="flex items-center justify-between px-6 py-4 border-b border-th-border rounded-t-2xl">
<h2 className="text-lg font-semibold text-th-text">{title}</h2> <h2 id={titleId} className="text-lg font-semibold text-th-text">{title}</h2>
<button <button
type="button"
onClick={onClose} onClick={onClose}
aria-label={t('common.close')}
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors" className="p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
> >
<X size={20} /> <X size={20} />
+6 -2
View File
@@ -38,12 +38,13 @@ export default function Navbar({ onMenuClick }) {
: '?'; : '?';
return ( return (
<header className="sticky top-0 z-20 bg-th-nav border-b border-th-border backdrop-blur-sm"> <header className="sticky top-0 z-20 bg-th-nav border-b border-th-border backdrop-blur-xs">
<div className="flex items-center justify-between h-16 px-4 md:px-6"> <div className="flex items-center justify-between h-16 px-4 md:px-6">
{/* Left section */} {/* Left section */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={onMenuClick} onClick={onMenuClick}
aria-label={t('nav.navigation')}
className="lg:hidden p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors" className="lg:hidden p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
> >
<Menu size={20} /> <Menu size={20} />
@@ -59,6 +60,9 @@ export default function Navbar({ onMenuClick }) {
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<button <button
onClick={() => setDropdownOpen(!dropdownOpen)} onClick={() => setDropdownOpen(!dropdownOpen)}
aria-label={user?.display_name || user?.name}
aria-haspopup="menu"
aria-expanded={dropdownOpen}
className="flex items-center gap-2 p-1.5 rounded-lg hover:bg-th-hover transition-colors" className="flex items-center gap-2 p-1.5 rounded-lg hover:bg-th-hover transition-colors"
> >
<div <div
@@ -68,7 +72,7 @@ export default function Navbar({ onMenuClick }) {
{user?.avatar_image ? ( {user?.avatar_image ? (
<img <img
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`} src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
alt="Avatar" alt=""
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (
+9 -3
View File
@@ -83,6 +83,11 @@ export default function NotificationBell() {
<button <button
onClick={() => setOpen(prev => !prev)} onClick={() => setOpen(prev => !prev)}
className="relative p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors" className="relative p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
aria-label={unreadCount > 0
? `${t('notifications.bell')} (${unreadCount})`
: t('notifications.bell')}
aria-haspopup="true"
aria-expanded={open}
title={t('notifications.bell')} title={t('notifications.bell')}
> >
<Bell size={20} /> <Bell size={20} />
@@ -148,7 +153,7 @@ export default function NotificationBell() {
${n.read ? 'hover:bg-th-hover' : 'bg-th-accent/5 hover:bg-th-accent/10'}`} ${n.read ? 'hover:bg-th-hover' : 'bg-th-accent/5 hover:bg-th-accent/10'}`}
> >
{/* Icon */} {/* Icon */}
<span className="text-lg flex-shrink-0 mt-0.5">{notificationIcon(n.type)}</span> <span className="text-lg shrink-0 mt-0.5">{notificationIcon(n.type)}</span>
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -164,7 +169,7 @@ export default function NotificationBell() {
</div> </div>
{/* Right side: unread dot, link icon, delete button */} {/* Right side: unread dot, link icon, delete button */}
<div className="flex flex-col items-end gap-1 flex-shrink-0"> <div className="flex flex-col items-end gap-1 shrink-0">
{!n.read && ( {!n.read && (
<span className="w-2 h-2 rounded-full bg-th-accent mt-1" /> <span className="w-2 h-2 rounded-full bg-th-accent mt-1" />
)} )}
@@ -173,7 +178,8 @@ export default function NotificationBell() {
)} )}
<button <button
onClick={(e) => handleDelete(e, n.id)} onClick={(e) => handleDelete(e, n.id)}
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:text-th-error transition-all text-th-text-s/50" className="opacity-0 group-hover:opacity-100 p-0.5 rounded-sm hover:text-th-error transition-all text-th-text-s/50"
aria-label={t('notifications.delete')}
title={t('notifications.delete')} title={t('notifications.delete')}
> >
<X size={13} /> <X size={13} />
+1 -1
View File
@@ -133,7 +133,7 @@ export default function RecordingList({ recordings, onRefresh }) {
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button <button
onClick={() => handlePublish(rec.recordID, !rec.published)} onClick={() => handlePublish(rec.recordID, !rec.published)}
disabled={loading[rec.recordID] === 'publishing'} disabled={loading[rec.recordID] === 'publishing'}
+23 -4
View File
@@ -2,6 +2,7 @@ import { Users, Play, Trash2, Radio, Loader2, Share2, Copy, Link } from 'lucide-
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import api from '../services/api'; import api from '../services/api';
import { isCurrentThemeDark } from '../themes';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -43,8 +44,21 @@ export default function RoomCard({ room, onDelete }) {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [room.uid]); }, [room.uid]);
const openRoom = () => navigate(`/rooms/${room.uid}`);
return ( return (
<div className="card-hover group p-5 cursor-pointer" onClick={() => navigate(`/rooms/${room.uid}`)}> <div
role="link"
tabIndex={0}
aria-label={room.name}
onClick={openRoom}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openRoom(); }
}}
className="card-hover group p-5 cursor-pointer focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2"
style={{ '--tw-ring-offset-color': 'var(--bg-primary)' }}
>
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -80,7 +94,7 @@ export default function RoomCard({ room, onDelete }) {
<span>Max: {room.max_participants}</span> <span>Max: {room.max_participants}</span>
)} )}
{room.access_code && ( {room.access_code && (
<span className="px-1.5 py-0.5 bg-th-warning/15 text-th-warning rounded text-[10px] font-medium"> <span className="px-1.5 py-0.5 bg-th-warning/15 text-th-warning rounded-sm text-[10px] font-medium">
{t('common.protected')} {t('common.protected')}
</span> </span>
)} )}
@@ -94,11 +108,12 @@ export default function RoomCard({ room, onDelete }) {
setStarting(true); setStarting(true);
try { try {
if (status.running) { if (status.running) {
const data = room.access_code ? { access_code: prompt(t('room.enterAccessCode')) } : {}; const data = { dark_mode: isCurrentThemeDark() };
if (room.access_code) data.access_code = prompt(t('room.enterAccessCode'));
const res = await api.post(`/rooms/${room.uid}/join`, data); const res = await api.post(`/rooms/${room.uid}/join`, data);
if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank'); if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank');
} else { } else {
const res = await api.post(`/rooms/${room.uid}/start`); const res = await api.post(`/rooms/${room.uid}/start`, { dark_mode: isCurrentThemeDark() });
if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank'); if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank');
toast.success(t('room.meetingStarted')); toast.success(t('room.meetingStarted'));
setTimeout(() => { setTimeout(() => {
@@ -121,6 +136,9 @@ export default function RoomCard({ room, onDelete }) {
<button <button
onClick={(e) => { e.stopPropagation(); setShowCopyMenu(v => !v); }} onClick={(e) => { e.stopPropagation(); setShowCopyMenu(v => !v); }}
className="btn-ghost text-xs py-1.5 px-2" className="btn-ghost text-xs py-1.5 px-2"
aria-label={t('room.copyLink')}
aria-haspopup="menu"
aria-expanded={showCopyMenu}
title={t('room.copyLink')} title={t('room.copyLink')}
> >
<Copy size={14} /> <Copy size={14} />
@@ -148,6 +166,7 @@ export default function RoomCard({ room, onDelete }) {
<button <button
onClick={(e) => { e.stopPropagation(); onDelete(room); }} onClick={(e) => { e.stopPropagation(); onDelete(room); }}
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error" className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"
aria-label={t('common.delete')}
title={t('common.delete')} title={t('common.delete')}
> >
<Trash2 size={14} /> <Trash2 size={14} />
+4 -3
View File
@@ -43,7 +43,7 @@ export default function Sidebar({ open, onClose }) {
const linkClasses = ({ isActive }) => const linkClasses = ({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive `flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive
? 'bg-th-accent text-th-accent-t shadow-sm' ? 'bg-th-accent text-th-accent-t shadow-xs'
: 'text-th-text-s hover:text-th-text hover:bg-th-hover' : 'text-th-text-s hover:text-th-text hover:bg-th-hover'
}`; }`;
@@ -60,6 +60,7 @@ export default function Sidebar({ open, onClose }) {
<BrandLogo size="md" className="flex-1 min-w-0" /> <BrandLogo size="md" className="flex-1 min-w-0" />
<button <button
onClick={onClose} onClick={onClose}
aria-label={t('common.close')}
className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors" className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
> >
<X size={18} /> <X size={18} />
@@ -106,13 +107,13 @@ export default function Sidebar({ open, onClose }) {
<div className="p-4 border-t border-th-border"> <div className="p-4 border-t border-th-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0 overflow-hidden" className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold shrink-0 overflow-hidden"
style={{ backgroundColor: user?.avatar_color || '#6366f1' }} style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
> >
{user?.avatar_image ? ( {user?.avatar_image ? (
<img <img
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`} src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
alt="Avatar" alt=""
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (
+9 -3
View File
@@ -10,17 +10,23 @@ export default function ThemeSelector({ onClose }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} /> <div className="fixed inset-0 bg-black/60 backdrop-blur-xs" onClick={onClose} aria-hidden="true" />
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden"> <div
role="dialog"
aria-modal="true"
aria-labelledby="theme-selector-title"
className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden"
>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-th-border"> <div className="flex items-center justify-between px-6 py-4 border-b border-th-border">
<div> <div>
<h2 className="text-lg font-semibold text-th-text">{t('themes.selectTheme')}</h2> <h2 id="theme-selector-title" className="text-lg font-semibold text-th-text">{t('themes.selectTheme')}</h2>
<p className="text-sm text-th-text-s mt-0.5">{t('themes.selectThemeSubtitle')}</p> <p className="text-sm text-th-text-s mt-0.5">{t('themes.selectThemeSubtitle')}</p>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
aria-label={t('common.close')}
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors" className="p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
> >
<X size={20} /> <X size={20} />
+74 -42
View File
@@ -23,7 +23,9 @@
"protected": "Geschützt", "protected": "Geschützt",
"live": "Live", "live": "Live",
"error": "Fehler", "error": "Fehler",
"success": "Erfolg" "success": "Erfolg",
"gridView": "Rasteransicht",
"listView": "Listenansicht"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -62,16 +64,16 @@
"passwordMismatch": "Passwörter stimmen nicht überein", "passwordMismatch": "Passwörter stimmen nicht überein",
"passwordTooShort": "Passwort muss mindestens 6 Zeichen lang sein", "passwordTooShort": "Passwort muss mindestens 6 Zeichen lang sein",
"loginSuccess": "Willkommen zurück!", "loginSuccess": "Willkommen zurück!",
"registerSuccess": "Registrierung erfolgreich!", "registerSuccess": "Registrierung erfolgreich",
"loginFailed": "Anmeldung fehlgeschlagen", "loginFailed": "Anmeldung fehlgeschlagen",
"registerFailed": "Registrierung fehlgeschlagen", "registerFailed": "Registrierung fehlgeschlagen",
"allFieldsRequired": "Alle Felder sind erforderlich", "allFieldsRequired": "Alle Felder sind erforderlich",
"verificationSent": "Verifizierungs-E-Mail wurde gesendet!", "verificationSent": "Verifizierungs-E-Mail wurde gesendet",
"verificationSentDesc": "Wir haben dir eine E-Mail mit einem Bestätigungslink geschickt. Bitte klicke auf den Link, um dein Konto zu aktivieren.", "verificationSentDesc": "Wir haben dir eine E-Mail mit einem Bestätigungslink geschickt. Bitte klicke auf den Link, um dein Konto zu aktivieren.",
"checkYourEmail": "Prüfe dein Postfach", "checkYourEmail": "Prüfe dein Postfach",
"verifying": "E-Mail wird verifiziert...", "verifying": "E-Mail wird verifiziert...",
"verifySuccess": "Deine E-Mail-Adresse wurde erfolgreich bestätigt. Du kannst dich jetzt anmelden.", "verifySuccess": "Deine E-Mail-Adresse wurde erfolgreich bestätigt. Du kannst dich jetzt anmelden.",
"verifySuccessTitle": "E-Mail bestätigt!", "verifySuccessTitle": "E-Mail bestätigt",
"verifyFailed": "Verifizierung fehlgeschlagen", "verifyFailed": "Verifizierung fehlgeschlagen",
"verifyFailedTitle": "Verifizierung fehlgeschlagen", "verifyFailedTitle": "Verifizierung fehlgeschlagen",
"verifyTokenMissing": "Kein Verifizierungstoken vorhanden.", "verifyTokenMissing": "Kein Verifizierungstoken vorhanden.",
@@ -88,7 +90,7 @@
"emailVerificationBanner": "Deine E-Mail-Adresse wurde noch nicht verifiziert.", "emailVerificationBanner": "Deine E-Mail-Adresse wurde noch nicht verifiziert.",
"emailVerificationResend": "Hier klicken um eine neue Verifizierungsmail zu erhalten", "emailVerificationResend": "Hier klicken um eine neue Verifizierungsmail zu erhalten",
"emailVerificationResendCooldown": "Erneut senden in {seconds}s", "emailVerificationResendCooldown": "Erneut senden in {seconds}s",
"emailVerificationResendSuccess": "Verifizierungsmail wurde gesendet!", "emailVerificationResendSuccess": "Verifizierungsmail wurde gesendet",
"emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden", "emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden",
"inviteOnly": "Nur mit Einladung", "inviteOnly": "Nur mit Einladung",
"inviteOnlyDesc": "Die Registrierung ist derzeit eingeschränkt. Sie benötigen einen Einladungslink von einem Administrator, um ein Konto zu erstellen.", "inviteOnlyDesc": "Die Registrierung ist derzeit eingeschränkt. Sie benötigen einen Einladungslink von einem Administrator, um ein Konto zu erstellen.",
@@ -100,6 +102,22 @@
"oauthNoToken": "Kein Authentifizierungstoken erhalten.", "oauthNoToken": "Kein Authentifizierungstoken erhalten.",
"oauthLoginFailed": "Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.", "oauthLoginFailed": "Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.",
"oauthRedirecting": "Du wirst angemeldet...", "oauthRedirecting": "Du wirst angemeldet...",
"forgotPassword": "Passwort vergessen?",
"forgotPasswordTitle": "Passwort zurücksetzen",
"forgotPasswordSubtitle": "Gib deine E-Mail-Adresse ein. Wir senden dir einen Link zum Zurücksetzen deines Passworts.",
"forgotPasswordSubmit": "Link senden",
"forgotPasswordSent": "Wenn ein Konto für diese Adresse existiert, haben wir dir eine E-Mail zum Zurücksetzen des Passworts gesendet.",
"forgotPasswordSentTitle": "Prüfe dein Postfach",
"forgotPasswordFailed": "Anfrage konnte nicht gesendet werden. Bitte versuche es erneut.",
"resetPasswordTitle": "Neues Passwort festlegen",
"resetPasswordSubtitle": "Wähle ein neues Passwort für dein Konto.",
"newPassword": "Neues Passwort",
"resetPasswordSubmit": "Passwort zurücksetzen",
"resetPasswordSuccess": "Dein Passwort wurde zurückgesetzt. Du kannst dich jetzt anmelden.",
"resetPasswordSuccessTitle": "Passwort zurückgesetzt",
"resetPasswordFailed": "Passwort konnte nicht zurückgesetzt werden.",
"resetTokenMissing": "Kein Token zum Zurücksetzen vorhanden.",
"passwordMinLength": "Passwort muss mindestens 8 Zeichen lang sein",
"2fa": { "2fa": {
"title": "Zwei-Faktor-Authentifizierung", "title": "Zwei-Faktor-Authentifizierung",
"prompt": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.", "prompt": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
@@ -110,28 +128,22 @@
} }
}, },
"home": { "home": {
"madeFor": "Made for BigBlueButton", "heroTitle": "Selbst gehostetes Frontend für BigBlueButton",
"heroTitle": "Meetings neu ", "heroSubtitle": "Redlight verwaltet Räume, Aufnahmen und Termine für deinen BigBlueButton-Server mit Federation zwischen Instanzen, CalDAV-Sync und SSO-Anbindung.",
"heroTitleHighlight": "definiert", "getStarted": "Konto erstellen",
"heroSubtitle": "Das moderne, selbst gehostete BigBlueButton-Frontend. Erstellen Sie Räume, verwalten Sie Aufnahmen und genießen Sie ein wunderschönes Interface mit über 15 Themes.", "features": "Funktionen",
"getStarted": "Jetzt starten",
"features": "Alles was Sie brauchen",
"featuresSubtitle": "Redlight bietet alle Funktionen, die Sie für professionelle Videokonferenzen benötigen.",
"featureVideoTitle": "Videokonferenzen",
"featureVideoDesc": "Erstellen und verwalten Sie Meetings direkt über BigBlueButton.",
"featureRoomsTitle": "Raumverwaltung", "featureRoomsTitle": "Raumverwaltung",
"featureRoomsDesc": "Unbegrenzte Räume mit individuellen Einstellungen und Zugangscodes.", "featureRoomsDesc": "Räume mit eigenen Einstellungen, Zugangscodes und geteiltem Zugriff für Kolleginnen und Kollegen.",
"featureUsersTitle": "Benutzerverwaltung",
"featureUsersDesc": "Registrierung, Login und Rollenverwaltung für Ihre Organisation.",
"featureThemesTitle": "15+ Themes",
"featureThemesDesc": "Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox und viele mehr.",
"featureRecordingsTitle": "Aufnahmen", "featureRecordingsTitle": "Aufnahmen",
"featureRecordingsDesc": "Alle Aufnahmen pro Raum einsehen, veröffentlichen oder löschen.", "featureRecordingsDesc": "Aufnahmen pro Raum einsehen, veröffentlichen oder löschen.",
"featureOpenSourceTitle": "Open Source", "featureFederationTitle": "Federation",
"featureOpenSourceDesc": "Vollständig quelloffen und selbst gehostet. Ihre Daten bleiben bei Ihnen.", "featureFederationDesc": "Nutzer anderer Redlight-Instanzen einladen und instanzübergreifende Einladungen annehmen.",
"statThemes": "Themes", "featureCalendarTitle": "Kalender",
"statRooms": "Räume", "featureCalendarDesc": "Meetings planen, per CalDAV mit Thunderbird, Apple Calendar oder DAVx⁵ synchronisieren, Erinnerungen erhalten.",
"statOpenSource": "Open Source", "featureVideoTitle": "Videokonferenzen",
"featureVideoDesc": "Meetings direkt über BigBlueButton erstellen, starten und verwalten.",
"featureAnalyticsTitle": "Learning Analytics",
"featureAnalyticsDesc": "Teilnahme- und Aktivitätsdaten aus Meetings einsehen und als CSV, Excel oder PDF exportieren.",
"footer": "© {year} Redlight. Ein Open-Source BigBlueButton Frontend." "footer": "© {year} Redlight. Ein Open-Source BigBlueButton Frontend."
}, },
"dashboard": { "dashboard": {
@@ -152,7 +164,7 @@
"accessCode": "Zugangscode", "accessCode": "Zugangscode",
"muteOnJoin": "Teilnehmer beim Beitritt stummschalten", "muteOnJoin": "Teilnehmer beim Beitritt stummschalten",
"allowRecording": "Aufnahme erlauben", "allowRecording": "Aufnahme erlauben",
"roomCreated": "Raum erstellt!", "roomCreated": "Raum erstellt",
"roomCreateFailed": "Raum konnte nicht erstellt werden", "roomCreateFailed": "Raum konnte nicht erstellt werden",
"roomDeleted": "Raum gelöscht", "roomDeleted": "Raum gelöscht",
"roomDeleteFailed": "Raum konnte nicht gelöscht werden", "roomDeleteFailed": "Raum konnte nicht gelöscht werden",
@@ -176,7 +188,7 @@
"copyLink": "Link kopieren", "copyLink": "Link kopieren",
"copyRoomLink": "Raum-Link", "copyRoomLink": "Raum-Link",
"copyGuestLink": "Gast-Link", "copyGuestLink": "Gast-Link",
"linkCopied": "Link kopiert!", "linkCopied": "Link kopiert",
"meetingDetails": "Meeting-Details", "meetingDetails": "Meeting-Details",
"meetingId": "Meeting ID", "meetingId": "Meeting ID",
"status": "Status", "status": "Status",
@@ -197,11 +209,12 @@
"anyoneCanStart": "Jeder kann das Meeting starten", "anyoneCanStart": "Jeder kann das Meeting starten",
"allJoinModerator": "Alle Teilnehmer als Moderator", "allJoinModerator": "Alle Teilnehmer als Moderator",
"allowRecording": "Aufnahme erlauben", "allowRecording": "Aufnahme erlauben",
"enableTranscript": "KI-Zusammenfassung / Transkript für Aufnahmen",
"noAccessCode": "Kein Zugangscode", "noAccessCode": "Kein Zugangscode",
"emptyNoCode": "Leer = kein Code", "emptyNoCode": "Leer = kein Code",
"settingsSaved": "Einstellungen gespeichert", "settingsSaved": "Einstellungen gespeichert",
"settingsSaveFailed": "Einstellungen konnten nicht gespeichert werden", "settingsSaveFailed": "Einstellungen konnten nicht gespeichert werden",
"meetingStarted": "Meeting gestartet!", "meetingStarted": "Meeting gestartet",
"meetingStartFailed": "Meeting konnte nicht gestartet werden", "meetingStartFailed": "Meeting konnte nicht gestartet werden",
"meetingEnded": "Meeting beendet", "meetingEnded": "Meeting beendet",
"meetingEndFailed": "Meeting konnte nicht beendet werden", "meetingEndFailed": "Meeting konnte nicht beendet werden",
@@ -216,7 +229,7 @@
"moderatorCodeHint": "Optionaler Code für Moderator-Rechte", "moderatorCodeHint": "Optionaler Code für Moderator-Rechte",
"moderatorCodeDesc": "Gäste, die diesen Code eingeben, erhalten Moderator-Rechte.", "moderatorCodeDesc": "Gäste, die diesen Code eingeben, erhalten Moderator-Rechte.",
"guestLink": "Gast-Einladungslink", "guestLink": "Gast-Einladungslink",
"guestLinkCopied": "Gast-Link kopiert!", "guestLinkCopied": "Gast-Link kopiert",
"guestJoinTitle": "Meeting beitreten", "guestJoinTitle": "Meeting beitreten",
"guestCreatedBy": "Erstellt von", "guestCreatedBy": "Erstellt von",
"guestMeetingRunning": "Meeting läuft", "guestMeetingRunning": "Meeting läuft",
@@ -351,7 +364,7 @@
"statusDisabledDesc": "Aktiviere die Zwei-Faktor-Authentifizierung für zusätzliche Sicherheit.", "statusDisabledDesc": "Aktiviere die Zwei-Faktor-Authentifizierung für zusätzliche Sicherheit.",
"enable": "2FA aktivieren", "enable": "2FA aktivieren",
"disable": "2FA deaktivieren", "disable": "2FA deaktivieren",
"enabled": "Zwei-Faktor-Authentifizierung aktiviert!", "enabled": "Zwei-Faktor-Authentifizierung aktiviert",
"disabled": "Zwei-Faktor-Authentifizierung deaktiviert.", "disabled": "Zwei-Faktor-Authentifizierung deaktiviert.",
"enableFailed": "2FA konnte nicht aktiviert werden", "enableFailed": "2FA konnte nicht aktiviert werden",
"disableFailed": "2FA konnte nicht deaktiviert werden", "disableFailed": "2FA konnte nicht deaktiviert werden",
@@ -380,7 +393,7 @@
"revoked": "Token widerrufen", "revoked": "Token widerrufen",
"revokeFailed": "Token konnte nicht widerrufen werden", "revokeFailed": "Token konnte nicht widerrufen werden",
"createFailed": "Token konnte nicht erstellt werden", "createFailed": "Token konnte nicht erstellt werden",
"newTokenCreated": "Token erstellt — jetzt kopieren!", "newTokenCreated": "Token erstellt — jetzt kopieren",
"newTokenHint": "Dieses Token wird nur einmal angezeigt. Kopiere es und trage es als Passwort in deiner Kalender-App ein.", "newTokenHint": "Dieses Token wird nur einmal angezeigt. Kopiere es und trage es als Passwort in deiner Kalender-App ein.",
"dismiss": "Ich habe das Token kopiert" "dismiss": "Ich habe das Token kopiert"
} }
@@ -448,11 +461,11 @@
"inviteTitle": "Benutzer-Einladungen", "inviteTitle": "Benutzer-Einladungen",
"inviteDescription": "Laden Sie neue Benutzer per E-Mail ein. Sie erhalten einen Registrierungslink, der 7 Tage gültig ist.", "inviteDescription": "Laden Sie neue Benutzer per E-Mail ein. Sie erhalten einen Registrierungslink, der 7 Tage gültig ist.",
"sendInvite": "Einladung senden", "sendInvite": "Einladung senden",
"inviteSent": "Einladung gesendet!", "inviteSent": "Einladung gesendet",
"inviteFailed": "Einladung konnte nicht gesendet werden", "inviteFailed": "Einladung konnte nicht gesendet werden",
"inviteDeleted": "Einladung gelöscht", "inviteDeleted": "Einladung gelöscht",
"inviteDeleteFailed": "Einladung konnte nicht gelöscht werden", "inviteDeleteFailed": "Einladung konnte nicht gelöscht werden",
"inviteLinkCopied": "Einladungslink kopiert!", "inviteLinkCopied": "Einladungslink kopiert",
"copyInviteLink": "Einladungslink kopieren", "copyInviteLink": "Einladungslink kopieren",
"inviteExpired": "Abgelaufen", "inviteExpired": "Abgelaufen",
"inviteUsedBy": "Verwendet von", "inviteUsedBy": "Verwendet von",
@@ -484,7 +497,21 @@
"oauthRemoveConfirm": "OAuth-Konfiguration wirklich entfernen? Benutzer können sich dann nicht mehr per SSO anmelden.", "oauthRemoveConfirm": "OAuth-Konfiguration wirklich entfernen? Benutzer können sich dann nicht mehr per SSO anmelden.",
"oauthNotConfigured": "OAuth ist noch nicht konfiguriert.", "oauthNotConfigured": "OAuth ist noch nicht konfiguriert.",
"oauthSave": "OAuth speichern", "oauthSave": "OAuth speichern",
"oauthRemove": "OAuth entfernen" "oauthRemove": "OAuth entfernen",
"roomsTitle": "Raumverwaltung",
"roomsDescription": "Alle Räume der Instanz einsehen, verwalten und bei Bedarf löschen.",
"searchRooms": "Räume suchen...",
"roomName": "Name",
"roomOwner": "Besitzer",
"roomShares": "Geteilt",
"roomCreated": "Erstellt",
"roomView": "Raum öffnen",
"deleteRoom": "Raum löschen",
"deleteRoomConfirm": "Raum \"{name}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.",
"roomDeleted": "Raum gelöscht",
"roomDeleteFailed": "Raum konnte nicht gelöscht werden",
"noRoomsFound": "Keine Räume vorhanden",
"showAllRooms": "Alle {count} Räume anzeigen"
}, },
"notifications": { "notifications": {
"bell": "Benachrichtigungen", "bell": "Benachrichtigungen",
@@ -500,14 +527,19 @@
"inbox": "Einladungen", "inbox": "Einladungen",
"inboxSubtitle": "Meeting-Einladungen von anderen Redlight-Instanzen", "inboxSubtitle": "Meeting-Einladungen von anderen Redlight-Instanzen",
"inviteTitle": "Remote-Benutzer einladen", "inviteTitle": "Remote-Benutzer einladen",
"inviteSubtitle": "Einen Benutzer von einer anderen Redlight-Instanz zu diesem Meeting einladen.", "inviteSubtitle": "Du kannst entweder einen Benutzer von einer anderen Redlight-Instanz über seine Adresse einladen oder direkt eine E-Mail-Einladung senden. Es kann nur eine Option gleichzeitig verwendet werden.",
"addressLabel": "Benutzeradresse", "addressLabel": "Benutzeradresse",
"addressPlaceholder": "@benutzer@andere-instanz.com", "addressPlaceholder": "@benutzer@andere-instanz.com",
"addressHint": "Format: @Benutzername@Domain der Redlight-Instanz", "addressHint": "Format: @Benutzername@Domain der Redlight-Instanz",
"emailLabel": "Per E-Mail einladen",
"emailPlaceholder": "name@beispiel.de, name2@beispiel.de",
"emailHint": "Eine oder mehrere E-Mail-Adressen, durch Komma getrennt",
"messageLabel": "Nachricht (optional)", "messageLabel": "Nachricht (optional)",
"messagePlaceholder": "Hallo, ich lade dich zu unserem Meeting ein!", "messagePlaceholder": "Hallo, ich lade dich zu unserem Meeting ein!",
"send": "Einladung senden", "send": "Einladung senden",
"sent": "Einladung gesendet!", "sent": "Einladung gesendet",
"emailSent": "E-Mail-Einladung(en) gesendet",
"emailSendFailed": "E-Mail-Einladung konnte nicht gesendet werden",
"sendFailed": "Einladung konnte nicht gesendet werden", "sendFailed": "Einladung konnte nicht gesendet werden",
"from": "Von", "from": "Von",
"accept": "Annehmen", "accept": "Annehmen",
@@ -532,7 +564,7 @@
"removeRoomConfirm": "Raum wirklich entfernen?", "removeRoomConfirm": "Raum wirklich entfernen?",
"roomRemoved": "Raum entfernt", "roomRemoved": "Raum entfernt",
"roomRemoveFailed": "Raum konnte nicht entfernt werden", "roomRemoveFailed": "Raum konnte nicht entfernt werden",
"acceptedSaved": "Einladung angenommen - Raum wurde in deinem Dashboard gespeichert!", "acceptedSaved": "Einladung angenommen - Raum wurde in deinem Dashboard gespeichert",
"meetingId": "Meeting ID", "meetingId": "Meeting ID",
"maxParticipants": "Max. Teilnehmer", "maxParticipants": "Max. Teilnehmer",
"recordingOn": "Aufnahme aktiviert", "recordingOn": "Aufnahme aktiviert",
@@ -548,9 +580,9 @@
"roomDeleted": "Gelöscht", "roomDeleted": "Gelöscht",
"roomDeletedNotice": "Dieser Raum wurde vom Besitzer auf der Ursprungsinstanz gelöscht und ist nicht mehr verfügbar.", "roomDeletedNotice": "Dieser Raum wurde vom Besitzer auf der Ursprungsinstanz gelöscht und ist nicht mehr verfügbar.",
"calendarEvent": "Kalendereinladung", "calendarEvent": "Kalendereinladung",
"calendarAccepted": "Kalender-Event angenommen und in deinen Kalender eingetragen!", "calendarAccepted": "Kalender-Event angenommen und in deinen Kalender eingetragen",
"localCalendarEvent": "Lokale Kalendereinladung", "localCalendarEvent": "Lokale Kalendereinladung",
"calendarLocalAccepted": "Einladung angenommen - Event wurde in deinen Kalender eingetragen!", "calendarLocalAccepted": "Einladung angenommen - Event wurde in deinen Kalender eingetragen",
"invitationRemoved": "Einladung entfernt", "invitationRemoved": "Einladung entfernt",
"removeInvitation": "Einladung entfernen" "removeInvitation": "Einladung entfernen"
}, },
@@ -579,8 +611,8 @@
"reminder1440": "1 Tag vorher", "reminder1440": "1 Tag vorher",
"timezone": "Zeitzone", "timezone": "Zeitzone",
"color": "Farbe", "color": "Farbe",
"eventCreated": "Event erstellt!", "eventCreated": "Event erstellt",
"eventUpdated": "Event aktualisiert!", "eventUpdated": "Event aktualisiert",
"eventDeleted": "Event gelöscht", "eventDeleted": "Event gelöscht",
"saveFailed": "Event konnte nicht gespeichert werden", "saveFailed": "Event konnte nicht gespeichert werden",
"deleteFailed": "Event konnte nicht gelöscht werden", "deleteFailed": "Event konnte nicht gelöscht werden",
@@ -607,7 +639,7 @@
"shareAdded": "Benutzer zum Event hinzugefügt", "shareAdded": "Benutzer zum Event hinzugefügt",
"shareRemoved": "Freigabe entfernt", "shareRemoved": "Freigabe entfernt",
"shareFailed": "Event konnte nicht geteilt werden", "shareFailed": "Event konnte nicht geteilt werden",
"invitationSent": "Einladung gesendet!", "invitationSent": "Einladung gesendet",
"invitationCancelled": "Einladung widerrufen", "invitationCancelled": "Einladung widerrufen",
"invitationPending": "Einladung ausstehend", "invitationPending": "Einladung ausstehend",
"pendingInvitations": "Ausstehende Einladungen", "pendingInvitations": "Ausstehende Einladungen",
+75 -43
View File
@@ -23,7 +23,9 @@
"protected": "Protected", "protected": "Protected",
"live": "Live", "live": "Live",
"error": "Error", "error": "Error",
"success": "Success" "success": "Success",
"gridView": "Grid view",
"listView": "List view"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -62,16 +64,16 @@
"passwordMismatch": "Passwords do not match", "passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 6 characters", "passwordTooShort": "Password must be at least 6 characters",
"loginSuccess": "Welcome back!", "loginSuccess": "Welcome back!",
"registerSuccess": "Registration successful!", "registerSuccess": "Registration successful",
"loginFailed": "Login failed", "loginFailed": "Login failed",
"registerFailed": "Registration failed", "registerFailed": "Registration failed",
"allFieldsRequired": "All fields are required", "allFieldsRequired": "All fields are required",
"verificationSent": "Verification email sent!", "verificationSent": "Verification email sent",
"verificationSentDesc": "We've sent you an email with a verification link. Please click the link to activate your account.", "verificationSentDesc": "We've sent you an email with a verification link. Please click the link to activate your account.",
"checkYourEmail": "Check your inbox", "checkYourEmail": "Check your inbox",
"verifying": "Verifying your email...", "verifying": "Verifying your email...",
"verifySuccess": "Your email has been verified successfully. You can now sign in.", "verifySuccess": "Your email has been verified successfully. You can now sign in.",
"verifySuccessTitle": "Email verified!", "verifySuccessTitle": "Email verified",
"verifyFailed": "Verification failed", "verifyFailed": "Verification failed",
"verifyFailedTitle": "Verification failed", "verifyFailedTitle": "Verification failed",
"verifyTokenMissing": "No verification token provided.", "verifyTokenMissing": "No verification token provided.",
@@ -88,7 +90,7 @@
"emailVerificationBanner": "Your email address has not been verified yet.", "emailVerificationBanner": "Your email address has not been verified yet.",
"emailVerificationResend": "Click here to receive a new verification email", "emailVerificationResend": "Click here to receive a new verification email",
"emailVerificationResendCooldown": "Resend in {seconds}s", "emailVerificationResendCooldown": "Resend in {seconds}s",
"emailVerificationResendSuccess": "Verification email sent!", "emailVerificationResendSuccess": "Verification email sent",
"emailVerificationResendFailed": "Could not send verification email", "emailVerificationResendFailed": "Could not send verification email",
"inviteOnly": "Invite Only", "inviteOnly": "Invite Only",
"inviteOnlyDesc": "Registration is currently restricted. You need an invitation link from an administrator to create an account.", "inviteOnlyDesc": "Registration is currently restricted. You need an invitation link from an administrator to create an account.",
@@ -100,6 +102,22 @@
"oauthNoToken": "No authentication token received.", "oauthNoToken": "No authentication token received.",
"oauthLoginFailed": "Could not complete sign in. Please try again.", "oauthLoginFailed": "Could not complete sign in. Please try again.",
"oauthRedirecting": "Signing you in...", "oauthRedirecting": "Signing you in...",
"forgotPassword": "Forgot password?",
"forgotPasswordTitle": "Reset your password",
"forgotPasswordSubtitle": "Enter your email address and we'll send you a link to reset your password.",
"forgotPasswordSubmit": "Send link",
"forgotPasswordSent": "If an account exists for that address, we've sent you a password reset email.",
"forgotPasswordSentTitle": "Check your inbox",
"forgotPasswordFailed": "Could not send the request. Please try again.",
"resetPasswordTitle": "Set a new password",
"resetPasswordSubtitle": "Choose a new password for your account.",
"newPassword": "New password",
"resetPasswordSubmit": "Reset password",
"resetPasswordSuccess": "Your password has been reset. You can now sign in.",
"resetPasswordSuccessTitle": "Password reset",
"resetPasswordFailed": "Could not reset password.",
"resetTokenMissing": "No reset token provided.",
"passwordMinLength": "Password must be at least 8 characters",
"2fa": { "2fa": {
"title": "Two-Factor Authentication", "title": "Two-Factor Authentication",
"prompt": "Enter the 6-digit code from your authenticator app.", "prompt": "Enter the 6-digit code from your authenticator app.",
@@ -110,28 +128,22 @@
} }
}, },
"home": { "home": {
"madeFor": "Made for BigBlueButton", "heroTitle": "Self-hosted frontend for BigBlueButton",
"heroTitle": "Meetings re", "heroSubtitle": "Redlight manages rooms, recordings and scheduling for your BigBlueButton server with federation between instances, CalDAV sync and SSO support.",
"heroTitleHighlight": "defined", "getStarted": "Create account",
"heroSubtitle": "The modern, self-hosted BigBlueButton frontend. Create rooms, manage recordings and enjoy a beautiful interface with over 15 themes.", "features": "Features",
"getStarted": "Get started", "featureRoomsTitle": "Room management",
"features": "Everything you need", "featureRoomsDesc": "Rooms with individual settings, access codes and shared access for colleagues.",
"featuresSubtitle": "Redlight provides all the features you need for professional video conferencing.",
"featureVideoTitle": "Video Conferencing",
"featureVideoDesc": "Create and manage meetings directly via BigBlueButton.",
"featureRoomsTitle": "Room Management",
"featureRoomsDesc": "Unlimited rooms with individual settings and access codes.",
"featureUsersTitle": "User Management",
"featureUsersDesc": "Registration, login and role management for your organization.",
"featureThemesTitle": "15+ Themes",
"featureThemesDesc": "Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox and many more.",
"featureRecordingsTitle": "Recordings", "featureRecordingsTitle": "Recordings",
"featureRecordingsDesc": "View, publish or delete all recordings per room.", "featureRecordingsDesc": "View, publish or delete recordings per room.",
"featureOpenSourceTitle": "Open Source", "featureFederationTitle": "Federation",
"featureOpenSourceDesc": "Fully open source and self-hosted. Your data stays with you.", "featureFederationDesc": "Invite users from other Redlight instances and accept cross-instance invitations.",
"statThemes": "Themes", "featureCalendarTitle": "Calendar",
"statRooms": "Rooms", "featureCalendarDesc": "Schedule meetings, sync via CalDAV with Thunderbird, Apple Calendar or DAVx⁵, get reminders.",
"statOpenSource": "Open Source", "featureVideoTitle": "Video conferencing",
"featureVideoDesc": "Create, start and manage meetings directly via BigBlueButton.",
"featureAnalyticsTitle": "Learning analytics",
"featureAnalyticsDesc": "Review attendance and activity data from meetings and export it as CSV, Excel or PDF.",
"footer": "© {year} Redlight. An open source BigBlueButton frontend." "footer": "© {year} Redlight. An open source BigBlueButton frontend."
}, },
"dashboard": { "dashboard": {
@@ -152,7 +164,7 @@
"accessCode": "Access code", "accessCode": "Access code",
"muteOnJoin": "Mute participants on join", "muteOnJoin": "Mute participants on join",
"allowRecording": "Allow recording", "allowRecording": "Allow recording",
"roomCreated": "Room created!", "roomCreated": "Room created",
"roomCreateFailed": "Room could not be created", "roomCreateFailed": "Room could not be created",
"roomDeleted": "Room deleted", "roomDeleted": "Room deleted",
"roomDeleteFailed": "Room could not be deleted", "roomDeleteFailed": "Room could not be deleted",
@@ -176,7 +188,7 @@
"copyLink": "Copy link", "copyLink": "Copy link",
"copyRoomLink": "Room Link", "copyRoomLink": "Room Link",
"copyGuestLink": "Guest Link", "copyGuestLink": "Guest Link",
"linkCopied": "Link copied!", "linkCopied": "Link copied",
"meetingDetails": "Meeting details", "meetingDetails": "Meeting details",
"meetingId": "Meeting ID", "meetingId": "Meeting ID",
"status": "Status", "status": "Status",
@@ -197,11 +209,12 @@
"anyoneCanStart": "Anyone can start the meeting", "anyoneCanStart": "Anyone can start the meeting",
"allJoinModerator": "All participants as moderator", "allJoinModerator": "All participants as moderator",
"allowRecording": "Allow recording", "allowRecording": "Allow recording",
"enableTranscript": "AI summary / transcript for recordings",
"noAccessCode": "No access code", "noAccessCode": "No access code",
"emptyNoCode": "Empty = no code", "emptyNoCode": "Empty = no code",
"settingsSaved": "Settings saved", "settingsSaved": "Settings saved",
"settingsSaveFailed": "Settings could not be saved", "settingsSaveFailed": "Settings could not be saved",
"meetingStarted": "Meeting started!", "meetingStarted": "Meeting started",
"meetingStartFailed": "Meeting could not be started", "meetingStartFailed": "Meeting could not be started",
"meetingEnded": "Meeting ended", "meetingEnded": "Meeting ended",
"meetingEndFailed": "Meeting could not be ended", "meetingEndFailed": "Meeting could not be ended",
@@ -216,7 +229,7 @@
"moderatorCodeHint": "Optional code for moderator rights", "moderatorCodeHint": "Optional code for moderator rights",
"moderatorCodeDesc": "Guests who enter this code will receive moderator rights.", "moderatorCodeDesc": "Guests who enter this code will receive moderator rights.",
"guestLink": "Guest Invite Link", "guestLink": "Guest Invite Link",
"guestLinkCopied": "Guest link copied!", "guestLinkCopied": "Guest link copied",
"guestJoinTitle": "Join Meeting", "guestJoinTitle": "Join Meeting",
"guestCreatedBy": "Created by", "guestCreatedBy": "Created by",
"guestMeetingRunning": "Meeting in progress", "guestMeetingRunning": "Meeting in progress",
@@ -351,7 +364,7 @@
"statusDisabledDesc": "Enable two-factor authentication for an extra layer of security.", "statusDisabledDesc": "Enable two-factor authentication for an extra layer of security.",
"enable": "Enable 2FA", "enable": "Enable 2FA",
"disable": "Disable 2FA", "disable": "Disable 2FA",
"enabled": "Two-factor authentication enabled!", "enabled": "Two-factor authentication enabled",
"disabled": "Two-factor authentication disabled.", "disabled": "Two-factor authentication disabled.",
"enableFailed": "Could not enable 2FA", "enableFailed": "Could not enable 2FA",
"disableFailed": "Could not disable 2FA", "disableFailed": "Could not disable 2FA",
@@ -380,7 +393,7 @@
"revoked": "Token revoked", "revoked": "Token revoked",
"revokeFailed": "Could not revoke token", "revokeFailed": "Could not revoke token",
"createFailed": "Could not create token", "createFailed": "Could not create token",
"newTokenCreated": "Token created — copy it now!", "newTokenCreated": "Token created — copy it now",
"newTokenHint": "This token will only be shown once. Copy it and enter it as the password in your calendar app.", "newTokenHint": "This token will only be shown once. Copy it and enter it as the password in your calendar app.",
"dismiss": "I have copied the token" "dismiss": "I have copied the token"
} }
@@ -448,11 +461,11 @@
"inviteTitle": "User Invitations", "inviteTitle": "User Invitations",
"inviteDescription": "Invite new users by email. They will receive a registration link valid for 7 days.", "inviteDescription": "Invite new users by email. They will receive a registration link valid for 7 days.",
"sendInvite": "Send invite", "sendInvite": "Send invite",
"inviteSent": "Invitation sent!", "inviteSent": "Invitation sent",
"inviteFailed": "Could not send invitation", "inviteFailed": "Could not send invitation",
"inviteDeleted": "Invitation deleted", "inviteDeleted": "Invitation deleted",
"inviteDeleteFailed": "Could not delete invitation", "inviteDeleteFailed": "Could not delete invitation",
"inviteLinkCopied": "Invite link copied!", "inviteLinkCopied": "Invite link copied",
"copyInviteLink": "Copy invite link", "copyInviteLink": "Copy invite link",
"inviteExpired": "Expired", "inviteExpired": "Expired",
"inviteUsedBy": "Used by", "inviteUsedBy": "Used by",
@@ -484,7 +497,21 @@
"oauthRemoveConfirm": "Really remove OAuth configuration? Users will no longer be able to sign in with SSO.", "oauthRemoveConfirm": "Really remove OAuth configuration? Users will no longer be able to sign in with SSO.",
"oauthNotConfigured": "OAuth is not configured yet.", "oauthNotConfigured": "OAuth is not configured yet.",
"oauthSave": "Save OAuth", "oauthSave": "Save OAuth",
"oauthRemove": "Remove OAuth" "oauthRemove": "Remove OAuth",
"roomsTitle": "Room Management",
"roomsDescription": "View, manage, and delete all rooms on this instance.",
"searchRooms": "Search rooms...",
"roomName": "Name",
"roomOwner": "Owner",
"roomShares": "Shared",
"roomCreated": "Created",
"roomView": "View room",
"deleteRoom": "Delete room",
"deleteRoomConfirm": "Really delete room \"{name}\"? This cannot be undone.",
"roomDeleted": "Room deleted",
"roomDeleteFailed": "Room could not be deleted",
"noRoomsFound": "No rooms found",
"showAllRooms": "Show all {count} rooms"
}, },
"notifications": { "notifications": {
"bell": "Notifications", "bell": "Notifications",
@@ -500,14 +527,19 @@
"inbox": "Invitations", "inbox": "Invitations",
"inboxSubtitle": "Meeting invitations from other Redlight instances", "inboxSubtitle": "Meeting invitations from other Redlight instances",
"inviteTitle": "Invite Remote User", "inviteTitle": "Invite Remote User",
"inviteSubtitle": "Invite a user from another Redlight instance to this meeting.", "inviteSubtitle": "You can either invite a user from another Redlight instance by their address, or send an email invitation directly. Only one option can be used at a time.",
"addressLabel": "User address", "addressLabel": "User address",
"addressPlaceholder": "@user@other-instance.com", "addressPlaceholder": "@user@other-instance.com",
"addressHint": "Format: @username@domain of the Redlight instance", "addressHint": "Format: @username@domain of the Redlight instance",
"emailLabel": "Invite by email",
"emailPlaceholder": "name@example.com, name2@example.com",
"emailHint": "Enter one or more email addresses, separated by commas",
"messageLabel": "Message (optional)", "messageLabel": "Message (optional)",
"messagePlaceholder": "Hi, I'd like to invite you to our meeting!", "messagePlaceholder": "Hi, I'd like to invite you to our meeting!",
"send": "Send invitation", "send": "Send invitation",
"sent": "Invitation sent!", "sent": "Invitation sent",
"emailSent": "Email invitation(s) sent",
"emailSendFailed": "Could not send email invitation",
"sendFailed": "Could not send invitation", "sendFailed": "Could not send invitation",
"from": "From", "from": "From",
"accept": "Accept", "accept": "Accept",
@@ -532,7 +564,7 @@
"removeRoomConfirm": "Really remove this room?", "removeRoomConfirm": "Really remove this room?",
"roomRemoved": "Room removed", "roomRemoved": "Room removed",
"roomRemoveFailed": "Could not remove room", "roomRemoveFailed": "Could not remove room",
"acceptedSaved": "Invitation accepted - room saved to your dashboard!", "acceptedSaved": "Invitation accepted - room saved to your dashboard",
"meetingId": "Meeting ID", "meetingId": "Meeting ID",
"maxParticipants": "Max. participants", "maxParticipants": "Max. participants",
"recordingOn": "Recording enabled", "recordingOn": "Recording enabled",
@@ -548,9 +580,9 @@
"roomDeleted": "Deleted", "roomDeleted": "Deleted",
"roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available.", "roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available.",
"calendarEvent": "Calendar Invitation", "calendarEvent": "Calendar Invitation",
"calendarAccepted": "Calendar event accepted and added to your calendar!", "calendarAccepted": "Calendar event accepted and added to your calendar",
"localCalendarEvent": "Local Calendar Invitation", "localCalendarEvent": "Local Calendar Invitation",
"calendarLocalAccepted": "Invitation accepted - event added to your calendar!", "calendarLocalAccepted": "Invitation accepted - event added to your calendar",
"invitationRemoved": "Invitation removed", "invitationRemoved": "Invitation removed",
"removeInvitation": "Remove invitation" "removeInvitation": "Remove invitation"
}, },
@@ -579,8 +611,8 @@
"reminder1440": "1 day before", "reminder1440": "1 day before",
"timezone": "Timezone", "timezone": "Timezone",
"color": "Color", "color": "Color",
"eventCreated": "Event created!", "eventCreated": "Event created",
"eventUpdated": "Event updated!", "eventUpdated": "Event updated",
"eventDeleted": "Event deleted", "eventDeleted": "Event deleted",
"saveFailed": "Could not save event", "saveFailed": "Could not save event",
"deleteFailed": "Could not delete event", "deleteFailed": "Could not delete event",
@@ -607,7 +639,7 @@
"shareAdded": "User added to event", "shareAdded": "User added to event",
"shareRemoved": "Share removed", "shareRemoved": "Share removed",
"shareFailed": "Could not share event", "shareFailed": "Could not share event",
"invitationSent": "Invitation sent!", "invitationSent": "Invitation sent",
"invitationCancelled": "Invitation cancelled", "invitationCancelled": "Invitation cancelled",
"invitationPending": "Invitation pending", "invitationPending": "Invitation pending",
"pendingInvitations": "Pending Invitations", "pendingInvitations": "Pending Invitations",
+139 -83
View File
@@ -1,6 +1,132 @@
@tailwind base; @import 'tailwindcss';
@tailwind components;
@tailwind utilities; @theme {
--color-th-bg: var(--bg-primary);
--color-th-bg-s: var(--bg-secondary);
--color-th-bg-t: var(--bg-tertiary);
--color-th-text: var(--text-primary);
--color-th-text-s: var(--text-secondary);
--color-th-accent: var(--accent);
--color-th-accent-h: var(--accent-hover);
--color-th-accent-t: var(--accent-text);
--color-th-border: var(--border);
--color-th-card: var(--card-bg);
--color-th-input: var(--input-bg);
--color-th-input-b: var(--input-border);
--color-th-nav: var(--nav-bg);
--color-th-side: var(--sidebar-bg);
--color-th-hover: var(--hover-bg);
--color-th-success: var(--success);
--color-th-warning: var(--warning);
--color-th-error: var(--error);
--color-th-ring: var(--ring);
--font-sans: Inter, system-ui, -apple-system, sans-serif;
--shadow-th:
0 1px 3px 0 var(--shadow-color), 0 1px 2px -1px var(--shadow-color);
--shadow-th-lg:
0 10px 15px -3px var(--shadow-color), 0 4px 6px -4px var(--shadow-color);
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
/* Restore Tailwind v3 default: buttons get a pointer cursor */
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]) {
cursor: pointer;
}
}
@utility btn-primary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-accent text-th-accent-t hover:bg-th-accent-h
transition-all duration-200 ease-out
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2
cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
@utility btn-secondary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-bg-s text-th-text border border-th-border
hover:bg-th-hover transition-all duration-200 ease-out
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2
cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
@utility btn-danger {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-error text-white hover:opacity-90
transition-all duration-200 ease-out
focus:outline-hidden focus:ring-2 focus:ring-th-error focus:ring-offset-2
cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
@utility btn-ghost {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
text-th-text-s hover:bg-th-hover hover:text-th-text
transition-all duration-200 ease-out
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2
cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
@utility input-field {
@apply w-full px-4 py-2.5 rounded-lg
bg-th-input text-th-text placeholder-th-text-s
border border-th-input-b
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:border-transparent
transition-all duration-200;
}
@utility card {
@apply bg-th-card rounded-xl border border-th-border
shadow-th transition-all duration-200;
}
@utility card-hover {
@apply card hover:shadow-th-lg cursor-pointer;
&:hover {
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
}
}
@utility gradient-text {
background: linear-gradient(
135deg,
var(--gradient-start),
var(--gradient-end)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
@utility gradient-bg {
background: linear-gradient(
135deg,
var(--gradient-start),
var(--gradient-end)
);
}
@layer base { @layer base {
/* ===== DEFAULT LIGHT ===== */ /* ===== DEFAULT LIGHT ===== */
@@ -444,8 +570,9 @@
} }
} }
@layer utilities {
/* ===== SCRUNKLY.CAT DARK ===== */ /* ===== SCRUNKLY.CAT DARK ===== */
[data-theme="scrunkly-cat"] { [data-theme='scrunkly-cat'] {
color-scheme: dark; color-scheme: dark;
--picker-icon-filter: invert(0.8); --picker-icon-filter: invert(0.8);
--bg-primary: #161924; --bg-primary: #161924;
@@ -473,7 +600,7 @@
} }
/* ===== RED MODULAR LIGHT ===== */ /* ===== RED MODULAR LIGHT ===== */
[data-theme="red-modular-light"] { [data-theme='red-modular-light'] {
color-scheme: light; color-scheme: light;
--picker-icon-filter: none; --picker-icon-filter: none;
--bg-primary: #ffffff; --bg-primary: #ffffff;
@@ -501,7 +628,7 @@
} }
/* ===== EVERFOREST DARK ===== */ /* ===== EVERFOREST DARK ===== */
[data-theme="everforest-dark"] { [data-theme='everforest-dark'] {
color-scheme: dark; color-scheme: dark;
--picker-icon-filter: invert(0.8); --picker-icon-filter: invert(0.8);
--bg-primary: #2d353b; --bg-primary: #2d353b;
@@ -529,7 +656,7 @@
} }
/* ===== EVERFOREST LIGHT ===== */ /* ===== EVERFOREST LIGHT ===== */
[data-theme="everforest-light"] { [data-theme='everforest-light'] {
color-scheme: light; color-scheme: light;
--picker-icon-filter: none; --picker-icon-filter: none;
--bg-primary: #fdf6e3; --bg-primary: #fdf6e3;
@@ -557,7 +684,7 @@
} }
/* ===== KANAGAWA ===== */ /* ===== KANAGAWA ===== */
[data-theme="kanagawa"] { [data-theme='kanagawa'] {
color-scheme: dark; color-scheme: dark;
--picker-icon-filter: invert(0.8); --picker-icon-filter: invert(0.8);
--bg-primary: #1f1f28; --bg-primary: #1f1f28;
@@ -585,7 +712,7 @@
} }
/* ===== AYU DARK ===== */ /* ===== AYU DARK ===== */
[data-theme="ayu-dark"] { [data-theme='ayu-dark'] {
color-scheme: dark; color-scheme: dark;
--picker-icon-filter: invert(0.8); --picker-icon-filter: invert(0.8);
--bg-primary: #0d1017; --bg-primary: #0d1017;
@@ -613,7 +740,7 @@
} }
/* ===== MOONLIGHT ===== */ /* ===== MOONLIGHT ===== */
[data-theme="moonlight"] { [data-theme='moonlight'] {
color-scheme: dark; color-scheme: dark;
--picker-icon-filter: invert(0.8); --picker-icon-filter: invert(0.8);
--bg-primary: #212337; --bg-primary: #212337;
@@ -641,7 +768,7 @@
} }
/* ===== CYBERPUNK ===== */ /* ===== CYBERPUNK ===== */
[data-theme="cyberpunk"] { [data-theme='cyberpunk'] {
color-scheme: dark; color-scheme: dark;
--picker-icon-filter: invert(0.8); --picker-icon-filter: invert(0.8);
--bg-primary: #0a0a0f; --bg-primary: #0a0a0f;
@@ -669,7 +796,7 @@
} }
/* ===== COTTON CANDY LIGHT ===== */ /* ===== COTTON CANDY LIGHT ===== */
[data-theme="cotton-candy-light"] { [data-theme='cotton-candy-light'] {
color-scheme: light; color-scheme: light;
--picker-icon-filter: none; --picker-icon-filter: none;
--bg-primary: #fff5f9; --bg-primary: #fff5f9;
@@ -695,77 +822,6 @@
--gradient-start: #ff85a2; --gradient-start: #ff85a2;
--gradient-end: #c084fc; --gradient-end: #c084fc;
} }
@layer components {
.btn-primary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-accent text-th-accent-t hover:bg-th-accent-h
transition-all duration-200 ease-out
focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
.btn-secondary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-bg-s text-th-text border border-th-border
hover:bg-th-hover transition-all duration-200 ease-out
focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
.btn-danger {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-error text-white hover:opacity-90
transition-all duration-200 ease-out
focus:outline-none focus:ring-2 focus:ring-th-error focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
.btn-ghost {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
text-th-text-s hover:bg-th-hover hover:text-th-text
transition-all duration-200 ease-out
focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
.input-field {
@apply w-full px-4 py-2.5 rounded-lg
bg-th-input text-th-text placeholder-th-text-s
border border-th-input-b
focus:outline-none focus:ring-2 focus:ring-th-ring focus:border-transparent
transition-all duration-200;
}
.card {
@apply bg-th-card rounded-xl border border-th-border
shadow-th transition-all duration-200;
}
.card-hover {
@apply card hover:shadow-th-lg cursor-pointer;
}
.card-hover:hover {
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
}
}
@layer utilities {
.gradient-text {
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.gradient-bg {
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
}
} }
/* ═══════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════
+327 -42
View File
@@ -4,7 +4,7 @@ import {
Users, Shield, Search, Trash2, ChevronDown, Loader2, Users, Shield, Search, Trash2, ChevronDown, Loader2,
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User, MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check, Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
ShieldCheck, Globe, Link as LinkIcon, LogIn, ShieldCheck, Globe, Link as LinkIcon, LogIn, DoorOpen, Eye, ExternalLink,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
@@ -55,6 +55,14 @@ export default function Admin() {
const [oauthForm, setOauthForm] = useState({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true }); const [oauthForm, setOauthForm] = useState({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true });
const [savingOauth, setSavingOauth] = useState(false); const [savingOauth, setSavingOauth] = useState(false);
// Rooms state
const [adminRooms, setAdminRooms] = useState([]);
const [adminRoomsLoading, setAdminRoomsLoading] = useState(true);
const [roomSearch, setRoomSearch] = useState('');
const [roomsExpanded, setRoomsExpanded] = useState(false);
const [showAllRoomsModal, setShowAllRoomsModal] = useState(false);
const [allRoomsSearch, setAllRoomsSearch] = useState('');
useEffect(() => { useEffect(() => {
if (user?.role !== 'admin') { if (user?.role !== 'admin') {
navigate('/dashboard'); navigate('/dashboard');
@@ -63,6 +71,7 @@ export default function Admin() {
fetchUsers(); fetchUsers();
fetchInvites(); fetchInvites();
fetchOauthConfig(); fetchOauthConfig();
fetchAdminRooms();
}, [user]); }, [user]);
useEffect(() => { useEffect(() => {
@@ -101,6 +110,29 @@ export default function Admin() {
} }
}; };
const fetchAdminRooms = async () => {
setAdminRoomsLoading(true);
try {
const res = await api.get('/admin/rooms');
setAdminRooms(res.data.rooms);
} catch {
// silently fail
} finally {
setAdminRoomsLoading(false);
}
};
const handleAdminDeleteRoom = async (uid, name) => {
if (!confirm(t('admin.deleteRoomConfirm', { name }))) return;
try {
await api.delete(`/rooms/${uid}`);
toast.success(t('admin.roomDeleted'));
fetchAdminRooms();
} catch (err) {
toast.error(err.response?.data?.error || t('admin.roomDeleteFailed'));
}
};
const handleRoleChange = async (userId, newRole) => { const handleRoleChange = async (userId, newRole) => {
try { try {
await api.put(`/admin/users/${userId}/role`, { role: newRole }); await api.put(`/admin/users/${userId}/role`, { role: newRole });
@@ -391,17 +423,18 @@ export default function Admin() {
<div className="grid gap-6 sm:grid-cols-2"> <div className="grid gap-6 sm:grid-cols-2">
{/* Logo upload */} {/* Logo upload */}
<div> <div>
<label className="block text-sm font-medium text-th-text mb-2">{t('admin.logoLabel')}</label> <p className="block text-sm font-medium text-th-text mb-2">{t('admin.logoLabel')}</p>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{hasLogo && logoUrl ? ( {hasLogo && logoUrl ? (
<div className="relative group"> <div className="relative group">
<img <img
src={`${logoUrl}?t=${Date.now()}`} src={`${logoUrl}?t=${Date.now()}`}
alt="Logo" alt=""
className="w-14 h-14 rounded-xl object-contain border border-th-border bg-th-bg p-1" className="w-14 h-14 rounded-xl object-contain border border-th-border bg-th-bg p-1"
/> />
<button <button
onClick={handleLogoRemove} onClick={handleLogoRemove}
aria-label={t('common.delete')}
className="absolute -top-2 -right-2 w-5 h-5 bg-th-error text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" className="absolute -top-2 -right-2 w-5 h-5 bg-th-error text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
> >
<XIcon size={12} /> <XIcon size={12} />
@@ -439,11 +472,12 @@ export default function Admin() {
{/* App name */} {/* App name */}
<div> <div>
<label className="block text-sm font-medium text-th-text mb-2">{t('admin.appNameLabel')}</label> <label htmlFor="admin-app-name" className="block text-sm font-medium text-th-text mb-2">{t('admin.appNameLabel')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Type size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" /> <Type size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="admin-app-name"
type="text" type="text"
value={editAppName} value={editAppName}
onChange={e => setEditAppName(e.target.value)} onChange={e => setEditAppName(e.target.value)}
@@ -470,7 +504,7 @@ export default function Admin() {
type="button" type="button"
disabled={savingHideAppName} disabled={savingHideAppName}
onClick={() => handleHideAppNameToggle(!hideAppName)} onClick={() => handleHideAppNameToggle(!hideAppName)}
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-1 disabled:opacity-50 ml-4 ${ className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-1 disabled:opacity-50 ml-4 ${
hideAppName ? 'bg-th-accent' : 'bg-th-border' hideAppName ? 'bg-th-accent' : 'bg-th-border'
}`} }`}
aria-checked={hideAppName} aria-checked={hideAppName}
@@ -488,12 +522,13 @@ export default function Admin() {
{/* Default theme */} {/* Default theme */}
<div className="mt-6 pt-6 border-t border-th-border"> <div className="mt-6 pt-6 border-t border-th-border">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Palette size={16} className="text-th-accent" /> <Palette size={16} className="text-th-accent" aria-hidden="true" />
<label className="block text-sm font-medium text-th-text">{t('admin.defaultThemeLabel')}</label> <label htmlFor="admin-default-theme" className="block text-sm font-medium text-th-text">{t('admin.defaultThemeLabel')}</label>
</div> </div>
<p className="text-xs text-th-text-s mb-3">{t('admin.defaultThemeDesc')}</p> <p className="text-xs text-th-text-s mb-3">{t('admin.defaultThemeDesc')}</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<select <select
id="admin-default-theme"
value={editDefaultTheme} value={editDefaultTheme}
onChange={e => setEditDefaultTheme(e.target.value)} onChange={e => setEditDefaultTheme(e.target.value)}
className="input-field text-sm flex-1" className="input-field text-sm flex-1"
@@ -507,7 +542,7 @@ export default function Admin() {
<button <button
onClick={handleDefaultThemeSave} onClick={handleDefaultThemeSave}
disabled={savingDefaultTheme || editDefaultTheme === (defaultTheme || 'dark')} disabled={savingDefaultTheme || editDefaultTheme === (defaultTheme || 'dark')}
className="btn-primary text-sm px-4 flex-shrink-0" className="btn-primary text-sm px-4 shrink-0"
> >
{savingDefaultTheme ? <Loader2 size={14} className="animate-spin" /> : t('common.save')} {savingDefaultTheme ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button> </button>
@@ -517,16 +552,17 @@ export default function Admin() {
{/* Legal links */} {/* Legal links */}
<div className="mt-6 pt-6 border-t border-th-border"> <div className="mt-6 pt-6 border-t border-th-border">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<LinkIcon size={16} className="text-th-accent" /> <LinkIcon size={16} className="text-th-accent" aria-hidden="true" />
<label className="block text-sm font-medium text-th-text">{t('admin.legalLinksTitle')}</label> <p className="block text-sm font-medium text-th-text">{t('admin.legalLinksTitle')}</p>
</div> </div>
<p className="text-xs text-th-text-s mb-4">{t('admin.legalLinksDesc')}</p> <p className="text-xs text-th-text-s mb-4">{t('admin.legalLinksDesc')}</p>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{/* Imprint */} {/* Imprint */}
<div> <div>
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.imprintUrl')}</label> <label htmlFor="admin-imprint-url" className="block text-xs font-medium text-th-text-s mb-1">{t('admin.imprintUrl')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
id="admin-imprint-url"
type="url" type="url"
value={editImprintUrl} value={editImprintUrl}
onChange={e => setEditImprintUrl(e.target.value)} onChange={e => setEditImprintUrl(e.target.value)}
@@ -537,7 +573,7 @@ export default function Admin() {
<button <button
onClick={handleImprintUrlSave} onClick={handleImprintUrlSave}
disabled={savingImprintUrl || editImprintUrl === (imprintUrl || '')} disabled={savingImprintUrl || editImprintUrl === (imprintUrl || '')}
className="btn-primary text-sm px-4 flex-shrink-0" className="btn-primary text-sm px-4 shrink-0"
> >
{savingImprintUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')} {savingImprintUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button> </button>
@@ -545,9 +581,10 @@ export default function Admin() {
</div> </div>
{/* Privacy Policy */} {/* Privacy Policy */}
<div> <div>
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.privacyUrl')}</label> <label htmlFor="admin-privacy-url" className="block text-xs font-medium text-th-text-s mb-1">{t('admin.privacyUrl')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
id="admin-privacy-url"
type="url" type="url"
value={editPrivacyUrl} value={editPrivacyUrl}
onChange={e => setEditPrivacyUrl(e.target.value)} onChange={e => setEditPrivacyUrl(e.target.value)}
@@ -558,7 +595,7 @@ export default function Admin() {
<button <button
onClick={handlePrivacyUrlSave} onClick={handlePrivacyUrlSave}
disabled={savingPrivacyUrl || editPrivacyUrl === (privacyUrl || '')} disabled={savingPrivacyUrl || editPrivacyUrl === (privacyUrl || '')}
className="btn-primary text-sm px-4 flex-shrink-0" className="btn-primary text-sm px-4 shrink-0"
> >
{savingPrivacyUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')} {savingPrivacyUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button> </button>
@@ -615,20 +652,21 @@ export default function Admin() {
{/* Send invite form */} {/* Send invite form */}
<form onSubmit={handleSendInvite} className="flex items-center gap-2 mb-6"> <form onSubmit={handleSendInvite} className="flex items-center gap-2 mb-6">
<div className="relative flex-1"> <div className="relative flex-1">
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" /> <Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
type="email" type="email"
value={inviteEmail} value={inviteEmail}
onChange={e => setInviteEmail(e.target.value)} onChange={e => setInviteEmail(e.target.value)}
className="input-field pl-9 text-sm" className="input-field pl-9 text-sm"
placeholder={t('auth.emailPlaceholder')} placeholder={t('auth.emailPlaceholder')}
aria-label={t('auth.email')}
required required
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={sendingInvite || !inviteEmail.trim()} disabled={sendingInvite || !inviteEmail.trim()}
className="btn-primary text-sm px-4 flex-shrink-0" className="btn-primary text-sm px-4 shrink-0"
> >
{sendingInvite ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />} {sendingInvite ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
{t('admin.sendInvite')} {t('admin.sendInvite')}
@@ -644,7 +682,7 @@ export default function Admin() {
return ( return (
<div key={inv.id} className="flex items-center justify-between gap-3 p-3 rounded-xl bg-th-bg border border-th-border"> <div key={inv.id} className="flex items-center justify-between gap-3 p-3 rounded-xl bg-th-bg border border-th-border">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${ <div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
isUsed ? 'bg-green-500/15 text-green-400' : isExpired ? 'bg-red-500/15 text-red-400' : 'bg-th-accent/15 text-th-accent' isUsed ? 'bg-green-500/15 text-green-400' : isExpired ? 'bg-red-500/15 text-red-400' : 'bg-th-accent/15 text-th-accent'
}`}> }`}>
{isUsed ? <Check size={14} /> : isExpired ? <XIcon size={14} /> : <Clock size={14} />} {isUsed ? <Check size={14} /> : isExpired ? <XIcon size={14} /> : <Clock size={14} />}
@@ -661,7 +699,7 @@ export default function Admin() {
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 shrink-0">
{!isUsed && !isExpired && ( {!isUsed && !isExpired && (
<button <button
onClick={() => handleCopyInviteLink(inv.token)} onClick={() => handleCopyInviteLink(inv.token)}
@@ -706,8 +744,9 @@ export default function Admin() {
<form onSubmit={handleOauthSave} className="space-y-4"> <form onSubmit={handleOauthSave} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthIssuer')}</label> <label htmlFor="admin-oauth-issuer" className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthIssuer')}</label>
<input <input
id="admin-oauth-issuer"
type="url" type="url"
value={oauthForm.issuer} value={oauthForm.issuer}
onChange={e => setOauthForm(f => ({ ...f, issuer: e.target.value }))} onChange={e => setOauthForm(f => ({ ...f, issuer: e.target.value }))}
@@ -718,8 +757,9 @@ export default function Admin() {
<p className="text-xs text-th-text-s mt-1">{t('admin.oauthIssuerHint')}</p> <p className="text-xs text-th-text-s mt-1">{t('admin.oauthIssuerHint')}</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientId')}</label> <label htmlFor="admin-oauth-client-id" className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientId')}</label>
<input <input
id="admin-oauth-client-id"
type="text" type="text"
value={oauthForm.clientId} value={oauthForm.clientId}
onChange={e => setOauthForm(f => ({ ...f, clientId: e.target.value }))} onChange={e => setOauthForm(f => ({ ...f, clientId: e.target.value }))}
@@ -732,9 +772,11 @@ export default function Admin() {
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientSecret')}</label> <label htmlFor="admin-oauth-client-secret" className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientSecret')}</label>
<input <input
id="admin-oauth-client-secret"
type="password" type="password"
autoComplete="new-password"
value={oauthForm.clientSecret} value={oauthForm.clientSecret}
onChange={e => setOauthForm(f => ({ ...f, clientSecret: e.target.value }))} onChange={e => setOauthForm(f => ({ ...f, clientSecret: e.target.value }))}
className="input-field text-sm" className="input-field text-sm"
@@ -745,8 +787,9 @@ export default function Admin() {
)} )}
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthDisplayName')}</label> <label htmlFor="admin-oauth-display-name" className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthDisplayName')}</label>
<input <input
id="admin-oauth-display-name"
type="text" type="text"
value={oauthForm.displayName} value={oauthForm.displayName}
onChange={e => setOauthForm(f => ({ ...f, displayName: e.target.value }))} onChange={e => setOauthForm(f => ({ ...f, displayName: e.target.value }))}
@@ -790,16 +833,133 @@ export default function Admin() {
)} )}
</div> </div>
{/* Room Management */}
<div className="card p-6 mb-8">
<button
type="button"
onClick={() => setRoomsExpanded(v => !v)}
className="flex items-center justify-between w-full text-left"
>
<div className="flex items-center gap-2">
<DoorOpen size={20} className="text-th-accent" />
<h2 className="text-lg font-semibold text-th-text">{t('admin.roomsTitle')}</h2>
<span className="text-sm text-th-text-s">({adminRooms.length})</span>
</div>
<ChevronDown size={18} className={`text-th-text-s transition-transform duration-200 ${roomsExpanded ? 'rotate-180' : ''}`} />
</button>
{roomsExpanded && (
<div className="mt-4">
<p className="text-sm text-th-text-s mb-5">{t('admin.roomsDescription')}</p>
{adminRoomsLoading ? (
<div className="flex justify-center py-4">
<Loader2 size={20} className="animate-spin text-th-accent" />
</div>
) : (
<>
{adminRooms.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-th-border">
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5">
{t('admin.roomName')}
</th>
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden sm:table-cell">
{t('admin.roomOwner')}
</th>
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden md:table-cell">
{t('admin.roomShares')}
</th>
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden lg:table-cell">
{t('admin.roomCreated')}
</th>
<th className="text-right text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5">
{t('admin.actions')}
</th>
</tr>
</thead>
<tbody>
{adminRooms.slice(0, 10).map(r => (
<tr key={r.id} className="border-b border-th-border last:border-0 hover:bg-th-hover transition-colors">
<td className="px-4 py-3">
<div>
<p className="text-sm font-medium text-th-text">{r.name}</p>
<p className="text-xs text-th-text-s font-mono">{r.uid}</p>
</div>
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<div>
<p className="text-sm text-th-text">{r.owner_name}</p>
<p className="text-xs text-th-text-s">{r.owner_email}</p>
</div>
</td>
<td className="px-4 py-3 text-sm text-th-text hidden md:table-cell">
{r.share_count}
</td>
<td className="px-4 py-3 text-sm text-th-text-s hidden lg:table-cell">
{new Date(r.created_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => navigate(`/rooms/${r.uid}`)}
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
title={t('admin.roomView')}
>
<Eye size={15} />
</button>
<button
onClick={() => handleAdminDeleteRoom(r.uid, r.name)}
className="p-1.5 rounded-lg hover:bg-th-hover text-th-error transition-colors"
title={t('admin.deleteRoom')}
>
<Trash2 size={15} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{adminRooms.length > 10 && (
<div className="mt-4 text-center">
<button
onClick={() => { setAllRoomsSearch(''); setShowAllRoomsModal(true); }}
className="btn-secondary text-sm"
>
{t('admin.showAllRooms', { count: adminRooms.length })}
</button>
</div>
)}
{adminRooms.length === 0 && (
<div className="text-center py-8">
<DoorOpen size={36} className="mx-auto text-th-text-s/40 mb-2" />
<p className="text-th-text-s text-sm">{t('admin.noRoomsFound')}</p>
</div>
)}
</>
)}
</div>
)}
</div>
{/* Search */} {/* Search */}
<div className="card p-4 mb-6"> <div className="card p-4 mb-6">
<div className="relative"> <div className="relative">
<Search size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Search size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
type="text" type="search"
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
className="input-field pl-11" className="input-field pl-11"
placeholder={t('admin.searchUsers')} placeholder={t('admin.searchUsers')}
aria-label={t('admin.searchUsers')}
/> />
</div> </div>
</div> </div>
@@ -833,7 +993,7 @@ export default function Admin() {
<td className="px-5 py-4"> <td className="px-5 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0 overflow-hidden" className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold shrink-0 overflow-hidden"
style={{ backgroundColor: u.avatar_color || '#6366f1' }} style={{ backgroundColor: u.avatar_color || '#6366f1' }}
> >
{u.avatar_image ? ( {u.avatar_image ? (
@@ -951,14 +1111,16 @@ export default function Admin() {
{/* Reset password modal */} {/* Reset password modal */}
{resetPwModal && ( {resetPwModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setResetPwModal(null)} /> <div className="fixed inset-0 bg-black/60 backdrop-blur-xs" onClick={() => setResetPwModal(null)} aria-hidden="true" />
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-sm p-6"> <div role="dialog" aria-modal="true" aria-labelledby="reset-pw-title" className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-sm p-6">
<h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.resetPasswordTitle')}</h3> <h3 id="reset-pw-title" className="text-lg font-semibold text-th-text mb-4">{t('admin.resetPasswordTitle')}</h3>
<form onSubmit={handleResetPassword}> <form onSubmit={handleResetPassword}>
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-medium text-th-text mb-1.5">{t('admin.newPasswordLabel')}</label> <label htmlFor="admin-reset-pw" className="block text-sm font-medium text-th-text mb-1.5">{t('admin.newPasswordLabel')}</label>
<input <input
id="admin-reset-pw"
type="password" type="password"
autoComplete="new-password"
value={newPassword} value={newPassword}
onChange={e => setNewPassword(e.target.value)} onChange={e => setNewPassword(e.target.value)}
className="input-field" className="input-field"
@@ -983,16 +1145,18 @@ export default function Admin() {
{/* Create user modal */} {/* Create user modal */}
{showCreateUser && ( {showCreateUser && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowCreateUser(false)} /> <div className="fixed inset-0 bg-black/60 backdrop-blur-xs" onClick={() => setShowCreateUser(false)} aria-hidden="true" />
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-md p-6"> <div role="dialog" aria-modal="true" aria-labelledby="create-user-title" className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-md p-6">
<h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.createUserTitle')}</h3> <h3 id="create-user-title" className="text-lg font-semibold text-th-text mb-4">{t('admin.createUserTitle')}</h3>
<form onSubmit={handleCreateUser} className="space-y-4"> <form onSubmit={handleCreateUser} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label> <label htmlFor="admin-new-username" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
<div className="relative"> <div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="admin-new-username"
type="text" type="text"
autoComplete="off"
value={newUser.name} value={newUser.name}
onChange={e => setNewUser({ ...newUser, name: e.target.value })} onChange={e => setNewUser({ ...newUser, name: e.target.value })}
className="input-field pl-11" className="input-field pl-11"
@@ -1003,11 +1167,13 @@ export default function Admin() {
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p> <p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label> <label htmlFor="admin-new-display-name" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
<div className="relative"> <div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="admin-new-display-name"
type="text" type="text"
autoComplete="off"
value={newUser.display_name} value={newUser.display_name}
onChange={e => setNewUser({ ...newUser, display_name: e.target.value })} onChange={e => setNewUser({ ...newUser, display_name: e.target.value })}
className="input-field pl-11" className="input-field pl-11"
@@ -1016,11 +1182,13 @@ export default function Admin() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label> <label htmlFor="admin-new-email" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<div className="relative"> <div className="relative">
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="admin-new-email"
type="email" type="email"
autoComplete="off"
value={newUser.email} value={newUser.email}
onChange={e => setNewUser({ ...newUser, email: e.target.value })} onChange={e => setNewUser({ ...newUser, email: e.target.value })}
className="input-field pl-11" className="input-field pl-11"
@@ -1030,11 +1198,13 @@ export default function Admin() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label> <label htmlFor="admin-new-password" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
<div className="relative"> <div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="admin-new-password"
type="password" type="password"
autoComplete="new-password"
value={newUser.password} value={newUser.password}
onChange={e => setNewUser({ ...newUser, password: e.target.value })} onChange={e => setNewUser({ ...newUser, password: e.target.value })}
className="input-field pl-11" className="input-field pl-11"
@@ -1045,8 +1215,9 @@ export default function Admin() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('admin.role')}</label> <label htmlFor="admin-new-role" className="block text-sm font-medium text-th-text mb-1.5">{t('admin.role')}</label>
<select <select
id="admin-new-role"
value={newUser.role} value={newUser.role}
onChange={e => setNewUser({ ...newUser, role: e.target.value })} onChange={e => setNewUser({ ...newUser, role: e.target.value })}
className="input-field" className="input-field"
@@ -1067,6 +1238,120 @@ export default function Admin() {
</div> </div>
</div> </div>
)} )}
{/* All rooms modal */}
{showAllRoomsModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60 backdrop-blur-xs" onClick={() => setShowAllRoomsModal(false)} />
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-th-border">
<div className="flex items-center gap-2">
<DoorOpen size={20} className="text-th-accent" />
<h3 className="text-lg font-semibold text-th-text">{t('admin.roomsTitle')}</h3>
<span className="text-sm text-th-text-s">({adminRooms.length})</span>
</div>
<button onClick={() => setShowAllRoomsModal(false)} aria-label={t('common.close')} className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors">
<XIcon size={18} />
</button>
</div>
<div className="p-4 border-b border-th-border">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
type="search"
value={allRoomsSearch}
onChange={e => setAllRoomsSearch(e.target.value)}
className="input-field pl-9 text-sm"
placeholder={t('admin.searchRooms')}
aria-label={t('admin.searchRooms')}
autoFocus
/>
</div>
</div>
<div className="overflow-y-auto flex-1 p-0">
<table className="w-full">
<thead className="sticky top-0 bg-th-card z-10">
<tr className="border-b border-th-border">
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5">
{t('admin.roomName')}
</th>
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden sm:table-cell">
{t('admin.roomOwner')}
</th>
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden md:table-cell">
{t('admin.roomShares')}
</th>
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden lg:table-cell">
{t('admin.roomCreated')}
</th>
<th className="text-right text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5">
{t('admin.actions')}
</th>
</tr>
</thead>
<tbody>
{adminRooms
.filter(r =>
r.name.toLowerCase().includes(allRoomsSearch.toLowerCase()) ||
r.owner_name.toLowerCase().includes(allRoomsSearch.toLowerCase()) ||
r.uid.toLowerCase().includes(allRoomsSearch.toLowerCase())
)
.map(r => (
<tr key={r.id} className="border-b border-th-border last:border-0 hover:bg-th-hover transition-colors">
<td className="px-4 py-3">
<div>
<p className="text-sm font-medium text-th-text">{r.name}</p>
<p className="text-xs text-th-text-s font-mono">{r.uid}</p>
</div>
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<div>
<p className="text-sm text-th-text">{r.owner_name}</p>
<p className="text-xs text-th-text-s">{r.owner_email}</p>
</div>
</td>
<td className="px-4 py-3 text-sm text-th-text hidden md:table-cell">
{r.share_count}
</td>
<td className="px-4 py-3 text-sm text-th-text-s hidden lg:table-cell">
{new Date(r.created_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => { setShowAllRoomsModal(false); navigate(`/rooms/${r.uid}`); }}
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
title={t('admin.roomView')}
>
<Eye size={15} />
</button>
<button
onClick={() => handleAdminDeleteRoom(r.uid, r.name)}
className="p-1.5 rounded-lg hover:bg-th-hover text-th-error transition-colors"
title={t('admin.deleteRoom')}
>
<Trash2 size={15} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{adminRooms.filter(r =>
r.name.toLowerCase().includes(allRoomsSearch.toLowerCase()) ||
r.owner_name.toLowerCase().includes(allRoomsSearch.toLowerCase()) ||
r.uid.toLowerCase().includes(allRoomsSearch.toLowerCase())
).length === 0 && (
<div className="text-center py-12">
<DoorOpen size={36} className="mx-auto text-th-text-s/40 mb-2" />
<p className="text-th-text-s text-sm">{t('admin.noRoomsFound')}</p>
</div>
)}
</div>
</div>
</div>
)}
</div> </div>
); );
} }
+23 -15
View File
@@ -454,7 +454,7 @@ export default function Calendar() {
<div <div
key={ev.id} key={ev.id}
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }} onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
className="text-[10px] leading-tight px-1.5 py-0.5 rounded truncate text-white font-medium cursor-pointer hover:opacity-80 transition-opacity" className="text-[10px] leading-tight px-1.5 py-0.5 rounded-sm truncate text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: ev.color || '#6366f1' }} style={{ backgroundColor: ev.color || '#6366f1' }}
title={ev.title} title={ev.title}
> >
@@ -492,11 +492,11 @@ export default function Calendar() {
<div <div
key={ev.id} key={ev.id}
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }} onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
className="text-xs px-2 py-1.5 rounded text-white font-medium cursor-pointer hover:opacity-80 transition-opacity" className="text-xs px-2 py-1.5 rounded-sm text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: ev.color || '#6366f1' }} style={{ backgroundColor: ev.color || '#6366f1' }}
> >
<div className="flex items-center gap-1 truncate"> <div className="flex items-center gap-1 truncate">
{ev.reminder_minutes && <Bell size={9} className="flex-shrink-0 opacity-70" />} {ev.reminder_minutes && <Bell size={9} className="shrink-0 opacity-70" />}
<span className="truncate">{ev.title}</span> <span className="truncate">{ev.title}</span>
</div> </div>
<div className="opacity-80 text-[10px]">{formatTime(ev.start_time)} - {formatTime(ev.end_time)}</div> <div className="opacity-80 text-[10px]">{formatTime(ev.start_time)} - {formatTime(ev.end_time)}</div>
@@ -515,8 +515,9 @@ export default function Calendar() {
<Modal title={editingEvent ? t('calendar.editEvent') : t('calendar.createEvent')} onClose={() => { setShowCreate(false); setEditingEvent(null); }}> <Modal title={editingEvent ? t('calendar.editEvent') : t('calendar.createEvent')} onClose={() => { setShowCreate(false); setEditingEvent(null); }}>
<form onSubmit={handleSave} className="space-y-4"> <form onSubmit={handleSave} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.eventTitle')} *</label> <label htmlFor="calendar-event-title" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.eventTitle')} *</label>
<input <input
id="calendar-event-title"
type="text" type="text"
value={form.title} value={form.title}
onChange={e => setForm({ ...form, title: e.target.value })} onChange={e => setForm({ ...form, title: e.target.value })}
@@ -527,8 +528,9 @@ export default function Calendar() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.description')}</label> <label htmlFor="calendar-event-description" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.description')}</label>
<textarea <textarea
id="calendar-event-description"
value={form.description} value={form.description}
onChange={e => setForm({ ...form, description: e.target.value })} onChange={e => setForm({ ...form, description: e.target.value })}
className="input-field resize-none" className="input-field resize-none"
@@ -555,13 +557,14 @@ export default function Calendar() {
/> />
</div> </div>
<div className="flex items-center gap-1.5 -mt-2 text-xs text-th-text-s"> <div className="flex items-center gap-1.5 -mt-2 text-xs text-th-text-s">
<Globe size={12} className="flex-shrink-0" /> <Globe size={12} className="shrink-0" />
<span>{getLocalTimezone()}</span> <span>{getLocalTimezone()}</span>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.linkedRoom')}</label> <label htmlFor="calendar-event-room" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.linkedRoom')}</label>
<select <select
id="calendar-event-room"
value={form.room_uid} value={form.room_uid}
onChange={e => setForm({ ...form, room_uid: e.target.value })} onChange={e => setForm({ ...form, room_uid: e.target.value })}
className="input-field" className="input-field"
@@ -575,10 +578,11 @@ export default function Calendar() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.reminderLabel')}</label> <label htmlFor="calendar-event-reminder" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.reminderLabel')}</label>
<div className="relative"> <div className="relative">
<Bell size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none" /> <Bell size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none" aria-hidden="true" />
<select <select
id="calendar-event-reminder"
value={form.reminder_minutes ?? ''} value={form.reminder_minutes ?? ''}
onChange={e => setForm({ ...form, reminder_minutes: e.target.value === '' ? null : Number(e.target.value) })} onChange={e => setForm({ ...form, reminder_minutes: e.target.value === '' ? null : Number(e.target.value) })}
className="input-field pl-9" className="input-field pl-9"
@@ -595,13 +599,16 @@ export default function Calendar() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.color')}</label> <p id="calendar-event-color-label" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.color')}</p>
<div className="flex gap-2"> <div role="radiogroup" aria-labelledby="calendar-event-color-label" className="flex gap-2">
{COLORS.map(c => ( {COLORS.map(c => (
<button <button
key={c} key={c}
type="button" type="button"
onClick={() => setForm({ ...form, color: c })} onClick={() => setForm({ ...form, color: c })}
role="radio"
aria-checked={form.color === c}
aria-label={c}
className={`w-7 h-7 rounded-full border-2 transition-all ${form.color === c ? 'border-th-text scale-110' : 'border-transparent'}`} className={`w-7 h-7 rounded-full border-2 transition-all ${form.color === c ? 'border-th-text scale-110' : 'border-transparent'}`}
style={{ backgroundColor: c }} style={{ backgroundColor: c }}
/> />
@@ -750,7 +757,7 @@ export default function Calendar() {
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left" className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
> >
<div <div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0" className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }} style={{ backgroundColor: u.avatar_color || '#6366f1' }}
> >
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)} {(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
@@ -772,7 +779,7 @@ export default function Calendar() {
<div key={u.user_id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border border-dashed"> <div key={u.user_id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border border-dashed">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<div <div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0" className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }} style={{ backgroundColor: u.avatar_color || '#6366f1' }}
> >
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)} {(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
@@ -799,7 +806,7 @@ export default function Calendar() {
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border"> <div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<div <div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0" className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }} style={{ backgroundColor: u.avatar_color || '#6366f1' }}
> >
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)} {(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
@@ -826,8 +833,9 @@ export default function Calendar() {
<p className="text-sm text-th-text-s mb-4">{t('calendar.sendFederatedDesc')}</p> <p className="text-sm text-th-text-s mb-4">{t('calendar.sendFederatedDesc')}</p>
<form onSubmit={handleFedSend} className="space-y-4"> <form onSubmit={handleFedSend} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label> <label htmlFor="calendar-fed-share-address" className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
<input <input
id="calendar-fed-share-address"
type="text" type="text"
value={fedAddress} value={fedAddress}
onChange={e => setFedAddress(e.target.value)} onChange={e => setFedAddress(e.target.value)}
+15 -7
View File
@@ -103,9 +103,11 @@ export default function Dashboard() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* View toggle */} {/* View toggle */}
<div className="hidden sm:flex items-center bg-th-bg-s rounded-lg border border-th-border p-0.5"> <div role="group" aria-label={t('common.gridView') + ' / ' + t('common.listView')} className="hidden sm:flex items-center bg-th-bg-s rounded-lg border border-th-border p-0.5">
<button <button
onClick={() => setViewMode('grid')} onClick={() => setViewMode('grid')}
aria-label={t('common.gridView')}
aria-pressed={viewMode === 'grid'}
className={`p-1.5 rounded-md transition-colors ${ className={`p-1.5 rounded-md transition-colors ${
viewMode === 'grid' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text' viewMode === 'grid' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
}`} }`}
@@ -114,6 +116,8 @@ export default function Dashboard() {
</button> </button>
<button <button
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}
aria-label={t('common.listView')}
aria-pressed={viewMode === 'list'}
className={`p-1.5 rounded-md transition-colors ${ className={`p-1.5 rounded-md transition-colors ${
viewMode === 'list' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text' viewMode === 'list' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
}`} }`}
@@ -197,8 +201,9 @@ export default function Dashboard() {
<Modal title={t('dashboard.createRoom')} onClose={() => setShowCreate(false)}> <Modal title={t('dashboard.createRoom')} onClose={() => setShowCreate(false)}>
<form onSubmit={handleCreate} className="space-y-4"> <form onSubmit={handleCreate} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')} *</label> <label htmlFor="create-room-name" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')} *</label>
<input <input
id="create-room-name"
type="text" type="text"
value={newRoom.name} value={newRoom.name}
onChange={e => setNewRoom({ ...newRoom, name: e.target.value })} onChange={e => setNewRoom({ ...newRoom, name: e.target.value })}
@@ -210,8 +215,9 @@ export default function Dashboard() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.welcomeMessage')}</label> <label htmlFor="create-room-welcome" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.welcomeMessage')}</label>
<textarea <textarea
id="create-room-welcome"
value={newRoom.welcome_message} value={newRoom.welcome_message}
onChange={e => setNewRoom({ ...newRoom, welcome_message: e.target.value })} onChange={e => setNewRoom({ ...newRoom, welcome_message: e.target.value })}
className="input-field resize-none" className="input-field resize-none"
@@ -222,8 +228,9 @@ export default function Dashboard() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.maxParticipants')}</label> <label htmlFor="create-room-max" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.maxParticipants')}</label>
<input <input
id="create-room-max"
type="number" type="number"
value={newRoom.max_participants} value={newRoom.max_participants}
onChange={e => setNewRoom({ ...newRoom, max_participants: parseInt(e.target.value) || 0 })} onChange={e => setNewRoom({ ...newRoom, max_participants: parseInt(e.target.value) || 0 })}
@@ -233,8 +240,9 @@ export default function Dashboard() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.accessCode')}</label> <label htmlFor="create-room-access-code" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.accessCode')}</label>
<input <input
id="create-room-access-code"
type="text" type="text"
value={newRoom.access_code} value={newRoom.access_code}
onChange={e => setNewRoom({ ...newRoom, access_code: e.target.value })} onChange={e => setNewRoom({ ...newRoom, access_code: e.target.value })}
@@ -250,7 +258,7 @@ export default function Dashboard() {
type="checkbox" type="checkbox"
checked={newRoom.mute_on_join} checked={newRoom.mute_on_join}
onChange={e => setNewRoom({ ...newRoom, mute_on_join: e.target.checked })} onChange={e => setNewRoom({ ...newRoom, mute_on_join: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring" className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/> />
<span className="text-sm text-th-text">{t('dashboard.muteOnJoin')}</span> <span className="text-sm text-th-text">{t('dashboard.muteOnJoin')}</span>
</label> </label>
@@ -259,7 +267,7 @@ export default function Dashboard() {
type="checkbox" type="checkbox"
checked={newRoom.record_meeting} checked={newRoom.record_meeting}
onChange={e => setNewRoom({ ...newRoom, record_meeting: e.target.checked })} onChange={e => setNewRoom({ ...newRoom, record_meeting: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring" className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/> />
<span className="text-sm text-th-text">{t('dashboard.allowRecording')}</span> <span className="text-sm text-th-text">{t('dashboard.allowRecording')}</span>
</label> </label>
+5 -5
View File
@@ -93,7 +93,7 @@ export default function FederatedRoomDetail() {
{isDeleted && ( {isDeleted && (
<div className="card p-4 mb-4 border-red-500/30 bg-red-500/10"> <div className="card p-4 mb-4 border-red-500/30 bg-red-500/10">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<AlertTriangle size={20} className="text-red-500 flex-shrink-0" /> <AlertTriangle size={20} className="text-red-500 shrink-0" />
<div> <div>
<p className="text-sm font-semibold text-red-500">{t('federation.roomDeleted')}</p> <p className="text-sm font-semibold text-red-500">{t('federation.roomDeleted')}</p>
<p className="text-xs text-th-text-s mt-0.5">{t('federation.roomDeletedNotice')}</p> <p className="text-xs text-th-text-s mt-0.5">{t('federation.roomDeletedNotice')}</p>
@@ -106,7 +106,7 @@ export default function FederatedRoomDetail() {
<div className="card p-6 mb-4"> <div className="card p-6 mb-4">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-start gap-3 min-w-0"> <div className="flex items-start gap-3 min-w-0">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${isDeleted ? 'bg-red-500/15' : 'bg-th-accent/15'}`}> <div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 mt-0.5 ${isDeleted ? 'bg-red-500/15' : 'bg-th-accent/15'}`}>
{isDeleted ? <AlertTriangle size={20} className="text-red-500" /> : <Globe size={20} className="text-th-accent" />} {isDeleted ? <AlertTriangle size={20} className="text-red-500" /> : <Globe size={20} className="text-th-accent" />}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -128,7 +128,7 @@ export default function FederatedRoomDetail() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 shrink-0">
{!isDeleted && ( {!isDeleted && (
<button <button
onClick={handleJoin} onClick={handleJoin}
@@ -189,7 +189,7 @@ export default function FederatedRoomDetail() {
{room.meet_id && ( {room.meet_id && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Hash size={16} className="text-th-accent flex-shrink-0 mt-0.5" /> <Hash size={16} className="text-th-accent shrink-0 mt-0.5" />
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs text-th-text-s mb-0.5">{t('federation.meetingId')}</p> <p className="text-xs text-th-text-s mb-0.5">{t('federation.meetingId')}</p>
<p className="text-sm font-mono text-th-text break-all">{room.meet_id}</p> <p className="text-sm font-mono text-th-text break-all">{room.meet_id}</p>
@@ -198,7 +198,7 @@ export default function FederatedRoomDetail() {
)} )}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Link2 size={16} className="text-th-accent flex-shrink-0 mt-0.5" /> <Link2 size={16} className="text-th-accent shrink-0 mt-0.5" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-xs text-th-text-s mb-0.5">{t('federation.joinUrl')}</p> <p className="text-xs text-th-text-s mb-0.5">{t('federation.joinUrl')}</p>
<p className="text-sm font-mono text-th-text break-all opacity-60 select-all">{room.join_url}</p> <p className="text-sm font-mono text-th-text break-all opacity-60 select-all">{room.join_url}</p>
+12 -12
View File
@@ -167,7 +167,7 @@ export default function FederationInbox() {
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Mail size={16} className="text-th-accent flex-shrink-0" /> <Mail size={16} className="text-th-accent shrink-0" />
<h3 className="text-base font-semibold text-th-text truncate">{inv.room_name}</h3> <h3 className="text-base font-semibold text-th-text truncate">{inv.room_name}</h3>
</div> </div>
<p className="text-sm text-th-text-s"> <p className="text-sm text-th-text-s">
@@ -180,7 +180,7 @@ export default function FederationInbox() {
{new Date(inv.created_at).toLocaleString()} {new Date(inv.created_at).toLocaleString()}
</p> </p>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 shrink-0">
<button onClick={() => handleAccept(inv.id)} className="btn-primary text-sm"> <button onClick={() => handleAccept(inv.id)} className="btn-primary text-sm">
<Check size={16} /> <Check size={16} />
{t('federation.accept')} {t('federation.accept')}
@@ -200,7 +200,7 @@ export default function FederationInbox() {
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Calendar size={16} className="text-th-success flex-shrink-0" /> <Calendar size={16} className="text-th-success shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wide text-th-success mr-1"> <span className="text-xs font-semibold uppercase tracking-wide text-th-success mr-1">
{t('federation.calendarEvent')} {t('federation.calendarEvent')}
</span> </span>
@@ -219,7 +219,7 @@ export default function FederationInbox() {
{new Date(inv.created_at).toLocaleString()} {new Date(inv.created_at).toLocaleString()}
</p> </p>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 shrink-0">
<button onClick={() => handleCalAccept(inv.id)} className="btn-primary text-sm"> <button onClick={() => handleCalAccept(inv.id)} className="btn-primary text-sm">
<Check size={16} /> <Check size={16} />
{t('federation.accept')} {t('federation.accept')}
@@ -239,7 +239,7 @@ export default function FederationInbox() {
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Calendar size={16} className="text-th-accent flex-shrink-0" /> <Calendar size={16} className="text-th-accent shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wide text-th-accent mr-1"> <span className="text-xs font-semibold uppercase tracking-wide text-th-accent mr-1">
{t('federation.localCalendarEvent')} {t('federation.localCalendarEvent')}
</span> </span>
@@ -258,7 +258,7 @@ export default function FederationInbox() {
{new Date(inv.created_at).toLocaleString()} {new Date(inv.created_at).toLocaleString()}
</p> </p>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 shrink-0">
<button onClick={() => handleLocalCalAccept(inv.id)} className="btn-primary text-sm"> <button onClick={() => handleLocalCalAccept(inv.id)} className="btn-primary text-sm">
<Check size={16} /> <Check size={16} />
{t('federation.accept')} {t('federation.accept')}
@@ -287,13 +287,13 @@ export default function FederationInbox() {
<div key={`room-past-${inv.id}`} className="card p-4 opacity-70"> <div key={`room-past-${inv.id}`} className="card p-4 opacity-70">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex items-center gap-2"> <div className="min-w-0 flex items-center gap-2">
<Mail size={14} className="text-th-text-s flex-shrink-0" /> <Mail size={14} className="text-th-text-s shrink-0" />
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3> <h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3>
<p className="text-xs text-th-text-s">{inv.from_user}</p> <p className="text-xs text-th-text-s">{inv.from_user}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted' <span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
? 'bg-th-success/15 text-th-success' ? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error' : 'bg-th-error/15 text-th-error'
@@ -326,13 +326,13 @@ export default function FederationInbox() {
<div key={`cal-past-${inv.id}`} className="card p-4 opacity-70"> <div key={`cal-past-${inv.id}`} className="card p-4 opacity-70">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex items-center gap-2"> <div className="min-w-0 flex items-center gap-2">
<Calendar size={14} className="text-th-text-s flex-shrink-0" /> <Calendar size={14} className="text-th-text-s shrink-0" />
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3> <h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
<p className="text-xs text-th-text-s">{inv.from_user} · {new Date(inv.start_time).toLocaleDateString()}</p> <p className="text-xs text-th-text-s">{inv.from_user} · {new Date(inv.start_time).toLocaleDateString()}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted' <span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
? 'bg-th-success/15 text-th-success' ? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error' : 'bg-th-error/15 text-th-error'
@@ -365,13 +365,13 @@ export default function FederationInbox() {
<div key={`localcal-past-${inv.id}`} className="card p-4 opacity-70"> <div key={`localcal-past-${inv.id}`} className="card p-4 opacity-70">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex items-center gap-2"> <div className="min-w-0 flex items-center gap-2">
<Calendar size={14} className="text-th-text-s flex-shrink-0" /> <Calendar size={14} className="text-th-text-s shrink-0" />
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3> <h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
<p className="text-xs text-th-text-s">{inv.from_name} · {new Date(inv.start_time).toLocaleDateString()}</p> <p className="text-xs text-th-text-s">{inv.from_name} · {new Date(inv.start_time).toLocaleDateString()}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted' <span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
? 'bg-th-success/15 text-th-success' ? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error' : 'bg-th-error/15 text-th-error'
+104
View File
@@ -0,0 +1,104 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import { Mail, ArrowRight, Loader2, CheckCircle } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import api from '../services/api';
import toast from 'react-hot-toast';
export default function ForgotPassword() {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const { t } = useLanguage();
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
await api.post('/auth/forgot-password', { email });
setSent(true);
} catch (err) {
toast.error(err.response?.data?.error || t('auth.forgotPasswordFailed'));
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
{/* Animated background */}
<div className="absolute inset-0 bg-th-bg">
<div className="absolute inset-0 opacity-30">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-pink-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '4s' }} />
</div>
</div>
<div className="relative w-full max-w-md">
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
<div className="flex justify-center mb-8">
<BrandLogo size="lg" />
</div>
{sent ? (
<div className="text-center space-y-4">
<CheckCircle size={48} className="mx-auto text-green-400" />
<h2 className="text-2xl font-bold text-th-text">{t('auth.forgotPasswordSentTitle')}</h2>
<p className="text-th-text-s">{t('auth.forgotPasswordSent')}</p>
<Link to="/login" className="btn-primary inline-flex items-center gap-2 mt-4">
{t('auth.login')}
</Link>
</div>
) : (
<>
<div className="mb-8">
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.forgotPasswordTitle')}</h2>
<p className="text-th-text-s">{t('auth.forgotPasswordSubtitle')}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="forgot-email" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<div className="relative">
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="forgot-email"
type="email"
autoComplete="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="input-field pl-11"
placeholder={t('auth.emailPlaceholder')}
required
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full py-3"
>
{loading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
{t('auth.forgotPasswordSubmit')}
<ArrowRight size={18} />
</>
)}
</button>
</form>
</>
)}
<Link to="/login" className="block mt-6 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
{t('auth.backToLogin')}
</Link>
</div>
</div>
</div>
);
}
+37 -11
View File
@@ -1,8 +1,9 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useParams, Link, useSearchParams } from 'react-router-dom'; import { useParams, Link, useSearchParams, useNavigate } from 'react-router-dom';
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText, Clock, X } from 'lucide-react'; import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText, Clock, X } from 'lucide-react';
import BrandLogo from '../components/BrandLogo'; import BrandLogo from '../components/BrandLogo';
import api from '../services/api'; import api from '../services/api';
import { isCurrentThemeDark } from '../themes';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
@@ -11,6 +12,7 @@ import { useBranding } from '../contexts/BrandingContext';
export default function GuestJoin() { export default function GuestJoin() {
const { uid } = useParams(); const { uid } = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { t } = useLanguage(); const { t } = useLanguage();
const { user } = useAuth(); const { user } = useAuth();
const { imprintUrl, privacyUrl } = useBranding(); const { imprintUrl, privacyUrl } = useBranding();
@@ -30,11 +32,18 @@ export default function GuestJoin() {
const joinMeeting = async () => { const joinMeeting = async () => {
setJoining(true); setJoining(true);
try { try {
const res = await api.post(`/rooms/${uid}/guest-join`, { const payload = {
name: name.trim(), name: name.trim(),
access_code: accessCode || undefined, access_code: accessCode || undefined,
moderator_code: moderatorCode || undefined, moderator_code: moderatorCode || undefined,
}); dark_mode: isCurrentThemeDark(),
};
// If logged in, send avatar data
if (isLoggedIn && user) {
if (user.avatar_image) payload.avatar_image = user.avatar_image;
if (user.avatar_color) payload.avatar_color = user.avatar_color;
}
const res = await api.post(`/rooms/${uid}/guest-join`, payload);
if (res.data.joinUrl) { if (res.data.joinUrl) {
window.location.href = res.data.joinUrl; window.location.href = res.data.joinUrl;
} }
@@ -54,6 +63,17 @@ export default function GuestJoin() {
useEffect(() => { useEffect(() => {
const fetchRoom = async () => { const fetchRoom = async () => {
// If logged in, check if user owns or has access to this room
if (isLoggedIn) {
try {
await api.get(`/rooms/${uid}`);
navigate(`/rooms/${uid}`, { replace: true });
return;
} catch {
// User doesn't have access — continue as guest
}
}
try { try {
const res = await api.get(`/rooms/${uid}/public`); const res = await api.get(`/rooms/${uid}/public`);
setRoomInfo(res.data.room); setRoomInfo(res.data.room);
@@ -212,11 +232,13 @@ export default function GuestJoin() {
) : ( ) : (
<form onSubmit={handleJoin} className="space-y-4"> <form onSubmit={handleJoin} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label> <label htmlFor="guest-name" className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label>
<div className="relative"> <div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="guest-name"
type="text" type="text"
autoComplete="name"
value={name} value={name}
onChange={e => !isLoggedIn && setName(e.target.value)} onChange={e => !isLoggedIn && setName(e.target.value)}
readOnly={isLoggedIn} readOnly={isLoggedIn}
@@ -230,11 +252,13 @@ export default function GuestJoin() {
{roomInfo.has_access_code && ( {roomInfo.has_access_code && (
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</label> <label htmlFor="guest-access-code" className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</label>
<div className="relative"> <div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="guest-access-code"
type="text" type="text"
autoComplete="off"
value={accessCode} value={accessCode}
onChange={e => setAccessCode(e.target.value)} onChange={e => setAccessCode(e.target.value)}
className="input-field pl-11" className="input-field pl-11"
@@ -245,14 +269,16 @@ export default function GuestJoin() {
)} )}
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5"> <label htmlFor="guest-moderator-code" className="block text-sm font-medium text-th-text mb-1.5">
{t('room.guestModeratorCode')} {t('room.guestModeratorCode')}
<span className="text-th-text-s font-normal ml-1">{t('room.guestModeratorOptional')}</span> <span className="text-th-text-s font-normal ml-1">{t('room.guestModeratorOptional')}</span>
</label> </label>
<div className="relative"> <div className="relative">
<Shield size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Shield size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="guest-moderator-code"
type="text" type="text"
autoComplete="off"
value={moderatorCode} value={moderatorCode}
onChange={e => setModeratorCode(e.target.value)} onChange={e => setModeratorCode(e.target.value)}
className="input-field pl-11" className="input-field pl-11"
@@ -265,7 +291,7 @@ export default function GuestJoin() {
{roomInfo.allow_recording && ( {roomInfo.allow_recording && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-3"> <div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-3">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertCircle size={16} className="text-amber-500 flex-shrink-0 mt-0.5" /> <AlertCircle size={16} className="text-amber-500 shrink-0 mt-0.5" />
<p className="text-sm text-amber-400">{t('room.guestRecordingNotice')}</p> <p className="text-sm text-amber-400">{t('room.guestRecordingNotice')}</p>
</div> </div>
<label className="flex items-center gap-2.5 cursor-pointer"> <label className="flex items-center gap-2.5 cursor-pointer">
@@ -273,7 +299,7 @@ export default function GuestJoin() {
type="checkbox" type="checkbox"
checked={recordingConsent} checked={recordingConsent}
onChange={e => setRecordingConsent(e.target.checked)} onChange={e => setRecordingConsent(e.target.checked)}
className="w-4 h-4 rounded accent-amber-500 cursor-pointer" className="w-4 h-4 rounded-sm accent-amber-500 cursor-pointer"
/> />
<span className="text-sm text-th-text">{t('room.guestRecordingConsent')}</span> <span className="text-sm text-th-text">{t('room.guestRecordingConsent')}</span>
</label> </label>
+33 -66
View File
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe, FileText, Lock } from 'lucide-react'; import { Video, Users, FileVideo, Network, CalendarDays, BarChart2, ArrowRight, FileText, Lock } from 'lucide-react';
import BrandLogo from '../components/BrandLogo'; import BrandLogo from '../components/BrandLogo';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { useBranding } from '../contexts/BrandingContext'; import { useBranding } from '../contexts/BrandingContext';
@@ -21,37 +21,35 @@ export default function Home() {
desc: t('home.featureRoomsDesc'), desc: t('home.featureRoomsDesc'),
}, },
{ {
icon: Shield, icon: FileVideo,
title: t('home.featureUsersTitle'),
desc: t('home.featureUsersDesc'),
},
{
icon: Palette,
title: t('home.featureThemesTitle'),
desc: t('home.featureThemesDesc'),
},
{
icon: Zap,
title: t('home.featureRecordingsTitle'), title: t('home.featureRecordingsTitle'),
desc: t('home.featureRecordingsDesc'), desc: t('home.featureRecordingsDesc'),
}, },
{ {
icon: Globe, icon: Network,
title: t('home.featureOpenSourceTitle'), title: t('home.featureFederationTitle'),
desc: t('home.featureOpenSourceDesc'), desc: t('home.featureFederationDesc'),
},
{
icon: CalendarDays,
title: t('home.featureCalendarTitle'),
desc: t('home.featureCalendarDesc'),
},
{
icon: BarChart2,
title: t('home.featureAnalyticsTitle'),
desc: t('home.featureAnalyticsDesc'),
}, },
]; ];
return ( return (
<div className="min-h-screen bg-th-bg"> <div className="min-h-screen bg-th-bg">
{/* Hero Section */}
<div className="relative overflow-hidden"> <div className="relative overflow-hidden">
{/* Background gradient */} {/* Background gradient */}
<div className="absolute inset-0 gradient-bg opacity-5" /> <div className="absolute inset-0 gradient-bg opacity-5" />
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[600px] gradient-bg opacity-10 blur-3xl rounded-full" /> <div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[600px] gradient-bg opacity-10 blur-3xl rounded-full" />
{/* Navbar */} <nav className="relative z-10 flex items-center justify-between px-6 md:px-12 py-4 border-b border-th-border">
<nav className="relative z-10 flex items-center justify-between px-6 md:px-12 py-4">
<BrandLogo size="md" /> <BrandLogo size="md" />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link to="/login" className="btn-ghost text-sm"> <Link to="/login" className="btn-ghost text-sm">
@@ -60,75 +58,43 @@ export default function Home() {
{!isInviteOnly && ( {!isInviteOnly && (
<Link to="/register" className="btn-primary text-sm"> <Link to="/register" className="btn-primary text-sm">
{t('auth.register')} {t('auth.register')}
<ArrowRight size={16} />
</Link> </Link>
)} )}
</div> </div>
</nav> </nav>
{/* Hero content */} {/* Hero */}
<div className="relative z-10 max-w-4xl mx-auto text-center px-6 pt-20 pb-32"> <div className="relative z-10 max-w-3xl mx-auto px-6 pt-24 pb-20">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-th-accent/10 text-th-accent text-sm font-medium mb-6"> <h1 className="text-3xl md:text-4xl font-bold text-th-text mb-4">
<Zap size={14} /> {t('home.heroTitle')}
{t('home.madeFor')}
</div>
<h1 className="text-4xl md:text-6xl lg:text-7xl font-extrabold text-th-text mb-6 leading-tight tracking-tight">
{t('home.heroTitle')}{' '}
<span className="gradient-text">{t('home.heroTitleHighlight')}</span>
</h1> </h1>
<p className="text-lg text-th-text-s mb-8 leading-relaxed">
<p className="text-lg md:text-xl text-th-text-s max-w-2xl mx-auto mb-10 leading-relaxed">
{t('home.heroSubtitle')} {t('home.heroSubtitle')}
</p> </p>
<div className="flex items-center gap-3">
<div className="flex items-center gap-4 justify-center">
{!isInviteOnly && ( {!isInviteOnly && (
<Link to="/register" className="btn-primary text-base px-8 py-3"> <Link to="/register" className="btn-primary px-6 py-2.5">
{t('home.getStarted')} {t('home.getStarted')}
<ArrowRight size={18} /> <ArrowRight size={16} />
</Link> </Link>
)} )}
<Link to="/login" className={`${isInviteOnly ? 'btn-primary' : 'btn-secondary'} text-base px-8 py-3`}> <Link to="/login" className={`${isInviteOnly ? 'btn-primary' : 'btn-secondary'} px-6 py-2.5`}>
{t('auth.login')} {t('auth.login')}
</Link> </Link>
</div> </div>
{/* Stats */}
<div className="flex items-center justify-center gap-8 md:gap-16 mt-16">
{[
{ value: '15+', label: t('home.statThemes') },
{ value: '∞', label: t('home.statRooms') },
{ value: '100%', label: t('home.statOpenSource') },
].map(stat => (
<div key={stat.label} className="text-center">
<div className="text-2xl md:text-3xl font-bold gradient-text">{stat.value}</div>
<div className="text-sm text-th-text-s mt-1">{stat.label}</div>
</div>
))}
</div>
</div> </div>
</div> </div>
{/* Features Section */} {/* Features */}
<div className="max-w-6xl mx-auto px-6 py-20"> <div className="border-t border-th-border">
<div className="text-center mb-16"> <div className="max-w-5xl mx-auto px-6 py-16">
<h2 className="text-3xl md:text-4xl font-bold text-th-text mb-4"> <h2 className="text-xl font-semibold text-th-text mb-10">
{t('home.features')} {t('home.features')}
</h2> </h2>
<p className="text-lg text-th-text-s max-w-2xl mx-auto">
{t('home.featuresSubtitle')}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map((feature, idx) => ( {features.map(feature => (
<div key={idx} className="card p-6 hover:shadow-th-lg transition-all duration-300 group"> <div key={feature.title} className="card p-6 hover:shadow-th-lg transition-shadow">
<div className="w-12 h-12 rounded-xl gradient-bg/10 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform" <div className="w-12 h-12 rounded-xl bg-th-accent/10 flex items-center justify-center mb-4">
style={{ background: `linear-gradient(135deg, var(--gradient-start), var(--gradient-end))`, opacity: 0.15 }}>
<feature.icon size={24} className="text-th-accent" style={{ opacity: 1 }} />
</div>
<div className="w-12 h-12 rounded-xl flex items-center justify-center mb-4 -mt-16 relative">
<feature.icon size={24} className="text-th-accent" /> <feature.icon size={24} className="text-th-accent" />
</div> </div>
<h3 className="text-base font-semibold text-th-text mb-2">{feature.title}</h3> <h3 className="text-base font-semibold text-th-text mb-2">{feature.title}</h3>
@@ -137,6 +103,7 @@ export default function Home() {
))} ))}
</div> </div>
</div> </div>
</div>
{/* Footer */} {/* Footer */}
<footer className="border-t border-th-border py-8 text-center"> <footer className="border-t border-th-border py-8 text-center">
+14 -5
View File
@@ -189,11 +189,13 @@ export default function Login() {
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label> <label htmlFor="login-email" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<div className="relative"> <div className="relative">
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="login-email"
type="email" type="email"
autoComplete="email"
value={email} value={email}
onChange={e => setEmail(e.target.value)} onChange={e => setEmail(e.target.value)}
className="input-field pl-11" className="input-field pl-11"
@@ -204,11 +206,13 @@ export default function Login() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label> <label htmlFor="login-password" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
<div className="relative"> <div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="login-password"
type="password" type="password"
autoComplete="current-password"
value={password} value={password}
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
className="input-field pl-11" className="input-field pl-11"
@@ -216,6 +220,11 @@ export default function Login() {
required required
/> />
</div> </div>
<div className="mt-1.5 text-right">
<Link to="/forgot-password" className="text-sm text-th-accent hover:underline">
{t('auth.forgotPassword')}
</Link>
</div>
</div> </div>
<button <button
@@ -257,7 +266,7 @@ export default function Login() {
{needsVerification && ( {needsVerification && (
<div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2"> <div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertTriangle size={16} className="text-amber-400 flex-shrink-0 mt-0.5" /> <AlertTriangle size={16} className="text-amber-400 shrink-0 mt-0.5" />
<p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p> <p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p>
</div> </div>
<button <button
+20 -10
View File
@@ -104,11 +104,13 @@ export default function Register() {
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label> <label htmlFor="register-username" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
<div className="relative"> <div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="register-username"
type="text" type="text"
autoComplete="username"
value={username} value={username}
onChange={e => setUsername(e.target.value)} onChange={e => setUsername(e.target.value)}
className="input-field pl-11" className="input-field pl-11"
@@ -120,11 +122,13 @@ export default function Register() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label> <label htmlFor="register-display-name" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
<div className="relative"> <div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="register-display-name"
type="text" type="text"
autoComplete="name"
value={displayName} value={displayName}
onChange={e => setDisplayName(e.target.value)} onChange={e => setDisplayName(e.target.value)}
className="input-field pl-11" className="input-field pl-11"
@@ -135,11 +139,13 @@ export default function Register() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label> <label htmlFor="register-email" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<div className="relative"> <div className="relative">
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="register-email"
type="email" type="email"
autoComplete="email"
value={email} value={email}
onChange={e => setEmail(e.target.value)} onChange={e => setEmail(e.target.value)}
className="input-field pl-11" className="input-field pl-11"
@@ -150,11 +156,13 @@ export default function Register() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label> <label htmlFor="register-password" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
<div className="relative"> <div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="register-password"
type="password" type="password"
autoComplete="new-password"
value={password} value={password}
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
className="input-field pl-11" className="input-field pl-11"
@@ -166,11 +174,13 @@ export default function Register() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.confirmPassword')}</label> <label htmlFor="register-confirm-password" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.confirmPassword')}</label>
<div className="relative"> <div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="register-confirm-password"
type="password" type="password"
autoComplete="new-password"
value={confirmPassword} value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)} onChange={e => setConfirmPassword(e.target.value)}
className="input-field pl-11" className="input-field pl-11"
+147
View File
@@ -0,0 +1,147 @@
import { useState } from 'react';
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import { Lock, ArrowRight, Loader2, CheckCircle, XCircle } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import api from '../services/api';
import toast from 'react-hot-toast';
const MIN_PASSWORD_LENGTH = 8;
export default function ResetPassword() {
const [searchParams] = useSearchParams();
const token = searchParams.get('token');
const { t } = useLanguage();
const navigate = useNavigate();
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (password.length < MIN_PASSWORD_LENGTH) {
toast.error(t('auth.passwordMinLength'));
return;
}
if (password !== confirm) {
toast.error(t('auth.passwordMismatch'));
return;
}
setLoading(true);
try {
await api.post('/auth/reset-password', { token, password });
setDone(true);
toast.success(t('auth.resetPasswordSuccess'));
setTimeout(() => navigate('/login'), 2500);
} catch (err) {
toast.error(err.response?.data?.error || t('auth.resetPasswordFailed'));
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
{/* Animated background */}
<div className="absolute inset-0 bg-th-bg">
<div className="absolute inset-0 opacity-30">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-pink-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '4s' }} />
</div>
</div>
<div className="relative w-full max-w-md">
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
<div className="flex justify-center mb-8">
<BrandLogo size="lg" />
</div>
{!token ? (
<div className="text-center space-y-4">
<XCircle size={48} className="mx-auto text-red-400" />
<h2 className="text-2xl font-bold text-th-text">{t('auth.resetPasswordFailed')}</h2>
<p className="text-th-text-s">{t('auth.resetTokenMissing')}</p>
<Link to="/forgot-password" className="btn-primary inline-flex items-center gap-2 mt-4">
{t('auth.forgotPasswordTitle')}
</Link>
</div>
) : done ? (
<div className="text-center space-y-4">
<CheckCircle size={48} className="mx-auto text-green-400" />
<h2 className="text-2xl font-bold text-th-text">{t('auth.resetPasswordSuccessTitle')}</h2>
<p className="text-th-text-s">{t('auth.resetPasswordSuccess')}</p>
<Link to="/login" className="btn-primary inline-flex items-center gap-2 mt-4">
{t('auth.login')}
</Link>
</div>
) : (
<>
<div className="mb-8">
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.resetPasswordTitle')}</h2>
<p className="text-th-text-s">{t('auth.resetPasswordSubtitle')}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="reset-password" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.newPassword')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="reset-password"
type="password"
autoComplete="new-password"
value={password}
onChange={e => setPassword(e.target.value)}
className="input-field pl-11"
placeholder={t('auth.passwordPlaceholder')}
required
/>
</div>
</div>
<div>
<label htmlFor="reset-confirm" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.confirmPassword')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="reset-confirm"
type="password"
autoComplete="new-password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
className="input-field pl-11"
placeholder={t('auth.passwordPlaceholder')}
required
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full py-3"
>
{loading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
{t('auth.resetPasswordSubmit')}
<ArrowRight size={18} />
</>
)}
</button>
</form>
</>
)}
<Link to="/login" className="block mt-6 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
{t('auth.backToLogin')}
</Link>
</div>
</div>
</div>
);
}
+109 -33
View File
@@ -8,7 +8,9 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
import api from '../services/api'; import api from '../services/api';
import { isCurrentThemeDark } from '../themes';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useBranding } from '../contexts/BrandingContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import RecordingList from '../components/RecordingList'; import RecordingList from '../components/RecordingList';
import AnalyticsList from '../components/AnalyticsList'; import AnalyticsList from '../components/AnalyticsList';
@@ -18,6 +20,7 @@ export default function RoomDetail() {
const { uid } = useParams(); const { uid } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const { transcriptionEnabled } = useBranding();
const { t } = useLanguage(); const { t } = useLanguage();
const [room, setRoom] = useState(null); const [room, setRoom] = useState(null);
@@ -51,6 +54,7 @@ export default function RoomDetail() {
// Federation invite state // Federation invite state
const [showFedInvite, setShowFedInvite] = useState(false); const [showFedInvite, setShowFedInvite] = useState(false);
const [fedAddress, setFedAddress] = useState(''); const [fedAddress, setFedAddress] = useState('');
const [fedEmails, setFedEmails] = useState('');
const [fedMessage, setFedMessage] = useState(''); const [fedMessage, setFedMessage] = useState('');
const [fedSending, setFedSending] = useState(false); const [fedSending, setFedSending] = useState(false);
@@ -61,7 +65,10 @@ export default function RoomDetail() {
const isOwner = room && user && room.user_id === user.id; const isOwner = room && user && room.user_id === user.id;
const isShared = room && !!room.shared; const isShared = room && !!room.shared;
const isAdmin = user?.role === 'admin';
const canManage = isOwner || isShared; const canManage = isOwner || isShared;
const canStart = canManage || isAdmin || !!room?.anyone_can_start;
const canEnd = canManage || isAdmin;
const fetchRoom = async () => { const fetchRoom = async () => {
try { try {
@@ -120,7 +127,7 @@ export default function RoomDetail() {
toast.success(t('room.meetingStarted')); toast.success(t('room.meetingStarted'));
setWaitingToJoin(false); setWaitingToJoin(false);
setActionLoading('join'); setActionLoading('join');
api.post(`/rooms/${uid}/join`, {}) api.post(`/rooms/${uid}/join`, { dark_mode: isCurrentThemeDark() })
.then(res => { if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank'); }) .then(res => { if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank'); })
.catch(err => toast.error(err.response?.data?.error || t('room.joinFailed'))) .catch(err => toast.error(err.response?.data?.error || t('room.joinFailed')))
.finally(() => setActionLoading(null)); .finally(() => setActionLoading(null));
@@ -131,7 +138,7 @@ export default function RoomDetail() {
const handleStart = async () => { const handleStart = async () => {
setActionLoading('start'); setActionLoading('start');
try { try {
const res = await api.post(`/rooms/${uid}/start`); const res = await api.post(`/rooms/${uid}/start`, { dark_mode: isCurrentThemeDark() });
if (res.data.joinUrl) { if (res.data.joinUrl) {
window.open(res.data.joinUrl, '_blank'); window.open(res.data.joinUrl, '_blank');
} }
@@ -153,7 +160,8 @@ export default function RoomDetail() {
setWaitingToJoin(false); setWaitingToJoin(false);
setActionLoading('join'); setActionLoading('join');
try { try {
const data = room.access_code ? { access_code: prompt(t('room.enterAccessCode')) } : {}; const data = { dark_mode: isCurrentThemeDark() };
if (room.access_code) data.access_code = prompt(t('room.enterAccessCode'));
const res = await api.post(`/rooms/${uid}/join`, data); const res = await api.post(`/rooms/${uid}/join`, data);
if (res.data.joinUrl) { if (res.data.joinUrl) {
window.open(res.data.joinUrl, '_blank'); window.open(res.data.joinUrl, '_blank');
@@ -197,6 +205,7 @@ export default function RoomDetail() {
moderator_code: editRoom.moderator_code, moderator_code: editRoom.moderator_code,
learning_analytics: !!editRoom.learning_analytics, learning_analytics: !!editRoom.learning_analytics,
analytics_visibility: editRoom.analytics_visibility || 'owner', analytics_visibility: editRoom.analytics_visibility || 'owner',
recording_transcript: !!editRoom.recording_transcript,
}); });
setRoom(res.data.room); setRoom(res.data.room);
setEditRoom(res.data.room); setEditRoom(res.data.room);
@@ -266,25 +275,51 @@ export default function RoomDetail() {
const handleFedInvite = async (e) => { const handleFedInvite = async (e) => {
e.preventDefault(); e.preventDefault();
// Accept @user@domain or user@domain — must have a domain part const hasAddress = fedAddress.trim().length > 0;
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress; const hasEmails = fedEmails.trim().length > 0;
if (!normalized.includes('@') || normalized.endsWith('@')) {
if (!hasAddress && !hasEmails) {
toast.error(t('federation.addressHint')); toast.error(t('federation.addressHint'));
return; return;
} }
setFedSending(true); setFedSending(true);
try { try {
if (hasAddress) {
// Federation address mode
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
if (!normalized.includes('@') || normalized.endsWith('@')) {
toast.error(t('federation.addressHint'));
setFedSending(false);
return;
}
await api.post('/federation/invite', { await api.post('/federation/invite', {
room_uid: uid, room_uid: uid,
to: fedAddress, to: fedAddress,
message: fedMessage || undefined, message: fedMessage || undefined,
}); });
toast.success(t('federation.sent')); toast.success(t('federation.sent'));
} else {
// Email mode
const emailList = fedEmails.split(',').map(e => e.trim()).filter(Boolean);
if (emailList.length === 0) {
toast.error(t('federation.emailHint'));
setFedSending(false);
return;
}
await api.post('/rooms/invite-email', {
room_uid: uid,
emails: emailList,
message: fedMessage || undefined,
});
toast.success(t('federation.emailSent'));
}
setShowFedInvite(false); setShowFedInvite(false);
setFedAddress(''); setFedAddress('');
setFedEmails('');
setFedMessage(''); setFedMessage('');
} catch (err) { } catch (err) {
toast.error(err.response?.data?.error || t('federation.sendFailed')); toast.error(err.response?.data?.error || t(hasAddress ? 'federation.sendFailed' : 'federation.emailSendFailed'));
} finally { } finally {
setFedSending(false); setFedSending(false);
} }
@@ -425,7 +460,7 @@ export default function RoomDetail() {
<span className="hidden sm:inline">{t('federation.inviteRemote')}</span> <span className="hidden sm:inline">{t('federation.inviteRemote')}</span>
</button> </button>
)} )}
{canManage && !status.running && !waitingToJoin && ( {canStart && !status.running && !waitingToJoin && (
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary"> <button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />} {actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
{t('room.start')} {t('room.start')}
@@ -440,7 +475,7 @@ export default function RoomDetail() {
{(actionLoading === 'join' || waitingToJoin) ? <Loader2 size={16} className="animate-spin" /> : <ExternalLink size={16} />} {(actionLoading === 'join' || waitingToJoin) ? <Loader2 size={16} className="animate-spin" /> : <ExternalLink size={16} />}
{waitingToJoin ? t('room.waitingToJoin') : t('room.join')} {waitingToJoin ? t('room.waitingToJoin') : t('room.join')}
</button> </button>
{canManage && status.running && ( {canEnd && status.running && (
<button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger"> <button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger">
{actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />} {actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />}
{t('room.end')} {t('room.end')}
@@ -481,7 +516,7 @@ export default function RoomDetail() {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-th-text-s">{t('room.meetingId')}</span> <span className="text-th-text-s">{t('room.meetingId')}</span>
<code className="bg-th-bg-s px-2 py-0.5 rounded text-xs text-th-text font-mono">{room.uid}</code> <code className="bg-th-bg-s px-2 py-0.5 rounded-sm text-xs text-th-text font-mono">{room.uid}</code>
</div> </div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-th-text-s">{t('room.status')}</span> <span className="text-th-text-s">{t('room.status')}</span>
@@ -550,8 +585,9 @@ export default function RoomDetail() {
{activeTab === 'settings' && isOwner && editRoom && ( {activeTab === 'settings' && isOwner && editRoom && (
<form onSubmit={handleSaveSettings} className="card p-6 space-y-5 max-w-2xl"> <form onSubmit={handleSaveSettings} className="card p-6 space-y-5 max-w-2xl">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')}</label> <label htmlFor="room-settings-name" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')}</label>
<input <input
id="room-settings-name"
type="text" type="text"
value={editRoom.name} value={editRoom.name}
onChange={e => setEditRoom({ ...editRoom, name: e.target.value })} onChange={e => setEditRoom({ ...editRoom, name: e.target.value })}
@@ -562,8 +598,9 @@ export default function RoomDetail() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.welcomeMsg')}</label> <label htmlFor="room-settings-welcome" className="block text-sm font-medium text-th-text mb-1.5">{t('room.welcomeMsg')}</label>
<textarea <textarea
id="room-settings-welcome"
value={editRoom.welcome_message || ''} value={editRoom.welcome_message || ''}
onChange={e => setEditRoom({ ...editRoom, welcome_message: e.target.value })} onChange={e => setEditRoom({ ...editRoom, welcome_message: e.target.value })}
className="input-field resize-none" className="input-field resize-none"
@@ -573,8 +610,9 @@ export default function RoomDetail() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.maxParticipants')}</label> <label htmlFor="room-settings-max" className="block text-sm font-medium text-th-text mb-1.5">{t('room.maxParticipants')}</label>
<input <input
id="room-settings-max"
type="number" type="number"
value={editRoom.max_participants} value={editRoom.max_participants}
onChange={e => setEditRoom({ ...editRoom, max_participants: parseInt(e.target.value) || 0 })} onChange={e => setEditRoom({ ...editRoom, max_participants: parseInt(e.target.value) || 0 })}
@@ -583,8 +621,9 @@ export default function RoomDetail() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.accessCode')}</label> <label htmlFor="room-settings-access-code" className="block text-sm font-medium text-th-text mb-1.5">{t('room.accessCode')}</label>
<input <input
id="room-settings-access-code"
type="text" type="text"
value={editRoom.access_code || ''} value={editRoom.access_code || ''}
onChange={e => setEditRoom({ ...editRoom, access_code: e.target.value })} onChange={e => setEditRoom({ ...editRoom, access_code: e.target.value })}
@@ -600,7 +639,7 @@ export default function RoomDetail() {
type="checkbox" type="checkbox"
checked={!!editRoom.mute_on_join} checked={!!editRoom.mute_on_join}
onChange={e => setEditRoom({ ...editRoom, mute_on_join: e.target.checked })} onChange={e => setEditRoom({ ...editRoom, mute_on_join: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring" className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/> />
<span className="text-sm text-th-text">{t('room.muteOnJoin')}</span> <span className="text-sm text-th-text">{t('room.muteOnJoin')}</span>
</label> </label>
@@ -609,7 +648,7 @@ export default function RoomDetail() {
type="checkbox" type="checkbox"
checked={!!editRoom.require_approval} checked={!!editRoom.require_approval}
onChange={e => setEditRoom({ ...editRoom, require_approval: e.target.checked })} onChange={e => setEditRoom({ ...editRoom, require_approval: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring" className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/> />
<span className="text-sm text-th-text">{t('room.requireApproval')}</span> <span className="text-sm text-th-text">{t('room.requireApproval')}</span>
</label> </label>
@@ -618,7 +657,7 @@ export default function RoomDetail() {
type="checkbox" type="checkbox"
checked={!!editRoom.anyone_can_start} checked={!!editRoom.anyone_can_start}
onChange={e => setEditRoom({ ...editRoom, anyone_can_start: e.target.checked })} onChange={e => setEditRoom({ ...editRoom, anyone_can_start: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring" className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/> />
<span className="text-sm text-th-text">{t('room.anyoneCanStart')}</span> <span className="text-sm text-th-text">{t('room.anyoneCanStart')}</span>
</label> </label>
@@ -627,7 +666,7 @@ export default function RoomDetail() {
type="checkbox" type="checkbox"
checked={!!editRoom.all_join_moderator} checked={!!editRoom.all_join_moderator}
onChange={e => setEditRoom({ ...editRoom, all_join_moderator: e.target.checked })} onChange={e => setEditRoom({ ...editRoom, all_join_moderator: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring" className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/> />
<span className="text-sm text-th-text">{t('room.allJoinModerator')}</span> <span className="text-sm text-th-text">{t('room.allJoinModerator')}</span>
</label> </label>
@@ -636,23 +675,36 @@ export default function RoomDetail() {
type="checkbox" type="checkbox"
checked={!!editRoom.record_meeting} checked={!!editRoom.record_meeting}
onChange={e => setEditRoom({ ...editRoom, record_meeting: e.target.checked })} onChange={e => setEditRoom({ ...editRoom, record_meeting: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring" className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/> />
<span className="text-sm text-th-text">{t('room.allowRecording')}</span> <span className="text-sm text-th-text">{t('room.allowRecording')}</span>
</label> </label>
{transcriptionEnabled && (
<label className={`flex items-center gap-3 ${editRoom.record_meeting ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
<input
type="checkbox"
checked={!!editRoom.recording_transcript}
disabled={!editRoom.record_meeting}
onChange={e => setEditRoom({ ...editRoom, recording_transcript: e.target.checked })}
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('room.enableTranscript')}</span>
</label>
)}
<label className="flex items-center gap-3 cursor-pointer"> <label className="flex items-center gap-3 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={!!editRoom.learning_analytics} checked={!!editRoom.learning_analytics}
onChange={e => setEditRoom({ ...editRoom, learning_analytics: e.target.checked })} onChange={e => setEditRoom({ ...editRoom, learning_analytics: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring" className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/> />
<span className="text-sm text-th-text">{t('room.enableAnalytics')}</span> <span className="text-sm text-th-text">{t('room.enableAnalytics')}</span>
</label> </label>
{!!editRoom.learning_analytics && ( {!!editRoom.learning_analytics && (
<div className="ml-7"> <div className="ml-7">
<label className="block text-xs font-medium text-th-text-s mb-1">{t('room.analyticsVisibility')}</label> <label htmlFor="room-settings-analytics-visibility" className="block text-xs font-medium text-th-text-s mb-1">{t('room.analyticsVisibility')}</label>
<select <select
id="room-settings-analytics-visibility"
value={editRoom.analytics_visibility || 'owner'} value={editRoom.analytics_visibility || 'owner'}
onChange={e => setEditRoom({ ...editRoom, analytics_visibility: e.target.value })} onChange={e => setEditRoom({ ...editRoom, analytics_visibility: e.target.value })}
className="input-field text-sm py-1.5 max-w-xs" className="input-field text-sm py-1.5 max-w-xs"
@@ -670,8 +722,9 @@ export default function RoomDetail() {
<h3 className="text-sm font-semibold text-th-text">{t('room.guestAccessTitle')}</h3> <h3 className="text-sm font-semibold text-th-text">{t('room.guestAccessTitle')}</h3>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.moderatorCode')}</label> <label htmlFor="room-settings-moderator-code" className="block text-sm font-medium text-th-text mb-1.5">{t('room.moderatorCode')}</label>
<input <input
id="room-settings-moderator-code"
type="text" type="text"
value={editRoom.moderator_code || ''} value={editRoom.moderator_code || ''}
onChange={e => setEditRoom({ ...editRoom, moderator_code: e.target.value })} onChange={e => setEditRoom({ ...editRoom, moderator_code: e.target.value })}
@@ -713,11 +766,11 @@ export default function RoomDetail() {
{room.presentation_file ? ( {room.presentation_file ? (
<div className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border"> <div className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<FileText size={16} className="text-th-accent flex-shrink-0" /> <FileText size={16} className="text-th-accent shrink-0" />
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs text-th-text-s">{t('room.presentationCurrent')}</p> <p className="text-xs text-th-text-s">{t('room.presentationCurrent')}</p>
<p className="text-sm text-th-text font-medium truncate"> <p className="text-sm text-th-text font-medium truncate">
{room.presentation_name || `presentation.${room.presentation_file?.split('.').pop()}`} {room.presentation_file}
</p> </p>
</div> </div>
</div> </div>
@@ -725,7 +778,7 @@ export default function RoomDetail() {
type="button" type="button"
onClick={handlePresentationRemove} onClick={handlePresentationRemove}
disabled={removingPresentation} disabled={removingPresentation}
className="btn-ghost text-th-error hover:bg-th-error/10 flex-shrink-0 text-xs py-1.5 px-3" className="btn-ghost text-th-error hover:bg-th-error/10 shrink-0 text-xs py-1.5 px-3"
> >
{removingPresentation ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />} {removingPresentation ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
{t('room.presentationRemove')} {t('room.presentationRemove')}
@@ -784,7 +837,7 @@ export default function RoomDetail() {
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left" className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
> >
<div <div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden" className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0 overflow-hidden"
style={{ backgroundColor: u.avatar_color || '#6366f1' }} style={{ backgroundColor: u.avatar_color || '#6366f1' }}
> >
{u.avatar_image ? ( {u.avatar_image ? (
@@ -810,7 +863,7 @@ export default function RoomDetail() {
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border"> <div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<div <div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden" className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0 overflow-hidden"
style={{ backgroundColor: u.avatar_color || '#6366f1' }} style={{ backgroundColor: u.avatar_color || '#6366f1' }}
> >
{u.avatar_image ? ( {u.avatar_image ? (
@@ -827,7 +880,7 @@ export default function RoomDetail() {
<button <button
type="button" type="button"
onClick={() => handleUnshare(u.id)} onClick={() => handleUnshare(u.id)}
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors flex-shrink-0" className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors shrink-0"
title={t('room.shareRemove')} title={t('room.shareRemove')}
> >
<X size={16} /> <X size={16} />
@@ -853,20 +906,43 @@ export default function RoomDetail() {
<p className="text-sm text-th-text-s mb-4">{t('federation.inviteSubtitle')}</p> <p className="text-sm text-th-text-s mb-4">{t('federation.inviteSubtitle')}</p>
<form onSubmit={handleFedInvite} className="space-y-4"> <form onSubmit={handleFedInvite} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label> <label htmlFor="fed-invite-address" className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
<input <input
id="fed-invite-address"
type="text" type="text"
value={fedAddress} value={fedAddress}
onChange={e => setFedAddress(e.target.value)} onChange={e => { setFedAddress(e.target.value); if (e.target.value) setFedEmails(''); }}
className="input-field" className="input-field"
placeholder={t('federation.addressPlaceholder')} placeholder={t('federation.addressPlaceholder')}
required disabled={fedEmails.trim().length > 0}
/> />
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p> <p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
</div> </div>
<div className="flex items-center gap-3 my-2">
<div className="flex-1 border-t border-th-border" />
<span className="text-xs text-th-text-s uppercase">{t('common.or')}</span>
<div className="flex-1 border-t border-th-border" />
</div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label> <label htmlFor="fed-invite-emails" className="block text-sm font-medium text-th-text mb-1.5">{t('federation.emailLabel')}</label>
<input
id="fed-invite-emails"
type="text"
value={fedEmails}
onChange={e => { setFedEmails(e.target.value); if (e.target.value) setFedAddress(''); }}
className="input-field"
placeholder={t('federation.emailPlaceholder')}
disabled={fedAddress.trim().length > 0}
/>
<p className="text-xs text-th-text-s mt-1">{t('federation.emailHint')}</p>
</div>
<div>
<label htmlFor="fed-invite-message" className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label>
<textarea <textarea
id="fed-invite-message"
value={fedMessage} value={fedMessage}
onChange={e => setFedMessage(e.target.value)} onChange={e => setFedMessage(e.target.value)}
className="input-field resize-none" className="input-field resize-none"
@@ -878,7 +954,7 @@ export default function RoomDetail() {
<button type="button" onClick={() => setShowFedInvite(false)} className="btn-secondary flex-1"> <button type="button" onClick={() => setShowFedInvite(false)} className="btn-secondary flex-1">
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button type="submit" disabled={fedSending} className="btn-primary flex-1"> <button type="submit" disabled={fedSending || (!fedAddress.trim() && !fedEmails.trim())} className="btn-primary flex-1">
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />} {fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
{t('federation.send')} {t('federation.send')}
</button> </button>
+50 -29
View File
@@ -267,7 +267,7 @@ export default function Settings() {
<div className="flex flex-col md:flex-row gap-6"> <div className="flex flex-col md:flex-row gap-6">
{/* Section nav */} {/* Section nav */}
<div className="md:w-56 flex-shrink-0"> <div className="md:w-56 shrink-0">
<nav className="flex md:flex-col gap-1"> <nav className="flex md:flex-col gap-1">
{sections.map(s => ( {sections.map(s => (
<button <button
@@ -301,7 +301,7 @@ export default function Settings() {
{user?.avatar_image ? ( {user?.avatar_image ? (
<img <img
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`} src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
alt="Avatar" alt=""
className="w-16 h-16 rounded-full object-cover" className="w-16 h-16 rounded-full object-cover"
/> />
) : ( ) : (
@@ -316,6 +316,7 @@ export default function Settings() {
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={uploadingAvatar} disabled={uploadingAvatar}
aria-label={t('settings.uploadImage')}
className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer" className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
> >
{uploadingAvatar ? ( {uploadingAvatar ? (
@@ -360,12 +361,15 @@ export default function Settings() {
{/* Avatar Color (fallback) */} {/* Avatar Color (fallback) */}
<div className="mb-6"> <div className="mb-6">
<label className="block text-sm font-medium text-th-text mb-3">{t('settings.avatarColor')}</label> <p id="avatar-color-label" className="block text-sm font-medium text-th-text mb-3">{t('settings.avatarColor')}</p>
<div className="flex flex-wrap gap-2"> <div role="radiogroup" aria-labelledby="avatar-color-label" className="flex flex-wrap gap-2">
{avatarColors.map(color => ( {avatarColors.map(color => (
<button <button
key={color} key={color}
onClick={() => handleAvatarColor(color)} onClick={() => handleAvatarColor(color)}
role="radio"
aria-checked={user?.avatar_color === color}
aria-label={color}
className={`w-7 h-7 rounded-full ring-2 ring-offset-2 transition-all ${ className={`w-7 h-7 rounded-full ring-2 ring-offset-2 transition-all ${
user?.avatar_color === color ? 'ring-th-accent' : 'ring-transparent hover:ring-th-border' user?.avatar_color === color ? 'ring-th-accent' : 'ring-transparent hover:ring-th-border'
}`} }`}
@@ -378,11 +382,13 @@ export default function Settings() {
<form onSubmit={handleProfileSave} className="space-y-4"> <form onSubmit={handleProfileSave} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label> <label htmlFor="settings-username" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
<div className="relative"> <div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="settings-username"
type="text" type="text"
autoComplete="username"
value={profile.name} value={profile.name}
onChange={e => setProfile({ ...profile, name: e.target.value })} onChange={e => setProfile({ ...profile, name: e.target.value })}
className="input-field pl-11" className="input-field pl-11"
@@ -392,11 +398,13 @@ export default function Settings() {
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p> <p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label> <label htmlFor="settings-display-name" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
<div className="relative"> <div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="settings-display-name"
type="text" type="text"
autoComplete="name"
value={profile.display_name} value={profile.display_name}
onChange={e => setProfile({ ...profile, display_name: e.target.value })} onChange={e => setProfile({ ...profile, display_name: e.target.value })}
className="input-field pl-11" className="input-field pl-11"
@@ -404,11 +412,13 @@ export default function Settings() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label> <label htmlFor="settings-email" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<div className="relative"> <div className="relative">
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="settings-email"
type="email" type="email"
autoComplete="email"
value={profile.email} value={profile.email}
onChange={e => setProfile({ ...profile, email: e.target.value })} onChange={e => setProfile({ ...profile, email: e.target.value })}
className="input-field pl-11" className="input-field pl-11"
@@ -430,11 +440,13 @@ export default function Settings() {
<h2 className="text-lg font-semibold text-th-text mb-6">{t('settings.changePassword')}</h2> <h2 className="text-lg font-semibold text-th-text mb-6">{t('settings.changePassword')}</h2>
<form onSubmit={handlePasswordSave} className="space-y-4"> <form onSubmit={handlePasswordSave} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label> <label htmlFor="settings-current-password" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
<div className="relative"> <div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="settings-current-password"
type="password" type="password"
autoComplete="current-password"
value={passwords.currentPassword} value={passwords.currentPassword}
onChange={e => setPasswords({ ...passwords, currentPassword: e.target.value })} onChange={e => setPasswords({ ...passwords, currentPassword: e.target.value })}
className="input-field pl-11" className="input-field pl-11"
@@ -443,11 +455,13 @@ export default function Settings() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.newPassword')}</label> <label htmlFor="settings-new-password" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.newPassword')}</label>
<div className="relative"> <div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="settings-new-password"
type="password" type="password"
autoComplete="new-password"
value={passwords.newPassword} value={passwords.newPassword}
onChange={e => setPasswords({ ...passwords, newPassword: e.target.value })} onChange={e => setPasswords({ ...passwords, newPassword: e.target.value })}
className="input-field pl-11" className="input-field pl-11"
@@ -458,11 +472,13 @@ export default function Settings() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.confirmNewPassword')}</label> <label htmlFor="settings-confirm-new-password" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.confirmNewPassword')}</label>
<div className="relative"> <div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="settings-confirm-new-password"
type="password" type="password"
autoComplete="new-password"
value={passwords.confirmPassword} value={passwords.confirmPassword}
onChange={e => setPasswords({ ...passwords, confirmPassword: e.target.value })} onChange={e => setPasswords({ ...passwords, confirmPassword: e.target.value })}
className="input-field pl-11" className="input-field pl-11"
@@ -495,7 +511,7 @@ export default function Settings() {
/* 2FA is enabled */ /* 2FA is enabled */
<div> <div>
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 mb-5"> <div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 mb-5">
<ShieldCheck size={22} className="text-emerald-400 flex-shrink-0" /> <ShieldCheck size={22} className="text-emerald-400 shrink-0" />
<div> <div>
<p className="text-sm font-medium text-emerald-300">{t('settings.security.statusEnabled')}</p> <p className="text-sm font-medium text-emerald-300">{t('settings.security.statusEnabled')}</p>
<p className="text-xs text-emerald-400/70">{t('settings.security.statusEnabledDesc')}</p> <p className="text-xs text-emerald-400/70">{t('settings.security.statusEnabledDesc')}</p>
@@ -514,11 +530,13 @@ export default function Settings() {
<form onSubmit={handleDisable2FA} className="space-y-4 p-4 rounded-xl bg-th-bg-t border border-th-border"> <form onSubmit={handleDisable2FA} className="space-y-4 p-4 rounded-xl bg-th-bg-t border border-th-border">
<p className="text-sm text-th-text-s">{t('settings.security.disableConfirm')}</p> <p className="text-sm text-th-text-s">{t('settings.security.disableConfirm')}</p>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label> <label htmlFor="twofa-disable-password" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
<div className="relative"> <div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="twofa-disable-password"
type="password" type="password"
autoComplete="current-password"
value={twoFaDisablePassword} value={twoFaDisablePassword}
onChange={e => setTwoFaDisablePassword(e.target.value)} onChange={e => setTwoFaDisablePassword(e.target.value)}
className="input-field pl-11" className="input-field pl-11"
@@ -527,8 +545,9 @@ export default function Settings() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.codeLabel')}</label> <label htmlFor="twofa-disable-code" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.codeLabel')}</label>
<input <input
id="twofa-disable-code"
type="text" type="text"
inputMode="numeric" inputMode="numeric"
autoComplete="one-time-code" autoComplete="one-time-code"
@@ -569,7 +588,8 @@ export default function Settings() {
</code> </code>
<button <button
onClick={() => { navigator.clipboard.writeText(twoFaSetupData.secret); toast.success(t('room.linkCopied')); }} onClick={() => { navigator.clipboard.writeText(twoFaSetupData.secret); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 flex-shrink-0" className="btn-ghost py-1.5 px-2 shrink-0"
aria-label={t('room.copyLink')}
> >
<Copy size={14} /> <Copy size={14} />
</button> </button>
@@ -577,8 +597,9 @@ export default function Settings() {
</div> </div>
<form onSubmit={handleEnable2FA} className="space-y-3"> <form onSubmit={handleEnable2FA} className="space-y-3">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.verifyCode')}</label> <label htmlFor="twofa-enable-code" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.verifyCode')}</label>
<input <input
id="twofa-enable-code"
type="text" type="text"
inputMode="numeric" inputMode="numeric"
autoComplete="one-time-code" autoComplete="one-time-code"
@@ -605,7 +626,7 @@ export default function Settings() {
/* 2FA is disabled — show enable button */ /* 2FA is disabled — show enable button */
<div> <div>
<div className="flex items-center gap-3 p-4 rounded-xl bg-th-bg-t border border-th-border mb-5"> <div className="flex items-center gap-3 p-4 rounded-xl bg-th-bg-t border border-th-border mb-5">
<ShieldOff size={22} className="text-th-text-s flex-shrink-0" /> <ShieldOff size={22} className="text-th-text-s shrink-0" />
<div> <div>
<p className="text-sm font-medium text-th-text">{t('settings.security.statusDisabled')}</p> <p className="text-sm font-medium text-th-text">{t('settings.security.statusDisabled')}</p>
<p className="text-xs text-th-text-s">{t('settings.security.statusDisabledDesc')}</p> <p className="text-xs text-th-text-s">{t('settings.security.statusDisabledDesc')}</p>
@@ -667,7 +688,7 @@ export default function Settings() {
> >
{/* Color preview */} {/* Color preview */}
<div <div
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 border" className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 border"
style={{ backgroundColor: th.colors.bg, borderColor: th.colors.accent + '40' }} style={{ backgroundColor: th.colors.bg, borderColor: th.colors.accent + '40' }}
> >
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: th.colors.accent }} /> <div className="w-4 h-4 rounded-full" style={{ backgroundColor: th.colors.accent }} />
@@ -699,7 +720,7 @@ export default function Settings() {
</code> </code>
<button <button
onClick={() => { navigator.clipboard.writeText(`${window.location.origin}/caldav/`); toast.success(t('room.linkCopied')); }} onClick={() => { navigator.clipboard.writeText(`${window.location.origin}/caldav/`); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 flex-shrink-0" className="btn-ghost py-1.5 px-2 shrink-0"
> >
<Copy size={14} /> <Copy size={14} />
</button> </button>
@@ -713,7 +734,7 @@ export default function Settings() {
</code> </code>
<button <button
onClick={() => { navigator.clipboard.writeText(user?.email || ''); toast.success(t('room.linkCopied')); }} onClick={() => { navigator.clipboard.writeText(user?.email || ''); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 flex-shrink-0" className="btn-ghost py-1.5 px-2 shrink-0"
> >
<Copy size={14} /> <Copy size={14} />
</button> </button>
@@ -732,12 +753,12 @@ export default function Settings() {
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg font-mono text-th-text break-all"> <code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg font-mono text-th-text break-all">
{tokenVisible ? newlyCreatedToken : '•'.repeat(48)} {tokenVisible ? newlyCreatedToken : '•'.repeat(48)}
</code> </code>
<button onClick={() => setTokenVisible(v => !v)} className="btn-ghost py-1.5 px-2 flex-shrink-0"> <button onClick={() => setTokenVisible(v => !v)} className="btn-ghost py-1.5 px-2 shrink-0">
{tokenVisible ? <EyeOff size={14} /> : <Eye size={14} />} {tokenVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</button> </button>
<button <button
onClick={() => { navigator.clipboard.writeText(newlyCreatedToken); toast.success(t('room.linkCopied')); }} onClick={() => { navigator.clipboard.writeText(newlyCreatedToken); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 flex-shrink-0" className="btn-ghost py-1.5 px-2 shrink-0"
> >
<Copy size={14} /> <Copy size={14} />
</button> </button>
@@ -790,7 +811,7 @@ export default function Settings() {
</div> </div>
<button <button
onClick={() => handleRevokeToken(tk.id)} onClick={() => handleRevokeToken(tk.id)}
className="btn-ghost py-1 px-2 text-th-error hover:text-th-error flex-shrink-0" className="btn-ghost py-1 px-2 text-th-error hover:text-th-error shrink-0"
title={t('settings.caldav.revoke')} title={t('settings.caldav.revoke')}
> >
<Trash2 size={14} /> <Trash2 size={14} />
+12
View File
@@ -173,6 +173,18 @@ export function getThemeById(id) {
return themes.find(t => t.id === id) || themes[1]; // default to dark return themes.find(t => t.id === id) || themes[1]; // default to dark
} }
// Whether the currently applied theme is a dark one. Reads the resolved theme
// from the <html data-theme> attribute (set by ThemeContext) with a localStorage
// fallback, so it works without React context — handy for one-off API payloads
// such as the BBB join call that forwards dark mode to the meeting.
export function isCurrentThemeDark() {
const id = (typeof document !== 'undefined'
&& document.documentElement.getAttribute('data-theme'))
|| (typeof localStorage !== 'undefined' && localStorage.getItem('theme'))
|| 'dark';
return getThemeById(id).type === 'dark';
}
export function getThemeGroups() { export function getThemeGroups() {
const groups = {}; const groups = {};
themes.forEach(theme => { themes.forEach(theme => {
-39
View File
@@ -1,39 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
th: {
bg: 'var(--bg-primary)',
'bg-s': 'var(--bg-secondary)',
'bg-t': 'var(--bg-tertiary)',
text: 'var(--text-primary)',
'text-s': 'var(--text-secondary)',
accent: 'var(--accent)',
'accent-h': 'var(--accent-hover)',
'accent-t': 'var(--accent-text)',
border: 'var(--border)',
card: 'var(--card-bg)',
input: 'var(--input-bg)',
'input-b': 'var(--input-border)',
nav: 'var(--nav-bg)',
side: 'var(--sidebar-bg)',
hover: 'var(--hover-bg)',
success: 'var(--success)',
warning: 'var(--warning)',
error: 'var(--error)',
ring: 'var(--ring)',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
boxShadow: {
'th': '0 1px 3px 0 var(--shadow-color), 0 1px 2px -1px var(--shadow-color)',
'th-lg': '0 10px 15px -3px var(--shadow-color), 0 4px 6px -4px var(--shadow-color)',
},
},
},
plugins: [],
};