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}
-
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}"