Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ef6a9f30b | |||
| 8be973a166 | |||
| 3898bf1b4b | |||
| 69a3c83436 | |||
| cd98ee4cc7 | |||
| d8dcb6e628 | |||
| a150bd1447 | |||
| 49769d4b51 | |||
| 9001aea8cd | |||
| cf74ed31af | |||
| 44ebdcb8ee |
15
.env.example
15
.env.example
@@ -12,9 +12,24 @@ JWT_SECRET=your-super-secret-jwt-key-change-this
|
|||||||
# DATABASE_URL=postgres://user:password@localhost:5432/redlight
|
# DATABASE_URL=postgres://user:password@localhost:5432/redlight
|
||||||
DATABASE_URL=
|
DATABASE_URL=
|
||||||
|
|
||||||
|
POSTGRES_USER=redlight
|
||||||
|
POSTGRES_PASSWORD=redlight
|
||||||
|
POSTGRES_DB=redlight
|
||||||
|
|
||||||
# SQLite file path (only used when DATABASE_URL is not set)
|
# SQLite file path (only used when DATABASE_URL is not set)
|
||||||
# SQLITE_PATH=./redlight.db
|
# SQLITE_PATH=./redlight.db
|
||||||
|
|
||||||
# Default Admin Account (created on first run)
|
# Default Admin Account (created on first run)
|
||||||
ADMIN_EMAIL=admin@example.com
|
ADMIN_EMAIL=admin@example.com
|
||||||
ADMIN_PASSWORD=admin123
|
ADMIN_PASSWORD=admin123
|
||||||
|
|
||||||
|
# SMTP Configuration (for email verification)
|
||||||
|
# If not set, registration works without email verification
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=noreply@example.com
|
||||||
|
SMTP_PASS=your-smtp-password
|
||||||
|
SMTP_FROM=noreply@example.com
|
||||||
|
|
||||||
|
# App URL (used for verification links, auto-detected if not set)
|
||||||
|
# APP_URL=https://your-domain.com
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ COPY server/ ./server/
|
|||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
# Create uploads directory
|
# Create uploads directory
|
||||||
RUN mkdir -p uploads/avatars
|
RUN mkdir -p uploads/avatars uploads/branding
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3001
|
ENV PORT=3001
|
||||||
|
|||||||
13
compose.yml
13
compose.yml
@@ -4,13 +4,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
environment:
|
env_file: ".env"
|
||||||
DATABASE_URL: postgres://redlight:redlight@postgres:5432/redlight
|
|
||||||
BBB_URL: https://your-bbb-server.com/bigbluebutton/api/
|
|
||||||
BBB_SECRET: your-bbb-shared-secret
|
|
||||||
JWT_SECRET: change-me-to-a-random-secret
|
|
||||||
ADMIN_EMAIL: admin@example.com
|
|
||||||
ADMIN_PASSWORD: admin123
|
|
||||||
volumes:
|
volumes:
|
||||||
- uploads:/app/uploads
|
- uploads:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -20,10 +14,7 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
env_file: ".env"
|
||||||
POSTGRES_USER: redlight
|
|
||||||
POSTGRES_PASSWORD: redlight
|
|
||||||
POSTGRES_DB: redlight
|
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
111
package-lock.json
generated
111
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "redlight",
|
"name": "redlight",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "redlight",
|
"name": "redlight",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@@ -17,11 +17,14 @@
|
|||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"nodemailer": "^8.0.1",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"react-hot-toast": "^2.4.0",
|
"react-hot-toast": "^2.4.0",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"xml2js": "^0.6.0"
|
"xml2js": "^0.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1333,6 +1336,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/append-field": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
@@ -1599,6 +1608,23 @@
|
|||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-from": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/busboy": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||||
|
"dependencies": {
|
||||||
|
"streamsearch": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -1794,6 +1820,21 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/concat-stream": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
|
"engines": [
|
||||||
|
"node >= 6.0"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.0.2",
|
||||||
|
"typedarray": "^0.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concurrently": {
|
"node_modules/concurrently": {
|
||||||
"version": "9.2.1",
|
"version": "9.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||||
@@ -3022,6 +3063,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mkdirp": {
|
||||||
|
"version": "0.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||||
|
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"minimist": "^1.2.6"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"mkdirp": "bin/cmd.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mkdirp-classic": {
|
"node_modules/mkdirp-classic": {
|
||||||
"version": "0.5.3",
|
"version": "0.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||||
@@ -3034,6 +3087,24 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/multer": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"append-field": "^1.0.0",
|
||||||
|
"busboy": "^1.6.0",
|
||||||
|
"concat-stream": "^2.0.0",
|
||||||
|
"mkdirp": "^0.5.6",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"type-is": "^1.6.18",
|
||||||
|
"xtend": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mz": {
|
"node_modules/mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||||
@@ -3111,6 +3182,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/normalize-path": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
@@ -4168,6 +4248,14 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/streamsearch": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
@@ -4469,6 +4557,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typedarray": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
@@ -4524,6 +4618,19 @@
|
|||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist-node/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "redlight",
|
"name": "redlight",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -n client,server -c blue,green \"vite\" \"node --watch server/index.js\"",
|
"dev": "concurrently -n client,server -c blue,green \"vite\" \"node --watch server/index.js\"",
|
||||||
@@ -21,11 +21,14 @@
|
|||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"nodemailer": "^8.0.1",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"react-hot-toast": "^2.4.0",
|
"react-hot-toast": "^2.4.0",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"xml2js": "^0.6.0"
|
"xml2js": "^0.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
BIN
redlight.db-shm
BIN
redlight.db-shm
Binary file not shown.
BIN
redlight.db-wal
BIN
redlight.db-wal
Binary file not shown.
@@ -39,14 +39,25 @@ function getRoomPasswords(uid) {
|
|||||||
return { moderatorPW: modPw, attendeePW: attPw };
|
return { moderatorPW: modPw, attendeePW: attPw };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMeeting(room) {
|
export async function createMeeting(room, logoutURL) {
|
||||||
const { moderatorPW, attendeePW } = getRoomPasswords(room.uid);
|
const { moderatorPW, attendeePW } = getRoomPasswords(room.uid);
|
||||||
|
|
||||||
|
// Build welcome message with guest invite link
|
||||||
|
let welcome = room.welcome_message || 'Willkommen!';
|
||||||
|
if (logoutURL) {
|
||||||
|
const guestLink = `${logoutURL}/join/${room.uid}`;
|
||||||
|
welcome += `<br><br>To invite other participants, share this link:<br><a href="${guestLink}">${guestLink}</a>`;
|
||||||
|
if (room.access_code) {
|
||||||
|
welcome += `<br>Access Code: <b>${room.access_code}</b>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
meetingID: room.uid,
|
meetingID: room.uid,
|
||||||
name: room.name,
|
name: room.name,
|
||||||
attendeePW,
|
attendeePW,
|
||||||
moderatorPW,
|
moderatorPW,
|
||||||
welcome: room.welcome_message || 'Willkommen!',
|
welcome,
|
||||||
record: room.record_meeting ? 'true' : 'false',
|
record: room.record_meeting ? 'true' : 'false',
|
||||||
autoStartRecording: 'false',
|
autoStartRecording: 'false',
|
||||||
allowStartStopRecording: 'true',
|
allowStartStopRecording: 'true',
|
||||||
@@ -54,6 +65,9 @@ export async function createMeeting(room) {
|
|||||||
'meta_bbb-origin': 'Redlight',
|
'meta_bbb-origin': 'Redlight',
|
||||||
'meta_bbb-origin-server-name': 'Redlight',
|
'meta_bbb-origin-server-name': 'Redlight',
|
||||||
};
|
};
|
||||||
|
if (logoutURL) {
|
||||||
|
params.logoutURL = logoutURL;
|
||||||
|
}
|
||||||
if (room.max_participants > 0) {
|
if (room.max_participants > 0) {
|
||||||
params.maxParticipants = room.max_participants.toString();
|
params.maxParticipants = room.max_participants.toString();
|
||||||
}
|
}
|
||||||
@@ -63,7 +77,7 @@ export async function createMeeting(room) {
|
|||||||
return apiCall('create', params);
|
return apiCall('create', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function joinMeeting(uid, name, isModerator = false) {
|
export async function joinMeeting(uid, name, isModerator = false, avatarURL = null) {
|
||||||
const { moderatorPW, attendeePW } = getRoomPasswords(uid);
|
const { moderatorPW, attendeePW } = getRoomPasswords(uid);
|
||||||
const params = {
|
const params = {
|
||||||
meetingID: uid,
|
meetingID: uid,
|
||||||
@@ -71,6 +85,9 @@ export async function joinMeeting(uid, name, isModerator = false) {
|
|||||||
password: isModerator ? moderatorPW : attendeePW,
|
password: isModerator ? moderatorPW : attendeePW,
|
||||||
redirect: 'true',
|
redirect: 'true',
|
||||||
};
|
};
|
||||||
|
if (avatarURL) {
|
||||||
|
params.avatarURL = avatarURL;
|
||||||
|
}
|
||||||
return buildUrl('join', params);
|
return buildUrl('join', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,9 @@ export async function initDatabase() {
|
|||||||
theme TEXT DEFAULT 'dark',
|
theme TEXT DEFAULT 'dark',
|
||||||
avatar_color TEXT DEFAULT '#6366f1',
|
avatar_color TEXT DEFAULT '#6366f1',
|
||||||
avatar_image TEXT DEFAULT NULL,
|
avatar_image TEXT DEFAULT NULL,
|
||||||
|
email_verified INTEGER DEFAULT 0,
|
||||||
|
verification_token TEXT,
|
||||||
|
verification_token_expires TIMESTAMP,
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP DEFAULT NOW()
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
);
|
);
|
||||||
@@ -157,9 +160,25 @@ export async function initDatabase() {
|
|||||||
updated_at TIMESTAMP DEFAULT NOW()
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_shares (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(room_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_rooms_user_id ON rooms(user_id);
|
CREATE INDEX IF NOT EXISTS idx_rooms_user_id ON rooms(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_rooms_uid ON rooms(uid);
|
CREATE INDEX IF NOT EXISTS idx_rooms_uid ON rooms(uid);
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_room_shares_room_id ON room_shares(room_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_room_shares_user_id ON room_shares(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
} else {
|
} else {
|
||||||
await db.exec(`
|
await db.exec(`
|
||||||
@@ -173,6 +192,9 @@ export async function initDatabase() {
|
|||||||
theme TEXT DEFAULT 'dark',
|
theme TEXT DEFAULT 'dark',
|
||||||
avatar_color TEXT DEFAULT '#6366f1',
|
avatar_color TEXT DEFAULT '#6366f1',
|
||||||
avatar_image TEXT DEFAULT NULL,
|
avatar_image TEXT DEFAULT NULL,
|
||||||
|
email_verified INTEGER DEFAULT 0,
|
||||||
|
verification_token TEXT,
|
||||||
|
verification_token_expires DATETIME,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -197,9 +219,27 @@ export async function initDatabase() {
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_shares (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(room_id, user_id),
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_rooms_user_id ON rooms(user_id);
|
CREATE INDEX IF NOT EXISTS idx_rooms_user_id ON rooms(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_rooms_uid ON rooms(uid);
|
CREATE INDEX IF NOT EXISTS idx_rooms_uid ON rooms(uid);
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_room_shares_room_id ON room_shares(room_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_room_shares_user_id ON room_shares(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +253,19 @@ export async function initDatabase() {
|
|||||||
if (!(await db.columnExists('rooms', 'moderator_code'))) {
|
if (!(await db.columnExists('rooms', 'moderator_code'))) {
|
||||||
await db.exec('ALTER TABLE rooms ADD COLUMN moderator_code TEXT');
|
await db.exec('ALTER TABLE rooms ADD COLUMN moderator_code TEXT');
|
||||||
}
|
}
|
||||||
|
if (!(await db.columnExists('users', 'email_verified'))) {
|
||||||
|
await db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0');
|
||||||
|
}
|
||||||
|
if (!(await db.columnExists('users', 'verification_token'))) {
|
||||||
|
await db.exec('ALTER TABLE users ADD COLUMN verification_token TEXT');
|
||||||
|
}
|
||||||
|
if (!(await db.columnExists('users', 'verification_token_expires'))) {
|
||||||
|
if (isPostgres) {
|
||||||
|
await db.exec('ALTER TABLE users ADD COLUMN verification_token_expires TIMESTAMP');
|
||||||
|
} else {
|
||||||
|
await db.exec('ALTER TABLE users ADD COLUMN verification_token_expires DATETIME');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Default admin ───────────────────────────────────────────────────────
|
// ── Default admin ───────────────────────────────────────────────────────
|
||||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
|
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||||
@@ -222,7 +275,7 @@ export async function initDatabase() {
|
|||||||
if (!existingAdmin) {
|
if (!existingAdmin) {
|
||||||
const hash = bcrypt.hashSync(adminPassword, 12);
|
const hash = bcrypt.hashSync(adminPassword, 12);
|
||||||
await db.run(
|
await db.run(
|
||||||
'INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)',
|
'INSERT INTO users (name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, 1)',
|
||||||
['Administrator', adminEmail, hash, 'admin']
|
['Administrator', adminEmail, hash, 'admin']
|
||||||
);
|
);
|
||||||
console.log(`✅ Default admin created: ${adminEmail}`);
|
console.log(`✅ Default admin created: ${adminEmail}`);
|
||||||
|
|||||||
70
server/config/mailer.js
Normal file
70
server/config/mailer.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
|
let transporter;
|
||||||
|
|
||||||
|
export function initMailer() {
|
||||||
|
const host = process.env.SMTP_HOST;
|
||||||
|
const port = parseInt(process.env.SMTP_PORT || '587', 10);
|
||||||
|
const user = process.env.SMTP_USER;
|
||||||
|
const pass = process.env.SMTP_PASS;
|
||||||
|
|
||||||
|
if (!host || !user || !pass) {
|
||||||
|
console.warn('⚠️ SMTP not configured – email verification disabled');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
transporter = nodemailer.createTransport({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure: port === 465,
|
||||||
|
auth: { user, pass },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ SMTP mailer configured');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMailerConfigured() {
|
||||||
|
return !!transporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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")
|
||||||
|
*/
|
||||||
|
export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight') {
|
||||||
|
if (!transporter) {
|
||||||
|
throw new Error('SMTP not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"${appName}" <${from}>`,
|
||||||
|
to,
|
||||||
|
subject: `${appName} – E-Mail bestätigen / Verify your email`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||||
|
<h2 style="color:#cba6f7;margin-top:0;">Hey ${name} 👋</h2>
|
||||||
|
<p>Bitte bestätige deine E-Mail-Adresse, indem du auf den folgenden Button klickst:</p>
|
||||||
|
<p style="text-align:center;margin:28px 0;">
|
||||||
|
<a href="${verifyUrl}"
|
||||||
|
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
|
||||||
|
E-Mail bestätigen
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size:13px;color:#7f849c;">
|
||||||
|
Oder kopiere diesen Link in deinen Browser:<br/>
|
||||||
|
<a href="${verifyUrl}" style="color:#89b4fa;word-break:break-all;">${verifyUrl}</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size:13px;color:#7f849c;">Der Link ist 24 Stunden gültig.</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||||
|
<p style="font-size:12px;color:#585b70;">Falls du dich nicht registriert hast, ignoriere diese E-Mail.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
text: `Hey ${name},\n\nBitte bestätige deine E-Mail: ${verifyUrl}\n\nDer Link ist 24 Stunden gültig.\n\n– ${appName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import cors from 'cors';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { initDatabase } from './config/database.js';
|
import { initDatabase } from './config/database.js';
|
||||||
|
import { initMailer } from './config/mailer.js';
|
||||||
import authRoutes from './routes/auth.js';
|
import authRoutes from './routes/auth.js';
|
||||||
import roomRoutes from './routes/rooms.js';
|
import roomRoutes from './routes/rooms.js';
|
||||||
import recordingRoutes from './routes/recordings.js';
|
import recordingRoutes from './routes/recordings.js';
|
||||||
import adminRoutes from './routes/admin.js';
|
import adminRoutes from './routes/admin.js';
|
||||||
|
import brandingRoutes from './routes/branding.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -15,6 +17,9 @@ const __dirname = path.dirname(__filename);
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Trust proxy for correct req.protocol behind reverse proxy
|
||||||
|
app.set('trust proxy', true);
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -22,12 +27,14 @@ app.use(express.json());
|
|||||||
// Initialize database & start server
|
// Initialize database & start server
|
||||||
async function start() {
|
async function start() {
|
||||||
await initDatabase();
|
await initDatabase();
|
||||||
|
initMailer();
|
||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/rooms', roomRoutes);
|
app.use('/api/rooms', roomRoutes);
|
||||||
app.use('/api/recordings', recordingRoutes);
|
app.use('/api/recordings', recordingRoutes);
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
|
app.use('/api/branding', brandingRoutes);
|
||||||
|
|
||||||
// Serve static files in production
|
// Serve static files in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { getDb } from '../config/database.js';
|
import { getDb } from '../config/database.js';
|
||||||
import { authenticateToken, generateToken } from '../middleware/auth.js';
|
import { authenticateToken, generateToken } from '../middleware/auth.js';
|
||||||
|
import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -37,8 +39,36 @@ router.post('/register', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hash = bcrypt.hashSync(password, 12);
|
const hash = bcrypt.hashSync(password, 12);
|
||||||
|
|
||||||
|
// If SMTP is configured, require email verification
|
||||||
|
if (isMailerConfigured()) {
|
||||||
|
const verificationToken = uuidv4();
|
||||||
|
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
'INSERT INTO users (name, email, password_hash, email_verified, verification_token, verification_token_expires) VALUES (?, ?, ?, 0, ?, ?)',
|
||||||
|
[name, email.toLowerCase(), hash, verificationToken, expires]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build verification URL
|
||||||
|
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
|
||||||
|
|
||||||
|
// Load app name from branding settings
|
||||||
|
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
|
||||||
|
let appName = 'Redlight';
|
||||||
|
if (brandingSetting?.value) {
|
||||||
|
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendVerificationEmail(email.toLowerCase(), name, verifyUrl, appName);
|
||||||
|
|
||||||
|
return res.status(201).json({ needsVerification: true, message: 'Verifizierungs-E-Mail wurde gesendet' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// No SMTP configured – register and login immediately (legacy behaviour)
|
||||||
const result = await db.run(
|
const result = await db.run(
|
||||||
'INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)',
|
'INSERT INTO users (name, email, password_hash, email_verified) VALUES (?, ?, ?, 1)',
|
||||||
[name, email.toLowerCase(), hash]
|
[name, email.toLowerCase(), hash]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -52,6 +82,86 @@ router.post('/register', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/verify-email?token=...
|
||||||
|
router.get('/verify-email', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { token } = req.query;
|
||||||
|
if (!token) {
|
||||||
|
return res.status(400).json({ error: 'Token fehlt' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const user = await db.get(
|
||||||
|
'SELECT id, verification_token_expires FROM users WHERE verification_token = ? AND email_verified = 0',
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(400).json({ error: 'Ungültiger oder bereits verwendeter Token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new Date(user.verification_token_expires) < new Date()) {
|
||||||
|
return res.status(400).json({ error: 'Token ist abgelaufen. Bitte registriere dich erneut.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
'UPDATE users SET email_verified = 1, verification_token = NULL, verification_token_expires = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ verified: true, message: 'E-Mail erfolgreich verifiziert' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Verify email error:', err);
|
||||||
|
res.status(500).json({ error: 'Verifizierung fehlgeschlagen' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/resend-verification
|
||||||
|
router.post('/resend-verification', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email } = req.body;
|
||||||
|
if (!email) {
|
||||||
|
return res.status(400).json({ error: 'E-Mail ist erforderlich' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMailerConfigured()) {
|
||||||
|
return res.status(400).json({ error: 'SMTP ist nicht konfiguriert' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const user = await db.get('SELECT id, name, email_verified FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||||
|
|
||||||
|
if (!user || user.email_verified) {
|
||||||
|
// Don't reveal whether account exists
|
||||||
|
return res.json({ message: 'Falls ein Konto existiert, wurde eine neue E-Mail gesendet.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationToken = uuidv4();
|
||||||
|
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
'UPDATE users SET verification_token = ?, verification_token_expires = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[verificationToken, expires, user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
|
||||||
|
|
||||||
|
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
|
||||||
|
let appName = 'Redlight';
|
||||||
|
if (brandingSetting?.value) {
|
||||||
|
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendVerificationEmail(email.toLowerCase(), user.name, verifyUrl, appName);
|
||||||
|
|
||||||
|
res.json({ message: 'Falls ein Konto existiert, wurde eine neue E-Mail gesendet.' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Resend verification error:', err);
|
||||||
|
res.status(500).json({ error: 'E-Mail konnte nicht gesendet werden' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/auth/login
|
// POST /api/auth/login
|
||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -68,6 +178,10 @@ router.post('/login', async (req, res) => {
|
|||||||
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
|
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user.email_verified && isMailerConfigured()) {
|
||||||
|
return res.status(403).json({ error: 'E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach.', needsVerification: true });
|
||||||
|
}
|
||||||
|
|
||||||
const token = generateToken(user.id);
|
const token = generateToken(user.id);
|
||||||
const { password_hash, ...safeUser } = user;
|
const { password_hash, ...safeUser } = user;
|
||||||
|
|
||||||
@@ -203,6 +317,36 @@ router.delete('/avatar', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/avatar/initials/:name - Generate SVG avatar from initials (public, BBB fetches this)
|
||||||
|
router.get('/avatar/initials/:name', (req, res) => {
|
||||||
|
const name = decodeURIComponent(req.params.name).trim();
|
||||||
|
const color = req.query.color || generateColorFromName(name);
|
||||||
|
const initials = name
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2) || '?';
|
||||||
|
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||||
|
<rect width="128" height="128" rx="64" fill="${color}"/>
|
||||||
|
<text x="64" y="64" dy=".35em" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-size="52" font-weight="bold">${initials}</text>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'image/svg+xml');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||||
|
res.send(svg);
|
||||||
|
});
|
||||||
|
|
||||||
|
function generateColorFromName(name) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
const hue = Math.abs(hash) % 360;
|
||||||
|
return `hsl(${hue}, 55%, 45%)`;
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/auth/avatar/:filename - Serve avatar image
|
// GET /api/auth/avatar/:filename - Serve avatar image
|
||||||
router.get('/avatar/:filename', (req, res) => {
|
router.get('/avatar/:filename', (req, res) => {
|
||||||
const filepath = path.join(uploadsDir, req.params.filename);
|
const filepath = path.join(uploadsDir, req.params.filename);
|
||||||
|
|||||||
159
server/routes/branding.js
Normal file
159
server/routes/branding.js
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { getDb } from '../config/database.js';
|
||||||
|
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Ensure uploads/branding directory exists
|
||||||
|
const brandingDir = path.join(__dirname, '..', '..', 'uploads', 'branding');
|
||||||
|
if (!fs.existsSync(brandingDir)) {
|
||||||
|
fs.mkdirSync(brandingDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multer config for logo upload
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => cb(null, brandingDir),
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase() || '.png';
|
||||||
|
cb(null, `logo${ext}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const allowed = /\.(jpg|jpeg|png|gif|svg|webp|ico)$/i;
|
||||||
|
const mimeAllowed = /^image\/(jpeg|png|gif|svg\+xml|webp|x-icon|vnd\.microsoft\.icon)$/;
|
||||||
|
if (allowed.test(path.extname(file.originalname)) && mimeAllowed.test(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only image files are allowed'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: get setting from DB
|
||||||
|
async function getSetting(key) {
|
||||||
|
const db = getDb();
|
||||||
|
const row = await db.get('SELECT value FROM settings WHERE key = ?', [key]);
|
||||||
|
return row?.value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: set setting in DB
|
||||||
|
async function setSetting(key, value) {
|
||||||
|
const db = getDb();
|
||||||
|
// Try update first, then insert if nothing was updated
|
||||||
|
const result = await db.run('UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?', [value, key]);
|
||||||
|
if (result.changes === 0) {
|
||||||
|
// Use INSERT with a dummy RETURNING to satisfy PG adapter, or just use exec-style
|
||||||
|
await db.run('INSERT INTO settings (key, value) VALUES (?, ?) RETURNING key', [key, value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: delete setting from DB
|
||||||
|
async function deleteSetting(key) {
|
||||||
|
const db = getDb();
|
||||||
|
await db.run('DELETE FROM settings WHERE key = ?', [key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: find current logo file on disk
|
||||||
|
function findLogoFile() {
|
||||||
|
if (!fs.existsSync(brandingDir)) return null;
|
||||||
|
const files = fs.readdirSync(brandingDir);
|
||||||
|
const logo = files.find(f => f.startsWith('logo.'));
|
||||||
|
return logo ? path.join(brandingDir, logo) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/branding - Get branding settings (public)
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const appName = await getSetting('app_name');
|
||||||
|
const logoFile = findLogoFile();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
appName: appName || 'Redlight',
|
||||||
|
hasLogo: !!logoFile,
|
||||||
|
logoUrl: logoFile ? '/api/branding/logo' : null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Get branding error:', err);
|
||||||
|
res.status(500).json({ error: 'Could not load branding' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/branding/logo - Serve logo file (public)
|
||||||
|
router.get('/logo', (req, res) => {
|
||||||
|
const logoFile = findLogoFile();
|
||||||
|
if (!logoFile) {
|
||||||
|
return res.status(404).json({ error: 'No logo found' });
|
||||||
|
}
|
||||||
|
res.sendFile(logoFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/branding/logo - Upload logo (admin only)
|
||||||
|
router.post('/logo', authenticateToken, requireAdmin, (req, res) => {
|
||||||
|
upload.single('logo')(req, res, async (err) => {
|
||||||
|
if (err) {
|
||||||
|
if (err instanceof multer.MulterError) {
|
||||||
|
return res.status(400).json({ error: err.code === 'LIMIT_FILE_SIZE' ? 'File too large (max 2MB)' : err.message });
|
||||||
|
}
|
||||||
|
return res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old logo files that don't match the new extension
|
||||||
|
const files = fs.readdirSync(brandingDir);
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.startsWith('logo.') && f !== req.file.filename) {
|
||||||
|
fs.unlinkSync(path.join(brandingDir, f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
logoUrl: '/api/branding/logo',
|
||||||
|
message: 'Logo uploaded',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/branding/logo - Remove logo (admin only)
|
||||||
|
router.delete('/logo', authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const logoFile = findLogoFile();
|
||||||
|
if (logoFile) {
|
||||||
|
fs.unlinkSync(logoFile);
|
||||||
|
}
|
||||||
|
res.json({ message: 'Logo removed' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete logo error:', err);
|
||||||
|
res.status(500).json({ error: 'Could not remove logo' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/branding/name - Update app name (admin only)
|
||||||
|
router.put('/name', authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { appName } = req.body;
|
||||||
|
if (!appName || !appName.trim()) {
|
||||||
|
return res.status(400).json({ error: 'App name is required' });
|
||||||
|
}
|
||||||
|
await setSetting('app_name', appName.trim());
|
||||||
|
res.json({ appName: appName.trim() });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update app name error:', err);
|
||||||
|
res.status(500).json({ error: 'Could not update app name' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -12,25 +12,66 @@ import {
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// GET /api/rooms - List user's rooms
|
// Build avatar URL for a user (uploaded image or generated initials)
|
||||||
|
function getUserAvatarURL(req, user) {
|
||||||
|
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
||||||
|
if (user.avatar_image) {
|
||||||
|
return `${baseUrl}/api/auth/avatar/${user.avatar_image}`;
|
||||||
|
}
|
||||||
|
const color = user.avatar_color ? `?color=${encodeURIComponent(user.avatar_color)}` : '';
|
||||||
|
return `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(user.name)}${color}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/rooms - List user's rooms (owned + shared)
|
||||||
router.get('/', authenticateToken, async (req, res) => {
|
router.get('/', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const rooms = await db.all(`
|
const ownRooms = await db.all(`
|
||||||
SELECT r.*, u.name as owner_name
|
SELECT r.*, u.name as owner_name, 0 as shared
|
||||||
FROM rooms r
|
FROM rooms r
|
||||||
JOIN users u ON r.user_id = u.id
|
JOIN users u ON r.user_id = u.id
|
||||||
WHERE r.user_id = ?
|
WHERE r.user_id = ?
|
||||||
ORDER BY r.created_at DESC
|
ORDER BY r.created_at DESC
|
||||||
`, [req.user.id]);
|
`, [req.user.id]);
|
||||||
|
|
||||||
res.json({ rooms });
|
const sharedRooms = await db.all(`
|
||||||
|
SELECT r.*, u.name as owner_name, 1 as shared
|
||||||
|
FROM rooms r
|
||||||
|
JOIN users u ON r.user_id = u.id
|
||||||
|
JOIN room_shares rs ON rs.room_id = r.id
|
||||||
|
WHERE rs.user_id = ?
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
`, [req.user.id]);
|
||||||
|
|
||||||
|
res.json({ rooms: [...ownRooms, ...sharedRooms] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('List rooms error:', err);
|
console.error('List rooms error:', err);
|
||||||
res.status(500).json({ error: 'Räume konnten nicht geladen werden' });
|
res.status(500).json({ error: 'Räume konnten nicht geladen werden' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/rooms/users/search - Search users for sharing (must be before /:uid routes)
|
||||||
|
router.get('/users/search', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { q } = req.query;
|
||||||
|
if (!q || q.length < 2) {
|
||||||
|
return res.json({ users: [] });
|
||||||
|
}
|
||||||
|
const db = getDb();
|
||||||
|
const searchTerm = `%${q}%`;
|
||||||
|
const users = await db.all(`
|
||||||
|
SELECT id, name, email, avatar_color, avatar_image
|
||||||
|
FROM users
|
||||||
|
WHERE (name LIKE ? OR email LIKE ?) AND id != ?
|
||||||
|
LIMIT 10
|
||||||
|
`, [searchTerm, searchTerm, req.user.id]);
|
||||||
|
res.json({ users });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Search users error:', err);
|
||||||
|
res.status(500).json({ error: 'Benutzersuche fehlgeschlagen' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/rooms/:uid - Get room details
|
// GET /api/rooms/:uid - Get room details
|
||||||
router.get('/:uid', authenticateToken, async (req, res) => {
|
router.get('/:uid', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -46,7 +87,24 @@ router.get('/:uid', authenticateToken, async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ room });
|
// Check access: owner, admin, or shared
|
||||||
|
if (room.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||||
|
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||||
|
if (!share) {
|
||||||
|
return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||||
|
}
|
||||||
|
room.shared = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get shared users
|
||||||
|
const sharedUsers = await db.all(`
|
||||||
|
SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image
|
||||||
|
FROM room_shares rs
|
||||||
|
JOIN users u ON rs.user_id = u.id
|
||||||
|
WHERE rs.room_id = ?
|
||||||
|
`, [room.id]);
|
||||||
|
|
||||||
|
res.json({ room, sharedUsers });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Get room error:', err);
|
console.error('Get room error:', err);
|
||||||
res.status(500).json({ error: 'Raum konnte nicht geladen werden' });
|
res.status(500).json({ error: 'Raum konnte nicht geladen werden' });
|
||||||
@@ -187,19 +245,105 @@ router.delete('/:uid', authenticateToken, async (req, res) => {
|
|||||||
res.status(500).json({ error: 'Raum konnte nicht gelöscht werden' });
|
res.status(500).json({ error: 'Raum konnte nicht gelöscht werden' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// GET /api/rooms/:uid/shares - Get shared users for a room
|
||||||
|
router.get('/:uid/shares', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
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: 'Raum nicht gefunden oder keine Berechtigung' });
|
||||||
|
}
|
||||||
|
const shares = await db.all(`
|
||||||
|
SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image
|
||||||
|
FROM room_shares rs
|
||||||
|
JOIN users u ON rs.user_id = u.id
|
||||||
|
WHERE rs.room_id = ?
|
||||||
|
`, [room.id]);
|
||||||
|
res.json({ shares });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Get shares error:', err);
|
||||||
|
res.status(500).json({ error: 'Fehler beim Laden der Freigaben' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/rooms/:uid/shares - Share room with a user
|
||||||
|
router.post('/:uid/shares', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { user_id } = req.body;
|
||||||
|
if (!user_id) {
|
||||||
|
return res.status(400).json({ error: 'Benutzer-ID erforderlich' });
|
||||||
|
}
|
||||||
|
const db = getDb();
|
||||||
|
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: 'Raum nicht gefunden oder keine Berechtigung' });
|
||||||
|
}
|
||||||
|
if (user_id === req.user.id) {
|
||||||
|
return res.status(400).json({ error: 'Du kannst den Raum nicht mit dir selbst teilen' });
|
||||||
|
}
|
||||||
|
// Check if already shared
|
||||||
|
const existing = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, user_id]);
|
||||||
|
if (existing) {
|
||||||
|
return res.status(400).json({ error: 'Raum ist bereits mit diesem Benutzer geteilt' });
|
||||||
|
}
|
||||||
|
await db.run('INSERT INTO room_shares (room_id, user_id) VALUES (?, ?)', [room.id, user_id]);
|
||||||
|
const shares = await db.all(`
|
||||||
|
SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image
|
||||||
|
FROM room_shares rs
|
||||||
|
JOIN users u ON rs.user_id = u.id
|
||||||
|
WHERE rs.room_id = ?
|
||||||
|
`, [room.id]);
|
||||||
|
res.json({ shares });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Share room error:', err);
|
||||||
|
res.status(500).json({ error: 'Fehler beim Teilen des Raums' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/rooms/:uid/shares/:userId - Remove share
|
||||||
|
router.delete('/:uid/shares/:userId', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
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: 'Raum nicht gefunden oder keine Berechtigung' });
|
||||||
|
}
|
||||||
|
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, parseInt(req.params.userId)]);
|
||||||
|
const shares = await db.all(`
|
||||||
|
SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image
|
||||||
|
FROM room_shares rs
|
||||||
|
JOIN users u ON rs.user_id = u.id
|
||||||
|
WHERE rs.room_id = ?
|
||||||
|
`, [room.id]);
|
||||||
|
res.json({ shares });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Remove share error:', err);
|
||||||
|
res.status(500).json({ error: 'Fehler beim Entfernen der Freigabe' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/rooms/:uid/start - Start meeting
|
// POST /api/rooms/:uid/start - Start meeting
|
||||||
router.post('/:uid/start', authenticateToken, async (req, res) => {
|
router.post('/:uid/start', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
|
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||||
|
|
||||||
if (!room) {
|
if (!room) {
|
||||||
return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' });
|
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await createMeeting(room);
|
// Check access: owner or shared user
|
||||||
const joinUrl = await joinMeeting(room.uid, req.user.name, true);
|
const isOwner = room.user_id === req.user.id;
|
||||||
|
if (!isOwner) {
|
||||||
|
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||||
|
if (!share) {
|
||||||
|
return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await createMeeting(room, `${req.protocol}://${req.get('host')}`);
|
||||||
|
const avatarURL = getUserAvatarURL(req, req.user);
|
||||||
|
const joinUrl = await joinMeeting(room.uid, req.user.name, true, avatarURL);
|
||||||
res.json({ joinUrl });
|
res.json({ joinUrl });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Start meeting error:', err);
|
console.error('Start meeting error:', err);
|
||||||
@@ -228,8 +372,12 @@ router.post('/:uid/join', authenticateToken, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Meeting läuft nicht. Bitte warten Sie, bis der Moderator das Meeting gestartet hat.' });
|
return res.status(400).json({ error: 'Meeting läuft nicht. Bitte warten Sie, bis der Moderator das Meeting gestartet hat.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isModerator = room.user_id === req.user.id || room.all_join_moderator;
|
// Owner and shared users join as moderator
|
||||||
const joinUrl = await joinMeeting(room.uid, req.user.name, isModerator);
|
const isOwner = room.user_id === req.user.id;
|
||||||
|
const isShared = !isOwner && await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||||
|
const isModerator = isOwner || !!isShared || room.all_join_moderator;
|
||||||
|
const avatarURL = getUserAvatarURL(req, req.user);
|
||||||
|
const joinUrl = await joinMeeting(room.uid, req.user.name, isModerator, avatarURL);
|
||||||
res.json({ joinUrl });
|
res.json({ joinUrl });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Join meeting error:', err);
|
console.error('Join meeting error:', err);
|
||||||
@@ -241,10 +389,19 @@ router.post('/:uid/join', authenticateToken, async (req, res) => {
|
|||||||
router.post('/:uid/end', authenticateToken, async (req, res) => {
|
router.post('/:uid/end', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
|
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||||
|
|
||||||
if (!room) {
|
if (!room) {
|
||||||
return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' });
|
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access: owner or shared user
|
||||||
|
const isOwner = room.user_id === req.user.id;
|
||||||
|
if (!isOwner) {
|
||||||
|
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||||
|
if (!share) {
|
||||||
|
return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await endMeeting(room.uid);
|
await endMeeting(room.uid);
|
||||||
@@ -260,7 +417,7 @@ router.get('/:uid/public', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const room = await db.get(`
|
const room = await db.get(`
|
||||||
SELECT r.uid, r.name, r.guest_access, r.welcome_message, r.access_code,
|
SELECT r.uid, r.name, r.welcome_message, r.access_code,
|
||||||
u.name as owner_name
|
u.name as owner_name
|
||||||
FROM rooms r
|
FROM rooms r
|
||||||
JOIN users u ON r.user_id = u.id
|
JOIN users u ON r.user_id = u.id
|
||||||
@@ -271,10 +428,6 @@ router.get('/:uid/public', async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!room.guest_access) {
|
|
||||||
return res.status(403).json({ error: 'Gastzugang ist für diesen Raum nicht aktiviert' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const running = await isMeetingRunning(room.uid);
|
const running = await isMeetingRunning(room.uid);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -309,10 +462,6 @@ router.post('/:uid/guest-join', async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!room.guest_access) {
|
|
||||||
return res.status(403).json({ error: 'Gastzugang ist für diesen Raum nicht aktiviert' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check access code if set
|
// Check access code if set
|
||||||
if (room.access_code && access_code !== room.access_code) {
|
if (room.access_code && access_code !== room.access_code) {
|
||||||
return res.status(403).json({ error: 'Falscher Zugangscode' });
|
return res.status(403).json({ error: 'Falscher Zugangscode' });
|
||||||
@@ -326,7 +475,7 @@ router.post('/:uid/guest-join', async (req, res) => {
|
|||||||
|
|
||||||
// If meeting not running but anyone_can_start, create it
|
// If meeting not running but anyone_can_start, create it
|
||||||
if (!running && room.anyone_can_start) {
|
if (!running && room.anyone_can_start) {
|
||||||
await createMeeting(room);
|
await createMeeting(room, `${req.protocol}://${req.get('host')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check moderator code
|
// Check moderator code
|
||||||
@@ -335,7 +484,9 @@ router.post('/:uid/guest-join', async (req, res) => {
|
|||||||
isModerator = true;
|
isModerator = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator);
|
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
||||||
|
const guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
|
||||||
|
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL);
|
||||||
res.json({ joinUrl });
|
res.json({ joinUrl });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Guest join error:', err);
|
console.error('Guest join error:', err);
|
||||||
|
|||||||
19
src/App.jsx
19
src/App.jsx
@@ -1,10 +1,14 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { useAuth } from './contexts/AuthContext';
|
import { useAuth } from './contexts/AuthContext';
|
||||||
|
import { useLanguage } from './contexts/LanguageContext';
|
||||||
|
import { useBranding } from './contexts/BrandingContext';
|
||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
|
import VerifyEmail from './pages/VerifyEmail';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import RoomDetail from './pages/RoomDetail';
|
import RoomDetail from './pages/RoomDetail';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
@@ -13,6 +17,20 @@ import GuestJoin from './pages/GuestJoin';
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
|
const { setLanguage } = useLanguage();
|
||||||
|
const { appName } = useBranding();
|
||||||
|
|
||||||
|
// Sync language from server when user loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.language) {
|
||||||
|
setLanguage(user.language);
|
||||||
|
}
|
||||||
|
}, [user?.language, setLanguage]);
|
||||||
|
|
||||||
|
// Update document title with branding
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = `${appName} - BigBlueButton Frontend`;
|
||||||
|
}, [appName]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -28,6 +46,7 @@ export default function App() {
|
|||||||
<Route path="/" element={user ? <Navigate to="/dashboard" /> : <Home />} />
|
<Route path="/" element={user ? <Navigate to="/dashboard" /> : <Home />} />
|
||||||
<Route path="/login" element={user ? <Navigate to="/dashboard" /> : <Login />} />
|
<Route path="/login" element={user ? <Navigate to="/dashboard" /> : <Login />} />
|
||||||
<Route path="/register" element={user ? <Navigate to="/dashboard" /> : <Register />} />
|
<Route path="/register" element={user ? <Navigate to="/dashboard" /> : <Register />} />
|
||||||
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
<Route path="/join/:uid" element={<GuestJoin />} />
|
<Route path="/join/:uid" element={<GuestJoin />} />
|
||||||
|
|
||||||
{/* Protected routes */}
|
{/* Protected routes */}
|
||||||
|
|||||||
35
src/components/BrandLogo.jsx
Normal file
35
src/components/BrandLogo.jsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Video } from 'lucide-react';
|
||||||
|
import { useBranding } from '../contexts/BrandingContext';
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: { box: 'w-8 h-8', rounded: 'rounded-lg', icon: 16, text: 'text-lg' },
|
||||||
|
md: { box: 'w-9 h-9', rounded: 'rounded-lg', icon: 20, text: 'text-xl' },
|
||||||
|
lg: { box: 'w-10 h-10', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BrandLogo({ size = 'md', className = '' }) {
|
||||||
|
const { appName, hasLogo, logoUrl } = useBranding();
|
||||||
|
const s = sizes[size] || sizes.md;
|
||||||
|
|
||||||
|
if (hasLogo && logoUrl) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2.5 ${className}`}>
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt={appName}
|
||||||
|
className={`${s.box} ${s.rounded} object-contain`}
|
||||||
|
/>
|
||||||
|
<span className={`${s.text} font-bold gradient-text`}>{appName}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2.5 ${className}`}>
|
||||||
|
<div className={`${s.box} gradient-bg ${s.rounded} flex items-center justify-center`}>
|
||||||
|
<Video size={s.icon} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<span className={`${s.text} font-bold gradient-text`}>{appName}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Menu, Search, LogOut, User } from 'lucide-react';
|
import { Menu, LogOut, User } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -47,16 +47,6 @@ export default function Navbar({ onMenuClick }) {
|
|||||||
>
|
>
|
||||||
<Menu size={20} />
|
<Menu size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<div className="hidden md:flex items-center gap-2 bg-th-bg-s border border-th-border rounded-lg px-3 py-2 w-64 lg:w-80">
|
|
||||||
<Search size={16} className="text-th-text-s flex-shrink-0" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={t('common.search')}
|
|
||||||
className="bg-transparent border-none outline-none text-sm text-th-text placeholder-th-text-s w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right section */}
|
{/* Right section */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Users, Play, Trash2, Radio, Loader2 } from 'lucide-react';
|
import { Users, Play, Trash2, Radio, Loader2, Share2 } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
@@ -39,9 +39,15 @@ export default function RoomCard({ room, onDelete }) {
|
|||||||
{t('common.live')}
|
{t('common.live')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{room.shared ? (
|
||||||
|
<span className="flex items-center gap-1 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
|
||||||
|
<Share2 size={10} />
|
||||||
|
{t('room.shared')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-th-text-s mt-0.5">
|
<p className="text-sm text-th-text-s mt-0.5">
|
||||||
{room.uid.substring(0, 8)}...
|
{room.shared ? room.owner_name : `${room.uid.substring(0, 8)}...`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +99,7 @@ export default function RoomCard({ room, onDelete }) {
|
|||||||
{starting ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
|
{starting ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
|
||||||
{status.running ? t('room.join') : t('room.startMeeting')}
|
{status.running ? t('room.join') : t('room.startMeeting')}
|
||||||
</button>
|
</button>
|
||||||
{onDelete && (
|
{onDelete && !room.shared && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete(room); }}
|
onClick={(e) => { e.stopPropagation(); onDelete(room); }}
|
||||||
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"
|
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { LayoutDashboard, Settings, Shield, Video, X, Palette } from 'lucide-react';
|
import { LayoutDashboard, Settings, Shield, X, Palette } from 'lucide-react';
|
||||||
|
import BrandLogo from './BrandLogo';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import ThemeSelector from './ThemeSelector';
|
import ThemeSelector from './ThemeSelector';
|
||||||
@@ -36,14 +37,7 @@ export default function Sidebar({ open, onClose }) {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center justify-between h-16 px-4 border-b border-th-border">
|
<div className="flex items-center justify-between h-16 px-4 border-b border-th-border">
|
||||||
<div className="flex items-center gap-2.5">
|
<BrandLogo size="sm" />
|
||||||
<div className="w-8 h-8 gradient-bg rounded-lg flex items-center justify-center">
|
|
||||||
<Video size={18} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-lg font-bold gradient-text">Redlight</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export function AuthProvider({ children }) {
|
|||||||
|
|
||||||
const register = useCallback(async (name, email, password) => {
|
const register = useCallback(async (name, email, password) => {
|
||||||
const res = await api.post('/auth/register', { name, email, password });
|
const res = await api.post('/auth/register', { name, email, password });
|
||||||
|
if (res.data.needsVerification) {
|
||||||
|
return { needsVerification: true };
|
||||||
|
}
|
||||||
localStorage.setItem('token', res.data.token);
|
localStorage.setItem('token', res.data.token);
|
||||||
setUser(res.data.user);
|
setUser(res.data.user);
|
||||||
return res.data.user;
|
return res.data.user;
|
||||||
|
|||||||
37
src/contexts/BrandingContext.jsx
Normal file
37
src/contexts/BrandingContext.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const BrandingContext = createContext();
|
||||||
|
|
||||||
|
export function BrandingProvider({ children }) {
|
||||||
|
const [branding, setBranding] = useState({
|
||||||
|
appName: 'Redlight',
|
||||||
|
hasLogo: false,
|
||||||
|
logoUrl: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchBranding = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/branding');
|
||||||
|
setBranding(res.data);
|
||||||
|
} catch {
|
||||||
|
// keep defaults
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBranding();
|
||||||
|
}, [fetchBranding]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrandingContext.Provider value={{ ...branding, refreshBranding: fetchBranding }}>
|
||||||
|
{children}
|
||||||
|
</BrandingContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBranding() {
|
||||||
|
const ctx = useContext(BrandingContext);
|
||||||
|
if (!ctx) throw new Error('useBranding must be used within BrandingProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -61,7 +61,17 @@
|
|||||||
"registerSuccess": "Registrierung erfolgreich!",
|
"registerSuccess": "Registrierung erfolgreich!",
|
||||||
"loginFailed": "Anmeldung fehlgeschlagen",
|
"loginFailed": "Anmeldung fehlgeschlagen",
|
||||||
"registerFailed": "Registrierung fehlgeschlagen",
|
"registerFailed": "Registrierung fehlgeschlagen",
|
||||||
"allFieldsRequired": "Alle Felder sind erforderlich"
|
"allFieldsRequired": "Alle Felder sind erforderlich",
|
||||||
|
"verificationSent": "Verifizierungs-E-Mail wurde gesendet!",
|
||||||
|
"verificationSentDesc": "Wir haben dir eine E-Mail mit einem Bestätigungslink geschickt. Bitte klicke auf den Link, um dein Konto zu aktivieren.",
|
||||||
|
"checkYourEmail": "Prüfe dein Postfach",
|
||||||
|
"verifying": "E-Mail wird verifiziert...",
|
||||||
|
"verifySuccess": "Deine E-Mail-Adresse wurde erfolgreich bestätigt. Du kannst dich jetzt anmelden.",
|
||||||
|
"verifySuccessTitle": "E-Mail bestätigt!",
|
||||||
|
"verifyFailed": "Verifizierung fehlgeschlagen",
|
||||||
|
"verifyFailedTitle": "Verifizierung fehlgeschlagen",
|
||||||
|
"verifyTokenMissing": "Kein Verifizierungstoken vorhanden.",
|
||||||
|
"emailNotVerified": "E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach."
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"poweredBy": "Powered by BigBlueButton",
|
"poweredBy": "Powered by BigBlueButton",
|
||||||
@@ -111,7 +121,8 @@
|
|||||||
"roomDeleted": "Raum gelöscht",
|
"roomDeleted": "Raum gelöscht",
|
||||||
"roomDeleteFailed": "Raum konnte nicht gelöscht werden",
|
"roomDeleteFailed": "Raum konnte nicht gelöscht werden",
|
||||||
"roomDeleteConfirm": "Raum \"{name}\" wirklich löschen?",
|
"roomDeleteConfirm": "Raum \"{name}\" wirklich löschen?",
|
||||||
"loadFailed": "Räume konnten nicht geladen werden"
|
"loadFailed": "Räume konnten nicht geladen werden",
|
||||||
|
"sharedWithMe": "Mit mir geteilt"
|
||||||
},
|
},
|
||||||
"room": {
|
"room": {
|
||||||
"backToDashboard": "Zurück zum Dashboard",
|
"backToDashboard": "Zurück zum Dashboard",
|
||||||
@@ -157,7 +168,44 @@
|
|||||||
"joinFailed": "Beitritt fehlgeschlagen",
|
"joinFailed": "Beitritt fehlgeschlagen",
|
||||||
"endConfirm": "Meeting wirklich beenden?",
|
"endConfirm": "Meeting wirklich beenden?",
|
||||||
"enterAccessCode": "Zugangscode eingeben:",
|
"enterAccessCode": "Zugangscode eingeben:",
|
||||||
"notFound": "Raum nicht gefunden"
|
"notFound": "Raum nicht gefunden",
|
||||||
|
"guestAccessTitle": "Gastzugang",
|
||||||
|
"guestAccess": "Gastzugang aktivieren",
|
||||||
|
"guestAccessHint": "Ermöglicht nicht angemeldeten Benutzern, dem Meeting beizutreten.",
|
||||||
|
"moderatorCode": "Moderator-Code",
|
||||||
|
"moderatorCodeHint": "Optionaler Code für Moderator-Rechte",
|
||||||
|
"moderatorCodeDesc": "Gäste, die diesen Code eingeben, erhalten Moderator-Rechte.",
|
||||||
|
"guestLink": "Gast-Einladungslink",
|
||||||
|
"guestLinkCopied": "Gast-Link kopiert!",
|
||||||
|
"guestJoinTitle": "Meeting beitreten",
|
||||||
|
"guestCreatedBy": "Erstellt von",
|
||||||
|
"guestMeetingRunning": "Meeting läuft",
|
||||||
|
"guestMeetingNotStarted": "Noch nicht gestartet",
|
||||||
|
"guestYourName": "Ihr Name",
|
||||||
|
"guestNamePlaceholder": "Max Mustermann",
|
||||||
|
"guestAccessCode": "Zugangscode",
|
||||||
|
"guestAccessCodePlaceholder": "Code eingeben",
|
||||||
|
"guestModeratorCode": "Moderator-Code",
|
||||||
|
"guestModeratorOptional": "(optional)",
|
||||||
|
"guestModeratorPlaceholder": "Nur wenn Sie Moderator sind",
|
||||||
|
"guestJoinButton": "Meeting beitreten",
|
||||||
|
"guestWaitingMessage": "Das Meeting wurde noch nicht gestartet. Bitte warten Sie, bis der Moderator es startet.",
|
||||||
|
"guestAccessDenied": "Zugang nicht möglich",
|
||||||
|
"guestNameRequired": "Name ist erforderlich",
|
||||||
|
"guestJoinFailed": "Beitritt fehlgeschlagen",
|
||||||
|
"guestAccessNotEnabled": "Der Gastzugang ist für diesen Raum nicht aktiviert.",
|
||||||
|
"guestWrongAccessCode": "Falscher Zugangscode",
|
||||||
|
"guestHasAccount": "Haben Sie ein Konto?",
|
||||||
|
"guestSignIn": "Anmelden",
|
||||||
|
"guestRoomNotFound": "Raum nicht gefunden",
|
||||||
|
"shared": "Geteilt",
|
||||||
|
"shareTitle": "Raum teilen",
|
||||||
|
"shareDescription": "Teilen Sie diesen Raum mit anderen Benutzern, damit diese ihn in ihrem Dashboard sehen und beitreten k\u00f6nnen.",
|
||||||
|
"shareSearchPlaceholder": "Benutzer suchen (Name oder E-Mail)...",
|
||||||
|
"shareAdded": "Benutzer hinzugef\u00fcgt",
|
||||||
|
"shareRemoved": "Freigabe entfernt",
|
||||||
|
"shareFailed": "Freigabe fehlgeschlagen",
|
||||||
|
"shareRemove": "Freigabe entfernen"
|
||||||
},
|
},
|
||||||
"recordings": {
|
"recordings": {
|
||||||
"title": "Aufnahmen",
|
"title": "Aufnahmen",
|
||||||
@@ -239,6 +287,19 @@
|
|||||||
"userDeleteFailed": "Fehler beim Löschen",
|
"userDeleteFailed": "Fehler beim Löschen",
|
||||||
"passwordReset": "Passwort zurückgesetzt",
|
"passwordReset": "Passwort zurückgesetzt",
|
||||||
"passwordResetFailed": "Fehler beim Zurücksetzen",
|
"passwordResetFailed": "Fehler beim Zurücksetzen",
|
||||||
"deleteUserConfirm": "Benutzer \"{name}\" wirklich löschen? Alle Räume werden ebenfalls gelöscht."
|
"deleteUserConfirm": "Benutzer \"{name}\" wirklich löschen? Alle Räume werden ebenfalls gelöscht.",
|
||||||
|
"brandingTitle": "Branding",
|
||||||
|
"brandingDescription": "Logo und App-Name anpassen, die in der Anwendung angezeigt werden.",
|
||||||
|
"logoLabel": "Logo",
|
||||||
|
"logoUpload": "Logo hochladen",
|
||||||
|
"logoChange": "Logo ändern",
|
||||||
|
"logoHint": "PNG, JPG, SVG oder WebP. Max. 5 MB.",
|
||||||
|
"logoUploaded": "Logo hochgeladen",
|
||||||
|
"logoUploadFailed": "Logo konnte nicht hochgeladen werden",
|
||||||
|
"logoRemoved": "Logo entfernt",
|
||||||
|
"logoRemoveFailed": "Logo konnte nicht entfernt werden",
|
||||||
|
"appNameLabel": "App-Name",
|
||||||
|
"appNameUpdated": "App-Name aktualisiert",
|
||||||
|
"appNameUpdateFailed": "App-Name konnte nicht aktualisiert werden"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,17 @@
|
|||||||
"registerSuccess": "Registration successful!",
|
"registerSuccess": "Registration successful!",
|
||||||
"loginFailed": "Login failed",
|
"loginFailed": "Login failed",
|
||||||
"registerFailed": "Registration failed",
|
"registerFailed": "Registration failed",
|
||||||
"allFieldsRequired": "All fields are required"
|
"allFieldsRequired": "All fields are required",
|
||||||
|
"verificationSent": "Verification email sent!",
|
||||||
|
"verificationSentDesc": "We've sent you an email with a verification link. Please click the link to activate your account.",
|
||||||
|
"checkYourEmail": "Check your inbox",
|
||||||
|
"verifying": "Verifying your email...",
|
||||||
|
"verifySuccess": "Your email has been verified successfully. You can now sign in.",
|
||||||
|
"verifySuccessTitle": "Email verified!",
|
||||||
|
"verifyFailed": "Verification failed",
|
||||||
|
"verifyFailedTitle": "Verification failed",
|
||||||
|
"verifyTokenMissing": "No verification token provided.",
|
||||||
|
"emailNotVerified": "Email not yet verified. Please check your inbox."
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"poweredBy": "Powered by BigBlueButton",
|
"poweredBy": "Powered by BigBlueButton",
|
||||||
@@ -111,7 +121,8 @@
|
|||||||
"roomDeleted": "Room deleted",
|
"roomDeleted": "Room deleted",
|
||||||
"roomDeleteFailed": "Room could not be deleted",
|
"roomDeleteFailed": "Room could not be deleted",
|
||||||
"roomDeleteConfirm": "Really delete room \"{name}\"?",
|
"roomDeleteConfirm": "Really delete room \"{name}\"?",
|
||||||
"loadFailed": "Rooms could not be loaded"
|
"loadFailed": "Rooms could not be loaded",
|
||||||
|
"sharedWithMe": "Shared with me"
|
||||||
},
|
},
|
||||||
"room": {
|
"room": {
|
||||||
"backToDashboard": "Back to Dashboard",
|
"backToDashboard": "Back to Dashboard",
|
||||||
@@ -157,7 +168,44 @@
|
|||||||
"joinFailed": "Join failed",
|
"joinFailed": "Join failed",
|
||||||
"endConfirm": "Really end meeting?",
|
"endConfirm": "Really end meeting?",
|
||||||
"enterAccessCode": "Enter access code:",
|
"enterAccessCode": "Enter access code:",
|
||||||
"notFound": "Room not found"
|
"notFound": "Room not found",
|
||||||
|
"guestAccessTitle": "Guest Access",
|
||||||
|
"guestAccess": "Enable guest access",
|
||||||
|
"guestAccessHint": "Allows unauthenticated users to join the meeting.",
|
||||||
|
"moderatorCode": "Moderator Code",
|
||||||
|
"moderatorCodeHint": "Optional code for moderator rights",
|
||||||
|
"moderatorCodeDesc": "Guests who enter this code will receive moderator rights.",
|
||||||
|
"guestLink": "Guest Invite Link",
|
||||||
|
"guestLinkCopied": "Guest link copied!",
|
||||||
|
"guestJoinTitle": "Join Meeting",
|
||||||
|
"guestCreatedBy": "Created by",
|
||||||
|
"guestMeetingRunning": "Meeting in progress",
|
||||||
|
"guestMeetingNotStarted": "Not started yet",
|
||||||
|
"guestYourName": "Your Name",
|
||||||
|
"guestNamePlaceholder": "John Doe",
|
||||||
|
"guestAccessCode": "Access Code",
|
||||||
|
"guestAccessCodePlaceholder": "Enter code",
|
||||||
|
"guestModeratorCode": "Moderator Code",
|
||||||
|
"guestModeratorOptional": "(optional)",
|
||||||
|
"guestModeratorPlaceholder": "Only if you are a moderator",
|
||||||
|
"guestJoinButton": "Join meeting",
|
||||||
|
"guestWaitingMessage": "The meeting has not started yet. Please wait for the moderator to start it.",
|
||||||
|
"guestAccessDenied": "Access denied",
|
||||||
|
"guestNameRequired": "Name is required",
|
||||||
|
"guestJoinFailed": "Join failed",
|
||||||
|
"guestAccessNotEnabled": "Guest access is not enabled for this room.",
|
||||||
|
"guestWrongAccessCode": "Wrong access code",
|
||||||
|
"guestHasAccount": "Have an account?",
|
||||||
|
"guestSignIn": "Sign in",
|
||||||
|
"guestRoomNotFound": "Room not found",
|
||||||
|
"shared": "Shared",
|
||||||
|
"shareTitle": "Share room",
|
||||||
|
"shareDescription": "Share this room with other users so they can see it in their dashboard and join meetings.",
|
||||||
|
"shareSearchPlaceholder": "Search users (name or email)...",
|
||||||
|
"shareAdded": "User added",
|
||||||
|
"shareRemoved": "Share removed",
|
||||||
|
"shareFailed": "Share failed",
|
||||||
|
"shareRemove": "Remove share"
|
||||||
},
|
},
|
||||||
"recordings": {
|
"recordings": {
|
||||||
"title": "Recordings",
|
"title": "Recordings",
|
||||||
@@ -239,6 +287,19 @@
|
|||||||
"userDeleteFailed": "Error deleting user",
|
"userDeleteFailed": "Error deleting user",
|
||||||
"passwordReset": "Password reset",
|
"passwordReset": "Password reset",
|
||||||
"passwordResetFailed": "Error resetting password",
|
"passwordResetFailed": "Error resetting password",
|
||||||
"deleteUserConfirm": "Really delete user \"{name}\"? All rooms will also be deleted."
|
"deleteUserConfirm": "Really delete user \"{name}\"? All rooms will also be deleted.",
|
||||||
|
"brandingTitle": "Branding",
|
||||||
|
"brandingDescription": "Customize the logo and app name shown across the application.",
|
||||||
|
"logoLabel": "Logo",
|
||||||
|
"logoUpload": "Upload logo",
|
||||||
|
"logoChange": "Change logo",
|
||||||
|
"logoHint": "PNG, JPG, SVG or WebP. Max 5 MB.",
|
||||||
|
"logoUploaded": "Logo uploaded",
|
||||||
|
"logoUploadFailed": "Logo upload failed",
|
||||||
|
"logoRemoved": "Logo removed",
|
||||||
|
"logoRemoveFailed": "Could not remove logo",
|
||||||
|
"appNameLabel": "App name",
|
||||||
|
"appNameUpdated": "App name updated",
|
||||||
|
"appNameUpdateFailed": "Could not update app name"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/main.jsx
33
src/main.jsx
@@ -6,6 +6,7 @@ import App from './App';
|
|||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import { LanguageProvider } from './contexts/LanguageContext';
|
import { LanguageProvider } from './contexts/LanguageContext';
|
||||||
|
import { BrandingProvider } from './contexts/BrandingContext';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
@@ -13,21 +14,23 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<BrandingProvider>
|
||||||
<App />
|
<AuthProvider>
|
||||||
<Toaster
|
<App />
|
||||||
position="top-right"
|
<Toaster
|
||||||
toastOptions={{
|
position="top-right"
|
||||||
duration: 4000,
|
toastOptions={{
|
||||||
style: {
|
duration: 4000,
|
||||||
background: 'var(--card-bg)',
|
style: {
|
||||||
color: 'var(--text-primary)',
|
background: 'var(--card-bg)',
|
||||||
border: '1px solid var(--border)',
|
color: 'var(--text-primary)',
|
||||||
},
|
border: '1px solid var(--border)',
|
||||||
}}
|
},
|
||||||
/>
|
}}
|
||||||
</AuthProvider>
|
/>
|
||||||
</ThemeProvider>
|
</AuthProvider>
|
||||||
|
</BrandingProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
||||||
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
||||||
|
Upload, X as XIcon, Image, Type,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { useBranding } from '../contexts/BrandingContext';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { t, language } = useLanguage();
|
const { t, language } = useLanguage();
|
||||||
|
const { appName, hasLogo, logoUrl, refreshBranding } = useBranding();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -23,6 +26,12 @@ export default function Admin() {
|
|||||||
const [creatingUser, setCreatingUser] = useState(false);
|
const [creatingUser, setCreatingUser] = useState(false);
|
||||||
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'user' });
|
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'user' });
|
||||||
|
|
||||||
|
// Branding state
|
||||||
|
const [editAppName, setEditAppName] = useState('');
|
||||||
|
const [savingName, setSavingName] = useState(false);
|
||||||
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||||
|
const logoInputRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.role !== 'admin') {
|
if (user?.role !== 'admin') {
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
@@ -31,6 +40,10 @@ export default function Admin() {
|
|||||||
fetchUsers();
|
fetchUsers();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditAppName(appName || 'Redlight');
|
||||||
|
}, [appName]);
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/admin/users');
|
const res = await api.get('/admin/users');
|
||||||
@@ -77,6 +90,51 @@ export default function Admin() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Branding handlers ──────────────────────────────────────────────────
|
||||||
|
const handleLogoUpload = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setUploadingLogo(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('logo', file);
|
||||||
|
await api.post('/branding/logo', formData, {
|
||||||
|
headers: { 'Content-Type': undefined },
|
||||||
|
});
|
||||||
|
toast.success(t('admin.logoUploaded'));
|
||||||
|
refreshBranding();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('admin.logoUploadFailed'));
|
||||||
|
} finally {
|
||||||
|
setUploadingLogo(false);
|
||||||
|
if (logoInputRef.current) logoInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoRemove = async () => {
|
||||||
|
try {
|
||||||
|
await api.delete('/branding/logo');
|
||||||
|
toast.success(t('admin.logoRemoved'));
|
||||||
|
refreshBranding();
|
||||||
|
} catch {
|
||||||
|
toast.error(t('admin.logoRemoveFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAppNameSave = async () => {
|
||||||
|
if (!editAppName.trim()) return;
|
||||||
|
setSavingName(true);
|
||||||
|
try {
|
||||||
|
await api.put('/branding/name', { appName: editAppName.trim() });
|
||||||
|
toast.success(t('admin.appNameUpdated'));
|
||||||
|
refreshBranding();
|
||||||
|
} catch {
|
||||||
|
toast.error(t('admin.appNameUpdateFailed'));
|
||||||
|
} finally {
|
||||||
|
setSavingName(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateUser = async (e) => {
|
const handleCreateUser = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setCreatingUser(true);
|
setCreatingUser(true);
|
||||||
@@ -126,6 +184,90 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Branding */}
|
||||||
|
<div className="card p-6 mb-8">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Image size={20} className="text-th-accent" />
|
||||||
|
<h2 className="text-lg font-semibold text-th-text">{t('admin.brandingTitle')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-th-text-s mb-5">{t('admin.brandingDescription')}</p>
|
||||||
|
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
|
{/* Logo upload */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-th-text mb-2">{t('admin.logoLabel')}</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{hasLogo && logoUrl ? (
|
||||||
|
<div className="relative group">
|
||||||
|
<img
|
||||||
|
src={`${logoUrl}?t=${Date.now()}`}
|
||||||
|
alt="Logo"
|
||||||
|
className="w-14 h-14 rounded-xl object-contain border border-th-border bg-th-bg p-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleLogoRemove}
|
||||||
|
className="absolute -top-2 -right-2 w-5 h-5 bg-th-error text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<XIcon size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-14 h-14 rounded-xl border-2 border-dashed border-th-border flex items-center justify-center text-th-text-s">
|
||||||
|
<Image size={24} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={logoInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleLogoUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => logoInputRef.current?.click()}
|
||||||
|
disabled={uploadingLogo}
|
||||||
|
className="btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
{uploadingLogo ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload size={14} />
|
||||||
|
)}
|
||||||
|
{hasLogo ? t('admin.logoChange') : t('admin.logoUpload')}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-th-text-s mt-1">{t('admin.logoHint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* App name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-th-text mb-2">{t('admin.appNameLabel')}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Type size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editAppName}
|
||||||
|
onChange={e => setEditAppName(e.target.value)}
|
||||||
|
className="input-field pl-9 text-sm"
|
||||||
|
placeholder="Redlight"
|
||||||
|
maxLength={30}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleAppNameSave}
|
||||||
|
disabled={savingName || editAppName.trim() === appName}
|
||||||
|
className="btn-primary text-sm px-4"
|
||||||
|
>
|
||||||
|
{savingName ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="card p-4 mb-6">
|
<div className="card p-4 mb-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|||||||
@@ -131,15 +131,36 @@ export default function Dashboard() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={
|
<>
|
||||||
viewMode === 'grid'
|
{/* Own rooms */}
|
||||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'
|
{rooms.filter(r => !r.shared).length > 0 && (
|
||||||
: 'space-y-3'
|
<div className={
|
||||||
}>
|
viewMode === 'grid'
|
||||||
{rooms.map(room => (
|
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||||
<RoomCard key={room.id} room={room} onDelete={handleDelete} />
|
: 'space-y-3'
|
||||||
))}
|
}>
|
||||||
</div>
|
{rooms.filter(r => !r.shared).map(room => (
|
||||||
|
<RoomCard key={room.id} room={room} onDelete={handleDelete} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shared rooms */}
|
||||||
|
{rooms.filter(r => r.shared).length > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-lg font-semibold text-th-text mb-4">{t('dashboard.sharedWithMe')}</h2>
|
||||||
|
<div className={
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||||
|
: 'space-y-3'
|
||||||
|
}>
|
||||||
|
{rooms.filter(r => r.shared).map(room => (
|
||||||
|
<RoomCard key={`shared-${room.id}`} room={room} onDelete={handleDelete} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create Room Modal */}
|
{/* Create Room Modal */}
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio } from 'lucide-react';
|
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio } from 'lucide-react';
|
||||||
|
import BrandLogo from '../components/BrandLogo';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
export default function GuestJoin() {
|
export default function GuestJoin() {
|
||||||
const { uid } = useParams();
|
const { uid } = useParams();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isLoggedIn = !!user;
|
||||||
const [roomInfo, setRoomInfo] = useState(null);
|
const [roomInfo, setRoomInfo] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [joining, setJoining] = useState(false);
|
const [joining, setJoining] = useState(false);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState(user?.name || '');
|
||||||
const [accessCode, setAccessCode] = useState('');
|
const [accessCode, setAccessCode] = useState('');
|
||||||
const [moderatorCode, setModeratorCode] = useState('');
|
const [moderatorCode, setModeratorCode] = useState('');
|
||||||
const [status, setStatus] = useState({ running: false });
|
const [status, setStatus] = useState({ running: false });
|
||||||
@@ -22,7 +28,14 @@ export default function GuestJoin() {
|
|||||||
setRoomInfo(res.data.room);
|
setRoomInfo(res.data.room);
|
||||||
setStatus({ running: res.data.running });
|
setStatus({ running: res.data.running });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Raum nicht gefunden');
|
const status = err.response?.status;
|
||||||
|
if (status === 403) {
|
||||||
|
setError(t('room.guestAccessNotEnabled'));
|
||||||
|
} else if (status === 404) {
|
||||||
|
setError(t('room.guestRoomNotFound'));
|
||||||
|
} else {
|
||||||
|
setError(t('room.guestRoomNotFound'));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -44,7 +57,7 @@ export default function GuestJoin() {
|
|||||||
const handleJoin = async (e) => {
|
const handleJoin = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
toast.error('Name ist erforderlich');
|
toast.error(t('room.guestNameRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +72,14 @@ export default function GuestJoin() {
|
|||||||
window.location.href = res.data.joinUrl;
|
window.location.href = res.data.joinUrl;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.response?.data?.error || 'Beitritt fehlgeschlagen');
|
const status = err.response?.status;
|
||||||
|
if (status === 403) {
|
||||||
|
toast.error(t('room.guestWrongAccessCode'));
|
||||||
|
} else if (status === 400) {
|
||||||
|
toast.error(t('room.guestWaitingMessage'));
|
||||||
|
} else {
|
||||||
|
toast.error(t('room.guestJoinFailed'));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setJoining(false);
|
setJoining(false);
|
||||||
}
|
}
|
||||||
@@ -87,10 +107,10 @@ export default function GuestJoin() {
|
|||||||
<div className="w-16 h-16 bg-th-error/15 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 bg-th-error/15 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<Video size={28} className="text-th-error" />
|
<Video size={28} className="text-th-error" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-bold text-th-text mb-2">Zugang nicht möglich</h2>
|
<h2 className="text-xl font-bold text-th-text mb-2">{t('room.guestAccessDenied')}</h2>
|
||||||
<p className="text-sm text-th-text-s mb-6">{error}</p>
|
<p className="text-sm text-th-text-s mb-6">{error}</p>
|
||||||
<Link to="/login" className="btn-primary inline-flex">
|
<Link to="/login" className="btn-primary inline-flex">
|
||||||
Zum Login
|
{t('auth.login')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,18 +133,15 @@ export default function GuestJoin() {
|
|||||||
<div className="relative w-full max-w-md">
|
<div className="relative w-full max-w-md">
|
||||||
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center justify-center gap-2.5 mb-6">
|
<div className="flex justify-center mb-6">
|
||||||
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
|
<BrandLogo size="lg" />
|
||||||
<Video size={22} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-bold gradient-text">Redlight</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Room info */}
|
{/* Room info */}
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h2 className="text-xl font-bold text-th-text mb-1">{roomInfo.name}</h2>
|
<h2 className="text-xl font-bold text-th-text mb-1">{roomInfo.name}</h2>
|
||||||
<p className="text-sm text-th-text-s">
|
<p className="text-sm text-th-text-s">
|
||||||
Erstellt von <span className="font-medium text-th-text">{roomInfo.owner_name}</span>
|
{t('room.guestCreatedBy')} <span className="font-medium text-th-text">{roomInfo.owner_name}</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium"
|
<div className="mt-3 inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium"
|
||||||
style={{
|
style={{
|
||||||
@@ -133,31 +150,32 @@ export default function GuestJoin() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{status.running ? <Radio size={10} className="animate-pulse" /> : <Users size={12} />}
|
{status.running ? <Radio size={10} className="animate-pulse" /> : <Users size={12} />}
|
||||||
{status.running ? 'Meeting läuft' : 'Noch nicht gestartet'}
|
{status.running ? t('room.guestMeetingRunning') : t('room.guestMeetingNotStarted')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Join form */}
|
{/* Join form */}
|
||||||
<form onSubmit={handleJoin} className="space-y-4">
|
<form onSubmit={handleJoin} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">Ihr Name *</label>
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={e => !isLoggedIn && setName(e.target.value)}
|
||||||
className="input-field pl-11"
|
readOnly={isLoggedIn}
|
||||||
placeholder="Max Mustermann"
|
className={`input-field pl-11 ${isLoggedIn ? 'opacity-70 cursor-not-allowed' : ''}`}
|
||||||
|
placeholder={t('room.guestNamePlaceholder')}
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus={!isLoggedIn}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{roomInfo.has_access_code && (
|
{roomInfo.has_access_code && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">Zugangscode</label>
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||||
<input
|
<input
|
||||||
@@ -165,7 +183,7 @@ export default function GuestJoin() {
|
|||||||
value={accessCode}
|
value={accessCode}
|
||||||
onChange={e => setAccessCode(e.target.value)}
|
onChange={e => setAccessCode(e.target.value)}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
placeholder="Code eingeben"
|
placeholder={t('room.guestAccessCodePlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,8 +191,8 @@ export default function GuestJoin() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">
|
<label className="block text-sm font-medium text-th-text mb-1.5">
|
||||||
Moderator-Code
|
{t('room.guestModeratorCode')}
|
||||||
<span className="text-th-text-s font-normal ml-1">(optional)</span>
|
<span className="text-th-text-s font-normal ml-1">{t('room.guestModeratorOptional')}</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Shield size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<Shield size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||||
@@ -183,7 +201,7 @@ export default function GuestJoin() {
|
|||||||
value={moderatorCode}
|
value={moderatorCode}
|
||||||
onChange={e => setModeratorCode(e.target.value)}
|
onChange={e => setModeratorCode(e.target.value)}
|
||||||
className="input-field pl-11"
|
className="input-field pl-11"
|
||||||
placeholder="Nur wenn Sie Moderator sind"
|
placeholder={t('room.guestModeratorPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,7 +215,7 @@ export default function GuestJoin() {
|
|||||||
<Loader2 size={18} className="animate-spin" />
|
<Loader2 size={18} className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Meeting beitreten
|
{t('room.guestJoinButton')}
|
||||||
<ArrowRight size={18} />
|
<ArrowRight size={18} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -205,16 +223,18 @@ export default function GuestJoin() {
|
|||||||
|
|
||||||
{!status.running && (
|
{!status.running && (
|
||||||
<p className="text-xs text-th-text-s text-center">
|
<p className="text-xs text-th-text-s text-center">
|
||||||
Das Meeting wurde noch nicht gestartet. Bitte warten Sie, bis der Moderator es startet.
|
{t('room.guestWaitingMessage')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 pt-4 border-t border-th-border text-center">
|
{!isLoggedIn && (
|
||||||
<Link to="/login" className="text-sm text-th-text-s hover:text-th-accent transition-colors">
|
<div className="mt-6 pt-4 border-t border-th-border text-center">
|
||||||
Haben Sie ein Konto? <span className="text-th-accent font-medium">Anmelden</span>
|
<Link to="/login" className="text-sm text-th-text-s hover:text-th-accent transition-colors">
|
||||||
</Link>
|
{t('room.guestHasAccount')} <span className="text-th-accent font-medium">{t('room.guestSignIn')}</span>
|
||||||
</div>
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe } from 'lucide-react';
|
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe } from 'lucide-react';
|
||||||
|
import BrandLogo from '../components/BrandLogo';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -48,12 +49,7 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Navbar */}
|
{/* Navbar */}
|
||||||
<nav className="relative z-10 flex items-center justify-between px-6 md:px-12 py-4">
|
<nav className="relative z-10 flex items-center justify-between px-6 md:px-12 py-4">
|
||||||
<div className="flex items-center gap-2.5">
|
<BrandLogo size="md" />
|
||||||
<div className="w-9 h-9 gradient-bg rounded-lg flex items-center justify-center">
|
|
||||||
<Video size={20} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xl font-bold gradient-text">Redlight</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link to="/login" className="btn-ghost text-sm">
|
<Link to="/login" className="btn-ghost text-sm">
|
||||||
{t('auth.login')}
|
{t('auth.login')}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useState } from 'react';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { Video, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
|
import { Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
|
||||||
|
import BrandLogo from '../components/BrandLogo';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
@@ -42,11 +43,8 @@ export default function Login() {
|
|||||||
<div className="relative w-full max-w-md">
|
<div className="relative w-full max-w-md">
|
||||||
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center justify-center gap-2.5 mb-8">
|
<div className="flex justify-center mb-8">
|
||||||
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
|
<BrandLogo size="lg" />
|
||||||
<Video size={22} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-bold gradient-text">Redlight</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useState } from 'react';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { Video, Mail, Lock, User, ArrowRight, Loader2 } from 'lucide-react';
|
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle } from 'lucide-react';
|
||||||
|
import BrandLogo from '../components/BrandLogo';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export default function Register() {
|
export default function Register() {
|
||||||
@@ -11,6 +12,7 @@ export default function Register() {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [needsVerification, setNeedsVerification] = useState(false);
|
||||||
const { register } = useAuth();
|
const { register } = useAuth();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -30,9 +32,14 @@ export default function Register() {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await register(name, email, password);
|
const result = await register(name, email, password);
|
||||||
toast.success(t('auth.registerSuccess'));
|
if (result?.needsVerification) {
|
||||||
navigate('/dashboard');
|
setNeedsVerification(true);
|
||||||
|
toast.success(t('auth.verificationSent'));
|
||||||
|
} else {
|
||||||
|
toast.success(t('auth.registerSuccess'));
|
||||||
|
navigate('/dashboard');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.response?.data?.error || t('auth.registerFailed'));
|
toast.error(err.response?.data?.error || t('auth.registerFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -55,13 +62,22 @@ export default function Register() {
|
|||||||
<div className="relative w-full max-w-md">
|
<div className="relative w-full max-w-md">
|
||||||
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center justify-center gap-2.5 mb-8">
|
<div className="flex justify-center mb-8">
|
||||||
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
|
<BrandLogo size="lg" />
|
||||||
<Video size={22} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-bold gradient-text">Redlight</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{needsVerification ? (
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<CheckCircle size={48} className="mx-auto text-green-400" />
|
||||||
|
<h2 className="text-2xl font-bold text-th-text">{t('auth.checkYourEmail')}</h2>
|
||||||
|
<p className="text-th-text-s">{t('auth.verificationSentDesc')}</p>
|
||||||
|
<p className="text-sm text-th-text-s font-medium">{email}</p>
|
||||||
|
<Link to="/login" className="btn-primary inline-flex items-center gap-2 mt-4">
|
||||||
|
{t('auth.login')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.createAccount')}</h2>
|
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.createAccount')}</h2>
|
||||||
<p className="text-th-text-s">
|
<p className="text-th-text-s">
|
||||||
@@ -158,6 +174,8 @@ export default function Register() {
|
|||||||
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
||||||
{t('auth.backToHome')}
|
{t('auth.backToHome')}
|
||||||
</Link>
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio,
|
ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio,
|
||||||
Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck,
|
Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck,
|
||||||
Shield, Save,
|
Shield, Save, UserPlus, X, Share2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
@@ -25,14 +25,21 @@ export default function RoomDetail() {
|
|||||||
const [activeTab, setActiveTab] = useState('overview');
|
const [activeTab, setActiveTab] = useState('overview');
|
||||||
const [editRoom, setEditRoom] = useState(null);
|
const [editRoom, setEditRoom] = useState(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [sharedUsers, setSharedUsers] = useState([]);
|
||||||
|
const [shareSearch, setShareSearch] = useState('');
|
||||||
|
const [shareResults, setShareResults] = useState([]);
|
||||||
|
const [shareSearching, setShareSearching] = useState(false);
|
||||||
|
|
||||||
const isOwner = room && user && room.user_id === user.id;
|
const isOwner = room && user && room.user_id === user.id;
|
||||||
|
const isShared = room && !!room.shared;
|
||||||
|
const canManage = isOwner || isShared;
|
||||||
|
|
||||||
const fetchRoom = async () => {
|
const fetchRoom = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/rooms/${uid}`);
|
const res = await api.get(`/rooms/${uid}`);
|
||||||
setRoom(res.data.room);
|
setRoom(res.data.room);
|
||||||
setEditRoom(res.data.room);
|
setEditRoom(res.data.room);
|
||||||
|
if (res.data.sharedUsers) setSharedUsers(res.data.sharedUsers);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('room.notFound'));
|
toast.error(t('room.notFound'));
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
@@ -144,6 +151,48 @@ export default function RoomDetail() {
|
|||||||
toast.success(t('room.linkCopied'));
|
toast.success(t('room.linkCopied'));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Share functions
|
||||||
|
const searchUsers = async (query) => {
|
||||||
|
setShareSearch(query);
|
||||||
|
if (query.length < 2) {
|
||||||
|
setShareResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShareSearching(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/rooms/users/search?q=${encodeURIComponent(query)}`);
|
||||||
|
// Filter out already shared users
|
||||||
|
const sharedIds = new Set(sharedUsers.map(u => u.id));
|
||||||
|
setShareResults(res.data.users.filter(u => !sharedIds.has(u.id)));
|
||||||
|
} catch {
|
||||||
|
setShareResults([]);
|
||||||
|
} finally {
|
||||||
|
setShareSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = async (userId) => {
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/rooms/${uid}/shares`, { user_id: userId });
|
||||||
|
setSharedUsers(res.data.shares);
|
||||||
|
setShareSearch('');
|
||||||
|
setShareResults([]);
|
||||||
|
toast.success(t('room.shareAdded'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('room.shareFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnshare = async (userId) => {
|
||||||
|
try {
|
||||||
|
const res = await api.delete(`/rooms/${uid}/shares/${userId}`);
|
||||||
|
setSharedUsers(res.data.shares);
|
||||||
|
toast.success(t('room.shareRemoved'));
|
||||||
|
} catch {
|
||||||
|
toast.error(t('room.shareFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
@@ -203,7 +252,7 @@ export default function RoomDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isOwner && !status.running && (
|
{canManage && !status.running && (
|
||||||
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
|
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
|
||||||
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
|
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
|
||||||
{t('room.start')}
|
{t('room.start')}
|
||||||
@@ -215,7 +264,7 @@ export default function RoomDetail() {
|
|||||||
{t('room.join')}
|
{t('room.join')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isOwner && status.running && (
|
{canManage && status.running && (
|
||||||
<button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger">
|
<button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger">
|
||||||
{actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />}
|
{actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />}
|
||||||
{t('room.end')}
|
{t('room.end')}
|
||||||
@@ -416,51 +465,119 @@ export default function RoomDetail() {
|
|||||||
{/* Guest access section */}
|
{/* Guest access section */}
|
||||||
<div className="pt-4 border-t border-th-border space-y-4">
|
<div className="pt-4 border-t border-th-border space-y-4">
|
||||||
<h3 className="text-sm font-semibold text-th-text">{t('room.guestAccessTitle')}</h3>
|
<h3 className="text-sm font-semibold text-th-text">{t('room.guestAccessTitle')}</h3>
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!editRoom.guest_access}
|
|
||||||
onChange={e => setEditRoom({ ...editRoom, guest_access: e.target.checked })}
|
|
||||||
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-th-text">{t('room.guestAccess')}</span>
|
|
||||||
<p className="text-xs text-th-text-s">{t('room.guestAccessHint')}</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{editRoom.guest_access && (
|
<div>
|
||||||
<>
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.moderatorCode')}</label>
|
||||||
<div>
|
<input
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.moderatorCode')}</label>
|
type="text"
|
||||||
<input
|
value={editRoom.moderator_code || ''}
|
||||||
type="text"
|
onChange={e => setEditRoom({ ...editRoom, moderator_code: e.target.value })}
|
||||||
value={editRoom.moderator_code || ''}
|
className="input-field"
|
||||||
onChange={e => setEditRoom({ ...editRoom, moderator_code: e.target.value })}
|
placeholder={t('room.moderatorCodeHint')}
|
||||||
className="input-field"
|
/>
|
||||||
placeholder={t('room.moderatorCodeHint')}
|
<p className="text-xs text-th-text-s mt-1">{t('room.moderatorCodeDesc')}</p>
|
||||||
/>
|
</div>
|
||||||
<p className="text-xs text-th-text-s mt-1">{t('room.moderatorCodeDesc')}</p>
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestLink')}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 bg-th-bg-s px-3 py-2 rounded-lg text-xs text-th-text font-mono truncate border border-th-border">
|
||||||
|
{window.location.origin}/join/{room.uid}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(`${window.location.origin}/join/${room.uid}`);
|
||||||
|
toast.success(t('room.linkCopied'));
|
||||||
|
}}
|
||||||
|
className="btn-ghost text-xs py-2 px-3"
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Share section */}
|
||||||
|
<div className="pt-4 border-t border-th-border space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-th-text flex items-center gap-2">
|
||||||
|
<Share2 size={16} />
|
||||||
|
{t('room.shareTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-th-text-s">{t('room.shareDescription')}</p>
|
||||||
|
|
||||||
|
{/* User search */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="relative">
|
||||||
|
<UserPlus size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={shareSearch}
|
||||||
|
onChange={e => searchUsers(e.target.value)}
|
||||||
|
className="input-field pl-11"
|
||||||
|
placeholder={t('room.shareSearchPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{shareResults.length > 0 && (
|
||||||
|
<div className="absolute z-10 w-full mt-1 bg-th-card border border-th-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||||
|
{shareResults.map(u => (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleShare(u.id)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden"
|
||||||
|
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
||||||
|
>
|
||||||
|
{u.avatar_image ? (
|
||||||
|
<img src={`${api.defaults.baseURL}/auth/avatar/${u.avatar_image}`} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
u.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-th-text truncate">{u.name}</div>
|
||||||
|
<div className="text-xs text-th-text-s truncate">{u.email}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestLink')}</label>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="flex-1 bg-th-bg-s px-3 py-2 rounded-lg text-xs text-th-text font-mono truncate border border-th-border">
|
{/* Shared users list */}
|
||||||
{window.location.origin}/join/{room.uid}
|
{sharedUsers.length > 0 && (
|
||||||
</code>
|
<div className="space-y-2">
|
||||||
|
{sharedUsers.map(u => (
|
||||||
|
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden"
|
||||||
|
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
||||||
|
>
|
||||||
|
{u.avatar_image ? (
|
||||||
|
<img src={`${api.defaults.baseURL}/auth/avatar/${u.avatar_image}`} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
u.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-th-text truncate">{u.name}</div>
|
||||||
|
<div className="text-xs text-th-text-s truncate">{u.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => handleUnshare(u.id)}
|
||||||
navigator.clipboard.writeText(`${window.location.origin}/join/${room.uid}`);
|
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors flex-shrink-0"
|
||||||
toast.success(t('room.linkCopied'));
|
title={t('room.shareRemove')}
|
||||||
}}
|
|
||||||
className="btn-ghost text-xs py-2 px-3"
|
|
||||||
>
|
>
|
||||||
<Copy size={14} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ export default function Settings() {
|
|||||||
});
|
});
|
||||||
const [savingProfile, setSavingProfile] = useState(false);
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
const [savingPassword, setSavingPassword] = useState(false);
|
const [savingPassword, setSavingPassword] = useState(false);
|
||||||
|
|
||||||
|
const handleLanguageChange = async (lang) => {
|
||||||
|
setLanguage(lang);
|
||||||
|
try {
|
||||||
|
const res = await api.put('/auth/profile', { language: lang });
|
||||||
|
updateUser(res.data.user);
|
||||||
|
} catch {
|
||||||
|
// Language is still saved locally even if API fails
|
||||||
|
}
|
||||||
|
};
|
||||||
const [activeSection, setActiveSection] = useState('profile');
|
const [activeSection, setActiveSection] = useState('profile');
|
||||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
@@ -44,6 +54,7 @@ export default function Settings() {
|
|||||||
name: profile.name,
|
name: profile.name,
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
theme,
|
theme,
|
||||||
|
language,
|
||||||
avatar_color: user?.avatar_color,
|
avatar_color: user?.avatar_color,
|
||||||
});
|
});
|
||||||
updateUser(res.data.user);
|
updateUser(res.data.user);
|
||||||
@@ -349,7 +360,7 @@ export default function Settings() {
|
|||||||
].map(lang => (
|
].map(lang => (
|
||||||
<button
|
<button
|
||||||
key={lang.code}
|
key={lang.code}
|
||||||
onClick={() => setLanguage(lang.code)}
|
onClick={() => handleLanguageChange(lang.code)}
|
||||||
className={`flex items-center gap-3 p-4 rounded-xl border-2 transition-all ${
|
className={`flex items-center gap-3 p-4 rounded-xl border-2 transition-all ${
|
||||||
language === lang.code
|
language === lang.code
|
||||||
? 'border-th-accent shadow-md bg-th-accent/5'
|
? 'border-th-accent shadow-md bg-th-accent/5'
|
||||||
|
|||||||
87
src/pages/VerifyEmail.jsx
Normal file
87
src/pages/VerifyEmail.jsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { CheckCircle, XCircle, Loader2, Mail } from 'lucide-react';
|
||||||
|
import BrandLogo from '../components/BrandLogo';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
export default function VerifyEmail() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const [status, setStatus] = useState('loading'); // loading | success | error
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setStatus('error');
|
||||||
|
setMessage(t('auth.verifyTokenMissing'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.get(`/auth/verify-email?token=${token}`)
|
||||||
|
.then(() => {
|
||||||
|
setStatus('success');
|
||||||
|
setMessage(t('auth.verifySuccess'));
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
setStatus('error');
|
||||||
|
setMessage(err.response?.data?.error || t('auth.verifyFailed'));
|
||||||
|
});
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
||||||
|
{/* Animated background */}
|
||||||
|
<div className="absolute inset-0 bg-th-bg">
|
||||||
|
<div className="absolute inset-0 opacity-30">
|
||||||
|
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-pink-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '4s' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-md">
|
||||||
|
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl text-center">
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
|
<BrandLogo size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 'loading' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Loader2 size={48} className="mx-auto animate-spin text-th-accent" />
|
||||||
|
<p className="text-th-text">{t('auth.verifying')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CheckCircle size={48} className="mx-auto text-green-400" />
|
||||||
|
<h2 className="text-2xl font-bold text-th-text">{t('auth.verifySuccessTitle')}</h2>
|
||||||
|
<p className="text-th-text-s">{message}</p>
|
||||||
|
<Link to="/login" className="btn-primary inline-flex items-center gap-2 mt-4">
|
||||||
|
{t('auth.login')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<XCircle size={48} className="mx-auto text-red-400" />
|
||||||
|
<h2 className="text-2xl font-bold text-th-text">{t('auth.verifyFailedTitle')}</h2>
|
||||||
|
<p className="text-th-text-s">{message}</p>
|
||||||
|
<Link to="/register" className="btn-primary inline-flex items-center gap-2 mt-4">
|
||||||
|
{t('auth.register')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link to="/" className="block mt-6 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
||||||
|
{t('auth.backToHome')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user