115 Commits
Author SHA1 Message Date
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
Michelle 8cbe28f915 chore: bump version to 2.1.2 and update user name handling in GuestJoin component
Build & Push Docker Image / build (push) Successful in 4m28s
Build & Push Docker Image / build (release) Successful in 4m20s
2026-03-26 09:40:41 +01:00
Michelle 5472e190d9 chore: Bump version to 2.1.1
Build & Push Docker Image / build (push) Successful in 4m19s
Build & Push Docker Image / build (release) Successful in 4m12s
2026-03-25 11:34:38 +01:00
Michelle 45be976de1 Don't show guestWaitingMessage when "anyone_can_start" is set
Build & Push Docker Image / build (push) Successful in 4m14s
2026-03-25 10:13:02 +01:00
Michelle 6dcb1e959b feat: allow guests to start a room if anyone_can_start is enabled
Build & Push Docker Image / build (push) Successful in 4m58s
2026-03-25 09:55:47 +01:00
Michelle bb2d179871 style: Update button styling and icon size in RecordingList component for improved UI
Build & Push Docker Image / build (push) Successful in 4m29s
2026-03-24 11:28:15 +01:00
Michelle 82b7d060ba Merge remote-tracking branch 'refs/remotes/origin/main'
Build & Push Docker Image / build (push) Successful in 4m34s
2026-03-16 13:32:45 +01:00
Michelle 0836436fe7 feat: Implement Two-Factor Authentication (2FA) for enhanced user account security. 2026-03-16 13:28:43 +01:00
Michelle 99d3b22f62 chore: update bcryptjs and better-sqlite3 dependencies; upgrade dotenv version
Build & Push Docker Image / build (release) Successful in 4m5s
Build & Push Docker Image / build (push) Successful in 3m59s
2026-03-13 23:00:38 +01:00
Michelle eed5d98ccc chore: update dependencies for Vite and React plugin
Build & Push Docker Image / build (push) Successful in 4m9s
2026-03-13 22:48:08 +01:00
Michelle 6513fdee41 fix(Dockerfile): update base image and streamline build stages
Build & Push Docker Image / build (push) Successful in 4m17s
2026-03-13 22:41:18 +01:00
Michelle cae84754e4 feat: add analytics visibility settings and export functionality
Build & Push Docker Image / build (push) Successful in 5m11s
- Added `analytics_visibility` column to `rooms` table to control who can view analytics data.
- Updated analytics routes to check visibility settings before allowing access and export of analytics data.
- Implemented export functionality for analytics in CSV, XLSX, and PDF formats.
- Enhanced `AnalyticsList` component to include export options for analytics entries.
- Updated room detail page to allow setting analytics visibility when creating or editing rooms.
- Added translations for new analytics visibility options and export messages.
2026-03-13 22:36:07 +01:00
Michelle a0a972b53a fix(NotificationContext): ensure audio playback is unlocked only for authenticated users
Build & Push Docker Image / build (push) Successful in 6m32s
2026-03-13 13:00:54 +01:00
Michelle 9b98803053 fix(NotificationContext): handle user ID for notifications fetching and prevent stale responses
Build & Push Docker Image / build (push) Successful in 6m6s
2026-03-13 12:43:20 +01:00
Michelle e43e7f5fc5 fix: update license information to GNU GPL v3 in README, package.json, and package-lock.json
Build & Push Docker Image / build (push) Successful in 6m7s
Build & Push Docker Image / build (release) Successful in 6m14s
fix: Add valkey to compose in case system is too old for DragonflyDB
2026-03-13 12:11:32 +01:00
Michelle 5731e6a9a8 change README again
Build & Push Docker Image / build (push) Successful in 6m10s
2026-03-13 11:59:59 +01:00
Michelle fa8292263c chore: update version to 2.0.0 in package files and README
Build & Push Docker Image / build (push) Has been cancelled
2026-03-13 11:57:29 +01:00
Michelle 4bc3403040 fix(README): correct warning message formatting for clarity
Build & Push Docker Image / build (push) Has been cancelled
2026-03-13 11:54:25 +01:00
Michelle e4f596f8c3 change README
Build & Push Docker Image / build (push) Has been cancelled
2026-03-13 11:52:55 +01:00
Michelle 00e563664e feat(analytics): enhance analytics functionality with token validation and data extraction
Build & Push Docker Image / build (push) Successful in 6m29s
2026-03-13 10:34:39 +01:00
Michelle 41ad3e037a feat(rooms): add learning analytics field to room update request
Build & Push Docker Image / build (push) Successful in 6m31s
2026-03-13 10:09:11 +01:00
Michelle 7ef173c49e feat(analytics): implement learning analytics feature with data collection and display
Build & Push Docker Image / build (push) Successful in 6m33s
2026-03-13 09:46:15 +01:00
Michelle 530377272b feat(theme): add cotton candy light theme with custom colors
Build & Push Docker Image / build (push) Successful in 6m28s
2026-03-12 14:05:15 +01:00
Michelle 52f122a98a Merge remote-tracking branch 'refs/remotes/origin/main'
Build & Push Docker Image / build (push) Successful in 6m30s
2026-03-12 10:31:46 +01:00
Michelle c2dcb02e9b bugfix red-modular-light theme 2026-03-12 10:30:52 +01:00
Michelle 71557280f5 feat(sidebar): update user initials display to show first two letters of first and last name
Build & Push Docker Image / build (push) Successful in 6m32s
2026-03-10 22:50:47 +01:00
Michelle 03e484b8c6 feat(auth): improve logout process to redirect to Keycloak before clearing user state
Build & Push Docker Image / build (push) Successful in 6m24s
2026-03-10 22:41:27 +01:00
Michelle 14ed0c3689 feat(toaster): adjust container style for improved toast positioning
Build & Push Docker Image / build (push) Successful in 6m30s
2026-03-10 22:28:47 +01:00
Michelle 5fc64330e0 feat(migration): enhance migration script to include site settings, branding, and OAuth configuration
Build & Push Docker Image / build (push) Has been cancelled
2026-03-10 22:25:53 +01:00
Michelle 3ab7ab6a70 feat(auth): enhance logout process to support RP-Initiated Logout for OIDC users
Build & Push Docker Image / build (push) Successful in 6m35s
2026-03-10 22:19:01 +01:00
Michelle a7b0b84f2d feat(database): modify PostgreSQL insert query to return the entire inserted row for tables without an "id" column
Build & Push Docker Image / build (push) Successful in 6m35s
2026-03-10 15:14:03 +01:00
Michelle 11d3972a74 feat(caldav): implement service discovery for CalDAV with redirection
Build & Push Docker Image / build (push) Successful in 6m25s
2026-03-04 22:33:43 +01:00
Michelle d8c52aae4e feat(database): make token column nullable in caldav_tokens table for improved flexibility
Build & Push Docker Image / build (push) Successful in 6m36s
2026-03-04 22:24:11 +01:00
Michelle f16fd9aef2 feat(i18n): update hero title in English and German for consistency
Build & Push Docker Image / build (push) Successful in 6m26s
2026-03-04 13:52:30 +01:00
Michelle 8edcb7d3df feat(calendar): store only token hash in database to enhance security
Build & Push Docker Image / build (push) Successful in 6m25s
feat(federation): escape LIKE special characters in originDomain to prevent wildcard injection

feat(oauth): redirect with token in hash fragment to avoid exposure in logs

feat(OAuthCallback): retrieve token from hash fragment for improved security
2026-03-04 13:41:40 +01:00
Michelle 6aa01d39f4 feat(DateTimePicker): optimize onChange handling with ref for improved performance
Build & Push Docker Image / build (push) Successful in 6m29s
2026-03-04 13:25:53 +01:00
Michelle bb4da19f4f feat(Sidebar): update BrandLogo size for improved layout
Build & Push Docker Image / build (push) Successful in 6m26s
2026-03-04 13:11:55 +01:00
Michelle e8d8ccda42 feat(Home): update text for branding and hero title in multiple languages
Build & Push Docker Image / build (push) Has been cancelled
2026-03-04 13:08:23 +01:00
Michelle 1d647d0a36 feat(DateTimePicker): update styles for month navigation and current month display
Build & Push Docker Image / build (push) Successful in 6m30s
2026-03-04 12:54:18 +01:00
Michelle e3a5f21c8b feat(DateTimePicker): import flatpickr CSS for improved styling
Build & Push Docker Image / build (push) Successful in 6m33s
2026-03-04 12:41:36 +01:00
Michelle 014de634b1 feat(DateTimePicker): integrate flatpickr for enhanced date/time selection and theming
Build & Push Docker Image / build (push) Successful in 6m29s
2026-03-04 12:33:51 +01:00
Michelle 268f6d0c5a feat(DateTimePicker): replace custom date/time picker with native inputs for improved theming and accessibility
Build & Push Docker Image / build (push) Successful in 6m50s
2026-03-04 12:17:42 +01:00
Michelle 7018c5579f feat(DateTimePicker): add fixed strategy to popperProps for improved positioning
Build & Push Docker Image / build (push) Successful in 6m30s
2026-03-04 11:55:48 +01:00
Michelle 2a7754dd56 feat(DateTimePicker): add return statement to render DateTimePicker component
Build & Push Docker Image / build (push) Successful in 6m48s
2026-03-04 11:14:04 +01:00
Michelle a78fc06f2b feat(DateTimePicker): implement calendar open handler to preserve scroll position
Build & Push Docker Image / build (push) Has been cancelled
feat(Modal): remove max height restriction for modal body
style: clean up z-index for datepicker popper
2026-03-04 11:12:56 +01:00
Michelle 15bfcc80c3 feat(DateTimePicker): remove custom popper container and adjust z-index for improved layout
Build & Push Docker Image / build (push) Successful in 7m4s
2026-03-04 11:00:39 +01:00
Michelle fcb83a9b72 feat(DateTimePicker): implement custom popper container for improved layout handling
Build & Push Docker Image / build (push) Successful in 6m58s
feat(Modal): enhance modal styling with rounded corners and improved overflow handling
style: adjust z-index for datepicker popper to ensure proper layering above modals
2026-03-04 10:49:17 +01:00
Michelle a69b2e4d9a feat(database): update reminder_sent_at column type for calendar_events based on database type
Build & Push Docker Image / build (push) Successful in 6m59s
2026-03-04 10:33:55 +01:00
Michelle 0d84610e3b feat(BrandLogo): enhance logo sizing and visibility based on app name visibility
Build & Push Docker Image / build (push) Successful in 6m57s
2026-03-04 10:23:23 +01:00
Michelle 8823f8789e feat(calendar): add reminder functionality for events with notifications
Build & Push Docker Image / build (push) Has been cancelled
2026-03-04 10:18:43 +01:00
Michelle ce2cf499dc feat: implement hide app name feature with toggle in admin settings and update branding context
Build & Push Docker Image / build (push) Has been cancelled
2026-03-04 10:11:35 +01:00
Michelle bac4e8ae7c chore: update dependencies and enhance datetime picker styling
Build & Push Docker Image / build (push) Successful in 6m27s
- Updated `multer` to version 2.1.0 and `@vitejs/plugin-react` to version 4.7.0 in package.json.
- Updated `vite` to version 6.4.1.
- Enhanced CSS for various themes to include `color-scheme` and `--picker-icon-filter` properties for better datetime picker integration.
- Refactored Calendar component to include styled datetime picker with icons for start and end time inputs.
- Improved timezone display for better user experience.
2026-03-04 09:54:07 +01:00
Michelle 43d94181f9 feat: add getBaseUrl function for consistent base URL generation across routes
Build & Push Docker Image / build (push) Successful in 6m28s
feat(calendar): display local timezone in calendar view
feat(i18n): add timezone label to German and English translations
2026-03-04 09:44:02 +01:00
Michelle 61274d31f1 feat(calendar): implement local date string formatting for event filtering
Build & Push Docker Image / build (push) Successful in 6m27s
2026-03-04 09:31:45 +01:00
Michelle 3d21967681 fix(caldav): update legacy token migration to set token as empty string
Build & Push Docker Image / build (push) Successful in 6m29s
2026-03-04 09:26:22 +01:00
Michelle 2d919cdc67 feat(caldav): add token_hash column and store SHA-256 hashed tokens
Build & Push Docker Image / build (push) Successful in 6m27s
2026-03-04 09:17:31 +01:00
Michelle 6e301e2928 feat(notfound): add NotFound page with 404 handling and localization
Build & Push Docker Image / build (push) Successful in 6m33s
2026-03-04 08:57:21 +01:00
Michelle cdfc585c8a feat: implement OAuth 2.0 / OpenID Connect support
Build & Push Docker Image / build (push) Failing after 1m12s
- Added OAuth configuration management in the admin panel.
- Implemented OAuth authorization flow with PKCE for enhanced security.
- Created routes for handling OAuth provider discovery, authorization, and callback.
- Integrated OAuth login and registration options in the frontend.
- Updated UI components to support OAuth login and registration.
- Added internationalization strings for OAuth-related messages.
- Implemented encryption for client secrets and secure state management.
- Added error handling and user feedback for OAuth processes.
2026-03-04 08:54:25 +01:00
Michelle e22a895672 feat(security): enhance input validation and security measures across various routes
Build & Push Docker Image / build (push) Successful in 6m38s
2026-03-04 08:39:29 +01:00
Michelle ba096a31a2 feat(room): add copy link functionality with options for room and guest links
Build & Push Docker Image / build (push) Successful in 6m25s
2026-03-03 14:23:57 +01:00
Michelle d886725c4f feat(migration): add Greenlight to Redlight migration script
Build & Push Docker Image / build (push) Successful in 6m29s
2026-03-03 12:34:35 +01:00
Michelle f3ef490012 feat(caldav): enhance eventToICS function to include join links and organizer details
Build & Push Docker Image / build (push) Successful in 6m24s
2026-03-03 12:13:36 +01:00
Michelle ddc0c684ec feat(caldav): implement CalDAV support with token management and calendar operations
Build & Push Docker Image / build (push) Successful in 6m5s
2026-03-03 11:41:35 +01:00
Michelle 68f31467af feat(theme): add new themes for Everforest, Kanagawa, Ayu, Moonlight, and Cyberpunk
Build & Push Docker Image / build (push) Successful in 6m7s
2026-03-03 11:28:35 +01:00
Michelle 05f2941b16 fix(room): prevent click event propagation on actions container
Build & Push Docker Image / build (push) Successful in 6m26s
2026-03-03 10:38:48 +01:00
Michelle 4bb22be496 feat(room): enforce minimum room name length of 2 characters in creation and editing
Build & Push Docker Image / build (push) Successful in 6m17s
2026-03-03 10:02:28 +01:00
Michelle 1c9c5224ae feat(room): add copy link functionality with clipboard support and update translations
Build & Push Docker Image / build (push) Successful in 6m27s
2026-03-03 09:00:50 +01:00
Michelle 2b8c179d03 Add waiting queue for guest join with sound
Build & Push Docker Image / build (push) Successful in 6m26s
2026-03-03 06:38:01 +01:00
Michelle df82316097 feat(federation): update federation configuration comments for clarity and key path details
Build & Push Docker Image / build (push) Successful in 6m29s
Build & Push Docker Image / build (release) Successful in 6m29s
2026-03-02 23:04:51 +01:00
Michelle e4001cb33f feat(federation): update key path handling and ensure directory creation for federation keys
Build & Push Docker Image / build (push) Has been cancelled
2026-03-02 23:01:15 +01:00
Michelle 4a4ec0a3a3 feat(i18n): add German and English email templates for invitations and verifications
Build & Push Docker Image / build (push) Successful in 6m29s
2026-03-02 18:55:38 +01:00
Michelle 9be3be7712 feat(notifications): add notification sound playback for new alerts and include sound file documentation
Build & Push Docker Image / build (push) Successful in 6m27s
2026-03-02 18:32:47 +01:00
Michelle dc7a78badb feat(notifications): add delete functionality for individual and all notifications
Build & Push Docker Image / build (push) Successful in 6m26s
feat(guest-join): support access code in guest join URL
2026-03-02 18:17:13 +01:00
Michelle 272c5dc2cc feat(branding): add imprint and privacy links to GuestJoin and Home components
Build & Push Docker Image / build (push) Successful in 6m28s
2026-03-02 16:57:02 +01:00
Michelle c13090bc80 feat(notifications): implement notification system with CRUD operations and UI integration
Build & Push Docker Image / build (push) Successful in 6m27s
2026-03-02 16:45:53 +01:00
Michelle 304349fce8 feat(sidebar): enhance user avatar display with image support and overflow handling
Build & Push Docker Image / build (push) Successful in 6m29s
2026-03-02 16:22:20 +01:00
Michelle b5218046c9 Refactor code and improve internationalization support
Build & Push Docker Image / build (push) Has been cancelled
- Updated import statements to remove invisible characters.
- Standardized comments to use a consistent hyphen format.
- Adjusted username validation error messages for consistency.
- Enhanced email sending functions to include language support.
- Added email internationalization configuration for dynamic translations.
- Updated calendar and federation routes to include language in user queries.
- Improved user feedback messages in German and English for clarity.
2026-03-02 16:14:54 +01:00
Michelle c2c10f9a4b feat(calendar): implement local calendar invitations with email notifications
Build & Push Docker Image / build (push) Successful in 6m19s
- Added functionality to create, accept, decline, and delete local calendar invitations.
- Integrated email notifications for calendar event invitations and deletions.
- Updated database schema to support local invitations and outbound event tracking.
- Enhanced the calendar UI to display pending invitations and allow users to manage them.
- Localized new strings for invitations in English and German.
2026-03-02 14:37:54 +01:00
Michelle d989e1291d feat: implement calendar invitation system with creation, acceptance, and management features
Build & Push Docker Image / build (push) Successful in 6m26s
2026-03-02 13:57:56 +01:00
Michelle 62a3812424 feat: update red modular light theme colors for improved consistency
Build & Push Docker Image / build (push) Successful in 6m3s
2026-03-02 11:59:31 +01:00
Michelle 2a8ded5211 feat(outlook-addin): remove add-in files and update server to prevent serving static files
Build & Push Docker Image / build (push) Successful in 5m59s
feat(i18n): add translations for "Add to Outlook" and "Add to Google Calendar"
feat(calendar): implement functionality to generate Outlook and Google Calendar event links
2026-03-02 11:49:01 +01:00
Michelle 9275c20d19 feat(outlook-addin): remove deprecated version overrides and add new icon assets
Build & Push Docker Image / build (push) Successful in 6m4s
2026-03-02 11:38:15 +01:00
Michelle af7540eb8c feat(outlook-addin): serve static files and prevent SPA routing for add-in paths
Build & Push Docker Image / build (push) Successful in 6m6s
2026-03-02 11:31:56 +01:00
Michelle 13c60ba052 feat: add calendar component with event management features including create, edit, delete, and share functionalities
Build & Push Docker Image / build (push) Successful in 6m25s
2026-03-02 10:35:01 +01:00
Michelle fae46c8395 feat(admin): add context menu for user actions with dynamic positioning
Build & Push Docker Image / build (push) Successful in 6m31s
2026-03-01 14:18:21 +01:00
Michelle bfec8de195 fix(database): modify admin seeded setting insertion to return key
Build & Push Docker Image / build (push) Successful in 6m27s
2026-03-01 13:20:27 +01:00
Michelle 25b13b4078 fix(database): prevent duplicate admin creation during database initialization
Build & Push Docker Image / build (push) Successful in 6m17s
2026-03-01 13:09:22 +01:00
Michelle df4666bb63 feat(invite-system): implement user invite functionality with registration mode control
Build & Push Docker Image / build (push) Successful in 6m24s
Build & Push Docker Image / build (release) Successful in 6m25s
2026-03-01 12:53:45 +01:00
Michelle 8c39275615 fix(database): update user creation query to include display_name field
Build & Push Docker Image / build (push) Successful in 6m21s
2026-03-01 12:32:25 +01:00
75 changed files with 14132 additions and 3484 deletions
+4 -3
View File
@@ -43,7 +43,8 @@ SMTP_FROM=noreply@example.com
# TRUST_PROXY=loopback # TRUST_PROXY=loopback
# Federation (inter-instance meeting invitations) # Federation (inter-instance meeting invitations)
# Set both values to enable federation between Redlight instances # Set FEDERATION_DOMAIN to enable federation between Redlight instances
# FEDERATION_DOMAIN=redlight.example.com # FEDERATION_DOMAIN=redlight.example.com
# RSA Private Key for signing outbound invitations (automatically generated if missing on startup) # The Ed25519 key pair is auto-generated on first start and stored at ./keys/federation_key.pem
# FEDERATION_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgk...\n-----END PRIVATE KEY-----" # Override the path with FEDERATION_KEY_PATH if needed
# FEDERATION_KEY_PATH=/app/keys/federation_key.pem
+1 -1
View File
@@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- main - main
- federation-try - develop-calendar
release: release:
types: [published] types: [published]
+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/
+15 -12
View File
@@ -1,28 +1,31 @@
# ── Stage 1: Build frontend ────────────────────────────────────────────────── # ── Stage 1: Install dependencies ────────────────────────────────────────────
FROM node:20-bullseye-slim AS builder FROM node:22-trixie-slim AS deps
WORKDIR /app WORKDIR /app
# Install build tools and sqlite headers for native modules ENV DEBIAN_FRONTEND=noninteractive
# Install build tools for native modules (better-sqlite3, pdfkit)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 build-essential libsqlite3-dev ca-certificates \ python3 build-essential libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
# Install all dependencies (including dev for vite build)
RUN npm ci RUN npm ci
# ── Stage 2: Build frontend ─────────────────────────────────────────────────
FROM deps AS builder
COPY . . COPY . .
RUN npm run build RUN npm run build
# Produce production node_modules (compile native modules here for the target arch)
RUN npm ci --omit=dev && npm cache clean --force
# ── Stage 2: Production image ─────────────────────────────────────────────── # Prune dev dependencies in-place (avoids a second npm ci)
RUN npm prune --omit=dev
FROM node:20-bullseye-slim # ── Stage 3: Production image ───────────────────────────────────────────────
FROM node:22-trixie-slim
# Allow forcing build from source (useful when prebuilt binaries are not available)
ARG BUILD_FROM_SOURCE=false
ENV npm_config_build_from_source=${BUILD_FROM_SOURCE}
WORKDIR /app WORKDIR /app
+211 -136
View File
@@ -1,61 +1,71 @@
# 🔴 Redlight # 🔴 Redlight
A modern, self-hosted BigBlueButton frontend with beautiful themes, federation, and powerful features. > ⚠️ **Warning:** This project is entirely *vibe coded* and meant to be a fun/hobby project. Use at your own risk!
A modern, self-hosted BigBlueButton frontend with 25+ themes, federation, calendar, CalDAV, OAuth/OIDC, and powerful room management.
![Node.js](https://img.shields.io/badge/Node.js-20+-green) ![Node.js](https://img.shields.io/badge/Node.js-20+-green)
![React](https://img.shields.io/badge/React-18+-blue) ![React](https://img.shields.io/badge/React-18+-blue)
![License](https://img.shields.io/badge/License-MIT-yellow) ![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)
## ✨ Features ## ✨ Features
### Core Features ### Core Features
- 🎥 **Video Conferencing** Integrated BigBlueButton support for professional video meetings - 🎥 **Video Conferencing** - Integrated BigBlueButton support for professional video meetings
- 🎨 **15+ Themes** Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox, and more - 🎨 **25+ Themes** - Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox, Tokyo Night, Solarized, Everforest, Ayu, Kanagawa, Moonlight, Cyberpunk, Cotton Candy, and more
- 📝 **Room Management** Create unlimited rooms with custom settings, access codes, and moderator codes - 📝 **Room Management** - Create unlimited rooms with custom settings, access codes, and moderator codes
- 🔐 **User Management** Registration, login, role-based access control (Admin/User) - 🔐 **User Management** - Registration, login, role-based access control (Admin/User)
- 📹 **Recording Management** View, publish, and delete meeting recordings per room - 📹 **Recording Management** - View, publish, and delete meeting recordings per room
- 🌍 **Multi-Language Support** German (Deutsch) and English built-in, easily extensible - 📊 **Learning Analytics** - Collect and view per-room participant engagement data (talk time, messages, reactions) via BBB callbacks, secured with HMAC tokens
- ✉️ **Email Verification** Optional SMTP-based email verification for user registration - 📅 **Calendar** - Built-in calendar with event creation, sharing, customizable reminders, and room linking
- 👤 **User Profiles** Customizable avatars, themes, and language preferences - 📆 **CalDAV Server** - Full CalDAV support for syncing calendars with Thunderbird, Apple Calendar, GNOME Calendar, DAVx⁵ (Android), and other standard clients
- 📱 **Responsive Design** Works seamlessly on mobile, tablet, and desktop - 🌍 **Multi-Language Support** - German (Deutsch) and English built-in, easily extensible
- 🌐 **Federation** Invite users from remote Redlight instances via Ed25519-signed messages - 🔔 **In-App Notifications** - Real-time notifications for room shares, federation invites, and calendar reminders
- 🐉 **DragonflyDB / Redis** JWT blacklisting for secure token revocation on logout - ✉️ **Email Verification** - Optional SMTP-based email verification for user registration
- 🔑 **OAuth / OIDC** - Login via OpenID Connect providers (Keycloak, Authentik, etc.) with PKCE
- 👤 **User Profiles** - Customizable display names, avatars, themes, and language preferences
- 📱 **Responsive Design** - Works seamlessly on mobile, tablet, and desktop
- 🌐 **Federation** - Invite users from remote Redlight instances via Ed25519-signed messages
- 🐉 **DragonflyDB / Redis** - JWT blacklisting for secure token revocation on logout
### Admin Features ### Admin Features
- 👥 **User Administration** Manage users and roles - 👥 **User Administration** - Manage users and roles
- 🏢 **Branding Customization** Custom app name, logos, and default theme - 🏢 **Branding Customization** - Custom app name, logos, and default theme
- 📊 **Dashboard** Overview of system statistics - 📊 **Dashboard** - Overview of system statistics
- 🔧 **Settings Management** System-wide configuration - 🔧 **Settings Management** - System-wide configuration
- ✉️ **Invite-Only Registration** - Generate invite tokens for controlled user signup
### Room Features ### Room Features
- 🔑 **Access Codes** Restrict room access with optional passwords - 🔑 **Access Codes** - Restrict room access with optional passwords
- 🔐 **Moderator Codes** Separate code to grant moderator privileges - 🔐 **Moderator Codes** - Separate code to grant moderator privileges
- 🚪 **Guest Access** Allow unauthenticated users to join meetings (rate-limited) - 🚪 **Guest Access** - Allow unauthenticated users to join meetings (rate-limited)
- ⏱️ **Max Participants** Set limits on concurrent participants - ⏱️ **Max Participants** - Set limits on concurrent participants
- 🎤 **Mute on Join** Automatically mute new participants - 🎤 **Mute on Join** - Automatically mute new participants
-**Approval Mode** Require moderator approval for participants -**Approval Mode** - Require moderator approval for participants
- 🎙️ **Anyone Can Start** Allow participants to start the meeting - 🎙️ **Anyone Can Start** - Allow participants to start the meeting
- 📹 **Recording Settings** Control whether meetings are recorded - 📹 **Recording Settings** - Control whether meetings are recorded
- 📊 **Presentation Upload** Upload PDF, PPTX, ODP, or image files as default slides - 📊 **Learning Analytics** - Toggle per-room to collect participant engagement data after each meeting
- 🤝 **Room Sharing** Share rooms with other registered users - 📑 **Presentation Upload** - Upload PDF, PPTX, ODP, DOC, DOCX as default slides
- 🤝 **Room Sharing** - Share rooms with other registered users
### Security ### Security
- 🛡️ **Comprehensive Rate Limiting** Login, register, profile, avatar, guest-join, and federation endpoints - 🛡️ **Comprehensive Rate Limiting** - Login, register, profile, avatar, guest-join, OAuth, and federation endpoints
- 🔒 **Input Validation** Email format, field length limits, ID format checks, color format validation - 🔒 **Input Validation** - Email format, field length limits, ID format checks, color format validation
- 🕐 **Timing-Safe Comparisons** Access codes and moderator codes compared with `crypto.timingSafeEqual` - 🕐 **Timing-Safe Comparisons** - Access codes and moderator codes compared with `crypto.timingSafeEqual`
- 📏 **Streaming Upload Limits** Avatar (5 MB) and presentation (50 MB) uploads reject early without buffering - 📏 **Streaming Upload Limits** - Avatar (5 MB) and presentation (50 MB) uploads reject early without buffering
- 🧹 **XSS Prevention** HTML-escaped emails, XML-escaped BBB parameters, SVG sanitization - 🧹 **XSS Prevention** - HTML-escaped emails, XML-escaped BBB parameters, SVG sanitization
- 🔐 **JWT Blacklist** Token revocation via DragonflyDB/Redis on logout - 🔐 **JWT Blacklist** - Token revocation via DragonflyDB/Redis on logout
- 🌐 **CORS Restriction** Locked to `APP_URL` in production - 🌐 **CORS Restriction** - Locked to `APP_URL` in production
- ⚙️ **Configurable Trust Proxy** `TRUST_PROXY` env var for reverse proxy setups - ⚙️ **Configurable Trust Proxy** - `TRUST_PROXY` env var for reverse proxy setups
- 🔏 **HMAC-Secured Callbacks** - Learning analytics callback URLs signed with HMAC-SHA256
### Developer Features ### Developer Features
- 🐳 **Docker Support** Easy deployment with Docker Compose (includes PostgreSQL + DragonflyDB) - 🐳 **Docker Support** - Easy deployment with Docker Compose (includes PostgreSQL + DragonflyDB)
- 🗄️ **Database Flexibility** SQLite (default) or PostgreSQL support - 🗄️ **Database Flexibility** - SQLite (default) or PostgreSQL support
- 🔌 **REST API** Comprehensive API for custom integrations - 🔌 **REST API** - Comprehensive API for custom integrations
- 📦 **Open Source** Full source code transparency - 📦 **Open Source** - Full source code transparency
- 🛠️ **Self-Hosted** Complete data privacy and control - 🛠️ **Self-Hosted** - Complete data privacy and control
--- ---
@@ -63,20 +73,24 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
| Feature | Redlight | Greenlight | | Feature | Redlight | Greenlight |
|---------|----------|-----------| |---------|----------|-----------|
| **Theme System** | 15+ customizable themes | Limited theming | | **Theme System** | 25+ customizable themes | Limited theming |
| **Learning Analytics** | ✅ Per-room engagement data | ❌ Not supported |
| **Calendar / CalDAV** | ✅ Built-in calendar + CalDAV sync | ❌ Not supported |
| **OAuth / OIDC** | ✅ OpenID Connect (PKCE) | ✅ Supported |
| **Federation** | ✅ Cross-instance invites | ❌ Not supported | | **Federation** | ✅ Cross-instance invites | ❌ Not supported |
| **Notifications** | ✅ In-app + calendar reminders | ❌ Not supported |
| **Language Support** | Multi-language ready | Multi-language ready | | **Language Support** | Multi-language ready | Multi-language ready |
| **UI Framework** | React + Tailwind (Modern) | Rails-based (Traditional) | | **UI Framework** | React + Tailwind (Modern) | Rails-based (Traditional) |
| **User Preferences** | Theme, language, avatar | Limited customization | | **User Preferences** | Theme, language, avatar, display name | Limited customization |
| **Database Options** | SQLite / PostgreSQL | PostgreSQL only | | **Database Options** | SQLite / PostgreSQL | PostgreSQL only |
| **Docker** | ✅ Supported | ✅ Supported | | **Docker** | ✅ Supported | ✅ Supported |
| **Admin Dashboard** | Modern React UI | Legacy Rails interface | | **Admin Dashboard** | Modern React UI | Legacy Rails interface |
| **Room Sharing** | ✅ Share rooms with users | ✅ Supported | | **Room Sharing** | ✅ Share rooms with users | ✅ Supported |
| **Recording Management** | Full control per room | Standard management | | **Recording Management** | Full control per room | Standard management |
| **Presentation Upload** | ✅ Supported | ✅ Supported |
| **API** | RESTful JSON API | RESTful API | | **API** | RESTful JSON API | RESTful API |
| **Setup Complexity** | Simple (5 min) | Moderate (10-15 min) | | **Setup Complexity** | Simple (5 min) | Moderate (10-15 min) |
| **Customization** | Easy (Tailwind CSS) | Requires Ruby/Rails | | **Customization** | Easy (Tailwind CSS) | Requires Ruby/Rails |
| **Community** | doesn't exist lol | Established |
--- ---
@@ -103,9 +117,9 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
```env ```env
BBB_URL=https://your-bbb-server.com/bigbluebutton/api/ BBB_URL=https://your-bbb-server.com/bigbluebutton/api/
BBB_SECRET=your-bbb-shared-secret BBB_SECRET=your-bbb-shared-secret
JWT_SECRET=your-secret-key # REQUIRED app won't start without this JWT_SECRET=your-secret-key # REQUIRED - app won't start without this
APP_URL=https://your-domain.com # Used for CORS and email links APP_URL=https://your-domain.com # Used for CORS and email links
DATABASE_URL=postgres://user:password@postgres:5432/redlight DATABASE_URL=postgres://redlight:redlight@postgres:5432/redlight
POSTGRES_USER=redlight POSTGRES_USER=redlight
POSTGRES_PASSWORD=redlight POSTGRES_PASSWORD=redlight
@@ -118,18 +132,23 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
# TRUST_PROXY=loopback # TRUST_PROXY=loopback
# Optional: Email verification # Optional: Email verification
SMTP_HOST=smtp.gmail.com # SMTP_HOST=smtp.gmail.com
SMTP_PORT=587 # SMTP_PORT=587
SMTP_USER=your-email@gmail.com # SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password # SMTP_PASS=your-app-password
# Optional: Federation (cross-instance room invites) # Optional: Federation (cross-instance room invites)
# FEDERATION_DOMAIN=your-domain.com # FEDERATION_DOMAIN=your-domain.com
# Optional: OAuth / OIDC login
# OAUTH_ISSUER=https://auth.your-domain.com/realms/your-realm
# OAUTH_CLIENT_ID=redlight
# OAUTH_CLIENT_SECRET=your-client-secret
``` ```
3. **Start the application** 3. **Start the application**
```bash ```bash
docker-compose up -d docker compose up -d
``` ```
4. **Access the application** 4. **Access the application**
@@ -165,8 +184,10 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
- **Frontend**: React 18, Tailwind CSS, React Router, Lucide Icons - **Frontend**: React 18, Tailwind CSS, React Router, Lucide Icons
- **Backend**: Node.js 20, Express, JWT, Bcrypt - **Backend**: Node.js 20, Express, JWT, Bcrypt
- **Database**: SQLite / PostgreSQL with better-sqlite3 / pg - **Database**: SQLite / PostgreSQL with better-sqlite3 / pg
- **Cache**: DragonflyDB / Redis (ioredis) JWT blacklisting - **Cache**: DragonflyDB / Redis (ioredis) - JWT blacklisting
- **Email**: Nodemailer - **Email**: Nodemailer
- **CalDAV**: xml2js-based WebDAV/CalDAV server
- **Auth**: JWT + OAuth/OIDC (PKCE)
- **Build**: Vite - **Build**: Vite
--- ---
@@ -176,20 +197,24 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
``` ```
redlight/ redlight/
├── server/ # Node.js/Express backend ├── server/ # Node.js/Express backend
│ ├── config/ # Database, Redis, mailer, BBB & federation config │ ├── config/ # Database, Redis, mailer, BBB, federation, OAuth & notification config
│ ├── middleware/ # JWT authentication & token blacklisting │ ├── i18n/ # Server-side translations (email templates)
│ ├── routes/ # API endpoints (auth, rooms, recordings, admin, branding, federation) │ ├── jobs/ # Background jobs (federation sync, calendar reminders)
│ ├── middleware/ # JWT authentication, logging & token blacklisting
│ ├── routes/ # API endpoints (auth, rooms, recordings, admin, branding,
│ │ # federation, calendar, caldav, notifications, oauth, analytics)
│ └── index.js # Server entry point │ └── index.js # Server entry point
├── src/ # React frontend ├── src/ # React frontend
│ ├── components/ # Reusable components │ ├── components/ # Reusable components (RecordingList, AnalyticsList, etc.)
│ ├── contexts/ # React context (Auth, Language, Theme, Branding) │ ├── contexts/ # React context (Auth, Language, Theme, Branding, Notification)
│ ├── i18n/ # Translations (DE, EN) │ ├── i18n/ # Translations (DE, EN)
│ ├── pages/ # Page components │ ├── pages/ # Page components
│ ├── services/ # API client │ ├── services/ # API client
│ ├── themes/ # Tailwind theme config │ ├── themes/ # 25+ theme definitions
│ └── main.jsx # Frontend entry point │ └── main.jsx # Frontend entry point
├── public/ # Static assets ├── public/ # Static assets
├── uploads/ # User avatars, branding & presentations (runtime) ├── uploads/ # User avatars, branding & presentations (runtime)
├── keys/ # Federation Ed25519 key pair (auto-generated)
├── compose.yml # Docker Compose (Redlight + PostgreSQL + DragonflyDB) ├── compose.yml # Docker Compose (Redlight + PostgreSQL + DragonflyDB)
├── Dockerfile # Multi-stage container image ├── Dockerfile # Multi-stage container image
└── package.json # Dependencies └── package.json # Dependencies
@@ -199,77 +224,109 @@ redlight/
## 🔐 Security ## 🔐 Security
- **JWT Authentication** Secure token-based auth with 7-day expiration and `jti`-based blacklisting via DragonflyDB/Redis - **JWT Authentication** - Secure token-based auth with 7-day expiration and `jti`-based blacklisting via DragonflyDB/Redis
- **Mandatory JWT Secret** Server refuses to start without a `JWT_SECRET` env var - **Mandatory JWT Secret** - Server refuses to start without a `JWT_SECRET` env var
- **HTTPS Ready** Configure behind reverse proxy (nginx, Caddy); trust proxy via `TRUST_PROXY` env - **OAuth / OIDC** - OpenID Connect with PKCE (S256) and cryptographic state tokens
- **Password Hashing** bcryptjs with salt rounds 12, minimum 8-character passwords - **HTTPS Ready** - Configure behind reverse proxy (nginx, Caddy); trust proxy via `TRUST_PROXY` env
- **Email Verification** Optional SMTP-based email verification with resend support - **Password Hashing** - bcryptjs with salt rounds 12, minimum 8-character passwords
- **CORS Protection** Restricted to `APP_URL` in production, open in development - **Email Verification** - Optional SMTP-based email verification with resend support
- **Rate Limiting** Login, register, profile, password, avatar, guest-join, and federation endpoints - **CORS Protection** - Restricted to `APP_URL` in production, open in development
- **Input Validation** Email regex, field length limits, ID format checks, hex-color format checks - **Rate Limiting** - Login, register, profile, password, avatar, guest-join, OAuth, and federation endpoints
- **Timing-Safe Comparisons** Access codes and moderator codes compared via `crypto.timingSafeEqual` - **Input Validation** - Email regex, field length limits, ID format checks, hex-color format checks
- **Upload Safety** Streaming body size limits (avatar 5 MB, presentation 50 MB) abort early without buffering - **Timing-Safe Comparisons** - Access codes and moderator codes compared via `crypto.timingSafeEqual`
- **XSS / Injection Prevention** HTML-escaped emails, XML-escaped BBB API parameters, SVG logos served as `attachment` - **Upload Safety** - Streaming body size limits (avatar 5 MB, presentation 50 MB) abort early without buffering
- **Admin Isolation** Role-based access control with strict admin checks - **XSS / Injection Prevention** - HTML-escaped emails, XML-escaped BBB API parameters, SVG logos served as `attachment`
- **HMAC-Secured Callbacks** - Learning analytics callback URLs signed with HMAC-SHA256 derived from BBB_SECRET
- **Admin Isolation** - Role-based access control with strict admin checks
- **Network Isolation** - Docker Compose uses an internal backend network for DB and cache
--- ---
## 📦 API Endpoints ## 📦 API Endpoints
### Authentication ### Authentication
- `POST /api/auth/register` Register new user - `POST /api/auth/register` - Register new user
- `POST /api/auth/login` Login user - `POST /api/auth/login` - Login user
- `POST /api/auth/logout` Logout (blacklists JWT) - `POST /api/auth/logout` - Logout (blacklists JWT)
- `GET /api/auth/verify-email?token=...` Verify email with token - `GET /api/auth/verify-email?token=...` - Verify email with token
- `POST /api/auth/resend-verification` Resend verification email - `POST /api/auth/resend-verification` - Resend verification email
- `GET /api/auth/me` Get current user info - `GET /api/auth/me` - Get current user info
- `PUT /api/auth/profile` Update profile (theme, language, display name) - `PUT /api/auth/profile` - Update profile (theme, language, display name)
- `PUT /api/auth/password` Change password - `PUT /api/auth/password` - Change password
- `POST /api/auth/avatar` Upload avatar image - `POST /api/auth/avatar` - Upload avatar image
- `DELETE /api/auth/avatar` Remove avatar image - `DELETE /api/auth/avatar` - Remove avatar image
### Rooms ### Rooms
- `GET /api/rooms` List user's rooms (owned + shared) - `GET /api/rooms` - List user's rooms (owned + shared)
- `POST /api/rooms` Create new room - `POST /api/rooms` - Create new room
- `GET /api/rooms/:uid` Get room details - `GET /api/rooms/:uid` - Get room details
- `PUT /api/rooms/:uid` Update room - `PUT /api/rooms/:uid` - Update room (incl. learning analytics toggle)
- `DELETE /api/rooms/:uid` Delete room - `DELETE /api/rooms/:uid` - Delete room
- `POST /api/rooms/:uid/start` Start meeting - `POST /api/rooms/:uid/start` - Start meeting
- `POST /api/rooms/:uid/join` Join meeting as authenticated user - `POST /api/rooms/:uid/join` - Join meeting as authenticated user
- `POST /api/rooms/:uid/guest-join` Join meeting as guest (rate-limited) - `POST /api/rooms/:uid/guest-join` - Join meeting as guest (rate-limited)
- `POST /api/rooms/:uid/end` End meeting - `POST /api/rooms/:uid/end` - End meeting
- `GET /api/rooms/:uid/running` Check if meeting is running - `GET /api/rooms/:uid/status` - Check if meeting is running
- `GET /api/rooms/:uid/shares` List shared users - `GET /api/rooms/:uid/shares` - List shared users
- `POST /api/rooms/:uid/shares` Share room with user - `POST /api/rooms/:uid/shares` - Share room with user
- `DELETE /api/rooms/:uid/shares/:userId` Remove share - `DELETE /api/rooms/:uid/shares/:userId` - Remove share
- `POST /api/rooms/:uid/presentation` Upload default presentation (PDF, PPTX, ODP, images) - `POST /api/rooms/:uid/presentation` - Upload default presentation
- `DELETE /api/rooms/:uid/presentation` Remove presentation - `DELETE /api/rooms/:uid/presentation` - Remove presentation
### Recordings ### Recordings
- `GET /api/recordings/:roomUid` List room recordings - `GET /api/recordings/room/:uid` - List room recordings
- `PUT /api/recordings/:recordingId` Publish/unpublish recording - `PUT /api/recordings/:recordID/publish` - Publish/unpublish recording
- `DELETE /api/recordings/:recordingId` Delete recording - `DELETE /api/recordings/:recordID` - Delete recording
### Learning Analytics
- `POST /api/analytics/callback/:uid?token=...` - BBB callback (HMAC-secured)
- `GET /api/analytics/room/:uid` - Get analytics for a room
- `DELETE /api/analytics/:id` - Delete analytics entry
### Calendar
- `GET /api/calendar` - List calendar events
- `POST /api/calendar` - Create event
- `PUT /api/calendar/:uid` - Update event
- `DELETE /api/calendar/:uid` - Delete event
- `GET /api/calendar/caldav-tokens` - List CalDAV tokens
- `POST /api/calendar/caldav-tokens` - Create CalDAV token
- `DELETE /api/calendar/caldav-tokens/:id` - Delete CalDAV token
### Notifications
- `GET /api/notifications` - List notifications
- `PUT /api/notifications/:id/read` - Mark as read
- `POST /api/notifications/read-all` - Mark all as read
- `DELETE /api/notifications/:id` - Delete notification
### Admin ### Admin
- `GET /api/admin/users` List all users - `GET /api/admin/users` - List all users
- `GET /api/admin/stats` System statistics - `GET /api/admin/stats` - System statistics
- `POST /api/admin/users` Create user (admin) - `POST /api/admin/users` - Create user (admin)
- `PUT /api/admin/users/:id` Update user - `PUT /api/admin/users/:id` - Update user
- `DELETE /api/admin/users/:id` Delete user - `DELETE /api/admin/users/:id` - Delete user
### Branding ### Branding
- `GET /api/branding` Get branding settings - `GET /api/branding` - Get branding settings
- `PUT /api/branding` Update branding (admin only) - `PUT /api/branding` - Update branding (admin only)
- `POST /api/branding/logo` Upload custom logo - `POST /api/branding/logo` - Upload custom logo
- `DELETE /api/branding/logo` Remove custom logo - `DELETE /api/branding/logo` - Remove custom logo
### OAuth
- `GET /api/oauth/url` - Get OAuth authorization URL
- `GET /api/oauth/callback` - OAuth callback (PKCE exchange)
### Federation ### Federation
- `GET /.well-known/redlight` Instance discovery (domain, public key) - `GET /.well-known/redlight` - Instance discovery (domain, public key)
- `POST /api/federation/invite` Send invitation to remote user - `POST /api/federation/invite` - Send invitation to remote user
- `POST /api/federation/receive` Receive invitation from remote instance (rate-limited) - `POST /api/federation/receive` - Receive invitation from remote instance (rate-limited)
- `GET /api/federation/invitations` List received invitations - `GET /api/federation/invitations` - List received invitations
- `PUT /api/federation/invitations/:id` Accept / decline invitation - `PUT /api/federation/invitations/:id` - Accept / decline invitation
- `DELETE /api/federation/invitations/:id` Delete invitation - `DELETE /api/federation/invitations/:id` - Delete invitation
### CalDAV
- `PROPFIND /caldav/` - CalDAV discovery
- `REPORT /caldav/:user/calendar/` - Calendar query
- `GET/PUT/DELETE /caldav/:user/calendar/:uid.ics` - Event CRUD
--- ---
@@ -290,15 +347,25 @@ Redlight comes with built-in support for multiple languages. Currently supported
## 🎨 Themes ## 🎨 Themes
Redlight includes the following themes: Redlight includes 25+ themes:
- 🌙 Dracula - ☀️ Light / 🌙 Dark (default)
- 🐱 Catppuccin Mocha / Latte
- 🧛 Dracula
- ❄️ Nord - ❄️ Nord
- 🐱 Catppuccin
- 🌹 Rosé Pine
- 🍂 Gruvbox (Dark, Light)
- 💜 One Dark
- 🌊 Tokyo Night - 🌊 Tokyo Night
- And more... - 💜 One Dark
- 🐙 GitHub Dark
- 🌹 Rosé Pine / Rosé Pine Dawn
- 🍂 Gruvbox Dark / Gruvbox Light
- ☀️ Solarized Dark / Solarized Light
- 🌲 Everforest Dark / Everforest Light
- 🌊 Kanagawa
- 🌙 Moonlight
- 🎮 Cyberpunk
- 🌸 Ayu Dark
- 🔴 Red Modular Light
- 🍬 Cotton Candy Light
- 🐱 scrunkly.cat Dark
Themes are fully customizable by editing `src/themes/index.js`. Themes are fully customizable by editing `src/themes/index.js`.
@@ -309,30 +376,37 @@ Themes are fully customizable by editing `src/themes/index.js`.
### Using Docker Compose (Recommended) ### Using Docker Compose (Recommended)
```bash ```bash
docker-compose up -d docker compose up -d
``` ```
Services: Services:
- **redlight** Node.js application - **redlight** - Node.js application (port 3001)
- **postgres** PostgreSQL database - **postgres** - PostgreSQL 17 database
- **dragonfly** DragonflyDB (Redis-compatible) for JWT blacklisting - **dragonfly** - DragonflyDB (Redis-compatible) for JWT blacklisting
The `compose.yml` uses isolated networks: `frontend` (public) and `backend` (internal, no external access). Data is persisted via named volumes (`pgdata`, `uploads`, `dragonflydata`). Federation keys are mounted from `./keys`.
### Environment Variables ### Environment Variables
| Variable | Required | Default | Description | | Variable | Required | Default | Description |
|----------|----------|---------|-------------| |----------|----------|---------|-------------|
| `BBB_URL` | Yes | | BigBlueButton API URL | | `BBB_URL` | Yes | - | BigBlueButton API URL |
| `BBB_SECRET` | Yes | | BigBlueButton shared secret | | `BBB_SECRET` | Yes | - | BigBlueButton shared secret |
| `JWT_SECRET` | Yes | | Secret for signing JWTs (server won't start without it) | | `JWT_SECRET` | Yes | - | Secret for signing JWTs (server won't start without it) |
| `APP_URL` | Recommended | | Public URL of the app (used for CORS + email links) | | `APP_URL` | Recommended | - | Public URL of the app (used for CORS + email links) |
| `DATABASE_URL` | No | SQLite | PostgreSQL connection string | | `DATABASE_URL` | No | SQLite | PostgreSQL connection string |
| `REDIS_URL` | No | `redis://localhost:6379` | DragonflyDB / Redis URL | | `REDIS_URL` | No | `redis://localhost:6379` | DragonflyDB / Redis URL |
| `TRUST_PROXY` | No | `loopback` | Express trust proxy setting (number or string) | | `TRUST_PROXY` | No | `loopback` | Express trust proxy setting (number or string) |
| `SMTP_HOST` | No | | SMTP server for email verification | | `SMTP_HOST` | No | - | SMTP server for email verification |
| `SMTP_PORT` | No | `587` | SMTP port | | `SMTP_PORT` | No | `587` | SMTP port |
| `SMTP_USER` | No | | SMTP username | | `SMTP_USER` | No | - | SMTP username |
| `SMTP_PASS` | No | | SMTP password | | `SMTP_PASS` | No | - | SMTP password |
| `FEDERATION_DOMAIN` | No | | Domain for federation (enables cross-instance invites) | | `FEDERATION_DOMAIN` | No | - | Domain for federation (enables cross-instance invites) |
| `OAUTH_ISSUER` | No | - | OIDC issuer URL (enables OAuth login) |
| `OAUTH_CLIENT_ID` | No | - | OIDC client ID |
| `OAUTH_CLIENT_SECRET` | No | - | OIDC client secret |
| `ADMIN_EMAIL` | No | `admin@example.com` | Default admin email (first start only) |
| `ADMIN_PASSWORD` | No | `admin123` | Default admin password (first start only) |
### Production Deployment ### Production Deployment
@@ -376,8 +450,9 @@ Federation allows users on different Redlight instances to invite each other int
### Setup ### Setup
1. Set `FEDERATION_DOMAIN=your-domain.com` in `.env`. 1. Set `FEDERATION_DOMAIN=your-domain.com` in `.env`.
2. On first start, an Ed25519 key pair is generated automatically and stored in `server/config/federation_key.pem`. 2. On first start, an Ed25519 key pair is generated automatically and stored in `keys/federation_key.pem`.
3. Other instances discover your public key via `GET /.well-known/redlight`. 3. In Docker, mount `./keys:/app/keys` (already configured in `compose.yml`).
4. Other instances discover your public key via `GET /.well-known/redlight`.
### How it works ### How it works
@@ -413,13 +488,13 @@ curl "https://your-bbb-server/bigbluebutton/api/getMeetings?checksum=..."
**Solution**: Clear browser cache (Ctrl+Shift+Del) or restart dev server with `npm run dev`. **Solution**: Clear browser cache (Ctrl+Shift+Del) or restart dev server with `npm run dev`.
### Issue: "DragonflyDB connection error" ### Issue: "DragonflyDB connection error"
**Solution**: Ensure DragonflyDB (or Redis) is running and `REDIS_URL` is correct. If unavailable, the app still works JWT blacklisting degrades gracefully (logout won't revoke tokens immediately). **Solution**: Ensure DragonflyDB (or Redis) is running and `REDIS_URL` is correct. If unavailable, the app still works - JWT blacklisting degrades gracefully (logout won't revoke tokens immediately).
--- ---
## 📝 License ## 📝 License
This project is licensed under the MIT License see [LICENSE](LICENSE) file for details. This project is licensed under the GNU GPL v3 (or later) - see [LICENSE](LICENSE) file for details.
--- ---
+33 -2
View File
@@ -6,12 +6,16 @@ services:
- "3001:3001" - "3001:3001"
env_file: ".env" env_file: ".env"
volumes: volumes:
- uploads:/app/uploads - ./uploads:/app/uploads
- ./keys:/app/keys
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
dragonfly: dragonfly:
condition: service_healthy condition: service_healthy
networks:
- frontend
- backend
postgres: postgres:
image: postgres:17-alpine image: postgres:17-alpine
@@ -24,6 +28,8 @@ services:
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
networks:
- backend
dragonfly: dragonfly:
image: ghcr.io/dragonflydb/dragonfly:latest image: ghcr.io/dragonflydb/dragonfly:latest
@@ -37,8 +43,33 @@ services:
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
networks:
- backend
# Use valkey, if your system is too old for DragonflyDB
# valkey:
# image: valkey/valkey:9
# restart: unless-stopped
# ulimits:
# memlock: -1
# volumes:
# - valkeydata:/data
# healthcheck:
# test: ["CMD", "redis-cli", "-p", "6379", "ping"]
# interval: 5s
# timeout: 5s
# retries: 5
# networks:
# - backend
volumes: volumes:
pgdata: pgdata:
uploads:
dragonflydata: dragonflydata:
#valkeydata:
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true
+1 -1
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 />
+694
View File
@@ -0,0 +1,694 @@
/**
* Greenlight → Redlight Migration Script
*
* Migrates users, rooms (including settings and shared accesses),
* site settings (branding, registration), logo, and OAuth/OIDC configuration
* from a Greenlight v3 PostgreSQL database into a Redlight instance
* (SQLite or PostgreSQL).
*
* Usage:
* node migrate-from-greenlight.mjs [options]
*
* Options:
* --dry-run Show what would be migrated without writing anything
* --skip-rooms Only migrate users, not rooms
* --skip-shares Do not migrate room share (shared_accesses)
* --skip-settings Do not migrate site_settings / branding
* --skip-oauth Do not migrate OAuth / OIDC configuration
* --verbose Print every imported row
*
* Environment variables (can also be in .env):
*
* Source (Greenlight DB — PostgreSQL):
* GL_DATABASE_URL Full Postgres connection string
* e.g. postgres://gl_user:pass@localhost/greenlight_db
*
* Target (Redlight DB — auto-detected):
* DATABASE_URL Set for PostgreSQL target (same format as GL_DATABASE_URL)
* SQLITE_PATH Set (or leave empty) for SQLite target
* Default: ./redlight.db (relative to this script)
*
* OAuth (from Greenlight .env — optional, only if --skip-oauth is NOT set):
* GL_OIDC_ISSUER OIDC issuer URL (e.g. https://keycloak.example.com/realms/myrealm)
* GL_OIDC_CLIENT_ID OIDC client ID
* GL_OIDC_CLIENT_SECRET OIDC client secret
* GL_OIDC_DISPLAY_NAME Button label on the login page (default: "SSO")
*
* Password hashes:
* Both Greenlight and Redlight use bcrypt, so password_digest is
* copied as-is — users can log in with their existing passwords.
*
* Site-settings mapping (GL site_settings → Redlight settings):
* BrandingImage → downloads file → uploads/branding/logo.*
* PrimaryColor → (logged, not mapped — Redlight uses themes)
* RegistrationMethod → registration_mode (open / invite)
* Terms → imprint_url
* PrivacyPolicy → privacy_url
*
* Meeting-option → Redlight field mapping:
* record → record_meeting
* muteOnStart → mute_on_join
* guestPolicy → require_approval (ASK_MODERATOR = true)
* glAnyoneCanStart → anyone_can_start
* glAnyoneJoinAsModerator → all_join_moderator
* glViewerAccessCode → access_code
* glModeratorAccessCode → moderator_code
*/
import 'dotenv/config';
import pg from 'pg';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ── helpers ────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const DRY_RUN = args.includes('--dry-run');
const SKIP_ROOMS = args.includes('--skip-rooms');
const SKIP_SHARES = args.includes('--skip-shares');
const SKIP_SETTINGS = args.includes('--skip-settings');
const SKIP_OAUTH = args.includes('--skip-oauth');
const VERBOSE = args.includes('--verbose');
const c = {
reset: '\x1b[0m',
bold: '\x1b[1m',
green: '\x1b[32m',
yellow:'\x1b[33m',
red: '\x1b[31m',
cyan: '\x1b[36m',
dim: '\x1b[2m',
};
const log = (...a) => console.log(...a);
const ok = (msg) => log(` ${c.green}${c.reset} ${msg}`);
const warn = (msg) => log(` ${c.yellow}${c.reset} ${msg}`);
const skip = (msg) => log(` ${c.dim} ${msg}${c.reset}`);
const err = (msg) => log(` ${c.red}${c.reset} ${msg}`);
const info = (msg) => log(` ${c.cyan}i${c.reset} ${msg}`);
const loud = (msg) => { if (VERBOSE) log(` ${c.dim}${msg}${c.reset}`); };
// ── Source DB (Greenlight PostgreSQL) ─────────────────────────────────────
const GL_URL = process.env.GL_DATABASE_URL;
if (!GL_URL) {
err('GL_DATABASE_URL is not set. Please set it in your .env file or environment.');
err(' Example: GL_DATABASE_URL=postgres://gl_user:pass@localhost/greenlight_production');
process.exit(1);
}
// ── Target DB (Redlight) ───────────────────────────────────────────────────
const RL_URL = process.env.DATABASE_URL;
const isPostgresTarget = !!(RL_URL && RL_URL.startsWith('postgres'));
// ── SQLite adapter (only loaded when needed) ───────────────────────────────
async function openSqlite() {
const require = createRequire(import.meta.url);
let Database;
try {
Database = require('better-sqlite3');
} catch {
err('better-sqlite3 is not installed. Run: npm install better-sqlite3');
process.exit(1);
}
const dbPath = process.env.SQLITE_PATH || './redlight.db';
const db = Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
return {
async get(sql, params = []) { return db.prepare(sql).get(...params); },
async all(sql, params = []) { return db.prepare(sql).all(...params); },
async run(sql, params = []) {
const r = db.prepare(sql).run(...params);
return { lastInsertRowid: Number(r.lastInsertRowid), changes: r.changes };
},
close() { db.close(); },
type: 'sqlite',
path: dbPath,
};
}
// ── PostgreSQL adapter ─────────────────────────────────────────────────────
async function openPostgres(url, label) {
const pool = new pg.Pool({ connectionString: url });
let index = 0;
const convert = (sql) => sql.replace(/\?/g, () => `$${++index}`);
return {
async get(sql, params = []) {
index = 0;
const r = await pool.query(convert(sql), params);
return r.rows[0];
},
async all(sql, params = []) {
index = 0;
const r = await pool.query(convert(sql), params);
return r.rows;
},
async run(sql, params = []) {
index = 0;
let q = convert(sql);
if (/^\s*INSERT/i.test(q) && !/RETURNING/i.test(q)) q += ' RETURNING id';
const r = await pool.query(q, params);
return { lastInsertRowid: r.rows[0]?.id, changes: r.rowCount };
},
async end() { await pool.end(); },
type: 'postgres',
};
}
// ── meeting option → redlight field map ───────────────────────────────────
const OPTION_MAP = {
record: 'record_meeting',
muteOnStart: 'mute_on_join',
guestPolicy: 'require_approval', // special: "ASK_MODERATOR"
glAnyoneCanStart: 'anyone_can_start',
glAnyoneJoinAsModerator: 'all_join_moderator',
glViewerAccessCode: 'access_code',
glModeratorAccessCode: 'moderator_code',
};
function boolOption(val) {
return val === 'true' || val === '1' ? 1 : 0;
}
// ── AES-256-GCM encryption (matching Redlight's oauth.js) ─────────────────
const ENCRYPTION_KEY = crypto
.createHash('sha256')
.update(process.env.JWT_SECRET || '')
.digest();
function encryptSecret(plaintext) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
}
// ── Helper: upsert a setting into the Redlight settings table ─────────────
async function upsertSetting(db, key, value, isPostgres) {
const existing = await db.get('SELECT key FROM settings WHERE key = ?', [key]);
if (existing) {
await db.run('UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?', [value, key]);
} else {
await db.run('INSERT INTO settings (key, value) VALUES (?, ?) RETURNING key', [key, value]);
}
}
// ── main ───────────────────────────────────────────────────────────────────
async function main() {
log();
log(`${c.bold}Greenlight → Redlight Migration${c.reset}`);
if (DRY_RUN) log(`${c.yellow}${c.bold} DRY RUN — nothing will be written${c.reset}`);
log();
// Connect to source
let glDb;
try {
glDb = await openPostgres(GL_URL, 'Greenlight');
await glDb.get('SELECT 1');
info(`Connected to Greenlight DB (PostgreSQL)`);
} catch (e) {
err(`Cannot connect to Greenlight DB: ${e.message}`);
process.exit(1);
}
// Connect to target
let rlDb;
try {
if (isPostgresTarget) {
rlDb = await openPostgres(RL_URL, 'Redlight');
await rlDb.get('SELECT 1');
info(`Connected to Redlight DB (PostgreSQL)`);
} else {
rlDb = await openSqlite();
info(`Connected to Redlight DB (SQLite: ${rlDb.path})`);
}
} catch (e) {
err(`Cannot connect to Redlight DB: ${e.message}`);
process.exit(1);
}
log();
// ── 1. Load Greenlight roles ───────────────────────────────────────────
const roles = await glDb.all('SELECT id, name FROM roles');
const adminRoleIds = new Set(
roles.filter(r => /admin|administrator/i.test(r.name)).map(r => r.id)
);
info(`Found ${roles.length} roles (${adminRoleIds.size} admin role(s))`);
// ── 2. Load Greenlight users (local + OIDC) ────────────────────────────
const glUsers = await glDb.all(`
SELECT id, name, email, password_digest, language, role_id, verified, status, provider, external_id
FROM users
ORDER BY created_at ASC
`);
const localUsers = glUsers.filter(u => u.provider === 'greenlight');
const oidcUsers = glUsers.filter(u => u.provider !== 'greenlight' && u.provider !== null);
info(`Found ${localUsers.length} local user(s), ${oidcUsers.length} OIDC/SSO user(s)`);
log();
// ── 3. Migrate local users ─────────────────────────────────────────────
log(`${c.bold}Local Users${c.reset}`);
const userIdMap = new Map(); // gl user id → rl user id
let usersCreated = 0, usersSkipped = 0;
for (const u of localUsers) {
if (!u.password_digest) {
warn(`${u.email} — no password, skipping`);
usersSkipped++;
continue;
}
const role = adminRoleIds.has(u.role_id) ? 'admin' : 'user';
const emailVerified = u.verified ? 1 : 0;
const lang = u.language || 'de';
const displayName = u.name || '';
const existing = await rlDb.get('SELECT id FROM users WHERE email = ?', [u.email]);
if (existing) {
userIdMap.set(u.id, existing.id);
skip(`${u.email} — already exists (id=${existing.id}), skipping`);
usersSkipped++;
continue;
}
loud(`INSERT user ${u.email} (role=${role})`);
if (!DRY_RUN) {
const result = await rlDb.run(
`INSERT INTO users (name, display_name, email, password_hash, role, language, email_verified)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[displayName, displayName, u.email, u.password_digest, role, lang, emailVerified]
);
userIdMap.set(u.id, result.lastInsertRowid);
ok(`${u.email} (${role})`);
} else {
ok(`[dry] ${u.email} (${role})`);
}
usersCreated++;
}
log();
log(` Created: ${c.green}${usersCreated}${c.reset} Skipped: ${usersSkipped}`);
log();
// ── 3b. Migrate OIDC / SSO users ───────────────────────────────────────
log(`${c.bold}OIDC / SSO Users${c.reset}`);
let oidcCreated = 0, oidcSkipped = 0;
for (const u of oidcUsers) {
const role = adminRoleIds.has(u.role_id) ? 'admin' : 'user';
const lang = u.language || 'de';
const displayName = u.name || '';
const email = (u.email || '').toLowerCase().trim();
if (!email) {
warn(`OIDC user id=${u.id} — no email, skipping`);
oidcSkipped++;
continue;
}
const existing = await rlDb.get('SELECT id FROM users WHERE email = ?', [email]);
if (existing) {
userIdMap.set(u.id, existing.id);
// Link OAuth provider if not set yet
if (!DRY_RUN && u.external_id) {
await rlDb.run(
'UPDATE users SET oauth_provider = ?, oauth_provider_id = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND oauth_provider IS NULL',
['oidc', u.external_id, existing.id]
);
}
skip(`${email} — already exists (id=${existing.id}), linked OIDC`);
oidcSkipped++;
continue;
}
// Generate a random unusable password hash for OIDC users
const randomHash = `oauth:${crypto.randomUUID()}`;
loud(`INSERT OIDC user ${email} (role=${role}, sub=${u.external_id || '?'})`);
if (!DRY_RUN) {
const result = await rlDb.run(
`INSERT INTO users (name, display_name, email, password_hash, role, language, email_verified, oauth_provider, oauth_provider_id)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`,
[displayName, displayName, email, randomHash, role, lang, 'oidc', u.external_id || null]
);
userIdMap.set(u.id, result.lastInsertRowid);
ok(`${email} (${role}, OIDC)`);
} else {
ok(`[dry] ${email} (${role}, OIDC)`);
}
oidcCreated++;
}
log();
log(` Created: ${c.green}${oidcCreated}${c.reset} Skipped: ${oidcSkipped}`);
log();
if (SKIP_ROOMS) {
log(`${c.yellow}--skip-rooms set, stopping here.${c.reset}`);
await cleanup(glDb, rlDb);
return;
}
// ── 4. Load Greenlight rooms + meeting options ─────────────────────────
log(`${c.bold}Rooms${c.reset}`);
const glRooms = await glDb.all(`
SELECT r.id, r.name, r.meeting_id, r.user_id
FROM rooms r
ORDER BY r.created_at ASC
`);
// Load all meeting options (key name list)
const meetingOptions = await glDb.all('SELECT id, name FROM meeting_options');
const optionIdToName = new Map(meetingOptions.map(m => [m.id, m.name]));
// Load all room_meeting_option values in one shot
const roomOptionRows = await glDb.all('SELECT room_id, meeting_option_id, value FROM room_meeting_options');
const roomOptions = new Map(); // room_id → { optionName: value }
for (const row of roomOptionRows) {
const name = optionIdToName.get(row.meeting_option_id);
if (!name) continue;
if (!roomOptions.has(row.room_id)) roomOptions.set(row.room_id, {});
roomOptions.get(row.room_id)[name] = row.value;
}
info(`Found ${glRooms.length} room(s) across all users`);
log();
let roomsCreated = 0, roomsSkipped = 0;
const roomIdMap = new Map(); // gl room id → rl room id
for (const room of glRooms) {
// Determine the redlight owner
const rlUserId = userIdMap.get(room.user_id);
if (!rlUserId && !DRY_RUN) {
// Try to look up the email in redlight directly in case user already existed
const glUser = glUsers.find(u => u.id === room.user_id);
if (glUser) {
const ex = await rlDb.get('SELECT id FROM users WHERE email = ?', [glUser.email]);
if (ex) {
userIdMap.set(room.user_id, ex.id);
} else {
warn(`Room "${room.name}" — owner not found in Redlight, skipping`);
roomsSkipped++;
continue;
}
} else {
warn(`Room "${room.name}" — owner not found in Greenlight users, skipping`);
roomsSkipped++;
continue;
}
}
const ownerId = userIdMap.get(room.user_id) || null;
// Use Greenlight meeting_id as the uid (preserves BBB meeting identity)
// Greenlight meeting_ids can be longer, but Redlight stores uid as TEXT — no problem.
const uid = room.meeting_id;
// Check if already in Redlight
const existingRoom = await rlDb.get('SELECT id FROM rooms WHERE uid = ?', [uid]);
if (existingRoom) {
roomIdMap.set(room.id, existingRoom.id);
skip(`"${room.name}" (${uid.substring(0, 12)}…) — already exists`);
roomsSkipped++;
continue;
}
// Map meeting options to Redlight fields
const opts = roomOptions.get(room.id) || {};
const mute_on_join = opts.muteOnStart ? boolOption(opts.muteOnStart) : 1;
const record_meeting = opts.record ? boolOption(opts.record) : 1;
const anyone_can_start = opts.glAnyoneCanStart ? boolOption(opts.glAnyoneCanStart) : 0;
const all_join_moderator = opts.glAnyoneJoinAsModerator ? boolOption(opts.glAnyoneJoinAsModerator): 0;
const require_approval = opts.guestPolicy === 'ASK_MODERATOR' ? 1 : 0;
const access_code = (opts.glViewerAccessCode && opts.glViewerAccessCode !== 'false')
? opts.glViewerAccessCode : null;
const moderator_code = (opts.glModeratorAccessCode && opts.glModeratorAccessCode !== 'false')
? opts.glModeratorAccessCode : null;
const guest_access = 1; // Default open like Greenlight
// Ensure room name meets Redlight 2-char minimum
const roomName = (room.name || 'Room').length >= 2 ? room.name : (room.name || 'Room').padEnd(2, ' ');
loud(`INSERT room "${roomName}" uid=${uid.substring(0, 16)}… owner=${ownerId}`);
if (!DRY_RUN && ownerId) {
const result = await rlDb.run(
`INSERT INTO rooms (uid, name, user_id, mute_on_join, record_meeting, anyone_can_start,
all_join_moderator, require_approval, access_code, moderator_code, guest_access)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[uid, roomName, ownerId, mute_on_join, record_meeting, anyone_can_start,
all_join_moderator, require_approval, access_code, moderator_code, guest_access]
);
roomIdMap.set(room.id, result.lastInsertRowid);
const optSummary = [
mute_on_join ? 'muted' : null,
record_meeting ? 'record' : null,
anyone_can_start ? 'anyStart' : null,
all_join_moderator ? 'allMod' : null,
require_approval ? 'approval' : null,
access_code ? 'code:'+access_code.substring(0,4)+'…' : null,
].filter(Boolean).join(', ');
ok(`"${roomName}"${optSummary ? ` [${optSummary}]` : ''}`);
} else if (DRY_RUN) {
ok(`[dry] "${roomName}" (uid=${uid.substring(0, 16)}…)`);
} else {
warn(`"${roomName}" — skipped (no owner resolved)`);
roomsSkipped++;
continue;
}
roomsCreated++;
}
log();
log(` Created: ${c.green}${roomsCreated}${c.reset} Skipped: ${roomsSkipped}`);
log();
// ── 5. Migrate shared_accesses ─────────────────────────────────────────
if (!SKIP_SHARES) {
log(`${c.bold}Room Shares${c.reset}`);
const shares = await glDb.all('SELECT user_id, room_id FROM shared_accesses');
info(`Found ${shares.length} shared accesse(s)`);
log();
let sharesCreated = 0, sharesSkipped = 0;
for (const s of shares) {
const rlUser = userIdMap.get(s.user_id);
const rlRoom = roomIdMap.get(s.room_id);
if (!rlUser || !rlRoom) {
loud(`Skip share user=${s.user_id} room=${s.room_id} — not found in target`);
sharesSkipped++;
continue;
}
const exists = await rlDb.get(
'SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?',
[rlRoom, rlUser]
);
if (exists) {
sharesSkipped++;
continue;
}
if (!DRY_RUN) {
await rlDb.run(
'INSERT INTO room_shares (room_id, user_id) VALUES (?, ?)',
[rlRoom, rlUser]
);
ok(`Share room_id=${rlRoom} → user_id=${rlUser}`);
} else {
ok(`[dry] Share room_id=${rlRoom} → user_id=${rlUser}`);
}
sharesCreated++;
}
log();
log(` Created: ${c.green}${sharesCreated}${c.reset} Skipped: ${sharesSkipped}`);
log();
}
// ── 6. Migrate site_settings / branding ────────────────────────────────
if (!SKIP_SETTINGS) {
log(`${c.bold}Site Settings & Branding${c.reset}`);
let settingsCount = 0;
// Check if site_settings table exists in Greenlight
let hasSiteSettings = false;
try {
await glDb.get('SELECT 1 FROM site_settings LIMIT 1');
hasSiteSettings = true;
} catch {
warn('No site_settings table found in Greenlight DB — skipping settings migration');
}
if (hasSiteSettings) {
const glSettings = await glDb.all('SELECT setting, value FROM site_settings');
const settingsMap = new Map(glSettings.map(s => [s.setting, s.value]));
info(`Found ${glSettings.length} site_setting(s) in Greenlight`);
// ── Registration mode ──────────────────────────────────────────────
const regMethod = settingsMap.get('RegistrationMethod');
if (regMethod) {
// Greenlight: "open", "invite", "approval" → Redlight: "open" or "invite"
const mode = regMethod === 'open' ? 'open' : 'invite';
if (!DRY_RUN) await upsertSetting(rlDb, 'registration_mode', mode, isPostgresTarget);
ok(`registration_mode → ${mode} (was: ${regMethod})`);
settingsCount++;
}
// ── Privacy policy URL ─────────────────────────────────────────────
const privacy = settingsMap.get('PrivacyPolicy');
if (privacy && privacy.trim()) {
if (!DRY_RUN) await upsertSetting(rlDb, 'privacy_url', privacy.trim(), isPostgresTarget);
ok(`privacy_url → ${privacy.trim()}`);
settingsCount++;
}
// ── Terms / Imprint URL ────────────────────────────────────────────
const terms = settingsMap.get('Terms');
if (terms && terms.trim()) {
if (!DRY_RUN) await upsertSetting(rlDb, 'imprint_url', terms.trim(), isPostgresTarget);
ok(`imprint_url → ${terms.trim()}`);
settingsCount++;
}
// ── Primary color (informational only — Redlight uses themes) ─────
const primaryColor = settingsMap.get('PrimaryColor');
if (primaryColor) {
info(`PrimaryColor in Greenlight was: ${primaryColor} (not mapped — Redlight uses themes)`);
}
// ── Branding image / logo ──────────────────────────────────────────
const brandingImage = settingsMap.get('BrandingImage');
if (brandingImage && brandingImage.trim()) {
const logoUrl = brandingImage.trim();
info(`BrandingImage URL: ${logoUrl}`);
if (!DRY_RUN) {
try {
const response = await fetch(logoUrl, { signal: AbortSignal.timeout(15_000) });
if (response.ok) {
const contentType = response.headers.get('content-type') || '';
let ext = '.png';
if (contentType.includes('svg')) ext = '.svg';
else if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = '.jpg';
else if (contentType.includes('gif')) ext = '.gif';
else if (contentType.includes('webp')) ext = '.webp';
else if (contentType.includes('ico')) ext = '.ico';
const brandingDir = path.join(__dirname, 'uploads', 'branding');
fs.mkdirSync(brandingDir, { recursive: true });
// Remove old logos
if (fs.existsSync(brandingDir)) {
for (const f of fs.readdirSync(brandingDir)) {
if (f.startsWith('logo.')) fs.unlinkSync(path.join(brandingDir, f));
}
}
const logoPath = path.join(brandingDir, `logo${ext}`);
const buffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync(logoPath, buffer);
ok(`Logo saved → uploads/branding/logo${ext} (${(buffer.length / 1024).toFixed(1)} KB)`);
settingsCount++;
} else {
warn(`Could not download logo (HTTP ${response.status}) — skipping`);
}
} catch (dlErr) {
warn(`Logo download failed: ${dlErr.message}`);
}
} else {
ok(`[dry] Would download logo from ${logoUrl}`);
settingsCount++;
}
}
log();
log(` Settings migrated: ${c.green}${settingsCount}${c.reset}`);
log();
}
}
// ── 7. Migrate OAuth / OIDC configuration ──────────────────────────────
if (!SKIP_OAUTH) {
log(`${c.bold}OAuth / OIDC Configuration${c.reset}`);
const issuer = process.env.GL_OIDC_ISSUER;
const clientId = process.env.GL_OIDC_CLIENT_ID;
const clientSecret = process.env.GL_OIDC_CLIENT_SECRET;
const displayName = process.env.GL_OIDC_DISPLAY_NAME || 'SSO';
if (issuer && clientId && clientSecret) {
if (!process.env.JWT_SECRET) {
warn('JWT_SECRET is not set — cannot encrypt client secret. Skipping OAuth migration.');
} else {
// Check if already configured
const existing = await rlDb.get("SELECT key FROM settings WHERE key = 'oauth_config'");
if (existing) {
warn('oauth_config already exists in Redlight — skipping (delete it first to re-migrate)');
} else {
info(`Issuer: ${issuer}`);
info(`Client ID: ${clientId}`);
info(`Display name: ${displayName}`);
if (!DRY_RUN) {
const config = JSON.stringify({
issuer,
clientId,
encryptedSecret: encryptSecret(clientSecret),
displayName,
autoRegister: true,
});
await upsertSetting(rlDb, 'oauth_config', config, isPostgresTarget);
ok('OAuth/OIDC configuration saved');
} else {
ok('[dry] Would save OAuth/OIDC configuration');
}
}
}
} else if (issuer || clientId || clientSecret) {
warn('Incomplete OIDC config — set GL_OIDC_ISSUER, GL_OIDC_CLIENT_ID, and GL_OIDC_CLIENT_SECRET');
} else {
info('No GL_OIDC_* env vars set — skipping OAuth migration');
info('Set GL_OIDC_ISSUER, GL_OIDC_CLIENT_ID, GL_OIDC_CLIENT_SECRET to migrate OIDC config');
}
log();
}
// ── Summary ────────────────────────────────────────────────────────────
log(`${c.bold}${c.green}Migration complete${c.reset}`);
log();
log(` Local users migrated: ${c.green}${usersCreated}${c.reset}`);
log(` OIDC users migrated: ${c.green}${oidcCreated}${c.reset}`);
log(` Rooms migrated: ${c.green}${roomsCreated}${c.reset}`);
if (!SKIP_SHARES) log(` Shares migrated: (see above)`);
if (!SKIP_SETTINGS) log(` Settings migrated: (see above)`);
if (!SKIP_OAUTH) log(` OAuth config: (see above)`);
if (DRY_RUN) {
log();
log(` ${c.yellow}${c.bold}This was a DRY RUN — rerun without --dry-run to apply.${c.reset}`);
}
log();
await cleanup(glDb, rlDb);
}
async function cleanup(glDb, rlDb) {
try { await glDb.end?.(); } catch {}
try { await rlDb.end?.(); rlDb.close?.(); } catch {}
}
main().catch(e => {
console.error(`\n${c.red}Fatal error:${c.reset}`, e.message);
process.exit(1);
});
+2500 -2367
View File
File diff suppressed because it is too large Load Diff
+25 -19
View File
@@ -1,7 +1,8 @@
{ {
"name": "redlight", "name": "redlight",
"private": true, "private": true,
"version": "1.2.1", "version": "2.3.0",
"license": "GPL-3.0-or-later",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently -n client,server -c blue,green \"vite\" \"node --watch server/index.js\"", "dev": "concurrently -n client,server -c blue,green \"vite\" \"node --watch server/index.js\"",
@@ -13,34 +14,39 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.0", "axios": "^1.7.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^11.0.0", "better-sqlite3": "^12.6.2",
"concurrently": "^9.0.0", "concurrently": "^9.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.0", "dotenv": "^17.3.1",
"express": "^4.21.0", "exceljs": "^4.4.0",
"express-rate-limit": "^7.5.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"flatpickr": "^4.6.13",
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"lucide-react": "^0.460.0", "lucide-react": "^1.16.0",
"multer": "^2.0.2", "multer": "^2.1.0",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"otpauth": "^9.5.0",
"pdfkit": "^0.18.0",
"pg": "^8.18.0", "pg": "^8.18.0",
"rate-limit-redis": "^4.3.1", "qrcode": "^1.5.4",
"react": "^18.3.0", "rate-limit-redis": "^5.0.0",
"react-dom": "^18.3.0", "react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hot-toast": "^2.4.0", "react-hot-toast": "^2.4.0",
"react-router-dom": "^6.28.0", "react-router-dom": "^7.15.1",
"uuid": "^13.0.0", "uuid": "^14.0.0",
"xml2js": "^0.6.0" "xml2js": "^0.6.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.0", "@tailwindcss/postcss": "^4.3.0",
"@types/react-dom": "^18.3.0", "@types/react": "^19.2.14",
"@vitejs/plugin-react": "^4.3.0", "@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.0", "@vitejs/plugin-react": "^6.0.1",
"postcss": "^8.4.0", "postcss": "^8.4.0",
"tailwindcss": "^3.4.0", "tailwindcss": "^4.3.0",
"vite": "^5.4.0" "vite": "^8.0.0"
} }
} }
+1 -2
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

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

+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';
}
+37 -10
View File
@@ -1,10 +1,25 @@
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';
const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/'; const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/';
const BBB_SECRET = process.env.BBB_SECRET || ''; const BBB_SECRET = process.env.BBB_SECRET || '';
if (!BBB_SECRET) {
log.bbb.warn('WARNING: BBB_SECRET is not set. BBB API calls will use an empty secret.');
}
// HTML-escape for safe embedding in BBB welcome messages
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getChecksum(apiCall, params) { function getChecksum(apiCall, params) {
const queryString = new URLSearchParams(params).toString(); const queryString = new URLSearchParams(params).toString();
const raw = apiCall + queryString + BBB_SECRET; const raw = apiCall + queryString + BBB_SECRET;
@@ -59,22 +74,22 @@ function getRoomPasswords(uid) {
return { moderatorPW: modPw, attendeePW: attPw }; return { moderatorPW: modPw, attendeePW: attPw };
} }
export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null) { export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null, analyticsCallbackURL = null, lang = 'en') {
const { moderatorPW, attendeePW } = getRoomPasswords(room.uid); const { moderatorPW, attendeePW } = getRoomPasswords(room.uid);
// Build welcome message with guest invite link // Build welcome message with guest invite link
let welcome = room.welcome_message || t('defaultWelcome'); // HTML-escape user-controlled content to prevent stored XSS via BBB
let welcome = room.welcome_message ? escapeHtml(room.welcome_message) : escapeHtml(t(lang, 'bbb.defaultWelcome'));
if (logoutURL) { if (logoutURL) {
const guestLink = `${logoutURL}/join/${room.uid}`; const guestLink = `${logoutURL}/join/${room.uid}`;
welcome += `<br><br>To invite other participants, share this link:<br><a href="${guestLink}">${guestLink}</a>`; welcome += `<br><br>${escapeHtml(t(lang, 'bbb.inviteHint'))}<br><a href="${escapeHtml(guestLink)}">${escapeHtml(guestLink)}</a>`;
if (room.access_code) { // Access code is intentionally NOT shown in the welcome message to prevent
welcome += `<br>Access Code: <b>${room.access_code}</b>`; // leaking it to all meeting participants.
}
} }
const params = { const params = {
meetingID: room.uid, meetingID: room.uid,
name: room.name, name: room.name.length >= 2 ? room.name : room.name.padEnd(2, ' '),
attendeePW, attendeePW,
moderatorPW, moderatorPW,
welcome, welcome,
@@ -97,8 +112,11 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati
if (room.access_code) { if (room.access_code) {
params.lockSettingsLockOnJoin = 'true'; params.lockSettingsLockOnJoin = 'true';
} }
if (analyticsCallbackURL) {
params['meta_analytics-callback-url'] = analyticsCallbackURL;
}
// 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;
if (presentationUrl) { if (presentationUrl) {
const safeUrl = presentationUrl const safeUrl = presentationUrl
@@ -112,7 +130,7 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati
return apiCall('create', params, xmlBody); return apiCall('create', params, xmlBody);
} }
export async function joinMeeting(uid, name, isModerator = false, avatarURL = null) { export async function joinMeeting(uid, name, isModerator = false, avatarURL = null, darkMode = false) {
const { moderatorPW, attendeePW } = getRoomPasswords(uid); const { moderatorPW, attendeePW } = getRoomPasswords(uid);
const params = { const params = {
meetingID: uid, meetingID: uid,
@@ -123,6 +141,11 @@ export async function joinMeeting(uid, name, isModerator = false, avatarURL = nu
if (avatarURL) { if (avatarURL) {
params.avatarURL = avatarURL; params.avatarURL = avatarURL;
} }
if (darkMode) {
// Per-user userdata override: start the client in dark theme for users who
// have a dark theme active in Redlight. Read by BBB as bbb_prefer_dark_theme.
params['userdata-bbb_prefer_dark_theme'] = 'true';
}
return buildUrl('join', params); return buildUrl('join', params);
} }
@@ -174,4 +197,8 @@ export async function publishRecording(recordID, publish) {
return apiCall('publishRecordings', { recordID, publish: publish ? 'true' : 'false' }); return apiCall('publishRecordings', { recordID, publish: publish ? 'true' : 'false' });
} }
export function getAnalyticsToken(uid) {
return crypto.createHmac('sha256', BBB_SECRET).update('analytics_' + uid).digest('hex');
}
export { getRoomPasswords }; export { getRoomPasswords };
+474 -13
View File
@@ -49,6 +49,12 @@ class SqliteAdapter {
return !!columns.find(c => c.name === column); return !!columns.find(c => c.name === column);
} }
async columnIsNullable(table, column) {
const columns = this.db.pragma(`table_info(${table})`);
const col = columns.find(c => c.name === column);
return col ? col.notnull === 0 : true;
}
close() { close() {
this.db.close(); this.db.close();
} }
@@ -77,7 +83,9 @@ class PostgresAdapter {
let pgSql = convertPlaceholders(sql); let pgSql = convertPlaceholders(sql);
const isInsert = /^\s*INSERT/i.test(pgSql); const isInsert = /^\s*INSERT/i.test(pgSql);
if (isInsert && !/RETURNING/i.test(pgSql)) { if (isInsert && !/RETURNING/i.test(pgSql)) {
pgSql += ' RETURNING id'; // Some tables (e.g. settings, oauth_states) have no "id" column.
// Return the inserted row generically and read id only when present.
pgSql += ' RETURNING *';
} }
const result = await this.pool.query(pgSql, params); const result = await this.pool.query(pgSql, params);
return { return {
@@ -98,6 +106,14 @@ class PostgresAdapter {
return result.rows.length > 0; return result.rows.length > 0;
} }
async columnIsNullable(table, column) {
const result = await this.pool.query(
'SELECT is_nullable FROM information_schema.columns WHERE table_name = $1 AND column_name = $2',
[table, column]
);
return result.rows.length > 0 ? result.rows[0].is_nullable === 'YES' : true;
}
close() { close() {
this.pool?.end(); this.pool?.end();
} }
@@ -106,7 +122,7 @@ class PostgresAdapter {
// ── Public API ────────────────────────────────────────────────────────────── // ── Public API ──────────────────────────────────────────────────────────────
export function getDb() { export function getDb() {
if (!db) { if (!db) {
throw new Error('Database not initialised call initDatabase() first'); throw new Error('Database not initialised - call initDatabase() first');
} }
return db; return db;
} }
@@ -405,17 +421,462 @@ export async function initDatabase() {
`); `);
} }
// ── Default admin ─────────────────────────────────────────────────────── // User invite tokens (invite-only registration)
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com'; if (isPostgres) {
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; await db.exec(`
CREATE TABLE IF NOT EXISTS user_invites (
id SERIAL PRIMARY KEY,
token TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
used_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
used_at TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_invites_token ON user_invites(token);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS user_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
created_by INTEGER NOT NULL,
used_by INTEGER,
used_at DATETIME,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_user_invites_token ON user_invites(token);
`);
}
const existingAdmin = await db.get('SELECT id FROM users WHERE email = ?', [adminEmail]); // ── Calendar tables ──────────────────────────────────────────────────────
if (!existingAdmin) { if (isPostgres) {
const hash = bcrypt.hashSync(adminPassword, 12); await db.exec(`
await db.run( CREATE TABLE IF NOT EXISTS calendar_events (
'INSERT INTO users (name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, 1)', id SERIAL PRIMARY KEY,
['Administrator', adminEmail, hash, 'admin'] uid TEXT UNIQUE NOT NULL,
); title TEXT NOT NULL,
log.db.info(`Default admin created: ${adminEmail}`); description TEXT,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
room_uid TEXT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
color TEXT DEFAULT '#6366f1',
federated_from TEXT DEFAULT NULL,
federated_join_url TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cal_events_user_id ON calendar_events(user_id);
CREATE INDEX IF NOT EXISTS idx_cal_events_uid ON calendar_events(uid);
CREATE INDEX IF NOT EXISTS idx_cal_events_start ON calendar_events(start_time);
CREATE TABLE IF NOT EXISTS calendar_event_shares (
id SERIAL PRIMARY KEY,
event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(event_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_cal_shares_event ON calendar_event_shares(event_id);
CREATE INDEX IF NOT EXISTS idx_cal_shares_user ON calendar_event_shares(user_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
room_uid TEXT,
user_id INTEGER NOT NULL,
color TEXT DEFAULT '#6366f1',
federated_from TEXT DEFAULT NULL,
federated_join_url TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_cal_events_user_id ON calendar_events(user_id);
CREATE INDEX IF NOT EXISTS idx_cal_events_uid ON calendar_events(uid);
CREATE INDEX IF NOT EXISTS idx_cal_events_start ON calendar_events(start_time);
CREATE TABLE IF NOT EXISTS calendar_event_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(event_id, user_id),
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_cal_shares_event ON calendar_event_shares(event_id);
CREATE INDEX IF NOT EXISTS idx_cal_shares_user ON calendar_event_shares(user_id);
`);
}
// Calendar migrations: add federated columns if missing
if (!(await db.columnExists('calendar_events', 'federated_from'))) {
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_from TEXT DEFAULT NULL');
}
if (!(await db.columnExists('calendar_events', 'federated_join_url'))) {
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL');
}
if (!(await db.columnExists('calendar_events', 'reminder_minutes'))) {
await db.exec('ALTER TABLE calendar_events ADD COLUMN reminder_minutes INTEGER DEFAULT NULL');
}
if (!(await db.columnExists('calendar_events', 'reminder_sent_at'))) {
await db.exec(`ALTER TABLE calendar_events ADD COLUMN reminder_sent_at ${isPostgres ? 'TIMESTAMP' : 'DATETIME'} DEFAULT NULL`);
}
// Calendar invitations (federated calendar events that must be accepted first)
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_invitations (
id SERIAL PRIMARY KEY,
event_uid TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
room_uid TEXT,
join_url TEXT,
from_user TEXT NOT NULL,
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
color TEXT DEFAULT '#6366f1',
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cal_inv_to_user ON calendar_invitations(to_user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_cal_inv_uid_user ON calendar_invitations(event_uid, to_user_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_invitations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_uid TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
room_uid TEXT,
join_url TEXT,
from_user TEXT NOT NULL,
to_user_id INTEGER NOT NULL,
color TEXT DEFAULT '#6366f1',
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(event_uid, to_user_id)
);
CREATE INDEX IF NOT EXISTS idx_cal_inv_to_user ON calendar_invitations(to_user_id);
`);
}
// Track outbound calendar event federation sends for deletion propagation
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_event_outbound (
id SERIAL PRIMARY KEY,
event_uid TEXT NOT NULL,
remote_domain TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(event_uid, remote_domain)
);
CREATE INDEX IF NOT EXISTS idx_cal_out_event_uid ON calendar_event_outbound(event_uid);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_event_outbound (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_uid TEXT NOT NULL,
remote_domain TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(event_uid, remote_domain)
);
CREATE INDEX IF NOT EXISTS idx_cal_out_event_uid ON calendar_event_outbound(event_uid);
`);
}
// Local calendar event invitations (share-with-acceptance flow)
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_local_invitations (
id SERIAL PRIMARY KEY,
event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(event_id, to_user_id)
);
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_to_user ON calendar_local_invitations(to_user_id);
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_event ON calendar_local_invitations(event_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_local_invitations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL,
from_user_id INTEGER NOT NULL,
to_user_id INTEGER NOT NULL,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(event_id, to_user_id),
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_to_user ON calendar_local_invitations(to_user_id);
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_event ON calendar_local_invitations(event_id);
`);
}
// ── Notifications table ──────────────────────────────────────────────────
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS notifications (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT,
link TEXT,
read INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT,
link TEXT,
read INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
`);
}
// ── CalDAV tokens ────────────────────────────────────────────────────────
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS caldav_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
last_used_at TIMESTAMP DEFAULT NULL
);
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS caldav_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME DEFAULT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token);
`);
}
// CalDAV: add token_hash column for SHA-256 hashed token lookup
if (!(await db.columnExists('caldav_tokens', 'token_hash'))) {
await db.exec('ALTER TABLE caldav_tokens ADD COLUMN token_hash TEXT DEFAULT NULL');
await db.exec('CREATE INDEX IF NOT EXISTS idx_caldav_tokens_hash ON caldav_tokens(token_hash)');
}
// CalDAV: make token column nullable (now only token_hash is stored for new tokens)
if (!(await db.columnIsNullable('caldav_tokens', 'token'))) {
if (isPostgres) {
await db.exec('ALTER TABLE caldav_tokens ALTER COLUMN token DROP NOT NULL');
} else {
// SQLite does not support ALTER COLUMN — recreate the table
await db.exec(`
CREATE TABLE caldav_tokens_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT UNIQUE,
token_hash TEXT DEFAULT NULL,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME DEFAULT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
INSERT INTO caldav_tokens_new (id, user_id, token, token_hash, name, created_at, last_used_at)
SELECT id, user_id, token, token_hash, name, created_at, last_used_at FROM caldav_tokens;
DROP TABLE caldav_tokens;
ALTER TABLE caldav_tokens_new RENAME TO caldav_tokens;
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token);
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_hash ON caldav_tokens(token_hash);
`);
}
}
// ── OAuth tables ────────────────────────────────────────────────────────
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS oauth_states (
state TEXT PRIMARY KEY,
provider TEXT NOT NULL,
code_verifier TEXT NOT NULL,
return_to TEXT,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS oauth_states (
state TEXT PRIMARY KEY,
provider TEXT NOT NULL,
code_verifier TEXT NOT NULL,
return_to TEXT,
expires_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at);
`);
}
// Add OAuth columns to users table
if (!(await db.columnExists('users', 'oauth_provider'))) {
await db.exec('ALTER TABLE users ADD COLUMN oauth_provider TEXT DEFAULT NULL');
}
if (!(await db.columnExists('users', 'oauth_provider_id'))) {
await db.exec('ALTER TABLE users ADD COLUMN oauth_provider_id TEXT DEFAULT NULL');
}
// ── Learning Analytics table ─────────────────────────────────────────────
if (!(await db.columnExists('rooms', 'learning_analytics'))) {
await db.exec('ALTER TABLE rooms ADD COLUMN learning_analytics INTEGER DEFAULT 0');
}
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS learning_analytics_data (
id SERIAL PRIMARY KEY,
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
meeting_id TEXT NOT NULL,
meeting_name TEXT,
data JSONB NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_analytics_room_id ON learning_analytics_data(room_id);
CREATE INDEX IF NOT EXISTS idx_analytics_meeting_id ON learning_analytics_data(meeting_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS learning_analytics_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
meeting_id TEXT NOT NULL,
meeting_name TEXT,
data TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_analytics_room_id ON learning_analytics_data(room_id);
CREATE INDEX IF NOT EXISTS idx_analytics_meeting_id ON learning_analytics_data(meeting_id);
`);
}
// ── TOTP 2FA columns ──────────────────────────────────────────────────────
if (!(await db.columnExists('users', 'totp_secret'))) {
await db.exec('ALTER TABLE users ADD COLUMN totp_secret TEXT DEFAULT NULL');
}
if (!(await db.columnExists('users', 'totp_enabled'))) {
await db.exec('ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0');
}
// ── Analytics visibility setting ────────────────────────────────────────
if (!(await db.columnExists('rooms', 'analytics_visibility'))) {
await db.exec("ALTER TABLE rooms ADD COLUMN analytics_visibility TEXT DEFAULT 'owner'");
}
// ── 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);
`);
}
// ── Default admin (only on very first start) ────────────────────────────
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
if (!adminAlreadySeeded) {
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
// Check if admin already exists (upgrade from older version without the flag)
const existing = await db.get('SELECT id FROM users WHERE email = ?', [adminEmail]);
if (!existing) {
const hash = bcrypt.hashSync(adminPassword, 12);
await db.run(
'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)',
['Administrator', 'Administrator', adminEmail, hash, 'admin']
);
log.db.info(`Default admin created: ${adminEmail}`);
}
// Mark as seeded so it never runs again, even if the admin email is changed
await db.run("INSERT INTO settings (key, value) VALUES ('admin_seeded', '1') RETURNING key");
} }
} }
+52
View File
@@ -0,0 +1,52 @@
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url);
const cache = {};
function load(lang) {
if (cache[lang]) return cache[lang];
try {
cache[lang] = require(path.resolve(__dirname, '../i18n', `${lang}.json`));
return cache[lang];
} catch {
if (lang !== 'en') return load('en');
cache[lang] = {};
return cache[lang];
}
}
/**
* Translate a dot-separated key for the given language.
* Interpolates {placeholder} tokens from params.
* Unresolved tokens are left as-is so callers can do HTML substitution afterwards.
*
* @param {string} lang Language code, e.g. 'en', 'de'
* @param {string} keyPath Dot-separated key, e.g. 'email.verify.subject'
* @param {Record<string,string>} [params] Values to interpolate
* @returns {string}
*/
export function t(lang, keyPath, params = {}) {
const keys = keyPath.split('.');
function resolve(dict) {
let val = dict;
for (const k of keys) {
val = val?.[k];
}
return typeof val === 'string' ? val : undefined;
}
let value = resolve(load(lang));
// Fallback to English
if (value === undefined) value = resolve(load('en'));
if (value === undefined) return keyPath;
return value.replace(/\{(\w+)\}/g, (match, k) =>
params[k] !== undefined ? String(params[k]) : match
);
}
+69 -3
View File
@@ -4,6 +4,9 @@ import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { log } from './logger.js'; import { log } from './logger.js';
import dns from 'dns';
import net from 'net';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -13,7 +16,11 @@ let publicKeyPem = '';
// Load or generate Ed25519 keys // Load or generate Ed25519 keys
if (FEDERATION_DOMAIN) { if (FEDERATION_DOMAIN) {
const keyPath = path.join(__dirname, 'federation_key.pem'); const keyPath = process.env.FEDERATION_KEY_PATH || '/app/keys/federation_key.pem';
const keyDir = path.dirname(keyPath);
if (!fs.existsSync(keyDir)) {
fs.mkdirSync(keyDir, { recursive: true });
}
if (!privateKeyPem && fs.existsSync(keyPath)) { if (!privateKeyPem && fs.existsSync(keyPath)) {
privateKeyPem = fs.readFileSync(keyPath, 'utf8'); privateKeyPem = fs.readFileSync(keyPath, 'utf8');
@@ -89,13 +96,69 @@ export function verifyPayload(payload, signature, remotePublicKeyPem) {
} }
} }
/**
* Check if a domain resolves to a private/internal IP address (SSRF protection).
* Blocks RFC 1918, loopback, link-local, and cloud metadata IPs.
* @param {string} domain
* @returns {Promise<void>} throws if domain resolves to a blocked IP
*/
async function assertPublicDomain(domain) {
// Allow localhost only in development
if (domain === 'localhost' || domain === '127.0.0.1' || domain === '::1') {
if (process.env.NODE_ENV === 'production') {
throw new Error('Federation to localhost is blocked in production');
}
return; // allow in dev
}
// If domain is a raw IP, check it directly
if (net.isIP(domain)) {
if (isPrivateIP(domain)) {
throw new Error(`Federation blocked: ${domain} resolves to a private IP`);
}
return;
}
// Resolve domain and check all resulting IPs
const { resolve4, resolve6 } = dns.promises;
const ips = [];
try { ips.push(...await resolve4(domain)); } catch {}
try { ips.push(...await resolve6(domain)); } catch {}
if (ips.length === 0) {
throw new Error(`Federation blocked: could not resolve ${domain}`);
}
for (const ip of ips) {
if (isPrivateIP(ip)) {
throw new Error(`Federation blocked: ${domain} resolves to a private IP (${ip})`);
}
}
}
function isPrivateIP(ip) {
// IPv4 private ranges
if (/^10\./.test(ip)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
if (/^192\.168\./.test(ip)) return true;
if (/^127\./.test(ip)) return true;
if (/^0\./.test(ip)) return true;
if (/^169\.254\./.test(ip)) return true; // link-local
if (ip === '::1' || ip === '::' || ip.startsWith('fe80:') || ip.startsWith('fc') || ip.startsWith('fd')) return true;
return false;
}
/** /**
* Discover a remote Redlight instance's federation API base URL. * Discover a remote Redlight instance's federation API base URL.
* Fetches https://{domain}/.well-known/redlight and caches the result. * Fetches https://{domain}/.well-known/redlight and caches the result.
* Includes SSRF protection: blocks private/internal IPs.
* @param {string} domain * @param {string} domain
* @returns {Promise<{ baseUrl: string, publicKey: string }>} * @returns {Promise<{ baseUrl: string, publicKey: string }>}
*/ */
export async function discoverInstance(domain) { export async function discoverInstance(domain) {
// SSRF protection: validate domain doesn't resolve to internal IP
await assertPublicDomain(domain);
const cached = discoveryCache.get(domain); const cached = discoveryCache.get(domain);
if (cached && (Date.now() - cached.cachedAt) < DISCOVERY_TTL_MS) { if (cached && (Date.now() - cached.cachedAt) < DISCOVERY_TTL_MS) {
return cached; return cached;
@@ -108,7 +171,8 @@ export async function discoverInstance(domain) {
try { try {
response = await fetch(wellKnownUrl, { signal: AbortSignal.timeout(TIMEOUT_MS) }); response = await fetch(wellKnownUrl, { signal: AbortSignal.timeout(TIMEOUT_MS) });
} catch (e) { } catch (e) {
if (e.message.includes('fetch') && wellKnownUrl.startsWith('https://localhost')) { // HTTP fallback only allowed in development for localhost
if (e.message.includes('fetch') && domain === 'localhost' && process.env.NODE_ENV !== 'production') {
response = await fetch(`http://${domain}/.well-known/redlight`, { signal: AbortSignal.timeout(TIMEOUT_MS) }); response = await fetch(`http://${domain}/.well-known/redlight`, { signal: AbortSignal.timeout(TIMEOUT_MS) });
} else throw e; } else throw e;
} }
@@ -124,7 +188,9 @@ export async function discoverInstance(domain) {
const baseUrl = `https://${domain}${data.federation_api || '/api/federation'}`; const baseUrl = `https://${domain}${data.federation_api || '/api/federation'}`;
const result = { const result = {
baseUrl: baseUrl.replace('https://localhost', 'http://localhost'), baseUrl: (domain === 'localhost' && process.env.NODE_ENV !== 'production')
? baseUrl.replace('https://localhost', 'http://localhost')
: baseUrl,
publicKey: data.public_key, publicKey: data.public_key,
cachedAt: Date.now(), cachedAt: Date.now(),
}; };
+218 -32
View File
@@ -1,5 +1,6 @@
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { log } from './logger.js'; import { log } from './logger.js';
import { t } from './emaili18n.js';
let transporter; let transporter;
@@ -21,7 +22,7 @@ export function initMailer() {
const pass = process.env.SMTP_PASS; const pass = process.env.SMTP_PASS;
if (!host || !user || !pass) { if (!host || !user || !pass) {
log.mailer.warn('SMTP not configured email verification disabled'); log.mailer.warn('SMTP not configured - email verification disabled');
return false; return false;
} }
@@ -45,17 +46,17 @@ export function isMailerConfigured() {
/** /**
* Send the verification email with a clickable link. * Send the verification email with a clickable link.
* @param {string} to recipient email * @param {string} to - recipient email
* @param {string} name user's display name * @param {string} name - user's display name
* @param {string} verifyUrl full verification URL * @param {string} verifyUrl - full verification URL
* @param {string} appName branding app name (default "Redlight") * @param {string} appName - branding app name (default "Redlight")
*/ */
// S3: sanitize name for use in email From header (strip quotes, newlines, control chars) // S3: sanitize name for use in email From header (strip quotes, newlines, control chars)
function sanitizeHeaderValue(str) { function sanitizeHeaderValue(str) {
return String(str).replace(/["\\\r\n\x00-\x1f]/g, '').trim().slice(0, 100); return String(str).replace(/["\\\r\n\x00-\x1f]/g, '').trim().slice(0, 100);
} }
export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight') { export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight', lang = 'en') {
if (!transporter) { if (!transporter) {
throw new Error('SMTP not configured'); throw new Error('SMTP not configured');
} }
@@ -63,46 +64,45 @@ 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}>`,
to, to,
subject: `${headerAppName} Verify your email`, subject: t(lang, 'email.verify.subject', { appName: headerAppName }),
html: ` html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;"> <div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2> <h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
<p>Please verify your email address by clicking the button below:</p> <p>${t(lang, 'email.verify.intro')}</p>
<p style="text-align:center;margin:28px 0;"> <p style="text-align:center;margin:28px 0;">
<a href="${verifyUrl}" <a href="${verifyUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;"> style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
Verify Email ${t(lang, 'email.verify.button')}
</a> </a>
</p> </p>
<p style="font-size:13px;color:#7f849c;"> <p style="font-size:13px;color:#7f849c;">
Or copy this link in your browser:<br/> ${t(lang, 'email.linkHint')}<br/>
<a href="${verifyUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(verifyUrl)}</a> <a href="${verifyUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(verifyUrl)}</a>
</p> </p>
<p style="font-size:13px;color:#7f849c;">This link is valid for 24 hours.</p> <p style="font-size:13px;color:#7f849c;">${t(lang, 'email.verify.validity')}</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/> <hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">If you didn't register, please ignore this email.</p> <p style="font-size:12px;color:#585b70;">${t(lang, 'email.verify.footer')}</p>
</div> </div>
`, `,
text: `Hey ${name},\n\nPlease verify your email: ${verifyUrl}\n\nThis link is valid for 24 hours.\n\n ${appName}`, text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.verify.intro')}\n${verifyUrl}\n\n${t(lang, 'email.verify.validity')}\n\n- ${appName}`,
}); });
} }
/** /**
* Send a federation meeting invitation email. * Send a federation meeting invitation email.
* @param {string} to recipient email * @param {string} to - recipient email
* @param {string} name recipient display name * @param {string} name - recipient display name
* @param {string} fromUser sender federated address (user@domain) * @param {string} fromUser - sender federated address (user@domain)
* @param {string} roomName name of the invited room * @param {string} roomName - name of the invited room
* @param {string} message optional personal message * @param {string} message - optional personal message
* @param {string} inboxUrl URL to the federation inbox * @param {string} inboxUrl - URL to the federation inbox
* @param {string} appName branding app name (default "Redlight") * @param {string} appName - branding app name (default "Redlight")
*/ */
export async function sendFederationInviteEmail(to, name, fromUser, roomName, message, inboxUrl, appName = 'Redlight') { export async function sendFederationInviteEmail(to, name, fromUser, roomName, message, inboxUrl, appName = 'Redlight', lang = 'en') {
if (!transporter) return; // silently skip if SMTP not configured if (!transporter) return; // silently skip if SMTP not configured
const from = process.env.SMTP_FROM || process.env.SMTP_USER; const from = process.env.SMTP_FROM || process.env.SMTP_USER;
@@ -111,31 +111,217 @@ 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')
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
await transporter.sendMail({ await transporter.sendMail({
from: `"${headerAppName}" <${from}>`, from: `"${headerAppName}" <${from}>`,
to, to,
subject: `${headerAppName} Meeting invitation from ${sanitizeHeaderValue(fromUser)}`, subject: t(lang, 'email.federationInvite.subject', { appName: headerAppName, fromUser: sanitizeHeaderValue(fromUser) }),
html: ` html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;"> <div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2> <h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
<p>You have received a meeting invitation from <strong style="color:#cdd6f4;">${safeFromUser}</strong>.</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 8px 0;font-size:13px;color:#7f849c;">Room:</p> <p style="margin:0 0 8px 0;font-size:13px;color:#7f849c;">${t(lang, 'email.federationInvite.roomLabel')}</p>
<p style="margin:0;font-size:16px;font-weight:bold;color:#cdd6f4;">${safeRoomName}</p> <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>` : ''} ${safeMessage ? `<p style="margin:12px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeMessage}&quot;</p>` : ''}
</div> </div>
<p style="text-align:center;margin:28px 0;"> <p style="text-align:center;margin:28px 0;">
<a href="${inboxUrl}" <a href="${inboxUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;"> style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
View Invitation ${t(lang, 'email.viewInvitation')}
</a> </a>
</p> </p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/> <hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">Open the link above to accept or decline the invitation.</p> <p style="font-size:12px;color:#585b70;">${t(lang, 'email.invitationFooter')}</p>
</div> </div>
`, `,
text: `Hey ${name},\n\nYou have received a meeting invitation from ${fromUser}.\nRoom: ${roomName}${message ? `\nMessage: "${message}"` : ''}\n\nView invitation: ${inboxUrl}\n\n ${appName}`, text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.federationInvite.intro', { fromUser })}\n${t(lang, 'email.federationInvite.roomLabel')} ${roomName}${message ? `\n"${message}"` : ''}\n\n${t(lang, 'email.viewInvitation')}: ${inboxUrl}\n\n- ${appName}`,
});
}
/**
* Send a 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).
*/
export async function sendCalendarInviteEmail(to, name, fromUser, title, startTime, endTime, description, inboxUrl, appName = 'Redlight', lang = 'en') {
if (!transporter) return;
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeName = escapeHtml(name);
const safeFromUser = escapeHtml(fromUser);
const safeTitle = escapeHtml(title);
const safeDesc = description ? escapeHtml(description) : null;
const formatDate = (iso) => {
try { return new Date(iso).toLocaleString(lang === 'de' ? 'de-DE' : 'en-GB', { dateStyle: 'full', timeStyle: 'short' }); }
catch { return iso; }
};
const introHtml = t(lang, 'email.calendarInvite.intro')
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: t(lang, 'email.calendarInvite.subject', { appName: headerAppName, fromUser: sanitizeHeaderValue(fromUser) }),
html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
<p>${introHtml}</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 4px 0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
<p style="margin:6px 0 0 0;font-size:13px;color:#a6adc8;">${escapeHtml(formatDate(startTime))} - ${escapeHtml(formatDate(endTime))}</p>
${safeDesc ? `<p style="margin:10px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeDesc}&quot;</p>` : ''}
</div>
<p style="text-align:center;margin:28px 0;">
<a href="${inboxUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
${t(lang, 'email.viewInvitation')}
</a>
</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.invitationFooter')}</p>
</div>
`,
text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.calendarInvite.intro', { fromUser })}\n${safeTitle}\n${formatDate(startTime)} \u2013 ${formatDate(endTime)}${description ? `\n\n"${description}"` : ''}\n\n${t(lang, 'email.viewInvitation')}: ${inboxUrl}\n\n\u2013 ${appName}`,
});
}
/**
* Notify a user that a federated calendar event they received was deleted by the organiser.
*/
export async function sendCalendarEventDeletedEmail(to, name, fromUser, title, appName = 'Redlight', lang = 'en') {
if (!transporter) return;
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeName = escapeHtml(name);
const safeFromUser = escapeHtml(fromUser);
const safeTitle = escapeHtml(title);
const introHtml = t(lang, 'email.calendarDeleted.intro')
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: t(lang, 'email.calendarDeleted.subject', { appName: headerAppName, title: sanitizeHeaderValue(title) }),
html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#f38ba8;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
<p>${introHtml}</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;border-left:4px solid #f38ba8;">
<p style="margin:0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
</div>
<p style="font-size:13px;color:#7f849c;">${t(lang, 'email.calendarDeleted.note')}</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.calendarDeleted.footer', { appName: escapeHtml(appName) })}</p>
</div>
`,
text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.calendarDeleted.intro', { fromUser })}\n"${title}"\n\n${t(lang, 'email.calendarDeleted.note')}\n\n\u2013 ${appName}`,
});
}
/**
* Send a user registration invite email.
* @param {string} to - recipient email
* @param {string} inviteUrl - full invite registration URL
* @param {string} appName - branding app name (default "Redlight")
*/
export async function sendInviteEmail(to, inviteUrl, appName = 'Redlight', lang = 'en') {
if (!transporter) {
throw new Error('SMTP not configured');
}
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeAppName = escapeHtml(appName);
const introHtml = t(lang, 'email.invite.intro')
.replace('{appName}', `<strong style="color:#cdd6f4;">${safeAppName}</strong>`);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: t(lang, 'email.invite.subject', { appName: headerAppName }),
html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.invite.title')}</h2>
<p>${introHtml}</p>
<p>${t(lang, 'email.invite.prompt')}</p>
<p style="text-align:center;margin:28px 0;">
<a href="${inviteUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
${t(lang, 'email.invite.button')}
</a>
</p>
<p style="font-size:13px;color:#7f849c;">
${t(lang, 'email.linkHint')}<br/>
<a href="${inviteUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(inviteUrl)}</a>
</p>
<p style="font-size:13px;color:#7f849c;">${t(lang, 'email.invite.validity')}</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.invite.footer')}</p>
</div>
`,
text: `${t(lang, 'email.invite.title')}\n\n${t(lang, 'email.invite.intro', { appName })}\n\n${t(lang, 'email.invite.prompt')}\n${inviteUrl}\n\n${t(lang, 'email.invite.validity')}\n\n- ${appName}`,
}); });
} }
+23
View File
@@ -0,0 +1,23 @@
import { getDb } from './database.js';
/**
* Create an in-app notification for a user.
* Non-fatal — exceptions are swallowed so that the main operation is never blocked.
*
* @param {number} userId - Recipient user ID
* @param {string} type - Notification type (room_share_added | room_share_removed | federation_invite_received)
* @param {string} title - Short title (e.g. room name or "from" address)
* @param {string|null} body - Optional longer message
* @param {string|null} link - Optional frontend path to navigate to when clicked
*/
export async function createNotification(userId, type, title, body = null, link = null) {
try {
const db = getDb();
await db.run(
'INSERT INTO notifications (user_id, type, title, body, link) VALUES (?, ?, ?, ?, ?)',
[userId, type, title, body, link],
);
} catch {
// Notifications are non-critical — never break main functionality
}
}
+275
View File
@@ -0,0 +1,275 @@
/**
* OAuth / OpenID Connect configuration for Redlight.
*
* Supports generic OIDC providers (Keycloak, Authentik, Google, GitHub, etc.)
* configured at runtime via admin settings stored in the database.
*
* Security:
* - PKCE (S256) on every authorization request
* - Anti-CSRF via cryptographic `state` parameter stored server-side
* - State entries expire after 10 minutes and are single-use
* - Client secrets are stored AES-256-GCM encrypted in the DB
* - Only https callback URLs in production
* - Token exchange uses server-side secret, never exposed to the browser
*/
import crypto from 'crypto';
import { getDb } from './database.js';
import { log } from './logger.js';
// ── Encryption helpers for client secrets ──────────────────────────────────
// Derive a key from JWT_SECRET (always available)
const ENCRYPTION_KEY = crypto
.createHash('sha256')
.update(process.env.JWT_SECRET || '')
.digest(); // 32 bytes → AES-256
/**
* Encrypt a plaintext string with AES-256-GCM.
* Returns "iv:authTag:ciphertext" (all hex-encoded).
*/
export function encryptSecret(plaintext) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
}
/**
* Decrypt an AES-256-GCM encrypted string.
*/
export function decryptSecret(encryptedStr) {
const [ivHex, authTagHex, ciphertext] = encryptedStr.split(':');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// ── PKCE helpers ───────────────────────────────────────────────────────────
/** Generate a cryptographically random code_verifier (RFC 7636). */
export function generateCodeVerifier() {
return crypto.randomBytes(32).toString('base64url');
}
/** Compute the S256 code_challenge from a code_verifier. */
export function computeCodeChallenge(verifier) {
return crypto.createHash('sha256').update(verifier).digest('base64url');
}
// ── State management (anti-CSRF) ───────────────────────────────────────────
const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
/**
* Create and persist an OAuth state token with associated PKCE verifier.
* @param {string} provider provider key (e.g. 'oidc')
* @param {string} codeVerifier PKCE code_verifier
* @param {string|null} returnTo optional return URL after login
* @returns {Promise<string>} state token
*/
export async function createOAuthState(provider, codeVerifier, returnTo = null) {
const state = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + STATE_TTL_MS).toISOString();
const db = getDb();
await db.run(
'INSERT INTO oauth_states (state, provider, code_verifier, return_to, expires_at) VALUES (?, ?, ?, ?, ?)',
[state, provider, codeVerifier, returnTo, expiresAt],
);
return state;
}
/**
* Consume (validate + delete) an OAuth state token.
* Returns the stored data or null if invalid/expired.
* @param {string} state
* @returns {Promise<{ provider: string, code_verifier: string, return_to: string|null } | null>}
*/
export async function consumeOAuthState(state) {
if (!state || typeof state !== 'string' || state.length > 128) return null;
const db = getDb();
const row = await db.get(
'SELECT * FROM oauth_states WHERE state = ?',
[state],
);
if (!row) return null;
// Always delete (single-use)
await db.run('DELETE FROM oauth_states WHERE state = ?', [state]);
// Check expiry
if (new Date(row.expires_at) < new Date()) return null;
return {
provider: row.provider,
code_verifier: row.code_verifier,
return_to: row.return_to,
};
}
/**
* Garbage-collect expired OAuth states (called periodically).
*/
export async function cleanupExpiredStates() {
try {
const db = getDb();
await db.run('DELETE FROM oauth_states WHERE expires_at < CURRENT_TIMESTAMP');
} catch (err) {
log.auth.warn(`OAuth state cleanup failed: ${err.message}`);
}
}
// ── Provider configuration ─────────────────────────────────────────────────
/**
* Load the stored OAuth provider config from the settings table.
* Returns null if OAuth is not configured.
* @returns {Promise<{ issuer: string, clientId: string, clientSecret: string, displayName: string, autoRegister: boolean } | null>}
*/
export async function getOAuthConfig() {
try {
const db = getDb();
const row = await db.get("SELECT value FROM settings WHERE key = 'oauth_config'");
if (!row?.value) return null;
const config = JSON.parse(row.value);
if (!config.issuer || !config.clientId || !config.encryptedSecret) return null;
return {
issuer: config.issuer,
clientId: config.clientId,
clientSecret: decryptSecret(config.encryptedSecret),
displayName: config.displayName || 'SSO',
autoRegister: config.autoRegister !== false,
};
} catch (err) {
log.auth.error(`Failed to load OAuth config: ${err.message}`);
return null;
}
}
/**
* Save OAuth provider config to the settings table.
* The client secret is encrypted before storage.
*/
export async function saveOAuthConfig({ issuer, clientId, clientSecret, displayName, autoRegister }) {
const db = getDb();
const config = {
issuer,
clientId,
encryptedSecret: encryptSecret(clientSecret),
displayName: displayName || 'SSO',
autoRegister: autoRegister !== false,
};
const value = JSON.stringify(config);
const existing = await db.get("SELECT key FROM settings WHERE key = 'oauth_config'");
if (existing) {
await db.run("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = 'oauth_config'", [value]);
} else {
await db.run("INSERT INTO settings (key, value) VALUES ('oauth_config', ?) RETURNING key", [value]);
}
}
/**
* Remove OAuth configuration.
*/
export async function deleteOAuthConfig() {
const db = getDb();
await db.run("DELETE FROM settings WHERE key = 'oauth_config'");
}
// ── OIDC Discovery ─────────────────────────────────────────────────────────
// Cache discovered OIDC endpoints { authorization_endpoint, token_endpoint, userinfo_endpoint, ... }
const discoveryCache = new Map();
const DISCOVERY_TTL_MS = 30 * 60 * 1000; // 30 minutes
/**
* Fetch and cache the OpenID Connect discovery document for the given issuer.
* @param {string} issuer
* @returns {Promise<object>}
*/
export async function discoverOIDC(issuer) {
const cached = discoveryCache.get(issuer);
if (cached && Date.now() - cached.fetchedAt < DISCOVERY_TTL_MS) {
return cached.data;
}
// Normalize issuer URL
const base = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;
const url = `${base}/.well-known/openid-configuration`;
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!response.ok) {
throw new Error(`OIDC discovery failed for ${issuer}: HTTP ${response.status}`);
}
const data = await response.json();
if (!data.authorization_endpoint || !data.token_endpoint) {
throw new Error(`OIDC discovery response missing required endpoints`);
}
discoveryCache.set(issuer, { data, fetchedAt: Date.now() });
return data;
}
/**
* Exchange an authorization code for tokens.
* @param {object} oidcConfig discovery document
* @param {string} code
* @param {string} redirectUri
* @param {string} clientId
* @param {string} clientSecret
* @param {string} codeVerifier PKCE verifier
* @returns {Promise<object>} token response
*/
export async function exchangeCode(oidcConfig, code, redirectUri, clientId, clientSecret, codeVerifier) {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret,
code_verifier: codeVerifier,
});
const response = await fetch(oidcConfig.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
throw new Error(`Token exchange failed: HTTP ${response.status} ${errText.slice(0, 200)}`);
}
return response.json();
}
/**
* Fetch user info from the provider's userinfo endpoint.
* @param {string} userInfoUrl
* @param {string} accessToken
* @returns {Promise<object>}
*/
export async function fetchUserInfo(userInfoUrl, accessToken) {
const response = await fetch(userInfoUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`UserInfo fetch failed: HTTP ${response.status}`);
}
return response.json();
}
+50
View File
@@ -0,0 +1,50 @@
{
"email": {
"greeting": "Hallo {name},",
"viewInvitation": "Einladung anzeigen",
"invitationFooter": "Öffne den Link oben, um die Einladung anzunehmen oder abzulehnen.",
"linkHint": "Oder kopiere diesen Link in deinen Browser:",
"verify": {
"subject": "{appName} - E-Mail-Adresse bestätigen",
"intro": "Bitte bestätige deine E-Mail-Adresse, indem du auf den Button klickst:",
"button": "E-Mail bestätigen",
"validity": "Dieser Link ist 24 Stunden gültig.",
"footer": "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren."
},
"invite": {
"subject": "{appName} - Du wurdest eingeladen",
"title": "Du wurdest eingeladen",
"intro": "Du wurdest eingeladen, ein Konto auf {appName} zu erstellen.",
"prompt": "Klicke auf den Button, um dich zu registrieren:",
"button": "Konto erstellen",
"validity": "Dieser Link ist 7 Tage gültig.",
"footer": "Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren."
},
"federationInvite": {
"subject": "{appName} - Meeting-Einladung von {fromUser}",
"intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.",
"roomLabel": "Raum:"
},
"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": {
"subject": "{appName} - Kalendereinladung von {fromUser}",
"intro": "Du hast eine Kalendereinladung von {fromUser} erhalten."
},
"calendarDeleted": {
"subject": "{appName} - Kalendereintrag abgesagt: {title}",
"intro": "Der folgende Kalendereintrag wurde vom Organisator ({fromUser}) gelöscht und ist nicht mehr verfügbar:",
"note": "Der Termin wurde automatisch aus deinem Kalender entfernt.",
"footer": "Diese Nachricht wurde automatisch von {appName} versendet."
}
},
"bbb": {
"defaultWelcome": "Willkommen im Meeting!",
"inviteHint": "Lade weitere Teilnehmer ein, indem du diesen Link teilst:"
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"email": {
"greeting": "Hello {name},",
"viewInvitation": "View Invitation",
"invitationFooter": "Open the link above to accept or decline the invitation.",
"linkHint": "Or copy this link in your browser:",
"verify": {
"subject": "{appName} - Verify your email",
"intro": "Please verify your email address by clicking the button below:",
"button": "Verify Email",
"validity": "This link is valid for 24 hours.",
"footer": "If you didn't register, please ignore this email."
},
"invite": {
"subject": "{appName} - You've been invited",
"title": "You have been invited",
"intro": "You have been invited to create an account on {appName}.",
"prompt": "Click the button below to register:",
"button": "Create Account",
"validity": "This link is valid for 7 days.",
"footer": "If you didn't expect this invitation, you can safely ignore this email."
},
"federationInvite": {
"subject": "{appName} - Meeting invitation from {fromUser}",
"intro": "You have received a meeting invitation from {fromUser}.",
"roomLabel": "Room:"
},
"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": {
"subject": "{appName} - Calendar invitation from {fromUser}",
"intro": "You have received a calendar invitation from {fromUser}."
},
"calendarDeleted": {
"subject": "{appName} - Calendar event cancelled: {title}",
"intro": "The following calendar event was deleted by the organiser ({fromUser}) and is no longer available:",
"note": "The event has been automatically removed from your calendar.",
"footer": "This message was sent automatically by {appName}."
}
},
"bbb": {
"defaultWelcome": "Welcome to the meeting!",
"inviteHint": "To invite other participants, share this link:"
}
}
+59 -8
View File
@@ -1,4 +1,4 @@
import 'dotenv/config'; import 'dotenv/config';
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import path from 'path'; import path from 'path';
@@ -13,7 +13,13 @@ import recordingRoutes from './routes/recordings.js';
import adminRoutes from './routes/admin.js'; import adminRoutes from './routes/admin.js';
import brandingRoutes from './routes/branding.js'; import brandingRoutes from './routes/branding.js';
import federationRoutes, { wellKnownHandler } from './routes/federation.js'; import federationRoutes, { wellKnownHandler } from './routes/federation.js';
import calendarRoutes from './routes/calendar.js';
import caldavRoutes from './routes/caldav.js';
import notificationRoutes from './routes/notifications.js';
import oauthRoutes from './routes/oauth.js';
import analyticsRoutes from './routes/analytics.js';
import { startFederationSync } from './jobs/federationSync.js'; import { startFederationSync } from './jobs/federationSync.js';
import { startCalendarReminders } from './jobs/calendarReminders.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -21,19 +27,38 @@ const __dirname = path.dirname(__filename);
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
// Trust proxy configurable via TRUST_PROXY env var (default: 1 = one local reverse proxy) // Trust proxy - configurable via TRUST_PROXY env var (default: 1 = one local reverse proxy)
// Use a number to trust that many hops, or a string like 'loopback' / an IP/CIDR. // Use a number to trust that many hops, or a string like 'loopback' / an IP/CIDR.
const rawTrustProxy = process.env.TRUST_PROXY ?? 'loopback'; const rawTrustProxy = process.env.TRUST_PROXY ?? 'loopback';
const trustProxy = /^\d+$/.test(rawTrustProxy) ? parseInt(rawTrustProxy, 10) : rawTrustProxy; const trustProxy = /^\d+$/.test(rawTrustProxy) ? parseInt(rawTrustProxy, 10) : rawTrustProxy;
app.set('trust proxy', trustProxy); app.set('trust proxy', trustProxy);
// ── Security headers ───────────────────────────────────────────────────────
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
if (process.env.NODE_ENV === 'production') {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
next();
});
// Middleware // Middleware
// M10: restrict CORS in production; allow all in development // M10: restrict CORS in production; deny cross-origin by default
const corsOptions = process.env.APP_URL const corsOptions = process.env.APP_URL
? { origin: process.env.APP_URL, credentials: true } ? { origin: process.env.APP_URL, credentials: true }
: {}; : { origin: false };
app.use(cors(corsOptions)); app.use(cors(corsOptions));
app.use(express.json()); // Global JSON body limit kept tight as a hardening measure. The BBB learning
// analytics callback can send much larger payloads, so it is excluded here and
// gets its own, more generous limit on the route itself (see routes/analytics.js).
const jsonParser = express.json({ limit: '100kb' });
app.use((req, res, next) => {
if (req.path.startsWith('/api/analytics/callback/')) return next();
return jsonParser(req, res, next);
});
// Request/Response logging (filters sensitive fields) // Request/Response logging (filters sensitive fields)
app.use(requestResponseLogger); app.use(requestResponseLogger);
@@ -42,9 +67,10 @@ async function start() {
await initDatabase(); await initDatabase();
initMailer(); initMailer();
// Serve uploaded files (avatars, presentations) // Serve uploaded files (avatars are served via /api/auth/avatar/:filename)
const uploadsPath = path.join(__dirname, '..', 'uploads'); const uploadsPath = path.join(__dirname, '..', 'uploads');
app.use('/uploads', express.static(uploadsPath)); app.use('/uploads/branding', express.static(path.join(uploadsPath, 'branding')));
// Presentations are served via /api/rooms/presentations/:filename?token=… (HMAC-protected)
// API Routes // API Routes
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
@@ -53,12 +79,35 @@ async function start() {
app.use('/api/admin', adminRoutes); app.use('/api/admin', adminRoutes);
app.use('/api/branding', brandingRoutes); app.use('/api/branding', brandingRoutes);
app.use('/api/federation', federationRoutes); app.use('/api/federation', federationRoutes);
app.use('/api/calendar', calendarRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/oauth', oauthRoutes);
app.use('/api/analytics', analyticsRoutes);
// CalDAV — mounted outside /api so calendar clients use a clean path
app.use('/caldav', caldavRoutes);
// Mount calendar federation receive also under /api/federation for remote instances
app.use('/api/federation', calendarRoutes);
app.get('/.well-known/redlight', wellKnownHandler); app.get('/.well-known/redlight', wellKnownHandler);
// ── CalDAV service discovery (RFC 6764) ──────────────────────────────────
// Clients probe /.well-known/caldav then PROPFIND / before they know the
// real CalDAV mount point. Redirect them to /caldav/ for all HTTP methods.
app.all('/.well-known/caldav', (req, res) => {
res.redirect(301, '/caldav/');
});
// Some clients (e.g. Thunderbird) send PROPFIND / directly at the server root.
// Express doesn't register non-standard methods, so intercept via middleware.
app.use('/', (req, res, next) => {
if (req.method === 'PROPFIND' && req.path === '/') {
return res.redirect(301, '/caldav/');
}
next();
});
// Serve static files in production // Serve static files in production
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, '..', 'dist'))); app.use(express.static(path.join(__dirname, '..', 'dist')));
app.get('*', (req, res) => { app.get('/*splat', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'dist', 'index.html')); res.sendFile(path.join(__dirname, '..', 'dist', 'index.html'));
}); });
} }
@@ -69,6 +118,8 @@ async function start() {
// Start periodic federation sync job (checks remote room settings every 60s) // Start periodic federation sync job (checks remote room settings every 60s)
startFederationSync(); startFederationSync();
// Start calendar reminder job (sends in-app + browser notifications before events)
startCalendarReminders();
} }
start().catch(err => { start().catch(err => {
+90
View File
@@ -0,0 +1,90 @@
import { getDb } from '../config/database.js';
import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js';
const CHECK_INTERVAL_MS = 60_000; // every minute
let timer = null;
/**
* Check for upcoming calendar events that need a reminder notification fired.
* Runs every minute. Updates `reminder_sent_at` after firing so reminders
* are never sent twice. Also resets `reminder_sent_at` to NULL when
* start_time or reminder_minutes is changed (handled in calendar route).
*/
async function runCheck() {
try {
const db = getDb();
// Fetch all events that have a reminder configured and haven't been sent yet
const pending = await db.all(`
SELECT ce.id, ce.uid, ce.title, ce.start_time, ce.reminder_minutes, ce.user_id,
ce.room_uid, ce.color
FROM calendar_events ce
WHERE ce.reminder_minutes IS NOT NULL
AND ce.reminder_sent_at IS NULL
`);
if (pending.length === 0) return;
const now = new Date();
const toFire = pending.filter(ev => {
const start = new Date(ev.start_time);
// Don't fire reminders for events that started more than 10 minutes ago (server downtime tolerance)
if (start < new Date(now.getTime() - 10 * 60_000)) return false;
const reminderTime = new Date(start.getTime() - ev.reminder_minutes * 60_000);
return reminderTime <= now;
});
for (const ev of toFire) {
try {
// Mark as sent immediately to prevent double-fire even if notification creation fails
await db.run(
'UPDATE calendar_events SET reminder_sent_at = ? WHERE id = ?',
[now.toISOString(), ev.id],
);
const start = new Date(ev.start_time);
const timeStr = start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const dateStr = start.toLocaleDateString([], { day: 'numeric', month: 'short' });
const body = `${dateStr} · ${timeStr}`;
const link = '/calendar';
// Notify the owner
await createNotification(ev.user_id, 'calendar_reminder', ev.title, body, link);
// Notify all accepted share users as well
const shares = await db.all(
'SELECT user_id FROM calendar_event_shares WHERE event_id = ?',
[ev.id],
);
for (const { user_id } of shares) {
await createNotification(user_id, 'calendar_reminder', ev.title, body, link);
}
log.server.info(`Calendar reminder fired for event ${ev.uid} (id=${ev.id})`);
} catch (evErr) {
log.server.error(`Calendar reminder failed for event ${ev.id}: ${evErr.message}`);
}
}
} catch (err) {
log.server.error(`Calendar reminder job error: ${err.message}`);
}
}
export function startCalendarReminders() {
if (timer) return;
// Slight delay on startup so DB is fully ready
setTimeout(() => {
runCheck();
timer = setInterval(runCheck, CHECK_INTERVAL_MS);
}, 5_000);
log.server.info('Calendar reminder job started');
}
export function stopCalendarReminders() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
+12 -1
View File
@@ -35,7 +35,7 @@ export async function authenticateToken(req, res, next) {
} }
const db = getDb(); const db = getDb();
const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [decoded.userId]); const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified, oauth_provider, totp_enabled FROM users WHERE id = ?', [decoded.userId]);
if (!user) { if (!user) {
return res.status(401).json({ error: 'User not found' }); return res.status(401).json({ error: 'User not found' });
} }
@@ -57,3 +57,14 @@ export function generateToken(userId) {
const jti = uuidv4(); const jti = uuidv4();
return jwt.sign({ userId, jti }, JWT_SECRET, { expiresIn: '7d' }); return jwt.sign({ userId, jti }, JWT_SECRET, { expiresIn: '7d' });
} }
/**
* Build the public base URL for the application.
* Prefers APP_URL env var. Falls back to X-Forwarded-Proto + Host header
* so that links are correct behind a TLS-terminating reverse proxy.
*/
export function getBaseUrl(req) {
if (process.env.APP_URL) return process.env.APP_URL.replace(/\/+$/, '');
const proto = req.get('x-forwarded-proto')?.split(',')[0]?.trim() || req.protocol;
return `${proto}://${req.get('host')}`;
}
+233 -5
View File
@@ -1,8 +1,17 @@
import { Router } from 'express'; import { Router } from 'express';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js'; import { getDb } from '../config/database.js';
import { authenticateToken, requireAdmin } from '../middleware/auth.js'; import { authenticateToken, requireAdmin, getBaseUrl } from '../middleware/auth.js';
import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js';
import { getAppName } from '../config/appName.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
import {
getOAuthConfig,
saveOAuthConfig,
deleteOAuthConfig,
discoverOIDC,
} from '../config/oauth.js';
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/; const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
@@ -24,12 +33,15 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/; const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(name)) { if (!usernameRegex.test(name)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' }); return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
} }
if (password.length < 8) { 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)) {
@@ -49,7 +61,7 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
return res.status(409).json({ error: 'Username is already taken' }); return res.status(409).json({ error: 'Username is already taken' });
} }
const hash = bcrypt.hashSync(password, 12); const hash = await bcrypt.hash(password, 12);
const result = await db.run( const result = await db.run(
'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)', 'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)',
[name, display_name, email.toLowerCase(), hash, validRole] [name, display_name, email.toLowerCase(), hash, validRole]
@@ -152,9 +164,12 @@ router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, r
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) { if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters long' }); return res.status(400).json({ error: 'Password must be at least 8 characters long' });
} }
if (newPassword.length > 64) {
return res.status(400).json({ error: 'Password must not exceed 64 characters' });
}
const db = getDb(); const db = getDb();
const hash = bcrypt.hashSync(newPassword, 12); const hash = await bcrypt.hash(newPassword, 12);
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.params.id]); await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.params.id]);
res.json({ message: 'Password reset' }); res.json({ message: 'Password reset' });
@@ -164,4 +179,217 @@ router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, r
} }
}); });
// ── User Invite System ─────────────────────────────────────────────────────
// POST /api/admin/invites - Create and send an invite
router.post('/invites', authenticateToken, requireAdmin, async (req, res) => {
try {
const { email } = req.body;
if (!email || !EMAIL_RE.test(email)) {
return res.status(400).json({ error: 'A valid email address is required' });
}
const db = getDb();
// Check if user with this email already exists
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existing) {
return res.status(409).json({ error: 'A user with this email already exists' });
}
// Check if there's already a pending invite for this email
const existingInvite = await db.get(
'SELECT id FROM user_invites WHERE email = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP',
[email.toLowerCase()]
);
if (existingInvite) {
return res.status(409).json({ error: 'There is already a pending invite for this email' });
}
const token = uuidv4();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days
await db.run(
'INSERT INTO user_invites (token, email, created_by, expires_at) VALUES (?, ?, ?, ?)',
[token, email.toLowerCase(), req.user.id, expiresAt]
);
// Send invite email if SMTP is configured
const baseUrl = getBaseUrl(req);
const inviteUrl = `${baseUrl}/register?invite=${token}`;
// Load configured app name (admin setting → env → default)
const appName = await getAppName();
if (isMailerConfigured()) {
try {
await sendInviteEmail(email.toLowerCase(), inviteUrl, appName, 'en');
} catch (mailErr) {
log.admin.warn(`Invite email failed (non-fatal): ${mailErr.message}`);
}
}
res.status(201).json({ invite: { token, email: email.toLowerCase(), expiresAt, inviteUrl } });
} catch (err) {
log.admin.error(`Create invite error: ${err.message}`);
res.status(500).json({ error: 'Invite could not be created' });
}
});
// GET /api/admin/invites - List all invites
router.get('/invites', authenticateToken, requireAdmin, async (req, res) => {
try {
const db = getDb();
const invites = await db.all(`
SELECT ui.id, ui.token, ui.email, ui.expires_at, ui.created_at, ui.used_at,
creator.name as created_by_name,
used_user.name as used_by_name
FROM user_invites ui
LEFT JOIN users creator ON creator.id = ui.created_by
LEFT JOIN users used_user ON used_user.id = ui.used_by
ORDER BY ui.created_at DESC
`);
res.json({ invites });
} catch (err) {
log.admin.error(`List invites error: ${err.message}`);
res.status(500).json({ error: 'Invites could not be loaded' });
}
});
// DELETE /api/admin/invites/:id - Delete an invite
router.delete('/invites/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
const db = getDb();
const invite = await db.get('SELECT id FROM user_invites WHERE id = ?', [req.params.id]);
if (!invite) {
return res.status(404).json({ error: 'Invite not found' });
}
await db.run('DELETE FROM user_invites WHERE id = ?', [req.params.id]);
res.json({ message: 'Invite deleted' });
} catch (err) {
log.admin.error(`Delete invite error: ${err.message}`);
res.status(500).json({ error: 'Invite could not be deleted' });
}
});
// ── OAuth / SSO Configuration (admin only) ──────────────────────────────────
// GET /api/admin/oauth - Get current OAuth configuration
router.get('/oauth', authenticateToken, requireAdmin, async (req, res) => {
try {
const config = await getOAuthConfig();
if (!config) {
return res.json({ configured: false, config: null });
}
// Never expose the decrypted client secret to the frontend
res.json({
configured: true,
config: {
issuer: config.issuer,
clientId: config.clientId,
hasClientSecret: !!config.clientSecret,
displayName: config.displayName || 'SSO',
autoRegister: config.autoRegister ?? true,
},
});
} catch (err) {
log.admin.error(`Get OAuth config error: ${err.message}`);
res.status(500).json({ error: 'Could not load OAuth configuration' });
}
});
// PUT /api/admin/oauth - Save OAuth configuration
router.put('/oauth', authenticateToken, requireAdmin, async (req, res) => {
try {
const { issuer, clientId, clientSecret, displayName, autoRegister } = req.body;
if (!issuer || !clientId) {
return res.status(400).json({ error: 'Issuer URL and Client ID are required' });
}
// Validate issuer URL
try {
const parsed = new URL(issuer);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return res.status(400).json({ error: 'Issuer URL must use https:// (or http:// for development)' });
}
} catch {
return res.status(400).json({ error: 'Invalid Issuer URL' });
}
// Validate display name length
if (displayName && displayName.length > 50) {
return res.status(400).json({ error: 'Display name must not exceed 50 characters' });
}
// Check if the existing config has a secret and none is being sent (keep old one)
let finalSecret = clientSecret;
if (!clientSecret) {
const existing = await getOAuthConfig();
if (existing?.clientSecret) {
finalSecret = existing.clientSecret;
}
}
// Attempt OIDC discovery to validate the issuer endpoint
try {
await discoverOIDC(issuer);
} catch (discErr) {
return res.status(400).json({
error: `Could not discover OIDC configuration at ${issuer}: ${discErr.message}`,
});
}
await saveOAuthConfig({
issuer,
clientId,
clientSecret: finalSecret || '',
displayName: displayName || 'SSO',
autoRegister: autoRegister !== false,
});
log.admin.info(`OAuth configuration saved by admin (issuer: ${issuer})`);
res.json({ message: 'OAuth configuration saved' });
} catch (err) {
log.admin.error(`Save OAuth config error: ${err.message}`);
res.status(500).json({ error: 'Could not save OAuth configuration' });
}
});
// DELETE /api/admin/oauth - Remove OAuth configuration
router.delete('/oauth', authenticateToken, requireAdmin, async (req, res) => {
try {
await deleteOAuthConfig();
log.admin.info('OAuth configuration removed by admin');
res.json({ message: 'OAuth configuration removed' });
} catch (err) {
log.admin.error(`Delete OAuth config error: ${err.message}`);
res.status(500).json({ error: 'Could not remove OAuth configuration' });
}
});
// ── Room Management (admin only) ────────────────────────────────────────────
// GET /api/admin/rooms - List all rooms with owner info
router.get('/rooms', authenticateToken, requireAdmin, async (req, res) => {
try {
const db = getDb();
const rooms = await db.all(`
SELECT r.id, r.uid, r.name, r.user_id, r.max_participants, r.access_code,
r.mute_on_join, r.record_meeting, r.guest_access, r.presentation_file,
r.created_at, r.updated_at,
COALESCE(NULLIF(u.display_name,''), u.name) as owner_name,
u.email as owner_email,
(SELECT COUNT(*) FROM room_shares rs WHERE rs.room_id = r.id) as share_count
FROM rooms r
JOIN users u ON r.user_id = u.id
ORDER BY r.created_at DESC
`);
res.json({ rooms });
} catch (err) {
log.admin.error(`List rooms error: ${err.message}`);
res.status(500).json({ error: 'Rooms could not be loaded' });
}
});
export default router; export default router;
+330
View File
@@ -0,0 +1,330 @@
import { Router, json } from 'express';
import crypto from 'crypto';
import ExcelJS from 'exceljs';
import PDFDocument from 'pdfkit';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
import { log } from '../config/logger.js';
import { getAnalyticsToken } from '../config/bbb.js';
const router = Router();
// Token check runs BEFORE the 5mb body parser so unauthenticated callers
// cannot make the server buffer large payloads.
// Constant-time comparison to prevent timing attacks. Reject non-string tokens
// (e.g. ?token=a&token=b would yield an array and crash Buffer.from).
function verifyCallbackToken(req, res, next) {
const { token } = req.query;
const expectedToken = getAnalyticsToken(req.params.uid);
if (typeof token !== 'string' || token.length !== expectedToken.length ||
!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) {
return res.status(403).json({ error: 'Invalid token' });
}
next();
}
// POST /api/analytics/callback/:uid?token=... - BBB Learning Analytics callback (token-secured)
// Excluded from the global 100kb body limit (see index.js): learning analytics
// payloads for large meetings can be several MB.
router.post('/callback/:uid', verifyCallbackToken, json({ limit: '5mb' }), async (req, res) => {
try {
const db = getDb();
const room = await db.get('SELECT id, uid, learning_analytics FROM rooms WHERE uid = ?', [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
}
if (!room.learning_analytics) {
return res.status(403).json({ error: 'Learning analytics not enabled for this room' });
}
const data = req.body;
if (!data || typeof data !== 'object') {
return res.status(400).json({ error: 'Invalid analytics data' });
}
// Extract meeting info from BBB learning analytics payload
// Format: { meeting_id, internal_meeting_id, data: { metadata: { meeting_name }, duration, attendees, ... } }
const meetingId = data.internal_meeting_id || data.meeting_id || room.uid;
const meetingName = data.data?.metadata?.meeting_name || data.meeting_id || room.uid;
// Upsert: update if same meeting already exists (BBB sends updates during the meeting)
const existing = await db.get(
'SELECT id FROM learning_analytics_data WHERE room_id = ? AND meeting_id = ?',
[room.id, meetingId]
);
const jsonData = JSON.stringify(data);
if (existing) {
await db.run(
'UPDATE learning_analytics_data SET data = ?, meeting_name = ?, created_at = CURRENT_TIMESTAMP WHERE id = ?',
[jsonData, meetingName, existing.id]
);
} else {
await db.run(
'INSERT INTO learning_analytics_data (room_id, meeting_id, meeting_name, data) VALUES (?, ?, ?, ?)',
[room.id, meetingId, meetingName, jsonData]
);
}
log.server.info(`Analytics callback received for room ${room.uid} (meeting: ${meetingId})`);
res.json({ success: true });
} catch (err) {
log.server.error(`Analytics callback error: ${err.message}`);
res.status(500).json({ error: 'Error processing analytics data' });
}
});
// GET /api/analytics/room/:uid - Get analytics for a room (authenticated)
router.get('/room/:uid', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get('SELECT id, user_id, analytics_visibility FROM rooms WHERE uid = ?', [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
}
// Check access: owner, shared (if visibility allows), or admin
if (room.user_id !== req.user.id && req.user.role !== 'admin') {
if (room.analytics_visibility !== 'shared') {
return res.status(403).json({ error: 'No permission to view analytics for this room' });
}
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
if (!share) {
return res.status(403).json({ error: 'No permission to view analytics for this room' });
}
}
const rows = await db.all(
'SELECT id, meeting_id, meeting_name, data, created_at FROM learning_analytics_data WHERE room_id = ? ORDER BY created_at DESC',
[room.id]
);
const analytics = rows.map(row => ({
id: row.id,
meetingId: row.meeting_id,
meetingName: row.meeting_name,
data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data,
createdAt: row.created_at,
}));
res.json({ analytics });
} catch (err) {
log.server.error(`Get analytics error: ${err.message}`);
res.status(500).json({ error: 'Error fetching analytics' });
}
});
// DELETE /api/analytics/:id - Delete analytics entry (authenticated, owner only)
router.delete('/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const entry = await db.get(
`SELECT la.id, la.room_id, r.user_id
FROM learning_analytics_data la
JOIN rooms r ON la.room_id = r.id
WHERE la.id = ?`,
[req.params.id]
);
if (!entry) {
return res.status(404).json({ error: 'Analytics entry not found' });
}
if (entry.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'No permission to delete this entry' });
}
await db.run('DELETE FROM learning_analytics_data WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (err) {
log.server.error(`Delete analytics error: ${err.message}`);
res.status(500).json({ error: 'Error deleting analytics entry' });
}
});
// Helper: extract flat attendee rows from analytics entry
function extractRows(entry) {
const data = typeof entry.data === 'string' ? JSON.parse(entry.data) : entry.data;
const attendees = data?.data?.attendees || [];
const meetingName = data?.data?.metadata?.meeting_name || entry.meeting_name || '';
const meetingDuration = data?.data?.duration || 0;
const meetingStart = data?.data?.start || '';
const meetingFinish = data?.data?.finish || '';
return attendees.map(a => ({
meetingName,
meetingStart,
meetingFinish,
meetingDuration,
name: a.name || '',
role: a.moderator ? 'Moderator' : 'Viewer',
duration: a.duration || 0,
talkTime: a.engagement?.talk_time || 0,
chats: a.engagement?.chats || 0,
talks: a.engagement?.talks || 0,
raiseHand: a.engagement?.raisehand || 0,
emojis: a.engagement?.emojis || 0,
pollVotes: a.engagement?.poll_votes || 0,
}));
}
const COLUMNS = [
{ header: 'Meeting', key: 'meetingName', width: 25 },
{ header: 'Start', key: 'meetingStart', width: 20 },
{ header: 'End', key: 'meetingFinish', width: 20 },
{ header: 'Meeting Duration (s)', key: 'meetingDuration', width: 18 },
{ header: 'Name', key: 'name', width: 25 },
{ header: 'Role', key: 'role', width: 12 },
{ header: 'Duration (s)', key: 'duration', width: 14 },
{ header: 'Talk Time (s)', key: 'talkTime', width: 14 },
{ header: 'Chats', key: 'chats', width: 8 },
{ header: 'Talks', key: 'talks', width: 8 },
{ header: 'Raise Hand', key: 'raiseHand', width: 12 },
{ header: 'Emojis', key: 'emojis', width: 8 },
{ header: 'Poll Votes', key: 'pollVotes', width: 10 },
];
// GET /api/analytics/:id/export/:format - Export a single analytics entry (csv, xlsx, pdf)
router.get('/:id/export/:format', authenticateToken, async (req, res) => {
try {
const format = req.params.format;
if (!['csv', 'xlsx', 'pdf'].includes(format)) {
return res.status(400).json({ error: 'Unsupported format. Use csv, xlsx, or pdf.' });
}
const db = getDb();
const entry = await db.get(
`SELECT la.*, r.user_id, r.analytics_visibility
FROM learning_analytics_data la
JOIN rooms r ON la.room_id = r.id
WHERE la.id = ?`,
[req.params.id]
);
if (!entry) {
return res.status(404).json({ error: 'Analytics entry not found' });
}
if (entry.user_id !== req.user.id && req.user.role !== 'admin') {
if (entry.analytics_visibility !== 'shared') {
return res.status(403).json({ error: 'No permission to export this entry' });
}
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [entry.room_id, req.user.id]);
if (!share) {
return res.status(403).json({ error: 'No permission to export this entry' });
}
}
const rows = extractRows(entry);
const safeName = (entry.meeting_name || 'analytics').replace(/[^a-zA-Z0-9_-]/g, '_');
if (format === 'csv') {
// Prefix-escape values that start with a formula trigger character so that
// Excel/LibreOffice do not evaluate them as formulas (CSV injection).
const escapeCsv = (val) => {
if (val === null || val === undefined) return '';
let s = String(val);
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
if (s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r')) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
};
const header = COLUMNS.map(c => c.header).join(',');
const csvRows = rows.map(r =>
COLUMNS.map(c => escapeCsv(r[c.key])).join(',')
);
const csv = [header, ...csvRows].join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.csv"`);
return res.send('\uFEFF' + csv); // BOM for Excel UTF-8
}
if (format === 'xlsx') {
// Prefix-escape strings that would otherwise be evaluated as formulas.
const sanitizeXlsx = (r) => {
const out = {};
for (const k of Object.keys(r)) {
const v = r[k];
out[k] = (typeof v === 'string' && /^[=+\-@\t\r]/.test(v)) ? "'" + v : v;
}
return out;
};
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Analytics');
sheet.columns = COLUMNS;
rows.forEach(r => sheet.addRow(sanitizeXlsx(r)));
// Style header row
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.xlsx"`);
await workbook.xlsx.write(res);
return res.end();
}
if (format === 'pdf') {
const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 30 });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.pdf"`);
doc.pipe(res);
// Title
doc.fontSize(16).text(entry.meeting_name || 'Learning Analytics', { align: 'center' });
doc.moveDown(0.5);
// Table header columns for PDF (subset for readability)
const pdfCols = [
{ header: 'Name', key: 'name', width: 120 },
{ header: 'Role', key: 'role', width: 65 },
{ header: 'Duration (s)', key: 'duration', width: 75 },
{ header: 'Talk Time (s)', key: 'talkTime', width: 75 },
{ header: 'Chats', key: 'chats', width: 50 },
{ header: 'Talks', key: 'talks', width: 50 },
{ header: 'Raise Hand', key: 'raiseHand', width: 65 },
{ header: 'Emojis', key: 'emojis', width: 50 },
{ header: 'Poll Votes', key: 'pollVotes', width: 60 },
];
const startX = doc.x;
let y = doc.y;
// Header
doc.fontSize(8).font('Helvetica-Bold');
pdfCols.forEach((col, i) => {
const x = startX + pdfCols.slice(0, i).reduce((s, c) => s + c.width, 0);
doc.text(col.header, x, y, { width: col.width, align: 'left' });
});
y += 14;
doc.moveTo(startX, y).lineTo(startX + pdfCols.reduce((s, c) => s + c.width, 0), y).stroke();
y += 4;
// Rows
doc.font('Helvetica').fontSize(8);
rows.forEach(r => {
if (y > doc.page.height - 50) {
doc.addPage();
y = 30;
}
pdfCols.forEach((col, i) => {
const x = startX + pdfCols.slice(0, i).reduce((s, c) => s + c.width, 0);
doc.text(String(r[col.key]), x, y, { width: col.width, align: 'left' });
});
y += 14;
});
doc.end();
return;
}
} catch (err) {
log.server.error(`Export analytics error: ${err.message}`);
res.status(500).json({ error: 'Error exporting analytics data' });
}
});
export default router;
+361 -45
View File
@@ -5,12 +5,15 @@ import { v4 as uuidv4 } from 'uuid';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { rateLimit } from 'express-rate-limit'; import { rateLimit, MemoryStore } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis'; import { RedisStore } from 'rate-limit-redis';
import * as OTPAuth from 'otpauth';
import { getDb } from '../config/database.js'; import { getDb } from '../config/database.js';
import redis from '../config/redis.js'; import redis from '../config/redis.js';
import { authenticateToken, generateToken } from '../middleware/auth.js'; import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js';
import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js'; import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
import { getOAuthConfig, discoverOIDC } from '../config/oauth.js';
import { getAppName } from '../config/appName.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
@@ -20,15 +23,65 @@ if (!process.env.JWT_SECRET) {
const JWT_SECRET = process.env.JWT_SECRET; const JWT_SECRET = process.env.JWT_SECRET;
// ── Rate Limiting ──────────────────────────────────────────────────────────── // ── Rate Limiting ────────────────────────────────────────────────────────────
function makeRedisStore(prefix) { // Shared Redis store with in-memory fallback. Without the fallback, a dead
try { // Redis connection makes every rate-limited endpoint (incl. login) return 500 —
return new RedisStore({ // rate limiting must degrade, not take auth down with it.
sendCommand: (...args) => redis.call(...args), class ResilientStore {
prefix, constructor(prefix) {
}); try {
} catch { this.redisStore = new RedisStore({
return undefined; // falls back to in-memory if Redis unavailable sendCommand: (...args) => redis.call(...args),
prefix,
});
} catch {
this.redisStore = null;
}
this.memoryStore = new MemoryStore();
} }
init(options) {
try {
// RedisStore.init returns the SCRIPT LOAD promise; catch it so a dead
// Redis connection surfaces as a warning instead of an unhandled
// rejection that kills the process.
const initResult = this.redisStore?.init?.(options);
if (typeof initResult?.catch === 'function') {
initResult.catch(err => log.auth.warn(`Rate-limit Redis init failed: ${err.message}`));
}
} catch (err) {
log.auth.warn(`Rate-limit Redis init failed: ${err.message}`);
}
this.memoryStore.init(options);
}
async increment(key) {
if (this.redisStore && redis.status === 'ready') {
try {
return await this.redisStore.increment(key);
} catch (err) {
log.auth.warn(`Rate-limit Redis store failed, using in-memory fallback: ${err.message}`);
}
}
return this.memoryStore.increment(key);
}
async decrement(key) {
if (this.redisStore && redis.status === 'ready') {
try {
return await this.redisStore.decrement(key);
} catch { /* fall through to memory */ }
}
return this.memoryStore.decrement(key);
}
async resetKey(key) {
if (this.redisStore && redis.status === 'ready') {
try {
return await this.redisStore.resetKey(key);
} catch { /* fall through to memory */ }
}
return this.memoryStore.resetKey(key);
}
}
function makeRedisStore(prefix) {
return new ResilientStore(prefix);
} }
// ── Validation helpers ───────────────────────────────────────────────────── // ── Validation helpers ─────────────────────────────────────────────────────
@@ -37,10 +90,17 @@ const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
// Simple format check for theme/language IDs (actual validation happens on the frontend) // Simple format check for theme/language IDs (actual validation happens on the frontend)
const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/; const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;
// Allowlist for CSS color values only permits hsl(), hex (#rgb/#rrggbb) and plain names // Allowlist for CSS color values - only permits hsl(), hex (#rgb/#rrggbb) and plain names
const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/; const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
const MIN_PASSWORD_LENGTH = 8; const MIN_PASSWORD_LENGTH = 8;
// bcrypt only uses the first 72 bytes; cap input to prevent CPU-DoS on hashing.
const MAX_PASSWORD_LENGTH = 64;
// Pre-computed bcrypt hash of a random string used as a dummy comparison
// target when the requested account does not exist. Keeps login timing
// roughly constant so we do not leak whether an email is registered.
const DUMMY_BCRYPT_HASH = bcrypt.hashSync('dummy-password-for-timing-' + Math.random(), 12);
// ── Rate Limiters ──────────────────────────────────────────────────────────── // ── Rate Limiters ────────────────────────────────────────────────────────────
const loginLimiter = rateLimit({ const loginLimiter = rateLimit({
@@ -98,6 +158,15 @@ const resendVerificationLimiter = rateLimit({
store: makeRedisStore('rl:resend:'), store: makeRedisStore('rl:resend:'),
}); });
const twoFaLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many 2FA attempts. Please try again later.' },
store: makeRedisStore('rl:2fa:'),
});
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');
@@ -112,7 +181,27 @@ const router = Router();
// POST /api/auth/register // POST /api/auth/register
router.post('/register', registerLimiter, async (req, res) => { router.post('/register', registerLimiter, async (req, res) => {
try { try {
const { username, display_name, email, password } = req.body; const { username, display_name, email, password, invite_token } = req.body;
// Check registration mode
const db = getDb();
const regModeSetting = await db.get("SELECT value FROM settings WHERE key = 'registration_mode'");
const registrationMode = regModeSetting?.value || 'open';
let validatedInvite = null;
if (registrationMode === 'invite') {
if (!invite_token) {
return res.status(403).json({ error: 'Registration is currently invite-only. You need an invitation link to register.' });
}
// Validate the invite token
validatedInvite = await db.get(
'SELECT * FROM user_invites WHERE token = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP',
[invite_token]
);
if (!validatedInvite) {
return res.status(403).json({ error: 'Invalid or expired invitation link.' });
}
}
if (!username || !display_name || !email || !password) { if (!username || !display_name || !email || !password) {
return res.status(400).json({ error: 'All fields are required' }); return res.status(400).json({ error: 'All fields are required' });
@@ -125,7 +214,7 @@ router.post('/register', registerLimiter, async (req, res) => {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/; const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(username)) { if (!usernameRegex.test(username)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' }); return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
} }
// M1: email format // M1: email format
@@ -137,9 +226,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 db = getDb(); // Emails are stored lowercased, so compare lowercased to catch case-variant duplicates
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]); 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' });
} }
@@ -149,7 +241,7 @@ router.post('/register', registerLimiter, async (req, res) => {
return res.status(409).json({ error: 'Username is already taken' }); return res.status(409).json({ error: 'Username is already taken' });
} }
const hash = bcrypt.hashSync(password, 12); const hash = await bcrypt.hash(password, 12);
// If SMTP is configured, require email verification // If SMTP is configured, require email verification
if (isMailerConfigured()) { if (isMailerConfigured()) {
@@ -161,19 +253,23 @@ router.post('/register', registerLimiter, async (req, res) => {
[username, display_name, email.toLowerCase(), hash, verificationToken, expires] [username, display_name, email.toLowerCase(), hash, verificationToken, expires]
); );
// Build verification URL // Mark invite as used if applicable
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; if (validatedInvite) {
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`; const newUser = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (newUser) {
// Load app name from branding settings await db.run('UPDATE user_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?', [newUser.id, validatedInvite.id]);
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'"); }
let appName = 'Redlight';
if (brandingSetting?.value) {
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
} }
// Build verification URL
const baseUrl = getBaseUrl(req);
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
// Load configured app name (admin setting → env → default)
const appName = await getAppName();
try { try {
await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName); await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName, 'en');
} catch (mailErr) { } catch (mailErr) {
log.auth.error(`Verification mail failed: ${mailErr.message}`); log.auth.error(`Verification mail failed: ${mailErr.message}`);
// Account is created but email failed — user can resend from login page // Account is created but email failed — user can resend from login page
@@ -183,12 +279,17 @@ router.post('/register', registerLimiter, async (req, res) => {
return res.status(201).json({ needsVerification: true, message: 'Verification email has been sent' }); return res.status(201).json({ needsVerification: true, message: 'Verification email has been sent' });
} }
// No SMTP configured register and login immediately (legacy behaviour) // No SMTP configured - register and login immediately (legacy behaviour)
const result = await db.run( const result = await db.run(
'INSERT INTO users (name, display_name, email, password_hash, email_verified) VALUES (?, ?, ?, ?, 1)', 'INSERT INTO users (name, display_name, email, password_hash, email_verified) VALUES (?, ?, ?, ?, 1)',
[username, display_name, email.toLowerCase(), hash] [username, display_name, email.toLowerCase(), hash]
); );
// Mark invite as used if applicable
if (validatedInvite) {
await db.run('UPDATE user_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?', [result.lastInsertRowid, validatedInvite.id]);
}
const token = generateToken(result.lastInsertRowid); const token = generateToken(result.lastInsertRowid);
const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [result.lastInsertRowid]); const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [result.lastInsertRowid]);
@@ -246,7 +347,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
} }
const db = getDb(); const db = getDb();
const user = await db.get('SELECT id, name, display_name, email_verified, verification_resend_at FROM users WHERE email = ?', [email.toLowerCase()]); const user = await db.get('SELECT id, name, display_name, language, email_verified, verification_resend_at FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user || user.email_verified) { if (!user || user.email_verified) {
// Don't reveal whether account exists // Don't reveal whether account exists
@@ -271,17 +372,13 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
[verificationToken, expires, now, user.id] [verificationToken, expires, now, user.id]
); );
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const baseUrl = getBaseUrl(req);
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`; const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'"); const appName = await getAppName();
let appName = 'Redlight';
if (brandingSetting?.value) {
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
}
try { try {
await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName); await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName, user.language || 'en');
} catch (mailErr) { } catch (mailErr) {
log.auth.error(`Resend verification mail failed: ${mailErr.message}`); log.auth.error(`Resend verification mail failed: ${mailErr.message}`);
return res.status(502).json({ error: 'Email could not be sent. Please check your SMTP configuration.' }); return res.status(502).json({ error: 'Email could not be sent. Please check your SMTP configuration.' });
@@ -303,15 +400,23 @@ router.post('/login', loginLimiter, async (req, res) => {
return res.status(400).json({ error: 'Email and password are required' }); return res.status(400).json({ error: 'Email and password are required' });
} }
// M1: basic email format check invalid format can never match a real account // M1: basic email format check - invalid format can never match a real account
if (!EMAIL_RE.test(email)) { if (!EMAIL_RE.test(email)) {
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' });
} }
@@ -319,8 +424,14 @@ router.post('/login', loginLimiter, async (req, res) => {
return res.status(403).json({ error: 'Email address not yet verified. Please check your inbox.', needsVerification: true }); return res.status(403).json({ error: 'Email address not yet verified. Please check your inbox.', needsVerification: true });
} }
// ── 2FA check ────────────────────────────────────────────────────────
if (user.totp_enabled) {
const tempToken = jwt.sign({ userId: user.id, purpose: '2fa' }, JWT_SECRET, { expiresIn: '5m' });
return res.json({ requires2FA: true, tempToken });
}
const token = generateToken(user.id); const token = generateToken(user.id);
const { password_hash, ...safeUser } = user; const { password_hash, verification_token, verification_token_expires, verification_resend_at, totp_secret, ...safeUser } = user;
res.json({ token, user: safeUser }); res.json({ token, user: safeUser });
} catch (err) { } catch (err) {
@@ -329,7 +440,54 @@ router.post('/login', loginLimiter, async (req, res) => {
} }
}); });
// POST /api/auth/logout revoke JWT via DragonflyDB blacklist // POST /api/auth/login/2fa - Verify TOTP code and complete login
router.post('/login/2fa', twoFaLimiter, async (req, res) => {
try {
const { tempToken, code } = req.body;
if (!tempToken || !code) {
return res.status(400).json({ error: 'Token and code are required' });
}
let decoded;
try {
decoded = jwt.verify(tempToken, JWT_SECRET);
} catch {
return res.status(401).json({ error: 'Invalid or expired token. Please log in again.' });
}
if (decoded.purpose !== '2fa') {
return res.status(401).json({ error: 'Invalid token' });
}
const db = getDb();
const user = await db.get('SELECT * FROM users WHERE id = ?', [decoded.userId]);
if (!user || !user.totp_enabled || !user.totp_secret) {
return res.status(401).json({ error: 'Invalid token' });
}
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(user.totp_secret),
algorithm: 'SHA1',
digits: 6,
period: 30,
});
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
if (delta === null) {
return res.status(401).json({ error: 'Invalid 2FA code' });
}
const token = generateToken(user.id);
const { password_hash, verification_token, verification_token_expires, verification_resend_at, totp_secret, ...safeUser } = user;
res.json({ token, user: safeUser });
} catch (err) {
log.auth.error(`2FA login error: ${err.message}`);
res.status(500).json({ error: '2FA verification failed' });
}
});
// POST /api/auth/logout - revoke JWT via DragonflyDB blacklist
router.post('/logout', authenticateToken, async (req, res) => { router.post('/logout', authenticateToken, async (req, res) => {
try { try {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
@@ -347,7 +505,31 @@ router.post('/logout', authenticateToken, async (req, res) => {
} }
} }
res.json({ message: 'Logged out successfully' }); // ── RP-Initiated Logout for OIDC/Keycloak users ──────────────────────
let keycloakLogoutUrl = null;
if (req.user.oauth_provider === 'oidc') {
try {
const config = await getOAuthConfig();
if (config) {
const oidc = await discoverOIDC(config.issuer);
if (oidc.end_session_endpoint) {
const idToken = await redis.get(`oidc:id_token:${req.user.id}`);
await redis.del(`oidc:id_token:${req.user.id}`);
const baseUrl = getBaseUrl(req);
const params = new URLSearchParams({
post_logout_redirect_uri: `${baseUrl}/`,
client_id: config.clientId,
});
if (idToken) params.set('id_token_hint', idToken);
keycloakLogoutUrl = `${oidc.end_session_endpoint}?${params.toString()}`;
}
}
} catch (oidcErr) {
log.auth.warn(`Could not build Keycloak logout URL: ${oidcErr.message}`);
}
}
res.json({ message: 'Logged out successfully', keycloakLogoutUrl });
} catch (err) { } catch (err) {
log.auth.error(`Logout error: ${err.message}`); log.auth.error(`Logout error: ${err.message}`);
res.status(500).json({ error: 'Logout failed' }); res.status(500).json({ error: 'Logout failed' });
@@ -400,7 +582,7 @@ router.put('/profile', authenticateToken, profileLimiter, async (req, res) => {
if (name && name !== req.user.name) { if (name && name !== req.user.name) {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/; const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(name)) { if (!usernameRegex.test(name)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' }); return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
} }
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?) AND id != ?', [name, req.user.id]); const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?) AND id != ?', [name, req.user.id]);
if (existingUsername) { if (existingUsername) {
@@ -440,6 +622,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();
@@ -453,7 +638,7 @@ router.put('/password', authenticateToken, passwordLimiter, async (req, res) =>
return res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters long` }); return res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
} }
const hash = bcrypt.hashSync(newPassword, 12); const hash = await bcrypt.hash(newPassword, 12);
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.user.id]); await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.user.id]);
res.json({ message: 'Password changed successfully' }); res.json({ message: 'Password changed successfully' });
@@ -466,13 +651,13 @@ router.put('/password', authenticateToken, passwordLimiter, async (req, res) =>
// POST /api/auth/avatar - Upload avatar image // POST /api/auth/avatar - Upload avatar image
router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => { router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
try { try {
// Validate content type // Validate file content by checking magic bytes (file signatures)
const contentType = req.headers['content-type']; const contentType = req.headers['content-type'];
if (!contentType || !contentType.startsWith('image/')) { if (!contentType || !contentType.startsWith('image/')) {
return res.status(400).json({ error: 'Only image files are allowed' }); return res.status(400).json({ error: 'Only image files are allowed' });
} }
// M15: stream-level size limit abort as soon as 2 MB is exceeded // M15: stream-level size limit - abort as soon as 2 MB is exceeded
const MAX_AVATAR_SIZE = 2 * 1024 * 1024; const MAX_AVATAR_SIZE = 2 * 1024 * 1024;
const buffer = await new Promise((resolve, reject) => { const buffer = await new Promise((resolve, reject) => {
const chunks = []; const chunks = [];
@@ -496,7 +681,18 @@ router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
return res.status(400).json({ error: 'Image must not exceed 2MB' }); return res.status(400).json({ error: 'Image must not exceed 2MB' });
} }
const ext = contentType.includes('png') ? 'png' : contentType.includes('gif') ? 'gif' : contentType.includes('webp') ? 'webp' : 'jpg'; // Validate magic bytes to prevent Content-Type spoofing
const magicBytes = buffer.slice(0, 8);
const isJPEG = magicBytes[0] === 0xFF && magicBytes[1] === 0xD8 && magicBytes[2] === 0xFF;
const isPNG = magicBytes[0] === 0x89 && magicBytes[1] === 0x50 && magicBytes[2] === 0x4E && magicBytes[3] === 0x47;
const isGIF = magicBytes[0] === 0x47 && magicBytes[1] === 0x49 && magicBytes[2] === 0x46;
const isWEBP = magicBytes[0] === 0x52 && magicBytes[1] === 0x49 && magicBytes[2] === 0x46 && magicBytes[3] === 0x46
&& buffer.length > 11 && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50;
if (!isJPEG && !isPNG && !isGIF && !isWEBP) {
return res.status(400).json({ error: 'File content does not match a supported image format (JPEG, PNG, GIF, WebP)' });
}
const ext = isPNG ? 'png' : isGIF ? 'gif' : isWEBP ? 'webp' : 'jpg';
const filename = `${req.user.id}_${Date.now()}.${ext}`; const filename = `${req.user.id}_${Date.now()}.${ext}`;
const filepath = path.join(uploadsDir, filename); const filepath = path.join(uploadsDir, filename);
@@ -602,4 +798,124 @@ router.get('/avatar/:filename', (req, res) => {
fs.createReadStream(filepath).pipe(res); fs.createReadStream(filepath).pipe(res);
}); });
// ── 2FA Management ──────────────────────────────────────────────────────────
// GET /api/auth/2fa/status
router.get('/2fa/status', authenticateToken, async (req, res) => {
const db = getDb();
const user = await db.get('SELECT totp_enabled FROM users WHERE id = ?', [req.user.id]);
res.json({ enabled: !!user?.totp_enabled });
});
// POST /api/auth/2fa/setup - Generate TOTP secret + provisioning URI
router.post('/2fa/setup', authenticateToken, twoFaLimiter, async (req, res) => {
try {
const db = getDb();
const user = await db.get('SELECT totp_enabled FROM users WHERE id = ?', [req.user.id]);
if (user?.totp_enabled) {
return res.status(400).json({ error: '2FA is already enabled' });
}
const secret = new OTPAuth.Secret({ size: 20 });
// Use the configured app name as the TOTP issuer (admin setting → env → default)
const issuer = await getAppName();
const totp = new OTPAuth.TOTP({
issuer,
label: req.user.email,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret,
});
// Store the secret (but don't enable yet — user must verify first)
await db.run('UPDATE users SET totp_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [secret.base32, req.user.id]);
res.json({ secret: secret.base32, uri: totp.toString() });
} catch (err) {
log.auth.error(`2FA setup error: ${err.message}`);
res.status(500).json({ error: '2FA setup failed' });
}
});
// POST /api/auth/2fa/enable - Verify code and activate 2FA
router.post('/2fa/enable', authenticateToken, twoFaLimiter, async (req, res) => {
try {
const { code } = req.body;
if (!code) {
return res.status(400).json({ error: 'Code is required' });
}
const db = getDb();
const user = await db.get('SELECT totp_secret, totp_enabled FROM users WHERE id = ?', [req.user.id]);
if (!user?.totp_secret) {
return res.status(400).json({ error: 'Please run 2FA setup first' });
}
if (user.totp_enabled) {
return res.status(400).json({ error: '2FA is already enabled' });
}
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(user.totp_secret),
algorithm: 'SHA1',
digits: 6,
period: 30,
});
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
if (delta === null) {
return res.status(401).json({ error: 'Invalid code. Please try again.' });
}
await db.run('UPDATE users SET totp_enabled = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]);
res.json({ enabled: true, message: '2FA has been enabled' });
} catch (err) {
log.auth.error(`2FA enable error: ${err.message}`);
res.status(500).json({ error: '2FA could not be enabled' });
}
});
// POST /api/auth/2fa/disable - Disable 2FA (requires password + TOTP code)
router.post('/2fa/disable', authenticateToken, twoFaLimiter, async (req, res) => {
try {
const { password, code } = req.body;
if (!password || !code) {
return res.status(400).json({ error: 'Password and code are required' });
}
if (typeof password !== 'string' || password.length > MAX_PASSWORD_LENGTH) {
return res.status(400).json({ error: 'Invalid password' });
}
const db = getDb();
const user = await db.get('SELECT password_hash, totp_secret, totp_enabled FROM users WHERE id = ?', [req.user.id]);
if (!user?.totp_enabled) {
return res.status(400).json({ error: '2FA is not enabled' });
}
if (!bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Invalid password' });
}
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(user.totp_secret),
algorithm: 'SHA1',
digits: 6,
period: 30,
});
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
if (delta === null) {
return res.status(401).json({ error: 'Invalid 2FA code' });
}
await db.run('UPDATE users SET totp_enabled = 0, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]);
res.json({ enabled: false, message: '2FA has been disabled' });
} catch (err) {
log.auth.error(`2FA disable error: ${err.message}`);
res.status(500).json({ error: '2FA could not be disabled' });
}
});
export default router; export default router;
+112
View File
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'url';
import { getDb } from '../config/database.js'; import { getDb } from '../config/database.js';
import { authenticateToken, requireAdmin } from '../middleware/auth.js'; import { authenticateToken, requireAdmin } from '../middleware/auth.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
import { getOAuthConfig } from '../config/oauth.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -14,6 +15,16 @@ const router = Router();
const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/; const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;
// Validate that a URL uses a safe scheme (http/https only)
function isSafeUrl(url) {
try {
const parsed = new URL(url);
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
} catch {
return false;
}
}
// Ensure uploads/branding directory exists // Ensure uploads/branding directory exists
const brandingDir = path.join(__dirname, '..', '..', 'uploads', 'branding'); const brandingDir = path.join(__dirname, '..', '..', 'uploads', 'branding');
if (!fs.existsSync(brandingDir)) { if (!fs.existsSync(brandingDir)) {
@@ -82,11 +93,34 @@ router.get('/', async (req, res) => {
const defaultTheme = await getSetting('default_theme'); const defaultTheme = await getSetting('default_theme');
const logoFile = findLogoFile(); const logoFile = findLogoFile();
const registrationMode = await getSetting('registration_mode');
const imprintUrl = await getSetting('imprint_url');
const privacyUrl = await getSetting('privacy_url');
// OAuth: expose whether OAuth is enabled + display name for login page
let oauthEnabled = false;
let oauthDisplayName = null;
try {
const oauthConfig = await getOAuthConfig();
if (oauthConfig) {
oauthEnabled = true;
oauthDisplayName = oauthConfig.displayName || 'SSO';
}
} catch { /* not configured */ }
const hideAppName = await getSetting('hide_app_name');
res.json({ res.json({
appName: appName || 'Redlight', appName: appName || 'Redlight',
hasLogo: !!logoFile, hasLogo: !!logoFile,
logoUrl: logoFile ? '/api/branding/logo' : null, logoUrl: logoFile ? '/api/branding/logo' : null,
defaultTheme: defaultTheme || null, defaultTheme: defaultTheme || null,
registrationMode: registrationMode || 'open',
imprintUrl: imprintUrl || null,
privacyUrl: privacyUrl || null,
oauthEnabled,
oauthDisplayName,
hideAppName: hideAppName === 'true',
}); });
} catch (err) { } catch (err) {
log.branding.error('Get branding error:', err); log.branding.error('Get branding error:', err);
@@ -192,4 +226,82 @@ router.put('/default-theme', authenticateToken, requireAdmin, async (req, res) =
} }
}); });
// PUT /api/branding/registration-mode - Set registration mode (admin only)
router.put('/registration-mode', authenticateToken, requireAdmin, async (req, res) => {
try {
const { registrationMode } = req.body;
if (!registrationMode || !['open', 'invite'].includes(registrationMode)) {
return res.status(400).json({ error: 'registrationMode must be "open" or "invite"' });
}
await setSetting('registration_mode', registrationMode);
res.json({ registrationMode });
} catch (err) {
log.branding.error('Update registration mode error:', err);
res.status(500).json({ error: 'Could not update registration mode' });
}
});
// PUT /api/branding/imprint-url - Set imprint URL (admin only)
router.put('/imprint-url', authenticateToken, requireAdmin, async (req, res) => {
try {
const { imprintUrl } = req.body;
if (imprintUrl && imprintUrl.length > 500) {
return res.status(400).json({ error: 'URL must not exceed 500 characters' });
}
if (imprintUrl && imprintUrl.trim() && !isSafeUrl(imprintUrl.trim())) {
return res.status(400).json({ error: 'URL must start with http:// or https://' });
}
if (imprintUrl && imprintUrl.trim()) {
await setSetting('imprint_url', imprintUrl.trim());
} else {
await deleteSetting('imprint_url');
}
res.json({ imprintUrl: imprintUrl?.trim() || null });
} catch (err) {
log.branding.error('Update imprint URL error:', err);
res.status(500).json({ error: 'Could not update imprint URL' });
}
});
// PUT /api/branding/privacy-url - Set privacy policy URL (admin only)
router.put('/privacy-url', authenticateToken, requireAdmin, async (req, res) => {
try {
const { privacyUrl } = req.body;
if (privacyUrl && privacyUrl.length > 500) {
return res.status(400).json({ error: 'URL must not exceed 500 characters' });
}
if (privacyUrl && privacyUrl.trim() && !isSafeUrl(privacyUrl.trim())) {
return res.status(400).json({ error: 'URL must start with http:// or https://' });
}
if (privacyUrl && privacyUrl.trim()) {
await setSetting('privacy_url', privacyUrl.trim());
} else {
await deleteSetting('privacy_url');
}
res.json({ privacyUrl: privacyUrl?.trim() || null });
} catch (err) {
log.branding.error('Update privacy URL error:', err);
res.status(500).json({ error: 'Could not update privacy URL' });
}
});
// PUT /api/branding/hide-app-name - Toggle app name visibility (admin only)
router.put('/hide-app-name', authenticateToken, requireAdmin, async (req, res) => {
try {
const { hideAppName } = req.body;
if (typeof hideAppName !== 'boolean') {
return res.status(400).json({ error: 'hideAppName must be a boolean' });
}
if (hideAppName) {
await setSetting('hide_app_name', 'true');
} else {
await deleteSetting('hide_app_name');
}
res.json({ hideAppName });
} catch (err) {
log.branding.error('Update hide app name error:', err);
res.status(500).json({ error: 'Could not update setting' });
}
});
export default router; export default router;
+551
View File
@@ -0,0 +1,551 @@
/**
* CalDAV server for Redlight
*
* Supports PROPFIND, REPORT, GET, PUT, DELETE, OPTIONS — enough for
* Thunderbird/Lightning, Apple Calendar, GNOME Calendar and DAVx⁵ (Android).
*
* Authentication: HTTP Basic Auth → email:caldav_token
* Token management: POST/GET/DELETE /api/calendar/caldav-tokens
*
* Mounted at: /caldav
*/
import { Router, text } from 'express';
import crypto from 'crypto';
import { rateLimit } from 'express-rate-limit';
import { getDb } from '../config/database.js';
import { log } from '../config/logger.js';
import { getBaseUrl } from '../middleware/auth.js';
const router = Router();
// Brute-force protection for the Basic-Auth tokens: only failed requests count
// (skipSuccessfulRequests), so legitimate sync clients polling frequently are
// not throttled.
router.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
skipSuccessfulRequests: true,
standardHeaders: true,
legacyHeaders: false,
message: 'Too many failed CalDAV requests. Please try again later.',
}));
// ── Body parsing for XML and iCalendar payloads ────────────────────────────
router.use(text({ type: ['application/xml', 'text/xml', 'text/calendar', 'application/octet-stream'] }));
// ── Helpers ────────────────────────────────────────────────────────────────
function xmlHeader() {
return '<?xml version="1.0" encoding="UTF-8"?>';
}
function escapeXml(str) {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeICS(str) {
return String(str || '')
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n');
}
function foldICSLine(line) {
// RFC 5545: fold lines longer than 75 octets
const bytes = Buffer.from(line, 'utf8');
if (bytes.length <= 75) return line;
const chunks = [];
let offset = 0;
let first = true;
while (offset < bytes.length) {
const chunk = first ? bytes.slice(0, 75) : bytes.slice(offset, offset + 74);
chunks.push((first ? '' : ' ') + chunk.toString('utf8'));
offset += first ? 75 : 74;
first = false;
}
return chunks.join('\r\n');
}
function toICSDate(dateStr) {
const d = new Date(dateStr);
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}
function parseICSDate(str) {
if (!str) return null;
// Strip TZID= prefix if present
const raw = str.includes(':') ? str.split(':').pop() : str;
if (raw.length === 8) {
// All-day: YYYYMMDD
return new Date(`${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}T00:00:00Z`);
}
return new Date(
`${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}T` +
`${raw.slice(9, 11)}:${raw.slice(11, 13)}:${raw.slice(13, 15)}` +
(raw.endsWith('Z') ? 'Z' : 'Z'),
);
}
function getICSProp(ics, key) {
const re = new RegExp(`^${key}(?:;[^:]*)?:(.+)$`, 'im');
const m = ics.match(re);
if (!m) return null;
// Unfold: join continuation lines
let v = m[1];
const unfoldRe = /\r?\n[ \t](.+)/g;
v = v.replace(unfoldRe, '$1');
return v.trim();
}
function eventToICS(event, base, user) {
// Determine the most useful join URL
const joinUrl = event.federated_join_url
|| (event.room_uid ? `${base}/join/${event.room_uid}` : null);
const roomUrl = event.room_uid ? `${base}/rooms/${event.room_uid}` : null;
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Redlight//CalDAV//EN',
'CALSCALE:GREGORIAN',
'BEGIN:VEVENT',
`UID:${event.uid}`,
`SUMMARY:${escapeICS(event.title)}`,
`DTSTART:${toICSDate(event.start_time)}`,
`DTEND:${toICSDate(event.end_time)}`,
`DTSTAMP:${toICSDate(event.updated_at || event.created_at)}`,
`LAST-MODIFIED:${toICSDate(event.updated_at || event.created_at)}`,
];
// LOCATION: show join link so calendar apps display "where" the meeting is
if (joinUrl) {
lines.push(`LOCATION:${escapeICS(joinUrl)}`);
lines.push(`URL:${joinUrl}`);
} else if (roomUrl) {
lines.push(`LOCATION:${escapeICS(roomUrl)}`);
lines.push(`URL:${roomUrl}`);
}
// DESCRIPTION: combine user description + join link hint
const descParts = [];
if (event.description) descParts.push(event.description);
if (joinUrl) {
descParts.push(`Join meeting: ${joinUrl}`);
}
if (roomUrl && roomUrl !== joinUrl) {
descParts.push(`Room page: ${roomUrl}`);
}
if (descParts.length > 0) {
lines.push(`DESCRIPTION:${escapeICS(descParts.join('\n'))}`);
}
// ORGANIZER
if (user) {
const cn = user.display_name || user.name || user.email;
lines.push(`ORGANIZER;CN=${escapeICS(cn)}:mailto:${user.email}`);
}
if (event.room_uid) {
lines.push(`X-REDLIGHT-ROOM-UID:${event.room_uid}`);
}
if (joinUrl) {
lines.push(`X-REDLIGHT-JOIN-URL:${escapeICS(joinUrl)}`);
}
if (event.reminder_minutes) {
lines.push(
'BEGIN:VALARM',
'ACTION:DISPLAY',
`DESCRIPTION:${escapeICS(event.title)}`,
`TRIGGER:-PT${event.reminder_minutes}M`,
'END:VALARM',
);
}
lines.push('END:VEVENT', 'END:VCALENDAR');
return lines.map(foldICSLine).join('\r\n');
}
function parseICSBody(body) {
const uid = getICSProp(body, 'UID');
const summary = (getICSProp(body, 'SUMMARY') || '')
.replace(/\\n/g, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';');
const description = (getICSProp(body, 'DESCRIPTION') || '')
.replace(/\\n/g, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';');
const dtstart = getICSProp(body, 'DTSTART');
const dtend = getICSProp(body, 'DTEND');
return {
uid: uid || null,
title: summary || 'Untitled',
description: description || null,
start_time: parseICSDate(dtstart),
end_time: parseICSDate(dtend),
};
}
// Build etag from updated_at
function etag(event) {
return `"${Buffer.from(event.updated_at || event.created_at).toString('base64')}"`;
}
// ── CalDAV authentication middleware ───────────────────────────────────────
async function caldavAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Basic ')) {
res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"');
return res.status(401).end();
}
try {
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8');
const colonIdx = decoded.indexOf(':');
if (colonIdx === -1) throw new Error('no colon');
const email = decoded.slice(0, colonIdx);
const token = decoded.slice(colonIdx + 1);
const db = getDb();
// Emails are stored lowercased
const user = await db.get(
'SELECT id, name, display_name, email FROM users WHERE email = ?',
[email.toLowerCase()],
);
if (!user) {
res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"');
return res.status(401).end();
}
// Hash the provided token with SHA-256 for constant-time comparison in SQL
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const tokenRow = await db.get(
'SELECT * FROM caldav_tokens WHERE user_id = ? AND token_hash = ?',
[user.id, tokenHash],
);
// Fallback: also check legacy plaintext tokens for backward compatibility
const tokenRowLegacy = !tokenRow ? await db.get(
'SELECT * FROM caldav_tokens WHERE user_id = ? AND token = ?',
[user.id, token],
) : null;
const matchedToken = tokenRow || tokenRowLegacy;
if (!matchedToken) {
res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"');
return res.status(401).end();
}
// Migrate legacy plaintext token to hashed version
if (tokenRowLegacy && !tokenRow) {
db.run("UPDATE caldav_tokens SET token_hash = ?, token = '' WHERE id = ?", [tokenHash, matchedToken.id]).catch(() => {});
}
// Update last_used_at (fire and forget)
db.run('UPDATE caldav_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?', [matchedToken.id]).catch(() => {});
req.caldavUser = user;
next();
} catch (err) {
log.server.error(`CalDAV auth error: ${err.message}`);
res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"');
return res.status(401).end();
}
}
// ── Common response headers ────────────────────────────────────────────────
function setDAVHeaders(res) {
res.set('DAV', '1, 2, calendar-access');
res.set('Allow', 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT');
res.set('MS-Author-Via', 'DAV');
}
// ── CalDAV username authorization ──────────────────────────────────────────
// Ensures the :username param matches the authenticated user's email
function validateCalDAVUser(req, res, next) {
if (req.params.username && decodeURIComponent(req.params.username).toLowerCase() !== req.caldavUser.email) {
return res.status(403).end();
}
next();
}
// ── Base URL helper (uses shared getBaseUrl from auth.js) ──────────────────
const baseUrl = getBaseUrl;
// ── PROPFIND response builders ─────────────────────────────────────────────
function multistatus(responses) {
return `${xmlHeader()}
<d:multistatus xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"
xmlns:c="urn:ietf:params:xml:ns:caldav">
${responses.join('\n')}
</d:multistatus>`;
}
function propResponse(href, props, status = '200 OK') {
const propXml = Object.entries(props)
.map(([k, v]) => ` <${k}>${v}</${k}>`)
.join('\n');
return ` <d:response>
<d:href>${escapeXml(href)}</d:href>
<d:propstat>
<d:prop>
${propXml}
</d:prop>
<d:status>HTTP/1.1 ${status}</d:status>
</d:propstat>
</d:response>`;
}
// ── OPTIONS ────────────────────────────────────────────────────────────────
router.options('/*splat', (req, res) => {
setDAVHeaders(res);
res.status(200).end();
});
// ── PROPFIND / ─────────────────────────────────────────────────────────────
// Service discovery root: tells the client where the user principal lives.
router.all('/', caldavAuth, async (req, res) => {
if (req.method !== 'PROPFIND') {
setDAVHeaders(res);
return res.status(405).end();
}
const base = baseUrl(req);
const principalHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/`;
setDAVHeaders(res);
res.status(207).type('application/xml; charset=utf-8').send(
multistatus([
propResponse('/caldav/', {
'd:current-user-principal': `<d:href>${principalHref}</d:href>`,
'd:principal-URL': `<d:href>${principalHref}</d:href>`,
'd:resourcetype': '<d:collection/>',
'd:displayname': 'Redlight CalDAV',
}),
]),
);
});
// ── PROPFIND /{username}/ ──────────────────────────────────────────────────
// User principal: tells the client where the calendar home is.
router.all('/:username/', caldavAuth, validateCalDAVUser, async (req, res) => {
if (req.method !== 'PROPFIND') {
setDAVHeaders(res);
return res.status(405).end();
}
const principalHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/`;
const calendarHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/calendar/`;
setDAVHeaders(res);
res.status(207).type('application/xml; charset=utf-8').send(
multistatus([
propResponse(principalHref, {
'd:resourcetype': '<d:collection/><d:principal/>',
'd:displayname': escapeXml(req.caldavUser.display_name || req.caldavUser.name),
'd:principal-URL': `<d:href>${principalHref}</d:href>`,
'c:calendar-home-set': `<d:href>${calendarHref}</d:href>`,
'd:current-user-principal': `<d:href>${principalHref}</d:href>`,
}),
]),
);
});
// ── PROPFIND + REPORT /{username}/calendar/ ────────────────────────────────
router.all('/:username/calendar/', caldavAuth, validateCalDAVUser, async (req, res) => {
const db = getDb();
const calendarHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/calendar/`;
// PROPFIND: return calendar collection metadata
if (req.method === 'PROPFIND') {
const latestEvent = await db.get(
'SELECT updated_at, created_at FROM calendar_events WHERE user_id = ? ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 1',
[req.caldavUser.id],
);
const ctag = latestEvent
? Buffer.from(String(latestEvent.updated_at || latestEvent.created_at)).toString('base64')
: '0';
// If Depth: 1, also return all event hrefs
const depth = req.headers.depth || '0';
const responses = [
propResponse(calendarHref, {
'd:resourcetype': '<d:collection/><c:calendar/>',
'd:displayname': 'Redlight Calendar',
'c:supported-calendar-component-set': '<c:comp name="VEVENT"/>',
'cs:getctag': escapeXml(ctag),
'd:sync-token': escapeXml(ctag),
}),
];
if (depth === '1') {
const events = await db.all(
'SELECT uid, updated_at, created_at FROM calendar_events WHERE user_id = ?',
[req.caldavUser.id],
);
for (const ev of events) {
responses.push(
propResponse(`${calendarHref}${ev.uid}.ics`, {
'd:resourcetype': '',
'd:getcontenttype': 'text/calendar; charset=utf-8',
'd:getetag': escapeXml(etag(ev)),
}),
);
}
}
setDAVHeaders(res);
return res.status(207).type('application/xml; charset=utf-8').send(multistatus(responses));
}
// REPORT: calendar-query or calendar-multiget
if (req.method === 'REPORT') {
const body = typeof req.body === 'string' ? req.body : '';
// calendar-multiget: client sends explicit hrefs
if (body.includes('calendar-multiget')) {
const hrefMatches = [...body.matchAll(/<[^:>]*:?href[^>]*>([^<]+)<\//gi)];
const uids = hrefMatches
.map(m => m[1].trim())
.filter(h => h.endsWith('.ics'))
.map(h => h.split('/').pop().replace('.ics', ''));
const responses = [];
for (const uid of uids) {
const ev = await db.get(
'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?',
[uid, req.caldavUser.id],
);
if (!ev) continue;
const ics = eventToICS(ev);
responses.push(
propResponse(`${calendarHref}${ev.uid}.ics`, {
'd:getetag': escapeXml(etag(ev)),
'c:calendar-data': escapeXml(eventToICS(ev, baseUrl(req), req.caldavUser)),
}),
);
}
setDAVHeaders(res);
return res.status(207).type('application/xml; charset=utf-8').send(multistatus(responses));
}
// calendar-query: filter by time range
let sql = 'SELECT * FROM calendar_events WHERE user_id = ?';
const params = [req.caldavUser.id];
const startMatch = body.match(/start="([^"]+)"/i);
const endMatch = body.match(/end="([^"]+)"/i);
if (startMatch) {
const startIso = parseICSDate(startMatch[1])?.toISOString();
if (startIso) { sql += ' AND end_time >= ?'; params.push(startIso); }
}
if (endMatch) {
const endIso = parseICSDate(endMatch[1])?.toISOString();
if (endIso) { sql += ' AND start_time <= ?'; params.push(endIso); }
}
sql += ' ORDER BY start_time ASC';
const events = await db.all(sql, params);
const responses = events.map(ev =>
propResponse(`${calendarHref}${ev.uid}.ics`, {
'd:getetag': escapeXml(etag(ev)),
'c:calendar-data': escapeXml(eventToICS(ev, baseUrl(req), req.caldavUser)),
}),
);
setDAVHeaders(res);
return res.status(207).type('application/xml; charset=utf-8').send(multistatus(responses));
}
setDAVHeaders(res);
res.status(405).end();
});
// ── GET /{username}/calendar/{uid}.ics ────────────────────────────────────
router.get('/:username/calendar/:filename', caldavAuth, validateCalDAVUser, async (req, res) => {
const uid = req.params.filename.replace(/\.ics$/, '');
const db = getDb();
const ev = await db.get(
'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?',
[uid, req.caldavUser.id],
);
if (!ev) return res.status(404).end();
setDAVHeaders(res);
res.set('ETag', etag(ev));
res.set('Content-Type', 'text/calendar; charset=utf-8');
res.send(eventToICS(ev, baseUrl(req), req.caldavUser));
});
// ── PUT /{username}/calendar/{uid}.ics — create or update ─────────────────
router.put('/:username/calendar/:filename', caldavAuth, validateCalDAVUser, async (req, res) => {
const uid = req.params.filename.replace(/\.ics$/, '');
const body = typeof req.body === 'string' ? req.body : '';
if (!body) return res.status(400).end();
const parsed = parseICSBody(body);
if (!parsed.start_time || !parsed.end_time) return res.status(400).end();
// Normalize UID: prefer from ICS, fall back to filename
const eventUid = parsed.uid || uid;
const db = getDb();
const existing = await db.get(
'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?',
[eventUid, req.caldavUser.id],
);
try {
if (existing) {
await db.run(
`UPDATE calendar_events SET title = ?, description = ?, start_time = ?, end_time = ?,
updated_at = CURRENT_TIMESTAMP WHERE uid = ? AND user_id = ?`,
[
parsed.title,
parsed.description,
parsed.start_time.toISOString(),
parsed.end_time.toISOString(),
eventUid,
req.caldavUser.id,
],
);
const updated = await db.get('SELECT * FROM calendar_events WHERE uid = ?', [eventUid]);
setDAVHeaders(res);
res.set('ETag', etag(updated));
return res.status(204).end();
} else {
await db.run(
`INSERT INTO calendar_events (uid, title, description, start_time, end_time, user_id, color)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
eventUid,
parsed.title,
parsed.description,
parsed.start_time.toISOString(),
parsed.end_time.toISOString(),
req.caldavUser.id,
'#6366f1',
],
);
const created = await db.get('SELECT * FROM calendar_events WHERE uid = ?', [eventUid]);
setDAVHeaders(res);
res.set('ETag', etag(created));
return res.status(201).end();
}
} catch (err) {
log.server.error(`CalDAV PUT error: ${err.message}`);
return res.status(500).end();
}
});
// ── DELETE /{username}/calendar/{uid}.ics ─────────────────────────────────
router.delete('/:username/calendar/:filename', caldavAuth, validateCalDAVUser, async (req, res) => {
const uid = req.params.filename.replace(/\.ics$/, '');
const db = getDb();
const ev = await db.get(
'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?',
[uid, req.caldavUser.id],
);
if (!ev) return res.status(404).end();
await db.run('DELETE FROM calendar_events WHERE uid = ? AND user_id = ?', [uid, req.caldavUser.id]);
setDAVHeaders(res);
res.status(204).end();
});
// ── Fallback ───────────────────────────────────────────────────────────────
router.all('/*splat', caldavAuth, (req, res) => {
setDAVHeaders(res);
res.status(405).end();
});
export default router;
+789
View File
@@ -0,0 +1,789 @@
import { Router } from 'express';
import crypto from 'crypto';
import { getDb } from '../config/database.js';
import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
import { log } from '../config/logger.js';
import { sendCalendarInviteEmail } from '../config/mailer.js';
import { getAppName } from '../config/appName.js';
import {
isFederationEnabled,
getFederationDomain,
signPayload,
verifyPayload,
discoverInstance,
parseAddress,
} from '../config/federation.js';
import { rateLimit } from 'express-rate-limit';
const router = Router();
// Allowlist for CSS color values - only permits hsl(), hex (#rgb/#rrggbb) and plain names
const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
// Allowed reminder intervals in minutes
const VALID_REMINDERS = new Set([5, 15, 30, 60, 120, 1440]);
// Rate limit for federation calendar receive
const calendarFederationLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests. Please try again later.' },
});
// ── GET /api/calendar/events — List events for the current user ─────────────
router.get('/events', authenticateToken, async (req, res) => {
try {
const db = getDb();
const { from, to } = req.query;
let sql = `
SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name
FROM calendar_events ce
JOIN users u ON ce.user_id = u.id
WHERE (ce.user_id = ? OR ce.id IN (
SELECT event_id FROM calendar_event_shares WHERE user_id = ?
))
`;
const params = [req.user.id, req.user.id];
if (from) {
sql += ' AND ce.end_time >= ?';
params.push(from);
}
if (to) {
sql += ' AND ce.start_time <= ?';
params.push(to);
}
sql += ' ORDER BY ce.start_time ASC';
const events = await db.all(sql, params);
// Mark shared events
for (const ev of events) {
ev.is_owner = ev.user_id === req.user.id;
}
res.json({ events });
} catch (err) {
log.server.error(`Calendar list error: ${err.message}`);
res.status(500).json({ error: 'Events could not be loaded' });
}
});
// ── GET /api/calendar/events/:id — Get single event ─────────────────────────
router.get('/events/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const event = await db.get(`
SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name
FROM calendar_events ce
JOIN users u ON ce.user_id = u.id
WHERE ce.id = ?
`, [req.params.id]);
if (!event) return res.status(404).json({ error: 'Event not found' });
// Check access
if (event.user_id !== req.user.id) {
const share = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, req.user.id]);
if (!share) return res.status(403).json({ error: 'No permission' });
}
// Get shared users
const sharedUsers = await db.all(`
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM calendar_event_shares ces
JOIN users u ON ces.user_id = u.id
WHERE ces.event_id = ?
`, [event.id]);
event.is_owner = event.user_id === req.user.id;
let pendingInvitations = [];
if (event.user_id === req.user.id) {
pendingInvitations = await db.all(`
SELECT cli.id, cli.to_user_id as user_id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM calendar_local_invitations cli
JOIN users u ON cli.to_user_id = u.id
WHERE cli.event_id = ? AND cli.status = 'pending'
`, [event.id]);
}
res.json({ event, sharedUsers, pendingInvitations });
} catch (err) {
log.server.error(`Calendar get event error: ${err.message}`);
res.status(500).json({ error: 'Event could not be loaded' });
}
});
// ── POST /api/calendar/events — Create event ────────────────────────────────
router.post('/events', authenticateToken, async (req, res) => {
try {
const { title, description, start_time, end_time, room_uid, color, reminder_minutes } = req.body;
if (!title || !title.trim()) return res.status(400).json({ error: 'Title is required' });
if (!start_time || !end_time) return res.status(400).json({ error: 'Start and end time are required' });
if (title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' });
if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' });
// Validate color format
if (color && !SAFE_COLOR_RE.test(color)) {
return res.status(400).json({ error: 'Invalid color format' });
}
const startDate = new Date(start_time);
const endDate = new Date(end_time);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return res.status(400).json({ error: 'Invalid date format' });
}
if (endDate <= startDate) {
return res.status(400).json({ error: 'End time must be after start time' });
}
// Verify room exists if specified
const db = getDb();
if (room_uid) {
const room = await db.get('SELECT id FROM rooms WHERE uid = ?', [room_uid]);
if (!room) return res.status(400).json({ error: 'Linked room not found' });
}
const uid = crypto.randomBytes(12).toString('hex');
const validReminder = (reminder_minutes != null && VALID_REMINDERS.has(Number(reminder_minutes)))
? Number(reminder_minutes) : null;
const result = await db.run(`
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, reminder_minutes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
uid,
title.trim(),
description || null,
startDate.toISOString(),
endDate.toISOString(),
room_uid || null,
req.user.id,
color || '#6366f1',
validReminder,
]);
const event = await db.get('SELECT * FROM calendar_events WHERE id = ?', [result.lastInsertRowid]);
res.status(201).json({ event });
} catch (err) {
log.server.error(`Calendar create error: ${err.message}`);
res.status(500).json({ error: 'Event could not be created' });
}
});
// ── PUT /api/calendar/events/:id — Update event ─────────────────────────────
router.put('/events/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
const { title, description, start_time, end_time, room_uid, color, reminder_minutes } = req.body;
if (title && title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' });
if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' });
// Validate color format
if (color && !SAFE_COLOR_RE.test(color)) {
return res.status(400).json({ error: 'Invalid color format' });
}
if (start_time && end_time) {
const s = new Date(start_time);
const e = new Date(end_time);
if (isNaN(s.getTime()) || isNaN(e.getTime())) return res.status(400).json({ error: 'Invalid date format' });
if (e <= s) return res.status(400).json({ error: 'End time must be after start time' });
}
if (room_uid) {
const room = await db.get('SELECT id FROM rooms WHERE uid = ?', [room_uid]);
if (!room) return res.status(400).json({ error: 'Linked room not found' });
}
const validReminder = (reminder_minutes !== undefined)
? ((reminder_minutes != null && VALID_REMINDERS.has(Number(reminder_minutes))) ? Number(reminder_minutes) : null)
: undefined;
// Reset reminder_sent_at when start_time or reminder_minutes changes so the job re-fires
const resetReminder = (start_time !== undefined && start_time !== event.start_time)
|| (reminder_minutes !== undefined && validReminder !== event.reminder_minutes);
await db.run(`
UPDATE calendar_events SET
title = COALESCE(?, title),
description = ?,
start_time = COALESCE(?, start_time),
end_time = COALESCE(?, end_time),
room_uid = ?,
color = COALESCE(?, color),
reminder_minutes = ?,
reminder_sent_at = ${resetReminder ? 'NULL' : 'reminder_sent_at'},
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, [
title || null,
description !== undefined ? description : event.description,
start_time || null,
end_time || null,
room_uid !== undefined ? (room_uid || null) : event.room_uid,
color || null,
// 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,
]);
const updated = await db.get('SELECT * FROM calendar_events WHERE id = ?', [req.params.id]);
res.json({ event: updated });
} catch (err) {
log.server.error(`Calendar update error: ${err.message}`);
res.status(500).json({ error: 'Event could not be updated' });
}
});
// ── DELETE /api/calendar/events/:id — Delete event ──────────────────────────
router.delete('/events/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
// Propagate deletion to all remote instances that received this event
if (isFederationEnabled()) {
try {
const outbound = await db.all(
'SELECT remote_domain FROM calendar_event_outbound WHERE event_uid = ?',
[event.uid]
);
for (const { remote_domain } of outbound) {
try {
const payload = {
event_uid: event.uid,
from_user: `@${req.user.name}@${getFederationDomain()}`,
timestamp: new Date().toISOString(),
};
const signature = signPayload(payload);
const { baseUrl: remoteApi } = await discoverInstance(remote_domain);
await fetch(`${remoteApi}/calendar-event-deleted`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Federation-Signature': signature,
'X-Federation-Origin': getFederationDomain(),
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10_000),
});
} catch (remoteErr) {
log.server.warn(`Calendar deletion propagation failed for ${remote_domain}: ${remoteErr.message}`);
}
}
await db.run('DELETE FROM calendar_event_outbound WHERE event_uid = ?', [event.uid]);
} catch (propErr) {
log.server.warn(`Calendar deletion propagation error: ${propErr.message}`);
}
}
await db.run('DELETE FROM calendar_events WHERE id = ?', [req.params.id]);
res.json({ message: 'Event deleted' });
} catch (err) {
log.server.error(`Calendar delete error: ${err.message}`);
res.status(500).json({ error: 'Event could not be deleted' });
}
});
// ── POST /api/calendar/events/:id/share — Invite local user to event ────────
router.post('/events/:id/share', authenticateToken, async (req, res) => {
try {
const { user_id } = req.body;
if (!user_id) return res.status(400).json({ error: 'User ID is required' });
const db = getDb();
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
if (user_id === req.user.id) return res.status(400).json({ error: 'Cannot share with yourself' });
const existing = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, user_id]);
if (existing) return res.status(400).json({ error: 'Already shared with this user' });
const pendingCheck = await db.get(
"SELECT id FROM calendar_local_invitations WHERE event_id = ? AND to_user_id = ? AND status = 'pending'",
[event.id, user_id]
);
if (pendingCheck) return res.status(400).json({ error: 'Invitation already pending for this user' });
await db.run(
'INSERT INTO calendar_local_invitations (event_id, from_user_id, to_user_id) VALUES (?, ?, ?)',
[event.id, req.user.id, user_id]
);
// Send notification email (fire-and-forget)
const targetUser = await db.get('SELECT name, display_name, email, language FROM users WHERE id = ?', [user_id]);
if (targetUser?.email) {
const appUrl = getBaseUrl(req);
const inboxUrl = `${appUrl}/federation/inbox`;
const appName = await getAppName();
const senderUser = await db.get('SELECT name, display_name FROM users WHERE id = ?', [req.user.id]);
const fromDisplay = (senderUser?.display_name && senderUser.display_name !== '') ? senderUser.display_name : (senderUser?.name || req.user.name);
sendCalendarInviteEmail(
targetUser.email,
(targetUser.display_name && targetUser.display_name !== '') ? targetUser.display_name : targetUser.name,
fromDisplay,
event.title, event.start_time, event.end_time, event.description,
inboxUrl, appName, targetUser.language || 'en'
).catch(mailErr => {
log.server.warn('Calendar local share mail failed (non-fatal):', mailErr.message);
});
}
const sharedUsers = await db.all(`
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM calendar_event_shares ces
JOIN users u ON ces.user_id = u.id
WHERE ces.event_id = ?
`, [event.id]);
const pendingInvitations = await db.all(`
SELECT cli.id, cli.to_user_id as user_id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM calendar_local_invitations cli
JOIN users u ON cli.to_user_id = u.id
WHERE cli.event_id = ? AND cli.status = 'pending'
`, [event.id]);
res.json({ sharedUsers, pendingInvitations });
} catch (err) {
log.server.error(`Calendar share error: ${err.message}`);
res.status(500).json({ error: 'Could not share event' });
}
});
// ── DELETE /api/calendar/events/:id/share/:userId — Remove share or cancel invitation ──
router.delete('/events/:id/share/:userId', authenticateToken, async (req, res) => {
try {
const db = getDb();
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
// Remove accepted share
await db.run('DELETE FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, parseInt(req.params.userId)]);
// Also cancel any pending local invitation for this user
await db.run(
"UPDATE calendar_local_invitations SET status = 'declined' WHERE event_id = ? AND to_user_id = ? AND status = 'pending'",
[event.id, parseInt(req.params.userId)]
);
const sharedUsers = await db.all(`
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM calendar_event_shares ces
JOIN users u ON ces.user_id = u.id
WHERE ces.event_id = ?
`, [event.id]);
const pendingInvitations = await db.all(`
SELECT cli.id, cli.to_user_id as user_id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM calendar_local_invitations cli
JOIN users u ON cli.to_user_id = u.id
WHERE cli.event_id = ? AND cli.status = 'pending'
`, [event.id]);
res.json({ sharedUsers, pendingInvitations });
} catch (err) {
log.server.error(`Calendar unshare error: ${err.message}`);
res.status(500).json({ error: 'Could not remove share' });
}
});
// ── GET /api/calendar/local-invitations — List local calendar invitations for current user ──
router.get('/local-invitations', authenticateToken, async (req, res) => {
try {
const db = getDb();
const invitations = await db.all(`
SELECT
cli.id, cli.event_id, cli.status, cli.created_at,
ce.title, ce.start_time, ce.end_time, ce.description, ce.color,
COALESCE(NULLIF(u.display_name,''), u.name) as from_name
FROM calendar_local_invitations cli
JOIN calendar_events ce ON cli.event_id = ce.id
JOIN users u ON cli.from_user_id = u.id
WHERE cli.to_user_id = ?
ORDER BY cli.created_at DESC
`, [req.user.id]);
res.json({ invitations });
} catch (err) {
log.server.error(`Calendar local invitations error: ${err.message}`);
res.status(500).json({ error: 'Could not load invitations' });
}
});
// ── POST /api/calendar/local-invitations/:id/accept — Accept local invitation ──
router.post('/local-invitations/:id/accept', authenticateToken, async (req, res) => {
try {
const db = getDb();
const inv = await db.get(
"SELECT * FROM calendar_local_invitations WHERE id = ? AND to_user_id = ? AND status = 'pending'",
[req.params.id, req.user.id]
);
if (!inv) return res.status(404).json({ error: 'Invitation not found' });
await db.run("UPDATE calendar_local_invitations SET status = 'accepted' WHERE id = ?", [inv.id]);
// Insert into calendar_event_shares so the event appears in the user's calendar
const existingShare = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [inv.event_id, req.user.id]);
if (!existingShare) {
await db.run('INSERT INTO calendar_event_shares (event_id, user_id) VALUES (?, ?)', [inv.event_id, req.user.id]);
}
res.json({ message: 'Invitation accepted' });
} catch (err) {
log.server.error(`Calendar local invitation accept error: ${err.message}`);
res.status(500).json({ error: 'Could not accept invitation' });
}
});
// ── DELETE /api/calendar/local-invitations/:id — Decline/remove local invitation ──
router.delete('/local-invitations/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const inv = await db.get(
'SELECT * FROM calendar_local_invitations WHERE id = ? AND to_user_id = ?',
[req.params.id, req.user.id]
);
if (!inv) return res.status(404).json({ error: 'Invitation not found' });
if (inv.status === 'pending') {
await db.run("UPDATE calendar_local_invitations SET status = 'declined' WHERE id = ?", [inv.id]);
} else {
// Accepted/declined - remove the share too if it was accepted
if (inv.status === 'accepted') {
await db.run('DELETE FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [inv.event_id, req.user.id]);
}
await db.run('DELETE FROM calendar_local_invitations WHERE id = ?', [inv.id]);
}
res.json({ message: 'Invitation removed' });
} catch (err) {
log.server.error(`Calendar local invitation delete error: ${err.message}`);
res.status(500).json({ error: 'Could not remove invitation' });
}
});
// ── GET /api/calendar/events/:id/ics — Download event as ICS ────────────────
router.get('/events/:id/ics', authenticateToken, async (req, res) => {
try {
const db = getDb();
const event = await db.get(`
SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name, u.email as organizer_email
FROM calendar_events ce
JOIN users u ON ce.user_id = u.id
WHERE ce.id = ?
`, [req.params.id]);
if (!event) return res.status(404).json({ error: 'Event not found' });
// Check access
if (event.user_id !== req.user.id) {
const share = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, req.user.id]);
if (!share) return res.status(403).json({ error: 'No permission' });
}
// Build room join URL if linked
const baseUrl = getBaseUrl(req);
let location = '';
if (event.room_uid) {
location = `${baseUrl}/join/${event.room_uid}`;
}
const ics = generateICS(event, location, baseUrl);
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(event.title)}.ics"`);
res.send(ics);
} catch (err) {
log.server.error(`ICS download error: ${err.message}`);
res.status(500).json({ error: 'Could not generate ICS file' });
}
});
// ── POST /api/calendar/events/:id/federation — Send event to remote user ────
router.post('/events/:id/federation', authenticateToken, async (req, res) => {
try {
if (!isFederationEnabled()) {
return res.status(400).json({ error: 'Federation is not configured on this instance' });
}
const { to } = req.body;
if (!to) return res.status(400).json({ error: 'Remote address is required' });
const { username, domain } = parseAddress(to);
if (!domain) return res.status(400).json({ error: 'Remote address must be in format username@domain' });
if (domain === getFederationDomain()) {
return res.status(400).json({ error: 'Cannot send to your own instance. Use local sharing instead.' });
}
const db = getDb();
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
const baseUrl = getBaseUrl(req);
let joinUrl = null;
if (event.room_uid) {
joinUrl = `${baseUrl}/join/${event.room_uid}`;
}
const payload = {
type: 'calendar_event',
event_uid: event.uid,
title: event.title,
description: event.description || '',
start_time: event.start_time,
end_time: event.end_time,
room_uid: event.room_uid || null,
join_url: joinUrl,
from_user: `@${req.user.name}@${getFederationDomain()}`,
to_user: to,
timestamp: new Date().toISOString(),
};
const signature = signPayload(payload);
const { baseUrl: remoteApi } = await discoverInstance(domain);
const response = await fetch(`${remoteApi}/calendar-event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Federation-Signature': signature,
'X-Federation-Origin': getFederationDomain(),
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || `Remote server responded with ${response.status}`);
}
// Track outbound send for deletion propagation
try {
await db.run(
`INSERT INTO calendar_event_outbound (event_uid, remote_domain) VALUES (?, ?)
ON CONFLICT(event_uid, remote_domain) DO NOTHING`,
[event.uid, domain]
);
} catch { /* table may not exist yet on upgrade */ }
res.json({ success: true });
} catch (err) {
log.server.error(`Calendar federation send error: ${err.message}`);
res.status(500).json({ error: err.message || 'Could not send event to remote instance' });
}
});
// ── POST /receive-event or /calendar-event — Receive calendar event from remote ──
// '/receive-event' when mounted at /api/calendar
// '/calendar-event' when mounted at /api/federation (for remote instance discovery)
router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, async (req, res) => {
try {
if (!isFederationEnabled()) {
return res.status(400).json({ error: 'Federation is not configured on this instance' });
}
const signature = req.headers['x-federation-signature'];
const payload = req.body || {};
if (!signature) return res.status(401).json({ error: 'Missing federation signature' });
const { event_uid, title, description, start_time, end_time, room_uid, join_url, from_user, to_user } = payload;
if (!event_uid || !title || !start_time || !end_time || !from_user || !to_user) {
return res.status(400).json({ error: 'Incomplete event payload' });
}
// Validate lengths
if (event_uid.length > 100 || title.length > 200 || (description && description.length > 5000) ||
from_user.length > 200 || to_user.length > 200 || (join_url && join_url.length > 2000)) {
return res.status(400).json({ error: 'Payload fields exceed maximum allowed length' });
}
// Verify signature
const { domain: senderDomain } = parseAddress(from_user);
if (!senderDomain) return res.status(400).json({ error: 'Sender address must include a domain' });
const { publicKey } = await discoverInstance(senderDomain);
if (!publicKey) return res.status(400).json({ error: 'Sender instance did not provide a public key' });
if (!verifyPayload(payload, signature, publicKey)) {
return res.status(403).json({ error: 'Invalid federation signature' });
}
// Find local user
const { username } = parseAddress(to_user);
const db = getDb();
const targetUser = await db.get('SELECT id, name, email, language FROM users WHERE LOWER(name) = LOWER(?)', [username]);
if (!targetUser) return res.status(404).json({ error: 'User not found on this instance' });
// Check duplicate (already in invitations or already accepted into calendar)
const existingInv = await db.get('SELECT id FROM calendar_invitations WHERE event_uid = ? AND to_user_id = ?', [event_uid, targetUser.id]);
if (existingInv) return res.json({ success: true, message: 'Calendar invitation already received' });
// Store as pending invitation — user must accept before it appears in calendar
await db.run(`
INSERT INTO calendar_invitations (event_uid, title, description, start_time, end_time, room_uid, join_url, from_user, to_user_id, color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
event_uid,
title,
description || null,
start_time,
end_time,
room_uid || null,
join_url || null,
from_user,
targetUser.id,
'#6366f1',
]);
// Send notification email (fire-and-forget)
if (targetUser.email) {
const appUrl = getBaseUrl(req);
const inboxUrl = `${appUrl}/federation/inbox`;
const appName = await getAppName();
sendCalendarInviteEmail(
targetUser.email, targetUser.name, from_user,
title, start_time, end_time, description || null,
inboxUrl, appName, targetUser.language || 'en'
).catch(mailErr => {
log.server.warn('Calendar invite mail failed (non-fatal):', mailErr.message);
});
}
res.json({ success: true });
} catch (err) {
log.server.error(`Calendar federation receive error: ${err.message}`);
res.status(500).json({ error: 'Failed to process calendar event' });
}
});
// ── Helper: Generate ICS content ────────────────────────────────────────────
function generateICS(event, location, prodIdDomain) {
const formatDate = (dateStr) => {
const d = new Date(dateStr);
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
};
const escapeICS = (str) => {
if (!str) return '';
return str.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
};
const now = formatDate(new Date().toISOString());
const dtStart = formatDate(event.start_time);
const dtEnd = formatDate(event.end_time);
let ics = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
`PRODID:-//${prodIdDomain}//Redlight Calendar//EN`,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
`UID:${event.uid}@${prodIdDomain}`,
`DTSTAMP:${now}`,
`DTSTART:${dtStart}`,
`DTEND:${dtEnd}`,
`SUMMARY:${escapeICS(event.title)}`,
];
if (event.description) {
ics.push(`DESCRIPTION:${escapeICS(event.description)}`);
}
if (location) {
ics.push(`LOCATION:${escapeICS(location)}`);
ics.push(`URL:${location}`);
}
if (event.organizer_name && event.organizer_email) {
ics.push(`ORGANIZER;CN=${escapeICS(event.organizer_name)}:mailto:${event.organizer_email}`);
}
if (event.reminder_minutes) {
ics.push(
'BEGIN:VALARM',
'ACTION:DISPLAY',
`DESCRIPTION:Reminder: ${escapeICS(event.title)}`,
`TRIGGER:-PT${event.reminder_minutes}M`,
'END:VALARM',
);
}
ics.push('END:VEVENT', 'END:VCALENDAR');
return ics.join('\r\n');
}
// ── CalDAV token management ────────────────────────────────────────────────
// GET /api/calendar/caldav-tokens
router.get('/caldav-tokens', authenticateToken, async (req, res) => {
try {
const db = getDb();
const tokens = await db.all(
'SELECT id, name, created_at, last_used_at FROM caldav_tokens WHERE user_id = ? ORDER BY created_at DESC',
[req.user.id],
);
res.json({ tokens });
} catch (err) {
log.server.error(`CalDAV list tokens error: ${err.message}`);
res.status(500).json({ error: 'Could not load tokens' });
}
});
// POST /api/calendar/caldav-tokens
router.post('/caldav-tokens', authenticateToken, async (req, res) => {
try {
const { name } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Token name is required' });
}
const db = getDb();
const count = await db.get(
'SELECT COUNT(*) as c FROM caldav_tokens WHERE user_id = ?',
[req.user.id],
);
if (count.c >= 10) {
return res.status(400).json({ error: 'Maximum of 10 tokens allowed' });
}
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const result = await db.run(
// Store only the hash — never the plaintext — to limit exposure on DB breach.
'INSERT INTO caldav_tokens (user_id, token_hash, name) VALUES (?, ?, ?)',
[req.user.id, tokenHash, name.trim()],
);
res.status(201).json({
token: { id: result.lastInsertRowid, name: name.trim() },
plainToken: token,
});
} catch (err) {
log.server.error(`CalDAV create token error: ${err.message}`);
res.status(500).json({ error: 'Could not create token' });
}
});
// DELETE /api/calendar/caldav-tokens/:id
router.delete('/caldav-tokens/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const result = await db.run(
'DELETE FROM caldav_tokens WHERE id = ? AND user_id = ?',
[req.params.id, req.user.id],
);
if (result.changes === 0) {
return res.status(404).json({ error: 'Token not found' });
}
res.json({ ok: true });
} catch (err) {
log.server.error(`CalDAV delete token error: ${err.message}`);
res.status(500).json({ error: 'Could not delete token' });
}
});
export default router;
+249 -26
View File
@@ -1,18 +1,20 @@
import { Router } from 'express'; import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { rateLimit } from 'express-rate-limit'; import { rateLimit } from 'express-rate-limit';
import { getDb } from '../config/database.js'; import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js'; import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
import { sendFederationInviteEmail } 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 { getAppName } from '../config/appName.js';
// M13: rate limit the unauthenticated federation receive endpoint // M13: rate limit the unauthenticated federation receive endpoint
const federationReceiveLimiter = rateLimit({ const federationReceiveLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, max: 100,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
message: { error: 'Too many federation requests. Please try again later.' }, message: { error: 'Too many federation requests. Please try again later.' },
}); });
import { import {
@@ -39,7 +41,7 @@ export function wellKnownHandler(req, res) {
federation_api: '/api/federation', federation_api: '/api/federation',
public_key: getPublicKey(), public_key: getPublicKey(),
software: 'Redlight', software: 'Redlight',
version: '1.2.1', version: '2.3.0',
}); });
} }
@@ -82,8 +84,11 @@ router.post('/invite', authenticateToken, async (req, res) => {
} }
// Build guest join URL for the remote user // Build guest join URL for the remote user
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; // If the room has an access code, embed it so the recipient can join without manual entry
const joinUrl = `${baseUrl}/join/${room.uid}`; const baseUrl = getBaseUrl(req);
const joinUrl = room.access_code
? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}`
: `${baseUrl}/join/${room.uid}`;
// Build invitation payload // Build invitation payload
const inviteId = uuidv4(); const inviteId = uuidv4();
@@ -157,6 +162,16 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
return res.status(400).json({ error: 'Incomplete invitation payload' }); return res.status(400).json({ error: 'Incomplete invitation payload' });
} }
// Validate join_url scheme to prevent javascript: or other malicious URIs
try {
const parsedUrl = new URL(join_url);
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
return res.status(400).json({ error: 'join_url must use https:// or http://' });
}
} catch {
return res.status(400).json({ error: 'Invalid join_url format' });
}
// S4: validate field lengths from remote to prevent oversized DB entries // S4: validate field lengths from remote to prevent oversized DB entries
if (invite_id.length > 100 || from_user.length > 200 || to_user.length > 200 || if (invite_id.length > 100 || from_user.length > 200 || to_user.length > 200 ||
room_name.length > 200 || join_url.length > 2000 || (message && message.length > 5000)) { room_name.length > 200 || join_url.length > 2000 || (message && message.length > 5000)) {
@@ -184,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]
); );
@@ -220,19 +235,28 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
} catch { /* column may not exist on very old installs */ } } catch { /* column may not exist on very old installs */ }
} }
// Send notification email (truly fire-and-forget never blocks the response) // Send notification email (truly fire-and-forget - never blocks the response)
if (targetUser.email) { if (targetUser.email) {
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const appUrl = getBaseUrl(req);
const inboxUrl = `${appUrl}/federation/inbox`; const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight'; const appName = await getAppName();
sendFederationInviteEmail( sendFederationInviteEmail(
targetUser.email, targetUser.name, from_user, targetUser.email, targetUser.name, from_user,
room_name, message || null, inboxUrl, appName room_name, message || null, inboxUrl, appName, targetUser.language || 'en'
).catch(mailErr => { ).catch(mailErr => {
log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message); log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message);
}); });
} }
// In-app notification
await createNotification(
targetUser.id,
'federation_invite_received',
from_user,
room_name,
'/federation/inbox',
);
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
log.federation.error('Federation receive error:', err); log.federation.error('Federation receive error:', err);
@@ -261,12 +285,28 @@ router.get('/invitations', authenticateToken, async (req, res) => {
router.get('/invitations/pending-count', authenticateToken, async (req, res) => { router.get('/invitations/pending-count', authenticateToken, async (req, res) => {
try { try {
const db = getDb(); const db = getDb();
const result = await db.get( const roomResult = await db.get(
`SELECT COUNT(*) as count FROM federation_invitations `SELECT COUNT(*) as count FROM federation_invitations
WHERE to_user_id = ? AND status = 'pending'`, WHERE to_user_id = ? AND status = 'pending'`,
[req.user.id] [req.user.id]
); );
res.json({ count: result?.count || 0 }); let calResult = { count: 0 };
try {
calResult = await db.get(
`SELECT COUNT(*) as count FROM calendar_invitations
WHERE to_user_id = ? AND status = 'pending'`,
[req.user.id]
);
} catch { /* table may not exist yet */ }
let localCalResult = { count: 0 };
try {
localCalResult = await db.get(
`SELECT COUNT(*) as count FROM calendar_local_invitations
WHERE to_user_id = ? AND status = 'pending'`,
[req.user.id]
);
} catch { /* table may not exist yet */ }
res.json({ count: (roomResult?.count || 0) + (calResult?.count || 0) + (localCalResult?.count || 0) });
} catch (err) { } catch (err) {
res.json({ count: 0 }); res.json({ count: 0 });
} }
@@ -338,6 +378,94 @@ router.delete('/invitations/:id', authenticateToken, async (req, res) => {
} }
}); });
// ── GET /api/federation/calendar-invitations — List calendar invitations ─────
router.get('/calendar-invitations', authenticateToken, async (req, res) => {
try {
const db = getDb();
const invitations = await db.all(
`SELECT * FROM calendar_invitations
WHERE to_user_id = ?
ORDER BY created_at DESC`,
[req.user.id]
);
res.json({ invitations });
} catch (err) {
log.federation.error('List calendar invitations error:', err);
res.status(500).json({ error: 'Failed to load calendar invitations' });
}
});
// ── POST /api/federation/calendar-invitations/:id/accept ─────────────────────
router.post('/calendar-invitations/:id/accept', authenticateToken, async (req, res) => {
try {
const db = getDb();
const inv = await db.get(
`SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`,
[req.params.id, req.user.id]
);
if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' });
if (inv.status === 'accepted') return res.status(400).json({ error: 'Already accepted' });
await db.run(
`UPDATE calendar_invitations SET status = 'accepted' WHERE id = ?`,
[inv.id]
);
// Check if event was already previously accepted (duplicate guard)
const existing = await db.get(
'SELECT id FROM calendar_events WHERE uid = ? AND user_id = ?',
[inv.event_uid, req.user.id]
);
if (!existing) {
await db.run(`
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, federated_from, federated_join_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
inv.event_uid,
inv.title,
inv.description || null,
inv.start_time,
inv.end_time,
inv.room_uid || null,
req.user.id,
inv.color || '#6366f1',
inv.from_user,
inv.join_url || null,
]);
}
res.json({ success: true });
} catch (err) {
log.federation.error('Accept calendar invitation error:', err);
res.status(500).json({ error: 'Failed to accept calendar invitation' });
}
});
// ── DELETE /api/federation/calendar-invitations/:id — Decline/dismiss ────────
router.delete('/calendar-invitations/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const inv = await db.get(
`SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`,
[req.params.id, req.user.id]
);
if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' });
if (inv.status === 'pending') {
// mark as declined
await db.run(`UPDATE calendar_invitations SET status = 'declined' WHERE id = ?`, [inv.id]);
} else {
// accepted or declined — permanently remove from inbox
await db.run('DELETE FROM calendar_invitations WHERE id = ?', [inv.id]);
}
res.json({ success: true });
} catch (err) {
log.federation.error('Delete calendar invitation error:', err);
res.status(500).json({ error: 'Failed to remove calendar invitation' });
}
});
// ── GET /api/federation/federated-rooms — List saved federated rooms ──────── // ── GET /api/federation/federated-rooms — List saved federated rooms ────────
router.get('/federated-rooms', authenticateToken, async (req, res) => { router.get('/federated-rooms', authenticateToken, async (req, res) => {
try { try {
@@ -423,6 +551,99 @@ router.post('/room-sync', federationReceiveLimiter, async (req, res) => {
} }
}); });
// ── POST /api/federation/calendar-event-deleted — Receive calendar deletion ─
router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res) => {
try {
if (!isFederationEnabled()) {
return res.status(400).json({ error: 'Federation is not configured on this instance' });
}
const signature = req.headers['x-federation-signature'];
const originDomain = req.headers['x-federation-origin'];
const payload = req.body || {};
if (!signature || !originDomain) {
return res.status(401).json({ error: 'Missing federation signature or origin' });
}
const { publicKey } = await discoverInstance(originDomain);
if (!publicKey || !verifyPayload(payload, signature, publicKey)) {
return res.status(403).json({ error: 'Invalid federation signature' });
}
const { event_uid } = payload;
if (!event_uid || typeof event_uid !== 'string') {
return res.status(400).json({ error: 'event_uid is required' });
}
const db = getDb();
// Escape LIKE special characters in originDomain to prevent wildcard injection.
const safeDomain = originDomain.replace(/[\\%_]/g, '\\$&');
// Collect all affected users before deleting (for email notifications)
let affectedUsers = [];
try {
// Users with pending/declined invitations
const invUsers = await db.all(
`SELECT u.email, u.name, u.language, ci.title, ci.from_user
FROM calendar_invitations ci
JOIN users u ON ci.to_user_id = u.id
WHERE ci.event_uid = ? AND ci.from_user LIKE ? ESCAPE '\\'`,
[event_uid, `%@${safeDomain}`]
);
// Users who already accepted (event in their calendar)
const calUsers = await db.all(
`SELECT u.email, u.name, u.language, ce.title, ce.federated_from AS from_user
FROM calendar_events ce
JOIN users u ON ce.user_id = u.id
WHERE ce.uid = ? AND ce.federated_from LIKE ? ESCAPE '\\'`,
[event_uid, `%@${safeDomain}`]
);
// Merge, deduplicate by email
const seen = new Set();
for (const row of [...invUsers, ...calUsers]) {
if (row.email && !seen.has(row.email)) {
seen.add(row.email);
affectedUsers.push(row);
}
}
} catch { /* non-fatal */ }
// Remove from calendar_invitations for all users on this instance
await db.run(
`DELETE FROM calendar_invitations
WHERE event_uid = ? AND from_user LIKE ? ESCAPE '\\'`,
[event_uid, `%@${safeDomain}`]
);
// Remove from calendar_events (accepted invitations) for all users on this instance
await db.run(
`DELETE FROM calendar_events
WHERE uid = ? AND federated_from LIKE ? ESCAPE '\\'`,
[event_uid, `%@${safeDomain}`]
);
log.federation.info(`Calendar event ${event_uid} deleted (origin: ${originDomain})`);
// Notify affected users by email (fire-and-forget)
if (affectedUsers.length > 0) {
const appName = await getAppName();
for (const u of affectedUsers) {
sendCalendarEventDeletedEmail(u.email, u.name, u.from_user, u.title, appName, u.language || 'en')
.catch(mailErr => {
log.federation.warn(`Calendar deletion mail to ${u.email} failed (non-fatal): ${mailErr.message}`);
});
}
}
res.json({ success: true });
} catch (err) {
log.federation.error('Calendar-event-deleted error:', err);
res.status(500).json({ error: 'Failed to process calendar event deletion' });
}
});
// ── POST /api/federation/room-deleted — Receive deletion notification ─────── // ── POST /api/federation/room-deleted — Receive deletion notification ───────
// Origin instance pushes this to notify that a room has been deleted. // Origin instance pushes this to notify that a room has been deleted.
router.post('/room-deleted', federationReceiveLimiter, async (req, res) => { router.post('/room-deleted', federationReceiveLimiter, async (req, res) => {
@@ -450,11 +671,13 @@ router.post('/room-deleted', federationReceiveLimiter, async (req, res) => {
} }
const db = getDb(); const db = getDb();
// Escape LIKE special characters in originDomain to prevent wildcard injection.
const safeDomain = originDomain.replace(/[\\%_]/g, '\\$&');
// Mark all federated_rooms with this meet_id (room_uid) from this origin as deleted // Mark all federated_rooms with this meet_id (room_uid) from this origin as deleted
await db.run( await db.run(
`UPDATE federated_rooms SET deleted = 1, updated_at = CURRENT_TIMESTAMP `UPDATE federated_rooms SET deleted = 1, updated_at = CURRENT_TIMESTAMP
WHERE meet_id = ? AND from_user LIKE ?`, WHERE meet_id = ? AND from_user LIKE ? ESCAPE '\\'`,
[room_uid, `%@${originDomain}`] [room_uid, `%@${safeDomain}`]
); );
log.federation.info(`Room ${room_uid} marked as deleted (origin: ${originDomain})`); log.federation.info(`Room ${room_uid} marked as deleted (origin: ${originDomain})`);
+74
View File
@@ -0,0 +1,74 @@
import { Router } from 'express';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
const router = Router();
// GET /api/notifications — List recent notifications for the current user
router.get('/', authenticateToken, async (req, res) => {
try {
const db = getDb();
const notifications = await db.all(
`SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50`,
[req.user.id],
);
const unreadCount = notifications.filter(n => !n.read).length;
res.json({ notifications, unreadCount });
} catch {
res.status(500).json({ error: 'Failed to load notifications' });
}
});
// POST /api/notifications/read-all — Mark all notifications as read
// NOTE: Must be declared before /:id/read to avoid routing collision
router.post('/read-all', authenticateToken, async (req, res) => {
try {
const db = getDb();
await db.run('UPDATE notifications SET read = 1 WHERE user_id = ?', [req.user.id]);
res.json({ success: true });
} catch {
res.status(500).json({ error: 'Failed to update notifications' });
}
});
// POST /api/notifications/:id/read — Mark a single notification as read
router.post('/:id/read', authenticateToken, async (req, res) => {
try {
const db = getDb();
await db.run(
'UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?',
[req.params.id, req.user.id],
);
res.json({ success: true });
} catch {
res.status(500).json({ error: 'Failed to update notification' });
}
});
// DELETE /api/notifications/all — Delete all notifications for current user
// NOTE: Declared before /:id to avoid routing collision
router.delete('/all', authenticateToken, async (req, res) => {
try {
const db = getDb();
await db.run('DELETE FROM notifications WHERE user_id = ?', [req.user.id]);
res.json({ success: true });
} catch {
res.status(500).json({ error: 'Failed to delete notifications' });
}
});
// DELETE /api/notifications/:id — Delete a single notification
router.delete('/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
await db.run(
'DELETE FROM notifications WHERE id = ? AND user_id = ?',
[req.params.id, req.user.id],
);
res.json({ success: true });
} catch {
res.status(500).json({ error: 'Failed to delete notification' });
}
});
export default router;
+278
View File
@@ -0,0 +1,278 @@
/**
* OAuth / OpenID Connect routes for Redlight.
*
* Flow:
* 1. Client calls GET /api/oauth/providers → returns enabled provider info
* 2. Client calls GET /api/oauth/authorize → server generates PKCE + state, redirects to IdP
* 3. IdP redirects to GET /api/oauth/callback with code + state
* 4. Server exchanges code for tokens, fetches user info, creates/links account, returns JWT
*
* Security:
* - PKCE (S256) everywhere
* - Cryptographic state token (single-use, 10-min TTL)
* - Client secret never leaves the server
* - OAuth user info validated and sanitized before DB insertion
* - Rate limited callback endpoint
*/
import { Router } from 'express';
import { rateLimit } from 'express-rate-limit';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js';
import { generateToken, getBaseUrl } from '../middleware/auth.js';
import { log } from '../config/logger.js';
import redis from '../config/redis.js';
import {
getOAuthConfig,
discoverOIDC,
createOAuthState,
consumeOAuthState,
generateCodeVerifier,
computeCodeChallenge,
exchangeCode,
fetchUserInfo,
} from '../config/oauth.js';
const router = Router();
// Rate limit the callback to prevent brute-force of authorization codes
const callbackLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many OAuth attempts. Please try again later.' },
});
// ── GET /api/oauth/providers — List available OAuth providers (public) ───────
router.get('/providers', async (req, res) => {
try {
const config = await getOAuthConfig();
if (!config) {
return res.json({ providers: [] });
}
// Never expose client secret or issuer details to the client
res.json({
providers: [
{
id: 'oidc',
name: config.displayName || 'SSO',
},
],
});
} catch (err) {
log.auth.error(`OAuth providers error: ${err.message}`);
res.json({ providers: [] });
}
});
// ── GET /api/oauth/authorize — Start OAuth flow (redirects to IdP) ──────────
router.get('/authorize', async (req, res) => {
try {
const config = await getOAuthConfig();
if (!config) {
return res.status(400).json({ error: 'OAuth is not configured' });
}
// Discover OIDC endpoints
const oidc = await discoverOIDC(config.issuer);
// Generate PKCE pair
const codeVerifier = generateCodeVerifier();
const codeChallenge = computeCodeChallenge(codeVerifier);
// Optional return_to from query (validate it's a relative path)
let returnTo = req.query.return_to || null;
if (returnTo && (typeof returnTo !== 'string' || !returnTo.startsWith('/') || returnTo.startsWith('//'))) {
returnTo = null; // prevent open redirect
}
// Create server-side state
const state = await createOAuthState('oidc', codeVerifier, returnTo);
// Build callback URL
const baseUrl = getBaseUrl(req);
const redirectUri = `${baseUrl}/api/oauth/callback`;
// Build authorization URL
const params = new URLSearchParams({
response_type: 'code',
client_id: config.clientId,
redirect_uri: redirectUri,
scope: 'openid email profile',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
const authUrl = `${oidc.authorization_endpoint}?${params.toString()}`;
res.redirect(authUrl);
} catch (err) {
log.auth.error(`OAuth authorize error: ${err.message}`);
res.status(500).json({ error: 'Could not start OAuth flow' });
}
});
// ── GET /api/oauth/callback — Handle IdP callback ──────────────────────────
router.get('/callback', callbackLimiter, async (req, res) => {
try {
const { code, state, error: oauthError, error_description } = req.query;
// Build frontend error redirect helper
const baseUrl = getBaseUrl(req);
const errorRedirect = (msg) =>
res.redirect(`${baseUrl}/oauth/callback?error=${encodeURIComponent(msg)}`);
// Handle IdP errors
if (oauthError) {
log.auth.warn(`OAuth IdP error: ${oauthError} ${error_description || ''}`);
return errorRedirect(error_description || oauthError);
}
if (!code || !state) {
return errorRedirect('Missing authorization code or state');
}
// Validate and consume state (CSRF + PKCE verifier retrieval)
const stateData = await consumeOAuthState(state);
if (!stateData) {
return errorRedirect('Invalid or expired OAuth state. Please try again.');
}
// Load provider config
const config = await getOAuthConfig();
if (!config) {
return errorRedirect('OAuth is not configured');
}
// Discover OIDC endpoints
const oidc = await discoverOIDC(config.issuer);
// Exchange code for tokens
const redirectUri = `${baseUrl}/api/oauth/callback`;
const tokenResponse = await exchangeCode(
oidc, code, redirectUri, config.clientId, config.clientSecret, stateData.code_verifier,
);
if (!tokenResponse.access_token) {
return errorRedirect('Token exchange failed: no access token received');
}
// Fetch user info from the IdP
let userInfo;
if (oidc.userinfo_endpoint) {
userInfo = await fetchUserInfo(oidc.userinfo_endpoint, tokenResponse.access_token);
} else {
return errorRedirect('Provider does not support userinfo endpoint');
}
// Extract and validate user attributes
const email = (userInfo.email || '').toLowerCase().trim();
const sub = userInfo.sub || '';
const name = userInfo.preferred_username || userInfo.name || email.split('@')[0] || '';
const displayName = userInfo.name || userInfo.preferred_username || '';
if (!email || !sub) {
return errorRedirect('OAuth provider did not return an email or subject');
}
// Basic email validation
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
if (!EMAIL_RE.test(email)) {
return errorRedirect('OAuth provider returned an invalid email address');
}
const db = getDb();
// ── Link or create user ──────────────────────────────────────────────
// 1. Check if there's already a user linked with this OAuth provider + sub
let user = await db.get(
'SELECT id, name, display_name, email, role, email_verified FROM users WHERE oauth_provider = ? AND oauth_provider_id = ?',
['oidc', sub],
);
if (!user) {
// 2. Check if a user with this email already exists (link accounts)
user = await db.get(
'SELECT id, name, display_name, email, role, email_verified, oauth_provider FROM users WHERE email = ?',
[email],
);
if (user) {
// Only auto-link to an existing local account if the IdP has actually
// verified the email. Otherwise an attacker who registers at the IdP
// with someone else's email could take over the local account.
if (userInfo.email_verified !== true) {
log.auth.warn(`OAuth account-linking blocked: provider did not assert email_verified=true for ${email}`);
return errorRedirect('Your OAuth provider has not verified this email address. Please verify it with the provider before logging in here.');
}
await db.run(
'UPDATE users SET oauth_provider = ?, oauth_provider_id = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
['oidc', sub, user.id],
);
log.auth.info(`Linked OAuth (oidc/${sub}) to existing user ${user.email}`);
} else {
// 3. Auto-register new user (if enabled)
if (!config.autoRegister) {
return errorRedirect('No account found for this email. Please register first or ask an admin.');
}
// Check registration mode
const regModeSetting = await db.get("SELECT value FROM settings WHERE key = 'registration_mode'");
const registrationMode = regModeSetting?.value || 'open';
if (registrationMode === 'invite') {
return errorRedirect('Registration is invite-only. An administrator must create your account first.');
}
// Sanitize username: only allow safe characters
const safeUsername = name.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 30) || `user_${Date.now()}`;
// Ensure username is unique
let finalUsername = safeUsername;
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?)', [safeUsername]);
if (existingUsername) {
finalUsername = `${safeUsername}_${Date.now().toString(36)}`;
}
// No password needed for OAuth users — use a random hash that can't be guessed
const randomPasswordHash = `oauth:${uuidv4()}`;
const result = await db.run(
`INSERT INTO users (name, display_name, email, password_hash, email_verified, oauth_provider, oauth_provider_id)
VALUES (?, ?, ?, ?, 1, ?, ?)`,
[finalUsername, displayName.slice(0, 100) || finalUsername, email, randomPasswordHash, 'oidc', sub],
);
user = await db.get(
'SELECT id, name, display_name, email, role, email_verified FROM users WHERE id = ?',
[result.lastInsertRowid],
);
log.auth.info(`Created new OAuth user: ${email} (oidc/${sub})`);
}
}
// Generate JWT
const token = generateToken(user.id);
// Store id_token in Redis for RP-Initiated Logout (Keycloak SLO)
if (tokenResponse.id_token) {
try {
await redis.setex(`oidc:id_token:${user.id}`, 7 * 24 * 3600, tokenResponse.id_token);
} catch (redisErr) {
log.auth.warn(`Failed to cache OIDC id_token: ${redisErr.message}`);
}
}
// Redirect to frontend callback page with token.
// Use a hash fragment so the token is never sent to the server (not logged, not in Referer headers).
const returnTo = stateData.return_to || '/dashboard';
res.redirect(`${baseUrl}/oauth/callback#token=${encodeURIComponent(token)}&return_to=${encodeURIComponent(returnTo)}`);
} catch (err) {
log.auth.error(`OAuth callback error: ${err.message}`);
const baseUrl = getBaseUrl(req);
res.redirect(`${baseUrl}/oauth/callback?error=${encodeURIComponent('OAuth authentication failed. Please try again.')}`);
}
});
export default router;
+201 -63
View File
@@ -11,6 +11,171 @@ import {
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);
}
}
// 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 +199,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 +225,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 +236,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 +262,14 @@ router.delete('/:recordID', authenticateToken, async (req, res) => {
} }
} }
} }
await deleteRecording(req.params.recordID); // Try to delete on BBB (may fail if server changed that's OK)
try {
await deleteRecording(req.params.recordID);
} catch (err) {
log.recordings.warn(`BBB deleteRecording failed (may already be gone): ${err.message}`);
}
// Always remove from local cache
await db.run('DELETE FROM recordings WHERE record_id = ?', [req.params.recordID]);
res.json({ message: 'Recording deleted' }); res.json({ message: 'Recording deleted' });
} catch (err) { } catch (err) {
log.recordings.error(`Delete recording error: ${err.message}`); log.recordings.error(`Delete recording error: ${err.message}`);
@@ -156,14 +280,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 +306,15 @@ router.put('/:recordID/publish', authenticateToken, async (req, res) => {
} }
} }
const { publish } = req.body; const { publish } = req.body;
await publishRecording(req.params.recordID, publish); // Try BBB API
try {
await publishRecording(req.params.recordID, publish);
} catch (err) {
log.recordings.warn(`BBB publishRecording failed: ${err.message}`);
}
// Update DB cache
await db.run('UPDATE recordings SET published = ?, updated_at = CURRENT_TIMESTAMP WHERE record_id = ?',
[publish ? 1 : 0, req.params.recordID]);
res.json({ message: publish ? 'Recording published' : 'Recording unpublished' }); res.json({ message: publish ? 'Recording published' : 'Recording unpublished' });
} catch (err) { } catch (err) {
log.recordings.error(`Publish recording error: ${err.message}`); log.recordings.error(`Publish recording error: ${err.message}`);
+255 -31
View File
@@ -1,18 +1,22 @@
import { Router } from 'express'; import { Router } from 'express';
import crypto from 'crypto'; 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 } from 'express-rate-limit';
import { getDb } from '../config/database.js'; import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js'; import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js';
import { sendGuestInviteEmail } from '../config/mailer.js';
import { getAppName } from '../config/appName.js';
import { import {
createMeeting, createMeeting,
joinMeeting, joinMeeting,
endMeeting, endMeeting,
getMeetingInfo, getMeetingInfo,
isMeetingRunning, isMeetingRunning,
getAnalyticsToken,
} from '../config/bbb.js'; } from '../config/bbb.js';
import { import {
isFederationEnabled, isFederationEnabled,
@@ -21,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;
@@ -35,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
@@ -44,11 +67,21 @@ const guestJoinLimiter = rateLimit({
message: { error: 'Too many join attempts. Please try again later.' }, message: { error: 'Too many join attempts. Please try again later.' },
}); });
// Rate limit email invitations: each request may carry up to 50 addresses, so
// without a cap any registered account could be abused as an SMTP spam relay.
const inviteEmailLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many email invitations. Please try again later.' },
});
const router = Router(); const router = Router();
// Build avatar URL for a user (uploaded image or generated initials) // Build avatar URL for a user (uploaded image or generated initials)
function getUserAvatarURL(req, user) { function getUserAvatarURL(req, user) {
const baseUrl = `${req.protocol}://${req.get('host')}`; const baseUrl = getBaseUrl(req);
if (user.avatar_image) { if (user.avatar_image) {
return `${baseUrl}/api/auth/avatar/${user.avatar_image}`; return `${baseUrl}/api/auth/avatar/${user.avatar_image}`;
} }
@@ -165,6 +198,9 @@ router.post('/', authenticateToken, async (req, res) => {
if (!name || name.trim().length === 0) { if (!name || name.trim().length === 0) {
return res.status(400).json({ error: 'Room name is required' }); return res.status(400).json({ error: 'Room name is required' });
} }
if (name.trim().length < 2) {
return res.status(400).json({ error: 'Room name must be at least 2 characters' });
}
// M7: field length limits // M7: field length limits
if (name.trim().length > 100) { if (name.trim().length > 100) {
@@ -239,9 +275,14 @@ router.put('/:uid', authenticateToken, async (req, res) => {
record_meeting, record_meeting,
guest_access, guest_access,
moderator_code, moderator_code,
learning_analytics,
analytics_visibility,
} = req.body; } = req.body;
// M12: field length limits (same as create) // M12: field length limits (same as create)
if (name && name.trim().length < 2) {
return res.status(400).json({ error: 'Room name must be at least 2 characters' });
}
if (name && name.trim().length > 100) { if (name && name.trim().length > 100) {
return res.status(400).json({ error: 'Room name must not exceed 100 characters' }); return res.status(400).json({ error: 'Room name must not exceed 100 characters' });
} }
@@ -275,6 +316,8 @@ router.put('/:uid', authenticateToken, async (req, res) => {
record_meeting = COALESCE(?, record_meeting), record_meeting = COALESCE(?, record_meeting),
guest_access = COALESCE(?, guest_access), guest_access = COALESCE(?, guest_access),
moderator_code = ?, moderator_code = ?,
learning_analytics = COALESCE(?, learning_analytics),
analytics_visibility = COALESCE(?, analytics_visibility),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE uid = ? WHERE uid = ?
`, [ `, [
@@ -289,6 +332,8 @@ router.put('/:uid', authenticateToken, async (req, res) => {
record_meeting !== undefined ? (record_meeting ? 1 : 0) : null, record_meeting !== undefined ? (record_meeting ? 1 : 0) : null,
guest_access !== undefined ? (guest_access ? 1 : 0) : null, guest_access !== undefined ? (guest_access ? 1 : 0) : null,
moderator_code !== undefined ? (moderator_code || null) : room.moderator_code, moderator_code !== undefined ? (moderator_code || null) : room.moderator_code,
learning_analytics !== undefined ? (learning_analytics ? 1 : 0) : null,
analytics_visibility && ['owner', 'shared'].includes(analytics_visibility) ? analytics_visibility : null,
req.params.uid, req.params.uid,
]); ]);
@@ -390,6 +435,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) {
@@ -402,6 +451,15 @@ router.post('/:uid/shares', authenticateToken, async (req, res) => {
JOIN users u ON rs.user_id = u.id JOIN users u ON rs.user_id = u.id
WHERE rs.room_id = ? WHERE rs.room_id = ?
`, [room.id]); `, [room.id]);
// Notify the user who was given access
const sharerName = req.user.display_name || req.user.name;
await createNotification(
user_id,
'room_share_added',
room.name,
sharerName,
`/rooms/${room.uid}`,
);
res.json({ shares }); res.json({ shares });
} catch (err) { } catch (err) {
log.rooms.error(`Share room error: ${err.message}`); log.rooms.error(`Share room error: ${err.message}`);
@@ -417,13 +475,22 @@ router.delete('/:uid/shares/:userId', authenticateToken, async (req, res) => {
if (!room) { if (!room) {
return res.status(404).json({ error: 'Room not found or no permission' }); return res.status(404).json({ error: 'Room not found or no permission' });
} }
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, parseInt(req.params.userId)]); const removedUserId = parseInt(req.params.userId);
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, removedUserId]);
const shares = await db.all(` const shares = await db.all(`
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM room_shares rs FROM room_shares rs
JOIN users u ON rs.user_id = u.id JOIN users u ON rs.user_id = u.id
WHERE rs.room_id = ? WHERE rs.room_id = ?
`, [room.id]); `, [room.id]);
// Notify the user whose access was removed
await createNotification(
removedUserId,
'room_share_removed',
room.name,
null,
'/dashboard',
);
res.json({ shares }); res.json({ shares });
} catch (err) { } catch (err) {
log.rooms.error(`Remove share error: ${err.message}`); log.rooms.error(`Remove share error: ${err.message}`);
@@ -441,24 +508,32 @@ router.post('/:uid/start', authenticateToken, async (req, res) => {
return res.status(404).json({ error: 'Room not found' }); return res.status(404).json({ error: 'Room not found' });
} }
// Check access: owner or shared // Check access: owner, admin, shared, or anyone_can_start
const isOwner = room.user_id === req.user.id; const isOwner = room.user_id === req.user.id;
if (!isOwner) { const isAdmin = req.user.role === 'admin';
if (!isOwner && !isAdmin && !room.anyone_can_start) {
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
if (!share) { if (!share) {
return res.status(403).json({ error: 'No permission' }); return res.status(403).json({ error: 'No permission' });
} }
} }
const baseUrl = `${req.protocol}://${req.get('host')}`; const baseUrl = getBaseUrl(req);
const loginURL = `${baseUrl}/join/${room.uid}`; const loginURL = `${baseUrl}/join/${room.uid}`;
const presentationUrl = room.presentation_file let presentationUrl = null;
? `${baseUrl}/uploads/presentations/${room.presentation_file}` if (room.presentation_file) {
const { token, expires } = signPresentationUrl(room.uid, room.presentation_file);
presentationUrl = `${baseUrl}/api/rooms/presentations/${token}/${expires}/${room.uid}/${encodeURIComponent(room.presentation_file)}`;
}
const analyticsCallbackURL = room.learning_analytics
? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}`
: null; : null;
await createMeeting(room, baseUrl, loginURL, presentationUrl); // Localise BBB's default welcome message in the room owner's language
const owner = await db.get('SELECT language FROM users WHERE id = ?', [room.user_id]);
await createMeeting(room, baseUrl, loginURL, presentationUrl, analyticsCallbackURL, owner?.language || 'en');
const avatarURL = getUserAvatarURL(req, req.user); const avatarURL = getUserAvatarURL(req, req.user);
const displayName = req.user.display_name || req.user.name; const displayName = req.user.display_name || req.user.name;
const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL); const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL, !!req.body.dark_mode);
res.json({ joinUrl }); res.json({ joinUrl });
} catch (err) { } catch (err) {
log.rooms.error(`Start meeting error: ${err.message}`); log.rooms.error(`Start meeting error: ${err.message}`);
@@ -492,7 +567,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}`);
@@ -510,9 +585,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' });
@@ -567,7 +643,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' });
@@ -598,9 +674,14 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
// If meeting not running but anyone_can_start, create it // If meeting not running but anyone_can_start, create it
if (!running && room.anyone_can_start) { if (!running && room.anyone_can_start) {
const baseUrl = `${req.protocol}://${req.get('host')}`; const baseUrl = getBaseUrl(req);
const loginURL = `${baseUrl}/join/${room.uid}`; const loginURL = `${baseUrl}/join/${room.uid}`;
await createMeeting(room, baseUrl, loginURL); const analyticsCallbackURL = room.learning_analytics
? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}`
: null;
// Localise BBB's default welcome message in the room owner's language
const owner = await db.get('SELECT language FROM users WHERE id = ?', [room.user_id]);
await createMeeting(room, baseUrl, loginURL, null, analyticsCallbackURL, owner?.language || 'en');
} }
// Check moderator code // Check moderator code
@@ -609,9 +690,22 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
isModerator = true; isModerator = true;
} }
const baseUrl = `${req.protocol}://${req.get('host')}`; const baseUrl = getBaseUrl(req);
const guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`; // Validate client-supplied avatar fields before embedding them in a URL
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL); // that BBB will fetch — guest-join is unauthenticated.
const safeAvatarImage = (typeof avatar_image === 'string' && SAFE_AVATAR_FILENAME_RE.test(avatar_image))
? avatar_image : null;
const safeAvatarColor = (typeof avatar_color === 'string' && SAFE_AVATAR_COLOR_RE.test(avatar_color))
? avatar_color : null;
let guestAvatarURL;
if (safeAvatarImage) {
guestAvatarURL = `${baseUrl}/api/auth/avatar/${encodeURIComponent(safeAvatarImage)}`;
} else if (safeAvatarColor) {
guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}?color=${encodeURIComponent(safeAvatarColor)}`;
} else {
guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
}
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL, !!dark_mode);
res.json({ joinUrl }); res.json({ joinUrl });
} catch (err) { } catch (err) {
log.rooms.error(`Guest join error: ${err.message}`); log.rooms.error(`Guest join error: ${err.message}`);
@@ -641,6 +735,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 {
@@ -648,7 +782,7 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => {
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]); const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
if (!room) return res.status(404).json({ error: 'Room not found or no permission' }); if (!room) return res.status(404).json({ error: 'Room not found or no permission' });
// M16: stream-level size limit abort as soon as 50 MB is exceeded // M16: stream-level size limit - abort as soon as 50 MB is exceeded
const MAX_PRESENTATION_SIZE = 50 * 1024 * 1024; const MAX_PRESENTATION_SIZE = 50 * 1024 * 1024;
const buffer = await new Promise((resolve, reject) => { const buffer = await new Promise((resolve, reject) => {
const chunks = []; const chunks = [];
@@ -684,24 +818,39 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => {
const ext = extMap[contentType]; const ext = extMap[contentType];
if (!ext) return res.status(400).json({ error: 'Unsupported file type. Allowed: PDF, PPT, PPTX, ODP, DOC, DOCX' }); if (!ext) return res.status(400).json({ error: 'Unsupported file type. Allowed: PDF, PPT, PPTX, ODP, DOC, DOCX' });
// Validate magic bytes to prevent Content-Type spoofing
const magic = buffer.slice(0, 8);
const isPDF = magic[0] === 0x25 && magic[1] === 0x50 && magic[2] === 0x44 && magic[3] === 0x46; // %PDF
const isZip = magic[0] === 0x50 && magic[1] === 0x4B && magic[2] === 0x03 && magic[3] === 0x04; // PK (PPTX, DOCX, ODP, etc.)
const isOle = magic[0] === 0xD0 && magic[1] === 0xCF && magic[2] === 0x11 && magic[3] === 0xE0; // OLE2 (PPT, DOC)
if (ext === 'pdf' && !isPDF) return res.status(400).json({ error: 'File content does not match PDF format' });
if (['pptx', 'docx', 'odp'].includes(ext) && !isZip) return res.status(400).json({ error: 'File content does not match expected archive format' });
if (['ppt', 'doc'].includes(ext) && !isOle) return res.status(400).json({ error: 'File content does not match expected document format' });
// Preserve original filename (sent as X-Filename header) // Preserve original filename (sent as X-Filename header)
const rawName = req.headers['x-filename']; const rawName = req.headers['x-filename'];
const originalName = rawName const filename = rawName
? decodeURIComponent(rawName).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, 200) ? decodeURIComponent(rawName).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, 200)
: `presentation.${ext}`; : `presentation.${ext}`;
const filename = `${room.uid}_${Date.now()}.${ext}`; // Each room gets its own folder: uploads/presentations/{roomUID}/
const filepath = path.join(presentationsDir, filename); const roomDir = path.join(presentationsDir, room.uid);
if (!fs.existsSync(roomDir)) fs.mkdirSync(roomDir, { recursive: true });
const filepath = path.join(roomDir, filename);
// S8: defense-in-depth path traversal check
if (!path.resolve(filepath).startsWith(roomDir + path.sep)) {
return res.status(400).json({ error: 'Invalid filename' });
}
// Remove old presentation file if exists // Remove old presentation file if exists
if (room.presentation_file) { if (room.presentation_file) {
// S8: defense-in-depth path traversal check const oldPath = path.resolve(roomDir, room.presentation_file);
const oldPath = path.resolve(presentationsDir, room.presentation_file); if (oldPath.startsWith(roomDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
if (oldPath.startsWith(presentationsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
} }
fs.writeFileSync(filepath, buffer); fs.writeFileSync(filepath, buffer);
await db.run('UPDATE rooms SET presentation_file = ?, presentation_name = ?, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [filename, originalName, req.params.uid]); await db.run('UPDATE rooms SET presentation_file = ?, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [filename, req.params.uid]);
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]); const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
res.json({ room: updated }); res.json({ room: updated });
} catch (err) { } catch (err) {
@@ -719,11 +868,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) {
@@ -732,4 +884,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;
+7 -2
View File
@@ -16,6 +16,9 @@ import Admin from './pages/Admin';
import GuestJoin from './pages/GuestJoin'; import GuestJoin from './pages/GuestJoin';
import FederationInbox from './pages/FederationInbox'; import FederationInbox from './pages/FederationInbox';
import FederatedRoomDetail from './pages/FederatedRoomDetail'; import FederatedRoomDetail from './pages/FederatedRoomDetail';
import Calendar from './pages/Calendar';
import OAuthCallback from './pages/OAuthCallback';
import NotFound from './pages/NotFound';
export default function App() { export default function App() {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@@ -49,11 +52,13 @@ 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="/oauth/callback" element={<OAuthCallback />} />
<Route path="/join/:uid" element={<GuestJoin />} /> <Route path="/join/:uid" element={<GuestJoin />} />
{/* Protected routes */} {/* Protected routes */}
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}> <Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/calendar" element={<Calendar />} />
<Route path="/rooms/:uid" element={<RoomDetail />} /> <Route path="/rooms/:uid" element={<RoomDetail />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} /> <Route path="/admin" element={<Admin />} />
@@ -61,8 +66,8 @@ export default function App() {
<Route path="/federation/rooms/:id" element={<FederatedRoomDetail />} /> <Route path="/federation/rooms/:id" element={<FederatedRoomDetail />} />
</Route> </Route>
{/* Catch all */} {/* 404 */}
<Route path="*" element={<Navigate to="/" />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
); );
} }
+261
View File
@@ -0,0 +1,261 @@
import { BarChart3, Trash2, Clock, Users, MessageSquare, Video, Mic, ChevronDown, ChevronUp, Hand, BarChart2, Download } from 'lucide-react';
import { useState } from 'react';
import api from '../services/api';
import { useLanguage } from '../contexts/LanguageContext';
import toast from 'react-hot-toast';
export default function AnalyticsList({ analytics, onRefresh, isOwner = true }) {
const [loading, setLoading] = useState({});
const [expanded, setExpanded] = useState({});
const [exportMenu, setExportMenu] = useState({});
const { t, language } = useLanguage();
const formatDate = (dateStr) => {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatDurationSec = (sec) => {
if (!sec || sec <= 0) return '0m';
const minutes = Math.floor(sec / 60);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
const secs = sec % 60;
if (hours > 0) return `${hours}h ${mins}m`;
if (minutes > 0) return `${mins}m ${secs}s`;
return `${secs}s`;
};
const handleDelete = async (id) => {
if (!confirm(t('analytics.deleteConfirm'))) return;
setLoading(prev => ({ ...prev, [id]: 'deleting' }));
try {
await api.delete(`/analytics/${id}`);
toast.success(t('analytics.deleted'));
onRefresh?.();
} catch {
toast.error(t('analytics.deleteFailed'));
} finally {
setLoading(prev => ({ ...prev, [id]: null }));
}
};
const toggleExpand = (id) => {
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
};
const toggleExportMenu = (id) => {
setExportMenu(prev => ({ ...prev, [id]: !prev[id] }));
};
const handleExport = async (id, format) => {
setExportMenu(prev => ({ ...prev, [id]: false }));
setLoading(prev => ({ ...prev, [id]: 'exporting' }));
try {
const response = await api.get(`/analytics/${id}/export/${format}`, { responseType: 'blob' });
const disposition = response.headers['content-disposition'];
const match = disposition?.match(/filename="?([^"]+)"?/);
const filename = match?.[1] || `analytics.${format}`;
const url = window.URL.createObjectURL(response.data);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
toast.success(t('analytics.exportSuccess'));
} catch {
toast.error(t('analytics.exportFailed'));
} finally {
setLoading(prev => ({ ...prev, [id]: null }));
}
};
// Extract user summary from BBB learning analytics callback data
// Payload: { meeting_id, data: { duration, start, finish, attendees: [{ name, moderator, duration, engagement: { chats, talks, raisehand, emojis, poll_votes, talk_time } }] } }
const getUserSummary = (data) => {
const attendees = data?.data?.attendees;
if (!Array.isArray(attendees)) return [];
return attendees.map(a => ({
name: a.name || '—',
isModerator: !!a.moderator,
duration: a.duration || 0,
talkTime: a.engagement?.talk_time || 0,
chats: a.engagement?.chats || 0,
talks: a.engagement?.talks || 0,
raiseHand: a.engagement?.raisehand || 0,
emojis: a.engagement?.emojis || 0,
pollVotes: a.engagement?.poll_votes || 0,
}));
};
const getMeetingSummary = (data) => ({
duration: data?.data?.duration || 0,
start: data?.data?.start || null,
finish: data?.data?.finish || null,
files: data?.data?.files || [],
polls: data?.data?.polls || [],
});
if (!analytics || analytics.length === 0) {
return (
<div className="text-center py-12">
<BarChart3 size={48} className="mx-auto text-th-text-s/40 mb-3" />
<p className="text-th-text-s text-sm">{t('analytics.noData')}</p>
</div>
);
}
return (
<div className="space-y-3">
{analytics.map(entry => {
const users = getUserSummary(entry.data);
const meeting = getMeetingSummary(entry.data);
const isExpanded = expanded[entry.id];
const totalParticipants = users.length;
const totalMessages = users.reduce((sum, u) => sum + u.chats, 0);
return (
<div key={entry.id} className="card p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-medium text-th-text truncate">
{entry.meetingName || entry.meetingId}
</h4>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-th-text-s">
<span className="flex items-center gap-1">
<Clock size={12} />
{meeting.start ? formatDate(meeting.start) : formatDate(entry.createdAt)}
</span>
<span className="flex items-center gap-1">
<BarChart2 size={12} />
{formatDurationSec(meeting.duration)}
</span>
<span className="flex items-center gap-1">
<Users size={12} />
{totalParticipants} {t('analytics.participants')}
</span>
<span className="flex items-center gap-1">
<MessageSquare size={12} />
{totalMessages} {t('analytics.messages')}
</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<div className="relative">
<button
onClick={() => toggleExportMenu(entry.id)}
disabled={loading[entry.id] === 'exporting'}
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-text transition-colors"
title={t('analytics.export')}
>
<Download size={16} />
</button>
{exportMenu[entry.id] && (
<div className="absolute right-0 top-full mt-1 bg-th-bg border border-th-border rounded-lg shadow-lg z-10 min-w-[120px] py-1">
<button
onClick={() => handleExport(entry.id, 'csv')}
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
CSV
</button>
<button
onClick={() => handleExport(entry.id, 'xlsx')}
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
Excel (.xlsx)
</button>
<button
onClick={() => handleExport(entry.id, 'pdf')}
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
PDF
</button>
</div>
)}
</div>
<button
onClick={() => toggleExpand(entry.id)}
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-text transition-colors"
title={isExpanded ? t('analytics.collapse') : t('analytics.expand')}
>
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{isOwner && (
<button
onClick={() => handleDelete(entry.id)}
disabled={loading[entry.id] === 'deleting'}
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors"
title={t('common.delete')}
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
{isExpanded && users.length > 0 && (
<div className="mt-4 border-t border-th-border pt-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-th-text-s border-b border-th-border">
<th className="pb-2 pr-4 font-medium">{t('analytics.userName')}</th>
<th className="pb-2 pr-4 font-medium">{t('analytics.role')}</th>
<th className="pb-2 pr-4 font-medium">
<span className="flex items-center gap-1"><Clock size={11} />{t('analytics.duration')}</span>
</th>
<th className="pb-2 pr-4 font-medium">
<span className="flex items-center gap-1"><Mic size={11} />{t('analytics.talkTime')}</span>
</th>
<th className="pb-2 pr-4 font-medium">
<span className="flex items-center gap-1"><MessageSquare size={11} />{t('analytics.messages')}</span>
</th>
<th className="pb-2 pr-4 font-medium">
<span className="flex items-center gap-1"><Hand size={11} />{t('analytics.raiseHand')}</span>
</th>
<th className="pb-2 font-medium">{t('analytics.reactions')}</th>
</tr>
</thead>
<tbody>
{users.map((u, i) => (
<tr key={i} className="border-b border-th-border/50 last:border-0">
<td className="py-2 pr-4 text-th-text font-medium">{u.name}</td>
<td className="py-2 pr-4">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${
u.isModerator
? 'bg-th-accent/15 text-th-accent'
: 'bg-th-bg-s text-th-text-s'
}`}>
{u.isModerator ? t('analytics.moderator') : t('analytics.viewer')}
</span>
</td>
<td className="py-2 pr-4 text-th-text-s">{formatDurationSec(u.duration)}</td>
<td className="py-2 pr-4 text-th-text-s">{formatDurationSec(u.talkTime)}</td>
<td className="py-2 pr-4 text-th-text-s">{u.chats}</td>
<td className="py-2 pr-4 text-th-text-s">{u.raiseHand}</td>
<td className="py-2 text-th-text-s">{u.emojis}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
})}
</div>
);
}
+12 -11
View File
@@ -2,24 +2,25 @@ import { Video } from 'lucide-react';
import { useBranding } from '../contexts/BrandingContext'; import { useBranding } from '../contexts/BrandingContext';
const sizes = { const sizes = {
sm: { box: 'w-8 h-8', rounded: 'rounded-lg', icon: 16, text: 'text-lg' }, sm: { box: 'w-8 h-8', h: 'h-8', maxW: 'max-w-32', rounded: 'rounded-lg', icon: 16, text: 'text-lg' },
md: { box: 'w-9 h-9', rounded: 'rounded-lg', icon: 20, text: 'text-xl' }, md: { box: 'w-9 h-9', h: 'h-12', maxW: 'max-w-40', rounded: 'rounded-lg', icon: 20, text: 'text-xl' },
lg: { box: 'w-10 h-10', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' }, lg: { box: 'w-10 h-10', h: 'h-10', maxW: 'max-w-48', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' },
}; };
export default function BrandLogo({ size = 'md', className = '' }) { export default function BrandLogo({ size = 'md', className = '' }) {
const { appName, hasLogo, logoUrl } = useBranding(); const { appName, hasLogo, logoUrl, hideAppName } = useBranding();
const s = sizes[size] || sizes.md; const s = sizes[size] || sizes.md;
if (hasLogo && logoUrl) { if (hasLogo && logoUrl) {
// When the app name is hidden, let the logo expand to its natural aspect
// ratio (w-auto) rather than being clamped into a tiny square.
const imgClass = hideAppName
? `${s.h} w-auto ${s.maxW} ${s.rounded} object-contain`
: `${s.box} ${s.rounded} object-contain`;
return ( return (
<div className={`flex items-center gap-2.5 ${className}`}> <div className={`flex items-center ${hideAppName ? 'justify-center' : 'gap-2.5'} ${className}`}>
<img <img src={logoUrl} alt={appName} className={imgClass} />
src={logoUrl} {!hideAppName && <span className={`${s.text} font-bold gradient-text`}>{appName}</span>}
alt={appName}
className={`${s.box} ${s.rounded} object-contain`}
/>
<span className={`${s.text} font-bold gradient-text`}>{appName}</span>
</div> </div>
); );
} }
+105
View File
@@ -0,0 +1,105 @@
import { useEffect, useRef } from 'react';
import flatpickr from 'flatpickr';
import 'flatpickr/dist/flatpickr.min.css';
import { German } from 'flatpickr/dist/l10n/de.js';
import { Calendar as CalendarIcon, Clock } from 'lucide-react';
// Register German as default locale
flatpickr.localize(German);
/**
* Themed DateTimePicker using flatpickr.
* flatpickr uses position:fixed for its calendar dropdown — no overflow,
* no scroll issues, no Popper.js needed. CSS variables drive all theming.
*
* Props:
* value local datetime string 'YYYY-MM-DDTHH:mm' (or '')
* onChange (localDatetimeString) => void
* label string
* required bool
* minDate Date | null
* icon 'calendar' (default) | 'clock'
*/
export default function DateTimePicker({
value,
onChange,
label,
required = false,
minDate = null,
icon = 'calendar',
}) {
const inputRef = useRef(null);
const fpRef = useRef(null);
// Always keep a current ref to onChange so flatpickr's closure never goes stale
const onChangeRef = useRef(onChange);
useEffect(() => { onChangeRef.current = onChange; });
useEffect(() => {
if (!inputRef.current) return;
fpRef.current = flatpickr(inputRef.current, {
enableTime: true,
time_24hr: true,
dateFormat: 'd.m.Y H:i',
minuteIncrement: 15,
minDate: minDate || undefined,
defaultDate: value || undefined,
appendTo: document.body, // portal to body → never clipped
static: false,
onChange: (selectedDates) => {
if (selectedDates.length === 0) { onChangeRef.current(''); return; }
const d = selectedDates[0];
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const h = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
onChangeRef.current(`${y}-${mo}-${day}T${h}:${mi}`);
},
});
return () => fpRef.current?.destroy();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Sync value from outside
useEffect(() => {
if (!fpRef.current) return;
const current = fpRef.current.selectedDates[0];
const incoming = value ? new Date(value) : null;
// Only setDate if actually different (avoid loops)
if (incoming && (!current || Math.abs(incoming - current) > 60000)) {
fpRef.current.setDate(incoming, false);
} else if (!incoming && current) {
fpRef.current.clear(false);
}
}, [value]);
// Sync minDate
useEffect(() => {
if (!fpRef.current) return;
fpRef.current.set('minDate', minDate || undefined);
}, [minDate]);
const Icon = icon === 'clock' ? Clock : CalendarIcon;
return (
<div>
{label && (
<label className="block text-sm font-medium text-th-text mb-1.5">
{label}{required && ' *'}
</label>
)}
<div className="relative">
<Icon size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none z-1" />
<input
ref={inputRef}
type="text"
required={required}
readOnly
placeholder="Datum & Uhrzeit wählen…"
className="input-field pl-9 text-sm w-full cursor-pointer"
/>
</div>
</div>
);
}
+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}
+45 -4
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} overflow-hidden`}> className="fixed inset-0 bg-black/60 backdrop-blur-xs"
onClick={onClose}
aria-hidden="true"
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
className={`relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full ${maxWidth} focus:outline-hidden`}
>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-th-border"> <div className="flex items-center justify-between px-6 py-4 border-b border-th-border rounded-t-2xl">
<h2 className="text-lg font-semibold text-th-text">{title}</h2> <h2 id={titleId} className="text-lg font-semibold text-th-text">{title}</h2>
<button <button
type="button"
onClick={onClose} onClick={onClose}
aria-label={t('common.close')}
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors" className="p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
> >
<X size={20} /> <X size={20} />
+10 -2
View File
@@ -4,6 +4,7 @@ import { useLanguage } from '../contexts/LanguageContext';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import api from '../services/api'; import api from '../services/api';
import NotificationBell from './NotificationBell';
export default function Navbar({ onMenuClick }) { export default function Navbar({ onMenuClick }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
@@ -37,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} />
@@ -51,10 +53,16 @@ export default function Navbar({ onMenuClick }) {
{/* Right section */} {/* Right section */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Notification bell */}
<NotificationBell />
{/* User dropdown */} {/* User dropdown */}
<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
@@ -64,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"
/> />
) : ( ) : (
+197
View File
@@ -0,0 +1,197 @@
import { useRef, useState, useEffect } from 'react';
import { Bell, BellOff, CheckCheck, ExternalLink, Trash2, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useNotifications } from '../contexts/NotificationContext';
import { useLanguage } from '../contexts/LanguageContext';
function timeAgo(dateStr, lang) {
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60_000);
const h = Math.floor(diff / 3_600_000);
const d = Math.floor(diff / 86_400_000);
if (lang === 'de') {
if (m < 1) return 'gerade eben';
if (m < 60) return `vor ${m} Min.`;
if (h < 24) return `vor ${h} Std.`;
return `vor ${d} Tag${d !== 1 ? 'en' : ''}`;
}
if (m < 1) return 'just now';
if (m < 60) return `${m}m ago`;
if (h < 24) return `${h}h ago`;
return `${d}d ago`;
}
function notificationIcon(type) {
switch (type) {
case 'room_share_added': return '🔗';
case 'room_share_removed': return '🚫';
case 'federation_invite_received': return '📩';
default: return '🔔';
}
}
function notificationSubtitle(n, t, lang) {
switch (n.type) {
case 'room_share_added':
return n.body
? (lang === 'de' ? `Geteilt von ${n.body}` : `Shared by ${n.body}`)
: t('notifications.roomShareAdded');
case 'room_share_removed':
return t('notifications.roomShareRemoved');
case 'federation_invite_received':
return n.body
? (lang === 'de' ? `Raum: ${n.body}` : `Room: ${n.body}`)
: t('notifications.federationInviteReceived');
default:
return n.body || '';
}
}
export default function NotificationBell() {
const { notifications, unreadCount, markRead, markAllRead, deleteNotification, clearAll } = useNotifications();
const { t, language } = useLanguage();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const containerRef = useRef(null);
useEffect(() => {
function handleOutsideClick(e) {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleOutsideClick);
return () => document.removeEventListener('mousedown', handleOutsideClick);
}, []);
const handleNotificationClick = async (n) => {
if (!n.read) await markRead(n.id);
if (n.link) navigate(n.link);
setOpen(false);
};
const handleDelete = async (e, id) => {
e.stopPropagation();
await deleteNotification(id);
};
const recent = notifications.slice(0, 20);
return (
<div className="relative" ref={containerRef}>
{/* Bell button */}
<button
onClick={() => setOpen(prev => !prev)}
className="relative p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
aria-label={unreadCount > 0
? `${t('notifications.bell')} (${unreadCount})`
: t('notifications.bell')}
aria-haspopup="true"
aria-expanded={open}
title={t('notifications.bell')}
>
<Bell size={20} />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 min-w-[16px] h-4 px-0.5 flex items-center justify-center rounded-full bg-th-accent text-th-accent-t text-[10px] font-bold leading-none">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* Dropdown */}
{open && (
<div className="absolute right-0 mt-2 w-80 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden z-50">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-th-border">
<div className="flex items-center gap-2">
<Bell size={16} className="text-th-accent" />
<span className="text-sm font-semibold text-th-text">{t('notifications.bell')}</span>
{unreadCount > 0 && (
<span className="px-1.5 py-0.5 rounded-full bg-th-accent text-th-accent-t text-xs font-bold">
{unreadCount}
</span>
)}
</div>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
title={t('notifications.markAllRead')}
>
<CheckCheck size={14} />
{t('notifications.markAllRead')}
</button>
)}
{notifications.length > 0 && (
<button
onClick={clearAll}
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-error transition-colors"
title={t('notifications.clearAll')}
>
<Trash2 size={13} />
{t('notifications.clearAll')}
</button>
)}
</div>
</div>
{/* List */}
<div className="max-h-80 overflow-y-auto">
{recent.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-th-text-s gap-2">
<BellOff size={24} />
<span className="text-sm">{t('notifications.noNotifications')}</span>
</div>
) : (
<ul>
{recent.map(n => (
<li
key={n.id}
onClick={() => handleNotificationClick(n)}
className={`group flex items-start gap-3 px-4 py-3 cursor-pointer transition-colors border-b border-th-border/50 last:border-0
${n.read ? 'hover:bg-th-hover' : 'bg-th-accent/5 hover:bg-th-accent/10'}`}
>
{/* Icon */}
<span className="text-lg shrink-0 mt-0.5">{notificationIcon(n.type)}</span>
{/* Content */}
<div className="flex-1 min-w-0">
<p className={`text-sm truncate ${n.read ? 'text-th-text-s' : 'text-th-text font-medium'}`}>
{n.title}
</p>
<p className="text-xs text-th-text-s truncate">
{notificationSubtitle(n, t, language)}
</p>
<p className="text-xs text-th-text-s/70 mt-0.5">
{timeAgo(n.created_at, language)}
</p>
</div>
{/* Right side: unread dot, link icon, delete button */}
<div className="flex flex-col items-end gap-1 shrink-0">
{!n.read && (
<span className="w-2 h-2 rounded-full bg-th-accent mt-1" />
)}
{n.link && (
<ExternalLink size={12} className="text-th-text-s/50" />
)}
<button
onClick={(e) => handleDelete(e, n.id)}
className="opacity-0 group-hover:opacity-100 p-0.5 rounded-sm hover:text-th-error transition-all text-th-text-s/50"
aria-label={t('notifications.delete')}
title={t('notifications.delete')}
>
<X size={13} />
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
)}
</div>
);
}
+3 -3
View File
@@ -122,9 +122,9 @@ export default function RecordingList({ recordings, onRefresh }) {
href={format.url} href={format.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-th-accent/10 text-th-accent text-xs font-medium hover:bg-th-accent/20 transition-colors" className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-th-accent/10 text-th-accent text-sm font-medium hover:bg-th-accent/20 transition-colors"
> >
<Play size={12} /> <Play size={14} />
{format.type === 'presentation' ? t('recordings.presentation') : format.type} {format.type === 'presentation' ? t('recordings.presentation') : format.type}
</a> </a>
))} ))}
@@ -133,7 +133,7 @@ export default function RecordingList({ recordings, onRefresh }) {
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button <button
onClick={() => handlePublish(rec.recordID, !rec.published)} onClick={() => handlePublish(rec.recordID, !rec.published)}
disabled={loading[rec.recordID] === 'publishing'} disabled={loading[rec.recordID] === 'publishing'}
+71 -7
View File
@@ -1,7 +1,8 @@
import { Users, Play, Trash2, Radio, Loader2, Share2 } from 'lucide-react'; import { Users, Play, Trash2, Radio, Loader2, Share2, Copy, Link } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import api from '../services/api'; import api from '../services/api';
import { isCurrentThemeDark } from '../themes';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -10,6 +11,24 @@ export default function RoomCard({ room, onDelete }) {
const { t } = useLanguage(); const { t } = useLanguage();
const [status, setStatus] = useState({ running: false, participantCount: 0 }); const [status, setStatus] = useState({ running: false, participantCount: 0 });
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
const [showCopyMenu, setShowCopyMenu] = useState(false);
const copyMenuRef = useRef(null);
useEffect(() => {
const handleClickOutside = (e) => {
if (copyMenuRef.current && !copyMenuRef.current.contains(e.target)) {
setShowCopyMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const copyToClipboard = (url) => {
navigator.clipboard.writeText(url);
toast.success(t('room.linkCopied'));
setShowCopyMenu(false);
};
useEffect(() => { useEffect(() => {
const checkStatus = async () => { const checkStatus = async () => {
@@ -25,8 +44,21 @@ export default function RoomCard({ room, onDelete }) {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [room.uid]); }, [room.uid]);
const openRoom = () => navigate(`/rooms/${room.uid}`);
return ( return (
<div className="card-hover group p-5 cursor-pointer" onClick={() => navigate(`/rooms/${room.uid}`)}> <div
role="link"
tabIndex={0}
aria-label={room.name}
onClick={openRoom}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openRoom(); }
}}
className="card-hover group p-5 cursor-pointer focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2"
style={{ '--tw-ring-offset-color': 'var(--bg-primary)' }}
>
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -62,25 +94,26 @@ export default function RoomCard({ room, onDelete }) {
<span>Max: {room.max_participants}</span> <span>Max: {room.max_participants}</span>
)} )}
{room.access_code && ( {room.access_code && (
<span className="px-1.5 py-0.5 bg-th-warning/15 text-th-warning rounded text-[10px] font-medium"> <span className="px-1.5 py-0.5 bg-th-warning/15 text-th-warning rounded-sm text-[10px] font-medium">
{t('common.protected')} {t('common.protected')}
</span> </span>
)} )}
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2 pt-3 border-t border-th-border"> <div className="flex items-center gap-2 pt-3 border-t border-th-border" onClick={(e) => e.stopPropagation()}>
<button <button
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
setStarting(true); setStarting(true);
try { try {
if (status.running) { if (status.running) {
const data = room.access_code ? { access_code: prompt(t('room.enterAccessCode')) } : {}; const data = { dark_mode: isCurrentThemeDark() };
if (room.access_code) data.access_code = prompt(t('room.enterAccessCode'));
const res = await api.post(`/rooms/${room.uid}/join`, data); const res = await api.post(`/rooms/${room.uid}/join`, data);
if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank'); if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank');
} else { } else {
const res = await api.post(`/rooms/${room.uid}/start`); const res = await api.post(`/rooms/${room.uid}/start`, { dark_mode: isCurrentThemeDark() });
if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank'); if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank');
toast.success(t('room.meetingStarted')); toast.success(t('room.meetingStarted'));
setTimeout(() => { setTimeout(() => {
@@ -99,10 +132,41 @@ export default function RoomCard({ room, onDelete }) {
{starting ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />} {starting ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
{status.running ? t('room.join') : t('room.startMeeting')} {status.running ? t('room.join') : t('room.startMeeting')}
</button> </button>
<div className="relative" ref={copyMenuRef}>
<button
onClick={(e) => { e.stopPropagation(); setShowCopyMenu(v => !v); }}
className="btn-ghost text-xs py-1.5 px-2"
aria-label={t('room.copyLink')}
aria-haspopup="menu"
aria-expanded={showCopyMenu}
title={t('room.copyLink')}
>
<Copy size={14} />
</button>
{showCopyMenu && (
<div className="absolute bottom-full right-0 mb-2 bg-th-surface border border-th-border rounded-lg shadow-lg z-50 min-w-[150px] py-1">
<button
onClick={(e) => { e.stopPropagation(); copyToClipboard(`${window.location.origin}/rooms/${room.uid}`); }}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
>
<Link size={12} />
{t('room.copyRoomLink')}
</button>
<button
onClick={(e) => { e.stopPropagation(); copyToClipboard(`${window.location.origin}/join/${room.uid}`); }}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
>
<Users size={12} />
{t('room.copyGuestLink')}
</button>
</div>
)}
</div>
{onDelete && !room.shared && ( {onDelete && !room.shared && (
<button <button
onClick={(e) => { e.stopPropagation(); onDelete(room); }} onClick={(e) => { e.stopPropagation(); onDelete(room); }}
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error" className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"
aria-label={t('common.delete')}
title={t('common.delete')} title={t('common.delete')}
> >
<Trash2 size={14} /> <Trash2 size={14} />
+48 -5
View File
@@ -1,8 +1,9 @@
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { LayoutDashboard, Settings, Shield, X, Palette, Globe } from 'lucide-react'; import { LayoutDashboard, Settings, Shield, X, Palette, Globe, CalendarDays, FileText, Lock } from 'lucide-react';
import BrandLogo from './BrandLogo'; import BrandLogo from './BrandLogo';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { useBranding } from '../contexts/BrandingContext';
import ThemeSelector from './ThemeSelector'; import ThemeSelector from './ThemeSelector';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import api from '../services/api'; import api from '../services/api';
@@ -10,6 +11,7 @@ import api from '../services/api';
export default function Sidebar({ open, onClose }) { export default function Sidebar({ open, onClose }) {
const { user } = useAuth(); const { user } = useAuth();
const { t } = useLanguage(); const { t } = useLanguage();
const { imprintUrl, privacyUrl } = useBranding();
const [themeOpen, setThemeOpen] = useState(false); const [themeOpen, setThemeOpen] = useState(false);
const [federationCount, setFederationCount] = useState(0); const [federationCount, setFederationCount] = useState(0);
@@ -30,6 +32,7 @@ export default function Sidebar({ open, onClose }) {
const navItems = [ const navItems = [
{ to: '/dashboard', icon: LayoutDashboard, label: t('nav.dashboard') }, { to: '/dashboard', icon: LayoutDashboard, label: t('nav.dashboard') },
{ to: '/calendar', icon: CalendarDays, label: t('nav.calendar') },
{ to: '/federation/inbox', icon: Globe, label: t('nav.federation'), badge: federationCount }, { to: '/federation/inbox', icon: Globe, label: t('nav.federation'), badge: federationCount },
{ to: '/settings', icon: Settings, label: t('nav.settings') }, { to: '/settings', icon: Settings, label: t('nav.settings') },
]; ];
@@ -40,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'
}`; }`;
@@ -54,9 +57,10 @@ export default function Sidebar({ open, onClose }) {
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Logo */} {/* Logo */}
<div className="flex items-center justify-between h-16 px-4 border-b border-th-border"> <div className="flex items-center justify-between h-16 px-4 border-b border-th-border">
<BrandLogo size="sm" /> <BrandLogo size="md" className="flex-1 min-w-0" />
<button <button
onClick={onClose} onClick={onClose}
aria-label={t('common.close')}
className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors" className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
> >
<X size={18} /> <X size={18} />
@@ -103,16 +107,55 @@ 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" 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?.display_name || user?.name)?.[0]?.toUpperCase() || '?'} {user?.avatar_image ? (
<img
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
alt=""
className="w-full h-full object-cover"
/>
) : (
(user?.display_name || user?.name)?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) || '?'
)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-th-text truncate">{user?.display_name || user?.name}</p> <p className="text-sm font-medium text-th-text truncate">{user?.display_name || user?.name}</p>
<p className="text-xs text-th-text-s truncate">@{user?.name}</p> <p className="text-xs text-th-text-s truncate">@{user?.name}</p>
</div> </div>
</div> </div>
{/* Imprint / Privacy Policy links */}
{(imprintUrl || privacyUrl) && (
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-th-border/60">
{imprintUrl && (
<a
href={imprintUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
>
<FileText size={11} />
{t('nav.imprint')}
</a>
)}
{imprintUrl && privacyUrl && (
<span className="text-th-border text-xs">·</span>
)}
{privacyUrl && (
<a
href={privacyUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
>
<Lock size={11} />
{t('nav.privacy')}
</a>
)}
</div>
)}
</div> </div>
</div> </div>
</aside> </aside>
+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} />
+36 -4
View File
@@ -23,13 +23,25 @@ export function AuthProvider({ children }) {
const login = useCallback(async (email, password) => { const login = useCallback(async (email, password) => {
const res = await api.post('/auth/login', { email, password }); const res = await api.post('/auth/login', { email, password });
if (res.data.requires2FA) {
return { requires2FA: true, tempToken: res.data.tempToken };
}
localStorage.setItem('token', res.data.token); localStorage.setItem('token', res.data.token);
setUser(res.data.user); setUser(res.data.user);
return res.data.user; return res.data.user;
}, []); }, []);
const register = useCallback(async (username, displayName, email, password) => { const verify2FA = useCallback(async (tempToken, code) => {
const res = await api.post('/auth/register', { username, display_name: displayName, email, password }); const res = await api.post('/auth/login/2fa', { tempToken, code });
localStorage.setItem('token', res.data.token);
setUser(res.data.user);
return res.data.user;
}, []);
const register = useCallback(async (username, displayName, email, password, inviteToken) => {
const payload = { username, display_name: displayName, email, password };
if (inviteToken) payload.invite_token = inviteToken;
const res = await api.post('/auth/register', payload);
if (res.data.needsVerification) { if (res.data.needsVerification) {
return { needsVerification: true }; return { needsVerification: true };
} }
@@ -39,21 +51,41 @@ export function AuthProvider({ children }) {
}, []); }, []);
const logout = useCallback(async () => { const logout = useCallback(async () => {
let keycloakLogoutUrl = null;
try { try {
await api.post('/auth/logout'); const res = await api.post('/auth/logout');
keycloakLogoutUrl = res.data?.keycloakLogoutUrl || null;
} catch { } catch {
// ignore — token is removed locally regardless // ignore — token is removed locally regardless
} }
localStorage.removeItem('token'); localStorage.removeItem('token');
if (keycloakLogoutUrl) {
// Redirect to Keycloak BEFORE clearing React state to avoid
// flash-rendering the login page while the redirect is pending.
window.location.href = keycloakLogoutUrl;
return;
}
setUser(null); setUser(null);
}, []); }, []);
const loginWithOAuth = useCallback(async (token) => {
localStorage.setItem('token', token);
try {
const res = await api.get('/auth/me');
setUser(res.data.user);
return res.data.user;
} catch (err) {
localStorage.removeItem('token');
throw err;
}
}, []);
const updateUser = useCallback((updatedUser) => { const updateUser = useCallback((updatedUser) => {
setUser(updatedUser); setUser(updatedUser);
}, []); }, []);
return ( return (
<AuthContext.Provider value={{ user, loading, login, register, logout, updateUser }}> <AuthContext.Provider value={{ user, loading, login, verify2FA, register, logout, loginWithOAuth, updateUser }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
+3
View File
@@ -11,6 +11,9 @@ export function BrandingProvider({ children }) {
hasLogo: false, hasLogo: false,
logoUrl: null, logoUrl: null,
defaultTheme: null, defaultTheme: null,
imprintUrl: null,
privacyUrl: null,
hideAppName: false,
}); });
const fetchBranding = useCallback(async () => { const fetchBranding = useCallback(async () => {
+189
View File
@@ -0,0 +1,189 @@
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
import toast from 'react-hot-toast';
import { useAuth } from './AuthContext';
import api from '../services/api';
// Lazily created Audio instance — reused across calls to avoid memory churn
let _audio = null;
let _audioUnlocked = false;
function getAudio() {
if (!_audio) {
_audio = new Audio('/sounds/notification.mp3');
_audio.volume = 0.5;
}
return _audio;
}
/** Called once on the first user gesture to silently play→pause the element,
* which "unlocks" it so later timer-based .play() calls are not blocked. */
function unlockAudio() {
if (_audioUnlocked) return;
_audioUnlocked = true;
const audio = getAudio();
audio.muted = true;
audio.play().then(() => {
audio.pause();
audio.muted = false;
audio.currentTime = 0;
}).catch(() => {
audio.muted = false;
});
}
function playNotificationSound() {
try {
const audio = getAudio();
audio.currentTime = 0;
audio.play().catch(() => {
// Autoplay still blocked — silent fail
});
} catch {
// Ignore any other errors (e.g. unsupported format)
}
}
const NotificationContext = createContext();
export function NotificationProvider({ children }) {
const { user } = useAuth();
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const activeUserId = useRef(null);
// Track seen IDs to detect genuinely new arrivals and show toasts
const seenIds = useRef(new Set());
const initialized = useRef(false);
const fetch = useCallback(async () => {
const requestUserId = user?.id;
if (!requestUserId) return;
try {
const res = await api.get('/notifications');
// Ignore stale responses that arrived after logout or account switch.
if (activeUserId.current !== requestUserId) return;
const incoming = res.data.notifications || [];
setNotifications(incoming);
setUnreadCount(res.data.unreadCount || 0);
// First fetch: just seed the seen-set without toasting
if (!initialized.current) {
incoming.forEach(n => seenIds.current.add(n.id));
initialized.current = true;
return;
}
// Subsequent fetches: toast new unread notifications
const newItems = incoming.filter(n => !n.read && !seenIds.current.has(n.id));
if (newItems.length > 0) {
playNotificationSound();
}
newItems.forEach(n => {
seenIds.current.add(n.id);
const icon = notificationIcon(n.type);
toast(`${icon} ${n.title}`, { duration: 5000 });
// Browser notification for calendar reminders
if (n.type === 'calendar_reminder' && 'Notification' in window) {
const fire = () => new Notification(n.title, { body: n.body || '', icon: '/favicon.ico' });
if (Notification.permission === 'granted') {
fire();
} else if (Notification.permission !== 'denied') {
Notification.requestPermission().then(p => { if (p === 'granted') fire(); });
}
}
});
} catch {
/* silent server may not be reachable */
}
}, [user]);
// Unlock audio playback only for authenticated sessions.
// This avoids any audio interaction while logged out (e.g. anonymous/incognito tabs).
useEffect(() => {
if (!user?.id) return;
const events = ['click', 'keydown', 'pointerdown'];
const handler = () => {
unlockAudio();
events.forEach(e => window.removeEventListener(e, handler));
};
events.forEach(e => window.addEventListener(e, handler, { once: true }));
return () => events.forEach(e => window.removeEventListener(e, handler));
}, [user?.id]);
useEffect(() => {
activeUserId.current = user?.id ?? null;
if (!user) {
_audioUnlocked = false;
setNotifications([]);
setUnreadCount(0);
seenIds.current = new Set();
initialized.current = false;
return;
}
fetch();
const interval = setInterval(fetch, 30_000);
return () => clearInterval(interval);
}, [user, fetch]);
const markRead = async (id) => {
try {
await api.post(`/notifications/${id}/read`);
setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, read: 1 } : n)),
);
setUnreadCount(prev => Math.max(0, prev - 1));
} catch { /* silent */ }
};
const markAllRead = async () => {
try {
await api.post('/notifications/read-all');
setNotifications(prev => prev.map(n => ({ ...n, read: 1 })));
setUnreadCount(0);
} catch { /* silent */ }
};
const deleteNotification = async (id) => {
try {
await api.delete(`/notifications/${id}`);
setNotifications(prev => {
const removed = prev.find(n => n.id === id);
if (removed && !removed.read) setUnreadCount(c => Math.max(0, c - 1));
return prev.filter(n => n.id !== id);
});
seenIds.current.delete(id);
} catch { /* silent */ }
};
const clearAll = async () => {
try {
await api.delete('/notifications/all');
setNotifications([]);
setUnreadCount(0);
seenIds.current = new Set();
} catch { /* silent */ }
};
return (
<NotificationContext.Provider value={{ notifications, unreadCount, markRead, markAllRead, deleteNotification, clearAll, refresh: fetch }}>
{children}
</NotificationContext.Provider>
);
}
export function useNotifications() {
const ctx = useContext(NotificationContext);
if (!ctx) throw new Error('useNotifications must be used within NotificationProvider');
return ctx;
}
function notificationIcon(type) {
switch (type) {
case 'room_share_added': return '🔗';
case 'room_share_removed': return '🚫';
case 'federation_invite_received': return '📩';
case 'calendar_reminder': return '🔔';
default: return '🔔';
}
}
+334 -40
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",
@@ -32,7 +34,10 @@
"appearance": "Darstellung", "appearance": "Darstellung",
"changeTheme": "Theme ändern", "changeTheme": "Theme ändern",
"navigation": "Navigation", "navigation": "Navigation",
"federation": "Einladungen" "calendar": "Kalender",
"federation": "Einladungen",
"imprint": "Impressum",
"privacy": "Datenschutz"
}, },
"auth": { "auth": {
"login": "Anmelden", "login": "Anmelden",
@@ -59,58 +64,70 @@
"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.",
"emailNotVerified": "E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach.", "emailNotVerified": "E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach.",
"username": "Benutzername", "username": "Benutzername",
"usernamePlaceholder": "z.B. maxmuster", "usernamePlaceholder": "z.B. maxmuster",
"usernameHint": "Nur Buchstaben, Zahlen, _ und - erlaubt (330 Zeichen)", "usernameHint": "Nur Buchstaben, Zahlen, _ und - erlaubt (3-30 Zeichen)",
"displayName": "Anzeigename", "displayName": "Anzeigename",
"displayNamePlaceholder": "Max Mustermann", "displayNamePlaceholder": "Max Mustermann",
"usernameTaken": "Benutzername ist bereits vergeben", "usernameTaken": "Benutzername ist bereits vergeben",
"usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, _ und - enthalten (330 Zeichen)", "usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, _ und - enthalten (3-30 Zeichen)",
"usernameRequired": "Benutzername ist erforderlich", "usernameRequired": "Benutzername ist erforderlich",
"displayNameRequired": "Anzeigename ist erforderlich", "displayNameRequired": "Anzeigename ist erforderlich",
"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",
"inviteOnlyDesc": "Die Registrierung ist derzeit eingeschränkt. Sie benötigen einen Einladungslink von einem Administrator, um ein Konto zu erstellen.",
"orContinueWith": "oder weiter mit",
"loginWithOAuth": "Anmelden mit {provider}",
"registerWithOAuth": "Registrieren mit {provider}",
"backToLogin": "Zurück zum Login",
"oauthError": "Anmeldung fehlgeschlagen",
"oauthNoToken": "Kein Authentifizierungstoken erhalten.",
"oauthLoginFailed": "Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.",
"oauthRedirecting": "Du wirst angemeldet...",
"2fa": {
"title": "Zwei-Faktor-Authentifizierung",
"prompt": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
"codeLabel": "Bestätigungscode",
"verify": "Bestätigen",
"verifyFailed": "Überprüfung fehlgeschlagen",
"backToLogin": "← Zurück zum Login"
}
}, },
"home": { "home": {
"poweredBy": "Powered by BigBlueButton", "heroTitle": "Selbst gehostetes Frontend für BigBlueButton",
"heroTitle": "Meetings neu ", "heroSubtitle": "Redlight verwaltet Räume, Aufnahmen und Termine für deinen BigBlueButton-Server mit Federation zwischen Instanzen, CalDAV-Sync und SSO-Anbindung.",
"heroTitleHighlight": "definiert", "getStarted": "Konto erstellen",
"heroSubtitle": "Das moderne, selbst gehostete BigBlueButton-Frontend. Erstellen Sie Räume, verwalten Sie Aufnahmen und genießen Sie ein wunderschönes Interface mit über 15 Themes.", "features": "Funktionen",
"getStarted": "Jetzt starten",
"features": "Alles was Sie brauchen",
"featuresSubtitle": "Redlight bietet alle Funktionen, die Sie für professionelle Videokonferenzen benötigen.",
"featureVideoTitle": "Videokonferenzen",
"featureVideoDesc": "Erstellen und verwalten Sie Meetings direkt über BigBlueButton.",
"featureRoomsTitle": "Raumverwaltung", "featureRoomsTitle": "Raumverwaltung",
"featureRoomsDesc": "Unbegrenzte Räume mit individuellen Einstellungen und Zugangscodes.", "featureRoomsDesc": "Räume mit eigenen Einstellungen, Zugangscodes und geteiltem Zugriff für Kolleginnen und Kollegen.",
"featureUsersTitle": "Benutzerverwaltung",
"featureUsersDesc": "Registrierung, Login und Rollenverwaltung für Ihre Organisation.",
"featureThemesTitle": "15+ Themes",
"featureThemesDesc": "Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox und viele mehr.",
"featureRecordingsTitle": "Aufnahmen", "featureRecordingsTitle": "Aufnahmen",
"featureRecordingsDesc": "Alle Aufnahmen pro Raum einsehen, veröffentlichen oder löschen.", "featureRecordingsDesc": "Aufnahmen pro Raum einsehen, veröffentlichen oder löschen.",
"featureOpenSourceTitle": "Open Source", "featureFederationTitle": "Federation",
"featureOpenSourceDesc": "Vollständig quelloffen und selbst gehostet. Ihre Daten bleiben bei Ihnen.", "featureFederationDesc": "Nutzer anderer Redlight-Instanzen einladen und instanzübergreifende Einladungen annehmen.",
"statThemes": "Themes", "featureCalendarTitle": "Kalender",
"statRooms": "Räume", "featureCalendarDesc": "Meetings planen, per CalDAV mit Thunderbird, Apple Calendar oder DAVx⁵ synchronisieren, Erinnerungen erhalten.",
"statOpenSource": "Open Source", "featureVideoTitle": "Videokonferenzen",
"featureVideoDesc": "Meetings direkt über BigBlueButton erstellen, starten und verwalten.",
"featureAnalyticsTitle": "Learning Analytics",
"featureAnalyticsDesc": "Teilnahme- und Aktivitätsdaten aus Meetings einsehen und als CSV, Excel oder PDF exportieren.",
"footer": "© {year} Redlight. Ein Open-Source BigBlueButton Frontend." "footer": "© {year} Redlight. Ein Open-Source BigBlueButton Frontend."
}, },
"dashboard": { "dashboard": {
@@ -131,7 +148,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",
@@ -153,7 +170,9 @@
"settings": "Einstellungen", "settings": "Einstellungen",
"participants": "{count} Teilnehmer", "participants": "{count} Teilnehmer",
"copyLink": "Link kopieren", "copyLink": "Link kopieren",
"linkCopied": "Link kopiert!", "copyRoomLink": "Raum-Link",
"copyGuestLink": "Gast-Link",
"linkCopied": "Link kopiert",
"meetingDetails": "Meeting-Details", "meetingDetails": "Meeting-Details",
"meetingId": "Meeting ID", "meetingId": "Meeting ID",
"status": "Status", "status": "Status",
@@ -178,7 +197,7 @@
"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",
@@ -193,7 +212,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",
@@ -207,6 +226,11 @@
"guestModeratorPlaceholder": "Nur wenn Sie Moderator sind", "guestModeratorPlaceholder": "Nur wenn Sie Moderator sind",
"guestJoinButton": "Meeting beitreten", "guestJoinButton": "Meeting beitreten",
"guestWaitingMessage": "Das Meeting wurde noch nicht gestartet. Bitte warten Sie, bis der Moderator es startet.", "guestWaitingMessage": "Das Meeting wurde noch nicht gestartet. Bitte warten Sie, bis der Moderator es startet.",
"guestWaitingTitle": "Warte auf Meeting-Start...",
"guestWaitingHint": "Du wirst automatisch beigetreten, sobald das Meeting gestartet wird.",
"guestCancelWaiting": "Abbrechen",
"guestMeetingStartedJoining": "Meeting gestartet! Trete jetzt bei...",
"waitingToJoin": "Warten...",
"guestAccessDenied": "Zugang nicht möglich", "guestAccessDenied": "Zugang nicht möglich",
"guestNameRequired": "Name ist erforderlich", "guestNameRequired": "Name ist erforderlich",
"guestJoinFailed": "Beitritt fehlgeschlagen", "guestJoinFailed": "Beitritt fehlgeschlagen",
@@ -228,13 +252,21 @@
"presentationRemoveFailed": "Präsentation konnte nicht entfernt werden", "presentationRemoveFailed": "Präsentation konnte nicht entfernt werden",
"presentationAllowedTypes": "PDF, PPT, PPTX, ODP, DOC, DOCX · max. 50 MB", "presentationAllowedTypes": "PDF, PPT, PPTX, ODP, DOC, DOCX · max. 50 MB",
"presentationCurrent": "Aktuell:", "presentationCurrent": "Aktuell:",
"shareTitle": "Raum teilen",
"shareDescription": "Teilen Sie diesen Raum mit anderen Benutzern, damit diese ihn in ihrem Dashboard sehen und beitreten k\u00f6nnen.", "shareDescription": "Teilen Sie diesen Raum mit anderen Benutzern, damit diese ihn in ihrem Dashboard sehen und beitreten k\u00f6nnen.",
"shareSearchPlaceholder": "Benutzer suchen (Name oder E-Mail)...", "shareSearchPlaceholder": "Benutzer suchen (Name oder E-Mail)...",
"shareAdded": "Benutzer hinzugef\u00fcgt", "shareAdded": "Benutzer hinzugef\u00fcgt",
"shareRemoved": "Freigabe entfernt", "shareRemoved": "Freigabe entfernt",
"shareFailed": "Freigabe fehlgeschlagen", "shareFailed": "Freigabe fehlgeschlagen",
"shareRemove": "Freigabe entfernen", "shareRemove": "Freigabe entfernen",
"defaultWelcome": "Willkommen zum Meeting!" "defaultWelcome": "Willkommen zum Meeting!",
"analytics": "Lernanalyse",
"enableAnalytics": "Lernanalyse aktivieren",
"enableAnalyticsHint": "Sammelt Engagement-Daten der Teilnehmer nach jedem Meeting.",
"analyticsVisibility": "Wer kann die Analyse sehen?",
"analyticsOwnerOnly": "Nur Raumbesitzer",
"analyticsSharedUsers": "Alle geteilten Benutzer",
"analyticsVisibilityHint": "Legt fest, wer die Analysedaten dieses Raums einsehen und exportieren kann."
}, },
"recordings": { "recordings": {
"title": "Aufnahmen", "title": "Aufnahmen",
@@ -252,6 +284,30 @@
"publish": "Veröffentlichen", "publish": "Veröffentlichen",
"loadFailed": "Aufnahmen konnten nicht geladen werden" "loadFailed": "Aufnahmen konnten nicht geladen werden"
}, },
"analytics": {
"title": "Lernanalyse",
"noData": "Keine Analysedaten vorhanden",
"participants": "Teilnehmer",
"messages": "Nachrichten",
"expand": "Details anzeigen",
"collapse": "Details ausblenden",
"deleteConfirm": "Analysedaten wirklich löschen?",
"deleted": "Analysedaten gelöscht",
"deleteFailed": "Fehler beim Löschen",
"userName": "Name",
"role": "Rolle",
"moderator": "Moderator",
"viewer": "Teilnehmer",
"talkTime": "Sprechzeit",
"webcamTime": "Webcam-Zeit",
"duration": "Dauer",
"meetingDuration": "Meeting-Dauer",
"raiseHand": "Handheben",
"reactions": "Reaktionen",
"export": "Herunterladen",
"exportSuccess": "Download gestartet",
"exportFailed": "Fehler beim Herunterladen"
},
"settings": { "settings": {
"title": "Einstellungen", "title": "Einstellungen",
"subtitle": "Verwalten Sie Ihr Profil und Ihre Einstellungen", "subtitle": "Verwalten Sie Ihr Profil und Ihre Einstellungen",
@@ -281,7 +337,49 @@
"passwordChanged": "Passwort geändert", "passwordChanged": "Passwort geändert",
"passwordChangeFailed": "Fehler beim Ändern", "passwordChangeFailed": "Fehler beim Ändern",
"passwordMismatch": "Passwörter stimmen nicht überein", "passwordMismatch": "Passwörter stimmen nicht überein",
"selectLanguage": "Sprache auswählen" "selectLanguage": "Sprache auswählen",
"security": {
"title": "Sicherheit",
"subtitle": "Schütze dein Konto mit Zwei-Faktor-Authentifizierung (2FA). Nach der Aktivierung benötigst du sowohl dein Passwort als auch einen Code aus deiner Authenticator-App zum Anmelden.",
"statusEnabled": "2FA ist aktiviert",
"statusEnabledDesc": "Dein Konto ist durch Zwei-Faktor-Authentifizierung geschützt.",
"statusDisabled": "2FA ist nicht aktiviert",
"statusDisabledDesc": "Aktiviere die Zwei-Faktor-Authentifizierung für zusätzliche Sicherheit.",
"enable": "2FA aktivieren",
"disable": "2FA deaktivieren",
"enabled": "Zwei-Faktor-Authentifizierung aktiviert",
"disabled": "Zwei-Faktor-Authentifizierung deaktiviert.",
"enableFailed": "2FA konnte nicht aktiviert werden",
"disableFailed": "2FA konnte nicht deaktiviert werden",
"setupFailed": "2FA-Einrichtung konnte nicht gestartet werden",
"scanQR": "Scanne diesen QR-Code mit deiner Authenticator-App (Google Authenticator, Authy, etc.).",
"manualKey": "Oder gib diesen Schlüssel manuell ein:",
"verifyCode": "Gib den Code aus deiner App zur Überprüfung ein",
"codeLabel": "6-stelliger Code",
"disableConfirm": "Gib dein Passwort und einen aktuellen 2FA-Code ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren."
},
"caldav": {
"title": "CalDAV",
"subtitle": "Verbinde deine Kalender-App (z. B. Apple Kalender, Thunderbird, DAVx⁵) über das CalDAV-Protokoll. Verwende deine E-Mail-Adresse und ein App-Token als Passwort.",
"serverUrl": "Server-URL",
"username": "Benutzername (E-Mail)",
"hint": "Gib niemals dein echtes Redlight-Passwort in einer Kalender-App ein. Verwende stattdessen ein App-Token.",
"newToken": "Neues App-Token generieren",
"tokenNamePlaceholder": "z. B. \"iPhone\" oder \"Thunderbird\"",
"generate": "Generieren",
"existingTokens": "Aktive Tokens",
"noTokens": "Noch keine Tokens erstellt.",
"created": "Erstellt",
"lastUsed": "Zuletzt verwendet",
"revoke": "Widerrufen",
"revokeConfirm": "Dieses Token wirklich widerrufen? Alle Kalender-Apps, die dieses Token verwenden, verlieren den Zugriff.",
"revoked": "Token widerrufen",
"revokeFailed": "Token konnte nicht widerrufen werden",
"createFailed": "Token konnte nicht erstellt werden",
"newTokenCreated": "Token erstellt — jetzt kopieren",
"newTokenHint": "Dieses Token wird nur einmal angezeigt. Kopiere es und trage es als Passwort in deiner Kalender-App ein.",
"dismiss": "Ich habe das Token kopiert"
}
}, },
"themes": { "themes": {
"selectTheme": "Theme auswählen", "selectTheme": "Theme auswählen",
@@ -330,23 +428,101 @@
"appNameLabel": "App-Name", "appNameLabel": "App-Name",
"appNameUpdated": "App-Name aktualisiert", "appNameUpdated": "App-Name aktualisiert",
"appNameUpdateFailed": "App-Name konnte nicht aktualisiert werden", "appNameUpdateFailed": "App-Name konnte nicht aktualisiert werden",
"hideAppNameLabel": "App-Namen ausblenden",
"hideAppNameHint": "Nur das Logo anzeigen, den App-Namen daneben ausblenden.",
"hideAppNameFailed": "Einstellung konnte nicht gespeichert werden",
"defaultThemeLabel": "Standard-Theme", "defaultThemeLabel": "Standard-Theme",
"defaultThemeDesc": "Wird für nicht angemeldete Seiten (Gast-Join, Login, Startseite) verwendet, wenn keine persönliche Einstellung gesetzt ist.", "defaultThemeDesc": "Wird für nicht angemeldete Seiten (Gast-Join, Login, Startseite) verwendet, wenn keine persönliche Einstellung gesetzt ist.",
"defaultThemeSaved": "Standard-Theme gespeichert", "defaultThemeSaved": "Standard-Theme gespeichert",
"defaultThemeUpdateFailed": "Standard-Theme konnte nicht aktualisiert werden" "defaultThemeUpdateFailed": "Standard-Theme konnte nicht aktualisiert werden",
"regModeTitle": "Registrierungsmodus",
"regModeDescription": "Steuern Sie, wie sich neue Benutzer registrieren können. \"Offen\" erlaubt jedem die Anmeldung. \"Nur mit Einladung\" erfordert einen Einladungslink.",
"regModeOpen": "Offene Registrierung",
"regModeInvite": "Nur mit Einladung",
"regModeSaved": "Registrierungsmodus aktualisiert",
"regModeFailed": "Registrierungsmodus konnte nicht aktualisiert werden",
"inviteTitle": "Benutzer-Einladungen",
"inviteDescription": "Laden Sie neue Benutzer per E-Mail ein. Sie erhalten einen Registrierungslink, der 7 Tage gültig ist.",
"sendInvite": "Einladung senden",
"inviteSent": "Einladung gesendet",
"inviteFailed": "Einladung konnte nicht gesendet werden",
"inviteDeleted": "Einladung gelöscht",
"inviteDeleteFailed": "Einladung konnte nicht gelöscht werden",
"inviteLinkCopied": "Einladungslink kopiert",
"copyInviteLink": "Einladungslink kopieren",
"inviteExpired": "Abgelaufen",
"inviteUsedBy": "Verwendet von",
"inviteExpiresAt": "Läuft ab am",
"noInvites": "Noch keine Einladungen",
"legalLinksTitle": "Rechtliche Links",
"legalLinksDesc": "Impressum- und Datenschutz-Links am unteren Rand der Seitenleiste anzeigen. Leer lassen zum Ausblenden.",
"imprintUrl": "Impressum-URL",
"privacyUrl": "Datenschutz-URL",
"imprintUrlSaved": "Impressum-URL gespeichert",
"privacyUrlSaved": "Datenschutz-URL gespeichert",
"imprintUrlFailed": "Impressum-URL konnte nicht gespeichert werden",
"privacyUrlFailed": "Datenschutz-URL konnte nicht gespeichert werden",
"oauthTitle": "OAuth / SSO",
"oauthDescription": "OpenID-Connect-Anbieter verbinden (z. B. Keycloak, Authentik, Google) für Single Sign-On.",
"oauthIssuer": "Issuer-URL",
"oauthIssuerHint": "Die OIDC-Issuer-URL, z. B. https://auth.example.com/realms/main",
"oauthClientId": "Client-ID",
"oauthClientSecret": "Client-Secret",
"oauthClientSecretHint": "Leer lassen, um das bestehende Secret beizubehalten",
"oauthDisplayName": "Button-Beschriftung",
"oauthDisplayNameHint": "Wird auf der Login-Seite angezeigt, z. B. Firmen-SSO",
"oauthAutoRegister": "Neue Benutzer automatisch registrieren",
"oauthAutoRegisterHint": "Erstellt automatisch Konten für Benutzer, die sich zum ersten Mal per OAuth anmelden.",
"oauthSaved": "OAuth-Konfiguration gespeichert",
"oauthSaveFailed": "OAuth-Konfiguration konnte nicht gespeichert werden",
"oauthRemoved": "OAuth-Konfiguration entfernt",
"oauthRemoveFailed": "OAuth-Konfiguration konnte nicht entfernt werden",
"oauthRemoveConfirm": "OAuth-Konfiguration wirklich entfernen? Benutzer können sich dann nicht mehr per SSO anmelden.",
"oauthNotConfigured": "OAuth ist noch nicht konfiguriert.",
"oauthSave": "OAuth speichern",
"oauthRemove": "OAuth entfernen",
"roomsTitle": "Raumverwaltung",
"roomsDescription": "Alle Räume der Instanz einsehen, verwalten und bei Bedarf löschen.",
"searchRooms": "Räume suchen...",
"roomName": "Name",
"roomOwner": "Besitzer",
"roomShares": "Geteilt",
"roomCreated": "Erstellt",
"roomView": "Raum öffnen",
"deleteRoom": "Raum löschen",
"deleteRoomConfirm": "Raum \"{name}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.",
"roomDeleted": "Raum gelöscht",
"roomDeleteFailed": "Raum konnte nicht gelöscht werden",
"noRoomsFound": "Keine Räume vorhanden",
"showAllRooms": "Alle {count} Räume anzeigen"
},
"notifications": {
"bell": "Benachrichtigungen",
"markAllRead": "Alle gelesen",
"clearAll": "Alle löschen",
"delete": "Löschen",
"noNotifications": "Keine Benachrichtigungen",
"roomShareAdded": "Raum wurde mit dir geteilt",
"roomShareRemoved": "Raumzugriff wurde entfernt",
"federationInviteReceived": "Neue Meeting-Einladung"
}, },
"federation": { "federation": {
"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",
@@ -371,7 +547,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",
@@ -385,6 +561,124 @@
"roomDetails": "Raumdetails", "roomDetails": "Raumdetails",
"joinUrl": "Beitritts-URL", "joinUrl": "Beitritts-URL",
"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",
"calendarAccepted": "Kalender-Event angenommen und in deinen Kalender eingetragen",
"localCalendarEvent": "Lokale Kalendereinladung",
"calendarLocalAccepted": "Einladung angenommen - Event wurde in deinen Kalender eingetragen",
"invitationRemoved": "Einladung entfernt",
"removeInvitation": "Einladung entfernen"
},
"calendar": {
"title": "Kalender",
"subtitle": "Meetings planen und verwalten",
"newEvent": "Neues Event",
"createEvent": "Event erstellen",
"editEvent": "Event bearbeiten",
"eventTitle": "Titel",
"eventTitlePlaceholder": "z.B. Team Meeting",
"description": "Beschreibung",
"descriptionPlaceholder": "Beschreibung hinzufügen...",
"startTime": "Beginn",
"endTime": "Ende",
"linkedRoom": "Verknüpfter Raum",
"noRoom": "Kein Raum (kein Videomeeting)",
"linkedRoomHint": "Verknüpfe einen Raum, um die Beitritts-URL automatisch ins Event einzufügen.",
"reminderLabel": "Erinnerung",
"reminderNone": "Keine Erinnerung",
"reminder5": "5 Minuten vorher",
"reminder15": "15 Minuten vorher",
"reminder30": "30 Minuten vorher",
"reminder60": "1 Stunde vorher",
"reminder120": "2 Stunden vorher",
"reminder1440": "1 Tag vorher",
"timezone": "Zeitzone",
"color": "Farbe",
"eventCreated": "Event erstellt",
"eventUpdated": "Event aktualisiert",
"eventDeleted": "Event gelöscht",
"saveFailed": "Event konnte nicht gespeichert werden",
"deleteFailed": "Event konnte nicht gelöscht werden",
"deleteConfirm": "Dieses Event wirklich löschen?",
"loadFailed": "Events konnten nicht geladen werden",
"today": "Heute",
"month": "Monat",
"week": "Woche",
"more": "weitere",
"mon": "Mo",
"tue": "Di",
"wed": "Mi",
"thu": "Do",
"fri": "Fr",
"sat": "Sa",
"sun": "So",
"downloadICS": "ICS herunterladen",
"addToOutlook": "Zu Outlook hinzufügen",
"addToGoogleCalendar": "Zu Google Kalender",
"icsDownloaded": "ICS-Datei heruntergeladen",
"icsFailed": "ICS-Datei konnte nicht heruntergeladen werden",
"share": "Teilen",
"shareEvent": "Event teilen",
"shareAdded": "Benutzer zum Event hinzugefügt",
"shareRemoved": "Freigabe entfernt",
"shareFailed": "Event konnte nicht geteilt werden",
"invitationSent": "Einladung gesendet",
"invitationCancelled": "Einladung widerrufen",
"invitationPending": "Einladung ausstehend",
"pendingInvitations": "Ausstehende Einladungen",
"accepted": "Angenommen",
"sendFederated": "An Remote senden",
"sendFederatedTitle": "Event an Remote-Instanz senden",
"sendFederatedDesc": "Sende dieses Kalender-Event an einen Benutzer auf einer anderen Redlight-Instanz. Der Empfänger muss die Einladung zuerst annehmen, bevor das Event in seinem Kalender erscheint.",
"send": "Senden",
"fedSent": "Kalendereinladung gesendet! Der Empfänger muss diese zuerst annehmen.",
"fedFailed": "Event konnte nicht an Remote-Instanz gesendet werden",
"openRoom": "Verknüpften Raum öffnen",
"organizer": "Organisator",
"federatedFrom": "Von Remote-Instanz",
"joinFederatedMeeting": "Remote-Meeting beitreten"
},
"email": {
"greeting": "Hey {name} 👋",
"viewInvitation": "Einladung anzeigen",
"invitationFooter": "Öffne den Link oben, um die Einladung anzunehmen oder abzulehnen.",
"linkHint": "Oder kopiere diesen Link in deinen Browser:",
"verify": {
"subject": "{appName} - E-Mail-Adresse bestätigen",
"intro": "Bitte bestätige deine E-Mail-Adresse, indem du auf den Button klickst:",
"button": "E-Mail bestätigen",
"validity": "Dieser Link ist 24 Stunden gültig.",
"footer": "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren."
},
"invite": {
"subject": "{appName} - Du wurdest eingeladen",
"title": "Du wurdest eingeladen! 🎉",
"intro": "Du wurdest eingeladen, ein Konto auf {appName} zu erstellen.",
"prompt": "Klicke auf den Button, um dich zu registrieren:",
"button": "Konto erstellen",
"validity": "Dieser Link ist 7 Tage gültig.",
"footer": "Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren."
},
"federationInvite": {
"subject": "{appName} - Meeting-Einladung von {fromUser}",
"intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.",
"roomLabel": "Raum:"
},
"calendarInvite": {
"subject": "{appName} - Kalendereinladung von {fromUser}",
"intro": "Du hast eine Kalendereinladung von {fromUser} erhalten."
},
"calendarDeleted": {
"subject": "{appName} - Kalendereintrag abgesagt: {title}",
"intro": "Der folgende Kalendereintrag wurde vom Organisator ({fromUser}) gelöscht und ist nicht mehr verfügbar:",
"note": "Der Termin wurde automatisch aus deinem Kalender entfernt.",
"footer": "Diese Nachricht wurde automatisch von {appName} versendet."
}
},
"notFound": {
"title": "Seite nicht gefunden",
"description": "Die Seite, die du suchst, existiert nicht oder wurde verschoben.",
"goBack": "Zurück",
"goHome": "Zur Startseite"
} }
} }
+335 -41
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",
@@ -32,7 +34,10 @@
"appearance": "Appearance", "appearance": "Appearance",
"changeTheme": "Change theme", "changeTheme": "Change theme",
"navigation": "Navigation", "navigation": "Navigation",
"federation": "Invitations" "calendar": "Calendar",
"federation": "Invitations",
"imprint": "Imprint",
"privacy": "Privacy Policy"
}, },
"auth": { "auth": {
"login": "Sign in", "login": "Sign in",
@@ -59,58 +64,70 @@
"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.",
"emailNotVerified": "Email not yet verified. Please check your inbox.", "emailNotVerified": "Email not yet verified. Please check your inbox.",
"username": "Username", "username": "Username",
"usernamePlaceholder": "e.g. johndoe", "usernamePlaceholder": "e.g. johndoe",
"usernameHint": "Letters, numbers, _ and - only (330 chars)", "usernameHint": "Letters, numbers, _ and - only (3-30 chars)",
"displayName": "Display Name", "displayName": "Display Name",
"displayNamePlaceholder": "John Doe", "displayNamePlaceholder": "John Doe",
"usernameTaken": "Username is already taken", "usernameTaken": "Username is already taken",
"usernameInvalid": "Username may only contain letters, numbers, _ and - (330 chars)", "usernameInvalid": "Username may only contain letters, numbers, _ and - (3-30 chars)",
"usernameRequired": "Username is required", "usernameRequired": "Username is required",
"displayNameRequired": "Display name is required", "displayNameRequired": "Display name is required",
"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",
"inviteOnlyDesc": "Registration is currently restricted. You need an invitation link from an administrator to create an account.",
"orContinueWith": "or continue with",
"loginWithOAuth": "Sign in with {provider}",
"registerWithOAuth": "Sign up with {provider}",
"backToLogin": "Back to login",
"oauthError": "Authentication failed",
"oauthNoToken": "No authentication token received.",
"oauthLoginFailed": "Could not complete sign in. Please try again.",
"oauthRedirecting": "Signing you in...",
"2fa": {
"title": "Two-Factor Authentication",
"prompt": "Enter the 6-digit code from your authenticator app.",
"codeLabel": "Verification code",
"verify": "Verify",
"verifyFailed": "Verification failed",
"backToLogin": "← Back to login"
}
}, },
"home": { "home": {
"poweredBy": "Powered by BigBlueButton", "heroTitle": "Self-hosted frontend for BigBlueButton",
"heroTitle": "Meetings re", "heroSubtitle": "Redlight manages rooms, recordings and scheduling for your BigBlueButton server with federation between instances, CalDAV sync and SSO support.",
"heroTitleHighlight": "defined", "getStarted": "Create account",
"heroSubtitle": "The modern, self-hosted BigBlueButton frontend. Create rooms, manage recordings and enjoy a beautiful interface with over 15 themes.", "features": "Features",
"getStarted": "Get started", "featureRoomsTitle": "Room management",
"features": "Everything you need", "featureRoomsDesc": "Rooms with individual settings, access codes and shared access for colleagues.",
"featuresSubtitle": "Redlight provides all the features you need for professional video conferencing.",
"featureVideoTitle": "Video Conferencing",
"featureVideoDesc": "Create and manage meetings directly via BigBlueButton.",
"featureRoomsTitle": "Room Management",
"featureRoomsDesc": "Unlimited rooms with individual settings and access codes.",
"featureUsersTitle": "User Management",
"featureUsersDesc": "Registration, login and role management for your organization.",
"featureThemesTitle": "15+ Themes",
"featureThemesDesc": "Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox and many more.",
"featureRecordingsTitle": "Recordings", "featureRecordingsTitle": "Recordings",
"featureRecordingsDesc": "View, publish or delete all recordings per room.", "featureRecordingsDesc": "View, publish or delete recordings per room.",
"featureOpenSourceTitle": "Open Source", "featureFederationTitle": "Federation",
"featureOpenSourceDesc": "Fully open source and self-hosted. Your data stays with you.", "featureFederationDesc": "Invite users from other Redlight instances and accept cross-instance invitations.",
"statThemes": "Themes", "featureCalendarTitle": "Calendar",
"statRooms": "Rooms", "featureCalendarDesc": "Schedule meetings, sync via CalDAV with Thunderbird, Apple Calendar or DAVx⁵, get reminders.",
"statOpenSource": "Open Source", "featureVideoTitle": "Video conferencing",
"featureVideoDesc": "Create, start and manage meetings directly via BigBlueButton.",
"featureAnalyticsTitle": "Learning analytics",
"featureAnalyticsDesc": "Review attendance and activity data from meetings and export it as CSV, Excel or PDF.",
"footer": "© {year} Redlight. An open source BigBlueButton frontend." "footer": "© {year} Redlight. An open source BigBlueButton frontend."
}, },
"dashboard": { "dashboard": {
@@ -131,7 +148,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",
@@ -153,7 +170,9 @@
"settings": "Settings", "settings": "Settings",
"participants": "{count} participants", "participants": "{count} participants",
"copyLink": "Copy link", "copyLink": "Copy link",
"linkCopied": "Link copied!", "copyRoomLink": "Room Link",
"copyGuestLink": "Guest Link",
"linkCopied": "Link copied",
"meetingDetails": "Meeting details", "meetingDetails": "Meeting details",
"meetingId": "Meeting ID", "meetingId": "Meeting ID",
"status": "Status", "status": "Status",
@@ -178,7 +197,7 @@
"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",
@@ -193,7 +212,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",
@@ -207,6 +226,11 @@
"guestModeratorPlaceholder": "Only if you are a moderator", "guestModeratorPlaceholder": "Only if you are a moderator",
"guestJoinButton": "Join meeting", "guestJoinButton": "Join meeting",
"guestWaitingMessage": "The meeting has not started yet. Please wait for the moderator to start it.", "guestWaitingMessage": "The meeting has not started yet. Please wait for the moderator to start it.",
"guestWaitingTitle": "Waiting for meeting to start...",
"guestWaitingHint": "You will be joined automatically as soon as the meeting starts.",
"guestCancelWaiting": "Cancel",
"guestMeetingStartedJoining": "Meeting started! Joining now...",
"waitingToJoin": "Waiting...",
"guestAccessDenied": "Access denied", "guestAccessDenied": "Access denied",
"guestNameRequired": "Name is required", "guestNameRequired": "Name is required",
"guestJoinFailed": "Join failed", "guestJoinFailed": "Join failed",
@@ -228,13 +252,21 @@
"presentationRemoveFailed": "Could not remove presentation", "presentationRemoveFailed": "Could not remove presentation",
"presentationAllowedTypes": "PDF, PPT, PPTX, ODP, DOC, DOCX · max. 50 MB", "presentationAllowedTypes": "PDF, PPT, PPTX, ODP, DOC, DOCX · max. 50 MB",
"presentationCurrent": "Current:", "presentationCurrent": "Current:",
"shareTitle": "Share Room",
"shareDescription": "Share this room with other users so they can see it in their dashboard and join meetings.", "shareDescription": "Share this room with other users so they can see it in their dashboard and join meetings.",
"shareSearchPlaceholder": "Search users (name or email)...", "shareSearchPlaceholder": "Search users (name or email)...",
"shareAdded": "User added", "shareAdded": "User added",
"shareRemoved": "Share removed", "shareRemoved": "Share removed",
"shareFailed": "Share failed", "shareFailed": "Share failed",
"shareRemove": "Remove share", "shareRemove": "Remove share",
"defaultWelcome": "Welcome to the meeting!" "defaultWelcome": "Welcome to the meeting!",
"analytics": "Learning Analytics",
"enableAnalytics": "Enable learning analytics",
"enableAnalyticsHint": "Collects participant engagement data after each meeting.",
"analyticsVisibility": "Who can see analytics?",
"analyticsOwnerOnly": "Room owner only",
"analyticsSharedUsers": "All shared users",
"analyticsVisibilityHint": "Controls who can view and export analytics data for this room."
}, },
"recordings": { "recordings": {
"title": "Recordings", "title": "Recordings",
@@ -252,6 +284,30 @@
"publish": "Publish", "publish": "Publish",
"loadFailed": "Recordings could not be loaded" "loadFailed": "Recordings could not be loaded"
}, },
"analytics": {
"title": "Learning Analytics",
"noData": "No analytics data available",
"participants": "Participants",
"messages": "Messages",
"expand": "Show details",
"collapse": "Hide details",
"deleteConfirm": "Really delete analytics data?",
"deleted": "Analytics data deleted",
"deleteFailed": "Error deleting data",
"userName": "Name",
"role": "Role",
"moderator": "Moderator",
"viewer": "Viewer",
"talkTime": "Talk time",
"webcamTime": "Webcam time",
"duration": "Duration",
"meetingDuration": "Meeting duration",
"raiseHand": "Raise hand",
"reactions": "Reactions",
"export": "Download",
"exportSuccess": "Download started",
"exportFailed": "Error downloading data"
},
"settings": { "settings": {
"title": "Settings", "title": "Settings",
"subtitle": "Manage your profile and settings", "subtitle": "Manage your profile and settings",
@@ -281,7 +337,49 @@
"passwordChanged": "Password changed", "passwordChanged": "Password changed",
"passwordChangeFailed": "Error changing password", "passwordChangeFailed": "Error changing password",
"passwordMismatch": "Passwords do not match", "passwordMismatch": "Passwords do not match",
"selectLanguage": "Select language" "selectLanguage": "Select language",
"security": {
"title": "Security",
"subtitle": "Protect your account with two-factor authentication (2FA). After enabling, you will need both your password and a code from your authenticator app to sign in.",
"statusEnabled": "2FA is enabled",
"statusEnabledDesc": "Your account is protected with two-factor authentication.",
"statusDisabled": "2FA is not enabled",
"statusDisabledDesc": "Enable two-factor authentication for an extra layer of security.",
"enable": "Enable 2FA",
"disable": "Disable 2FA",
"enabled": "Two-factor authentication enabled",
"disabled": "Two-factor authentication disabled.",
"enableFailed": "Could not enable 2FA",
"disableFailed": "Could not disable 2FA",
"setupFailed": "Could not start 2FA setup",
"scanQR": "Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.).",
"manualKey": "Or enter this key manually:",
"verifyCode": "Enter the code from your app to verify",
"codeLabel": "6-digit code",
"disableConfirm": "Enter your password and a current 2FA code to disable two-factor authentication."
},
"caldav": {
"title": "CalDAV",
"subtitle": "Connect your calendar app (e.g. Apple Calendar, Thunderbird, DAVx⁵) using the CalDAV protocol. Use your email address and an app token as password.",
"serverUrl": "Server URL",
"username": "Username (Email)",
"hint": "Never enter your real Redlight password in a calendar app. Use an app token instead.",
"newToken": "Generate new app token",
"tokenNamePlaceholder": "e.g. \"iPhone\" or \"Thunderbird\"",
"generate": "Generate",
"existingTokens": "Active tokens",
"noTokens": "No tokens created yet.",
"created": "Created",
"lastUsed": "Last used",
"revoke": "Revoke",
"revokeConfirm": "Really revoke this token? All connected calendar apps using this token will lose access.",
"revoked": "Token revoked",
"revokeFailed": "Could not revoke token",
"createFailed": "Could not create token",
"newTokenCreated": "Token created — copy it now",
"newTokenHint": "This token will only be shown once. Copy it and enter it as the password in your calendar app.",
"dismiss": "I have copied the token"
}
}, },
"themes": { "themes": {
"selectTheme": "Select theme", "selectTheme": "Select theme",
@@ -330,23 +428,101 @@
"appNameLabel": "App name", "appNameLabel": "App name",
"appNameUpdated": "App name updated", "appNameUpdated": "App name updated",
"appNameUpdateFailed": "Could not update app name", "appNameUpdateFailed": "Could not update app name",
"hideAppNameLabel": "Hide app name",
"hideAppNameHint": "Only show the logo, hide the app name text next to it.",
"hideAppNameFailed": "Could not update setting",
"defaultThemeLabel": "Default Theme", "defaultThemeLabel": "Default Theme",
"defaultThemeDesc": "Applied to unauthenticated pages (guest join, login, home) when no personal preference is set.", "defaultThemeDesc": "Applied to unauthenticated pages (guest join, login, home) when no personal preference is set.",
"defaultThemeSaved": "Default theme saved", "defaultThemeSaved": "Default theme saved",
"defaultThemeUpdateFailed": "Could not update default theme" "defaultThemeUpdateFailed": "Could not update default theme",
"regModeTitle": "Registration Mode",
"regModeDescription": "Control how new users can register. \"Open\" allows everyone to sign up. \"Invite only\" requires an invitation link.",
"regModeOpen": "Open registration",
"regModeInvite": "Invite only",
"regModeSaved": "Registration mode updated",
"regModeFailed": "Could not update registration mode",
"inviteTitle": "User Invitations",
"inviteDescription": "Invite new users by email. They will receive a registration link valid for 7 days.",
"sendInvite": "Send invite",
"inviteSent": "Invitation sent",
"inviteFailed": "Could not send invitation",
"inviteDeleted": "Invitation deleted",
"inviteDeleteFailed": "Could not delete invitation",
"inviteLinkCopied": "Invite link copied",
"copyInviteLink": "Copy invite link",
"inviteExpired": "Expired",
"inviteUsedBy": "Used by",
"inviteExpiresAt": "Expires",
"noInvites": "No invitations yet",
"legalLinksTitle": "Legal Links",
"legalLinksDesc": "Show Imprint and Privacy Policy links at the bottom of the sidebar. Leave blank to hide.",
"imprintUrl": "Imprint URL",
"privacyUrl": "Privacy Policy URL",
"imprintUrlSaved": "Imprint URL saved",
"privacyUrlSaved": "Privacy Policy URL saved",
"imprintUrlFailed": "Could not save Imprint URL",
"privacyUrlFailed": "Could not save Privacy Policy URL",
"oauthTitle": "OAuth / SSO",
"oauthDescription": "Connect an OpenID Connect provider (e.g. Keycloak, Authentik, Google) to allow Single Sign-On.",
"oauthIssuer": "Issuer URL",
"oauthIssuerHint": "The OIDC issuer URL, e.g. https://auth.example.com/realms/main",
"oauthClientId": "Client ID",
"oauthClientSecret": "Client Secret",
"oauthClientSecretHint": "Leave blank to keep the existing secret",
"oauthDisplayName": "Button label",
"oauthDisplayNameHint": "Shown on the login page, e.g. \"Company SSO\"",
"oauthAutoRegister": "Auto-register new users",
"oauthAutoRegisterHint": "Automatically create accounts for users signing in via OAuth for the first time.",
"oauthSaved": "OAuth configuration saved",
"oauthSaveFailed": "Could not save OAuth configuration",
"oauthRemoved": "OAuth configuration removed",
"oauthRemoveFailed": "Could not remove OAuth configuration",
"oauthRemoveConfirm": "Really remove OAuth configuration? Users will no longer be able to sign in with SSO.",
"oauthNotConfigured": "OAuth is not configured yet.",
"oauthSave": "Save OAuth",
"oauthRemove": "Remove OAuth",
"roomsTitle": "Room Management",
"roomsDescription": "View, manage, and delete all rooms on this instance.",
"searchRooms": "Search rooms...",
"roomName": "Name",
"roomOwner": "Owner",
"roomShares": "Shared",
"roomCreated": "Created",
"roomView": "View room",
"deleteRoom": "Delete room",
"deleteRoomConfirm": "Really delete room \"{name}\"? This cannot be undone.",
"roomDeleted": "Room deleted",
"roomDeleteFailed": "Room could not be deleted",
"noRoomsFound": "No rooms found",
"showAllRooms": "Show all {count} rooms"
},
"notifications": {
"bell": "Notifications",
"markAllRead": "Mark all read",
"clearAll": "Clear all",
"delete": "Delete",
"noNotifications": "No notifications yet",
"roomShareAdded": "Room shared with you",
"roomShareRemoved": "Room access removed",
"federationInviteReceived": "New meeting invitation"
}, },
"federation": { "federation": {
"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",
@@ -371,7 +547,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",
@@ -385,6 +561,124 @@
"roomDetails": "Room Details", "roomDetails": "Room Details",
"joinUrl": "Join URL", "joinUrl": "Join URL",
"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",
"calendarAccepted": "Calendar event accepted and added to your calendar",
"localCalendarEvent": "Local Calendar Invitation",
"calendarLocalAccepted": "Invitation accepted - event added to your calendar",
"invitationRemoved": "Invitation removed",
"removeInvitation": "Remove invitation"
},
"calendar": {
"title": "Calendar",
"subtitle": "Plan and manage your meetings",
"newEvent": "New Event",
"createEvent": "Create Event",
"editEvent": "Edit Event",
"eventTitle": "Title",
"eventTitlePlaceholder": "e.g. Team Meeting",
"description": "Description",
"descriptionPlaceholder": "Add a description...",
"startTime": "Start",
"endTime": "End",
"linkedRoom": "Linked Room",
"noRoom": "No room (no video meeting)",
"linkedRoomHint": "Link a room to automatically include the join-URL in the event.",
"reminderLabel": "Reminder",
"reminderNone": "No reminder",
"reminder5": "5 minutes before",
"reminder15": "15 minutes before",
"reminder30": "30 minutes before",
"reminder60": "1 hour before",
"reminder120": "2 hours before",
"reminder1440": "1 day before",
"timezone": "Timezone",
"color": "Color",
"eventCreated": "Event created",
"eventUpdated": "Event updated",
"eventDeleted": "Event deleted",
"saveFailed": "Could not save event",
"deleteFailed": "Could not delete event",
"deleteConfirm": "Really delete this event?",
"loadFailed": "Events could not be loaded",
"today": "Today",
"month": "Month",
"week": "Week",
"more": "more",
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat",
"sun": "Sun",
"downloadICS": "Download ICS",
"addToOutlook": "Add to Outlook",
"addToGoogleCalendar": "Google Calendar",
"icsDownloaded": "ICS file downloaded",
"icsFailed": "Could not download ICS file",
"share": "Share",
"shareEvent": "Share Event",
"shareAdded": "User added to event",
"shareRemoved": "Share removed",
"shareFailed": "Could not share event",
"invitationSent": "Invitation sent",
"invitationCancelled": "Invitation cancelled",
"invitationPending": "Invitation pending",
"pendingInvitations": "Pending Invitations",
"accepted": "Accepted",
"sendFederated": "Send to remote",
"sendFederatedTitle": "Send Event to Remote Instance",
"sendFederatedDesc": "Send this calendar event to a user on another Redlight instance. The recipient must accept the invitation before the event appears in their calendar.",
"send": "Send",
"fedSent": "Calendar invitation sent! The recipient must accept it first.",
"fedFailed": "Could not send event to remote instance",
"openRoom": "Open linked room",
"organizer": "Organizer",
"federatedFrom": "From remote instance",
"joinFederatedMeeting": "Join remote meeting"
},
"email": {
"greeting": "Hey {name} 👋",
"viewInvitation": "View Invitation",
"invitationFooter": "Open the link above to accept or decline the invitation.",
"linkHint": "Or copy this link in your browser:",
"verify": {
"subject": "{appName} - Verify your email",
"intro": "Please verify your email address by clicking the button below:",
"button": "Verify Email",
"validity": "This link is valid for 24 hours.",
"footer": "If you didn't register, please ignore this email."
},
"invite": {
"subject": "{appName} - You've been invited",
"title": "You've been invited! 🎉",
"intro": "You have been invited to create an account on {appName}.",
"prompt": "Click the button below to register:",
"button": "Create Account",
"validity": "This link is valid for 7 days.",
"footer": "If you didn't expect this invitation, you can safely ignore this email."
},
"federationInvite": {
"subject": "{appName} - Meeting invitation from {fromUser}",
"intro": "You have received a meeting invitation from {fromUser}.",
"roomLabel": "Room:"
},
"calendarInvite": {
"subject": "{appName} - Calendar invitation from {fromUser}",
"intro": "You have received a calendar invitation from {fromUser}."
},
"calendarDeleted": {
"subject": "{appName} - Calendar event cancelled: {title}",
"intro": "The following calendar event was deleted by the organiser ({fromUser}) and is no longer available:",
"note": "The event has been automatically removed from your calendar.",
"footer": "This message was sent automatically by {appName}."
}
},
"notFound": {
"title": "Page not found",
"description": "The page you are looking for doesn't exist or has been moved.",
"goBack": "Go back",
"goHome": "Back to home"
} }
} }
+598 -57
View File
@@ -1,11 +1,139 @@
@tailwind base; @import 'tailwindcss';
@tailwind components;
@tailwind utilities; @theme {
--color-th-bg: var(--bg-primary);
--color-th-bg-s: var(--bg-secondary);
--color-th-bg-t: var(--bg-tertiary);
--color-th-text: var(--text-primary);
--color-th-text-s: var(--text-secondary);
--color-th-accent: var(--accent);
--color-th-accent-h: var(--accent-hover);
--color-th-accent-t: var(--accent-text);
--color-th-border: var(--border);
--color-th-card: var(--card-bg);
--color-th-input: var(--input-bg);
--color-th-input-b: var(--input-border);
--color-th-nav: var(--nav-bg);
--color-th-side: var(--sidebar-bg);
--color-th-hover: var(--hover-bg);
--color-th-success: var(--success);
--color-th-warning: var(--warning);
--color-th-error: var(--error);
--color-th-ring: var(--ring);
--font-sans: Inter, system-ui, -apple-system, sans-serif;
--shadow-th:
0 1px 3px 0 var(--shadow-color), 0 1px 2px -1px var(--shadow-color);
--shadow-th-lg:
0 10px 15px -3px var(--shadow-color), 0 4px 6px -4px var(--shadow-color);
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
/* Restore Tailwind v3 default: buttons get a pointer cursor */
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]) {
cursor: pointer;
}
}
@utility btn-primary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-accent text-th-accent-t hover:bg-th-accent-h
transition-all duration-200 ease-out
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2
cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
@utility btn-secondary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-bg-s text-th-text border border-th-border
hover:bg-th-hover transition-all duration-200 ease-out
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2
cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
@utility btn-danger {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-error text-white hover:opacity-90
transition-all duration-200 ease-out
focus:outline-hidden focus:ring-2 focus:ring-th-error focus:ring-offset-2
cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
@utility btn-ghost {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
text-th-text-s hover:bg-th-hover hover:text-th-text
transition-all duration-200 ease-out
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2
cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
@utility input-field {
@apply w-full px-4 py-2.5 rounded-lg
bg-th-input text-th-text placeholder-th-text-s
border border-th-input-b
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:border-transparent
transition-all duration-200;
}
@utility card {
@apply bg-th-card rounded-xl border border-th-border
shadow-th transition-all duration-200;
}
@utility card-hover {
@apply card hover:shadow-th-lg cursor-pointer;
&:hover {
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
}
}
@utility gradient-text {
background: linear-gradient(
135deg,
var(--gradient-start),
var(--gradient-end)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
@utility gradient-bg {
background: linear-gradient(
135deg,
var(--gradient-start),
var(--gradient-end)
);
}
@layer base { @layer base {
/* ===== DEFAULT LIGHT ===== */ /* ===== DEFAULT LIGHT ===== */
:root, :root,
[data-theme="light"] { [data-theme="light"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #ffffff; --bg-primary: #ffffff;
--bg-secondary: #f8fafc; --bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9; --bg-tertiary: #f1f5f9;
@@ -32,6 +160,8 @@
/* ===== DEFAULT DARK ===== */ /* ===== DEFAULT DARK ===== */
[data-theme="dark"] { [data-theme="dark"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #0f172a; --bg-primary: #0f172a;
--bg-secondary: #1e293b; --bg-secondary: #1e293b;
--bg-tertiary: #334155; --bg-tertiary: #334155;
@@ -58,6 +188,8 @@
/* ===== DRACULA ===== */ /* ===== DRACULA ===== */
[data-theme="dracula"] { [data-theme="dracula"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #282a36; --bg-primary: #282a36;
--bg-secondary: #44475a; --bg-secondary: #44475a;
--bg-tertiary: #383a4c; --bg-tertiary: #383a4c;
@@ -84,6 +216,8 @@
/* ===== CATPPUCCIN MOCHA ===== */ /* ===== CATPPUCCIN MOCHA ===== */
[data-theme="mocha"] { [data-theme="mocha"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #1e1e2e; --bg-primary: #1e1e2e;
--bg-secondary: #313244; --bg-secondary: #313244;
--bg-tertiary: #45475a; --bg-tertiary: #45475a;
@@ -110,6 +244,8 @@
/* ===== CATPPUCCIN LATTE (Light) ===== */ /* ===== CATPPUCCIN LATTE (Light) ===== */
[data-theme="latte"] { [data-theme="latte"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #eff1f5; --bg-primary: #eff1f5;
--bg-secondary: #e6e9ef; --bg-secondary: #e6e9ef;
--bg-tertiary: #dce0e8; --bg-tertiary: #dce0e8;
@@ -136,6 +272,8 @@
/* ===== NORD ===== */ /* ===== NORD ===== */
[data-theme="nord"] { [data-theme="nord"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #2e3440; --bg-primary: #2e3440;
--bg-secondary: #3b4252; --bg-secondary: #3b4252;
--bg-tertiary: #434c5e; --bg-tertiary: #434c5e;
@@ -162,6 +300,8 @@
/* ===== TOKYO NIGHT ===== */ /* ===== TOKYO NIGHT ===== */
[data-theme="tokyo-night"] { [data-theme="tokyo-night"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #1a1b26; --bg-primary: #1a1b26;
--bg-secondary: #24283b; --bg-secondary: #24283b;
--bg-tertiary: #2f3349; --bg-tertiary: #2f3349;
@@ -188,6 +328,8 @@
/* ===== GRUVBOX DARK ===== */ /* ===== GRUVBOX DARK ===== */
[data-theme="gruvbox-dark"] { [data-theme="gruvbox-dark"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #282828; --bg-primary: #282828;
--bg-secondary: #3c3836; --bg-secondary: #3c3836;
--bg-tertiary: #504945; --bg-tertiary: #504945;
@@ -214,6 +356,8 @@
/* ===== GRUVBOX LIGHT ===== */ /* ===== GRUVBOX LIGHT ===== */
[data-theme="gruvbox-light"] { [data-theme="gruvbox-light"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #fbf1c7; --bg-primary: #fbf1c7;
--bg-secondary: #ebdbb2; --bg-secondary: #ebdbb2;
--bg-tertiary: #d5c4a1; --bg-tertiary: #d5c4a1;
@@ -240,6 +384,8 @@
/* ===== ROSE PINE ===== */ /* ===== ROSE PINE ===== */
[data-theme="rose-pine"] { [data-theme="rose-pine"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #191724; --bg-primary: #191724;
--bg-secondary: #1f1d2e; --bg-secondary: #1f1d2e;
--bg-tertiary: #26233a; --bg-tertiary: #26233a;
@@ -266,6 +412,8 @@
/* ===== ROSE PINE DAWN (Light) ===== */ /* ===== ROSE PINE DAWN (Light) ===== */
[data-theme="rose-pine-dawn"] { [data-theme="rose-pine-dawn"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #faf4ed; --bg-primary: #faf4ed;
--bg-secondary: #fffaf3; --bg-secondary: #fffaf3;
--bg-tertiary: #f2e9e1; --bg-tertiary: #f2e9e1;
@@ -292,6 +440,8 @@
/* ===== SOLARIZED DARK ===== */ /* ===== SOLARIZED DARK ===== */
[data-theme="solarized-dark"] { [data-theme="solarized-dark"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #002b36; --bg-primary: #002b36;
--bg-secondary: #073642; --bg-secondary: #073642;
--bg-tertiary: #0a4050; --bg-tertiary: #0a4050;
@@ -318,6 +468,8 @@
/* ===== SOLARIZED LIGHT ===== */ /* ===== SOLARIZED LIGHT ===== */
[data-theme="solarized-light"] { [data-theme="solarized-light"] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #fdf6e3; --bg-primary: #fdf6e3;
--bg-secondary: #eee8d5; --bg-secondary: #eee8d5;
--bg-tertiary: #e4ddc8; --bg-tertiary: #e4ddc8;
@@ -344,6 +496,8 @@
/* ===== ONE DARK ===== */ /* ===== ONE DARK ===== */
[data-theme="one-dark"] { [data-theme="one-dark"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #282c34; --bg-primary: #282c34;
--bg-secondary: #2c313a; --bg-secondary: #2c313a;
--bg-tertiary: #353b45; --bg-tertiary: #353b45;
@@ -370,6 +524,8 @@
/* ===== GITHUB DARK ===== */ /* ===== GITHUB DARK ===== */
[data-theme="github-dark"] { [data-theme="github-dark"] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #0d1117; --bg-primary: #0d1117;
--bg-secondary: #161b22; --bg-secondary: #161b22;
--bg-tertiary: #21262d; --bg-tertiary: #21262d;
@@ -414,8 +570,11 @@
} }
} }
@layer utilities {
/* ===== SCRUNKLY.CAT DARK ===== */ /* ===== SCRUNKLY.CAT DARK ===== */
[data-theme="scrunkly-cat"] { [data-theme='scrunkly-cat'] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #161924; --bg-primary: #161924;
--bg-secondary: #161924; --bg-secondary: #161924;
--bg-tertiary: #1b2130; --bg-tertiary: #1b2130;
@@ -440,74 +599,456 @@
--gradient-end: #d6336a; --gradient-end: #d6336a;
} }
/* ===== RED MODULAR LIGHT ===== */
@layer components { [data-theme='red-modular-light'] {
.btn-primary { color-scheme: light;
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium --picker-icon-filter: none;
bg-th-accent text-th-accent-t hover:bg-th-accent-h --bg-primary: #ffffff;
transition-all duration-200 ease-out --bg-secondary: #f8fafc;
focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-2 --bg-tertiary: #f1f5f9;
disabled:opacity-50 disabled:cursor-not-allowed; --text-primary: #000000;
--tw-ring-offset-color: var(--bg-primary); --text-secondary: #333333;
--accent: #e60000;
--accent-hover: #ff3333;
--accent-text: #ffffff;
--border: #e2e8f0;
--card-bg: #ffffff;
--input-bg: #ffffff;
--input-border: #cbd5e1;
--nav-bg: #ffffff;
--sidebar-bg: #f8fafc;
--hover-bg: #f1f5f9;
--success: #86b300;
--warning: #ecb637;
--error: #ec4137;
--ring: #e60000;
--shadow-color: rgba(0, 0, 0, 0.3);
--gradient-start: #e60000;
--gradient-end: #ff3333;
} }
.btn-secondary { /* ===== EVERFOREST DARK ===== */
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium [data-theme='everforest-dark'] {
bg-th-bg-s text-th-text border border-th-border color-scheme: dark;
hover:bg-th-hover transition-all duration-200 ease-out --picker-icon-filter: invert(0.8);
focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-2 --bg-primary: #2d353b;
disabled:opacity-50 disabled:cursor-not-allowed; --bg-secondary: #343f44;
--tw-ring-offset-color: var(--bg-primary); --bg-tertiary: #3d484d;
--text-primary: #d3c6aa;
--text-secondary: #859289;
--accent: #a7c080;
--accent-hover: #bdd4a0;
--accent-text: #2d353b;
--border: #4f585e;
--card-bg: #343f44;
--input-bg: #343f44;
--input-border: #4f585e;
--nav-bg: #272e33;
--sidebar-bg: #272e33;
--hover-bg: #3d484d;
--success: #a7c080;
--warning: #e69875;
--error: #e67e80;
--ring: #a7c080;
--shadow-color: rgba(0, 0, 0, 0.35);
--gradient-start: #a7c080;
--gradient-end: #83c092;
} }
.btn-danger { /* ===== EVERFOREST LIGHT ===== */
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium [data-theme='everforest-light'] {
bg-th-error text-white hover:opacity-90 color-scheme: light;
transition-all duration-200 ease-out --picker-icon-filter: none;
focus:outline-none focus:ring-2 focus:ring-th-error focus:ring-offset-2 --bg-primary: #fdf6e3;
disabled:opacity-50 disabled:cursor-not-allowed; --bg-secondary: #f4f0d9;
--tw-ring-offset-color: var(--bg-primary); --bg-tertiary: #eae4ca;
--text-primary: #5c6a72;
--text-secondary: #829181;
--accent: #8da101;
--accent-hover: #6e8c00;
--accent-text: #fdf6e3;
--border: #d5ceb5;
--card-bg: #f4f0d9;
--input-bg: #fdf6e3;
--input-border: #c5bda0;
--nav-bg: #f4f0d9;
--sidebar-bg: #eae4ca;
--hover-bg: #eae4ca;
--success: #8da101;
--warning: #dfa000;
--error: #f85552;
--ring: #8da101;
--shadow-color: rgba(92, 106, 114, 0.1);
--gradient-start: #8da101;
--gradient-end: #35a77c;
} }
.btn-ghost { /* ===== KANAGAWA ===== */
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium [data-theme='kanagawa'] {
text-th-text-s hover:bg-th-hover hover:text-th-text color-scheme: dark;
transition-all duration-200 ease-out --picker-icon-filter: invert(0.8);
focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-2 --bg-primary: #1f1f28;
disabled:opacity-50 disabled:cursor-not-allowed; --bg-secondary: #2a2a37;
--tw-ring-offset-color: var(--bg-primary); --bg-tertiary: #363646;
--text-primary: #dcd7ba;
--text-secondary: #727169;
--accent: #7e9cd8;
--accent-hover: #98b4e8;
--accent-text: #1f1f28;
--border: #363646;
--card-bg: #2a2a37;
--input-bg: #2a2a37;
--input-border: #363646;
--nav-bg: #16161d;
--sidebar-bg: #16161d;
--hover-bg: #363646;
--success: #76946a;
--warning: #dca561;
--error: #c34043;
--ring: #7e9cd8;
--shadow-color: rgba(0, 0, 0, 0.45);
--gradient-start: #7e9cd8;
--gradient-end: #957fb8;
} }
.input-field { /* ===== AYU DARK ===== */
@apply w-full px-4 py-2.5 rounded-lg [data-theme='ayu-dark'] {
bg-th-input text-th-text placeholder-th-text-s color-scheme: dark;
border border-th-input-b --picker-icon-filter: invert(0.8);
focus:outline-none focus:ring-2 focus:ring-th-ring focus:border-transparent --bg-primary: #0d1017;
transition-all duration-200; --bg-secondary: #131721;
--bg-tertiary: #1a212e;
--text-primary: #bfbdb6;
--text-secondary: #6c7380;
--accent: #39bae6;
--accent-hover: #59ccf0;
--accent-text: #0d1017;
--border: #1a212e;
--card-bg: #131721;
--input-bg: #0d1017;
--input-border: #242b38;
--nav-bg: #0d1017;
--sidebar-bg: #0d1017;
--hover-bg: #1a212e;
--success: #aad94c;
--warning: #ffb454;
--error: #f07178;
--ring: #39bae6;
--shadow-color: rgba(0, 0, 0, 0.5);
--gradient-start: #39bae6;
--gradient-end: #6a9ff7;
} }
.card { /* ===== MOONLIGHT ===== */
@apply bg-th-card rounded-xl border border-th-border [data-theme='moonlight'] {
shadow-th transition-all duration-200; color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #212337;
--bg-secondary: #2b2d3f;
--bg-tertiary: #353750;
--text-primary: #c8d3f5;
--text-secondary: #828dae;
--accent: #82aaff;
--accent-hover: #9dbdff;
--accent-text: #212337;
--border: #353750;
--card-bg: #2b2d3f;
--input-bg: #2b2d3f;
--input-border: #444668;
--nav-bg: #1e2030;
--sidebar-bg: #1e2030;
--hover-bg: #353750;
--success: #c3e88d;
--warning: #ffc777;
--error: #ff757f;
--ring: #82aaff;
--shadow-color: rgba(0, 0, 0, 0.4);
--gradient-start: #82aaff;
--gradient-end: #c099ff;
} }
.card-hover { /* ===== CYBERPUNK ===== */
@apply card hover:shadow-th-lg cursor-pointer; [data-theme='cyberpunk'] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #0a0a0f;
--bg-secondary: #0e0e1a;
--bg-tertiary: #141428;
--text-primary: #e0e0ff;
--text-secondary: #7878bb;
--accent: #ff0080;
--accent-hover: #ff33a0;
--accent-text: #ffffff;
--border: #1e1e3a;
--card-bg: #0e0e1a;
--input-bg: #0d0d18;
--input-border: #1e1e3a;
--nav-bg: #07070f;
--sidebar-bg: #07070f;
--hover-bg: #141428;
--success: #00ff9f;
--warning: #ffdd00;
--error: #ff3333;
--ring: #ff0080;
--shadow-color: rgba(255, 0, 128, 0.15);
--gradient-start: #ff0080;
--gradient-end: #00e5ff;
} }
.card-hover:hover {
border-color: color-mix(in srgb, var(--accent) 30%, transparent); /* ===== COTTON CANDY LIGHT ===== */
[data-theme='cotton-candy-light'] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #fff5f9;
--bg-secondary: #ffe8f2;
--bg-tertiary: #ffd6e8;
--text-primary: #8b2635;
--text-secondary: #b05470;
--accent: #ff85a2;
--accent-hover: #ff6b8d;
--accent-text: #ffffff;
--border: #ffc2d9;
--card-bg: #ffe8f2;
--input-bg: #fff5f9;
--input-border: #ffaac8;
--nav-bg: #ffe8f2;
--sidebar-bg: #ffd6e8;
--hover-bg: #ffd6e8;
--success: #5cb85c;
--warning: #f0ad4e;
--error: #d9534f;
--ring: #ff85a2;
--shadow-color: rgba(255, 133, 162, 0.15);
--gradient-start: #ff85a2;
--gradient-end: #c084fc;
} }
} }
@layer utilities { /* ═══════════════════════════════════════════════════════════════
.gradient-text { FLATPICKR THEMED OVERRIDES — fully driven by CSS variables
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); ═══════════════════════════════════════════════════════════════ */
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.gradient-bg { /* Calendar container — appended to body */
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); .flatpickr-calendar {
} background: var(--card-bg) !important;
border: 1px solid var(--border) !important;
border-radius: 0.75rem !important;
box-shadow: 0 10px 25px -5px var(--shadow-color), 0 4px 10px -6px var(--shadow-color) !important;
font-family: inherit !important;
color: var(--text-primary) !important;
z-index: 9999 !important;
overflow: hidden;
} }
.flatpickr-calendar::before,
.flatpickr-calendar::after {
display: none !important; /* hide arrow */
}
/* ── Month navigation ─────────────────────────────────────────── */
.flatpickr-months {
background: var(--bg-secondary) !important;
border-bottom: 1px solid var(--border) !important;
padding: 0 !important;
align-items: center !important;
height: 2.75rem !important;
position: relative !important;
}
.flatpickr-months .flatpickr-month {
background: transparent !important;
color: var(--text-primary) !important;
height: 2.75rem !important;
display: flex !important;
align-items: center !important;
}
.flatpickr-current-month {
font-size: 0.875rem !important;
font-weight: 600 !important;
color: var(--text-primary) !important;
padding: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
height: 100% !important;
width: 100% !important;
position: relative !important;
left: 0 !important;
}
.flatpickr-current-month .flatpickr-monthDropdown-months {
background: var(--bg-secondary) !important;
color: var(--text-primary) !important;
border: none !important;
font-weight: 600 !important;
font-size: 0.875rem !important;
appearance: none !important;
-webkit-appearance: none !important;
}
.flatpickr-current-month .flatpickr-monthDropdown-months option {
background: var(--card-bg) !important;
color: var(--text-primary) !important;
}
.flatpickr-current-month input.cur-year {
color: var(--text-primary) !important;
font-weight: 600 !important;
font-size: 0.875rem !important;
}
.flatpickr-months .flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month {
color: var(--text-secondary) !important;
fill: var(--text-secondary) !important;
padding: 0.5rem 0.625rem !important;
transition: color 0.15s !important;
display: flex !important;
align-items: center !important;
height: 2.75rem !important;
top: 0 !important;
}
.flatpickr-months .flatpickr-prev-month:hover,
.flatpickr-months .flatpickr-next-month:hover {
color: var(--text-primary) !important;
fill: var(--text-primary) !important;
}
.flatpickr-months .flatpickr-prev-month svg,
.flatpickr-months .flatpickr-next-month svg {
fill: inherit !important;
width: 12px !important;
height: 12px !important;
}
/* ── Day names row ────────────────────────────────────────────── */
.flatpickr-weekdays {
background: var(--bg-secondary) !important;
border-bottom: 1px solid var(--border) !important;
padding: 0.125rem 0 !important;
}
span.flatpickr-weekday {
color: var(--text-secondary) !important;
font-size: 0.6875rem !important;
font-weight: 600 !important;
text-transform: uppercase !important;
letter-spacing: 0.05em !important;
}
/* ── Days grid ────────────────────────────────────────────────── */
.flatpickr-days {
border: none !important;
}
.dayContainer {
padding: 0.25rem !important;
}
.flatpickr-day {
color: var(--text-primary) !important;
border: none !important;
border-radius: 0.375rem !important;
font-size: 0.8125rem !important;
transition: background 0.12s, color 0.12s !important;
}
.flatpickr-day:hover:not(.selected):not(.flatpickr-disabled) {
background: var(--hover-bg) !important;
border: none !important;
}
.flatpickr-day.selected,
.flatpickr-day.selected:hover {
background: var(--accent) !important;
color: var(--accent-text) !important;
border: none !important;
font-weight: 600 !important;
}
.flatpickr-day.today:not(.selected) {
font-weight: 700 !important;
color: var(--accent) !important;
border: none !important;
position: relative !important;
}
.flatpickr-day.today:not(.selected)::after {
content: '';
position: absolute;
bottom: 3px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent);
}
.flatpickr-day.prevMonthDay,
.flatpickr-day.nextMonthDay {
color: var(--text-secondary) !important;
opacity: 0.4 !important;
}
.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover {
color: var(--text-secondary) !important;
opacity: 0.3 !important;
cursor: not-allowed !important;
}
/* ── Time picker ──────────────────────────────────────────────── */
.flatpickr-time {
border-top: 1px solid var(--border) !important;
background: var(--bg-secondary) !important;
max-height: none !important;
height: auto !important;
}
.flatpickr-time input {
color: var(--text-primary) !important;
background: transparent !important;
font-size: 0.9375rem !important;
font-weight: 600 !important;
font-variant-numeric: tabular-nums !important;
}
.flatpickr-time input:hover,
.flatpickr-time input:focus {
background: var(--hover-bg) !important;
border-radius: 0.375rem !important;
}
.flatpickr-time .flatpickr-time-separator {
color: var(--text-secondary) !important;
font-weight: 600 !important;
}
.flatpickr-time .flatpickr-am-pm {
color: var(--text-primary) !important;
background: transparent !important;
}
.flatpickr-time .numInputWrapper span {
border: none !important;
}
.flatpickr-time .numInputWrapper span.arrowUp::after {
border-bottom-color: var(--text-secondary) !important;
}
.flatpickr-time .numInputWrapper span.arrowDown::after {
border-top-color: var(--text-secondary) !important;
}
.flatpickr-time .numInputWrapper:hover span.arrowUp::after {
border-bottom-color: var(--text-primary) !important;
}
.flatpickr-time .numInputWrapper:hover span.arrowDown::after {
border-top-color: var(--text-primary) !important;
}
+16 -12
View File
@@ -7,6 +7,7 @@ import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { LanguageProvider } from './contexts/LanguageContext'; import { LanguageProvider } from './contexts/LanguageContext';
import { BrandingProvider } from './contexts/BrandingContext'; import { BrandingProvider } from './contexts/BrandingContext';
import { NotificationProvider } from './contexts/NotificationContext';
import './index.css'; import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
@@ -16,18 +17,21 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<ThemeProvider> <ThemeProvider>
<BrandingProvider> <BrandingProvider>
<AuthProvider> <AuthProvider>
<App /> <NotificationProvider>
<Toaster <App />
position="top-right" <Toaster
toastOptions={{ position="top-right"
duration: 4000, containerStyle={{ top: 70 }}
style: { toastOptions={{
background: 'var(--card-bg)', duration: 4000,
color: 'var(--text-primary)', style: {
border: '1px solid var(--border)', background: 'var(--card-bg)',
}, color: 'var(--text-primary)',
}} border: '1px solid var(--border)',
/> },
}}
/>
</NotificationProvider>
</AuthProvider> </AuthProvider>
</BrandingProvider> </BrandingProvider>
</ThemeProvider> </ThemeProvider>
+842 -59
View File
File diff suppressed because it is too large Load Diff
+896
View File
@@ -0,0 +1,896 @@
import { useState, useEffect, useMemo } from 'react';
import {
ChevronLeft, ChevronRight, Plus, Clock, Video, Bell,
Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send, ExternalLink,
} from 'lucide-react';
import api from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import Modal from '../components/Modal';
import DateTimePicker from '../components/DateTimePicker';
import toast from 'react-hot-toast';
const COLORS = ['#6366f1', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6'];
export default function Calendar() {
const { user } = useAuth();
const { t } = useLanguage();
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [currentDate, setCurrentDate] = useState(new Date());
const [view, setView] = useState('month'); // month | week
const [rooms, setRooms] = useState([]);
// Modal state
const [showCreate, setShowCreate] = useState(false);
const [showDetail, setShowDetail] = useState(null);
const [editingEvent, setEditingEvent] = useState(null);
const [showShare, setShowShare] = useState(null);
const [showFedShare, setShowFedShare] = useState(null);
// Create/Edit form
const [form, setForm] = useState({
title: '', description: '', start_time: '', end_time: '',
room_uid: '', color: '#6366f1', reminder_minutes: null,
});
const [saving, setSaving] = useState(false);
// Share state
const [shareSearch, setShareSearch] = useState('');
const [shareResults, setShareResults] = useState([]);
const [sharedUsers, setSharedUsers] = useState([]);
const [pendingInvitations, setPendingInvitations] = useState([]);
const [fedAddress, setFedAddress] = useState('');
const [fedSending, setFedSending] = useState(false);
// Load events on month change
useEffect(() => {
fetchEvents();
}, [currentDate]);
useEffect(() => {
fetchRooms();
}, []);
const fetchEvents = async () => {
try {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const from = new Date(year, month - 1, 1).toISOString();
const to = new Date(year, month + 2, 0).toISOString();
const res = await api.get(`/calendar/events?from=${from}&to=${to}`);
setEvents(res.data.events || []);
} catch {
toast.error(t('calendar.loadFailed'));
} finally {
setLoading(false);
}
};
const fetchRooms = async () => {
try {
const res = await api.get('/rooms');
setRooms(res.data.rooms || []);
} catch { /* ignore */ }
};
// Calendar grid computation
const calendarDays = useMemo(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Start from Monday (ISO week)
let startOffset = firstDay.getDay() - 1;
if (startOffset < 0) startOffset = 6;
const calStart = new Date(year, month, 1 - startOffset);
const days = [];
const current = new Date(calStart);
for (let i = 0; i < 42; i++) {
days.push(new Date(current));
current.setDate(current.getDate() + 1);
}
return days;
}, [currentDate]);
const weekDays = useMemo(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const date = currentDate.getDate();
const dayOfWeek = currentDate.getDay();
let mondayOffset = dayOfWeek - 1;
if (mondayOffset < 0) mondayOffset = 6;
const monday = new Date(year, month, date - mondayOffset);
const days = [];
for (let i = 0; i < 7; i++) {
days.push(new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + i));
}
return days;
}, [currentDate]);
const eventsForDay = (day) => {
const dayStr = toLocalDateStr(day);
return events.filter(ev => {
const start = toLocalDateStr(new Date(ev.start_time));
const end = toLocalDateStr(new Date(ev.end_time));
return dayStr >= start && dayStr <= end;
});
};
const isToday = (day) => {
const today = new Date();
return day.toDateString() === today.toDateString();
};
const isCurrentMonth = (day) => {
return day.getMonth() === currentDate.getMonth();
};
const navigatePrev = () => {
const d = new Date(currentDate);
if (view === 'month') d.setMonth(d.getMonth() - 1);
else d.setDate(d.getDate() - 7);
setCurrentDate(d);
};
const navigateNext = () => {
const d = new Date(currentDate);
if (view === 'month') d.setMonth(d.getMonth() + 1);
else d.setDate(d.getDate() + 7);
setCurrentDate(d);
};
const goToToday = () => setCurrentDate(new Date());
const monthLabel = currentDate.toLocaleString('default', { month: 'long', year: 'numeric' });
const openCreateForDay = (day) => {
const start = new Date(day);
start.setHours(9, 0, 0, 0);
const end = new Date(day);
end.setHours(10, 0, 0, 0);
setForm({
title: '', description: '',
start_time: toLocalDateTimeStr(start),
end_time: toLocalDateTimeStr(end),
room_uid: '', color: '#6366f1', reminder_minutes: null,
});
setEditingEvent(null);
setShowCreate(true);
};
const openEdit = (ev) => {
setForm({
title: ev.title,
description: ev.description || '',
start_time: toLocalDateTimeStr(new Date(ev.start_time)),
end_time: toLocalDateTimeStr(new Date(ev.end_time)),
room_uid: ev.room_uid || '',
color: ev.color || '#6366f1',
reminder_minutes: ev.reminder_minutes ?? null,
});
setEditingEvent(ev);
setShowDetail(null);
setShowCreate(true);
};
const handleSave = async (e) => {
e.preventDefault();
setSaving(true);
try {
const data = {
...form,
start_time: new Date(form.start_time).toISOString(),
end_time: new Date(form.end_time).toISOString(),
};
if (editingEvent) {
await api.put(`/calendar/events/${editingEvent.id}`, data);
toast.success(t('calendar.eventUpdated'));
} else {
await api.post('/calendar/events', data);
toast.success(t('calendar.eventCreated'));
}
setShowCreate(false);
setEditingEvent(null);
fetchEvents();
} catch (err) {
toast.error(err.response?.data?.error || t('calendar.saveFailed'));
} finally {
setSaving(false);
}
};
const handleDelete = async (ev) => {
if (!confirm(t('calendar.deleteConfirm'))) return;
try {
await api.delete(`/calendar/events/${ev.id}`);
toast.success(t('calendar.eventDeleted'));
setShowDetail(null);
fetchEvents();
} catch {
toast.error(t('calendar.deleteFailed'));
}
};
const handleDownloadICS = async (ev) => {
try {
const res = await api.get(`/calendar/events/${ev.id}/ics`, { responseType: 'blob' });
const url = window.URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = `${ev.title}.ics`;
a.click();
window.URL.revokeObjectURL(url);
toast.success(t('calendar.icsDownloaded'));
} catch {
toast.error(t('calendar.icsFailed'));
}
};
const buildOutlookUrl = (ev) => {
const start = new Date(ev.start_time);
const end = new Date(ev.end_time);
const fmt = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
const baseUrl = window.location.origin;
const location = ev.room_uid ? `${baseUrl}/join/${ev.room_uid}` : '';
const body = [ev.description || '', location ? `\n\nMeeting: ${location}` : ''].join('');
const params = new URLSearchParams({
rru: 'addevent',
subject: ev.title,
startdt: start.toISOString(),
enddt: end.toISOString(),
body: body.trim(),
location,
allday: 'false',
path: '/calendar/action/compose',
});
return `https://outlook.live.com/calendar/0/action/compose?${params.toString()}`;
};
const buildGoogleCalUrl = (ev) => {
const fmt = (d) => new Date(d).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
const baseUrl = window.location.origin;
const location = ev.room_uid ? `${baseUrl}/join/${ev.room_uid}` : '';
const details = [ev.description || '', location ? `\nMeeting: ${location}` : ''].join('');
const params = new URLSearchParams({
action: 'TEMPLATE',
text: ev.title,
dates: `${fmt(ev.start_time)}/${fmt(ev.end_time)}`,
details: details.trim(),
location,
});
return `https://calendar.google.com/calendar/render?${params.toString()}`;
};
// Share functions
const openShareModal = async (ev) => {
setShowShare(ev);
setShareSearch('');
setShareResults([]);
setPendingInvitations([]);
try {
const res = await api.get(`/calendar/events/${ev.id}`);
setSharedUsers(res.data.sharedUsers || []);
setPendingInvitations(res.data.pendingInvitations || []);
} catch { /* ignore */ }
};
const searchUsers = async (query) => {
setShareSearch(query);
if (query.length < 2) { setShareResults([]); return; }
try {
const res = await api.get(`/rooms/users/search?q=${encodeURIComponent(query)}`);
const sharedIds = new Set(sharedUsers.map(u => u.id));
const pendingIds = new Set(pendingInvitations.map(u => u.user_id));
setShareResults(res.data.users.filter(u => !sharedIds.has(u.id) && !pendingIds.has(u.id)));
} catch { setShareResults([]); }
};
const handleShare = async (userId) => {
if (!showShare) return;
try {
const res = await api.post(`/calendar/events/${showShare.id}/share`, { user_id: userId });
setSharedUsers(res.data.sharedUsers);
setPendingInvitations(res.data.pendingInvitations || []);
setShareSearch('');
setShareResults([]);
toast.success(t('calendar.invitationSent'));
} catch (err) {
toast.error(err.response?.data?.error || t('calendar.shareFailed'));
}
};
const handleUnshare = async (userId) => {
if (!showShare) return;
try {
const res = await api.delete(`/calendar/events/${showShare.id}/share/${userId}`);
setSharedUsers(res.data.sharedUsers);
setPendingInvitations(res.data.pendingInvitations || []);
toast.success(t('calendar.shareRemoved'));
} catch { toast.error(t('calendar.shareFailed')); }
};
const handleCancelInvitation = async (userId) => {
if (!showShare) return;
try {
const res = await api.delete(`/calendar/events/${showShare.id}/share/${userId}`);
setSharedUsers(res.data.sharedUsers);
setPendingInvitations(res.data.pendingInvitations || []);
toast.success(t('calendar.invitationCancelled'));
} catch { toast.error(t('calendar.shareFailed')); }
};
const handleFedSend = async (e) => {
e.preventDefault();
if (!showFedShare) return;
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
if (!normalized.includes('@') || normalized.endsWith('@')) {
toast.error(t('federation.addressHint'));
return;
}
setFedSending(true);
try {
await api.post(`/calendar/events/${showFedShare.id}/federation`, { to: fedAddress });
toast.success(t('calendar.fedSent'));
setShowFedShare(null);
setFedAddress('');
} catch (err) {
toast.error(err.response?.data?.error || t('calendar.fedFailed'));
} finally {
setFedSending(false);
}
};
const dayNames = [
t('calendar.mon'), t('calendar.tue'), t('calendar.wed'),
t('calendar.thu'), t('calendar.fri'), t('calendar.sat'), t('calendar.sun'),
];
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 size={32} className="animate-spin text-th-accent" />
</div>
);
}
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-th-text">{t('calendar.title')}</h1>
<p className="text-sm text-th-text-s mt-1">{t('calendar.subtitle')}</p>
</div>
<button onClick={() => {
const now = new Date();
now.setHours(now.getHours() + 1, 0, 0, 0);
const end = new Date(now);
end.setHours(end.getHours() + 1);
setForm({
title: '', description: '',
start_time: toLocalDateTimeStr(now),
end_time: toLocalDateTimeStr(end),
room_uid: '', color: '#6366f1',
});
setEditingEvent(null);
setShowCreate(true);
}} className="btn-primary">
<Plus size={18} />
<span className="hidden sm:inline">{t('calendar.newEvent')}</span>
</button>
</div>
{/* Toolbar */}
<div className="card p-3 mb-4 flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<button onClick={navigatePrev} className="btn-ghost p-2">
<ChevronLeft size={18} />
</button>
<button onClick={goToToday} className="btn-ghost text-sm px-3 py-1.5">
{t('calendar.today')}
</button>
<button onClick={navigateNext} className="btn-ghost p-2">
<ChevronRight size={18} />
</button>
<h2 className="text-lg font-semibold text-th-text ml-2">{monthLabel}</h2>
</div>
<div className="flex items-center bg-th-bg-s rounded-lg border border-th-border p-0.5">
<button
onClick={() => setView('month')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
view === 'month' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
}`}
>
{t('calendar.month')}
</button>
<button
onClick={() => setView('week')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
view === 'week' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
}`}
>
{t('calendar.week')}
</button>
</div>
</div>
{/* Calendar Grid */}
<div className="card overflow-hidden">
{/* Day headers */}
<div className="grid grid-cols-7 border-b border-th-border">
{dayNames.map((name, i) => (
<div key={i} className="py-2.5 text-center text-xs font-semibold text-th-text-s uppercase tracking-wider border-r border-th-border last:border-r-0">
{name}
</div>
))}
</div>
{/* Days */}
{view === 'month' ? (
<div className="grid grid-cols-7">
{calendarDays.map((day, i) => {
const dayEvents = eventsForDay(day);
const today = isToday(day);
const inMonth = isCurrentMonth(day);
return (
<div
key={i}
onClick={() => openCreateForDay(day)}
className={`min-h-[100px] p-1.5 border-r border-b border-th-border last:border-r-0 cursor-pointer hover:bg-th-hover/50 transition-colors
${!inMonth ? 'opacity-40' : ''}`}
>
<div className={`text-xs font-medium mb-1 w-6 h-6 flex items-center justify-center rounded-full
${today ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s'}`}>
{day.getDate()}
</div>
<div className="space-y-0.5">
{dayEvents.slice(0, 3).map(ev => (
<div
key={ev.id}
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
className="text-[10px] leading-tight px-1.5 py-0.5 rounded-sm truncate text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: ev.color || '#6366f1' }}
title={ev.title}
>
{formatTime(ev.start_time)} {ev.title}
</div>
))}
{dayEvents.length > 3 && (
<div className="text-[10px] text-th-text-s font-medium px-1.5">
+{dayEvents.length - 3} {t('calendar.more')}
</div>
)}
</div>
</div>
);
})}
</div>
) : (
/* Week view */
<div className="grid grid-cols-7">
{weekDays.map((day, i) => {
const dayEvents = eventsForDay(day);
const today = isToday(day);
return (
<div
key={i}
onClick={() => openCreateForDay(day)}
className="min-h-[300px] p-2 border-r border-b border-th-border last:border-r-0 cursor-pointer hover:bg-th-hover/50 transition-colors"
>
<div className={`text-sm font-medium mb-2 w-7 h-7 flex items-center justify-center rounded-full
${today ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s'}`}>
{day.getDate()}
</div>
<div className="space-y-1">
{dayEvents.map(ev => (
<div
key={ev.id}
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
className="text-xs px-2 py-1.5 rounded-sm text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: ev.color || '#6366f1' }}
>
<div className="flex items-center gap-1 truncate">
{ev.reminder_minutes && <Bell size={9} className="shrink-0 opacity-70" />}
<span className="truncate">{ev.title}</span>
</div>
<div className="opacity-80 text-[10px]">{formatTime(ev.start_time)} - {formatTime(ev.end_time)}</div>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Create/Edit Modal */}
{showCreate && (
<Modal title={editingEvent ? t('calendar.editEvent') : t('calendar.createEvent')} onClose={() => { setShowCreate(false); setEditingEvent(null); }}>
<form onSubmit={handleSave} className="space-y-4">
<div>
<label htmlFor="calendar-event-title" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.eventTitle')} *</label>
<input
id="calendar-event-title"
type="text"
value={form.title}
onChange={e => setForm({ ...form, title: e.target.value })}
className="input-field"
placeholder={t('calendar.eventTitlePlaceholder')}
required
/>
</div>
<div>
<label htmlFor="calendar-event-description" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.description')}</label>
<textarea
id="calendar-event-description"
value={form.description}
onChange={e => setForm({ ...form, description: e.target.value })}
className="input-field resize-none"
rows={2}
placeholder={t('calendar.descriptionPlaceholder')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<DateTimePicker
label={t('calendar.startTime')}
value={form.start_time}
onChange={v => setForm({ ...form, start_time: v })}
required
icon="calendar"
/>
<DateTimePicker
label={t('calendar.endTime')}
value={form.end_time}
onChange={v => setForm({ ...form, end_time: v })}
required
icon="clock"
minDate={form.start_time ? new Date(form.start_time) : null}
/>
</div>
<div className="flex items-center gap-1.5 -mt-2 text-xs text-th-text-s">
<Globe size={12} className="shrink-0" />
<span>{getLocalTimezone()}</span>
</div>
<div>
<label htmlFor="calendar-event-room" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.linkedRoom')}</label>
<select
id="calendar-event-room"
value={form.room_uid}
onChange={e => setForm({ ...form, room_uid: e.target.value })}
className="input-field"
>
<option value="">{t('calendar.noRoom')}</option>
{rooms.map(r => (
<option key={r.uid} value={r.uid}>{r.name}</option>
))}
</select>
<p className="text-xs text-th-text-s mt-1">{t('calendar.linkedRoomHint')}</p>
</div>
<div>
<label htmlFor="calendar-event-reminder" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.reminderLabel')}</label>
<div className="relative">
<Bell size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none" aria-hidden="true" />
<select
id="calendar-event-reminder"
value={form.reminder_minutes ?? ''}
onChange={e => setForm({ ...form, reminder_minutes: e.target.value === '' ? null : Number(e.target.value) })}
className="input-field pl-9"
>
<option value="">{t('calendar.reminderNone')}</option>
<option value="5">{t('calendar.reminder5')}</option>
<option value="15">{t('calendar.reminder15')}</option>
<option value="30">{t('calendar.reminder30')}</option>
<option value="60">{t('calendar.reminder60')}</option>
<option value="120">{t('calendar.reminder120')}</option>
<option value="1440">{t('calendar.reminder1440')}</option>
</select>
</div>
</div>
<div>
<p id="calendar-event-color-label" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.color')}</p>
<div role="radiogroup" aria-labelledby="calendar-event-color-label" className="flex gap-2">
{COLORS.map(c => (
<button
key={c}
type="button"
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'}`}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
<div className="flex items-center gap-3 pt-4 border-t border-th-border">
<button type="button" onClick={() => { setShowCreate(false); setEditingEvent(null); }} className="btn-secondary flex-1">
{t('common.cancel')}
</button>
<button type="submit" disabled={saving} className="btn-primary flex-1">
{saving ? <Loader2 size={18} className="animate-spin" /> : t('common.save')}
</button>
</div>
</form>
</Modal>
)}
{/* Event Detail Modal */}
{showDetail && (
<Modal title={showDetail.title} onClose={() => setShowDetail(null)}>
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-th-text-s">
<Clock size={14} />
<span>
{new Date(showDetail.start_time).toLocaleString()} - {new Date(showDetail.end_time).toLocaleString()}
</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-th-text-s opacity-70 -mt-2">
<Globe size={12} />
<span>{getLocalTimezone()}</span>
</div>
{showDetail.description && (
<p className="text-sm text-th-text">{showDetail.description}</p>
)}
{showDetail.room_uid && (
<div className="flex items-center gap-2 text-sm">
<Video size={14} className="text-th-accent" />
<a
href={`/rooms/${showDetail.room_uid}`}
className="text-th-accent hover:underline"
onClick={(e) => { e.preventDefault(); window.location.href = `/rooms/${showDetail.room_uid}`; }}
>
{t('calendar.openRoom')}
</a>
</div>
)}
{showDetail.federated_from && (
<div className="flex items-center gap-2 text-xs text-th-text-s">
<Globe size={12} />
<span>{t('calendar.federatedFrom')}: {showDetail.federated_from}</span>
</div>
)}
{showDetail.federated_join_url && (
<a
href={showDetail.federated_join_url}
target="_blank"
rel="noopener noreferrer"
className="btn-primary text-sm w-full justify-center"
>
<Video size={14} />
{t('calendar.joinFederatedMeeting')}
</a>
)}
{showDetail.organizer_name && (
<div className="text-xs text-th-text-s">
{t('calendar.organizer')}: {showDetail.organizer_name}
</div>
)}
{/* Actions */}
<div className="flex flex-wrap items-center gap-2 pt-4 border-t border-th-border">
<a
href={buildOutlookUrl(showDetail)}
target="_blank"
rel="noopener noreferrer"
className="btn-ghost text-xs py-1.5 px-3 inline-flex items-center gap-1.5 no-underline"
>
<ExternalLink size={14} />
{t('calendar.addToOutlook')}
</a>
<a
href={buildGoogleCalUrl(showDetail)}
target="_blank"
rel="noopener noreferrer"
className="btn-ghost text-xs py-1.5 px-3 inline-flex items-center gap-1.5 no-underline"
>
<ExternalLink size={14} />
{t('calendar.addToGoogleCalendar')}
</a>
<button onClick={() => handleDownloadICS(showDetail)} className="btn-ghost text-xs py-1.5 px-3">
<Download size={14} />
{t('calendar.downloadICS')}
</button>
{showDetail.is_owner && (
<>
<button onClick={() => openEdit(showDetail)} className="btn-ghost text-xs py-1.5 px-3">
<Edit size={14} />
{t('common.edit')}
</button>
<button onClick={() => openShareModal(showDetail)} className="btn-ghost text-xs py-1.5 px-3">
<Share2 size={14} />
{t('calendar.share')}
</button>
<button onClick={() => { setShowFedShare(showDetail); setShowDetail(null); }} className="btn-ghost text-xs py-1.5 px-3">
<Globe size={14} />
{t('calendar.sendFederated')}
</button>
<button onClick={() => handleDelete(showDetail)} className="btn-ghost text-xs py-1.5 px-3 text-th-error hover:text-th-error">
<Trash2 size={14} />
{t('common.delete')}
</button>
</>
)}
</div>
</div>
</Modal>
)}
{/* Share Modal */}
{showShare && (
<Modal title={t('calendar.shareEvent')} onClose={() => setShowShare(null)}>
<div className="space-y-4">
<div className="relative">
<UserPlus size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={shareSearch}
onChange={e => searchUsers(e.target.value)}
className="input-field pl-11"
placeholder={t('room.shareSearchPlaceholder')}
/>
{shareResults.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-th-card border border-th-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
{shareResults.map(u => (
<button
key={u.id}
onClick={() => handleShare(u.id)}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
<div className="text-xs text-th-text-s truncate">{u.email}</div>
</div>
</button>
))}
</div>
)}
</div>
{pendingInvitations.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold text-th-text-s uppercase tracking-wider">{t('calendar.pendingInvitations')}</p>
{pendingInvitations.map(u => (
<div key={u.user_id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border border-dashed">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
<div className="text-xs text-th-warning">{t('calendar.invitationPending')}</div>
</div>
</div>
<button onClick={() => handleCancelInvitation(u.user_id)} className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors">
<X size={16} />
</button>
</div>
))}
</div>
)}
{sharedUsers.length > 0 && (
<div className="space-y-2">
{pendingInvitations.length > 0 && (
<p className="text-xs font-semibold text-th-text-s uppercase tracking-wider">{t('calendar.accepted')}</p>
)}
{sharedUsers.map(u => (
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
<div className="text-xs text-th-text-s truncate">{u.email}</div>
</div>
</div>
<button onClick={() => handleUnshare(u.id)} className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors">
<X size={16} />
</button>
</div>
))}
</div>
)}
</div>
</Modal>
)}
{/* Federation Share Modal */}
{showFedShare && (
<Modal title={t('calendar.sendFederatedTitle')} onClose={() => setShowFedShare(null)}>
<p className="text-sm text-th-text-s mb-4">{t('calendar.sendFederatedDesc')}</p>
<form onSubmit={handleFedSend} className="space-y-4">
<div>
<label htmlFor="calendar-fed-share-address" className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
<input
id="calendar-fed-share-address"
type="text"
value={fedAddress}
onChange={e => setFedAddress(e.target.value)}
className="input-field"
placeholder={t('federation.addressPlaceholder')}
required
/>
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
</div>
<div className="flex items-center gap-3 pt-2 border-t border-th-border">
<button type="button" onClick={() => setShowFedShare(null)} className="btn-secondary flex-1">
{t('common.cancel')}
</button>
<button type="submit" disabled={fedSending} className="btn-primary flex-1">
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
{t('calendar.send')}
</button>
</div>
</form>
</Modal>
)}
</div>
);
}
// Helpers
function toLocalDateStr(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function toLocalDateTimeStr(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
return `${y}-${m}-${d}T${h}:${min}`;
}
function formatTime(dateStr) {
const d = new Date(dateStr);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function getLocalTimezone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
const offset = -new Date().getTimezoneOffset();
const sign = offset >= 0 ? '+' : '-';
const h = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0');
const m = String(Math.abs(offset) % 60).padStart(2, '0');
return `UTC${sign}${h}:${m}`;
}
}
+16 -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,20 +201,23 @@ export default function Dashboard() {
<Modal title={t('dashboard.createRoom')} onClose={() => setShowCreate(false)}> <Modal title={t('dashboard.createRoom')} onClose={() => setShowCreate(false)}>
<form onSubmit={handleCreate} className="space-y-4"> <form onSubmit={handleCreate} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')} *</label> <label htmlFor="create-room-name" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')} *</label>
<input <input
id="create-room-name"
type="text" type="text"
value={newRoom.name} value={newRoom.name}
onChange={e => setNewRoom({ ...newRoom, name: e.target.value })} onChange={e => setNewRoom({ ...newRoom, name: e.target.value })}
className="input-field" className="input-field"
placeholder={t('dashboard.roomNamePlaceholder')} placeholder={t('dashboard.roomNamePlaceholder')}
required required
minLength={2}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.welcomeMessage')}</label> <label htmlFor="create-room-welcome" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.welcomeMessage')}</label>
<textarea <textarea
id="create-room-welcome"
value={newRoom.welcome_message} value={newRoom.welcome_message}
onChange={e => setNewRoom({ ...newRoom, welcome_message: e.target.value })} onChange={e => setNewRoom({ ...newRoom, welcome_message: e.target.value })}
className="input-field resize-none" className="input-field resize-none"
@@ -221,8 +228,9 @@ export default function Dashboard() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.maxParticipants')}</label> <label htmlFor="create-room-max" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.maxParticipants')}</label>
<input <input
id="create-room-max"
type="number" type="number"
value={newRoom.max_participants} value={newRoom.max_participants}
onChange={e => setNewRoom({ ...newRoom, max_participants: parseInt(e.target.value) || 0 })} onChange={e => setNewRoom({ ...newRoom, max_participants: parseInt(e.target.value) || 0 })}
@@ -232,8 +240,9 @@ export default function Dashboard() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.accessCode')}</label> <label htmlFor="create-room-access-code" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.accessCode')}</label>
<input <input
id="create-room-access-code"
type="text" type="text"
value={newRoom.access_code} value={newRoom.access_code}
onChange={e => setNewRoom({ ...newRoom, access_code: e.target.value })} onChange={e => setNewRoom({ ...newRoom, access_code: e.target.value })}
@@ -249,7 +258,7 @@ export default function Dashboard() {
type="checkbox" type="checkbox"
checked={newRoom.mute_on_join} checked={newRoom.mute_on_join}
onChange={e => setNewRoom({ ...newRoom, mute_on_join: e.target.checked })} onChange={e => setNewRoom({ ...newRoom, mute_on_join: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring" className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/> />
<span className="text-sm text-th-text">{t('dashboard.muteOnJoin')}</span> <span className="text-sm text-th-text">{t('dashboard.muteOnJoin')}</span>
</label> </label>
@@ -258,7 +267,7 @@ export default function Dashboard() {
type="checkbox" type="checkbox"
checked={newRoom.record_meeting} checked={newRoom.record_meeting}
onChange={e => setNewRoom({ ...newRoom, record_meeting: e.target.checked })} onChange={e => setNewRoom({ ...newRoom, record_meeting: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring" className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/> />
<span className="text-sm text-th-text">{t('dashboard.allowRecording')}</span> <span className="text-sm text-th-text">{t('dashboard.allowRecording')}</span>
</label> </label>
+16 -6
View File
@@ -39,7 +39,17 @@ export default function FederatedRoomDetail() {
}, [id]); }, [id]);
const handleJoin = () => { const handleJoin = () => {
window.open(room.join_url, '_blank'); // Validate URL scheme to prevent javascript: or other malicious URIs
try {
const url = new URL(room.join_url);
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
toast.error(t('federation.invalidJoinUrl'));
return;
}
window.open(room.join_url, '_blank');
} catch {
toast.error(t('federation.invalidJoinUrl'));
}
}; };
const handleRemove = async () => { const handleRemove = async () => {
@@ -83,7 +93,7 @@ export default function FederatedRoomDetail() {
{isDeleted && ( {isDeleted && (
<div className="card p-4 mb-4 border-red-500/30 bg-red-500/10"> <div className="card p-4 mb-4 border-red-500/30 bg-red-500/10">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<AlertTriangle size={20} className="text-red-500 flex-shrink-0" /> <AlertTriangle size={20} className="text-red-500 shrink-0" />
<div> <div>
<p className="text-sm font-semibold text-red-500">{t('federation.roomDeleted')}</p> <p className="text-sm font-semibold text-red-500">{t('federation.roomDeleted')}</p>
<p className="text-xs text-th-text-s mt-0.5">{t('federation.roomDeletedNotice')}</p> <p className="text-xs text-th-text-s mt-0.5">{t('federation.roomDeletedNotice')}</p>
@@ -96,7 +106,7 @@ export default function FederatedRoomDetail() {
<div className="card p-6 mb-4"> <div className="card p-6 mb-4">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-start gap-3 min-w-0"> <div className="flex items-start gap-3 min-w-0">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${isDeleted ? 'bg-red-500/15' : 'bg-th-accent/15'}`}> <div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 mt-0.5 ${isDeleted ? 'bg-red-500/15' : 'bg-th-accent/15'}`}>
{isDeleted ? <AlertTriangle size={20} className="text-red-500" /> : <Globe size={20} className="text-th-accent" />} {isDeleted ? <AlertTriangle size={20} className="text-red-500" /> : <Globe size={20} className="text-th-accent" />}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -118,7 +128,7 @@ export default function FederatedRoomDetail() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 shrink-0">
{!isDeleted && ( {!isDeleted && (
<button <button
onClick={handleJoin} onClick={handleJoin}
@@ -179,7 +189,7 @@ export default function FederatedRoomDetail() {
{room.meet_id && ( {room.meet_id && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Hash size={16} className="text-th-accent flex-shrink-0 mt-0.5" /> <Hash size={16} className="text-th-accent shrink-0 mt-0.5" />
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs text-th-text-s mb-0.5">{t('federation.meetingId')}</p> <p className="text-xs text-th-text-s mb-0.5">{t('federation.meetingId')}</p>
<p className="text-sm font-mono text-th-text break-all">{room.meet_id}</p> <p className="text-sm font-mono text-th-text break-all">{room.meet_id}</p>
@@ -188,7 +198,7 @@ export default function FederatedRoomDetail() {
)} )}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Link2 size={16} className="text-th-accent flex-shrink-0 mt-0.5" /> <Link2 size={16} className="text-th-accent shrink-0 mt-0.5" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-xs text-th-text-s mb-0.5">{t('federation.joinUrl')}</p> <p className="text-xs text-th-text-s mb-0.5">{t('federation.joinUrl')}</p>
<p className="text-sm font-mono text-th-text break-all opacity-60 select-all">{room.join_url}</p> <p className="text-sm font-mono text-th-text break-all opacity-60 select-all">{room.join_url}</p>
+273 -31
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Globe, Mail, Check, X, ExternalLink, Loader2, Inbox } from 'lucide-react'; import { Globe, Mail, Check, X, ExternalLink, Loader2, Inbox, Calendar, Trash2 } from 'lucide-react';
import api from '../services/api'; import api from '../services/api';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -7,12 +7,20 @@ import toast from 'react-hot-toast';
export default function FederationInbox() { export default function FederationInbox() {
const { t } = useLanguage(); const { t } = useLanguage();
const [invitations, setInvitations] = useState([]); const [invitations, setInvitations] = useState([]);
const [calendarInvitations, setCalendarInvitations] = useState([]);
const [localCalInvitations, setLocalCalInvitations] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchInvitations = async () => { const fetchInvitations = async () => {
try { try {
const res = await api.get('/federation/invitations'); const [roomRes, calRes, localCalRes] = await Promise.all([
setInvitations(res.data.invitations || []); api.get('/federation/invitations'),
api.get('/federation/calendar-invitations').catch(() => ({ data: { invitations: [] } })),
api.get('/calendar/local-invitations').catch(() => ({ data: { invitations: [] } })),
]);
setInvitations(roomRes.data.invitations || []);
setCalendarInvitations(calRes.data.invitations || []);
setLocalCalInvitations(localCalRes.data.invitations || []);
} catch { } catch {
toast.error(t('federation.loadFailed')); toast.error(t('federation.loadFailed'));
} finally { } finally {
@@ -24,6 +32,7 @@ export default function FederationInbox() {
fetchInvitations(); fetchInvitations();
}, []); }, []);
// ── Room invitation actions ──────────────────────────────────────────────
const handleAccept = async (id) => { const handleAccept = async (id) => {
try { try {
await api.post(`/federation/invitations/${id}/accept`); await api.post(`/federation/invitations/${id}/accept`);
@@ -44,6 +53,78 @@ export default function FederationInbox() {
} }
}; };
const handleDeleteInvitation = async (id) => {
try {
await api.delete(`/federation/invitations/${id}`);
toast.success(t('federation.invitationRemoved'));
fetchInvitations();
} catch {
toast.error(t('federation.declineFailed'));
}
};
// ── Calendar invitation actions ──────────────────────────────────────────
const handleCalAccept = async (id) => {
try {
await api.post(`/federation/calendar-invitations/${id}/accept`);
toast.success(t('federation.calendarAccepted'));
fetchInvitations();
} catch {
toast.error(t('federation.acceptFailed'));
}
};
const handleCalDecline = async (id) => {
try {
await api.delete(`/federation/calendar-invitations/${id}`);
toast.success(t('federation.declined'));
fetchInvitations();
} catch {
toast.error(t('federation.declineFailed'));
}
};
const handleCalDelete = async (id) => {
try {
await api.delete(`/federation/calendar-invitations/${id}`);
toast.success(t('federation.invitationRemoved'));
fetchInvitations();
} catch {
toast.error(t('federation.declineFailed'));
}
};
// ── Local calendar invitation actions ───────────────────────────────────
const handleLocalCalAccept = async (id) => {
try {
await api.post(`/calendar/local-invitations/${id}/accept`);
toast.success(t('federation.calendarLocalAccepted'));
fetchInvitations();
} catch {
toast.error(t('federation.acceptFailed'));
}
};
const handleLocalCalDecline = async (id) => {
try {
await api.delete(`/calendar/local-invitations/${id}`);
toast.success(t('federation.declined'));
fetchInvitations();
} catch {
toast.error(t('federation.declineFailed'));
}
};
const handleLocalCalDelete = async (id) => {
try {
await api.delete(`/calendar/local-invitations/${id}`);
toast.success(t('federation.invitationRemoved'));
fetchInvitations();
} catch {
toast.error(t('federation.declineFailed'));
}
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
@@ -52,8 +133,15 @@ export default function FederationInbox() {
); );
} }
const pending = invitations.filter(i => i.status === 'pending'); const pendingRooms = invitations.filter(i => i.status === 'pending');
const past = invitations.filter(i => i.status !== 'pending'); const pastRooms = invitations.filter(i => i.status !== 'pending');
const pendingCal = calendarInvitations.filter(i => i.status === 'pending');
const pastCal = calendarInvitations.filter(i => i.status !== 'pending');
const pendingLocalCal = localCalInvitations.filter(i => i.status === 'pending');
const pastLocalCal = localCalInvitations.filter(i => i.status !== 'pending');
const totalPending = pendingRooms.length + pendingCal.length + pendingLocalCal.length;
const totalPast = pastRooms.length + pastCal.length + pastLocalCal.length;
return ( return (
<div> <div>
@@ -67,18 +155,19 @@ export default function FederationInbox() {
</div> </div>
{/* Pending invitations */} {/* Pending invitations */}
{pending.length > 0 && ( {totalPending > 0 && (
<div className="mb-8"> <div className="mb-8">
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4"> <h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
{t('federation.pending')} ({pending.length}) {t('federation.pending')} ({totalPending})
</h2> </h2>
<div className="space-y-3"> <div className="space-y-3">
{pending.map(inv => ( {/* Pending room invitations */}
<div key={inv.id} className="card p-5 border-l-4 border-l-th-accent"> {pendingRooms.map(inv => (
<div key={`room-${inv.id}`} className="card p-5 border-l-4 border-l-th-accent">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="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">
@@ -91,18 +180,90 @@ 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 <button onClick={() => handleAccept(inv.id)} className="btn-primary text-sm">
onClick={() => handleAccept(inv.id)}
className="btn-primary text-sm"
>
<Check size={16} /> <Check size={16} />
{t('federation.accept')} {t('federation.accept')}
</button> </button>
<button <button onClick={() => handleDecline(inv.id)} className="btn-secondary text-sm">
onClick={() => handleDecline(inv.id)} <X size={16} />
className="btn-secondary text-sm" {t('federation.decline')}
> </button>
</div>
</div>
</div>
))}
{/* Pending calendar invitations */}
{pendingCal.map(inv => (
<div key={`cal-${inv.id}`} className="card p-5 border-l-4 border-l-th-success">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<Calendar size={16} className="text-th-success shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wide text-th-success mr-1">
{t('federation.calendarEvent')}
</span>
<h3 className="text-base font-semibold text-th-text truncate">{inv.title}</h3>
</div>
<p className="text-sm text-th-text-s">
{t('federation.from')}: <span className="font-medium text-th-text">{inv.from_user}</span>
</p>
<p className="text-sm text-th-text-s mt-1">
{new Date(inv.start_time).toLocaleString()} - {new Date(inv.end_time).toLocaleString()}
</p>
{inv.description && (
<p className="text-sm text-th-text-s mt-1 italic">"{inv.description}"</p>
)}
<p className="text-xs text-th-text-s mt-1">
{new Date(inv.created_at).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => handleCalAccept(inv.id)} className="btn-primary text-sm">
<Check size={16} />
{t('federation.accept')}
</button>
<button onClick={() => handleCalDecline(inv.id)} className="btn-secondary text-sm">
<X size={16} />
{t('federation.decline')}
</button>
</div>
</div>
</div>
))}
{/* Pending local calendar invitations */}
{pendingLocalCal.map(inv => (
<div key={`localcal-${inv.id}`} className="card p-5 border-l-4 border-l-th-accent">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<Calendar size={16} className="text-th-accent shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wide text-th-accent mr-1">
{t('federation.localCalendarEvent')}
</span>
<h3 className="text-base font-semibold text-th-text truncate">{inv.title}</h3>
</div>
<p className="text-sm text-th-text-s">
{t('federation.from')}: <span className="font-medium text-th-text">{inv.from_name}</span>
</p>
<p className="text-sm text-th-text-s mt-1">
{new Date(inv.start_time).toLocaleString()} - {new Date(inv.end_time).toLocaleString()}
</p>
{inv.description && (
<p className="text-sm text-th-text-s mt-1 italic">"{inv.description}"</p>
)}
<p className="text-xs text-th-text-s mt-1">
{new Date(inv.created_at).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => handleLocalCalAccept(inv.id)} className="btn-primary text-sm">
<Check size={16} />
{t('federation.accept')}
</button>
<button onClick={() => handleLocalCalDecline(inv.id)} className="btn-secondary text-sm">
<X size={16} /> <X size={16} />
{t('federation.decline')} {t('federation.decline')}
</button> </button>
@@ -115,24 +276,28 @@ export default function FederationInbox() {
)} )}
{/* Past invitations */} {/* Past invitations */}
{past.length > 0 && ( {totalPast > 0 && (
<div className="mb-8"> <div className="mb-8">
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4"> <h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
{t('federation.previousInvites')} {t('federation.previousInvites')}
</h2> </h2>
<div className="space-y-2"> <div className="space-y-2">
{past.map(inv => ( {/* Past room invitations */}
<div key={inv.id} className="card p-4 opacity-60"> {pastRooms.map(inv => (
<div key={`room-past-${inv.id}`} className="card p-4 opacity-70">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0 flex items-center gap-2">
<h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3> <Mail size={14} className="text-th-text-s shrink-0" />
<p className="text-xs text-th-text-s">{inv.from_user}</p> <div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3>
<p className="text-xs text-th-text-s">{inv.from_user}</p>
</div>
</div> </div>
<div className="flex items-center gap-3 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'
}`}> }`}>
{inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')} {inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')}
</span> </span>
{inv.status === 'accepted' && ( {inv.status === 'accepted' && (
@@ -144,6 +309,82 @@ export default function FederationInbox() {
<ExternalLink size={14} /> <ExternalLink size={14} />
</button> </button>
)} )}
<button
onClick={() => handleDeleteInvitation(inv.id)}
className="btn-ghost text-xs py-1.5 px-2 text-th-text-s hover:text-th-error"
title={t('federation.removeInvitation')}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
))}
{/* Past calendar invitations */}
{pastCal.map(inv => (
<div key={`cal-past-${inv.id}`} className="card p-4 opacity-70">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex items-center gap-2">
<Calendar size={14} className="text-th-text-s shrink-0" />
<div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
<p className="text-xs text-th-text-s">{inv.from_user} · {new Date(inv.start_time).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error'
}`}>
{inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')}
</span>
{inv.status === 'accepted' && inv.join_url && (
<button
onClick={() => window.open(inv.join_url, '_blank')}
className="btn-ghost text-xs py-1.5 px-2"
title={t('federation.openLink')}
>
<ExternalLink size={14} />
</button>
)}
<button
onClick={() => handleCalDelete(inv.id)}
className="btn-ghost text-xs py-1.5 px-2 text-th-text-s hover:text-th-error"
title={t('federation.removeInvitation')}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
))}
{/* Past local calendar invitations */}
{pastLocalCal.map(inv => (
<div key={`localcal-past-${inv.id}`} className="card p-4 opacity-70">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex items-center gap-2">
<Calendar size={14} className="text-th-text-s shrink-0" />
<div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
<p className="text-xs text-th-text-s">{inv.from_name} · {new Date(inv.start_time).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error'
}`}>
{inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')}
</span>
<button
onClick={() => handleLocalCalDelete(inv.id)}
className="btn-ghost text-xs py-1.5 px-2 text-th-text-s hover:text-th-error"
title={t('federation.removeInvitation')}
>
<Trash2 size={14} />
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -153,7 +394,7 @@ export default function FederationInbox() {
)} )}
{/* Empty state */} {/* Empty state */}
{invitations.length === 0 && ( {totalPending === 0 && totalPast === 0 && (
<div className="card p-12 text-center"> <div className="card p-12 text-center">
<Inbox size={48} className="mx-auto text-th-text-s/40 mb-4" /> <Inbox size={48} className="mx-auto text-th-text-s/40 mb-4" />
<h3 className="text-lg font-semibold text-th-text mb-2">{t('federation.noInvitations')}</h3> <h3 className="text-lg font-semibold text-th-text mb-2">{t('federation.noInvitations')}</h3>
@@ -163,3 +404,4 @@ export default function FederationInbox() {
</div> </div>
); );
} }
+222 -116
View File
@@ -1,33 +1,84 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link, useSearchParams, useNavigate } from 'react-router-dom';
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle } 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';
import { useBranding } from '../contexts/BrandingContext';
export default function GuestJoin() { export default function GuestJoin() {
const { uid } = useParams(); const { uid } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { t } = useLanguage(); const { t } = useLanguage();
const { user } = useAuth(); const { user } = useAuth();
const { imprintUrl, privacyUrl } = useBranding();
const isLoggedIn = !!user; const isLoggedIn = !!user;
const [roomInfo, setRoomInfo] = useState(null); const [roomInfo, setRoomInfo] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [joining, setJoining] = useState(false); const [joining, setJoining] = useState(false);
const [name, setName] = useState(user?.name || ''); const [name, setName] = useState(user?.display_name || user?.name || '');
const [accessCode, setAccessCode] = useState(''); const [accessCode, setAccessCode] = useState(searchParams.get('ac') || '');
const [moderatorCode, setModeratorCode] = useState(''); const [moderatorCode, setModeratorCode] = useState('');
const [status, setStatus] = useState({ running: false }); const [status, setStatus] = useState({ running: false });
const [recordingConsent, setRecordingConsent] = useState(false); const [recordingConsent, setRecordingConsent] = useState(false);
const [waiting, setWaiting] = useState(false);
const prevRunningRef = useRef(false);
const joinMeeting = async () => {
setJoining(true);
try {
const payload = {
name: name.trim(),
access_code: accessCode || undefined,
moderator_code: moderatorCode || undefined,
dark_mode: isCurrentThemeDark(),
};
// If logged in, send avatar data
if (isLoggedIn && user) {
if (user.avatar_image) payload.avatar_image = user.avatar_image;
if (user.avatar_color) payload.avatar_color = user.avatar_color;
}
const res = await api.post(`/rooms/${uid}/guest-join`, payload);
if (res.data.joinUrl) {
window.location.href = res.data.joinUrl;
}
} catch (err) {
const errStatus = err.response?.status;
if (errStatus === 403) {
toast.error(t('room.guestWrongAccessCode'));
setWaiting(false);
} else {
toast.error(t('room.guestJoinFailed'));
setWaiting(false);
}
} finally {
setJoining(false);
}
};
useEffect(() => { useEffect(() => {
const fetchRoom = async () => { const fetchRoom = async () => {
// If logged in, check if user owns or has access to this room
if (isLoggedIn) {
try {
await api.get(`/rooms/${uid}`);
navigate(`/rooms/${uid}`, { replace: true });
return;
} catch {
// User doesn't have access — continue as guest
}
}
try { try {
const res = await api.get(`/rooms/${uid}/public`); const res = await api.get(`/rooms/${uid}/public`);
setRoomInfo(res.data.room); setRoomInfo(res.data.room);
setStatus({ running: res.data.running }); setStatus({ running: res.data.running });
prevRunningRef.current = res.data.running;
} catch (err) { } catch (err) {
const status = err.response?.status; const status = err.response?.status;
if (status === 403) { if (status === 403) {
@@ -50,45 +101,36 @@ export default function GuestJoin() {
} catch { } catch {
// ignore // ignore
} }
}, 10000); }, 5000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [uid]); }, [uid]);
// Auto-join when meeting starts while waiting
useEffect(() => {
if (!prevRunningRef.current && status.running && waiting) {
new Audio('/sounds/meeting-started.mp3').play().catch(() => { });
toast.success(t('room.guestMeetingStartedJoining'));
joinMeeting();
}
prevRunningRef.current = status.running;
}, [status.running]);
const handleJoin = async (e) => { const handleJoin = async (e) => {
e.preventDefault(); e.preventDefault();
if (!name.trim()) { if (!name.trim()) {
toast.error(t('room.guestNameRequired')); toast.error(t('room.guestNameRequired'));
return; return;
} }
if (roomInfo?.allow_recording && !recordingConsent) { if (roomInfo?.allow_recording && !recordingConsent) {
toast.error(t('room.guestRecordingConsent')); toast.error(t('room.guestRecordingConsent'));
return; return;
} }
if (!status.running && !roomInfo?.anyone_can_start) {
setJoining(true); setWaiting(true);
try { return;
const res = await api.post(`/rooms/${uid}/guest-join`, {
name: name.trim(),
access_code: accessCode || undefined,
moderator_code: moderatorCode || undefined,
});
if (res.data.joinUrl) {
window.location.href = res.data.joinUrl;
}
} catch (err) {
const status = err.response?.status;
if (status === 403) {
toast.error(t('room.guestWrongAccessCode'));
} else if (status === 400) {
toast.error(t('room.guestWaitingMessage'));
} else {
toast.error(t('room.guestJoinFailed'));
}
} finally {
setJoining(false);
} }
await joinMeeting();
}; };
if (loading) { if (loading) {
@@ -161,97 +203,131 @@ export default function GuestJoin() {
</div> </div>
{/* Join form */} {/* Join form */}
<form onSubmit={handleJoin} className="space-y-4"> {waiting ? (
<div> <div className="flex flex-col items-center gap-5 py-4">
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label> <div className="w-16 h-16 rounded-full flex items-center justify-center bg-th-accent/10">
<div className="relative"> <Clock size={28} className="text-th-accent animate-pulse" />
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={name}
onChange={e => !isLoggedIn && setName(e.target.value)}
readOnly={isLoggedIn}
className={`input-field pl-11 ${isLoggedIn ? 'opacity-70 cursor-not-allowed' : ''}`}
placeholder={t('room.guestNamePlaceholder')}
required
autoFocus={!isLoggedIn}
/>
</div> </div>
</div> <div className="text-center">
<p className="font-semibold text-th-text mb-1">{t('room.guestWaitingTitle')}</p>
{roomInfo.has_access_code && ( <p className="text-sm text-th-text-s">{t('room.guestWaitingHint')}</p>
<div> </div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</label> {joining && (
<div className="relative"> <div className="flex items-center gap-2 text-sm text-th-success font-medium">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Loader2 size={16} className="animate-spin" />
<input {t('room.guestMeetingStartedJoining')}
type="text"
value={accessCode}
onChange={e => setAccessCode(e.target.value)}
className="input-field pl-11"
placeholder={t('room.guestAccessCodePlaceholder')}
/>
</div> </div>
</div>
)}
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">
{t('room.guestModeratorCode')}
<span className="text-th-text-s font-normal ml-1">{t('room.guestModeratorOptional')}</span>
</label>
<div className="relative">
<Shield size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={moderatorCode}
onChange={e => setModeratorCode(e.target.value)}
className="input-field pl-11"
placeholder={t('room.guestModeratorPlaceholder')}
/>
</div>
</div>
{/* Recording consent notice */}
{roomInfo.allow_recording && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertCircle size={16} className="text-amber-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-400">{t('room.guestRecordingNotice')}</p>
</div>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={recordingConsent}
onChange={e => setRecordingConsent(e.target.checked)}
className="w-4 h-4 rounded accent-amber-500 cursor-pointer"
/>
<span className="text-sm text-th-text">{t('room.guestRecordingConsent')}</span>
</label>
</div>
)}
<button
type="submit"
disabled={joining || (!status.running && !roomInfo.anyone_can_start) || (roomInfo.allow_recording && !recordingConsent)}
className="btn-primary w-full py-3"
>
{joining ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
{t('room.guestJoinButton')}
<ArrowRight size={18} />
</>
)} )}
</button> {!joining && (
<button
type="button"
onClick={() => setWaiting(false)}
className="btn-ghost flex items-center gap-2 text-sm"
>
<X size={16} />
{t('room.guestCancelWaiting')}
</button>
)}
</div>
) : (
<form onSubmit={handleJoin} className="space-y-4">
<div>
<label htmlFor="guest-name" className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="guest-name"
type="text"
autoComplete="name"
value={name}
onChange={e => !isLoggedIn && setName(e.target.value)}
readOnly={isLoggedIn}
className={`input-field pl-11 ${isLoggedIn ? 'opacity-70 cursor-not-allowed' : ''}`}
placeholder={t('room.guestNamePlaceholder')}
required
autoFocus={!isLoggedIn}
/>
</div>
</div>
{!status.running && ( {roomInfo.has_access_code && (
<p className="text-xs text-th-text-s text-center"> <div>
{t('room.guestWaitingMessage')} <label htmlFor="guest-access-code" className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</label>
</p> <div className="relative">
)} <Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
</form> <input
id="guest-access-code"
type="text"
autoComplete="off"
value={accessCode}
onChange={e => setAccessCode(e.target.value)}
className="input-field pl-11"
placeholder={t('room.guestAccessCodePlaceholder')}
/>
</div>
</div>
)}
<div>
<label htmlFor="guest-moderator-code" className="block text-sm font-medium text-th-text mb-1.5">
{t('room.guestModeratorCode')}
<span className="text-th-text-s font-normal ml-1">{t('room.guestModeratorOptional')}</span>
</label>
<div className="relative">
<Shield size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="guest-moderator-code"
type="text"
autoComplete="off"
value={moderatorCode}
onChange={e => setModeratorCode(e.target.value)}
className="input-field pl-11"
placeholder={t('room.guestModeratorPlaceholder')}
/>
</div>
</div>
{/* Recording consent notice */}
{roomInfo.allow_recording && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertCircle size={16} className="text-amber-500 shrink-0 mt-0.5" />
<p className="text-sm text-amber-400">{t('room.guestRecordingNotice')}</p>
</div>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={recordingConsent}
onChange={e => setRecordingConsent(e.target.checked)}
className="w-4 h-4 rounded-sm accent-amber-500 cursor-pointer"
/>
<span className="text-sm text-th-text">{t('room.guestRecordingConsent')}</span>
</label>
</div>
)}
<button
type="submit"
disabled={joining || (roomInfo.allow_recording && !recordingConsent)}
className="btn-primary w-full py-3"
>
{joining ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
{t('room.guestJoinButton')}
<ArrowRight size={18} />
</>
)}
</button>
{!status.running && !roomInfo?.anyone_can_start && (
<p className="text-xs text-th-text-s text-center">
{t('room.guestWaitingMessage')}
</p>
)}
</form>
)}
{!isLoggedIn && ( {!isLoggedIn && (
<div className="mt-6 pt-4 border-t border-th-border text-center"> <div className="mt-6 pt-4 border-t border-th-border text-center">
@@ -260,6 +336,36 @@ export default function GuestJoin() {
</Link> </Link>
</div> </div>
)} )}
{(imprintUrl || privacyUrl) && (
<div className="flex items-center justify-center gap-4 mt-4 pt-4 border-t border-th-border/60">
{imprintUrl && (
<a
href={imprintUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
>
<FileText size={11} />
{t('nav.imprint')}
</a>
)}
{imprintUrl && privacyUrl && (
<span className="text-th-border text-xs">·</span>
)}
{privacyUrl && (
<a
href={privacyUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
>
<Lock size={11} />
{t('nav.privacy')}
</a>
)}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
+80 -77
View File
@@ -1,10 +1,13 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe } 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';
export default function Home() { export default function Home() {
const { t } = useLanguage(); const { t } = useLanguage();
const { registrationMode, imprintUrl, privacyUrl } = useBranding();
const isInviteOnly = registrationMode === 'invite';
const features = [ const features = [
{ {
@@ -18,116 +21,87 @@ 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">
{t('auth.login')} {t('auth.login')}
</Link> </Link>
<Link to="/register" className="btn-primary text-sm"> {!isInviteOnly && (
{t('auth.register')} <Link to="/register" className="btn-primary text-sm">
<ArrowRight size={16} /> {t('auth.register')}
</Link> </Link>
)}
</div> </div>
</nav> </nav>
{/* Hero content */} {/* Hero */}
<div className="relative z-10 max-w-4xl mx-auto text-center px-6 pt-20 pb-32"> <div className="relative z-10 max-w-3xl mx-auto px-6 pt-24 pb-20">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-th-accent/10 text-th-accent text-sm font-medium mb-6"> <h1 className="text-3xl md:text-4xl font-bold text-th-text mb-4">
<Zap size={14} /> {t('home.heroTitle')}
{t('home.poweredBy')}
</div>
<h1 className="text-4xl md:text-6xl lg:text-7xl font-extrabold text-th-text mb-6 leading-tight tracking-tight">
{t('home.heroTitle')}{' '}
<span className="gradient-text">{t('home.heroTitleHighlight')}</span>
</h1> </h1>
<p className="text-lg text-th-text-s mb-8 leading-relaxed">
<p className="text-lg md:text-xl text-th-text-s max-w-2xl mx-auto mb-10 leading-relaxed">
{t('home.heroSubtitle')} {t('home.heroSubtitle')}
</p> </p>
<div className="flex items-center gap-3">
<div className="flex items-center gap-4 justify-center"> {!isInviteOnly && (
<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="btn-secondary text-base px-8 py-3"> )}
<Link to="/login" className={`${isInviteOnly ? 'btn-primary' : 'btn-secondary'} px-6 py-2.5`}>
{t('auth.login')} {t('auth.login')}
</Link> </Link>
</div> </div>
{/* Stats */}
<div className="flex items-center justify-center gap-8 md:gap-16 mt-16">
{[
{ value: '15+', label: t('home.statThemes') },
{ value: '∞', label: t('home.statRooms') },
{ value: '100%', label: t('home.statOpenSource') },
].map(stat => (
<div key={stat.label} className="text-center">
<div className="text-2xl md:text-3xl font-bold gradient-text">{stat.value}</div>
<div className="text-sm text-th-text-s mt-1">{stat.label}</div>
</div>
))}
</div>
</div> </div>
</div> </div>
{/* Features Section */} {/* Features */}
<div className="max-w-6xl mx-auto px-6 py-20"> <div className="border-t border-th-border">
<div className="text-center mb-16"> <div className="max-w-5xl mx-auto px-6 py-16">
<h2 className="text-3xl md:text-4xl font-bold text-th-text mb-4"> <h2 className="text-xl font-semibold text-th-text mb-10">
{t('home.features')} {t('home.features')}
</h2> </h2>
<p className="text-lg text-th-text-s max-w-2xl mx-auto"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{t('home.featuresSubtitle')} {features.map(feature => (
</p> <div key={feature.title} className="card p-6 hover:shadow-th-lg transition-shadow">
</div> <div className="w-12 h-12 rounded-xl bg-th-accent/10 flex items-center justify-center mb-4">
<feature.icon size={24} className="text-th-accent" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> </div>
{features.map((feature, idx) => ( <h3 className="text-base font-semibold text-th-text mb-2">{feature.title}</h3>
<div key={idx} className="card p-6 hover:shadow-th-lg transition-all duration-300 group"> <p className="text-sm text-th-text-s leading-relaxed">{feature.desc}</p>
<div className="w-12 h-12 rounded-xl gradient-bg/10 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform"
style={{ background: `linear-gradient(135deg, var(--gradient-start), var(--gradient-end))`, opacity: 0.15 }}>
<feature.icon size={24} className="text-th-accent" style={{ opacity: 1 }} />
</div> </div>
<div className="w-12 h-12 rounded-xl flex items-center justify-center mb-4 -mt-16 relative"> ))}
<feature.icon size={24} className="text-th-accent" /> </div>
</div>
<h3 className="text-base font-semibold text-th-text mb-2">{feature.title}</h3>
<p className="text-sm text-th-text-s leading-relaxed">{feature.desc}</p>
</div>
))}
</div> </div>
</div> </div>
@@ -136,6 +110,35 @@ export default function Home() {
<p className="text-sm text-th-text-s"> <p className="text-sm text-th-text-s">
{t('home.footer', { year: new Date().getFullYear() })} {t('home.footer', { year: new Date().getFullYear() })}
</p> </p>
{(imprintUrl || privacyUrl) && (
<div className="flex items-center justify-center gap-4 mt-3">
{imprintUrl && (
<a
href={imprintUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
>
<FileText size={12} />
{t('nav.imprint')}
</a>
)}
{imprintUrl && privacyUrl && (
<span className="text-th-border text-xs">·</span>
)}
{privacyUrl && (
<a
href={privacyUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
>
<Lock size={12} />
{t('nav.privacy')}
</a>
)}
</div>
)}
</footer> </footer>
</div> </div>
); );
+207 -78
View File
@@ -1,8 +1,9 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw } from 'lucide-react'; import { useBranding } from '../contexts/BrandingContext';
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw, LogIn, ShieldCheck } from 'lucide-react';
import BrandLogo from '../components/BrandLogo'; import BrandLogo from '../components/BrandLogo';
import api from '../services/api'; import api from '../services/api';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -14,8 +15,16 @@ export default function Login() {
const [needsVerification, setNeedsVerification] = useState(false); const [needsVerification, setNeedsVerification] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0); const [resendCooldown, setResendCooldown] = useState(0);
const [resending, setResending] = useState(false); const [resending, setResending] = useState(false);
const { login } = useAuth(); // 2FA state
const [needs2FA, setNeeds2FA] = useState(false);
const [tempToken, setTempToken] = useState('');
const [totpCode, setTotpCode] = useState('');
const [verifying2FA, setVerifying2FA] = useState(false);
const totpInputRef = useRef(null);
const { login, verify2FA } = useAuth();
const { t } = useLanguage(); const { t } = useLanguage();
const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
@@ -24,6 +33,13 @@ export default function Login() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [resendCooldown]); }, [resendCooldown]);
// Auto-focus TOTP input when 2FA screen appears
useEffect(() => {
if (needs2FA && totpInputRef.current) {
totpInputRef.current.focus();
}
}, [needs2FA]);
const handleResend = async () => { const handleResend = async () => {
if (resendCooldown > 0 || resending) return; if (resendCooldown > 0 || resending) return;
setResending(true); setResending(true);
@@ -46,7 +62,13 @@ export default function Login() {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
try { try {
await login(email, password); const result = await login(email, password);
if (result?.requires2FA) {
setTempToken(result.tempToken);
setNeeds2FA(true);
setLoading(false);
return;
}
toast.success(t('auth.loginSuccess')); toast.success(t('auth.loginSuccess'));
navigate('/dashboard'); navigate('/dashboard');
} catch (err) { } catch (err) {
@@ -60,6 +82,27 @@ export default function Login() {
} }
}; };
const handle2FASubmit = async (e) => {
e.preventDefault();
setVerifying2FA(true);
try {
await verify2FA(tempToken, totpCode);
toast.success(t('auth.loginSuccess'));
navigate('/dashboard');
} catch (err) {
toast.error(err.response?.data?.error || t('auth.2fa.verifyFailed'));
setTotpCode('');
} finally {
setVerifying2FA(false);
}
};
const handleBack = () => {
setNeeds2FA(false);
setTempToken('');
setTotpCode('');
};
return ( return (
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden"> <div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
{/* Animated background */} {/* Animated background */}
@@ -79,89 +122,175 @@ export default function Login() {
<BrandLogo size="lg" /> <BrandLogo size="lg" />
</div> </div>
<div className="mb-8"> {needs2FA ? (
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.welcomeBack')}</h2> <>
<p className="text-th-text-s"> {/* 2FA verification step */}
{t('auth.loginSubtitle')} <div className="mb-8 text-center">
</p> <div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-th-accent/10 mb-4">
</div> <ShieldCheck size={28} className="text-th-accent" />
</div>
<form onSubmit={handleSubmit} className="space-y-5"> <h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.2fa.title')}</h2>
<div> <p className="text-th-text-s text-sm">
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label> {t('auth.2fa.prompt')}
<div className="relative"> </p>
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="input-field pl-11"
placeholder={t('auth.emailPlaceholder')}
required
/>
</div> </div>
</div>
<div> <form onSubmit={handle2FASubmit} className="space-y-5">
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label> <div>
<div className="relative"> <label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.2fa.codeLabel')}</label>
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <div className="relative">
<input <ShieldCheck size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
type="password" <input
value={password} ref={totpInputRef}
onChange={e => setPassword(e.target.value)} type="text"
className="input-field pl-11" inputMode="numeric"
placeholder={t('auth.passwordPlaceholder')} autoComplete="one-time-code"
required value={totpCode}
/> onChange={e => setTotpCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
className="input-field pl-11 text-center text-lg tracking-[0.3em] font-mono"
placeholder="000 000"
required
maxLength={7}
/>
</div>
</div>
<button
type="submit"
disabled={verifying2FA || totpCode.replace(/\s/g, '').length < 6}
className="btn-primary w-full py-3"
>
{verifying2FA ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
{t('auth.2fa.verify')}
<ArrowRight size={18} />
</>
)}
</button>
</form>
<button
onClick={handleBack}
className="block mt-4 w-full text-center text-sm text-th-text-s hover:text-th-text transition-colors"
>
{t('auth.2fa.backToLogin')}
</button>
</>
) : (
<>
<div className="mb-8">
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.welcomeBack')}</h2>
<p className="text-th-text-s">
{t('auth.loginSubtitle')}
</p>
</div> </div>
</div>
<button <form onSubmit={handleSubmit} className="space-y-5">
type="submit" <div>
disabled={loading} <label htmlFor="login-email" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
className="btn-primary w-full py-3" <div className="relative">
> <Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
{loading ? ( <input
<Loader2 size={18} className="animate-spin" /> id="login-email"
) : ( type="email"
autoComplete="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="input-field pl-11"
placeholder={t('auth.emailPlaceholder')}
required
/>
</div>
</div>
<div>
<label htmlFor="login-password" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="login-password"
type="password"
autoComplete="current-password"
value={password}
onChange={e => setPassword(e.target.value)}
className="input-field pl-11"
placeholder={t('auth.passwordPlaceholder')}
required
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full py-3"
>
{loading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
{t('auth.login')}
<ArrowRight size={18} />
</>
)}
</button>
</form>
{oauthEnabled && (
<> <>
{t('auth.login')} <div className="relative my-6">
<ArrowRight size={18} /> <div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-th-border" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-3 bg-th-card/80 text-th-text-s">{t('auth.orContinueWith')}</span>
</div>
</div>
<a
href="/api/oauth/authorize"
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
>
<LogIn size={18} />
{t('auth.loginWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
</a>
</> </>
)} )}
</button>
</form>
{needsVerification && ( {needsVerification && (
<div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2"> <div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertTriangle size={16} className="text-amber-400 flex-shrink-0 mt-0.5" /> <AlertTriangle size={16} className="text-amber-400 shrink-0 mt-0.5" />
<p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p> <p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p>
</div> </div>
<button <button
onClick={handleResend} onClick={handleResend}
disabled={resendCooldown > 0 || resending} disabled={resendCooldown > 0 || resending}
className="flex items-center gap-1.5 text-sm text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors disabled:opacity-60 disabled:no-underline disabled:cursor-not-allowed" className="flex items-center gap-1.5 text-sm text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors disabled:opacity-60 disabled:no-underline disabled:cursor-not-allowed"
> >
<RefreshCw size={13} className={resending ? 'animate-spin' : ''} /> <RefreshCw size={13} className={resending ? 'animate-spin' : ''} />
{resendCooldown > 0 {resendCooldown > 0
? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown) ? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown)
: t('auth.emailVerificationResend')} : t('auth.emailVerificationResend')}
</button> </button>
</div> </div>
)}
{registrationMode !== 'invite' && (
<p className="mt-6 text-center text-sm text-th-text-s">
{t('auth.noAccount')}{' '}
<Link to="/register" className="text-th-accent hover:underline font-medium">
{t('auth.signUpNow')}
</Link>
</p>
)}
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
{t('auth.backToHome')}
</Link>
</>
)} )}
<p className="mt-6 text-center text-sm text-th-text-s">
{t('auth.noAccount')}{' '}
<Link to="/register" className="text-th-accent hover:underline font-medium">
{t('auth.signUpNow')}
</Link>
</p>
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
{t('auth.backToHome')}
</Link>
</div> </div>
</div> </div>
</div> </div>
+59
View File
@@ -0,0 +1,59 @@
import { Link } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import { Ghost, ArrowLeft, Home } from 'lucide-react';
export default function NotFound() {
const { t } = useLanguage();
return (
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
{/* Animated background */}
<div className="absolute inset-0 bg-th-bg">
<div className="absolute inset-0 opacity-20">
<div className="absolute top-1/3 left-1/3 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
<div className="absolute bottom-1/3 right-1/3 w-72 h-72 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '3s' }} />
</div>
</div>
<div className="relative w-full max-w-md text-center">
<div className="card p-10 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
{/* Ghost icon with subtle animation */}
<div className="flex justify-center mb-6">
<div className="w-20 h-20 bg-th-accent/10 rounded-full flex items-center justify-center animate-bounce" style={{ animationDuration: '2s' }}>
<Ghost size={40} className="text-th-accent" />
</div>
</div>
{/* 404 number */}
<h1 className="text-7xl font-extrabold text-th-text mb-2 tracking-tight">404</h1>
<h2 className="text-xl font-semibold text-th-text mb-2">
{t('notFound.title')}
</h2>
<p className="text-th-text-s mb-8">
{t('notFound.description')}
</p>
{/* Action buttons */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<button
onClick={() => window.history.back()}
className="btn-secondary w-full sm:w-auto px-5 py-2.5 flex items-center justify-center gap-2"
>
<ArrowLeft size={16} />
{t('notFound.goBack')}
</button>
<Link
to="/"
className="btn-primary w-full sm:w-auto px-5 py-2.5 flex items-center justify-center gap-2"
>
<Home size={16} />
{t('notFound.goHome')}
</Link>
</div>
</div>
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { Loader2, AlertTriangle } from 'lucide-react';
import toast from 'react-hot-toast';
export default function OAuthCallback() {
const [searchParams] = useSearchParams();
const [error, setError] = useState(null);
const { loginWithOAuth } = useAuth();
const { t } = useLanguage();
const navigate = useNavigate();
useEffect(() => {
// Token is passed via hash fragment (never sent to server, not logged, not in Referer).
// Error is still a regular query param since it contains no sensitive data.
const hash = window.location.hash.slice(1); // strip leading '#'
const hashParams = new URLSearchParams(hash);
const token = hashParams.get('token');
const errorMsg = searchParams.get('error');
const returnTo = hashParams.get('return_to') || searchParams.get('return_to') || '/dashboard';
if (errorMsg) {
setError(errorMsg);
return;
}
if (!token) {
setError(t('auth.oauthNoToken'));
return;
}
// Store token and redirect
loginWithOAuth(token)
.then(() => {
toast.success(t('auth.loginSuccess'));
navigate(returnTo, { replace: true });
})
.catch(() => {
setError(t('auth.oauthLoginFailed'));
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (error) {
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="absolute inset-0 bg-th-bg" />
<div className="relative w-full max-w-md">
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl text-center">
<div className="flex justify-center mb-4">
<div className="w-12 h-12 bg-red-500/20 rounded-full flex items-center justify-center">
<AlertTriangle size={24} className="text-red-400" />
</div>
</div>
<h2 className="text-xl font-bold text-th-text mb-2">{t('auth.oauthError')}</h2>
<p className="text-th-text-s mb-6">{error}</p>
<button
onClick={() => navigate('/login', { replace: true })}
className="btn-primary w-full py-3"
>
{t('auth.backToLogin')}
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="absolute inset-0 bg-th-bg" />
<div className="relative flex flex-col items-center gap-4">
<Loader2 size={32} className="animate-spin text-th-accent" />
<p className="text-th-text-s">{t('auth.oauthRedirecting')}</p>
</div>
</div>
);
}
+60 -14
View File
@@ -1,12 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle } from 'lucide-react'; import { useBranding } from '../contexts/BrandingContext';
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle, ShieldAlert, LogIn } from 'lucide-react';
import BrandLogo from '../components/BrandLogo'; import BrandLogo from '../components/BrandLogo';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
export default function Register() { export default function Register() {
const [searchParams] = useSearchParams();
const inviteToken = searchParams.get('invite') || '';
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState(''); const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@@ -16,8 +19,12 @@ export default function Register() {
const [needsVerification, setNeedsVerification] = useState(false); const [needsVerification, setNeedsVerification] = useState(false);
const { register } = useAuth(); const { register } = useAuth();
const { t } = useLanguage(); const { t } = useLanguage();
const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
const navigate = useNavigate(); const navigate = useNavigate();
// Invite-only mode without a token → show blocked message
const isBlocked = registrationMode === 'invite' && !inviteToken;
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
@@ -26,14 +33,14 @@ export default function Register() {
return; return;
} }
if (password.length < 6) { if (password.length < 8) {
toast.error(t('auth.passwordTooShort')); toast.error(t('auth.passwordTooShort'));
return; return;
} }
setLoading(true); setLoading(true);
try { try {
const result = await register(username, displayName, email, password); const result = await register(username, displayName, email, password, inviteToken);
if (result?.needsVerification) { if (result?.needsVerification) {
setNeedsVerification(true); setNeedsVerification(true);
toast.success(t('auth.verificationSent')); toast.success(t('auth.verificationSent'));
@@ -77,6 +84,15 @@ export default function Register() {
{t('auth.login')} {t('auth.login')}
</Link> </Link>
</div> </div>
) : isBlocked ? (
<div className="text-center space-y-4">
<ShieldAlert size={48} className="mx-auto text-amber-400" />
<h2 className="text-2xl font-bold text-th-text">{t('auth.inviteOnly')}</h2>
<p className="text-th-text-s">{t('auth.inviteOnlyDesc')}</p>
<Link to="/login" className="btn-primary inline-flex items-center gap-2 mt-4">
{t('auth.login')}
</Link>
</div>
) : ( ) : (
<> <>
<div className="mb-8"> <div className="mb-8">
@@ -88,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"
@@ -104,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"
@@ -119,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"
@@ -134,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"
@@ -150,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"
@@ -181,6 +207,26 @@ export default function Register() {
</button> </button>
</form> </form>
{oauthEnabled && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-th-border" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-3 bg-th-card/80 text-th-text-s">{t('auth.orContinueWith')}</span>
</div>
</div>
<a
href="/api/oauth/authorize"
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
>
<LogIn size={18} />
{t('auth.registerWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
</a>
</>
)}
<p className="mt-6 text-center text-sm text-th-text-s"> <p className="mt-6 text-center text-sm text-th-text-s">
{t('auth.hasAccount')}{' '} {t('auth.hasAccount')}{' '}
<Link to="/login" className="text-th-accent hover:underline font-medium"> <Link to="/login" className="text-th-accent hover:underline font-medium">
+216 -50
View File
@@ -4,13 +4,15 @@ import {
ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio, ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio,
Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck, Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck,
Shield, Save, UserPlus, X, Share2, Globe, Send, Shield, Save, UserPlus, X, Share2, Globe, Send,
FileText, Upload, Trash2, FileText, Upload, Trash2, Link, BarChart3,
} from 'lucide-react'; } from 'lucide-react';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
import api from '../services/api'; import api from '../services/api';
import { isCurrentThemeDark } from '../themes';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import RecordingList from '../components/RecordingList'; import RecordingList from '../components/RecordingList';
import AnalyticsList from '../components/AnalyticsList';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
export default function RoomDetail() { export default function RoomDetail() {
@@ -22,6 +24,7 @@ export default function RoomDetail() {
const [room, setRoom] = useState(null); const [room, setRoom] = useState(null);
const [status, setStatus] = useState({ running: false, participantCount: 0, moderatorCount: 0 }); const [status, setStatus] = useState({ running: false, participantCount: 0, moderatorCount: 0 });
const [recordings, setRecordings] = useState([]); const [recordings, setRecordings] = useState([]);
const [analytics, setAnalytics] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(null); const [actionLoading, setActionLoading] = useState(null);
const [activeTab, setActiveTab] = useState('overview'); const [activeTab, setActiveTab] = useState('overview');
@@ -31,10 +34,25 @@ export default function RoomDetail() {
const [shareSearch, setShareSearch] = useState(''); const [shareSearch, setShareSearch] = useState('');
const [shareResults, setShareResults] = useState([]); const [shareResults, setShareResults] = useState([]);
const [shareSearching, setShareSearching] = useState(false); const [shareSearching, setShareSearching] = useState(false);
const [waitingToJoin, setWaitingToJoin] = useState(false);
const prevRunningRef = useRef(false);
const [showCopyMenu, setShowCopyMenu] = useState(false);
const copyMenuRef = useRef(null);
useEffect(() => {
const handleClickOutside = (e) => {
if (copyMenuRef.current && !copyMenuRef.current.contains(e.target)) {
setShowCopyMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Federation invite state // Federation invite state
const [showFedInvite, setShowFedInvite] = useState(false); const [showFedInvite, setShowFedInvite] = useState(false);
const [fedAddress, setFedAddress] = useState(''); const [fedAddress, setFedAddress] = useState('');
const [fedEmails, setFedEmails] = useState('');
const [fedMessage, setFedMessage] = useState(''); const [fedMessage, setFedMessage] = useState('');
const [fedSending, setFedSending] = useState(false); const [fedSending, setFedSending] = useState(false);
@@ -45,7 +63,10 @@ export default function RoomDetail() {
const isOwner = room && user && room.user_id === user.id; const isOwner = room && user && room.user_id === user.id;
const isShared = room && !!room.shared; const isShared = room && !!room.shared;
const isAdmin = user?.role === 'admin';
const canManage = isOwner || isShared; const canManage = isOwner || isShared;
const canStart = canManage || isAdmin || !!room?.anyone_can_start;
const canEnd = canManage || isAdmin;
const fetchRoom = async () => { const fetchRoom = async () => {
try { try {
@@ -79,18 +100,43 @@ export default function RoomDetail() {
} }
}; };
const fetchAnalytics = async () => {
try {
const res = await api.get(`/analytics/room/${uid}`);
setAnalytics(res.data.analytics || []);
} catch {
// Ignore
}
};
useEffect(() => { useEffect(() => {
fetchRoom(); fetchRoom();
fetchStatus(); fetchStatus();
fetchRecordings(); fetchRecordings();
fetchAnalytics();
const interval = setInterval(fetchStatus, 10000); const interval = setInterval(fetchStatus, 10000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [uid]); }, [uid]);
// Auto-join when meeting starts while waiting
useEffect(() => {
if (!prevRunningRef.current && status.running && waitingToJoin) {
new Audio('/sounds/meeting-started.mp3').play().catch(() => {});
toast.success(t('room.meetingStarted'));
setWaitingToJoin(false);
setActionLoading('join');
api.post(`/rooms/${uid}/join`, { dark_mode: isCurrentThemeDark() })
.then(res => { if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank'); })
.catch(err => toast.error(err.response?.data?.error || t('room.joinFailed')))
.finally(() => setActionLoading(null));
}
prevRunningRef.current = status.running;
}, [status.running]);
const handleStart = async () => { const handleStart = async () => {
setActionLoading('start'); setActionLoading('start');
try { try {
const res = await api.post(`/rooms/${uid}/start`); const res = await api.post(`/rooms/${uid}/start`, { dark_mode: isCurrentThemeDark() });
if (res.data.joinUrl) { if (res.data.joinUrl) {
window.open(res.data.joinUrl, '_blank'); window.open(res.data.joinUrl, '_blank');
} }
@@ -104,9 +150,16 @@ export default function RoomDetail() {
}; };
const handleJoin = async () => { const handleJoin = async () => {
if (!status.running) {
setWaitingToJoin(true);
toast(t('room.guestWaitingTitle'), { icon: '🕐' });
return;
}
setWaitingToJoin(false);
setActionLoading('join'); setActionLoading('join');
try { try {
const data = room.access_code ? { access_code: prompt(t('room.enterAccessCode')) } : {}; const data = { dark_mode: isCurrentThemeDark() };
if (room.access_code) data.access_code = prompt(t('room.enterAccessCode'));
const res = await api.post(`/rooms/${uid}/join`, data); const res = await api.post(`/rooms/${uid}/join`, data);
if (res.data.joinUrl) { if (res.data.joinUrl) {
window.open(res.data.joinUrl, '_blank'); window.open(res.data.joinUrl, '_blank');
@@ -148,6 +201,8 @@ export default function RoomDetail() {
record_meeting: !!editRoom.record_meeting, record_meeting: !!editRoom.record_meeting,
guest_access: !!editRoom.guest_access, guest_access: !!editRoom.guest_access,
moderator_code: editRoom.moderator_code, moderator_code: editRoom.moderator_code,
learning_analytics: !!editRoom.learning_analytics,
analytics_visibility: editRoom.analytics_visibility || 'owner',
}); });
setRoom(res.data.room); setRoom(res.data.room);
setEditRoom(res.data.room); setEditRoom(res.data.room);
@@ -159,9 +214,10 @@ export default function RoomDetail() {
} }
}; };
const copyLink = () => { const copyToClipboard = (url) => {
navigator.clipboard.writeText(`${window.location.origin}/rooms/${uid}`); navigator.clipboard.writeText(url);
toast.success(t('room.linkCopied')); toast.success(t('room.linkCopied'));
setShowCopyMenu(false);
}; };
// Federation invite handler // Federation invite handler
@@ -216,25 +272,51 @@ export default function RoomDetail() {
const handleFedInvite = async (e) => { const handleFedInvite = async (e) => {
e.preventDefault(); e.preventDefault();
// Accept @user@domain or user@domain — must have a domain part const hasAddress = fedAddress.trim().length > 0;
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress; const hasEmails = fedEmails.trim().length > 0;
if (!normalized.includes('@') || normalized.endsWith('@')) {
if (!hasAddress && !hasEmails) {
toast.error(t('federation.addressHint')); toast.error(t('federation.addressHint'));
return; return;
} }
setFedSending(true); setFedSending(true);
try { try {
await api.post('/federation/invite', { if (hasAddress) {
room_uid: uid, // Federation address mode
to: fedAddress, const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
message: fedMessage || undefined, if (!normalized.includes('@') || normalized.endsWith('@')) {
}); toast.error(t('federation.addressHint'));
toast.success(t('federation.sent')); setFedSending(false);
return;
}
await api.post('/federation/invite', {
room_uid: uid,
to: fedAddress,
message: fedMessage || undefined,
});
toast.success(t('federation.sent'));
} else {
// Email mode
const emailList = fedEmails.split(',').map(e => e.trim()).filter(Boolean);
if (emailList.length === 0) {
toast.error(t('federation.emailHint'));
setFedSending(false);
return;
}
await api.post('/rooms/invite-email', {
room_uid: uid,
emails: emailList,
message: fedMessage || undefined,
});
toast.success(t('federation.emailSent'));
}
setShowFedInvite(false); setShowFedInvite(false);
setFedAddress(''); setFedAddress('');
setFedEmails('');
setFedMessage(''); setFedMessage('');
} catch (err) { } catch (err) {
toast.error(err.response?.data?.error || t('federation.sendFailed')); toast.error(err.response?.data?.error || t(hasAddress ? 'federation.sendFailed' : 'federation.emailSendFailed'));
} finally { } finally {
setFedSending(false); setFedSending(false);
} }
@@ -295,6 +377,7 @@ export default function RoomDetail() {
const tabs = [ const tabs = [
{ id: 'overview', label: t('room.overview'), icon: Play }, { id: 'overview', label: t('room.overview'), icon: Play },
{ id: 'recordings', label: t('room.recordings'), icon: FileVideo, count: recordings.length }, { id: 'recordings', label: t('room.recordings'), icon: FileVideo, count: recordings.length },
{ id: 'analytics', label: t('room.analytics'), icon: BarChart3, count: analytics.length, hidden: !room.learning_analytics || (isShared && room.analytics_visibility !== 'shared') },
...(isOwner ? [{ id: 'settings', label: t('room.settings'), icon: Settings }] : []), ...(isOwner ? [{ id: 'settings', label: t('room.settings'), icon: Settings }] : []),
]; ];
@@ -333,10 +416,33 @@ export default function RoomDetail() {
{t('common.protected')} {t('common.protected')}
</span> </span>
)} )}
<button onClick={copyLink} className="flex items-center gap-1 hover:text-th-accent transition-colors"> <div className="relative" ref={copyMenuRef}>
<Copy size={14} /> <button
{t('room.copyLink')} onClick={() => setShowCopyMenu(v => !v)}
</button> className="flex items-center gap-1 hover:text-th-accent transition-colors"
>
<Copy size={14} />
{t('room.copyLink')}
</button>
{showCopyMenu && (
<div className="absolute bottom-full left-0 mb-2 bg-th-surface border border-th-border rounded-lg shadow-lg z-50 min-w-[160px] py-1">
<button
onClick={() => copyToClipboard(`${window.location.origin}/rooms/${uid}`)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
>
<Link size={12} />
{t('room.copyRoomLink')}
</button>
<button
onClick={() => copyToClipboard(`${window.location.origin}/join/${uid}`)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
>
<Users size={12} />
{t('room.copyGuestLink')}
</button>
</div>
)}
</div>
</div> </div>
</div> </div>
@@ -351,19 +457,22 @@ export default function RoomDetail() {
<span className="hidden sm:inline">{t('federation.inviteRemote')}</span> <span className="hidden sm:inline">{t('federation.inviteRemote')}</span>
</button> </button>
)} )}
{canManage && !status.running && ( {canStart && !status.running && !waitingToJoin && (
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary"> <button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />} {actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
{t('room.start')} {t('room.start')}
</button> </button>
)} )}
{status.running && ( <button
<button onClick={handleJoin} disabled={actionLoading === 'join'} className="btn-primary"> onClick={waitingToJoin ? () => setWaitingToJoin(false) : handleJoin}
{actionLoading === 'join' ? <Loader2 size={16} className="animate-spin" /> : <ExternalLink size={16} />} disabled={actionLoading === 'join'}
{t('room.join')} className={waitingToJoin ? 'btn-ghost' : 'btn-primary'}
</button> title={waitingToJoin ? t('room.guestCancelWaiting') : undefined}
)} >
{canManage && status.running && ( {(actionLoading === 'join' || waitingToJoin) ? <Loader2 size={16} className="animate-spin" /> : <ExternalLink size={16} />}
{waitingToJoin ? t('room.waitingToJoin') : t('room.join')}
</button>
{canEnd && status.running && (
<button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger"> <button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger">
{actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />} {actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />}
{t('room.end')} {t('room.end')}
@@ -375,7 +484,7 @@ export default function RoomDetail() {
{/* Tabs */} {/* Tabs */}
<div className="flex items-center gap-1 mb-6 border-b border-th-border"> <div className="flex items-center gap-1 mb-6 border-b border-th-border">
{tabs.map(tab => ( {tabs.filter(tab => !tab.hidden).map(tab => (
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
@@ -404,7 +513,7 @@ export default function RoomDetail() {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-th-text-s">{t('room.meetingId')}</span> <span className="text-th-text-s">{t('room.meetingId')}</span>
<code className="bg-th-bg-s px-2 py-0.5 rounded text-xs text-th-text font-mono">{room.uid}</code> <code className="bg-th-bg-s px-2 py-0.5 rounded-sm text-xs text-th-text font-mono">{room.uid}</code>
</div> </div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-th-text-s">{t('room.status')}</span> <span className="text-th-text-s">{t('room.status')}</span>
@@ -466,22 +575,29 @@ export default function RoomDetail() {
<RecordingList recordings={recordings} onRefresh={fetchRecordings} /> <RecordingList recordings={recordings} onRefresh={fetchRecordings} />
)} )}
{activeTab === 'analytics' && (
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} isOwner={isOwner} />
)}
{activeTab === 'settings' && isOwner && editRoom && ( {activeTab === 'settings' && isOwner && editRoom && (
<form onSubmit={handleSaveSettings} className="card p-6 space-y-5 max-w-2xl"> <form onSubmit={handleSaveSettings} className="card p-6 space-y-5 max-w-2xl">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')}</label> <label htmlFor="room-settings-name" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')}</label>
<input <input
id="room-settings-name"
type="text" type="text"
value={editRoom.name} value={editRoom.name}
onChange={e => setEditRoom({ ...editRoom, name: e.target.value })} onChange={e => setEditRoom({ ...editRoom, name: e.target.value })}
className="input-field" className="input-field"
required required
minLength={2}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.welcomeMsg')}</label> <label htmlFor="room-settings-welcome" className="block text-sm font-medium text-th-text mb-1.5">{t('room.welcomeMsg')}</label>
<textarea <textarea
id="room-settings-welcome"
value={editRoom.welcome_message || ''} value={editRoom.welcome_message || ''}
onChange={e => setEditRoom({ ...editRoom, welcome_message: e.target.value })} onChange={e => setEditRoom({ ...editRoom, welcome_message: e.target.value })}
className="input-field resize-none" className="input-field resize-none"
@@ -491,8 +607,9 @@ export default function RoomDetail() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.maxParticipants')}</label> <label htmlFor="room-settings-max" className="block text-sm font-medium text-th-text mb-1.5">{t('room.maxParticipants')}</label>
<input <input
id="room-settings-max"
type="number" type="number"
value={editRoom.max_participants} value={editRoom.max_participants}
onChange={e => setEditRoom({ ...editRoom, max_participants: parseInt(e.target.value) || 0 })} onChange={e => setEditRoom({ ...editRoom, max_participants: parseInt(e.target.value) || 0 })}
@@ -501,8 +618,9 @@ export default function RoomDetail() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.accessCode')}</label> <label htmlFor="room-settings-access-code" className="block text-sm font-medium text-th-text mb-1.5">{t('room.accessCode')}</label>
<input <input
id="room-settings-access-code"
type="text" type="text"
value={editRoom.access_code || ''} value={editRoom.access_code || ''}
onChange={e => setEditRoom({ ...editRoom, access_code: e.target.value })} onChange={e => setEditRoom({ ...editRoom, access_code: e.target.value })}
@@ -518,7 +636,7 @@ export default function RoomDetail() {
type="checkbox" type="checkbox"
checked={!!editRoom.mute_on_join} checked={!!editRoom.mute_on_join}
onChange={e => setEditRoom({ ...editRoom, mute_on_join: e.target.checked })} onChange={e => setEditRoom({ ...editRoom, mute_on_join: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring" className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/> />
<span className="text-sm text-th-text">{t('room.muteOnJoin')}</span> <span className="text-sm text-th-text">{t('room.muteOnJoin')}</span>
</label> </label>
@@ -527,7 +645,7 @@ export default function RoomDetail() {
type="checkbox" type="checkbox"
checked={!!editRoom.require_approval} checked={!!editRoom.require_approval}
onChange={e => setEditRoom({ ...editRoom, require_approval: e.target.checked })} onChange={e => setEditRoom({ ...editRoom, require_approval: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring" className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/> />
<span className="text-sm text-th-text">{t('room.requireApproval')}</span> <span className="text-sm text-th-text">{t('room.requireApproval')}</span>
</label> </label>
@@ -536,7 +654,7 @@ export default function RoomDetail() {
type="checkbox" type="checkbox"
checked={!!editRoom.anyone_can_start} checked={!!editRoom.anyone_can_start}
onChange={e => setEditRoom({ ...editRoom, anyone_can_start: e.target.checked })} onChange={e => setEditRoom({ ...editRoom, anyone_can_start: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring" className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/> />
<span className="text-sm text-th-text">{t('room.anyoneCanStart')}</span> <span className="text-sm text-th-text">{t('room.anyoneCanStart')}</span>
</label> </label>
@@ -545,7 +663,7 @@ export default function RoomDetail() {
type="checkbox" type="checkbox"
checked={!!editRoom.all_join_moderator} checked={!!editRoom.all_join_moderator}
onChange={e => setEditRoom({ ...editRoom, all_join_moderator: e.target.checked })} onChange={e => setEditRoom({ ...editRoom, all_join_moderator: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring" className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/> />
<span className="text-sm text-th-text">{t('room.allJoinModerator')}</span> <span className="text-sm text-th-text">{t('room.allJoinModerator')}</span>
</label> </label>
@@ -554,10 +672,34 @@ 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>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={!!editRoom.learning_analytics}
onChange={e => setEditRoom({ ...editRoom, learning_analytics: e.target.checked })}
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('room.enableAnalytics')}</span>
</label>
{!!editRoom.learning_analytics && (
<div className="ml-7">
<label htmlFor="room-settings-analytics-visibility" className="block text-xs font-medium text-th-text-s mb-1">{t('room.analyticsVisibility')}</label>
<select
id="room-settings-analytics-visibility"
value={editRoom.analytics_visibility || 'owner'}
onChange={e => setEditRoom({ ...editRoom, analytics_visibility: e.target.value })}
className="input-field text-sm py-1.5 max-w-xs"
>
<option value="owner">{t('room.analyticsOwnerOnly')}</option>
<option value="shared">{t('room.analyticsSharedUsers')}</option>
</select>
<p className="text-xs text-th-text-s mt-1">{t('room.analyticsVisibilityHint')}</p>
</div>
)}
</div> </div>
{/* Guest access section */} {/* Guest access section */}
@@ -565,8 +707,9 @@ export default function RoomDetail() {
<h3 className="text-sm font-semibold text-th-text">{t('room.guestAccessTitle')}</h3> <h3 className="text-sm font-semibold text-th-text">{t('room.guestAccessTitle')}</h3>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.moderatorCode')}</label> <label htmlFor="room-settings-moderator-code" className="block text-sm font-medium text-th-text mb-1.5">{t('room.moderatorCode')}</label>
<input <input
id="room-settings-moderator-code"
type="text" type="text"
value={editRoom.moderator_code || ''} value={editRoom.moderator_code || ''}
onChange={e => setEditRoom({ ...editRoom, moderator_code: e.target.value })} onChange={e => setEditRoom({ ...editRoom, moderator_code: e.target.value })}
@@ -608,11 +751,11 @@ export default function RoomDetail() {
{room.presentation_file ? ( {room.presentation_file ? (
<div className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border"> <div className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<FileText size={16} className="text-th-accent flex-shrink-0" /> <FileText size={16} className="text-th-accent shrink-0" />
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs text-th-text-s">{t('room.presentationCurrent')}</p> <p className="text-xs text-th-text-s">{t('room.presentationCurrent')}</p>
<p className="text-sm text-th-text font-medium truncate"> <p className="text-sm text-th-text font-medium truncate">
{room.presentation_name || `presentation.${room.presentation_file?.split('.').pop()}`} {room.presentation_file}
</p> </p>
</div> </div>
</div> </div>
@@ -620,7 +763,7 @@ export default function RoomDetail() {
type="button" type="button"
onClick={handlePresentationRemove} onClick={handlePresentationRemove}
disabled={removingPresentation} disabled={removingPresentation}
className="btn-ghost text-th-error hover:bg-th-error/10 flex-shrink-0 text-xs py-1.5 px-3" className="btn-ghost text-th-error hover:bg-th-error/10 shrink-0 text-xs py-1.5 px-3"
> >
{removingPresentation ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />} {removingPresentation ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
{t('room.presentationRemove')} {t('room.presentationRemove')}
@@ -679,7 +822,7 @@ export default function RoomDetail() {
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left" className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
> >
<div <div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden" className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0 overflow-hidden"
style={{ backgroundColor: u.avatar_color || '#6366f1' }} style={{ backgroundColor: u.avatar_color || '#6366f1' }}
> >
{u.avatar_image ? ( {u.avatar_image ? (
@@ -705,7 +848,7 @@ export default function RoomDetail() {
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border"> <div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<div <div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden" className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0 overflow-hidden"
style={{ backgroundColor: u.avatar_color || '#6366f1' }} style={{ backgroundColor: u.avatar_color || '#6366f1' }}
> >
{u.avatar_image ? ( {u.avatar_image ? (
@@ -722,7 +865,7 @@ export default function RoomDetail() {
<button <button
type="button" type="button"
onClick={() => handleUnshare(u.id)} onClick={() => handleUnshare(u.id)}
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors flex-shrink-0" className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors shrink-0"
title={t('room.shareRemove')} title={t('room.shareRemove')}
> >
<X size={16} /> <X size={16} />
@@ -748,20 +891,43 @@ export default function RoomDetail() {
<p className="text-sm text-th-text-s mb-4">{t('federation.inviteSubtitle')}</p> <p className="text-sm text-th-text-s mb-4">{t('federation.inviteSubtitle')}</p>
<form onSubmit={handleFedInvite} className="space-y-4"> <form onSubmit={handleFedInvite} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label> <label htmlFor="fed-invite-address" className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
<input <input
id="fed-invite-address"
type="text" type="text"
value={fedAddress} value={fedAddress}
onChange={e => setFedAddress(e.target.value)} onChange={e => { setFedAddress(e.target.value); if (e.target.value) setFedEmails(''); }}
className="input-field" className="input-field"
placeholder={t('federation.addressPlaceholder')} placeholder={t('federation.addressPlaceholder')}
required disabled={fedEmails.trim().length > 0}
/> />
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p> <p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
</div> </div>
<div className="flex items-center gap-3 my-2">
<div className="flex-1 border-t border-th-border" />
<span className="text-xs text-th-text-s uppercase">{t('common.or')}</span>
<div className="flex-1 border-t border-th-border" />
</div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label> <label htmlFor="fed-invite-emails" className="block text-sm font-medium text-th-text mb-1.5">{t('federation.emailLabel')}</label>
<input
id="fed-invite-emails"
type="text"
value={fedEmails}
onChange={e => { setFedEmails(e.target.value); if (e.target.value) setFedAddress(''); }}
className="input-field"
placeholder={t('federation.emailPlaceholder')}
disabled={fedAddress.trim().length > 0}
/>
<p className="text-xs text-th-text-s mt-1">{t('federation.emailHint')}</p>
</div>
<div>
<label htmlFor="fed-invite-message" className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label>
<textarea <textarea
id="fed-invite-message"
value={fedMessage} value={fedMessage}
onChange={e => setFedMessage(e.target.value)} onChange={e => setFedMessage(e.target.value)}
className="input-field resize-none" className="input-field resize-none"
@@ -773,7 +939,7 @@ export default function RoomDetail() {
<button type="button" onClick={() => setShowFedInvite(false)} className="btn-secondary flex-1"> <button type="button" onClick={() => setShowFedInvite(false)} className="btn-secondary flex-1">
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button type="submit" disabled={fedSending} className="btn-primary flex-1"> <button type="submit" disabled={fedSending || (!fedAddress.trim() && !fedEmails.trim())} className="btn-primary flex-1">
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />} {fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
{t('federation.send')} {t('federation.send')}
</button> </button>
+418 -21
View File
@@ -1,5 +1,5 @@
import { useState, useRef } from 'react'; import { useState, useRef, useEffect } from 'react';
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X } from 'lucide-react'; import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X, Calendar, Plus, Trash2, Copy, Eye, EyeOff, Shield, ShieldCheck, ShieldOff } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
@@ -38,6 +38,121 @@ export default function Settings() {
const [uploadingAvatar, setUploadingAvatar] = useState(false); const [uploadingAvatar, setUploadingAvatar] = useState(false);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
// CalDAV token state
const [caldavTokens, setCaldavTokens] = useState([]);
const [caldavLoading, setCaldavLoading] = useState(false);
const [newTokenName, setNewTokenName] = useState('');
const [creatingToken, setCreatingToken] = useState(false);
const [newlyCreatedToken, setNewlyCreatedToken] = useState(null);
const [tokenVisible, setTokenVisible] = useState(false);
// 2FA state
const [twoFaEnabled, setTwoFaEnabled] = useState(!!user?.totp_enabled);
const [twoFaLoading, setTwoFaLoading] = useState(false);
const [twoFaSetupData, setTwoFaSetupData] = useState(null); // { secret, uri, qrDataUrl }
const [twoFaCode, setTwoFaCode] = useState('');
const [twoFaEnabling, setTwoFaEnabling] = useState(false);
const [twoFaDisablePassword, setTwoFaDisablePassword] = useState('');
const [twoFaDisableCode, setTwoFaDisableCode] = useState('');
const [twoFaDisabling, setTwoFaDisabling] = useState(false);
const [showDisableForm, setShowDisableForm] = useState(false);
useEffect(() => {
if (activeSection === 'caldav') {
setCaldavLoading(true);
api.get('/calendar/caldav-tokens')
.then(r => setCaldavTokens(r.data.tokens || []))
.catch(() => {})
.finally(() => setCaldavLoading(false));
}
if (activeSection === 'security') {
setTwoFaLoading(true);
api.get('/auth/2fa/status')
.then(r => setTwoFaEnabled(r.data.enabled))
.catch(() => {})
.finally(() => setTwoFaLoading(false));
}
}, [activeSection]);
const handleCreateToken = async (e) => {
e.preventDefault();
if (!newTokenName.trim()) return;
setCreatingToken(true);
try {
const res = await api.post('/calendar/caldav-tokens', { name: newTokenName.trim() });
setNewlyCreatedToken(res.data.plainToken);
setTokenVisible(false);
setNewTokenName('');
const r = await api.get('/calendar/caldav-tokens');
setCaldavTokens(r.data.tokens || []);
} catch (err) {
toast.error(err.response?.data?.error || t('settings.caldav.createFailed'));
} finally {
setCreatingToken(false);
}
};
const handleRevokeToken = async (id) => {
if (!confirm(t('settings.caldav.revokeConfirm'))) return;
try {
await api.delete(`/calendar/caldav-tokens/${id}`);
setCaldavTokens(prev => prev.filter(tk => tk.id !== id));
toast.success(t('settings.caldav.revoked'));
} catch {
toast.error(t('settings.caldav.revokeFailed'));
}
};
// 2FA handlers
const handleSetup2FA = async () => {
setTwoFaLoading(true);
try {
const res = await api.post('/auth/2fa/setup');
// Generate QR code data URL client-side
const QRCode = (await import('qrcode')).default;
const qrDataUrl = await QRCode.toDataURL(res.data.uri, { width: 200, margin: 2, color: { dark: '#000000', light: '#ffffff' } });
setTwoFaSetupData({ secret: res.data.secret, uri: res.data.uri, qrDataUrl });
} catch (err) {
toast.error(err.response?.data?.error || t('settings.security.setupFailed'));
} finally {
setTwoFaLoading(false);
}
};
const handleEnable2FA = async (e) => {
e.preventDefault();
setTwoFaEnabling(true);
try {
await api.post('/auth/2fa/enable', { code: twoFaCode });
setTwoFaEnabled(true);
setTwoFaSetupData(null);
setTwoFaCode('');
toast.success(t('settings.security.enabled'));
} catch (err) {
toast.error(err.response?.data?.error || t('settings.security.enableFailed'));
setTwoFaCode('');
} finally {
setTwoFaEnabling(false);
}
};
const handleDisable2FA = async (e) => {
e.preventDefault();
setTwoFaDisabling(true);
try {
await api.post('/auth/2fa/disable', { password: twoFaDisablePassword, code: twoFaDisableCode });
setTwoFaEnabled(false);
setShowDisableForm(false);
setTwoFaDisablePassword('');
setTwoFaDisableCode('');
toast.success(t('settings.security.disabled'));
} catch (err) {
toast.error(err.response?.data?.error || t('settings.security.disableFailed'));
} finally {
setTwoFaDisabling(false);
}
};
const groups = getThemeGroups(); const groups = getThemeGroups();
const avatarColors = [ const avatarColors = [
@@ -137,8 +252,10 @@ export default function Settings() {
const sections = [ const sections = [
{ id: 'profile', label: t('settings.profile'), icon: User }, { id: 'profile', label: t('settings.profile'), icon: User },
{ id: 'password', label: t('settings.password'), icon: Lock }, { id: 'password', label: t('settings.password'), icon: Lock },
{ id: 'security', label: t('settings.security.title'), icon: Shield },
{ id: 'language', label: t('settings.language'), icon: Globe }, { id: 'language', label: t('settings.language'), icon: Globe },
{ id: 'themes', label: t('settings.themes'), icon: Palette }, { id: 'themes', label: t('settings.themes'), icon: Palette },
{ id: 'caldav', label: t('settings.caldav.title'), icon: Calendar },
]; ];
return ( return (
@@ -150,7 +267,7 @@ export default function Settings() {
<div className="flex flex-col md:flex-row gap-6"> <div className="flex flex-col md:flex-row gap-6">
{/* Section nav */} {/* Section nav */}
<div className="md:w-56 flex-shrink-0"> <div className="md:w-56 shrink-0">
<nav className="flex md:flex-col gap-1"> <nav className="flex md:flex-col gap-1">
{sections.map(s => ( {sections.map(s => (
<button <button
@@ -184,7 +301,7 @@ export default function Settings() {
{user?.avatar_image ? ( {user?.avatar_image ? (
<img <img
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`} src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
alt="Avatar" alt=""
className="w-16 h-16 rounded-full object-cover" className="w-16 h-16 rounded-full object-cover"
/> />
) : ( ) : (
@@ -199,6 +316,7 @@ export default function Settings() {
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={uploadingAvatar} disabled={uploadingAvatar}
aria-label={t('settings.uploadImage')}
className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer" className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
> >
{uploadingAvatar ? ( {uploadingAvatar ? (
@@ -243,12 +361,15 @@ export default function Settings() {
{/* Avatar Color (fallback) */} {/* Avatar Color (fallback) */}
<div className="mb-6"> <div className="mb-6">
<label className="block text-sm font-medium text-th-text mb-3">{t('settings.avatarColor')}</label> <p id="avatar-color-label" className="block text-sm font-medium text-th-text mb-3">{t('settings.avatarColor')}</p>
<div className="flex flex-wrap gap-2"> <div role="radiogroup" aria-labelledby="avatar-color-label" className="flex flex-wrap gap-2">
{avatarColors.map(color => ( {avatarColors.map(color => (
<button <button
key={color} key={color}
onClick={() => handleAvatarColor(color)} onClick={() => handleAvatarColor(color)}
role="radio"
aria-checked={user?.avatar_color === color}
aria-label={color}
className={`w-7 h-7 rounded-full ring-2 ring-offset-2 transition-all ${ className={`w-7 h-7 rounded-full ring-2 ring-offset-2 transition-all ${
user?.avatar_color === color ? 'ring-th-accent' : 'ring-transparent hover:ring-th-border' user?.avatar_color === color ? 'ring-th-accent' : 'ring-transparent hover:ring-th-border'
}`} }`}
@@ -261,11 +382,13 @@ export default function Settings() {
<form onSubmit={handleProfileSave} className="space-y-4"> <form onSubmit={handleProfileSave} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label> <label htmlFor="settings-username" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
<div className="relative"> <div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="settings-username"
type="text" type="text"
autoComplete="username"
value={profile.name} value={profile.name}
onChange={e => setProfile({ ...profile, name: e.target.value })} onChange={e => setProfile({ ...profile, name: e.target.value })}
className="input-field pl-11" className="input-field pl-11"
@@ -275,11 +398,13 @@ export default function Settings() {
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p> <p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label> <label htmlFor="settings-display-name" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
<div className="relative"> <div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="settings-display-name"
type="text" type="text"
autoComplete="name"
value={profile.display_name} value={profile.display_name}
onChange={e => setProfile({ ...profile, display_name: e.target.value })} onChange={e => setProfile({ ...profile, display_name: e.target.value })}
className="input-field pl-11" className="input-field pl-11"
@@ -287,11 +412,13 @@ export default function Settings() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label> <label htmlFor="settings-email" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<div className="relative"> <div className="relative">
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="settings-email"
type="email" type="email"
autoComplete="email"
value={profile.email} value={profile.email}
onChange={e => setProfile({ ...profile, email: e.target.value })} onChange={e => setProfile({ ...profile, email: e.target.value })}
className="input-field pl-11" className="input-field pl-11"
@@ -313,11 +440,13 @@ export default function Settings() {
<h2 className="text-lg font-semibold text-th-text mb-6">{t('settings.changePassword')}</h2> <h2 className="text-lg font-semibold text-th-text mb-6">{t('settings.changePassword')}</h2>
<form onSubmit={handlePasswordSave} className="space-y-4"> <form onSubmit={handlePasswordSave} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label> <label htmlFor="settings-current-password" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
<div className="relative"> <div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="settings-current-password"
type="password" type="password"
autoComplete="current-password"
value={passwords.currentPassword} value={passwords.currentPassword}
onChange={e => setPasswords({ ...passwords, currentPassword: e.target.value })} onChange={e => setPasswords({ ...passwords, currentPassword: e.target.value })}
className="input-field pl-11" className="input-field pl-11"
@@ -326,11 +455,13 @@ export default function Settings() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.newPassword')}</label> <label htmlFor="settings-new-password" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.newPassword')}</label>
<div className="relative"> <div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="settings-new-password"
type="password" type="password"
autoComplete="new-password"
value={passwords.newPassword} value={passwords.newPassword}
onChange={e => setPasswords({ ...passwords, newPassword: e.target.value })} onChange={e => setPasswords({ ...passwords, newPassword: e.target.value })}
className="input-field pl-11" className="input-field pl-11"
@@ -341,11 +472,13 @@ export default function Settings() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.confirmNewPassword')}</label> <label htmlFor="settings-confirm-new-password" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.confirmNewPassword')}</label>
<div className="relative"> <div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input <input
id="settings-confirm-new-password"
type="password" type="password"
autoComplete="new-password"
value={passwords.confirmPassword} value={passwords.confirmPassword}
onChange={e => setPasswords({ ...passwords, confirmPassword: e.target.value })} onChange={e => setPasswords({ ...passwords, confirmPassword: e.target.value })}
className="input-field pl-11" className="input-field pl-11"
@@ -363,6 +496,152 @@ export default function Settings() {
</div> </div>
)} )}
{/* Security / 2FA section */}
{activeSection === 'security' && (
<div className="space-y-5">
<div className="card p-6">
<h2 className="text-lg font-semibold text-th-text mb-1">{t('settings.security.title')}</h2>
<p className="text-sm text-th-text-s mb-6">{t('settings.security.subtitle')}</p>
{twoFaLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 size={24} className="animate-spin text-th-text-s" />
</div>
) : twoFaEnabled ? (
/* 2FA is enabled */
<div>
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 mb-5">
<ShieldCheck size={22} className="text-emerald-400 shrink-0" />
<div>
<p className="text-sm font-medium text-emerald-300">{t('settings.security.statusEnabled')}</p>
<p className="text-xs text-emerald-400/70">{t('settings.security.statusEnabledDesc')}</p>
</div>
</div>
{!showDisableForm ? (
<button
onClick={() => setShowDisableForm(true)}
className="btn-ghost text-th-error hover:text-th-error text-sm"
>
<ShieldOff size={16} />
{t('settings.security.disable')}
</button>
) : (
<form onSubmit={handleDisable2FA} className="space-y-4 p-4 rounded-xl bg-th-bg-t border border-th-border">
<p className="text-sm text-th-text-s">{t('settings.security.disableConfirm')}</p>
<div>
<label htmlFor="twofa-disable-password" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="twofa-disable-password"
type="password"
autoComplete="current-password"
value={twoFaDisablePassword}
onChange={e => setTwoFaDisablePassword(e.target.value)}
className="input-field pl-11"
required
/>
</div>
</div>
<div>
<label htmlFor="twofa-disable-code" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.codeLabel')}</label>
<input
id="twofa-disable-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={twoFaDisableCode}
onChange={e => setTwoFaDisableCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
className="input-field text-center text-lg tracking-[0.3em] font-mono"
placeholder="000 000"
required
maxLength={7}
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={twoFaDisabling} className="btn-primary bg-red-600 hover:bg-red-700 border-red-600">
{twoFaDisabling ? <Loader2 size={14} className="animate-spin" /> : <ShieldOff size={14} />}
{t('settings.security.disable')}
</button>
<button type="button" onClick={() => { setShowDisableForm(false); setTwoFaDisablePassword(''); setTwoFaDisableCode(''); }} className="btn-ghost text-sm">
{t('common.cancel')}
</button>
</div>
</form>
)}
</div>
) : twoFaSetupData ? (
/* Setup flow: show QR code + verification */
<div className="space-y-5">
<div className="text-center">
<p className="text-sm text-th-text mb-4">{t('settings.security.scanQR')}</p>
<div className="inline-block p-3 bg-white rounded-xl">
<img src={twoFaSetupData.qrDataUrl} alt="TOTP QR Code" className="w-[200px] h-[200px]" />
</div>
</div>
<div>
<p className="text-xs font-medium text-th-text-s mb-1">{t('settings.security.manualKey')}</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg text-th-accent font-mono break-all">
{twoFaSetupData.secret}
</code>
<button
onClick={() => { navigator.clipboard.writeText(twoFaSetupData.secret); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 shrink-0"
aria-label={t('room.copyLink')}
>
<Copy size={14} />
</button>
</div>
</div>
<form onSubmit={handleEnable2FA} className="space-y-3">
<div>
<label htmlFor="twofa-enable-code" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.verifyCode')}</label>
<input
id="twofa-enable-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={twoFaCode}
onChange={e => setTwoFaCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
className="input-field text-center text-lg tracking-[0.3em] font-mono"
placeholder="000 000"
required
maxLength={7}
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={twoFaEnabling || twoFaCode.replace(/\s/g, '').length < 6} className="btn-primary">
{twoFaEnabling ? <Loader2 size={14} className="animate-spin" /> : <ShieldCheck size={14} />}
{t('settings.security.enable')}
</button>
<button type="button" onClick={() => { setTwoFaSetupData(null); setTwoFaCode(''); }} className="btn-ghost text-sm">
{t('common.cancel')}
</button>
</div>
</form>
</div>
) : (
/* 2FA is disabled — show enable button */
<div>
<div className="flex items-center gap-3 p-4 rounded-xl bg-th-bg-t border border-th-border mb-5">
<ShieldOff size={22} className="text-th-text-s shrink-0" />
<div>
<p className="text-sm font-medium text-th-text">{t('settings.security.statusDisabled')}</p>
<p className="text-xs text-th-text-s">{t('settings.security.statusDisabledDesc')}</p>
</div>
</div>
<button onClick={handleSetup2FA} disabled={twoFaLoading} className="btn-primary">
{twoFaLoading ? <Loader2 size={16} className="animate-spin" /> : <Shield size={16} />}
{t('settings.security.enable')}
</button>
</div>
)}
</div>
</div>
)}
{/* Language section */} {/* Language section */}
{activeSection === 'language' && ( {activeSection === 'language' && (
<div className="card p-6"> <div className="card p-6">
@@ -409,7 +688,7 @@ export default function Settings() {
> >
{/* Color preview */} {/* Color preview */}
<div <div
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 border" className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 border"
style={{ backgroundColor: th.colors.bg, borderColor: th.colors.accent + '40' }} style={{ backgroundColor: th.colors.bg, borderColor: th.colors.accent + '40' }}
> >
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: th.colors.accent }} /> <div className="w-4 h-4 rounded-full" style={{ backgroundColor: th.colors.accent }} />
@@ -425,8 +704,126 @@ export default function Settings() {
))} ))}
</div> </div>
)} )}
</div> {/* CalDAV section */}
</div> {activeSection === 'caldav' && (
<div className="space-y-5">
{/* Info Card */}
<div className="card p-6">
<h2 className="text-lg font-semibold text-th-text mb-1">{t('settings.caldav.title')}</h2>
<p className="text-sm text-th-text-s mb-5">{t('settings.caldav.subtitle')}</p>
<div className="space-y-3">
<div>
<p className="text-xs font-medium text-th-text-s mb-1">{t('settings.caldav.serverUrl')}</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg text-th-accent font-mono truncate">
{`${window.location.origin}/caldav/`}
</code>
<button
onClick={() => { navigator.clipboard.writeText(`${window.location.origin}/caldav/`); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 shrink-0"
>
<Copy size={14} />
</button>
</div>
</div>
<div>
<p className="text-xs font-medium text-th-text-s mb-1">{t('settings.caldav.username')}</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg text-th-text font-mono">
{user?.email}
</code>
<button
onClick={() => { navigator.clipboard.writeText(user?.email || ''); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 shrink-0"
>
<Copy size={14} />
</button>
</div>
</div>
<p className="text-xs text-th-text-s">{t('settings.caldav.hint')}</p>
</div>
</div>
{/* New token was just created */}
{newlyCreatedToken && (
<div className="card p-5 border-2 border-th-success/40 bg-th-success/5">
<p className="text-sm font-semibold text-th-success mb-2">{t('settings.caldav.newTokenCreated')}</p>
<p className="text-xs text-th-text-s mb-3">{t('settings.caldav.newTokenHint')}</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg font-mono text-th-text break-all">
{tokenVisible ? newlyCreatedToken : '•'.repeat(48)}
</code>
<button onClick={() => setTokenVisible(v => !v)} className="btn-ghost py-1.5 px-2 shrink-0">
{tokenVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button
onClick={() => { navigator.clipboard.writeText(newlyCreatedToken); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 shrink-0"
>
<Copy size={14} />
</button>
</div>
<button
onClick={() => setNewlyCreatedToken(null)}
className="mt-3 text-xs text-th-text-s hover:text-th-text underline"
>
{t('settings.caldav.dismiss')}
</button>
</div>
)}
{/* Create new token */}
<div className="card p-5">
<h3 className="text-sm font-semibold text-th-text mb-3">{t('settings.caldav.newToken')}</h3>
<form onSubmit={handleCreateToken} className="flex gap-2">
<input
type="text"
value={newTokenName}
onChange={e => setNewTokenName(e.target.value)}
placeholder={t('settings.caldav.tokenNamePlaceholder')}
className="input-field flex-1 text-sm"
required
/>
<button type="submit" disabled={creatingToken || !newTokenName.trim()} className="btn-primary py-1.5 px-4">
{creatingToken ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
{t('settings.caldav.generate')}
</button>
</form>
</div>
{/* Token list */}
<div className="card p-5">
<h3 className="text-sm font-semibold text-th-text mb-3">{t('settings.caldav.existingTokens')}</h3>
{caldavLoading ? (
<div className="flex items-center justify-center py-6"><Loader2 size={20} className="animate-spin text-th-text-s" /></div>
) : caldavTokens.length === 0 ? (
<p className="text-sm text-th-text-s py-3">{t('settings.caldav.noTokens')}</p>
) : (
<div className="space-y-2">
{caldavTokens.map(tk => (
<div key={tk.id} className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg bg-th-bg-t">
<div className="min-w-0">
<p className="text-sm font-medium text-th-text truncate">{tk.name}</p>
<p className="text-xs text-th-text-s">
{t('settings.caldav.created')}: {new Date(tk.created_at).toLocaleDateString()}
{tk.last_used_at && ` · ${t('settings.caldav.lastUsed')}: ${new Date(tk.last_used_at).toLocaleDateString()}`}
</p>
</div>
<button
onClick={() => handleRevokeToken(tk.id)}
className="btn-ghost py-1 px-2 text-th-error hover:text-th-error shrink-0"
title={t('settings.caldav.revoke')}
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
</div>
)}
</div> </div>
</div> </div>
); );
} }
+68
View File
@@ -111,12 +111,80 @@ export const themes = [
group: 'Community', group: 'Community',
colors: { bg: '#161924', accent: '#b30051', text: '#dadada' }, colors: { bg: '#161924', accent: '#b30051', text: '#dadada' },
}, },
{
id: 'red-modular-light',
name: 'Red Modular Light',
type: 'light',
group: 'Community',
colors: { bg: '#ffffff', accent: '#e60000', text: '#000000' },
},
{
id: 'everforest-dark',
name: 'Everforest Dark',
type: 'dark',
group: 'Everforest',
colors: { bg: '#2d353b', accent: '#a7c080', text: '#d3c6aa' },
},
{
id: 'everforest-light',
name: 'Everforest Light',
type: 'light',
group: 'Everforest',
colors: { bg: '#fdf6e3', accent: '#8da101', text: '#5c6a72' },
},
{
id: 'kanagawa',
name: 'Kanagawa',
type: 'dark',
group: 'Community',
colors: { bg: '#1f1f28', accent: '#7e9cd8', text: '#dcd7ba' },
},
{
id: 'ayu-dark',
name: 'Ayu Dark',
type: 'dark',
group: 'Ayu',
colors: { bg: '#0d1017', accent: '#39bae6', text: '#bfbdb6' },
},
{
id: 'moonlight',
name: 'Moonlight',
type: 'dark',
group: 'Community',
colors: { bg: '#212337', accent: '#82aaff', text: '#c8d3f5' },
},
{
id: 'cyberpunk',
name: 'Cyberpunk',
type: 'dark',
group: 'Community',
colors: { bg: '#0a0a0f', accent: '#ff0080', text: '#e0e0ff' },
},
{
id: 'cotton-candy-light',
name: 'Cotton Candy Light',
type: 'light',
group: 'Community',
colors: { bg: '#fff5f9', accent: '#ff85a2', text: '#8b2635' },
},
]; ];
export function getThemeById(id) { 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: [],
};