From b5218046c9ec9447635b486f61956ec13ab2760c Mon Sep 17 00:00:00 2001 From: Michelle Date: Mon, 2 Mar 2026 16:14:54 +0100 Subject: [PATCH] Refactor code and improve internationalization support - 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. --- README.md | 216 +++++++++++++++++----------------- server/config/bbb.js | 4 +- server/config/database.js | 4 +- server/config/emaili18n.js | 52 ++++++++ server/config/mailer.js | 131 +++++++++++---------- server/index.js | 4 +- server/routes/admin.js | 6 +- server/routes/auth.js | 22 ++-- server/routes/calendar.js | 12 +- server/routes/federation.js | 12 +- server/routes/rooms.js | 4 +- src/i18n/de.json | 47 +++++++- src/i18n/en.json | 47 +++++++- src/pages/Calendar.jsx | 6 +- src/pages/FederationInbox.jsx | 6 +- 15 files changed, 356 insertions(+), 217 deletions(-) create mode 100644 server/config/emaili18n.js diff --git a/README.md b/README.md index a4c5ea6..1c9e065 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# πŸ”΄ Redlight +ο»Ώ# πŸ”΄ Redlight A modern, self-hosted BigBlueButton frontend with beautiful themes, federation, and powerful features. @@ -10,52 +10,52 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation, ## ✨ Features ### Core Features -- πŸŽ₯ **Video Conferencing** – Integrated BigBlueButton support for professional video meetings -- 🎨 **15+ Themes** – Dracula, Nord, Catppuccin, RosΓ© Pine, Gruvbox, and more -- πŸ“ **Room Management** – Create unlimited rooms with custom settings, access codes, and moderator codes -- πŸ” **User Management** – Registration, login, role-based access control (Admin/User) -- πŸ“Ή **Recording Management** – View, publish, and delete meeting recordings per room -- 🌍 **Multi-Language Support** – German (Deutsch) and English built-in, easily extensible -- βœ‰οΈ **Email Verification** – Optional SMTP-based email verification for user registration -- πŸ‘€ **User Profiles** – Customizable avatars, themes, and language preferences -- πŸ“± **Responsive Design** – Works seamlessly on mobile, tablet, and desktop -- 🌐 **Federation** – Invite users from remote Redlight instances via Ed25519-signed messages -- πŸ‰ **DragonflyDB / Redis** – JWT blacklisting for secure token revocation on logout +- πŸŽ₯ **Video Conferencing** - Integrated BigBlueButton support for professional video meetings +- 🎨 **15+ Themes** - Dracula, Nord, Catppuccin, RosΓ© Pine, Gruvbox, and more +- πŸ“ **Room Management** - Create unlimited rooms with custom settings, access codes, and moderator codes +- πŸ” **User Management** - Registration, login, role-based access control (Admin/User) +- πŸ“Ή **Recording Management** - View, publish, and delete meeting recordings per room +- 🌍 **Multi-Language Support** - German (Deutsch) and English built-in, easily extensible +- βœ‰οΈ **Email Verification** - Optional SMTP-based email verification for user registration +- πŸ‘€ **User Profiles** - Customizable avatars, themes, and language preferences +- πŸ“± **Responsive Design** - Works seamlessly on mobile, tablet, and desktop +- 🌐 **Federation** - Invite users from remote Redlight instances via Ed25519-signed messages +- πŸ‰ **DragonflyDB / Redis** - JWT blacklisting for secure token revocation on logout ### Admin Features -- πŸ‘₯ **User Administration** – Manage users and roles -- 🏒 **Branding Customization** – Custom app name, logos, and default theme -- πŸ“Š **Dashboard** – Overview of system statistics -- πŸ”§ **Settings Management** – System-wide configuration +- πŸ‘₯ **User Administration** - Manage users and roles +- 🏒 **Branding Customization** - Custom app name, logos, and default theme +- πŸ“Š **Dashboard** - Overview of system statistics +- πŸ”§ **Settings Management** - System-wide configuration ### Room Features -- πŸ”‘ **Access Codes** – Restrict room access with optional passwords -- πŸ” **Moderator Codes** – Separate code to grant moderator privileges -- πŸšͺ **Guest Access** – Allow unauthenticated users to join meetings (rate-limited) -- ⏱️ **Max Participants** – Set limits on concurrent participants -- 🎀 **Mute on Join** – Automatically mute new participants -- βœ… **Approval Mode** – Require moderator approval for participants -- πŸŽ™οΈ **Anyone Can Start** – Allow participants to start the meeting -- πŸ“Ή **Recording Settings** – Control whether meetings are recorded -- πŸ“Š **Presentation Upload** – Upload PDF, PPTX, ODP, or image files as default slides -- 🀝 **Room Sharing** – Share rooms with other registered users +- πŸ”‘ **Access Codes** - Restrict room access with optional passwords +- πŸ” **Moderator Codes** - Separate code to grant moderator privileges +- πŸšͺ **Guest Access** - Allow unauthenticated users to join meetings (rate-limited) +- ⏱️ **Max Participants** - Set limits on concurrent participants +- 🎀 **Mute on Join** - Automatically mute new participants +- βœ… **Approval Mode** - Require moderator approval for participants +- πŸŽ™οΈ **Anyone Can Start** - Allow participants to start the meeting +- πŸ“Ή **Recording Settings** - Control whether meetings are recorded +- πŸ“Š **Presentation Upload** - Upload PDF, PPTX, ODP, or image files as default slides +- 🀝 **Room Sharing** - Share rooms with other registered users ### Security -- πŸ›‘οΈ **Comprehensive Rate Limiting** – Login, register, profile, avatar, guest-join, and federation endpoints -- πŸ”’ **Input Validation** – Email format, field length limits, ID format checks, color format validation -- πŸ• **Timing-Safe Comparisons** – Access codes and moderator codes compared with `crypto.timingSafeEqual` -- πŸ“ **Streaming Upload Limits** – Avatar (5 MB) and presentation (50 MB) uploads reject early without buffering -- 🧹 **XSS Prevention** – HTML-escaped emails, XML-escaped BBB parameters, SVG sanitization -- πŸ” **JWT Blacklist** – Token revocation via DragonflyDB/Redis on logout -- 🌐 **CORS Restriction** – Locked to `APP_URL` in production -- βš™οΈ **Configurable Trust Proxy** – `TRUST_PROXY` env var for reverse proxy setups +- πŸ›‘οΈ **Comprehensive Rate Limiting** - Login, register, profile, avatar, guest-join, and federation endpoints +- πŸ”’ **Input Validation** - Email format, field length limits, ID format checks, color format validation +- πŸ• **Timing-Safe Comparisons** - Access codes and moderator codes compared with `crypto.timingSafeEqual` +- πŸ“ **Streaming Upload Limits** - Avatar (5 MB) and presentation (50 MB) uploads reject early without buffering +- 🧹 **XSS Prevention** - HTML-escaped emails, XML-escaped BBB parameters, SVG sanitization +- πŸ” **JWT Blacklist** - Token revocation via DragonflyDB/Redis on logout +- 🌐 **CORS Restriction** - Locked to `APP_URL` in production +- βš™οΈ **Configurable Trust Proxy** - `TRUST_PROXY` env var for reverse proxy setups ### Developer Features -- 🐳 **Docker Support** – Easy deployment with Docker Compose (includes PostgreSQL + DragonflyDB) -- πŸ—„οΈ **Database Flexibility** – SQLite (default) or PostgreSQL support -- πŸ”Œ **REST API** – Comprehensive API for custom integrations -- πŸ“¦ **Open Source** – Full source code transparency -- πŸ› οΈ **Self-Hosted** – Complete data privacy and control +- 🐳 **Docker Support** - Easy deployment with Docker Compose (includes PostgreSQL + DragonflyDB) +- πŸ—„οΈ **Database Flexibility** - SQLite (default) or PostgreSQL support +- πŸ”Œ **REST API** - Comprehensive API for custom integrations +- πŸ“¦ **Open Source** - Full source code transparency +- πŸ› οΈ **Self-Hosted** - Complete data privacy and control --- @@ -103,7 +103,7 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation, ```env BBB_URL=https://your-bbb-server.com/bigbluebutton/api/ BBB_SECRET=your-bbb-shared-secret - JWT_SECRET=your-secret-key # REQUIRED – app won't start without this + JWT_SECRET=your-secret-key # REQUIRED - app won't start without this APP_URL=https://your-domain.com # Used for CORS and email links DATABASE_URL=postgres://user:password@postgres:5432/redlight @@ -165,7 +165,7 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation, - **Frontend**: React 18, Tailwind CSS, React Router, Lucide Icons - **Backend**: Node.js 20, Express, JWT, Bcrypt - **Database**: SQLite / PostgreSQL with better-sqlite3 / pg -- **Cache**: DragonflyDB / Redis (ioredis) – JWT blacklisting +- **Cache**: DragonflyDB / Redis (ioredis) - JWT blacklisting - **Email**: Nodemailer - **Build**: Vite @@ -199,77 +199,77 @@ redlight/ ## πŸ” Security -- **JWT Authentication** – Secure token-based auth with 7-day expiration and `jti`-based blacklisting via DragonflyDB/Redis -- **Mandatory JWT Secret** – Server refuses to start without a `JWT_SECRET` env var -- **HTTPS Ready** – Configure behind reverse proxy (nginx, Caddy); trust proxy via `TRUST_PROXY` env -- **Password Hashing** – bcryptjs with salt rounds 12, minimum 8-character passwords -- **Email Verification** – Optional SMTP-based email verification with resend support -- **CORS Protection** – Restricted to `APP_URL` in production, open in development -- **Rate Limiting** – Login, register, profile, password, avatar, guest-join, and federation endpoints -- **Input Validation** – Email regex, field length limits, ID format checks, hex-color format checks -- **Timing-Safe Comparisons** – Access codes and moderator codes compared via `crypto.timingSafeEqual` -- **Upload Safety** – Streaming body size limits (avatar 5 MB, presentation 50 MB) abort early without buffering -- **XSS / Injection Prevention** – HTML-escaped emails, XML-escaped BBB API parameters, SVG logos served as `attachment` -- **Admin Isolation** – Role-based access control with strict admin checks +- **JWT Authentication** - Secure token-based auth with 7-day expiration and `jti`-based blacklisting via DragonflyDB/Redis +- **Mandatory JWT Secret** - Server refuses to start without a `JWT_SECRET` env var +- **HTTPS Ready** - Configure behind reverse proxy (nginx, Caddy); trust proxy via `TRUST_PROXY` env +- **Password Hashing** - bcryptjs with salt rounds 12, minimum 8-character passwords +- **Email Verification** - Optional SMTP-based email verification with resend support +- **CORS Protection** - Restricted to `APP_URL` in production, open in development +- **Rate Limiting** - Login, register, profile, password, avatar, guest-join, and federation endpoints +- **Input Validation** - Email regex, field length limits, ID format checks, hex-color format checks +- **Timing-Safe Comparisons** - Access codes and moderator codes compared via `crypto.timingSafeEqual` +- **Upload Safety** - Streaming body size limits (avatar 5 MB, presentation 50 MB) abort early without buffering +- **XSS / Injection Prevention** - HTML-escaped emails, XML-escaped BBB API parameters, SVG logos served as `attachment` +- **Admin Isolation** - Role-based access control with strict admin checks --- ## πŸ“¦ API Endpoints ### Authentication -- `POST /api/auth/register` – Register new user -- `POST /api/auth/login` – Login user -- `POST /api/auth/logout` – Logout (blacklists JWT) -- `GET /api/auth/verify-email?token=...` – Verify email with token -- `POST /api/auth/resend-verification` – Resend verification email -- `GET /api/auth/me` – Get current user info -- `PUT /api/auth/profile` – Update profile (theme, language, display name) -- `PUT /api/auth/password` – Change password -- `POST /api/auth/avatar` – Upload avatar image -- `DELETE /api/auth/avatar` – Remove avatar image +- `POST /api/auth/register` - Register new user +- `POST /api/auth/login` - Login user +- `POST /api/auth/logout` - Logout (blacklists JWT) +- `GET /api/auth/verify-email?token=...` - Verify email with token +- `POST /api/auth/resend-verification` - Resend verification email +- `GET /api/auth/me` - Get current user info +- `PUT /api/auth/profile` - Update profile (theme, language, display name) +- `PUT /api/auth/password` - Change password +- `POST /api/auth/avatar` - Upload avatar image +- `DELETE /api/auth/avatar` - Remove avatar image ### Rooms -- `GET /api/rooms` – List user's rooms (owned + shared) -- `POST /api/rooms` – Create new room -- `GET /api/rooms/:uid` – Get room details -- `PUT /api/rooms/:uid` – Update room -- `DELETE /api/rooms/:uid` – Delete room -- `POST /api/rooms/:uid/start` – Start meeting -- `POST /api/rooms/:uid/join` – Join meeting as authenticated user -- `POST /api/rooms/:uid/guest-join` – Join meeting as guest (rate-limited) -- `POST /api/rooms/:uid/end` – End meeting -- `GET /api/rooms/:uid/running` – Check if meeting is running -- `GET /api/rooms/:uid/shares` – List shared users -- `POST /api/rooms/:uid/shares` – Share room with user -- `DELETE /api/rooms/:uid/shares/:userId` – Remove share -- `POST /api/rooms/:uid/presentation` – Upload default presentation (PDF, PPTX, ODP, images) -- `DELETE /api/rooms/:uid/presentation` – Remove presentation +- `GET /api/rooms` - List user's rooms (owned + shared) +- `POST /api/rooms` - Create new room +- `GET /api/rooms/:uid` - Get room details +- `PUT /api/rooms/:uid` - Update room +- `DELETE /api/rooms/:uid` - Delete room +- `POST /api/rooms/:uid/start` - Start meeting +- `POST /api/rooms/:uid/join` - Join meeting as authenticated user +- `POST /api/rooms/:uid/guest-join` - Join meeting as guest (rate-limited) +- `POST /api/rooms/:uid/end` - End meeting +- `GET /api/rooms/:uid/running` - Check if meeting is running +- `GET /api/rooms/:uid/shares` - List shared users +- `POST /api/rooms/:uid/shares` - Share room with user +- `DELETE /api/rooms/:uid/shares/:userId` - Remove share +- `POST /api/rooms/:uid/presentation` - Upload default presentation (PDF, PPTX, ODP, images) +- `DELETE /api/rooms/:uid/presentation` - Remove presentation ### Recordings -- `GET /api/recordings/:roomUid` – List room recordings -- `PUT /api/recordings/:recordingId` – Publish/unpublish recording -- `DELETE /api/recordings/:recordingId` – Delete recording +- `GET /api/recordings/:roomUid` - List room recordings +- `PUT /api/recordings/:recordingId` - Publish/unpublish recording +- `DELETE /api/recordings/:recordingId` - Delete recording ### Admin -- `GET /api/admin/users` – List all users -- `GET /api/admin/stats` – System statistics -- `POST /api/admin/users` – Create user (admin) -- `PUT /api/admin/users/:id` – Update user -- `DELETE /api/admin/users/:id` – Delete user +- `GET /api/admin/users` - List all users +- `GET /api/admin/stats` - System statistics +- `POST /api/admin/users` - Create user (admin) +- `PUT /api/admin/users/:id` - Update user +- `DELETE /api/admin/users/:id` - Delete user ### Branding -- `GET /api/branding` – Get branding settings -- `PUT /api/branding` – Update branding (admin only) -- `POST /api/branding/logo` – Upload custom logo -- `DELETE /api/branding/logo` – Remove custom logo +- `GET /api/branding` - Get branding settings +- `PUT /api/branding` - Update branding (admin only) +- `POST /api/branding/logo` - Upload custom logo +- `DELETE /api/branding/logo` - Remove custom logo ### Federation -- `GET /.well-known/redlight` – Instance discovery (domain, public key) -- `POST /api/federation/invite` – Send invitation to remote user -- `POST /api/federation/receive` – Receive invitation from remote instance (rate-limited) -- `GET /api/federation/invitations` – List received invitations -- `PUT /api/federation/invitations/:id` – Accept / decline invitation -- `DELETE /api/federation/invitations/:id` – Delete invitation +- `GET /.well-known/redlight` - Instance discovery (domain, public key) +- `POST /api/federation/invite` - Send invitation to remote user +- `POST /api/federation/receive` - Receive invitation from remote instance (rate-limited) +- `GET /api/federation/invitations` - List received invitations +- `PUT /api/federation/invitations/:id` - Accept / decline invitation +- `DELETE /api/federation/invitations/:id` - Delete invitation --- @@ -313,26 +313,26 @@ docker-compose up -d ``` Services: -- **redlight** – Node.js application -- **postgres** – PostgreSQL database -- **dragonfly** – DragonflyDB (Redis-compatible) for JWT blacklisting +- **redlight** - Node.js application +- **postgres** - PostgreSQL database +- **dragonfly** - DragonflyDB (Redis-compatible) for JWT blacklisting ### Environment Variables | Variable | Required | Default | Description | |----------|----------|---------|-------------| -| `BBB_URL` | Yes | – | BigBlueButton API URL | -| `BBB_SECRET` | Yes | – | BigBlueButton shared secret | -| `JWT_SECRET` | Yes | – | Secret for signing JWTs (server won't start without it) | -| `APP_URL` | Recommended | – | Public URL of the app (used for CORS + email links) | +| `BBB_URL` | Yes | - | BigBlueButton API URL | +| `BBB_SECRET` | Yes | - | BigBlueButton shared secret | +| `JWT_SECRET` | Yes | - | Secret for signing JWTs (server won't start without it) | +| `APP_URL` | Recommended | - | Public URL of the app (used for CORS + email links) | | `DATABASE_URL` | No | SQLite | PostgreSQL connection string | | `REDIS_URL` | No | `redis://localhost:6379` | DragonflyDB / Redis URL | | `TRUST_PROXY` | No | `loopback` | Express trust proxy setting (number or string) | -| `SMTP_HOST` | No | – | SMTP server for email verification | +| `SMTP_HOST` | No | - | SMTP server for email verification | | `SMTP_PORT` | No | `587` | SMTP port | -| `SMTP_USER` | No | – | SMTP username | -| `SMTP_PASS` | No | – | SMTP password | -| `FEDERATION_DOMAIN` | No | – | Domain for federation (enables cross-instance invites) | +| `SMTP_USER` | No | - | SMTP username | +| `SMTP_PASS` | No | - | SMTP password | +| `FEDERATION_DOMAIN` | No | - | Domain for federation (enables cross-instance invites) | ### Production Deployment @@ -419,7 +419,7 @@ curl "https://your-bbb-server/bigbluebutton/api/getMeetings?checksum=..." ## πŸ“ License -This project is licensed under the MIT License – see [LICENSE](LICENSE) file for details. +This project is licensed under the MIT License - see [LICENSE](LICENSE) file for details. --- diff --git a/server/config/bbb.js b/server/config/bbb.js index fd2288b..4a9ff82 100644 --- a/server/config/bbb.js +++ b/server/config/bbb.js @@ -1,4 +1,4 @@ -import crypto from 'crypto'; +ο»Ώimport crypto from 'crypto'; import xml2js from 'xml2js'; import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParams } from './logger.js'; @@ -98,7 +98,7 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati params.lockSettingsLockOnJoin = 'true'; } - // Build optional presentation XML body – escape URL to prevent XML injection + // Build optional presentation XML body - escape URL to prevent XML injection let xmlBody = null; if (presentationUrl) { const safeUrl = presentationUrl diff --git a/server/config/database.js b/server/config/database.js index 39582ab..290c9c2 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -1,4 +1,4 @@ -import bcrypt from 'bcryptjs'; +ο»Ώimport bcrypt from 'bcryptjs'; import path from 'path'; import { fileURLToPath } from 'url'; import { log } from './logger.js'; @@ -106,7 +106,7 @@ class PostgresAdapter { // ── Public API ────────────────────────────────────────────────────────────── export function getDb() { if (!db) { - throw new Error('Database not initialised – call initDatabase() first'); + throw new Error('Database not initialised - call initDatabase() first'); } return db; } diff --git a/server/config/emaili18n.js b/server/config/emaili18n.js new file mode 100644 index 0000000..d51a723 --- /dev/null +++ b/server/config/emaili18n.js @@ -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, '../../src/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} [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 + ); +} diff --git a/server/config/mailer.js b/server/config/mailer.js index b12e7ed..f33cfa1 100644 --- a/server/config/mailer.js +++ b/server/config/mailer.js @@ -1,5 +1,6 @@ -import nodemailer from 'nodemailer'; +ο»Ώimport nodemailer from 'nodemailer'; import { log } from './logger.js'; +import { t } from './emaili18n.js'; let transporter; @@ -21,7 +22,7 @@ export function initMailer() { const pass = process.env.SMTP_PASS; if (!host || !user || !pass) { - log.mailer.warn('SMTP not configured – email verification disabled'); + log.mailer.warn('SMTP not configured - email verification disabled'); return false; } @@ -45,17 +46,17 @@ export function isMailerConfigured() { /** * Send the verification email with a clickable link. - * @param {string} to – recipient email - * @param {string} name – user's display name - * @param {string} verifyUrl – full verification URL - * @param {string} appName – branding app name (default "Redlight") + * @param {string} to - recipient email + * @param {string} name - user's display name + * @param {string} verifyUrl - full verification URL + * @param {string} appName - branding app name (default "Redlight") */ // S3: sanitize name for use in email From header (strip quotes, newlines, control chars) function sanitizeHeaderValue(str) { return String(str).replace(/["\\\r\n\x00-\x1f]/g, '').trim().slice(0, 100); } -export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight') { +export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight', lang = 'en') { if (!transporter) { throw new Error('SMTP not configured'); } @@ -68,41 +69,41 @@ export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redl await transporter.sendMail({ from: `"${headerAppName}" <${from}>`, to, - subject: `${headerAppName} – Verify your email`, + subject: t(lang, 'email.verify.subject', { appName: headerAppName }), html: `
-

Hey ${safeName} πŸ‘‹

-

Please verify your email address by clicking the button below:

+

${t(lang, 'email.greeting', { name: safeName })}

+

${t(lang, 'email.verify.intro')}

- Verify Email + ${t(lang, 'email.verify.button')}

- Or copy this link in your browser:
+ ${t(lang, 'email.linkHint')}
${escapeHtml(verifyUrl)}

-

This link is valid for 24 hours.

+

${t(lang, 'email.verify.validity')}


-

If you didn't register, please ignore this email.

+

${t(lang, 'email.verify.footer')}

`, - text: `Hey ${name},\n\nPlease verify your email: ${verifyUrl}\n\nThis link is valid for 24 hours.\n\n– ${appName}`, + text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.verify.intro')}\n${verifyUrl}\n\n${t(lang, 'email.verify.validity')}\n\n- ${appName}`, }); } /** * Send a federation meeting invitation email. - * @param {string} to – recipient email - * @param {string} name – recipient display name - * @param {string} fromUser – sender federated address (user@domain) - * @param {string} roomName – name of the invited room - * @param {string} message – optional personal message - * @param {string} inboxUrl – URL to the federation inbox - * @param {string} appName – branding app name (default "Redlight") + * @param {string} to - recipient email + * @param {string} name - recipient display name + * @param {string} fromUser - sender federated address (user@domain) + * @param {string} roomName - name of the invited room + * @param {string} message - optional personal message + * @param {string} inboxUrl - URL to the federation inbox + * @param {string} appName - branding app name (default "Redlight") */ -export async function sendFederationInviteEmail(to, name, fromUser, roomName, message, inboxUrl, appName = 'Redlight') { +export async function sendFederationInviteEmail(to, name, fromUser, roomName, message, inboxUrl, appName = 'Redlight', lang = 'en') { if (!transporter) return; // silently skip if SMTP not configured const from = process.env.SMTP_FROM || process.env.SMTP_USER; @@ -113,37 +114,40 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me const safeMessage = message ? escapeHtml(message) : null; const safeAppName = escapeHtml(appName); + const introHtml = t(lang, 'email.federationInvite.intro') + .replace('{fromUser}', `${safeFromUser}`); + await transporter.sendMail({ from: `"${headerAppName}" <${from}>`, to, - subject: `${headerAppName} - Meeting invitation from ${sanitizeHeaderValue(fromUser)}`, + subject: t(lang, 'email.federationInvite.subject', { appName: headerAppName, fromUser: sanitizeHeaderValue(fromUser) }), html: `
-

Hey ${safeName} πŸ‘‹

-

You have received a meeting invitation from ${safeFromUser}.

+

${t(lang, 'email.greeting', { name: safeName })}

+

${introHtml}

-

Room:

+

${t(lang, 'email.federationInvite.roomLabel')}

${safeRoomName}

${safeMessage ? `

"${safeMessage}"

` : ''}

- View Invitation + ${t(lang, 'email.viewInvitation')}


-

Open the link above to accept or decline the invitation.

+

${t(lang, 'email.invitationFooter')}

`, - text: `Hey ${name},\n\nYou have received a meeting invitation from ${fromUser}.\nRoom: ${roomName}${message ? `\nMessage: "${message}"` : ''}\n\nView invitation: ${inboxUrl}\n\n– ${appName}`, + text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.federationInvite.intro', { fromUser })}\n${t(lang, 'email.federationInvite.roomLabel')} ${roomName}${message ? `\n"${message}"` : ''}\n\n${t(lang, 'email.viewInvitation')}: ${inboxUrl}\n\n- ${appName}`, }); } /** * Send a calendar event invitation email (federated). */ -export async function sendCalendarInviteEmail(to, name, fromUser, title, startTime, endTime, description, inboxUrl, appName = 'Redlight') { +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; @@ -154,41 +158,44 @@ export async function sendCalendarInviteEmail(to, name, fromUser, title, startTi const safeDesc = description ? escapeHtml(description) : null; const formatDate = (iso) => { - try { return new Date(iso).toLocaleString('en-GB', { dateStyle: 'full', timeStyle: 'short' }); } + 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}', `${safeFromUser}`); + await transporter.sendMail({ from: `"${headerAppName}" <${from}>`, to, - subject: `${headerAppName} - Calendar invitation from ${sanitizeHeaderValue(fromUser)}`, + subject: t(lang, 'email.calendarInvite.subject', { appName: headerAppName, fromUser: sanitizeHeaderValue(fromUser) }), html: `
-

Hey ${safeName} πŸ‘‹

-

You have received a calendar invitation from ${safeFromUser}.

+

${t(lang, 'email.greeting', { name: safeName })}

+

${introHtml}

${safeTitle}

-

πŸ• ${escapeHtml(formatDate(startTime))} – ${escapeHtml(formatDate(endTime))}

+

πŸ• ${escapeHtml(formatDate(startTime))} - ${escapeHtml(formatDate(endTime))}

${safeDesc ? `

"${safeDesc}"

` : ''}

- View Invitation + ${t(lang, 'email.viewInvitation')}


-

Open the link above to accept or decline the invitation.

+

${t(lang, 'email.invitationFooter')}

`, - text: `Hey ${name},\n\nYou have received a calendar invitation from ${fromUser}.\nEvent: ${title}\nTime: ${formatDate(startTime)} \u2013 ${formatDate(endTime)}${description ? `\n\n"${description}"` : ''}\n\nView invitation: ${inboxUrl}\n\n\u2013 ${appName}`, + 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') { +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; @@ -197,33 +204,36 @@ export async function sendCalendarEventDeletedEmail(to, name, fromUser, title, a const safeFromUser = escapeHtml(fromUser); const safeTitle = escapeHtml(title); + const introHtml = t(lang, 'email.calendarDeleted.intro') + .replace('{fromUser}', `${safeFromUser}`); + await transporter.sendMail({ from: `"${headerAppName}" <${from}>`, to, - subject: `${headerAppName} – Calendar event cancelled: ${sanitizeHeaderValue(title)}`, + subject: t(lang, 'email.calendarDeleted.subject', { appName: headerAppName, title: sanitizeHeaderValue(title) }), html: `
-

Hey ${safeName} πŸ‘‹

-

The following calendar event was deleted by the organiser (${safeFromUser}) and is no longer available:

+

${t(lang, 'email.greeting', { name: safeName })}

+

${introHtml}

${safeTitle}

-

The event has been automatically removed from your calendar.

+

${t(lang, 'email.calendarDeleted.note')}


-

This message was sent automatically by ${escapeHtml(appName)}.

+

${t(lang, 'email.calendarDeleted.footer', { appName: escapeHtml(appName) })}

`, - text: `Hey ${name},\n\nThe calendar event "${title}" by ${fromUser} has been deleted and removed from your calendar.\n\n\u2013 ${appName}`, + text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.calendarDeleted.intro', { fromUser })}\n"${title}"\n\n${t(lang, 'email.calendarDeleted.note')}\n\n\u2013 ${appName}`, }); } /** * Send a user registration invite email. - * @param {string} to – recipient email - * @param {string} inviteUrl – full invite registration URL - * @param {string} appName – branding app name (default "Redlight") + * @param {string} to - recipient email + * @param {string} inviteUrl - full invite registration URL + * @param {string} appName - branding app name (default "Redlight") */ -export async function sendInviteEmail(to, inviteUrl, appName = 'Redlight') { +export async function sendInviteEmail(to, inviteUrl, appName = 'Redlight', lang = 'en') { if (!transporter) { throw new Error('SMTP not configured'); } @@ -232,30 +242,33 @@ export async function sendInviteEmail(to, inviteUrl, appName = 'Redlight') { const headerAppName = sanitizeHeaderValue(appName); const safeAppName = escapeHtml(appName); + const introHtml = t(lang, 'email.invite.intro') + .replace('{appName}', `${safeAppName}`); + await transporter.sendMail({ from: `"${headerAppName}" <${from}>`, to, - subject: `${headerAppName} – You've been invited`, + subject: t(lang, 'email.invite.subject', { appName: headerAppName }), html: `
-

You've been invited! πŸŽ‰

-

You have been invited to create an account on ${safeAppName}.

-

Click the button below to register:

+

${t(lang, 'email.invite.title')}

+

${introHtml}

+

${t(lang, 'email.invite.prompt')}

- Create Account + ${t(lang, 'email.invite.button')}

- Or copy this link in your browser:
+ ${t(lang, 'email.linkHint')}
${escapeHtml(inviteUrl)}

-

This link is valid for 7 days.

+

${t(lang, 'email.invite.validity')}


-

If you didn't expect this invitation, you can safely ignore this email.

+

${t(lang, 'email.invite.footer')}

`, - text: `You've been invited to create an account on ${appName}.\n\nRegister here: ${inviteUrl}\n\nThis link is valid for 7 days.\n\n– ${appName}`, + text: `${t(lang, 'email.invite.title')}\n\n${t(lang, 'email.invite.intro', { appName })}\n\n${t(lang, 'email.invite.prompt')}\n${inviteUrl}\n\n${t(lang, 'email.invite.validity')}\n\n- ${appName}`, }); } diff --git a/server/index.js b/server/index.js index f539724..cd6e797 100644 --- a/server/index.js +++ b/server/index.js @@ -1,4 +1,4 @@ -import 'dotenv/config'; +ο»Ώimport 'dotenv/config'; import express from 'express'; import cors from 'cors'; import path from 'path'; @@ -22,7 +22,7 @@ const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT || 3001; -// Trust proxy – configurable via TRUST_PROXY env var (default: 1 = one local reverse proxy) +// Trust proxy - configurable via TRUST_PROXY env var (default: 1 = one local reverse proxy) // Use a number to trust that many hops, or a string like 'loopback' / an IP/CIDR. const rawTrustProxy = process.env.TRUST_PROXY ?? 'loopback'; const trustProxy = /^\d+$/.test(rawTrustProxy) ? parseInt(rawTrustProxy, 10) : rawTrustProxy; diff --git a/server/routes/admin.js b/server/routes/admin.js index c3708c7..ee0af15 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -1,4 +1,4 @@ -import { Router } from 'express'; +ο»Ώimport { Router } from 'express'; import bcrypt from 'bcryptjs'; import { v4 as uuidv4 } from 'uuid'; import { getDb } from '../config/database.js'; @@ -26,7 +26,7 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => { const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/; if (!usernameRegex.test(name)) { - return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3–30 chars)' }); + return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' }); } if (password.length < 8) { @@ -211,7 +211,7 @@ router.post('/invites', authenticateToken, requireAdmin, async (req, res) => { if (isMailerConfigured()) { try { - await sendInviteEmail(email.toLowerCase(), inviteUrl, appName); + await sendInviteEmail(email.toLowerCase(), inviteUrl, appName, 'en'); } catch (mailErr) { log.admin.warn(`Invite email failed (non-fatal): ${mailErr.message}`); } diff --git a/server/routes/auth.js b/server/routes/auth.js index aecb2f2..42aa388 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -1,4 +1,4 @@ -import { Router } from 'express'; +ο»Ώimport { Router } from 'express'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; @@ -37,7 +37,7 @@ const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/; // Simple format check for theme/language IDs (actual validation happens on the frontend) const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/; -// Allowlist for CSS color values – only permits hsl(), hex (#rgb/#rrggbb) and plain names +// Allowlist for CSS color values - only permits hsl(), hex (#rgb/#rrggbb) and plain names const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/; const MIN_PASSWORD_LENGTH = 8; @@ -145,7 +145,7 @@ router.post('/register', registerLimiter, async (req, res) => { const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/; if (!usernameRegex.test(username)) { - return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3–30 chars)' }); + return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' }); } // M1: email format @@ -200,7 +200,7 @@ router.post('/register', registerLimiter, async (req, res) => { } try { - await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName); + await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName, 'en'); } catch (mailErr) { log.auth.error(`Verification mail failed: ${mailErr.message}`); // Account is created but email failed β€” user can resend from login page @@ -210,7 +210,7 @@ router.post('/register', registerLimiter, async (req, res) => { return res.status(201).json({ needsVerification: true, message: 'Verification email has been sent' }); } - // No SMTP configured – register and login immediately (legacy behaviour) + // No SMTP configured - register and login immediately (legacy behaviour) const result = await db.run( 'INSERT INTO users (name, display_name, email, password_hash, email_verified) VALUES (?, ?, ?, ?, 1)', [username, display_name, email.toLowerCase(), hash] @@ -278,7 +278,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res) } const db = getDb(); - const user = await db.get('SELECT id, name, display_name, email_verified, verification_resend_at FROM users WHERE email = ?', [email.toLowerCase()]); + const user = await db.get('SELECT id, name, display_name, language, email_verified, verification_resend_at FROM users WHERE email = ?', [email.toLowerCase()]); if (!user || user.email_verified) { // Don't reveal whether account exists @@ -313,7 +313,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res) } try { - await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName); + await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName, user.language || 'en'); } catch (mailErr) { log.auth.error(`Resend verification mail failed: ${mailErr.message}`); return res.status(502).json({ error: 'Email could not be sent. Please check your SMTP configuration.' }); @@ -335,7 +335,7 @@ router.post('/login', loginLimiter, async (req, res) => { return res.status(400).json({ error: 'Email and password are required' }); } - // M1: basic email format check – invalid format can never match a real account + // M1: basic email format check - invalid format can never match a real account if (!EMAIL_RE.test(email)) { return res.status(401).json({ error: 'Invalid credentials' }); } @@ -361,7 +361,7 @@ router.post('/login', loginLimiter, async (req, res) => { } }); -// POST /api/auth/logout – revoke JWT via DragonflyDB blacklist +// POST /api/auth/logout - revoke JWT via DragonflyDB blacklist router.post('/logout', authenticateToken, async (req, res) => { try { const authHeader = req.headers.authorization; @@ -432,7 +432,7 @@ router.put('/profile', authenticateToken, profileLimiter, async (req, res) => { if (name && name !== req.user.name) { const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/; if (!usernameRegex.test(name)) { - return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3–30 chars)' }); + return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' }); } const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?) AND id != ?', [name, req.user.id]); if (existingUsername) { @@ -504,7 +504,7 @@ router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => { return res.status(400).json({ error: 'Only image files are allowed' }); } - // M15: stream-level size limit – abort as soon as 2 MB is exceeded + // M15: stream-level size limit - abort as soon as 2 MB is exceeded const MAX_AVATAR_SIZE = 2 * 1024 * 1024; const buffer = await new Promise((resolve, reject) => { const chunks = []; diff --git a/server/routes/calendar.js b/server/routes/calendar.js index b3a198c..ae46538 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -1,4 +1,4 @@ -import { Router } from 'express'; +ο»Ώimport { Router } from 'express'; import crypto from 'crypto'; import { getDb } from '../config/database.js'; import { authenticateToken } from '../middleware/auth.js'; @@ -289,7 +289,7 @@ router.post('/events/:id/share', authenticateToken, async (req, res) => { ); // Send notification email (fire-and-forget) - const targetUser = await db.get('SELECT name, display_name, email FROM users WHERE id = ?', [user_id]); + const targetUser = await db.get('SELECT name, display_name, email, language FROM users WHERE id = ?', [user_id]); if (targetUser?.email) { const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const inboxUrl = `${appUrl}/federation/inbox`; @@ -301,7 +301,7 @@ router.post('/events/:id/share', authenticateToken, async (req, res) => { (targetUser.display_name && targetUser.display_name !== '') ? targetUser.display_name : targetUser.name, fromDisplay, event.title, event.start_time, event.end_time, event.description, - inboxUrl, appName + inboxUrl, appName, targetUser.language || 'en' ).catch(mailErr => { log.server.warn('Calendar local share mail failed (non-fatal):', mailErr.message); }); @@ -423,7 +423,7 @@ router.delete('/local-invitations/:id', authenticateToken, async (req, res) => { 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 + // 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]); } @@ -587,7 +587,7 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as // Find local user const { username } = parseAddress(to_user); const db = getDb(); - const targetUser = await db.get('SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)', [username]); + 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) @@ -619,7 +619,7 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as sendCalendarInviteEmail( targetUser.email, targetUser.name, from_user, title, start_time, end_time, description || null, - inboxUrl, appName + inboxUrl, appName, targetUser.language || 'en' ).catch(mailErr => { log.server.warn('Calendar invite mail failed (non-fatal):', mailErr.message); }); diff --git a/server/routes/federation.js b/server/routes/federation.js index 74ce4fd..4f511eb 100644 --- a/server/routes/federation.js +++ b/server/routes/federation.js @@ -1,4 +1,4 @@ -import { Router } from 'express'; +ο»Ώimport { Router } from 'express'; import { v4 as uuidv4 } from 'uuid'; import { rateLimit } from 'express-rate-limit'; import { getDb } from '../config/database.js'; @@ -220,14 +220,14 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => { } catch { /* column may not exist on very old installs */ } } - // Send notification email (truly fire-and-forget – never blocks the response) + // Send notification email (truly fire-and-forget - never blocks the response) if (targetUser.email) { const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const inboxUrl = `${appUrl}/federation/inbox`; const appName = process.env.APP_NAME || 'Redlight'; sendFederationInviteEmail( targetUser.email, targetUser.name, from_user, - room_name, message || null, inboxUrl, appName + room_name, message || null, inboxUrl, appName, targetUser.language || 'en' ).catch(mailErr => { log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message); }); @@ -559,7 +559,7 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res try { // Users with pending/declined invitations const invUsers = await db.all( - `SELECT u.email, u.name, ci.title, ci.from_user + `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 ?`, @@ -567,7 +567,7 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res ); // Users who already accepted (event in their calendar) const calUsers = await db.all( - `SELECT u.email, u.name, ce.title, ce.federated_from AS from_user + `SELECT u.email, u.name, u.language, ce.title, ce.federated_from AS from_user FROM calendar_events ce JOIN users u ON ce.user_id = u.id WHERE ce.uid = ? AND ce.federated_from LIKE ?`, @@ -603,7 +603,7 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res if (affectedUsers.length > 0) { const appName = process.env.APP_NAME || 'Redlight'; for (const u of affectedUsers) { - sendCalendarEventDeletedEmail(u.email, u.name, u.from_user, u.title, appName) + 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}`); }); diff --git a/server/routes/rooms.js b/server/routes/rooms.js index a0ce978..c3dc5e0 100644 --- a/server/routes/rooms.js +++ b/server/routes/rooms.js @@ -1,4 +1,4 @@ -import { Router } from 'express'; +ο»Ώimport { Router } from 'express'; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; @@ -648,7 +648,7 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => { const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]); if (!room) return res.status(404).json({ error: 'Room not found or no permission' }); - // M16: stream-level size limit – abort as soon as 50 MB is exceeded + // M16: stream-level size limit - abort as soon as 50 MB is exceeded const MAX_PRESENTATION_SIZE = 50 * 1024 * 1024; const buffer = await new Promise((resolve, reject) => { const chunks = []; diff --git a/src/i18n/de.json b/src/i18n/de.json index 333398d..51afac3 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -1,4 +1,4 @@ -{ +ο»Ώ{ "common": { "appName": "Redlight", "loading": "Laden...", @@ -76,11 +76,11 @@ "emailNotVerified": "E-Mail-Adresse noch nicht verifiziert. Bitte prΓΌfe dein Postfach.", "username": "Benutzername", "usernamePlaceholder": "z.B. maxmuster", - "usernameHint": "Nur Buchstaben, Zahlen, _ und - erlaubt (3–30 Zeichen)", + "usernameHint": "Nur Buchstaben, Zahlen, _ und - erlaubt (3-30 Zeichen)", "displayName": "Anzeigename", "displayNamePlaceholder": "Max Mustermann", "usernameTaken": "Benutzername ist bereits vergeben", - "usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, _ und - enthalten (3–30 Zeichen)", + "usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, _ und - enthalten (3-30 Zeichen)", "usernameRequired": "Benutzername ist erforderlich", "displayNameRequired": "Anzeigename ist erforderlich", "emailVerificationBanner": "Deine E-Mail-Adresse wurde noch nicht verifiziert.", @@ -394,7 +394,7 @@ "removeRoomConfirm": "Raum wirklich entfernen?", "roomRemoved": "Raum entfernt", "roomRemoveFailed": "Raum konnte nicht entfernt werden", - "acceptedSaved": "Einladung angenommen – Raum wurde in deinem Dashboard gespeichert!", + "acceptedSaved": "Einladung angenommen - Raum wurde in deinem Dashboard gespeichert!", "meetingId": "Meeting ID", "maxParticipants": "Max. Teilnehmer", "recordingOn": "Aufnahme aktiviert", @@ -412,7 +412,7 @@ "calendarEvent": "Kalendereinladung", "calendarAccepted": "Kalender-Event angenommen und in deinen Kalender eingetragen!", "localCalendarEvent": "Lokale Kalendereinladung", - "calendarLocalAccepted": "Einladung angenommen – Event wurde in deinen Kalender eingetragen!", + "calendarLocalAccepted": "Einladung angenommen - Event wurde in deinen Kalender eingetragen!", "invitationRemoved": "Einladung entfernt", "removeInvitation": "Einladung entfernen" }, @@ -475,5 +475,42 @@ "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." + } } } \ No newline at end of file diff --git a/src/i18n/en.json b/src/i18n/en.json index c67bba1..12b4399 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1,4 +1,4 @@ -{ +ο»Ώ{ "common": { "appName": "Redlight", "loading": "Loading...", @@ -76,11 +76,11 @@ "emailNotVerified": "Email not yet verified. Please check your inbox.", "username": "Username", "usernamePlaceholder": "e.g. johndoe", - "usernameHint": "Letters, numbers, _ and - only (3–30 chars)", + "usernameHint": "Letters, numbers, _ and - only (3-30 chars)", "displayName": "Display Name", "displayNamePlaceholder": "John Doe", "usernameTaken": "Username is already taken", - "usernameInvalid": "Username may only contain letters, numbers, _ and - (3–30 chars)", + "usernameInvalid": "Username may only contain letters, numbers, _ and - (3-30 chars)", "usernameRequired": "Username is required", "displayNameRequired": "Display name is required", "emailVerificationBanner": "Your email address has not been verified yet.", @@ -394,7 +394,7 @@ "removeRoomConfirm": "Really remove this room?", "roomRemoved": "Room removed", "roomRemoveFailed": "Could not remove room", - "acceptedSaved": "Invitation accepted – room saved to your dashboard!", + "acceptedSaved": "Invitation accepted - room saved to your dashboard!", "meetingId": "Meeting ID", "maxParticipants": "Max. participants", "recordingOn": "Recording enabled", @@ -412,7 +412,7 @@ "calendarEvent": "Calendar Invitation", "calendarAccepted": "Calendar event accepted and added to your calendar!", "localCalendarEvent": "Local Calendar Invitation", - "calendarLocalAccepted": "Invitation accepted – event added to your calendar!", + "calendarLocalAccepted": "Invitation accepted - event added to your calendar!", "invitationRemoved": "Invitation removed", "removeInvitation": "Remove invitation" }, @@ -475,5 +475,42 @@ "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}." + } } } \ No newline at end of file diff --git a/src/pages/Calendar.jsx b/src/pages/Calendar.jsx index 57d547c..4f5e436 100644 --- a/src/pages/Calendar.jsx +++ b/src/pages/Calendar.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react'; +ο»Ώimport { useState, useEffect, useMemo } from 'react'; import { ChevronLeft, ChevronRight, Plus, Calendar as CalendarIcon, Clock, Video, Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send, ExternalLink, @@ -494,7 +494,7 @@ export default function Calendar() { style={{ backgroundColor: ev.color || '#6366f1' }} >
{ev.title}
-
{formatTime(ev.start_time)} – {formatTime(ev.end_time)}
+
{formatTime(ev.start_time)} - {formatTime(ev.end_time)}
))} @@ -604,7 +604,7 @@ export default function Calendar() {
- {new Date(showDetail.start_time).toLocaleString()} – {new Date(showDetail.end_time).toLocaleString()} + {new Date(showDetail.start_time).toLocaleString()} - {new Date(showDetail.end_time).toLocaleString()}
diff --git a/src/pages/FederationInbox.jsx b/src/pages/FederationInbox.jsx index bb6d717..7454ed3 100644 --- a/src/pages/FederationInbox.jsx +++ b/src/pages/FederationInbox.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +ο»Ώimport { useState, useEffect } from 'react'; import { Globe, Mail, Check, X, ExternalLink, Loader2, Inbox, Calendar, Trash2 } from 'lucide-react'; import api from '../services/api'; import { useLanguage } from '../contexts/LanguageContext'; @@ -210,7 +210,7 @@ export default function FederationInbox() { {t('federation.from')}: {inv.from_user}

- {new Date(inv.start_time).toLocaleString()} – {new Date(inv.end_time).toLocaleString()} + {new Date(inv.start_time).toLocaleString()} - {new Date(inv.end_time).toLocaleString()}

{inv.description && (

"{inv.description}"

@@ -249,7 +249,7 @@ export default function FederationInbox() { {t('federation.from')}: {inv.from_name}

- {new Date(inv.start_time).toLocaleString()} – {new Date(inv.end_time).toLocaleString()} + {new Date(inv.start_time).toLocaleString()} - {new Date(inv.end_time).toLocaleString()}

{inv.description && (

"{inv.description}"