Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cbe28f915 | |||
| 5472e190d9 | |||
| 45be976de1 | |||
| 6dcb1e959b | |||
| bb2d179871 | |||
| 82b7d060ba | |||
| 0836436fe7 | |||
| 99d3b22f62 | |||
| eed5d98ccc | |||
| 6513fdee41 | |||
| cae84754e4 | |||
| a0a972b53a | |||
| 9b98803053 | |||
| e43e7f5fc5 | |||
| 5731e6a9a8 | |||
| fa8292263c | |||
| 4bc3403040 | |||
| e4f596f8c3 | |||
| 00e563664e | |||
| 41ad3e037a | |||
| 7ef173c49e | |||
| 530377272b | |||
| 52f122a98a | |||
| c2dcb02e9b | |||
| 71557280f5 | |||
| 03e484b8c6 | |||
| 14ed0c3689 | |||
| 5fc64330e0 | |||
| 3ab7ab6a70 | |||
| a7b0b84f2d | |||
| 11d3972a74 | |||
| d8c52aae4e | |||
| f16fd9aef2 | |||
| 8edcb7d3df | |||
| 6aa01d39f4 | |||
| bb4da19f4f | |||
| e8d8ccda42 | |||
| 1d647d0a36 | |||
| e3a5f21c8b | |||
| 014de634b1 | |||
| 268f6d0c5a | |||
| 7018c5579f | |||
| 2a7754dd56 | |||
| a78fc06f2b | |||
| 15bfcc80c3 | |||
| fcb83a9b72 | |||
| a69b2e4d9a | |||
| 0d84610e3b | |||
| 8823f8789e | |||
| ce2cf499dc | |||
| bac4e8ae7c | |||
| 43d94181f9 | |||
| 61274d31f1 | |||
| 3d21967681 | |||
| 2d919cdc67 | |||
| 6e301e2928 | |||
| cdfc585c8a | |||
| e22a895672 | |||
| ba096a31a2 | |||
| d886725c4f | |||
| f3ef490012 | |||
| ddc0c684ec | |||
| 68f31467af | |||
| 05f2941b16 | |||
| 4bb22be496 | |||
| 1c9c5224ae | |||
| 2b8c179d03 | |||
| df82316097 | |||
| e4001cb33f | |||
| 4a4ec0a3a3 | |||
| 9be3be7712 | |||
| dc7a78badb | |||
| 272c5dc2cc | |||
| c13090bc80 | |||
| 304349fce8 | |||
| b5218046c9 | |||
| c2c10f9a4b | |||
| d989e1291d | |||
| 62a3812424 | |||
| 2a8ded5211 | |||
| 9275c20d19 | |||
| af7540eb8c | |||
| 13c60ba052 | |||
| fae46c8395 | |||
| bfec8de195 | |||
| 25b13b4078 | |||
| df4666bb63 | |||
| 8c39275615 | |||
| 57bb1fb696 | |||
| 89b2a853d3 | |||
| ed8fb134ad | |||
| 8e18149ad1 | |||
| 1cff066c17 | |||
| c281628fdc | |||
| 2831f80ab4 | |||
| 1fb999d73b | |||
| 7466f3513d | |||
| 616442a82a | |||
| 5cb8201fb5 | |||
| 3556aaede7 | |||
| ed97587248 | |||
| 9814150ba8 | |||
| 4d6a09c3fd | |||
| 4d1245f358 | |||
| 4d0756d864 | |||
| ab52ca4529 | |||
| 15eed76ab4 | |||
| a7af7d0e6f | |||
| 9be9938f02 | |||
| d781022b63 | |||
| 2762df3e57 | |||
| d7d7991ff0 | |||
| e5b6c225e9 | |||
| 83849bd2f6 | |||
| ffb7a45bfc | |||
| 3b4c1c383e | |||
| ef5790b44d | |||
| 0d48f52d3b | |||
| c5a6a15731 | |||
| a35b708cbf | |||
| 0b9bba2285 | |||
| 32cc4d724b | |||
| 1e19aa24dd | |||
| a6e400b6b7 | |||
| 52a2e2260c | |||
| 7426ae8088 |
15
.env.example
15
.env.example
@@ -19,6 +19,9 @@ POSTGRES_DB=redlight
|
||||
# SQLite file path (only used when DATABASE_URL is not set)
|
||||
# SQLITE_PATH=./redlight.db
|
||||
|
||||
# Dragonfly (Redis-compatible in-memory database) Configuration
|
||||
REDIS_URL=redis://dragonfly:6379
|
||||
|
||||
# Default Admin Account (created on first run)
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=admin123
|
||||
@@ -33,3 +36,15 @@ SMTP_FROM=noreply@example.com
|
||||
|
||||
# App URL (used for verification links, auto-detected if not set)
|
||||
# APP_URL=https://your-domain.com
|
||||
|
||||
# Reverse Proxy trust depth (express 'trust proxy' setting)
|
||||
# loopback = trust only 127.0.0.1 / ::1 (default)
|
||||
# Use a number for proxy hops (e.g. 1), or a specific IP/CIDR.
|
||||
# TRUST_PROXY=loopback
|
||||
|
||||
# Federation (inter-instance meeting invitations)
|
||||
# Set FEDERATION_DOMAIN to enable federation between Redlight instances
|
||||
# FEDERATION_DOMAIN=redlight.example.com
|
||||
# The Ed25519 key pair is auto-generated on first start and stored at ./keys/federation_key.pem
|
||||
# Override the path with FEDERATION_KEY_PATH if needed
|
||||
# FEDERATION_KEY_PATH=/app/keys/federation_key.pem
|
||||
|
||||
@@ -4,12 +4,16 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop-calendar
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Set to 'true' if you need to compile native modules from source (e.g. better-sqlite3 on arm)
|
||||
BUILD_FROM_SOURCE: 'false'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -18,6 +22,9 @@ jobs:
|
||||
id: registry
|
||||
run: echo "host=$(echo ${{ github.server_url }} | sed 's|https\?://||')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -44,6 +51,10 @@ jobs:
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
# Build multi-arch images for amd64 and arm64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
BUILD_FROM_SOURCE=${{ env.BUILD_FROM_SOURCE }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
35
Dockerfile
35
Dockerfile
@@ -1,34 +1,41 @@
|
||||
# ── Stage 1: Build frontend ──────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
# ── Stage 1: Install dependencies ────────────────────────────────────────────
|
||||
FROM node:22-trixie-slim AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install build tools for native modules (better-sqlite3, pdfkit)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 build-essential libsqlite3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install all dependencies (including dev for vite build)
|
||||
RUN npm ci
|
||||
|
||||
# ── Stage 2: Build frontend ─────────────────────────────────────────────────
|
||||
FROM deps AS builder
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: Production image ───────────────────────────────────────────────
|
||||
FROM node:20-alpine
|
||||
# Prune dev dependencies in-place (avoids a second npm ci)
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
# better-sqlite3 needs build tools for native compilation
|
||||
RUN apk add --no-cache python3 make g++
|
||||
# ── Stage 3: Production image ───────────────────────────────────────────────
|
||||
FROM node:22-trixie-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
# Remove build tools after install to keep image smaller
|
||||
RUN apk del python3 make g++
|
||||
# Copy production node_modules and built frontend from builder stage
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Copy server code
|
||||
COPY server/ ./server/
|
||||
|
||||
# Copy built frontend from builder stage
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p uploads/avatars uploads/branding
|
||||
|
||||
|
||||
232
LICENSE
Normal file
232
LICENSE
Normal file
@@ -0,0 +1,232 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
“This License” refers to version 3 of the GNU General Public License.
|
||||
|
||||
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||
|
||||
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||
|
||||
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||
|
||||
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||
|
||||
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||
|
||||
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||
|
||||
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||
|
||||
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||
|
||||
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||
|
||||
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||
|
||||
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||
|
||||
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||
|
||||
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||
|
||||
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||
|
||||
redlight
|
||||
Copyright (C) 2026 Michelle
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||
|
||||
redlight Copyright (C) 2026 Michelle
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
509
README.md
Normal file
509
README.md
Normal file
@@ -0,0 +1,509 @@
|
||||
# 🔴 Redlight
|
||||
|
||||
> ⚠️ **Warning:** This project is entirely *vibe coded* and meant to be a fun/hobby project. Use at your own risk!
|
||||
|
||||
A modern, self-hosted BigBlueButton frontend with 25+ themes, federation, calendar, CalDAV, OAuth/OIDC, and powerful room management.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## ✨ Features
|
||||
|
||||
### Core Features
|
||||
- 🎥 **Video Conferencing** - Integrated BigBlueButton support for professional video meetings
|
||||
- 🎨 **25+ Themes** - Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox, Tokyo Night, Solarized, Everforest, Ayu, Kanagawa, Moonlight, Cyberpunk, Cotton Candy, and more
|
||||
- 📝 **Room Management** - Create unlimited rooms with custom settings, access codes, and moderator codes
|
||||
- 🔐 **User Management** - Registration, login, role-based access control (Admin/User)
|
||||
- 📹 **Recording Management** - View, publish, and delete meeting recordings per room
|
||||
- 📊 **Learning Analytics** - Collect and view per-room participant engagement data (talk time, messages, reactions) via BBB callbacks, secured with HMAC tokens
|
||||
- 📅 **Calendar** - Built-in calendar with event creation, sharing, customizable reminders, and room linking
|
||||
- 📆 **CalDAV Server** - Full CalDAV support for syncing calendars with Thunderbird, Apple Calendar, GNOME Calendar, DAVx⁵ (Android), and other standard clients
|
||||
- 🌍 **Multi-Language Support** - German (Deutsch) and English built-in, easily extensible
|
||||
- 🔔 **In-App Notifications** - Real-time notifications for room shares, federation invites, and calendar reminders
|
||||
- ✉️ **Email Verification** - Optional SMTP-based email verification for user registration
|
||||
- 🔑 **OAuth / OIDC** - Login via OpenID Connect providers (Keycloak, Authentik, etc.) with PKCE
|
||||
- 👤 **User Profiles** - Customizable display names, avatars, themes, and language preferences
|
||||
- 📱 **Responsive Design** - Works seamlessly on mobile, tablet, and desktop
|
||||
- 🌐 **Federation** - Invite users from remote Redlight instances via Ed25519-signed messages
|
||||
- 🐉 **DragonflyDB / Redis** - JWT blacklisting for secure token revocation on logout
|
||||
|
||||
### Admin Features
|
||||
- 👥 **User Administration** - Manage users and roles
|
||||
- 🏢 **Branding Customization** - Custom app name, logos, and default theme
|
||||
- 📊 **Dashboard** - Overview of system statistics
|
||||
- 🔧 **Settings Management** - System-wide configuration
|
||||
- ✉️ **Invite-Only Registration** - Generate invite tokens for controlled user signup
|
||||
|
||||
### Room Features
|
||||
- 🔑 **Access Codes** - Restrict room access with optional passwords
|
||||
- 🔐 **Moderator Codes** - Separate code to grant moderator privileges
|
||||
- 🚪 **Guest Access** - Allow unauthenticated users to join meetings (rate-limited)
|
||||
- ⏱️ **Max Participants** - Set limits on concurrent participants
|
||||
- 🎤 **Mute on Join** - Automatically mute new participants
|
||||
- ✅ **Approval Mode** - Require moderator approval for participants
|
||||
- 🎙️ **Anyone Can Start** - Allow participants to start the meeting
|
||||
- 📹 **Recording Settings** - Control whether meetings are recorded
|
||||
- 📊 **Learning Analytics** - Toggle per-room to collect participant engagement data after each meeting
|
||||
- 📑 **Presentation Upload** - Upload PDF, PPTX, ODP, DOC, DOCX as default slides
|
||||
- 🤝 **Room Sharing** - Share rooms with other registered users
|
||||
|
||||
### Security
|
||||
- 🛡️ **Comprehensive Rate Limiting** - Login, register, profile, avatar, guest-join, OAuth, and federation endpoints
|
||||
- 🔒 **Input Validation** - Email format, field length limits, ID format checks, color format validation
|
||||
- 🕐 **Timing-Safe Comparisons** - Access codes and moderator codes compared with `crypto.timingSafeEqual`
|
||||
- 📏 **Streaming Upload Limits** - Avatar (5 MB) and presentation (50 MB) uploads reject early without buffering
|
||||
- 🧹 **XSS Prevention** - HTML-escaped emails, XML-escaped BBB parameters, SVG sanitization
|
||||
- 🔐 **JWT Blacklist** - Token revocation via DragonflyDB/Redis on logout
|
||||
- 🌐 **CORS Restriction** - Locked to `APP_URL` in production
|
||||
- ⚙️ **Configurable Trust Proxy** - `TRUST_PROXY` env var for reverse proxy setups
|
||||
- 🔏 **HMAC-Secured Callbacks** - Learning analytics callback URLs signed with HMAC-SHA256
|
||||
|
||||
### Developer Features
|
||||
- 🐳 **Docker Support** - Easy deployment with Docker Compose (includes PostgreSQL + DragonflyDB)
|
||||
- 🗄️ **Database Flexibility** - SQLite (default) or PostgreSQL support
|
||||
- 🔌 **REST API** - Comprehensive API for custom integrations
|
||||
- 📦 **Open Source** - Full source code transparency
|
||||
- 🛠️ **Self-Hosted** - Complete data privacy and control
|
||||
|
||||
---
|
||||
|
||||
## 📊 Comparison: Redlight vs Greenlight
|
||||
|
||||
| Feature | Redlight | Greenlight |
|
||||
|---------|----------|-----------|
|
||||
| **Theme System** | 25+ customizable themes | Limited theming |
|
||||
| **Learning Analytics** | ✅ Per-room engagement data | ❌ Not supported |
|
||||
| **Calendar / CalDAV** | ✅ Built-in calendar + CalDAV sync | ❌ Not supported |
|
||||
| **OAuth / OIDC** | ✅ OpenID Connect (PKCE) | ✅ Supported |
|
||||
| **Federation** | ✅ Cross-instance invites | ❌ Not supported |
|
||||
| **Notifications** | ✅ In-app + calendar reminders | ❌ Not supported |
|
||||
| **Language Support** | Multi-language ready | Multi-language ready |
|
||||
| **UI Framework** | React + Tailwind (Modern) | Rails-based (Traditional) |
|
||||
| **User Preferences** | Theme, language, avatar, display name | Limited customization |
|
||||
| **Database Options** | SQLite / PostgreSQL | PostgreSQL only |
|
||||
| **Docker** | ✅ Supported | ✅ Supported |
|
||||
| **Admin Dashboard** | Modern React UI | Legacy Rails interface |
|
||||
| **Room Sharing** | ✅ Share rooms with users | ✅ Supported |
|
||||
| **Recording Management** | Full control per room | Standard management |
|
||||
| **Presentation Upload** | ✅ Supported | ✅ Supported |
|
||||
| **API** | RESTful JSON API | RESTful API |
|
||||
| **Setup Complexity** | Simple (5 min) | Moderate (10-15 min) |
|
||||
| **Customization** | Easy (Tailwind CSS) | Requires Ruby/Rails |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker & Docker Compose
|
||||
- BigBlueButton server (with API access)
|
||||
- SMTP server (optional, for email verification)
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://git.scrunkly.cat/Michelle/redlight
|
||||
cd redlight
|
||||
```
|
||||
|
||||
2. **Configure environment**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
Edit `.env` with your settings:
|
||||
```env
|
||||
BBB_URL=https://your-bbb-server.com/bigbluebutton/api/
|
||||
BBB_SECRET=your-bbb-shared-secret
|
||||
JWT_SECRET=your-secret-key # REQUIRED - app won't start without this
|
||||
APP_URL=https://your-domain.com # Used for CORS and email links
|
||||
DATABASE_URL=postgres://redlight:redlight@postgres:5432/redlight
|
||||
|
||||
POSTGRES_USER=redlight
|
||||
POSTGRES_PASSWORD=redlight
|
||||
POSTGRES_DB=redlight
|
||||
|
||||
# DragonflyDB / Redis (JWT blacklist for logout)
|
||||
REDIS_URL=redis://dragonfly:6379
|
||||
|
||||
# Reverse proxy trust (default: loopback)
|
||||
# TRUST_PROXY=loopback
|
||||
|
||||
# Optional: Email verification
|
||||
# SMTP_HOST=smtp.gmail.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=your-email@gmail.com
|
||||
# SMTP_PASS=your-app-password
|
||||
|
||||
# Optional: Federation (cross-instance room invites)
|
||||
# FEDERATION_DOMAIN=your-domain.com
|
||||
|
||||
# Optional: OAuth / OIDC login
|
||||
# OAUTH_ISSUER=https://auth.your-domain.com/realms/your-realm
|
||||
# OAUTH_CLIENT_ID=redlight
|
||||
# OAUTH_CLIENT_SECRET=your-client-secret
|
||||
```
|
||||
|
||||
3. **Start the application**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
4. **Access the application**
|
||||
- Open `http://localhost:3001` in your browser
|
||||
- Default admin: `admin@example.com` / `admin123`
|
||||
- Change password immediately!
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Local Setup
|
||||
|
||||
1. **Install dependencies**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Start development server**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
- Frontend: http://localhost:5173
|
||||
- Backend: http://localhost:3001
|
||||
|
||||
3. **Build for production**
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
- **Frontend**: React 18, Tailwind CSS, React Router, Lucide Icons
|
||||
- **Backend**: Node.js 20, Express, JWT, Bcrypt
|
||||
- **Database**: SQLite / PostgreSQL with better-sqlite3 / pg
|
||||
- **Cache**: DragonflyDB / Redis (ioredis) - JWT blacklisting
|
||||
- **Email**: Nodemailer
|
||||
- **CalDAV**: xml2js-based WebDAV/CalDAV server
|
||||
- **Auth**: JWT + OAuth/OIDC (PKCE)
|
||||
- **Build**: Vite
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
redlight/
|
||||
├── server/ # Node.js/Express backend
|
||||
│ ├── config/ # Database, Redis, mailer, BBB, federation, OAuth & notification config
|
||||
│ ├── i18n/ # Server-side translations (email templates)
|
||||
│ ├── jobs/ # Background jobs (federation sync, calendar reminders)
|
||||
│ ├── middleware/ # JWT authentication, logging & token blacklisting
|
||||
│ ├── routes/ # API endpoints (auth, rooms, recordings, admin, branding,
|
||||
│ │ # federation, calendar, caldav, notifications, oauth, analytics)
|
||||
│ └── index.js # Server entry point
|
||||
├── src/ # React frontend
|
||||
│ ├── components/ # Reusable components (RecordingList, AnalyticsList, etc.)
|
||||
│ ├── contexts/ # React context (Auth, Language, Theme, Branding, Notification)
|
||||
│ ├── i18n/ # Translations (DE, EN)
|
||||
│ ├── pages/ # Page components
|
||||
│ ├── services/ # API client
|
||||
│ ├── themes/ # 25+ theme definitions
|
||||
│ └── main.jsx # Frontend entry point
|
||||
├── public/ # Static assets
|
||||
├── uploads/ # User avatars, branding & presentations (runtime)
|
||||
├── keys/ # Federation Ed25519 key pair (auto-generated)
|
||||
├── compose.yml # Docker Compose (Redlight + PostgreSQL + DragonflyDB)
|
||||
├── Dockerfile # Multi-stage container image
|
||||
└── package.json # Dependencies
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
- **JWT Authentication** - Secure token-based auth with 7-day expiration and `jti`-based blacklisting via DragonflyDB/Redis
|
||||
- **Mandatory JWT Secret** - Server refuses to start without a `JWT_SECRET` env var
|
||||
- **OAuth / OIDC** - OpenID Connect with PKCE (S256) and cryptographic state tokens
|
||||
- **HTTPS Ready** - Configure behind reverse proxy (nginx, Caddy); trust proxy via `TRUST_PROXY` env
|
||||
- **Password Hashing** - bcryptjs with salt rounds 12, minimum 8-character passwords
|
||||
- **Email Verification** - Optional SMTP-based email verification with resend support
|
||||
- **CORS Protection** - Restricted to `APP_URL` in production, open in development
|
||||
- **Rate Limiting** - Login, register, profile, password, avatar, guest-join, OAuth, and federation endpoints
|
||||
- **Input Validation** - Email regex, field length limits, ID format checks, hex-color format checks
|
||||
- **Timing-Safe Comparisons** - Access codes and moderator codes compared via `crypto.timingSafeEqual`
|
||||
- **Upload Safety** - Streaming body size limits (avatar 5 MB, presentation 50 MB) abort early without buffering
|
||||
- **XSS / Injection Prevention** - HTML-escaped emails, XML-escaped BBB API parameters, SVG logos served as `attachment`
|
||||
- **HMAC-Secured Callbacks** - Learning analytics callback URLs signed with HMAC-SHA256 derived from BBB_SECRET
|
||||
- **Admin Isolation** - Role-based access control with strict admin checks
|
||||
- **Network Isolation** - Docker Compose uses an internal backend network for DB and cache
|
||||
|
||||
---
|
||||
|
||||
## 📦 API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `POST /api/auth/register` - Register new user
|
||||
- `POST /api/auth/login` - Login user
|
||||
- `POST /api/auth/logout` - Logout (blacklists JWT)
|
||||
- `GET /api/auth/verify-email?token=...` - Verify email with token
|
||||
- `POST /api/auth/resend-verification` - Resend verification email
|
||||
- `GET /api/auth/me` - Get current user info
|
||||
- `PUT /api/auth/profile` - Update profile (theme, language, display name)
|
||||
- `PUT /api/auth/password` - Change password
|
||||
- `POST /api/auth/avatar` - Upload avatar image
|
||||
- `DELETE /api/auth/avatar` - Remove avatar image
|
||||
|
||||
### Rooms
|
||||
- `GET /api/rooms` - List user's rooms (owned + shared)
|
||||
- `POST /api/rooms` - Create new room
|
||||
- `GET /api/rooms/:uid` - Get room details
|
||||
- `PUT /api/rooms/:uid` - Update room (incl. learning analytics toggle)
|
||||
- `DELETE /api/rooms/:uid` - Delete room
|
||||
- `POST /api/rooms/:uid/start` - Start meeting
|
||||
- `POST /api/rooms/:uid/join` - Join meeting as authenticated user
|
||||
- `POST /api/rooms/:uid/guest-join` - Join meeting as guest (rate-limited)
|
||||
- `POST /api/rooms/:uid/end` - End meeting
|
||||
- `GET /api/rooms/:uid/status` - Check if meeting is running
|
||||
- `GET /api/rooms/:uid/shares` - List shared users
|
||||
- `POST /api/rooms/:uid/shares` - Share room with user
|
||||
- `DELETE /api/rooms/:uid/shares/:userId` - Remove share
|
||||
- `POST /api/rooms/:uid/presentation` - Upload default presentation
|
||||
- `DELETE /api/rooms/:uid/presentation` - Remove presentation
|
||||
|
||||
### Recordings
|
||||
- `GET /api/recordings/room/:uid` - List room recordings
|
||||
- `PUT /api/recordings/:recordID/publish` - Publish/unpublish recording
|
||||
- `DELETE /api/recordings/:recordID` - Delete recording
|
||||
|
||||
### Learning Analytics
|
||||
- `POST /api/analytics/callback/:uid?token=...` - BBB callback (HMAC-secured)
|
||||
- `GET /api/analytics/room/:uid` - Get analytics for a room
|
||||
- `DELETE /api/analytics/:id` - Delete analytics entry
|
||||
|
||||
### Calendar
|
||||
- `GET /api/calendar` - List calendar events
|
||||
- `POST /api/calendar` - Create event
|
||||
- `PUT /api/calendar/:uid` - Update event
|
||||
- `DELETE /api/calendar/:uid` - Delete event
|
||||
- `GET /api/calendar/caldav-tokens` - List CalDAV tokens
|
||||
- `POST /api/calendar/caldav-tokens` - Create CalDAV token
|
||||
- `DELETE /api/calendar/caldav-tokens/:id` - Delete CalDAV token
|
||||
|
||||
### Notifications
|
||||
- `GET /api/notifications` - List notifications
|
||||
- `PUT /api/notifications/:id/read` - Mark as read
|
||||
- `POST /api/notifications/read-all` - Mark all as read
|
||||
- `DELETE /api/notifications/:id` - Delete notification
|
||||
|
||||
### Admin
|
||||
- `GET /api/admin/users` - List all users
|
||||
- `GET /api/admin/stats` - System statistics
|
||||
- `POST /api/admin/users` - Create user (admin)
|
||||
- `PUT /api/admin/users/:id` - Update user
|
||||
- `DELETE /api/admin/users/:id` - Delete user
|
||||
|
||||
### Branding
|
||||
- `GET /api/branding` - Get branding settings
|
||||
- `PUT /api/branding` - Update branding (admin only)
|
||||
- `POST /api/branding/logo` - Upload custom logo
|
||||
- `DELETE /api/branding/logo` - Remove custom logo
|
||||
|
||||
### OAuth
|
||||
- `GET /api/oauth/url` - Get OAuth authorization URL
|
||||
- `GET /api/oauth/callback` - OAuth callback (PKCE exchange)
|
||||
|
||||
### Federation
|
||||
- `GET /.well-known/redlight` - Instance discovery (domain, public key)
|
||||
- `POST /api/federation/invite` - Send invitation to remote user
|
||||
- `POST /api/federation/receive` - Receive invitation from remote instance (rate-limited)
|
||||
- `GET /api/federation/invitations` - List received invitations
|
||||
- `PUT /api/federation/invitations/:id` - Accept / decline invitation
|
||||
- `DELETE /api/federation/invitations/:id` - Delete invitation
|
||||
|
||||
### CalDAV
|
||||
- `PROPFIND /caldav/` - CalDAV discovery
|
||||
- `REPORT /caldav/:user/calendar/` - Calendar query
|
||||
- `GET/PUT/DELETE /caldav/:user/calendar/:uid.ics` - Event CRUD
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Internationalization (i18n)
|
||||
|
||||
Redlight comes with built-in support for multiple languages. Currently supported:
|
||||
- 🇩🇪 Deutsch (German)
|
||||
- 🇬🇧 English
|
||||
|
||||
### Adding a new language
|
||||
|
||||
1. Create `src/i18n/xx.json` (e.g., `fr.json` for French)
|
||||
2. Copy structure from `de.json` or `en.json`
|
||||
3. Translate all strings
|
||||
4. Update `src/i18n/index.js` to include the new language
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Themes
|
||||
|
||||
Redlight includes 25+ themes:
|
||||
- ☀️ Light / 🌙 Dark (default)
|
||||
- 🐱 Catppuccin Mocha / Latte
|
||||
- 🧛 Dracula
|
||||
- ❄️ Nord
|
||||
- 🌊 Tokyo Night
|
||||
- 💜 One Dark
|
||||
- 🐙 GitHub Dark
|
||||
- 🌹 Rosé Pine / Rosé Pine Dawn
|
||||
- 🍂 Gruvbox Dark / Gruvbox Light
|
||||
- ☀️ Solarized Dark / Solarized Light
|
||||
- 🌲 Everforest Dark / Everforest Light
|
||||
- 🌊 Kanagawa
|
||||
- 🌙 Moonlight
|
||||
- 🎮 Cyberpunk
|
||||
- 🌸 Ayu Dark
|
||||
- 🔴 Red Modular Light
|
||||
- 🍬 Cotton Candy Light
|
||||
- 🐱 scrunkly.cat Dark
|
||||
|
||||
Themes are fully customizable by editing `src/themes/index.js`.
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker Deployment
|
||||
|
||||
### Using Docker Compose (Recommended)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Services:
|
||||
- **redlight** - Node.js application (port 3001)
|
||||
- **postgres** - PostgreSQL 17 database
|
||||
- **dragonfly** - DragonflyDB (Redis-compatible) for JWT blacklisting
|
||||
|
||||
The `compose.yml` uses isolated networks: `frontend` (public) and `backend` (internal, no external access). Data is persisted via named volumes (`pgdata`, `uploads`, `dragonflydata`). Federation keys are mounted from `./keys`.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `BBB_URL` | Yes | - | BigBlueButton API URL |
|
||||
| `BBB_SECRET` | Yes | - | BigBlueButton shared secret |
|
||||
| `JWT_SECRET` | Yes | - | Secret for signing JWTs (server won't start without it) |
|
||||
| `APP_URL` | Recommended | - | Public URL of the app (used for CORS + email links) |
|
||||
| `DATABASE_URL` | No | SQLite | PostgreSQL connection string |
|
||||
| `REDIS_URL` | No | `redis://localhost:6379` | DragonflyDB / Redis URL |
|
||||
| `TRUST_PROXY` | No | `loopback` | Express trust proxy setting (number or string) |
|
||||
| `SMTP_HOST` | No | - | SMTP server for email verification |
|
||||
| `SMTP_PORT` | No | `587` | SMTP port |
|
||||
| `SMTP_USER` | No | - | SMTP username |
|
||||
| `SMTP_PASS` | No | - | SMTP password |
|
||||
| `FEDERATION_DOMAIN` | No | - | Domain for federation (enables cross-instance invites) |
|
||||
| `OAUTH_ISSUER` | No | - | OIDC issuer URL (enables OAuth login) |
|
||||
| `OAUTH_CLIENT_ID` | No | - | OIDC client ID |
|
||||
| `OAUTH_CLIENT_SECRET` | No | - | OIDC client secret |
|
||||
| `ADMIN_EMAIL` | No | `admin@example.com` | Default admin email (first start only) |
|
||||
| `ADMIN_PASSWORD` | No | `admin123` | Default admin password (first start only) |
|
||||
|
||||
### Production Deployment
|
||||
|
||||
Behind a reverse proxy (nginx example):
|
||||
|
||||
```nginx
|
||||
upstream redlight {
|
||||
server localhost:3001;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
client_max_body_size 5M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://redlight;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** When running behind a reverse proxy, set `TRUST_PROXY=1` (or the appropriate value) in `.env` so Express reads the correct client IP for rate limiting.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Federation
|
||||
|
||||
Federation allows users on different Redlight instances to invite each other into rooms.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Set `FEDERATION_DOMAIN=your-domain.com` in `.env`.
|
||||
2. On first start, an Ed25519 key pair is generated automatically and stored in `keys/federation_key.pem`.
|
||||
3. In Docker, mount `./keys:/app/keys` (already configured in `compose.yml`).
|
||||
4. Other instances discover your public key via `GET /.well-known/redlight`.
|
||||
|
||||
### How it works
|
||||
|
||||
1. **User A** on `instance-a.com` sends an invite to `userB@instance-b.com`.
|
||||
2. Redlight looks up `instance-b.com/.well-known/redlight` to discover the federation API.
|
||||
3. The invite payload is signed with instance A's private key and POSTed to instance B's `/api/federation/receive`.
|
||||
4. Instance B verifies the Ed25519 signature against instance A's public key.
|
||||
5. **User B** sees the invitation and can accept or decline. Accepting provides a join link to the remote room.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Issue: "ERR_ERL_PERMISSIVE_TRUST_PROXY"
|
||||
**Solution**: Set `TRUST_PROXY` in `.env`. Use `loopback` (default) or `1` when behind a single reverse proxy.
|
||||
|
||||
### Issue: "JWT_SECRET is not set"
|
||||
**Solution**: The server requires a `JWT_SECRET` environment variable and will refuse to start without one. Add it to your `.env` file.
|
||||
|
||||
### Issue: "Email verification not working"
|
||||
**Solution**: Ensure SMTP is configured in `.env`. If `SMTP_HOST` is not set, email verification is disabled.
|
||||
|
||||
### Issue: "BigBlueButton API error"
|
||||
**Solution**: Verify `BBB_URL` and `BBB_SECRET` are correct. Test the connection with:
|
||||
```bash
|
||||
curl "https://your-bbb-server/bigbluebutton/api/getMeetings?checksum=..."
|
||||
```
|
||||
|
||||
### Issue: "Database connection failed"
|
||||
**Solution**: Check `DATABASE_URL` format. For PostgreSQL: `postgres://user:password@host:5432/redlight`
|
||||
|
||||
### Issue: "Theme not applying"
|
||||
**Solution**: Clear browser cache (Ctrl+Shift+Del) or restart dev server with `npm run dev`.
|
||||
|
||||
### Issue: "DragonflyDB connection error"
|
||||
**Solution**: Ensure DragonflyDB (or Redis) is running and `REDIS_URL` is correct. If unavailable, the app still works - JWT blacklisting degrades gracefully (logout won't revoke tokens immediately).
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
|
||||
This project is licensed under the GNU GPL v3 (or later) - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
51
compose.yml
51
compose.yml
@@ -6,10 +6,16 @@ services:
|
||||
- "3001:3001"
|
||||
env_file: ".env"
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
- ./uploads:/app/uploads
|
||||
- ./keys:/app/keys
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
dragonfly:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
@@ -22,7 +28,48 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- backend
|
||||
|
||||
dragonfly:
|
||||
image: ghcr.io/dragonflydb/dragonfly:latest
|
||||
restart: unless-stopped
|
||||
ulimits:
|
||||
memlock: -1
|
||||
volumes:
|
||||
- dragonflydata:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-p", "6379", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- backend
|
||||
|
||||
# Use valkey, if your system is too old for DragonflyDB
|
||||
# valkey:
|
||||
# image: valkey/valkey:9
|
||||
# restart: unless-stopped
|
||||
# ulimits:
|
||||
# memlock: -1
|
||||
# volumes:
|
||||
# - valkeydata:/data
|
||||
# healthcheck:
|
||||
# test: ["CMD", "redis-cli", "-p", "6379", "ping"]
|
||||
# interval: 5s
|
||||
# timeout: 5s
|
||||
# retries: 5
|
||||
# networks:
|
||||
# - backend
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
uploads:
|
||||
dragonflydata:
|
||||
#valkeydata:
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
driver: bridge
|
||||
backend:
|
||||
driver: bridge
|
||||
internal: true
|
||||
|
||||
694
migrate-from-greenlight.mjs
Normal file
694
migrate-from-greenlight.mjs
Normal file
@@ -0,0 +1,694 @@
|
||||
/**
|
||||
* Greenlight → Redlight Migration Script
|
||||
*
|
||||
* Migrates users, rooms (including settings and shared accesses),
|
||||
* site settings (branding, registration), logo, and OAuth/OIDC configuration
|
||||
* from a Greenlight v3 PostgreSQL database into a Redlight instance
|
||||
* (SQLite or PostgreSQL).
|
||||
*
|
||||
* Usage:
|
||||
* node migrate-from-greenlight.mjs [options]
|
||||
*
|
||||
* Options:
|
||||
* --dry-run Show what would be migrated without writing anything
|
||||
* --skip-rooms Only migrate users, not rooms
|
||||
* --skip-shares Do not migrate room share (shared_accesses)
|
||||
* --skip-settings Do not migrate site_settings / branding
|
||||
* --skip-oauth Do not migrate OAuth / OIDC configuration
|
||||
* --verbose Print every imported row
|
||||
*
|
||||
* Environment variables (can also be in .env):
|
||||
*
|
||||
* Source (Greenlight DB — PostgreSQL):
|
||||
* GL_DATABASE_URL Full Postgres connection string
|
||||
* e.g. postgres://gl_user:pass@localhost/greenlight_db
|
||||
*
|
||||
* Target (Redlight DB — auto-detected):
|
||||
* DATABASE_URL Set for PostgreSQL target (same format as GL_DATABASE_URL)
|
||||
* SQLITE_PATH Set (or leave empty) for SQLite target
|
||||
* Default: ./redlight.db (relative to this script)
|
||||
*
|
||||
* OAuth (from Greenlight .env — optional, only if --skip-oauth is NOT set):
|
||||
* GL_OIDC_ISSUER OIDC issuer URL (e.g. https://keycloak.example.com/realms/myrealm)
|
||||
* GL_OIDC_CLIENT_ID OIDC client ID
|
||||
* GL_OIDC_CLIENT_SECRET OIDC client secret
|
||||
* GL_OIDC_DISPLAY_NAME Button label on the login page (default: "SSO")
|
||||
*
|
||||
* Password hashes:
|
||||
* Both Greenlight and Redlight use bcrypt, so password_digest is
|
||||
* copied as-is — users can log in with their existing passwords.
|
||||
*
|
||||
* Site-settings mapping (GL site_settings → Redlight settings):
|
||||
* BrandingImage → downloads file → uploads/branding/logo.*
|
||||
* PrimaryColor → (logged, not mapped — Redlight uses themes)
|
||||
* RegistrationMethod → registration_mode (open / invite)
|
||||
* Terms → imprint_url
|
||||
* PrivacyPolicy → privacy_url
|
||||
*
|
||||
* Meeting-option → Redlight field mapping:
|
||||
* record → record_meeting
|
||||
* muteOnStart → mute_on_join
|
||||
* guestPolicy → require_approval (ASK_MODERATOR = true)
|
||||
* glAnyoneCanStart → anyone_can_start
|
||||
* glAnyoneJoinAsModerator → all_join_moderator
|
||||
* glViewerAccessCode → access_code
|
||||
* glModeratorAccessCode → moderator_code
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import pg from 'pg';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
const args = process.argv.slice(2);
|
||||
const DRY_RUN = args.includes('--dry-run');
|
||||
const SKIP_ROOMS = args.includes('--skip-rooms');
|
||||
const SKIP_SHARES = args.includes('--skip-shares');
|
||||
const SKIP_SETTINGS = args.includes('--skip-settings');
|
||||
const SKIP_OAUTH = args.includes('--skip-oauth');
|
||||
const VERBOSE = args.includes('--verbose');
|
||||
|
||||
const c = {
|
||||
reset: '\x1b[0m',
|
||||
bold: '\x1b[1m',
|
||||
green: '\x1b[32m',
|
||||
yellow:'\x1b[33m',
|
||||
red: '\x1b[31m',
|
||||
cyan: '\x1b[36m',
|
||||
dim: '\x1b[2m',
|
||||
};
|
||||
|
||||
const log = (...a) => console.log(...a);
|
||||
const ok = (msg) => log(` ${c.green}✓${c.reset} ${msg}`);
|
||||
const warn = (msg) => log(` ${c.yellow}⚠${c.reset} ${msg}`);
|
||||
const skip = (msg) => log(` ${c.dim}– ${msg}${c.reset}`);
|
||||
const err = (msg) => log(` ${c.red}✗${c.reset} ${msg}`);
|
||||
const info = (msg) => log(` ${c.cyan}i${c.reset} ${msg}`);
|
||||
const loud = (msg) => { if (VERBOSE) log(` ${c.dim}${msg}${c.reset}`); };
|
||||
|
||||
// ── Source DB (Greenlight PostgreSQL) ─────────────────────────────────────
|
||||
const GL_URL = process.env.GL_DATABASE_URL;
|
||||
if (!GL_URL) {
|
||||
err('GL_DATABASE_URL is not set. Please set it in your .env file or environment.');
|
||||
err(' Example: GL_DATABASE_URL=postgres://gl_user:pass@localhost/greenlight_production');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Target DB (Redlight) ───────────────────────────────────────────────────
|
||||
const RL_URL = process.env.DATABASE_URL;
|
||||
const isPostgresTarget = !!(RL_URL && RL_URL.startsWith('postgres'));
|
||||
|
||||
// ── SQLite adapter (only loaded when needed) ───────────────────────────────
|
||||
async function openSqlite() {
|
||||
const require = createRequire(import.meta.url);
|
||||
let Database;
|
||||
try {
|
||||
Database = require('better-sqlite3');
|
||||
} catch {
|
||||
err('better-sqlite3 is not installed. Run: npm install better-sqlite3');
|
||||
process.exit(1);
|
||||
}
|
||||
const dbPath = process.env.SQLITE_PATH || './redlight.db';
|
||||
const db = Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
return {
|
||||
async get(sql, params = []) { return db.prepare(sql).get(...params); },
|
||||
async all(sql, params = []) { return db.prepare(sql).all(...params); },
|
||||
async run(sql, params = []) {
|
||||
const r = db.prepare(sql).run(...params);
|
||||
return { lastInsertRowid: Number(r.lastInsertRowid), changes: r.changes };
|
||||
},
|
||||
close() { db.close(); },
|
||||
type: 'sqlite',
|
||||
path: dbPath,
|
||||
};
|
||||
}
|
||||
|
||||
// ── PostgreSQL adapter ─────────────────────────────────────────────────────
|
||||
async function openPostgres(url, label) {
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
let index = 0;
|
||||
const convert = (sql) => sql.replace(/\?/g, () => `$${++index}`);
|
||||
return {
|
||||
async get(sql, params = []) {
|
||||
index = 0;
|
||||
const r = await pool.query(convert(sql), params);
|
||||
return r.rows[0];
|
||||
},
|
||||
async all(sql, params = []) {
|
||||
index = 0;
|
||||
const r = await pool.query(convert(sql), params);
|
||||
return r.rows;
|
||||
},
|
||||
async run(sql, params = []) {
|
||||
index = 0;
|
||||
let q = convert(sql);
|
||||
if (/^\s*INSERT/i.test(q) && !/RETURNING/i.test(q)) q += ' RETURNING id';
|
||||
const r = await pool.query(q, params);
|
||||
return { lastInsertRowid: r.rows[0]?.id, changes: r.rowCount };
|
||||
},
|
||||
async end() { await pool.end(); },
|
||||
type: 'postgres',
|
||||
};
|
||||
}
|
||||
|
||||
// ── meeting option → redlight field map ───────────────────────────────────
|
||||
const OPTION_MAP = {
|
||||
record: 'record_meeting',
|
||||
muteOnStart: 'mute_on_join',
|
||||
guestPolicy: 'require_approval', // special: "ASK_MODERATOR"
|
||||
glAnyoneCanStart: 'anyone_can_start',
|
||||
glAnyoneJoinAsModerator: 'all_join_moderator',
|
||||
glViewerAccessCode: 'access_code',
|
||||
glModeratorAccessCode: 'moderator_code',
|
||||
};
|
||||
|
||||
function boolOption(val) {
|
||||
return val === 'true' || val === '1' ? 1 : 0;
|
||||
}
|
||||
|
||||
// ── AES-256-GCM encryption (matching Redlight's oauth.js) ─────────────────
|
||||
const ENCRYPTION_KEY = crypto
|
||||
.createHash('sha256')
|
||||
.update(process.env.JWT_SECRET || '')
|
||||
.digest();
|
||||
|
||||
function encryptSecret(plaintext) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
const authTag = cipher.getAuthTag().toString('hex');
|
||||
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
|
||||
}
|
||||
|
||||
// ── Helper: upsert a setting into the Redlight settings table ─────────────
|
||||
async function upsertSetting(db, key, value, isPostgres) {
|
||||
const existing = await db.get('SELECT key FROM settings WHERE key = ?', [key]);
|
||||
if (existing) {
|
||||
await db.run('UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?', [value, key]);
|
||||
} else {
|
||||
await db.run('INSERT INTO settings (key, value) VALUES (?, ?) RETURNING key', [key, value]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── main ───────────────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
log();
|
||||
log(`${c.bold}Greenlight → Redlight Migration${c.reset}`);
|
||||
if (DRY_RUN) log(`${c.yellow}${c.bold} DRY RUN — nothing will be written${c.reset}`);
|
||||
log();
|
||||
|
||||
// Connect to source
|
||||
let glDb;
|
||||
try {
|
||||
glDb = await openPostgres(GL_URL, 'Greenlight');
|
||||
await glDb.get('SELECT 1');
|
||||
info(`Connected to Greenlight DB (PostgreSQL)`);
|
||||
} catch (e) {
|
||||
err(`Cannot connect to Greenlight DB: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Connect to target
|
||||
let rlDb;
|
||||
try {
|
||||
if (isPostgresTarget) {
|
||||
rlDb = await openPostgres(RL_URL, 'Redlight');
|
||||
await rlDb.get('SELECT 1');
|
||||
info(`Connected to Redlight DB (PostgreSQL)`);
|
||||
} else {
|
||||
rlDb = await openSqlite();
|
||||
info(`Connected to Redlight DB (SQLite: ${rlDb.path})`);
|
||||
}
|
||||
} catch (e) {
|
||||
err(`Cannot connect to Redlight DB: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
log();
|
||||
|
||||
// ── 1. Load Greenlight roles ───────────────────────────────────────────
|
||||
const roles = await glDb.all('SELECT id, name FROM roles');
|
||||
const adminRoleIds = new Set(
|
||||
roles.filter(r => /admin|administrator/i.test(r.name)).map(r => r.id)
|
||||
);
|
||||
info(`Found ${roles.length} roles (${adminRoleIds.size} admin role(s))`);
|
||||
|
||||
// ── 2. Load Greenlight users (local + OIDC) ────────────────────────────
|
||||
const glUsers = await glDb.all(`
|
||||
SELECT id, name, email, password_digest, language, role_id, verified, status, provider, external_id
|
||||
FROM users
|
||||
ORDER BY created_at ASC
|
||||
`);
|
||||
const localUsers = glUsers.filter(u => u.provider === 'greenlight');
|
||||
const oidcUsers = glUsers.filter(u => u.provider !== 'greenlight' && u.provider !== null);
|
||||
info(`Found ${localUsers.length} local user(s), ${oidcUsers.length} OIDC/SSO user(s)`);
|
||||
log();
|
||||
|
||||
// ── 3. Migrate local users ─────────────────────────────────────────────
|
||||
log(`${c.bold}Local Users${c.reset}`);
|
||||
const userIdMap = new Map(); // gl user id → rl user id
|
||||
let usersCreated = 0, usersSkipped = 0;
|
||||
|
||||
for (const u of localUsers) {
|
||||
if (!u.password_digest) {
|
||||
warn(`${u.email} — no password, skipping`);
|
||||
usersSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = adminRoleIds.has(u.role_id) ? 'admin' : 'user';
|
||||
const emailVerified = u.verified ? 1 : 0;
|
||||
const lang = u.language || 'de';
|
||||
const displayName = u.name || '';
|
||||
|
||||
const existing = await rlDb.get('SELECT id FROM users WHERE email = ?', [u.email]);
|
||||
|
||||
if (existing) {
|
||||
userIdMap.set(u.id, existing.id);
|
||||
skip(`${u.email} — already exists (id=${existing.id}), skipping`);
|
||||
usersSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
loud(`INSERT user ${u.email} (role=${role})`);
|
||||
if (!DRY_RUN) {
|
||||
const result = await rlDb.run(
|
||||
`INSERT INTO users (name, display_name, email, password_hash, role, language, email_verified)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[displayName, displayName, u.email, u.password_digest, role, lang, emailVerified]
|
||||
);
|
||||
userIdMap.set(u.id, result.lastInsertRowid);
|
||||
ok(`${u.email} (${role})`);
|
||||
} else {
|
||||
ok(`[dry] ${u.email} (${role})`);
|
||||
}
|
||||
usersCreated++;
|
||||
}
|
||||
|
||||
log();
|
||||
log(` Created: ${c.green}${usersCreated}${c.reset} Skipped: ${usersSkipped}`);
|
||||
log();
|
||||
|
||||
// ── 3b. Migrate OIDC / SSO users ───────────────────────────────────────
|
||||
log(`${c.bold}OIDC / SSO Users${c.reset}`);
|
||||
let oidcCreated = 0, oidcSkipped = 0;
|
||||
|
||||
for (const u of oidcUsers) {
|
||||
const role = adminRoleIds.has(u.role_id) ? 'admin' : 'user';
|
||||
const lang = u.language || 'de';
|
||||
const displayName = u.name || '';
|
||||
const email = (u.email || '').toLowerCase().trim();
|
||||
if (!email) {
|
||||
warn(`OIDC user id=${u.id} — no email, skipping`);
|
||||
oidcSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await rlDb.get('SELECT id FROM users WHERE email = ?', [email]);
|
||||
|
||||
if (existing) {
|
||||
userIdMap.set(u.id, existing.id);
|
||||
// Link OAuth provider if not set yet
|
||||
if (!DRY_RUN && u.external_id) {
|
||||
await rlDb.run(
|
||||
'UPDATE users SET oauth_provider = ?, oauth_provider_id = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND oauth_provider IS NULL',
|
||||
['oidc', u.external_id, existing.id]
|
||||
);
|
||||
}
|
||||
skip(`${email} — already exists (id=${existing.id}), linked OIDC`);
|
||||
oidcSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate a random unusable password hash for OIDC users
|
||||
const randomHash = `oauth:${crypto.randomUUID()}`;
|
||||
|
||||
loud(`INSERT OIDC user ${email} (role=${role}, sub=${u.external_id || '?'})`);
|
||||
if (!DRY_RUN) {
|
||||
const result = await rlDb.run(
|
||||
`INSERT INTO users (name, display_name, email, password_hash, role, language, email_verified, oauth_provider, oauth_provider_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`,
|
||||
[displayName, displayName, email, randomHash, role, lang, 'oidc', u.external_id || null]
|
||||
);
|
||||
userIdMap.set(u.id, result.lastInsertRowid);
|
||||
ok(`${email} (${role}, OIDC)`);
|
||||
} else {
|
||||
ok(`[dry] ${email} (${role}, OIDC)`);
|
||||
}
|
||||
oidcCreated++;
|
||||
}
|
||||
|
||||
log();
|
||||
log(` Created: ${c.green}${oidcCreated}${c.reset} Skipped: ${oidcSkipped}`);
|
||||
log();
|
||||
|
||||
if (SKIP_ROOMS) {
|
||||
log(`${c.yellow}--skip-rooms set, stopping here.${c.reset}`);
|
||||
await cleanup(glDb, rlDb);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 4. Load Greenlight rooms + meeting options ─────────────────────────
|
||||
log(`${c.bold}Rooms${c.reset}`);
|
||||
|
||||
const glRooms = await glDb.all(`
|
||||
SELECT r.id, r.name, r.meeting_id, r.user_id
|
||||
FROM rooms r
|
||||
ORDER BY r.created_at ASC
|
||||
`);
|
||||
|
||||
// Load all meeting options (key name list)
|
||||
const meetingOptions = await glDb.all('SELECT id, name FROM meeting_options');
|
||||
const optionIdToName = new Map(meetingOptions.map(m => [m.id, m.name]));
|
||||
|
||||
// Load all room_meeting_option values in one shot
|
||||
const roomOptionRows = await glDb.all('SELECT room_id, meeting_option_id, value FROM room_meeting_options');
|
||||
const roomOptions = new Map(); // room_id → { optionName: value }
|
||||
for (const row of roomOptionRows) {
|
||||
const name = optionIdToName.get(row.meeting_option_id);
|
||||
if (!name) continue;
|
||||
if (!roomOptions.has(row.room_id)) roomOptions.set(row.room_id, {});
|
||||
roomOptions.get(row.room_id)[name] = row.value;
|
||||
}
|
||||
|
||||
info(`Found ${glRooms.length} room(s) across all users`);
|
||||
log();
|
||||
|
||||
let roomsCreated = 0, roomsSkipped = 0;
|
||||
const roomIdMap = new Map(); // gl room id → rl room id
|
||||
|
||||
for (const room of glRooms) {
|
||||
// Determine the redlight owner
|
||||
const rlUserId = userIdMap.get(room.user_id);
|
||||
if (!rlUserId && !DRY_RUN) {
|
||||
// Try to look up the email in redlight directly in case user already existed
|
||||
const glUser = glUsers.find(u => u.id === room.user_id);
|
||||
if (glUser) {
|
||||
const ex = await rlDb.get('SELECT id FROM users WHERE email = ?', [glUser.email]);
|
||||
if (ex) {
|
||||
userIdMap.set(room.user_id, ex.id);
|
||||
} else {
|
||||
warn(`Room "${room.name}" — owner not found in Redlight, skipping`);
|
||||
roomsSkipped++;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
warn(`Room "${room.name}" — owner not found in Greenlight users, skipping`);
|
||||
roomsSkipped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const ownerId = userIdMap.get(room.user_id) || null;
|
||||
|
||||
// Use Greenlight meeting_id as the uid (preserves BBB meeting identity)
|
||||
// Greenlight meeting_ids can be longer, but Redlight stores uid as TEXT — no problem.
|
||||
const uid = room.meeting_id;
|
||||
|
||||
// Check if already in Redlight
|
||||
const existingRoom = await rlDb.get('SELECT id FROM rooms WHERE uid = ?', [uid]);
|
||||
if (existingRoom) {
|
||||
roomIdMap.set(room.id, existingRoom.id);
|
||||
skip(`"${room.name}" (${uid.substring(0, 12)}…) — already exists`);
|
||||
roomsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Map meeting options to Redlight fields
|
||||
const opts = roomOptions.get(room.id) || {};
|
||||
|
||||
const mute_on_join = opts.muteOnStart ? boolOption(opts.muteOnStart) : 1;
|
||||
const record_meeting = opts.record ? boolOption(opts.record) : 1;
|
||||
const anyone_can_start = opts.glAnyoneCanStart ? boolOption(opts.glAnyoneCanStart) : 0;
|
||||
const all_join_moderator = opts.glAnyoneJoinAsModerator ? boolOption(opts.glAnyoneJoinAsModerator): 0;
|
||||
const require_approval = opts.guestPolicy === 'ASK_MODERATOR' ? 1 : 0;
|
||||
const access_code = (opts.glViewerAccessCode && opts.glViewerAccessCode !== 'false')
|
||||
? opts.glViewerAccessCode : null;
|
||||
const moderator_code = (opts.glModeratorAccessCode && opts.glModeratorAccessCode !== 'false')
|
||||
? opts.glModeratorAccessCode : null;
|
||||
const guest_access = 1; // Default open like Greenlight
|
||||
|
||||
// Ensure room name meets Redlight 2-char minimum
|
||||
const roomName = (room.name || 'Room').length >= 2 ? room.name : (room.name || 'Room').padEnd(2, ' ');
|
||||
|
||||
loud(`INSERT room "${roomName}" uid=${uid.substring(0, 16)}… owner=${ownerId}`);
|
||||
|
||||
if (!DRY_RUN && ownerId) {
|
||||
const result = await rlDb.run(
|
||||
`INSERT INTO rooms (uid, name, user_id, mute_on_join, record_meeting, anyone_can_start,
|
||||
all_join_moderator, require_approval, access_code, moderator_code, guest_access)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[uid, roomName, ownerId, mute_on_join, record_meeting, anyone_can_start,
|
||||
all_join_moderator, require_approval, access_code, moderator_code, guest_access]
|
||||
);
|
||||
roomIdMap.set(room.id, result.lastInsertRowid);
|
||||
const optSummary = [
|
||||
mute_on_join ? 'muted' : null,
|
||||
record_meeting ? 'record' : null,
|
||||
anyone_can_start ? 'anyStart' : null,
|
||||
all_join_moderator ? 'allMod' : null,
|
||||
require_approval ? 'approval' : null,
|
||||
access_code ? 'code:'+access_code.substring(0,4)+'…' : null,
|
||||
].filter(Boolean).join(', ');
|
||||
ok(`"${roomName}"${optSummary ? ` [${optSummary}]` : ''}`);
|
||||
} else if (DRY_RUN) {
|
||||
ok(`[dry] "${roomName}" (uid=${uid.substring(0, 16)}…)`);
|
||||
} else {
|
||||
warn(`"${roomName}" — skipped (no owner resolved)`);
|
||||
roomsSkipped++;
|
||||
continue;
|
||||
}
|
||||
roomsCreated++;
|
||||
}
|
||||
|
||||
log();
|
||||
log(` Created: ${c.green}${roomsCreated}${c.reset} Skipped: ${roomsSkipped}`);
|
||||
log();
|
||||
|
||||
// ── 5. Migrate shared_accesses ─────────────────────────────────────────
|
||||
if (!SKIP_SHARES) {
|
||||
log(`${c.bold}Room Shares${c.reset}`);
|
||||
const shares = await glDb.all('SELECT user_id, room_id FROM shared_accesses');
|
||||
info(`Found ${shares.length} shared accesse(s)`);
|
||||
log();
|
||||
|
||||
let sharesCreated = 0, sharesSkipped = 0;
|
||||
for (const s of shares) {
|
||||
const rlUser = userIdMap.get(s.user_id);
|
||||
const rlRoom = roomIdMap.get(s.room_id);
|
||||
|
||||
if (!rlUser || !rlRoom) {
|
||||
loud(`Skip share user=${s.user_id} room=${s.room_id} — not found in target`);
|
||||
sharesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const exists = await rlDb.get(
|
||||
'SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?',
|
||||
[rlRoom, rlUser]
|
||||
);
|
||||
if (exists) {
|
||||
sharesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!DRY_RUN) {
|
||||
await rlDb.run(
|
||||
'INSERT INTO room_shares (room_id, user_id) VALUES (?, ?)',
|
||||
[rlRoom, rlUser]
|
||||
);
|
||||
ok(`Share room_id=${rlRoom} → user_id=${rlUser}`);
|
||||
} else {
|
||||
ok(`[dry] Share room_id=${rlRoom} → user_id=${rlUser}`);
|
||||
}
|
||||
sharesCreated++;
|
||||
}
|
||||
log();
|
||||
log(` Created: ${c.green}${sharesCreated}${c.reset} Skipped: ${sharesSkipped}`);
|
||||
log();
|
||||
}
|
||||
|
||||
// ── 6. Migrate site_settings / branding ────────────────────────────────
|
||||
if (!SKIP_SETTINGS) {
|
||||
log(`${c.bold}Site Settings & Branding${c.reset}`);
|
||||
let settingsCount = 0;
|
||||
|
||||
// Check if site_settings table exists in Greenlight
|
||||
let hasSiteSettings = false;
|
||||
try {
|
||||
await glDb.get('SELECT 1 FROM site_settings LIMIT 1');
|
||||
hasSiteSettings = true;
|
||||
} catch {
|
||||
warn('No site_settings table found in Greenlight DB — skipping settings migration');
|
||||
}
|
||||
|
||||
if (hasSiteSettings) {
|
||||
const glSettings = await glDb.all('SELECT setting, value FROM site_settings');
|
||||
const settingsMap = new Map(glSettings.map(s => [s.setting, s.value]));
|
||||
info(`Found ${glSettings.length} site_setting(s) in Greenlight`);
|
||||
|
||||
// ── Registration mode ──────────────────────────────────────────────
|
||||
const regMethod = settingsMap.get('RegistrationMethod');
|
||||
if (regMethod) {
|
||||
// Greenlight: "open", "invite", "approval" → Redlight: "open" or "invite"
|
||||
const mode = regMethod === 'open' ? 'open' : 'invite';
|
||||
if (!DRY_RUN) await upsertSetting(rlDb, 'registration_mode', mode, isPostgresTarget);
|
||||
ok(`registration_mode → ${mode} (was: ${regMethod})`);
|
||||
settingsCount++;
|
||||
}
|
||||
|
||||
// ── Privacy policy URL ─────────────────────────────────────────────
|
||||
const privacy = settingsMap.get('PrivacyPolicy');
|
||||
if (privacy && privacy.trim()) {
|
||||
if (!DRY_RUN) await upsertSetting(rlDb, 'privacy_url', privacy.trim(), isPostgresTarget);
|
||||
ok(`privacy_url → ${privacy.trim()}`);
|
||||
settingsCount++;
|
||||
}
|
||||
|
||||
// ── Terms / Imprint URL ────────────────────────────────────────────
|
||||
const terms = settingsMap.get('Terms');
|
||||
if (terms && terms.trim()) {
|
||||
if (!DRY_RUN) await upsertSetting(rlDb, 'imprint_url', terms.trim(), isPostgresTarget);
|
||||
ok(`imprint_url → ${terms.trim()}`);
|
||||
settingsCount++;
|
||||
}
|
||||
|
||||
// ── Primary color (informational only — Redlight uses themes) ─────
|
||||
const primaryColor = settingsMap.get('PrimaryColor');
|
||||
if (primaryColor) {
|
||||
info(`PrimaryColor in Greenlight was: ${primaryColor} (not mapped — Redlight uses themes)`);
|
||||
}
|
||||
|
||||
// ── Branding image / logo ──────────────────────────────────────────
|
||||
const brandingImage = settingsMap.get('BrandingImage');
|
||||
if (brandingImage && brandingImage.trim()) {
|
||||
const logoUrl = brandingImage.trim();
|
||||
info(`BrandingImage URL: ${logoUrl}`);
|
||||
|
||||
if (!DRY_RUN) {
|
||||
try {
|
||||
const response = await fetch(logoUrl, { signal: AbortSignal.timeout(15_000) });
|
||||
if (response.ok) {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
let ext = '.png';
|
||||
if (contentType.includes('svg')) ext = '.svg';
|
||||
else if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = '.jpg';
|
||||
else if (contentType.includes('gif')) ext = '.gif';
|
||||
else if (contentType.includes('webp')) ext = '.webp';
|
||||
else if (contentType.includes('ico')) ext = '.ico';
|
||||
|
||||
const brandingDir = path.join(__dirname, 'uploads', 'branding');
|
||||
fs.mkdirSync(brandingDir, { recursive: true });
|
||||
|
||||
// Remove old logos
|
||||
if (fs.existsSync(brandingDir)) {
|
||||
for (const f of fs.readdirSync(brandingDir)) {
|
||||
if (f.startsWith('logo.')) fs.unlinkSync(path.join(brandingDir, f));
|
||||
}
|
||||
}
|
||||
|
||||
const logoPath = path.join(brandingDir, `logo${ext}`);
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
fs.writeFileSync(logoPath, buffer);
|
||||
ok(`Logo saved → uploads/branding/logo${ext} (${(buffer.length / 1024).toFixed(1)} KB)`);
|
||||
settingsCount++;
|
||||
} else {
|
||||
warn(`Could not download logo (HTTP ${response.status}) — skipping`);
|
||||
}
|
||||
} catch (dlErr) {
|
||||
warn(`Logo download failed: ${dlErr.message}`);
|
||||
}
|
||||
} else {
|
||||
ok(`[dry] Would download logo from ${logoUrl}`);
|
||||
settingsCount++;
|
||||
}
|
||||
}
|
||||
|
||||
log();
|
||||
log(` Settings migrated: ${c.green}${settingsCount}${c.reset}`);
|
||||
log();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 7. Migrate OAuth / OIDC configuration ──────────────────────────────
|
||||
if (!SKIP_OAUTH) {
|
||||
log(`${c.bold}OAuth / OIDC Configuration${c.reset}`);
|
||||
|
||||
const issuer = process.env.GL_OIDC_ISSUER;
|
||||
const clientId = process.env.GL_OIDC_CLIENT_ID;
|
||||
const clientSecret = process.env.GL_OIDC_CLIENT_SECRET;
|
||||
const displayName = process.env.GL_OIDC_DISPLAY_NAME || 'SSO';
|
||||
|
||||
if (issuer && clientId && clientSecret) {
|
||||
if (!process.env.JWT_SECRET) {
|
||||
warn('JWT_SECRET is not set — cannot encrypt client secret. Skipping OAuth migration.');
|
||||
} else {
|
||||
// Check if already configured
|
||||
const existing = await rlDb.get("SELECT key FROM settings WHERE key = 'oauth_config'");
|
||||
if (existing) {
|
||||
warn('oauth_config already exists in Redlight — skipping (delete it first to re-migrate)');
|
||||
} else {
|
||||
info(`Issuer: ${issuer}`);
|
||||
info(`Client ID: ${clientId}`);
|
||||
info(`Display name: ${displayName}`);
|
||||
|
||||
if (!DRY_RUN) {
|
||||
const config = JSON.stringify({
|
||||
issuer,
|
||||
clientId,
|
||||
encryptedSecret: encryptSecret(clientSecret),
|
||||
displayName,
|
||||
autoRegister: true,
|
||||
});
|
||||
await upsertSetting(rlDb, 'oauth_config', config, isPostgresTarget);
|
||||
ok('OAuth/OIDC configuration saved');
|
||||
} else {
|
||||
ok('[dry] Would save OAuth/OIDC configuration');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (issuer || clientId || clientSecret) {
|
||||
warn('Incomplete OIDC config — set GL_OIDC_ISSUER, GL_OIDC_CLIENT_ID, and GL_OIDC_CLIENT_SECRET');
|
||||
} else {
|
||||
info('No GL_OIDC_* env vars set — skipping OAuth migration');
|
||||
info('Set GL_OIDC_ISSUER, GL_OIDC_CLIENT_ID, GL_OIDC_CLIENT_SECRET to migrate OIDC config');
|
||||
}
|
||||
log();
|
||||
}
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────────────────
|
||||
log(`${c.bold}${c.green}Migration complete${c.reset}`);
|
||||
log();
|
||||
log(` Local users migrated: ${c.green}${usersCreated}${c.reset}`);
|
||||
log(` OIDC users migrated: ${c.green}${oidcCreated}${c.reset}`);
|
||||
log(` Rooms migrated: ${c.green}${roomsCreated}${c.reset}`);
|
||||
if (!SKIP_SHARES) log(` Shares migrated: (see above)`);
|
||||
if (!SKIP_SETTINGS) log(` Settings migrated: (see above)`);
|
||||
if (!SKIP_OAUTH) log(` OAuth config: (see above)`);
|
||||
if (DRY_RUN) {
|
||||
log();
|
||||
log(` ${c.yellow}${c.bold}This was a DRY RUN — rerun without --dry-run to apply.${c.reset}`);
|
||||
}
|
||||
log();
|
||||
|
||||
await cleanup(glDb, rlDb);
|
||||
}
|
||||
|
||||
async function cleanup(glDb, rlDb) {
|
||||
try { await glDb.end?.(); } catch {}
|
||||
try { await rlDb.end?.(); rlDb.close?.(); } catch {}
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error(`\n${c.red}Fatal error:${c.reset}`, e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
3521
package-lock.json
generated
3521
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "redlight",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"version": "2.1.2",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n client,server -c blue,green \"vite\" \"node --watch server/index.js\"",
|
||||
@@ -13,17 +14,25 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"concurrently": "^9.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"flatpickr": "^4.6.13",
|
||||
"ioredis": "^5.10.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"multer": "^2.0.2",
|
||||
"multer": "^2.1.0",
|
||||
"nodemailer": "^8.0.1",
|
||||
"otpauth": "^9.5.0",
|
||||
"pdfkit": "^0.17.2",
|
||||
"pg": "^8.18.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"rate-limit-redis": "^4.3.1",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
@@ -34,10 +43,10 @@
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vite": "^5.4.0"
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
13
public/sounds/README.md
Normal file
13
public/sounds/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Notification Sound
|
||||
|
||||
Pop-up Sound by BeezleFM -- https://freesound.org/s/512135/ -- License: Attribution 4.0
|
||||
|
||||
Place your notification sound file here as:
|
||||
|
||||
`notification.mp3`
|
||||
|
||||
The file is served at `/sounds/notification.mp3` and played automatically
|
||||
whenever a new in-app notification arrives.
|
||||
|
||||
Supported formats: MP3, OGG, WAV — MP3 recommended for broadest browser support.
|
||||
Keep the file short (< 2 s) and not too loud.
|
||||
BIN
public/sounds/meeting-started.mp3
Normal file
BIN
public/sounds/meeting-started.mp3
Normal file
Binary file not shown.
BIN
public/sounds/notification.mp3
Normal file
BIN
public/sounds/notification.mp3
Normal file
Binary file not shown.
@@ -1,9 +1,24 @@
|
||||
import crypto from 'crypto';
|
||||
import crypto from 'crypto';
|
||||
import xml2js from 'xml2js';
|
||||
import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParams } from './logger.js';
|
||||
|
||||
const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/';
|
||||
const BBB_SECRET = process.env.BBB_SECRET || '';
|
||||
|
||||
if (!BBB_SECRET) {
|
||||
log.bbb.warn('WARNING: BBB_SECRET is not set. BBB API calls will use an empty secret.');
|
||||
}
|
||||
|
||||
// HTML-escape for safe embedding in BBB welcome messages
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function getChecksum(apiCall, params) {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const raw = apiCall + queryString + BBB_SECRET;
|
||||
@@ -16,18 +31,37 @@ function buildUrl(apiCall, params = {}) {
|
||||
return `${BBB_URL}${apiCall}?${queryString}`;
|
||||
}
|
||||
|
||||
async function apiCall(apiCallName, params = {}) {
|
||||
async function apiCall(apiCallName, params = {}, xmlBody = null) {
|
||||
const url = buildUrl(apiCallName, params);
|
||||
const method = xmlBody ? 'POST' : 'GET';
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const fetchOptions = xmlBody
|
||||
? { method: 'POST', headers: { 'Content-Type': 'application/xml' }, body: xmlBody }
|
||||
: {};
|
||||
const response = await fetch(url, fetchOptions);
|
||||
const duration = Date.now() - start;
|
||||
const xml = await response.text();
|
||||
const result = await xml2js.parseStringPromise(xml, {
|
||||
explicitArray: false,
|
||||
trim: true,
|
||||
});
|
||||
|
||||
const returncode = result?.response?.returncode || '-';
|
||||
const paramStr = sanitizeBBBParams(params);
|
||||
|
||||
// Greenlight-style: method action → status returncode (duration) params
|
||||
log.bbb.info(
|
||||
`${fmtMethod(method)} ${apiCallName} → ${fmtStatus(response.status)} ${fmtReturncode(returncode)} (${fmtDuration(duration)}) ${paramStr}`
|
||||
);
|
||||
|
||||
return result.response;
|
||||
} catch (error) {
|
||||
console.error(`BBB API error (${apiCallName}):`, error.message);
|
||||
const duration = Date.now() - start;
|
||||
log.bbb.error(
|
||||
`${fmtMethod(method)} ${apiCallName} ✗ FAILED (${fmtDuration(duration)}) ${error.message}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -39,22 +73,22 @@ function getRoomPasswords(uid) {
|
||||
return { moderatorPW: modPw, attendeePW: attPw };
|
||||
}
|
||||
|
||||
export async function createMeeting(room, logoutURL) {
|
||||
export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null, analyticsCallbackURL = null) {
|
||||
const { moderatorPW, attendeePW } = getRoomPasswords(room.uid);
|
||||
|
||||
// Build welcome message with guest invite link
|
||||
let welcome = room.welcome_message || 'Willkommen!';
|
||||
// HTML-escape user-controlled content to prevent stored XSS via BBB
|
||||
let welcome = room.welcome_message ? escapeHtml(room.welcome_message) : t('defaultWelcome');
|
||||
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>`;
|
||||
}
|
||||
welcome += `<br><br>To invite other participants, share this link:<br><a href="${escapeHtml(guestLink)}">${escapeHtml(guestLink)}</a>`;
|
||||
// Access code is intentionally NOT shown in the welcome message to prevent
|
||||
// leaking it to all meeting participants.
|
||||
}
|
||||
|
||||
const params = {
|
||||
meetingID: room.uid,
|
||||
name: room.name,
|
||||
name: room.name.length >= 2 ? room.name : room.name.padEnd(2, ' '),
|
||||
attendeePW,
|
||||
moderatorPW,
|
||||
welcome,
|
||||
@@ -68,13 +102,31 @@ export async function createMeeting(room, logoutURL) {
|
||||
if (logoutURL) {
|
||||
params.logoutURL = logoutURL;
|
||||
}
|
||||
if (loginURL) {
|
||||
params.loginURL = loginURL;
|
||||
}
|
||||
if (room.max_participants > 0) {
|
||||
params.maxParticipants = room.max_participants.toString();
|
||||
}
|
||||
if (room.access_code) {
|
||||
params.lockSettingsLockOnJoin = 'true';
|
||||
}
|
||||
return apiCall('create', params);
|
||||
if (analyticsCallbackURL) {
|
||||
params['meta_analytics-callback-url'] = analyticsCallbackURL;
|
||||
}
|
||||
|
||||
// Build optional presentation XML body - escape URL to prevent XML injection
|
||||
let xmlBody = null;
|
||||
if (presentationUrl) {
|
||||
const safeUrl = presentationUrl
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
xmlBody = `<modules><module name="presentation"><document url="${safeUrl}" /></module></modules>`;
|
||||
}
|
||||
|
||||
return apiCall('create', params, xmlBody);
|
||||
}
|
||||
|
||||
export async function joinMeeting(uid, name, isModerator = false, avatarURL = null) {
|
||||
@@ -120,6 +172,17 @@ export async function getRecordings(meetingID) {
|
||||
return Array.isArray(recordings) ? recordings : [recordings];
|
||||
}
|
||||
|
||||
export async function getRecordingByRecordId(recordID) {
|
||||
const result = await apiCall('getRecordings', { recordID });
|
||||
if (result.returncode !== 'SUCCESS' || !result.recordings) {
|
||||
return null;
|
||||
}
|
||||
const recordings = result.recordings.recording;
|
||||
if (!recordings) return null;
|
||||
const arr = Array.isArray(recordings) ? recordings : [recordings];
|
||||
return arr[0] || null;
|
||||
}
|
||||
|
||||
export async function deleteRecording(recordID) {
|
||||
return apiCall('deleteRecordings', { recordID });
|
||||
}
|
||||
@@ -128,4 +191,8 @@ export async function publishRecording(recordID, publish) {
|
||||
return apiCall('publishRecordings', { recordID, publish: publish ? 'true' : 'false' });
|
||||
}
|
||||
|
||||
export function getAnalyticsToken(uid) {
|
||||
return crypto.createHmac('sha256', BBB_SECRET).update('analytics_' + uid).digest('hex');
|
||||
}
|
||||
|
||||
export { getRoomPasswords };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { log } from './logger.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -48,6 +49,12 @@ class SqliteAdapter {
|
||||
return !!columns.find(c => c.name === column);
|
||||
}
|
||||
|
||||
async columnIsNullable(table, column) {
|
||||
const columns = this.db.pragma(`table_info(${table})`);
|
||||
const col = columns.find(c => c.name === column);
|
||||
return col ? col.notnull === 0 : true;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.db.close();
|
||||
}
|
||||
@@ -76,7 +83,9 @@ class PostgresAdapter {
|
||||
let pgSql = convertPlaceholders(sql);
|
||||
const isInsert = /^\s*INSERT/i.test(pgSql);
|
||||
if (isInsert && !/RETURNING/i.test(pgSql)) {
|
||||
pgSql += ' RETURNING id';
|
||||
// Some tables (e.g. settings, oauth_states) have no "id" column.
|
||||
// Return the inserted row generically and read id only when present.
|
||||
pgSql += ' RETURNING *';
|
||||
}
|
||||
const result = await this.pool.query(pgSql, params);
|
||||
return {
|
||||
@@ -97,6 +106,14 @@ class PostgresAdapter {
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
async columnIsNullable(table, column) {
|
||||
const result = await this.pool.query(
|
||||
'SELECT is_nullable FROM information_schema.columns WHERE table_name = $1 AND column_name = $2',
|
||||
[table, column]
|
||||
);
|
||||
return result.rows.length > 0 ? result.rows[0].is_nullable === 'YES' : true;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.pool?.end();
|
||||
}
|
||||
@@ -105,7 +122,7 @@ class PostgresAdapter {
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
export function getDb() {
|
||||
if (!db) {
|
||||
throw new Error('Database not initialised – call initDatabase() first');
|
||||
throw new Error('Database not initialised - call initDatabase() first');
|
||||
}
|
||||
return db;
|
||||
}
|
||||
@@ -113,10 +130,10 @@ export function getDb() {
|
||||
export async function initDatabase() {
|
||||
// Create the right adapter
|
||||
if (isPostgres) {
|
||||
console.log('📦 Using PostgreSQL database');
|
||||
log.db.info('Using PostgreSQL database');
|
||||
db = new PostgresAdapter();
|
||||
} else {
|
||||
console.log('📦 Using SQLite database');
|
||||
log.db.info('Using SQLite database');
|
||||
db = new SqliteAdapter();
|
||||
}
|
||||
await db.init();
|
||||
@@ -127,6 +144,7 @@ export async function initDatabase() {
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
display_name TEXT DEFAULT '',
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT DEFAULT 'user' CHECK(role IN ('user', 'admin')),
|
||||
@@ -137,6 +155,7 @@ export async function initDatabase() {
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
verification_token TEXT,
|
||||
verification_token_expires TIMESTAMP,
|
||||
verification_resend_at TIMESTAMP DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
@@ -156,6 +175,7 @@ export async function initDatabase() {
|
||||
record_meeting INTEGER DEFAULT 1,
|
||||
guest_access INTEGER DEFAULT 0,
|
||||
moderator_code TEXT,
|
||||
presentation_file TEXT DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
@@ -179,12 +199,41 @@ export async function initDatabase() {
|
||||
value TEXT,
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federation_invitations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
invite_id TEXT UNIQUE NOT NULL,
|
||||
from_user TEXT NOT NULL,
|
||||
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
room_name TEXT NOT NULL,
|
||||
message TEXT,
|
||||
join_url TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_fed_inv_to_user ON federation_invitations(to_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fed_inv_invite_id ON federation_invitations(invite_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federated_rooms (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
invite_id TEXT UNIQUE NOT NULL,
|
||||
room_name TEXT NOT NULL,
|
||||
from_user TEXT NOT NULL,
|
||||
join_url TEXT NOT NULL,
|
||||
meet_id TEXT,
|
||||
max_participants INTEGER DEFAULT 0,
|
||||
allow_recording INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_fed_rooms_user_id ON federated_rooms(user_id);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
display_name TEXT DEFAULT '',
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT DEFAULT 'user' CHECK(role IN ('user', 'admin')),
|
||||
@@ -195,6 +244,7 @@ export async function initDatabase() {
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
verification_token TEXT,
|
||||
verification_token_expires DATETIME,
|
||||
verification_resend_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -214,6 +264,7 @@ export async function initDatabase() {
|
||||
record_meeting INTEGER DEFAULT 1,
|
||||
guest_access INTEGER DEFAULT 0,
|
||||
moderator_code TEXT,
|
||||
presentation_file TEXT DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
@@ -240,6 +291,37 @@ export async function initDatabase() {
|
||||
value TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federation_invitations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
invite_id TEXT UNIQUE NOT NULL,
|
||||
from_user TEXT NOT NULL,
|
||||
to_user_id INTEGER NOT NULL,
|
||||
room_name TEXT NOT NULL,
|
||||
message TEXT,
|
||||
join_url TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fed_inv_to_user ON federation_invitations(to_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fed_inv_invite_id ON federation_invitations(invite_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federated_rooms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
invite_id TEXT UNIQUE NOT NULL,
|
||||
room_name TEXT NOT NULL,
|
||||
from_user TEXT NOT NULL,
|
||||
join_url TEXT NOT NULL,
|
||||
meet_id TEXT,
|
||||
max_participants INTEGER DEFAULT 0,
|
||||
allow_recording INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_fed_rooms_user_id ON federated_rooms(user_id);
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -266,18 +348,490 @@ export async function initDatabase() {
|
||||
await db.exec('ALTER TABLE users ADD COLUMN verification_token_expires DATETIME');
|
||||
}
|
||||
}
|
||||
if (!(await db.columnExists('federated_rooms', 'meet_id'))) {
|
||||
await db.exec('ALTER TABLE federated_rooms ADD COLUMN meet_id TEXT');
|
||||
}
|
||||
if (!(await db.columnExists('federated_rooms', 'max_participants'))) {
|
||||
await db.exec('ALTER TABLE federated_rooms ADD COLUMN max_participants INTEGER DEFAULT 0');
|
||||
}
|
||||
if (!(await db.columnExists('federated_rooms', 'allow_recording'))) {
|
||||
await db.exec('ALTER TABLE federated_rooms ADD COLUMN allow_recording INTEGER DEFAULT 1');
|
||||
}
|
||||
if (!(await db.columnExists('federation_invitations', 'room_uid'))) {
|
||||
await db.exec('ALTER TABLE federation_invitations ADD COLUMN room_uid TEXT');
|
||||
}
|
||||
if (!(await db.columnExists('federation_invitations', 'max_participants'))) {
|
||||
await db.exec('ALTER TABLE federation_invitations ADD COLUMN max_participants INTEGER DEFAULT 0');
|
||||
}
|
||||
if (!(await db.columnExists('federation_invitations', 'allow_recording'))) {
|
||||
await db.exec('ALTER TABLE federation_invitations ADD COLUMN allow_recording INTEGER DEFAULT 1');
|
||||
}
|
||||
if (!(await db.columnExists('users', 'display_name'))) {
|
||||
await db.exec("ALTER TABLE users ADD COLUMN display_name TEXT DEFAULT ''");
|
||||
await db.exec("UPDATE users SET display_name = name WHERE display_name = ''");
|
||||
}
|
||||
if (!(await db.columnExists('rooms', 'presentation_file'))) {
|
||||
await db.exec('ALTER TABLE rooms ADD COLUMN presentation_file TEXT DEFAULT NULL');
|
||||
}
|
||||
if (!(await db.columnExists('rooms', 'presentation_name'))) {
|
||||
await db.exec('ALTER TABLE rooms ADD COLUMN presentation_name TEXT DEFAULT NULL');
|
||||
}
|
||||
if (!(await db.columnExists('users', 'verification_resend_at'))) {
|
||||
if (isPostgres) {
|
||||
await db.exec('ALTER TABLE users ADD COLUMN verification_resend_at TIMESTAMP DEFAULT NULL');
|
||||
} else {
|
||||
await db.exec('ALTER TABLE users ADD COLUMN verification_resend_at DATETIME DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Default admin ───────────────────────────────────────────────────────
|
||||
// Federation sync: add deleted + updated_at to federated_rooms
|
||||
if (!(await db.columnExists('federated_rooms', 'deleted'))) {
|
||||
await db.exec('ALTER TABLE federated_rooms ADD COLUMN deleted INTEGER DEFAULT 0');
|
||||
}
|
||||
if (!(await db.columnExists('federated_rooms', 'updated_at'))) {
|
||||
if (isPostgres) {
|
||||
await db.exec('ALTER TABLE federated_rooms ADD COLUMN updated_at TIMESTAMP DEFAULT NOW()');
|
||||
} else {
|
||||
await db.exec('ALTER TABLE federated_rooms ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP');
|
||||
}
|
||||
}
|
||||
|
||||
// Track outbound federation invites for deletion propagation
|
||||
if (isPostgres) {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS federation_outbound_invites (
|
||||
id SERIAL PRIMARY KEY,
|
||||
room_uid TEXT NOT NULL,
|
||||
remote_domain TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(room_uid, remote_domain)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_fed_out_room_uid ON federation_outbound_invites(room_uid);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS federation_outbound_invites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_uid TEXT NOT NULL,
|
||||
remote_domain TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(room_uid, remote_domain)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_fed_out_room_uid ON federation_outbound_invites(room_uid);
|
||||
`);
|
||||
}
|
||||
|
||||
// User invite tokens (invite-only registration)
|
||||
if (isPostgres) {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_invites (
|
||||
id SERIAL PRIMARY KEY,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
used_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
used_at TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_invites_token ON user_invites(token);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_invites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
created_by INTEGER NOT NULL,
|
||||
used_by INTEGER,
|
||||
used_at DATETIME,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_invites_token ON user_invites(token);
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Calendar tables ──────────────────────────────────────────────────────
|
||||
if (isPostgres) {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
uid TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
start_time TIMESTAMP NOT NULL,
|
||||
end_time TIMESTAMP NOT NULL,
|
||||
room_uid TEXT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
color TEXT DEFAULT '#6366f1',
|
||||
federated_from TEXT DEFAULT NULL,
|
||||
federated_join_url TEXT DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_events_user_id ON calendar_events(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_events_uid ON calendar_events(uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_events_start ON calendar_events(start_time);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_event_shares (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(event_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_shares_event ON calendar_event_shares(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_shares_user ON calendar_event_shares(user_id);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uid TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME NOT NULL,
|
||||
room_uid TEXT,
|
||||
user_id INTEGER NOT NULL,
|
||||
color TEXT DEFAULT '#6366f1',
|
||||
federated_from TEXT DEFAULT NULL,
|
||||
federated_join_url TEXT DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_events_user_id ON calendar_events(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_events_uid ON calendar_events(uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_events_start ON calendar_events(start_time);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_event_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(event_id, user_id),
|
||||
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_shares_event ON calendar_event_shares(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_shares_user ON calendar_event_shares(user_id);
|
||||
`);
|
||||
}
|
||||
|
||||
// Calendar migrations: add federated columns if missing
|
||||
if (!(await db.columnExists('calendar_events', 'federated_from'))) {
|
||||
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_from TEXT DEFAULT NULL');
|
||||
}
|
||||
if (!(await db.columnExists('calendar_events', 'federated_join_url'))) {
|
||||
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL');
|
||||
}
|
||||
if (!(await db.columnExists('calendar_events', 'reminder_minutes'))) {
|
||||
await db.exec('ALTER TABLE calendar_events ADD COLUMN reminder_minutes INTEGER DEFAULT NULL');
|
||||
}
|
||||
if (!(await db.columnExists('calendar_events', 'reminder_sent_at'))) {
|
||||
await db.exec(`ALTER TABLE calendar_events ADD COLUMN reminder_sent_at ${isPostgres ? 'TIMESTAMP' : 'DATETIME'} DEFAULT NULL`);
|
||||
}
|
||||
|
||||
// Calendar invitations (federated calendar events that must be accepted first)
|
||||
if (isPostgres) {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS calendar_invitations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_uid TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
start_time TIMESTAMP NOT NULL,
|
||||
end_time TIMESTAMP NOT NULL,
|
||||
room_uid TEXT,
|
||||
join_url TEXT,
|
||||
from_user TEXT NOT NULL,
|
||||
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
color TEXT DEFAULT '#6366f1',
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_inv_to_user ON calendar_invitations(to_user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_cal_inv_uid_user ON calendar_invitations(event_uid, to_user_id);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS calendar_invitations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_uid TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME NOT NULL,
|
||||
room_uid TEXT,
|
||||
join_url TEXT,
|
||||
from_user TEXT NOT NULL,
|
||||
to_user_id INTEGER NOT NULL,
|
||||
color TEXT DEFAULT '#6366f1',
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(event_uid, to_user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_inv_to_user ON calendar_invitations(to_user_id);
|
||||
`);
|
||||
}
|
||||
|
||||
// Track outbound calendar event federation sends for deletion propagation
|
||||
if (isPostgres) {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS calendar_event_outbound (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_uid TEXT NOT NULL,
|
||||
remote_domain TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(event_uid, remote_domain)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_out_event_uid ON calendar_event_outbound(event_uid);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS calendar_event_outbound (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_uid TEXT NOT NULL,
|
||||
remote_domain TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(event_uid, remote_domain)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_out_event_uid ON calendar_event_outbound(event_uid);
|
||||
`);
|
||||
}
|
||||
|
||||
// Local calendar event invitations (share-with-acceptance flow)
|
||||
if (isPostgres) {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS calendar_local_invitations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
|
||||
from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(event_id, to_user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_to_user ON calendar_local_invitations(to_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_event ON calendar_local_invitations(event_id);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS calendar_local_invitations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id INTEGER NOT NULL,
|
||||
from_user_id INTEGER NOT NULL,
|
||||
to_user_id INTEGER NOT NULL,
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(event_id, to_user_id),
|
||||
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_to_user ON calendar_local_invitations(to_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_event ON calendar_local_invitations(event_id);
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Notifications table ──────────────────────────────────────────────────
|
||||
if (isPostgres) {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT,
|
||||
link TEXT,
|
||||
read INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT,
|
||||
link TEXT,
|
||||
read INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
|
||||
`);
|
||||
}
|
||||
|
||||
// ── CalDAV tokens ────────────────────────────────────────────────────────
|
||||
if (isPostgres) {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS caldav_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
last_used_at TIMESTAMP DEFAULT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS caldav_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at DATETIME DEFAULT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token);
|
||||
`);
|
||||
}
|
||||
|
||||
// CalDAV: add token_hash column for SHA-256 hashed token lookup
|
||||
if (!(await db.columnExists('caldav_tokens', 'token_hash'))) {
|
||||
await db.exec('ALTER TABLE caldav_tokens ADD COLUMN token_hash TEXT DEFAULT NULL');
|
||||
await db.exec('CREATE INDEX IF NOT EXISTS idx_caldav_tokens_hash ON caldav_tokens(token_hash)');
|
||||
}
|
||||
|
||||
// CalDAV: make token column nullable (now only token_hash is stored for new tokens)
|
||||
if (!(await db.columnIsNullable('caldav_tokens', 'token'))) {
|
||||
if (isPostgres) {
|
||||
await db.exec('ALTER TABLE caldav_tokens ALTER COLUMN token DROP NOT NULL');
|
||||
} else {
|
||||
// SQLite does not support ALTER COLUMN — recreate the table
|
||||
await db.exec(`
|
||||
CREATE TABLE caldav_tokens_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
token TEXT UNIQUE,
|
||||
token_hash TEXT DEFAULT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at DATETIME DEFAULT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO caldav_tokens_new (id, user_id, token, token_hash, name, created_at, last_used_at)
|
||||
SELECT id, user_id, token, token_hash, name, created_at, last_used_at FROM caldav_tokens;
|
||||
DROP TABLE caldav_tokens;
|
||||
ALTER TABLE caldav_tokens_new RENAME TO caldav_tokens;
|
||||
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_hash ON caldav_tokens(token_hash);
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── OAuth tables ────────────────────────────────────────────────────────
|
||||
if (isPostgres) {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS oauth_states (
|
||||
state TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
code_verifier TEXT NOT NULL,
|
||||
return_to TEXT,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS oauth_states (
|
||||
state TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
code_verifier TEXT NOT NULL,
|
||||
return_to TEXT,
|
||||
expires_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at);
|
||||
`);
|
||||
}
|
||||
|
||||
// Add OAuth columns to users table
|
||||
if (!(await db.columnExists('users', 'oauth_provider'))) {
|
||||
await db.exec('ALTER TABLE users ADD COLUMN oauth_provider TEXT DEFAULT NULL');
|
||||
}
|
||||
if (!(await db.columnExists('users', 'oauth_provider_id'))) {
|
||||
await db.exec('ALTER TABLE users ADD COLUMN oauth_provider_id TEXT DEFAULT NULL');
|
||||
}
|
||||
|
||||
// ── Learning Analytics table ─────────────────────────────────────────────
|
||||
if (!(await db.columnExists('rooms', 'learning_analytics'))) {
|
||||
await db.exec('ALTER TABLE rooms ADD COLUMN learning_analytics INTEGER DEFAULT 0');
|
||||
}
|
||||
|
||||
if (isPostgres) {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS learning_analytics_data (
|
||||
id SERIAL PRIMARY KEY,
|
||||
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
meeting_id TEXT NOT NULL,
|
||||
meeting_name TEXT,
|
||||
data JSONB NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_room_id ON learning_analytics_data(room_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_meeting_id ON learning_analytics_data(meeting_id);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS learning_analytics_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER NOT NULL,
|
||||
meeting_id TEXT NOT NULL,
|
||||
meeting_name TEXT,
|
||||
data TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_room_id ON learning_analytics_data(room_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_meeting_id ON learning_analytics_data(meeting_id);
|
||||
`);
|
||||
}
|
||||
|
||||
// ── TOTP 2FA columns ──────────────────────────────────────────────────────
|
||||
if (!(await db.columnExists('users', 'totp_secret'))) {
|
||||
await db.exec('ALTER TABLE users ADD COLUMN totp_secret TEXT DEFAULT NULL');
|
||||
}
|
||||
if (!(await db.columnExists('users', 'totp_enabled'))) {
|
||||
await db.exec('ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0');
|
||||
}
|
||||
|
||||
// ── Analytics visibility setting ────────────────────────────────────────
|
||||
if (!(await db.columnExists('rooms', 'analytics_visibility'))) {
|
||||
await db.exec("ALTER TABLE rooms ADD COLUMN analytics_visibility TEXT DEFAULT 'owner'");
|
||||
}
|
||||
|
||||
// ── Default admin (only on very first start) ────────────────────────────
|
||||
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
|
||||
if (!adminAlreadySeeded) {
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
|
||||
const existingAdmin = await db.get('SELECT id FROM users WHERE email = ?', [adminEmail]);
|
||||
if (!existingAdmin) {
|
||||
// Check if admin already exists (upgrade from older version without the flag)
|
||||
const existing = await db.get('SELECT id FROM users WHERE email = ?', [adminEmail]);
|
||||
if (!existing) {
|
||||
const hash = bcrypt.hashSync(adminPassword, 12);
|
||||
await db.run(
|
||||
'INSERT INTO users (name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, 1)',
|
||||
['Administrator', adminEmail, hash, 'admin']
|
||||
'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)',
|
||||
['Administrator', 'Administrator', adminEmail, hash, 'admin']
|
||||
);
|
||||
console.log(`✅ Default admin created: ${adminEmail}`);
|
||||
log.db.info(`Default admin created: ${adminEmail}`);
|
||||
}
|
||||
// Mark as seeded so it never runs again, even if the admin email is changed
|
||||
await db.run("INSERT INTO settings (key, value) VALUES ('admin_seeded', '1') RETURNING key");
|
||||
}
|
||||
}
|
||||
|
||||
52
server/config/emaili18n.js
Normal file
52
server/config/emaili18n.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { createRequire } from 'module';
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const cache = {};
|
||||
|
||||
function load(lang) {
|
||||
if (cache[lang]) return cache[lang];
|
||||
try {
|
||||
cache[lang] = require(path.resolve(__dirname, '../i18n', `${lang}.json`));
|
||||
return cache[lang];
|
||||
} catch {
|
||||
if (lang !== 'en') return load('en');
|
||||
cache[lang] = {};
|
||||
return cache[lang];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a dot-separated key for the given language.
|
||||
* Interpolates {placeholder} tokens from params.
|
||||
* Unresolved tokens are left as-is so callers can do HTML substitution afterwards.
|
||||
*
|
||||
* @param {string} lang Language code, e.g. 'en', 'de'
|
||||
* @param {string} keyPath Dot-separated key, e.g. 'email.verify.subject'
|
||||
* @param {Record<string,string>} [params] Values to interpolate
|
||||
* @returns {string}
|
||||
*/
|
||||
export function t(lang, keyPath, params = {}) {
|
||||
const keys = keyPath.split('.');
|
||||
|
||||
function resolve(dict) {
|
||||
let val = dict;
|
||||
for (const k of keys) {
|
||||
val = val?.[k];
|
||||
}
|
||||
return typeof val === 'string' ? val : undefined;
|
||||
}
|
||||
|
||||
let value = resolve(load(lang));
|
||||
// Fallback to English
|
||||
if (value === undefined) value = resolve(load('en'));
|
||||
if (value === undefined) return keyPath;
|
||||
|
||||
return value.replace(/\{(\w+)\}/g, (match, k) =>
|
||||
params[k] !== undefined ? String(params[k]) : match
|
||||
);
|
||||
}
|
||||
223
server/config/federation.js
Normal file
223
server/config/federation.js
Normal file
@@ -0,0 +1,223 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { log } from './logger.js';
|
||||
|
||||
import dns from 'dns';
|
||||
import net from 'net';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const FEDERATION_DOMAIN = process.env.FEDERATION_DOMAIN || '';
|
||||
let privateKeyPem = process.env.FEDERATION_PRIVATE_KEY || '';
|
||||
let publicKeyPem = '';
|
||||
|
||||
// Load or generate Ed25519 keys
|
||||
if (FEDERATION_DOMAIN) {
|
||||
const keyPath = process.env.FEDERATION_KEY_PATH || '/app/keys/federation_key.pem';
|
||||
const keyDir = path.dirname(keyPath);
|
||||
if (!fs.existsSync(keyDir)) {
|
||||
fs.mkdirSync(keyDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (!privateKeyPem && fs.existsSync(keyPath)) {
|
||||
privateKeyPem = fs.readFileSync(keyPath, 'utf8');
|
||||
}
|
||||
|
||||
if (!privateKeyPem) {
|
||||
log.federation.info('Generating new Ed25519 key pair...');
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
privateKeyPem = privateKey;
|
||||
fs.writeFileSync(keyPath, privateKeyPem, 'utf8');
|
||||
log.federation.info(`Saved new private key to ${keyPath}`);
|
||||
}
|
||||
|
||||
// Derive public key from the loaded private key
|
||||
const currentPrivateKey = crypto.createPrivateKey(privateKeyPem);
|
||||
publicKeyPem = crypto.createPublicKey(currentPrivateKey).export({ type: 'spki', format: 'pem' });
|
||||
}
|
||||
|
||||
// Instance discovery cache (domain → { baseUrl, publicKey, cachedAt })
|
||||
const discoveryCache = new Map();
|
||||
const DISCOVERY_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Get this instance's federation domain.
|
||||
*/
|
||||
export function getFederationDomain() {
|
||||
return FEDERATION_DOMAIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this instance's Ed25519 public key (PEM format).
|
||||
*/
|
||||
export function getPublicKey() {
|
||||
return publicKeyPem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if federation is configured on this instance.
|
||||
*/
|
||||
export function isFederationEnabled() {
|
||||
return !!(FEDERATION_DOMAIN && privateKeyPem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ed25519 sign a JSON payload.
|
||||
* @param {object} payload
|
||||
* @returns {string} base64 signature
|
||||
*/
|
||||
export function signPayload(payload) {
|
||||
if (!privateKeyPem) throw new Error("Federation private key not available");
|
||||
const data = Buffer.from(JSON.stringify(payload));
|
||||
return crypto.sign(null, data, privateKeyPem).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an Ed25519 signature against a JSON payload using a remote public key.
|
||||
* @param {object} payload
|
||||
* @param {string} signature base64 signature
|
||||
* @param {string} remotePublicKeyPem
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function verifyPayload(payload, signature, remotePublicKeyPem) {
|
||||
if (!remotePublicKeyPem || !signature) return false;
|
||||
try {
|
||||
const data = Buffer.from(JSON.stringify(payload));
|
||||
return crypto.verify(null, data, remotePublicKeyPem, Buffer.from(signature, 'base64'));
|
||||
} catch (e) {
|
||||
log.federation.error(`Signature verification error: ${e.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain resolves to a private/internal IP address (SSRF protection).
|
||||
* Blocks RFC 1918, loopback, link-local, and cloud metadata IPs.
|
||||
* @param {string} domain
|
||||
* @returns {Promise<void>} throws if domain resolves to a blocked IP
|
||||
*/
|
||||
async function assertPublicDomain(domain) {
|
||||
// Allow localhost only in development
|
||||
if (domain === 'localhost' || domain === '127.0.0.1' || domain === '::1') {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('Federation to localhost is blocked in production');
|
||||
}
|
||||
return; // allow in dev
|
||||
}
|
||||
|
||||
// If domain is a raw IP, check it directly
|
||||
if (net.isIP(domain)) {
|
||||
if (isPrivateIP(domain)) {
|
||||
throw new Error(`Federation blocked: ${domain} resolves to a private IP`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve domain and check all resulting IPs
|
||||
const { resolve4, resolve6 } = dns.promises;
|
||||
const ips = [];
|
||||
try { ips.push(...await resolve4(domain)); } catch {}
|
||||
try { ips.push(...await resolve6(domain)); } catch {}
|
||||
|
||||
if (ips.length === 0) {
|
||||
throw new Error(`Federation blocked: could not resolve ${domain}`);
|
||||
}
|
||||
|
||||
for (const ip of ips) {
|
||||
if (isPrivateIP(ip)) {
|
||||
throw new Error(`Federation blocked: ${domain} resolves to a private IP (${ip})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivateIP(ip) {
|
||||
// IPv4 private ranges
|
||||
if (/^10\./.test(ip)) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
|
||||
if (/^192\.168\./.test(ip)) return true;
|
||||
if (/^127\./.test(ip)) return true;
|
||||
if (/^0\./.test(ip)) return true;
|
||||
if (/^169\.254\./.test(ip)) return true; // link-local
|
||||
if (ip === '::1' || ip === '::' || ip.startsWith('fe80:') || ip.startsWith('fc') || ip.startsWith('fd')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover a remote Redlight instance's federation API base URL.
|
||||
* Fetches https://{domain}/.well-known/redlight and caches the result.
|
||||
* Includes SSRF protection: blocks private/internal IPs.
|
||||
* @param {string} domain
|
||||
* @returns {Promise<{ baseUrl: string, publicKey: string }>}
|
||||
*/
|
||||
export async function discoverInstance(domain) {
|
||||
// SSRF protection: validate domain doesn't resolve to internal IP
|
||||
await assertPublicDomain(domain);
|
||||
|
||||
const cached = discoveryCache.get(domain);
|
||||
if (cached && (Date.now() - cached.cachedAt) < DISCOVERY_TTL_MS) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const wellKnownUrl = `https://${domain}/.well-known/redlight`;
|
||||
const TIMEOUT_MS = 10_000; // 10 seconds
|
||||
try {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(wellKnownUrl, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
||||
} catch (e) {
|
||||
// HTTP fallback only allowed in development for localhost
|
||||
if (e.message.includes('fetch') && domain === 'localhost' && process.env.NODE_ENV !== 'production') {
|
||||
response = await fetch(`http://${domain}/.well-known/redlight`, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
||||
} else throw e;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discovery failed: HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.public_key) {
|
||||
throw new Error(`Remote instance at ${domain} did not provide a public key`);
|
||||
}
|
||||
|
||||
const baseUrl = `https://${domain}${data.federation_api || '/api/federation'}`;
|
||||
const result = {
|
||||
baseUrl: (domain === 'localhost' && process.env.NODE_ENV !== 'production')
|
||||
? baseUrl.replace('https://localhost', 'http://localhost')
|
||||
: baseUrl,
|
||||
publicKey: data.public_key,
|
||||
cachedAt: Date.now(),
|
||||
};
|
||||
|
||||
discoveryCache.set(domain, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
log.federation.error(`Discovery failed for ${domain}: ${error.message}`);
|
||||
throw new Error(`Could not discover Redlight instance at ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a federated address like "username@domain.com".
|
||||
* @param {string} address
|
||||
* @returns {{ username: string, domain: string | null }}
|
||||
*/
|
||||
export function parseAddress(address) {
|
||||
if (!address) return { username: address, domain: null };
|
||||
// Accept both @user@domain (Mastodon-style) and user@domain
|
||||
const normalized = address.startsWith('@') ? address.slice(1) : address;
|
||||
if (!normalized.includes('@')) {
|
||||
return { username: normalized, domain: null };
|
||||
}
|
||||
const atIndex = normalized.lastIndexOf('@');
|
||||
return {
|
||||
username: normalized.substring(0, atIndex),
|
||||
domain: normalized.substring(atIndex + 1),
|
||||
};
|
||||
}
|
||||
157
server/config/logger.js
Normal file
157
server/config/logger.js
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Centralized logger for Redlight server.
|
||||
*
|
||||
* Produces clean, colorized, tagged log lines inspired by Greenlight/Rails lograge style.
|
||||
*
|
||||
* Format: TIMESTAMP LEVEL [TAG] message
|
||||
* Example: 2026-03-01 12:00:00 INFO [BBB] GET getMeetings → 200 SUCCESS (45ms)
|
||||
*/
|
||||
|
||||
// ── ANSI colors ─────────────────────────────────────────────────────────────
|
||||
const C = {
|
||||
reset: '\x1b[0m',
|
||||
bold: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
white: '\x1b[37m',
|
||||
gray: '\x1b[90m',
|
||||
};
|
||||
|
||||
const USE_COLOR = process.env.NO_COLOR ? false : true;
|
||||
const c = (color, text) => USE_COLOR ? `${color}${text}${C.reset}` : text;
|
||||
|
||||
// ── Timestamp ───────────────────────────────────────────────────────────────
|
||||
function ts() {
|
||||
const d = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}.${ms}`;
|
||||
}
|
||||
|
||||
// ── Level formatting ────────────────────────────────────────────────────────
|
||||
const LEVEL_STYLE = {
|
||||
DEBUG: { color: C.gray, label: 'DEBUG' },
|
||||
INFO: { color: C.green, label: ' INFO' },
|
||||
WARN: { color: C.yellow, label: ' WARN' },
|
||||
ERROR: { color: C.red, label: 'ERROR' },
|
||||
};
|
||||
|
||||
// ── Tag colors ──────────────────────────────────────────────────────────────
|
||||
const TAG_COLORS = {
|
||||
BBB: C.magenta,
|
||||
HTTP: C.cyan,
|
||||
Federation: C.blue,
|
||||
FedSync: C.blue,
|
||||
DB: C.yellow,
|
||||
Auth: C.green,
|
||||
Server: C.white,
|
||||
Mailer: C.cyan,
|
||||
Redis: C.magenta,
|
||||
Admin: C.yellow,
|
||||
Rooms: C.green,
|
||||
Recordings: C.cyan,
|
||||
Branding: C.white,
|
||||
};
|
||||
|
||||
function formatLine(level, tag, message) {
|
||||
const lvl = LEVEL_STYLE[level] || LEVEL_STYLE.INFO;
|
||||
const tagColor = TAG_COLORS[tag] || C.white;
|
||||
const timestamp = c(C.gray, ts());
|
||||
const levelStr = c(lvl.color, lvl.label);
|
||||
const tagStr = c(tagColor, `[${tag}]`);
|
||||
return `${timestamp} ${levelStr} ${tagStr} ${message}`;
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a tagged logger.
|
||||
* @param {string} tag - e.g. 'BBB', 'HTTP', 'Federation'
|
||||
*/
|
||||
export function createLogger(tag) {
|
||||
return {
|
||||
debug: (msg, ...args) => console.debug(formatLine('DEBUG', tag, msg), ...args),
|
||||
info: (msg, ...args) => console.info(formatLine('INFO', tag, msg), ...args),
|
||||
warn: (msg, ...args) => console.warn(formatLine('WARN', tag, msg), ...args),
|
||||
error: (msg, ...args) => console.error(formatLine('ERROR', tag, msg), ...args),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Pre-built loggers for common subsystems ─────────────────────────────────
|
||||
export const log = {
|
||||
bbb: createLogger('BBB'),
|
||||
http: createLogger('HTTP'),
|
||||
federation: createLogger('Federation'),
|
||||
fedSync: createLogger('FedSync'),
|
||||
db: createLogger('DB'),
|
||||
auth: createLogger('Auth'),
|
||||
server: createLogger('Server'),
|
||||
mailer: createLogger('Mailer'),
|
||||
redis: createLogger('Redis'),
|
||||
admin: createLogger('Admin'),
|
||||
rooms: createLogger('Rooms'),
|
||||
recordings: createLogger('Recordings'),
|
||||
branding: createLogger('Branding'),
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Format duration with unit and color. */
|
||||
export function fmtDuration(ms) {
|
||||
const num = Number(ms);
|
||||
if (num < 100) return c(C.green, `${num.toFixed(0)}ms`);
|
||||
if (num < 1000) return c(C.yellow, `${num.toFixed(0)}ms`);
|
||||
return c(C.red, `${(num / 1000).toFixed(2)}s`);
|
||||
}
|
||||
|
||||
/** Format HTTP status with color. */
|
||||
export function fmtStatus(status) {
|
||||
const s = Number(status);
|
||||
if (s < 300) return c(C.green, String(s));
|
||||
if (s < 400) return c(C.cyan, String(s));
|
||||
if (s < 500) return c(C.yellow, String(s));
|
||||
return c(C.red, String(s));
|
||||
}
|
||||
|
||||
/** Format HTTP method with color. */
|
||||
export function fmtMethod(method) {
|
||||
const m = String(method).toUpperCase();
|
||||
const colors = { GET: C.green, POST: C.cyan, PUT: C.yellow, PATCH: C.yellow, DELETE: C.red };
|
||||
return c(colors[m] || C.white, m.padEnd(6));
|
||||
}
|
||||
|
||||
/** Format BBB returncode with color. */
|
||||
export function fmtReturncode(code) {
|
||||
if (code === 'SUCCESS') return c(C.green, code);
|
||||
if (code === 'FAILED') return c(C.red, code);
|
||||
return c(C.yellow, code || '-');
|
||||
}
|
||||
|
||||
// ── Sensitive value filtering ───────────────────────────────────────────────
|
||||
const SENSITIVE_KEYS = [/pass/i, /pwd/i, /password/i, /token/i, /jwt/i, /secret/i, /authorization/i, /api[_-]?key/i];
|
||||
export function isSensitiveKey(key) {
|
||||
return SENSITIVE_KEYS.some(rx => rx.test(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize BBB params for logging: filter sensitive values, truncate long strings, omit checksum.
|
||||
*/
|
||||
export function sanitizeBBBParams(params) {
|
||||
const parts = [];
|
||||
for (const k of Object.keys(params || {})) {
|
||||
if (k.toLowerCase() === 'checksum') continue;
|
||||
if (isSensitiveKey(k)) {
|
||||
parts.push(`${k}=${c(C.dim, '[FILTERED]')}`);
|
||||
} else {
|
||||
let v = params[k];
|
||||
if (typeof v === 'string' && v.length > 80) v = v.slice(0, 80) + '…';
|
||||
parts.push(`${c(C.gray, k)}=${v}`);
|
||||
}
|
||||
}
|
||||
return parts.join(' ') || '-';
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { log } from './logger.js';
|
||||
import { t } from './emaili18n.js';
|
||||
|
||||
let transporter;
|
||||
|
||||
// Escape HTML special characters to prevent injection in email bodies
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function initMailer() {
|
||||
const host = process.env.SMTP_HOST;
|
||||
const port = parseInt(process.env.SMTP_PORT || '587', 10);
|
||||
@@ -9,7 +22,7 @@ export function initMailer() {
|
||||
const pass = process.env.SMTP_PASS;
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
console.warn('⚠️ SMTP not configured – email verification disabled');
|
||||
log.mailer.warn('SMTP not configured - email verification disabled');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -18,9 +31,12 @@ export function initMailer() {
|
||||
port,
|
||||
secure: port === 465,
|
||||
auth: { user, pass },
|
||||
connectionTimeout: 10_000, // 10 s to establish TCP connection
|
||||
greetingTimeout: 10_000, // 10 s to receive SMTP greeting
|
||||
socketTimeout: 15_000, // 15 s of inactivity before abort
|
||||
});
|
||||
|
||||
console.log('✅ SMTP mailer configured');
|
||||
log.mailer.info('SMTP mailer configured');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -30,41 +46,229 @@ export function isMailerConfigured() {
|
||||
|
||||
/**
|
||||
* Send the verification email with a clickable link.
|
||||
* @param {string} to – recipient email
|
||||
* @param {string} name – user's display name
|
||||
* @param {string} verifyUrl – full verification URL
|
||||
* @param {string} appName – branding app name (default "Redlight")
|
||||
* @param {string} to - recipient email
|
||||
* @param {string} name - user's display name
|
||||
* @param {string} verifyUrl - full verification URL
|
||||
* @param {string} appName - branding app name (default "Redlight")
|
||||
*/
|
||||
export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight') {
|
||||
// S3: sanitize name for use in email From header (strip quotes, newlines, control chars)
|
||||
function sanitizeHeaderValue(str) {
|
||||
return String(str).replace(/["\\\r\n\x00-\x1f]/g, '').trim().slice(0, 100);
|
||||
}
|
||||
|
||||
export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight', lang = 'en') {
|
||||
if (!transporter) {
|
||||
throw new Error('SMTP not configured');
|
||||
}
|
||||
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
const headerAppName = sanitizeHeaderValue(appName);
|
||||
const safeName = escapeHtml(name);
|
||||
const safeAppName = escapeHtml(appName);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${appName}" <${from}>`,
|
||||
from: `"${headerAppName}" <${from}>`,
|
||||
to,
|
||||
subject: `${appName} – E-Mail bestätigen / Verify your email`,
|
||||
subject: t(lang, 'email.verify.subject', { appName: headerAppName }),
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||
<h2 style="color:#cba6f7;margin-top:0;">Hey ${name} 👋</h2>
|
||||
<p>Bitte bestätige deine E-Mail-Adresse, indem du auf den folgenden Button klickst:</p>
|
||||
<h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
|
||||
<p>${t(lang, 'email.verify.intro')}</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
|
||||
${t(lang, 'email.verify.button')}
|
||||
</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>
|
||||
${t(lang, 'email.linkHint')}<br/>
|
||||
<a href="${verifyUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(verifyUrl)}</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">Der Link ist 24 Stunden gültig.</p>
|
||||
<p style="font-size:13px;color:#7f849c;">${t(lang, 'email.verify.validity')}</p>
|
||||
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||
<p style="font-size:12px;color:#585b70;">Falls du dich nicht registriert hast, ignoriere diese E-Mail.</p>
|
||||
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.verify.footer')}</p>
|
||||
</div>
|
||||
`,
|
||||
text: `Hey ${name},\n\nBitte bestätige deine E-Mail: ${verifyUrl}\n\nDer Link ist 24 Stunden gültig.\n\n– ${appName}`,
|
||||
text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.verify.intro')}\n${verifyUrl}\n\n${t(lang, 'email.verify.validity')}\n\n- ${appName}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a federation meeting invitation email.
|
||||
* @param {string} to - recipient email
|
||||
* @param {string} name - recipient display name
|
||||
* @param {string} fromUser - sender federated address (user@domain)
|
||||
* @param {string} roomName - name of the invited room
|
||||
* @param {string} message - optional personal message
|
||||
* @param {string} inboxUrl - URL to the federation inbox
|
||||
* @param {string} appName - branding app name (default "Redlight")
|
||||
*/
|
||||
export async function sendFederationInviteEmail(to, name, fromUser, roomName, message, inboxUrl, appName = 'Redlight', lang = 'en') {
|
||||
if (!transporter) return; // silently skip if SMTP not configured
|
||||
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
const headerAppName = sanitizeHeaderValue(appName);
|
||||
const safeName = escapeHtml(name);
|
||||
const safeFromUser = escapeHtml(fromUser);
|
||||
const safeRoomName = escapeHtml(roomName);
|
||||
const safeMessage = message ? escapeHtml(message) : null;
|
||||
const safeAppName = escapeHtml(appName);
|
||||
|
||||
const introHtml = t(lang, 'email.federationInvite.intro')
|
||||
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${headerAppName}" <${from}>`,
|
||||
to,
|
||||
subject: t(lang, 'email.federationInvite.subject', { appName: headerAppName, fromUser: sanitizeHeaderValue(fromUser) }),
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||
<h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
|
||||
<p>${introHtml}</p>
|
||||
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
|
||||
<p style="margin:0 0 8px 0;font-size:13px;color:#7f849c;">${t(lang, 'email.federationInvite.roomLabel')}</p>
|
||||
<p style="margin:0;font-size:16px;font-weight:bold;color:#cdd6f4;">${safeRoomName}</p>
|
||||
${safeMessage ? `<p style="margin:12px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">"${safeMessage}"</p>` : ''}
|
||||
</div>
|
||||
<p style="text-align:center;margin:28px 0;">
|
||||
<a href="${inboxUrl}"
|
||||
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
|
||||
${t(lang, 'email.viewInvitation')}
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.invitationFooter')}</p>
|
||||
</div>
|
||||
`,
|
||||
text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.federationInvite.intro', { fromUser })}\n${t(lang, 'email.federationInvite.roomLabel')} ${roomName}${message ? `\n"${message}"` : ''}\n\n${t(lang, 'email.viewInvitation')}: ${inboxUrl}\n\n- ${appName}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a calendar event invitation email (federated).
|
||||
*/
|
||||
export async function sendCalendarInviteEmail(to, name, fromUser, title, startTime, endTime, description, inboxUrl, appName = 'Redlight', lang = 'en') {
|
||||
if (!transporter) return;
|
||||
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
const headerAppName = sanitizeHeaderValue(appName);
|
||||
const safeName = escapeHtml(name);
|
||||
const safeFromUser = escapeHtml(fromUser);
|
||||
const safeTitle = escapeHtml(title);
|
||||
const safeDesc = description ? escapeHtml(description) : null;
|
||||
|
||||
const formatDate = (iso) => {
|
||||
try { return new Date(iso).toLocaleString(lang === 'de' ? 'de-DE' : 'en-GB', { dateStyle: 'full', timeStyle: 'short' }); }
|
||||
catch { return iso; }
|
||||
};
|
||||
|
||||
const introHtml = t(lang, 'email.calendarInvite.intro')
|
||||
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${headerAppName}" <${from}>`,
|
||||
to,
|
||||
subject: t(lang, 'email.calendarInvite.subject', { appName: headerAppName, fromUser: sanitizeHeaderValue(fromUser) }),
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||
<h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
|
||||
<p>${introHtml}</p>
|
||||
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
|
||||
<p style="margin:0 0 4px 0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
|
||||
<p style="margin:6px 0 0 0;font-size:13px;color:#a6adc8;">🕐 ${escapeHtml(formatDate(startTime))} - ${escapeHtml(formatDate(endTime))}</p>
|
||||
${safeDesc ? `<p style="margin:10px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">"${safeDesc}"</p>` : ''}
|
||||
</div>
|
||||
<p style="text-align:center;margin:28px 0;">
|
||||
<a href="${inboxUrl}"
|
||||
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
|
||||
${t(lang, 'email.viewInvitation')}
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.invitationFooter')}</p>
|
||||
</div>
|
||||
`,
|
||||
text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.calendarInvite.intro', { fromUser })}\n${safeTitle}\n${formatDate(startTime)} \u2013 ${formatDate(endTime)}${description ? `\n\n"${description}"` : ''}\n\n${t(lang, 'email.viewInvitation')}: ${inboxUrl}\n\n\u2013 ${appName}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify a user that a federated calendar event they received was deleted by the organiser.
|
||||
*/
|
||||
export async function sendCalendarEventDeletedEmail(to, name, fromUser, title, appName = 'Redlight', lang = 'en') {
|
||||
if (!transporter) return;
|
||||
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
const headerAppName = sanitizeHeaderValue(appName);
|
||||
const safeName = escapeHtml(name);
|
||||
const safeFromUser = escapeHtml(fromUser);
|
||||
const safeTitle = escapeHtml(title);
|
||||
|
||||
const introHtml = t(lang, 'email.calendarDeleted.intro')
|
||||
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${headerAppName}" <${from}>`,
|
||||
to,
|
||||
subject: t(lang, 'email.calendarDeleted.subject', { appName: headerAppName, title: sanitizeHeaderValue(title) }),
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||
<h2 style="color:#f38ba8;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
|
||||
<p>${introHtml}</p>
|
||||
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;border-left:4px solid #f38ba8;">
|
||||
<p style="margin:0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
|
||||
</div>
|
||||
<p style="font-size:13px;color:#7f849c;">${t(lang, 'email.calendarDeleted.note')}</p>
|
||||
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.calendarDeleted.footer', { appName: escapeHtml(appName) })}</p>
|
||||
</div>
|
||||
`,
|
||||
text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.calendarDeleted.intro', { fromUser })}\n"${title}"\n\n${t(lang, 'email.calendarDeleted.note')}\n\n\u2013 ${appName}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a user registration invite email.
|
||||
* @param {string} to - recipient email
|
||||
* @param {string} inviteUrl - full invite registration URL
|
||||
* @param {string} appName - branding app name (default "Redlight")
|
||||
*/
|
||||
export async function sendInviteEmail(to, inviteUrl, appName = 'Redlight', lang = 'en') {
|
||||
if (!transporter) {
|
||||
throw new Error('SMTP not configured');
|
||||
}
|
||||
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
const headerAppName = sanitizeHeaderValue(appName);
|
||||
const safeAppName = escapeHtml(appName);
|
||||
|
||||
const introHtml = t(lang, 'email.invite.intro')
|
||||
.replace('{appName}', `<strong style="color:#cdd6f4;">${safeAppName}</strong>`);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${headerAppName}" <${from}>`,
|
||||
to,
|
||||
subject: t(lang, 'email.invite.subject', { appName: headerAppName }),
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||
<h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.invite.title')}</h2>
|
||||
<p>${introHtml}</p>
|
||||
<p>${t(lang, 'email.invite.prompt')}</p>
|
||||
<p style="text-align:center;margin:28px 0;">
|
||||
<a href="${inviteUrl}"
|
||||
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
|
||||
${t(lang, 'email.invite.button')}
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">
|
||||
${t(lang, 'email.linkHint')}<br/>
|
||||
<a href="${inviteUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(inviteUrl)}</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">${t(lang, 'email.invite.validity')}</p>
|
||||
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.invite.footer')}</p>
|
||||
</div>
|
||||
`,
|
||||
text: `${t(lang, 'email.invite.title')}\n\n${t(lang, 'email.invite.intro', { appName })}\n\n${t(lang, 'email.invite.prompt')}\n${inviteUrl}\n\n${t(lang, 'email.invite.validity')}\n\n- ${appName}`,
|
||||
});
|
||||
}
|
||||
|
||||
23
server/config/notifications.js
Normal file
23
server/config/notifications.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getDb } from './database.js';
|
||||
|
||||
/**
|
||||
* Create an in-app notification for a user.
|
||||
* Non-fatal — exceptions are swallowed so that the main operation is never blocked.
|
||||
*
|
||||
* @param {number} userId - Recipient user ID
|
||||
* @param {string} type - Notification type (room_share_added | room_share_removed | federation_invite_received)
|
||||
* @param {string} title - Short title (e.g. room name or "from" address)
|
||||
* @param {string|null} body - Optional longer message
|
||||
* @param {string|null} link - Optional frontend path to navigate to when clicked
|
||||
*/
|
||||
export async function createNotification(userId, type, title, body = null, link = null) {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.run(
|
||||
'INSERT INTO notifications (user_id, type, title, body, link) VALUES (?, ?, ?, ?, ?)',
|
||||
[userId, type, title, body, link],
|
||||
);
|
||||
} catch {
|
||||
// Notifications are non-critical — never break main functionality
|
||||
}
|
||||
}
|
||||
275
server/config/oauth.js
Normal file
275
server/config/oauth.js
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* OAuth / OpenID Connect configuration for Redlight.
|
||||
*
|
||||
* Supports generic OIDC providers (Keycloak, Authentik, Google, GitHub, etc.)
|
||||
* configured at runtime via admin settings stored in the database.
|
||||
*
|
||||
* Security:
|
||||
* - PKCE (S256) on every authorization request
|
||||
* - Anti-CSRF via cryptographic `state` parameter stored server-side
|
||||
* - State entries expire after 10 minutes and are single-use
|
||||
* - Client secrets are stored AES-256-GCM encrypted in the DB
|
||||
* - Only https callback URLs in production
|
||||
* - Token exchange uses server-side secret, never exposed to the browser
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { getDb } from './database.js';
|
||||
import { log } from './logger.js';
|
||||
|
||||
// ── Encryption helpers for client secrets ──────────────────────────────────
|
||||
// Derive a key from JWT_SECRET (always available)
|
||||
const ENCRYPTION_KEY = crypto
|
||||
.createHash('sha256')
|
||||
.update(process.env.JWT_SECRET || '')
|
||||
.digest(); // 32 bytes → AES-256
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext string with AES-256-GCM.
|
||||
* Returns "iv:authTag:ciphertext" (all hex-encoded).
|
||||
*/
|
||||
export function encryptSecret(plaintext) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
const authTag = cipher.getAuthTag().toString('hex');
|
||||
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an AES-256-GCM encrypted string.
|
||||
*/
|
||||
export function decryptSecret(encryptedStr) {
|
||||
const [ivHex, authTagHex, ciphertext] = encryptedStr.split(':');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// ── PKCE helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Generate a cryptographically random code_verifier (RFC 7636). */
|
||||
export function generateCodeVerifier() {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
/** Compute the S256 code_challenge from a code_verifier. */
|
||||
export function computeCodeChallenge(verifier) {
|
||||
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||
}
|
||||
|
||||
// ── State management (anti-CSRF) ───────────────────────────────────────────
|
||||
|
||||
const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/**
|
||||
* Create and persist an OAuth state token with associated PKCE verifier.
|
||||
* @param {string} provider – provider key (e.g. 'oidc')
|
||||
* @param {string} codeVerifier – PKCE code_verifier
|
||||
* @param {string|null} returnTo – optional return URL after login
|
||||
* @returns {Promise<string>} state token
|
||||
*/
|
||||
export async function createOAuthState(provider, codeVerifier, returnTo = null) {
|
||||
const state = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + STATE_TTL_MS).toISOString();
|
||||
const db = getDb();
|
||||
await db.run(
|
||||
'INSERT INTO oauth_states (state, provider, code_verifier, return_to, expires_at) VALUES (?, ?, ?, ?, ?)',
|
||||
[state, provider, codeVerifier, returnTo, expiresAt],
|
||||
);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume (validate + delete) an OAuth state token.
|
||||
* Returns the stored data or null if invalid/expired.
|
||||
* @param {string} state
|
||||
* @returns {Promise<{ provider: string, code_verifier: string, return_to: string|null } | null>}
|
||||
*/
|
||||
export async function consumeOAuthState(state) {
|
||||
if (!state || typeof state !== 'string' || state.length > 128) return null;
|
||||
const db = getDb();
|
||||
const row = await db.get(
|
||||
'SELECT * FROM oauth_states WHERE state = ?',
|
||||
[state],
|
||||
);
|
||||
if (!row) return null;
|
||||
|
||||
// Always delete (single-use)
|
||||
await db.run('DELETE FROM oauth_states WHERE state = ?', [state]);
|
||||
|
||||
// Check expiry
|
||||
if (new Date(row.expires_at) < new Date()) return null;
|
||||
|
||||
return {
|
||||
provider: row.provider,
|
||||
code_verifier: row.code_verifier,
|
||||
return_to: row.return_to,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Garbage-collect expired OAuth states (called periodically).
|
||||
*/
|
||||
export async function cleanupExpiredStates() {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.run('DELETE FROM oauth_states WHERE expires_at < CURRENT_TIMESTAMP');
|
||||
} catch (err) {
|
||||
log.auth.warn(`OAuth state cleanup failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provider configuration ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load the stored OAuth provider config from the settings table.
|
||||
* Returns null if OAuth is not configured.
|
||||
* @returns {Promise<{ issuer: string, clientId: string, clientSecret: string, displayName: string, autoRegister: boolean } | null>}
|
||||
*/
|
||||
export async function getOAuthConfig() {
|
||||
try {
|
||||
const db = getDb();
|
||||
const row = await db.get("SELECT value FROM settings WHERE key = 'oauth_config'");
|
||||
if (!row?.value) return null;
|
||||
|
||||
const config = JSON.parse(row.value);
|
||||
if (!config.issuer || !config.clientId || !config.encryptedSecret) return null;
|
||||
|
||||
return {
|
||||
issuer: config.issuer,
|
||||
clientId: config.clientId,
|
||||
clientSecret: decryptSecret(config.encryptedSecret),
|
||||
displayName: config.displayName || 'SSO',
|
||||
autoRegister: config.autoRegister !== false,
|
||||
};
|
||||
} catch (err) {
|
||||
log.auth.error(`Failed to load OAuth config: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save OAuth provider config to the settings table.
|
||||
* The client secret is encrypted before storage.
|
||||
*/
|
||||
export async function saveOAuthConfig({ issuer, clientId, clientSecret, displayName, autoRegister }) {
|
||||
const db = getDb();
|
||||
const config = {
|
||||
issuer,
|
||||
clientId,
|
||||
encryptedSecret: encryptSecret(clientSecret),
|
||||
displayName: displayName || 'SSO',
|
||||
autoRegister: autoRegister !== false,
|
||||
};
|
||||
const value = JSON.stringify(config);
|
||||
|
||||
const existing = await db.get("SELECT key FROM settings WHERE key = 'oauth_config'");
|
||||
if (existing) {
|
||||
await db.run("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = 'oauth_config'", [value]);
|
||||
} else {
|
||||
await db.run("INSERT INTO settings (key, value) VALUES ('oauth_config', ?) RETURNING key", [value]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove OAuth configuration.
|
||||
*/
|
||||
export async function deleteOAuthConfig() {
|
||||
const db = getDb();
|
||||
await db.run("DELETE FROM settings WHERE key = 'oauth_config'");
|
||||
}
|
||||
|
||||
// ── OIDC Discovery ─────────────────────────────────────────────────────────
|
||||
|
||||
// Cache discovered OIDC endpoints { authorization_endpoint, token_endpoint, userinfo_endpoint, ... }
|
||||
const discoveryCache = new Map();
|
||||
const DISCOVERY_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
/**
|
||||
* Fetch and cache the OpenID Connect discovery document for the given issuer.
|
||||
* @param {string} issuer
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function discoverOIDC(issuer) {
|
||||
const cached = discoveryCache.get(issuer);
|
||||
if (cached && Date.now() - cached.fetchedAt < DISCOVERY_TTL_MS) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// Normalize issuer URL
|
||||
const base = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;
|
||||
const url = `${base}/.well-known/openid-configuration`;
|
||||
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!response.ok) {
|
||||
throw new Error(`OIDC discovery failed for ${issuer}: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.authorization_endpoint || !data.token_endpoint) {
|
||||
throw new Error(`OIDC discovery response missing required endpoints`);
|
||||
}
|
||||
|
||||
discoveryCache.set(issuer, { data, fetchedAt: Date.now() });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange an authorization code for tokens.
|
||||
* @param {object} oidcConfig – discovery document
|
||||
* @param {string} code
|
||||
* @param {string} redirectUri
|
||||
* @param {string} clientId
|
||||
* @param {string} clientSecret
|
||||
* @param {string} codeVerifier – PKCE verifier
|
||||
* @returns {Promise<object>} token response
|
||||
*/
|
||||
export async function exchangeCode(oidcConfig, code, redirectUri, clientId, clientSecret, codeVerifier) {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
const response = await fetch(oidcConfig.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text().catch(() => '');
|
||||
throw new Error(`Token exchange failed: HTTP ${response.status} – ${errText.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user info from the provider's userinfo endpoint.
|
||||
* @param {string} userInfoUrl
|
||||
* @param {string} accessToken
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function fetchUserInfo(userInfoUrl, accessToken) {
|
||||
const response = await fetch(userInfoUrl, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`UserInfo fetch failed: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
25
server/config/redis.js
Normal file
25
server/config/redis.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import Redis from 'ioredis';
|
||||
import { log } from './logger.js';
|
||||
|
||||
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||
|
||||
const redis = new Redis(REDIS_URL, {
|
||||
maxRetriesPerRequest: null,
|
||||
retryStrategy: (times) => {
|
||||
if (times > 3) return null; // stop retrying after 3 attempts
|
||||
return Math.min(times * 200, 1000);
|
||||
},
|
||||
});
|
||||
|
||||
redis.on('error', (err) => {
|
||||
// Suppress ECONNREFUSED noise after initial failure — only warn
|
||||
if (err.code !== 'ECONNREFUSED') {
|
||||
log.redis.warn(`DragonflyDB error: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
redis.on('connect', () => {
|
||||
log.redis.info('DragonflyDB connected');
|
||||
});
|
||||
|
||||
export default redis;
|
||||
39
server/i18n/de.json
Normal file
39
server/i18n/de.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"email": {
|
||||
"greeting": "Hey {name} 👋",
|
||||
"viewInvitation": "Einladung anzeigen",
|
||||
"invitationFooter": "Öffne den Link oben, um die Einladung anzunehmen oder abzulehnen.",
|
||||
"linkHint": "Oder kopiere diesen Link in deinen Browser:",
|
||||
"verify": {
|
||||
"subject": "{appName} - E-Mail-Adresse bestätigen",
|
||||
"intro": "Bitte bestätige deine E-Mail-Adresse, indem du auf den Button klickst:",
|
||||
"button": "E-Mail bestätigen",
|
||||
"validity": "Dieser Link ist 24 Stunden gültig.",
|
||||
"footer": "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren."
|
||||
},
|
||||
"invite": {
|
||||
"subject": "{appName} - Du wurdest eingeladen",
|
||||
"title": "Du wurdest eingeladen! 🎉",
|
||||
"intro": "Du wurdest eingeladen, ein Konto auf {appName} zu erstellen.",
|
||||
"prompt": "Klicke auf den Button, um dich zu registrieren:",
|
||||
"button": "Konto erstellen",
|
||||
"validity": "Dieser Link ist 7 Tage gültig.",
|
||||
"footer": "Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren."
|
||||
},
|
||||
"federationInvite": {
|
||||
"subject": "{appName} - Meeting-Einladung von {fromUser}",
|
||||
"intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.",
|
||||
"roomLabel": "Raum:"
|
||||
},
|
||||
"calendarInvite": {
|
||||
"subject": "{appName} - Kalendereinladung von {fromUser}",
|
||||
"intro": "Du hast eine Kalendereinladung von {fromUser} erhalten."
|
||||
},
|
||||
"calendarDeleted": {
|
||||
"subject": "{appName} - Kalendereintrag abgesagt: {title}",
|
||||
"intro": "Der folgende Kalendereintrag wurde vom Organisator ({fromUser}) gelöscht und ist nicht mehr verfügbar:",
|
||||
"note": "Der Termin wurde automatisch aus deinem Kalender entfernt.",
|
||||
"footer": "Diese Nachricht wurde automatisch von {appName} versendet."
|
||||
}
|
||||
}
|
||||
}
|
||||
39
server/i18n/en.json
Normal file
39
server/i18n/en.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"email": {
|
||||
"greeting": "Hey {name} 👋",
|
||||
"viewInvitation": "View Invitation",
|
||||
"invitationFooter": "Open the link above to accept or decline the invitation.",
|
||||
"linkHint": "Or copy this link in your browser:",
|
||||
"verify": {
|
||||
"subject": "{appName} - Verify your email",
|
||||
"intro": "Please verify your email address by clicking the button below:",
|
||||
"button": "Verify Email",
|
||||
"validity": "This link is valid for 24 hours.",
|
||||
"footer": "If you didn't register, please ignore this email."
|
||||
},
|
||||
"invite": {
|
||||
"subject": "{appName} - You've been invited",
|
||||
"title": "You've been invited! 🎉",
|
||||
"intro": "You have been invited to create an account on {appName}.",
|
||||
"prompt": "Click the button below to register:",
|
||||
"button": "Create Account",
|
||||
"validity": "This link is valid for 7 days.",
|
||||
"footer": "If you didn't expect this invitation, you can safely ignore this email."
|
||||
},
|
||||
"federationInvite": {
|
||||
"subject": "{appName} - Meeting invitation from {fromUser}",
|
||||
"intro": "You have received a meeting invitation from {fromUser}.",
|
||||
"roomLabel": "Room:"
|
||||
},
|
||||
"calendarInvite": {
|
||||
"subject": "{appName} - Calendar invitation from {fromUser}",
|
||||
"intro": "You have received a calendar invitation from {fromUser}."
|
||||
},
|
||||
"calendarDeleted": {
|
||||
"subject": "{appName} - Calendar event cancelled: {title}",
|
||||
"intro": "The following calendar event was deleted by the organiser ({fromUser}) and is no longer available:",
|
||||
"note": "The event has been automatically removed from your calendar.",
|
||||
"footer": "This message was sent automatically by {appName}."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'dotenv/config';
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { log } from './config/logger.js';
|
||||
import requestResponseLogger from './middleware/logging.js';
|
||||
import { initDatabase } from './config/database.js';
|
||||
import { initMailer } from './config/mailer.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
@@ -10,6 +12,14 @@ import roomRoutes from './routes/rooms.js';
|
||||
import recordingRoutes from './routes/recordings.js';
|
||||
import adminRoutes from './routes/admin.js';
|
||||
import brandingRoutes from './routes/branding.js';
|
||||
import federationRoutes, { wellKnownHandler } from './routes/federation.js';
|
||||
import calendarRoutes from './routes/calendar.js';
|
||||
import caldavRoutes from './routes/caldav.js';
|
||||
import notificationRoutes from './routes/notifications.js';
|
||||
import oauthRoutes from './routes/oauth.js';
|
||||
import analyticsRoutes from './routes/analytics.js';
|
||||
import { startFederationSync } from './jobs/federationSync.js';
|
||||
import { startCalendarReminders } from './jobs/calendarReminders.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -17,24 +27,74 @@ const __dirname = path.dirname(__filename);
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Trust proxy for correct req.protocol behind reverse proxy
|
||||
app.set('trust proxy', true);
|
||||
// Trust proxy - configurable via TRUST_PROXY env var (default: 1 = one local reverse proxy)
|
||||
// Use a number to trust that many hops, or a string like 'loopback' / an IP/CIDR.
|
||||
const rawTrustProxy = process.env.TRUST_PROXY ?? 'loopback';
|
||||
const trustProxy = /^\d+$/.test(rawTrustProxy) ? parseInt(rawTrustProxy, 10) : rawTrustProxy;
|
||||
app.set('trust proxy', trustProxy);
|
||||
|
||||
// ── Security headers ───────────────────────────────────────────────────────
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
// M10: restrict CORS in production; deny cross-origin by default
|
||||
const corsOptions = process.env.APP_URL
|
||||
? { origin: process.env.APP_URL, credentials: true }
|
||||
: { origin: false };
|
||||
app.use(cors(corsOptions));
|
||||
app.use(express.json({ limit: '100kb' }));
|
||||
// Request/Response logging (filters sensitive fields)
|
||||
app.use(requestResponseLogger);
|
||||
|
||||
// Initialize database & start server
|
||||
async function start() {
|
||||
await initDatabase();
|
||||
initMailer();
|
||||
|
||||
// Serve uploaded files (branding only — avatars served via /api/auth/avatar/:filename, presentations require auth)
|
||||
const uploadsPath = path.join(__dirname, '..', 'uploads');
|
||||
app.use('/uploads/branding', express.static(path.join(uploadsPath, 'branding')));
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/rooms', roomRoutes);
|
||||
app.use('/api/recordings', recordingRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/branding', brandingRoutes);
|
||||
app.use('/api/federation', federationRoutes);
|
||||
app.use('/api/calendar', calendarRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api/oauth', oauthRoutes);
|
||||
app.use('/api/analytics', analyticsRoutes);
|
||||
// CalDAV — mounted outside /api so calendar clients use a clean path
|
||||
app.use('/caldav', caldavRoutes);
|
||||
// Mount calendar federation receive also under /api/federation for remote instances
|
||||
app.use('/api/federation', calendarRoutes);
|
||||
app.get('/.well-known/redlight', wellKnownHandler);
|
||||
|
||||
// ── CalDAV service discovery (RFC 6764) ──────────────────────────────────
|
||||
// Clients probe /.well-known/caldav then PROPFIND / before they know the
|
||||
// real CalDAV mount point. Redirect them to /caldav/ for all HTTP methods.
|
||||
app.all('/.well-known/caldav', (req, res) => {
|
||||
res.redirect(301, '/caldav/');
|
||||
});
|
||||
// Some clients (e.g. Thunderbird) send PROPFIND / directly at the server root.
|
||||
// Express doesn't register non-standard methods, so intercept via middleware.
|
||||
app.use('/', (req, res, next) => {
|
||||
if (req.method === 'PROPFIND' && req.path === '/') {
|
||||
return res.redirect(301, '/caldav/');
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Serve static files in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
@@ -45,11 +105,16 @@ async function start() {
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🔴 Redlight server running on http://localhost:${PORT}`);
|
||||
log.server.info(`Redlight server running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
// Start periodic federation sync job (checks remote room settings every 60s)
|
||||
startFederationSync();
|
||||
// Start calendar reminder job (sends in-app + browser notifications before events)
|
||||
startCalendarReminders();
|
||||
}
|
||||
|
||||
start().catch(err => {
|
||||
console.error('❌ Failed to start server:', err);
|
||||
log.server.error(`Failed to start server: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
90
server/jobs/calendarReminders.js
Normal file
90
server/jobs/calendarReminders.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { getDb } from '../config/database.js';
|
||||
import { log } from '../config/logger.js';
|
||||
import { createNotification } from '../config/notifications.js';
|
||||
|
||||
const CHECK_INTERVAL_MS = 60_000; // every minute
|
||||
|
||||
let timer = null;
|
||||
|
||||
/**
|
||||
* Check for upcoming calendar events that need a reminder notification fired.
|
||||
* Runs every minute. Updates `reminder_sent_at` after firing so reminders
|
||||
* are never sent twice. Also resets `reminder_sent_at` to NULL when
|
||||
* start_time or reminder_minutes is changed (handled in calendar route).
|
||||
*/
|
||||
async function runCheck() {
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
// Fetch all events that have a reminder configured and haven't been sent yet
|
||||
const pending = await db.all(`
|
||||
SELECT ce.id, ce.uid, ce.title, ce.start_time, ce.reminder_minutes, ce.user_id,
|
||||
ce.room_uid, ce.color
|
||||
FROM calendar_events ce
|
||||
WHERE ce.reminder_minutes IS NOT NULL
|
||||
AND ce.reminder_sent_at IS NULL
|
||||
`);
|
||||
|
||||
if (pending.length === 0) return;
|
||||
|
||||
const now = new Date();
|
||||
const toFire = pending.filter(ev => {
|
||||
const start = new Date(ev.start_time);
|
||||
// Don't fire reminders for events that started more than 10 minutes ago (server downtime tolerance)
|
||||
if (start < new Date(now.getTime() - 10 * 60_000)) return false;
|
||||
const reminderTime = new Date(start.getTime() - ev.reminder_minutes * 60_000);
|
||||
return reminderTime <= now;
|
||||
});
|
||||
|
||||
for (const ev of toFire) {
|
||||
try {
|
||||
// Mark as sent immediately to prevent double-fire even if notification creation fails
|
||||
await db.run(
|
||||
'UPDATE calendar_events SET reminder_sent_at = ? WHERE id = ?',
|
||||
[now.toISOString(), ev.id],
|
||||
);
|
||||
|
||||
const start = new Date(ev.start_time);
|
||||
const timeStr = start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const dateStr = start.toLocaleDateString([], { day: 'numeric', month: 'short' });
|
||||
const body = `${dateStr} · ${timeStr}`;
|
||||
const link = '/calendar';
|
||||
|
||||
// Notify the owner
|
||||
await createNotification(ev.user_id, 'calendar_reminder', ev.title, body, link);
|
||||
|
||||
// Notify all accepted share users as well
|
||||
const shares = await db.all(
|
||||
'SELECT user_id FROM calendar_event_shares WHERE event_id = ?',
|
||||
[ev.id],
|
||||
);
|
||||
for (const { user_id } of shares) {
|
||||
await createNotification(user_id, 'calendar_reminder', ev.title, body, link);
|
||||
}
|
||||
|
||||
log.server.info(`Calendar reminder fired for event ${ev.uid} (id=${ev.id})`);
|
||||
} catch (evErr) {
|
||||
log.server.error(`Calendar reminder failed for event ${ev.id}: ${evErr.message}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.server.error(`Calendar reminder job error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function startCalendarReminders() {
|
||||
if (timer) return;
|
||||
// Slight delay on startup so DB is fully ready
|
||||
setTimeout(() => {
|
||||
runCheck();
|
||||
timer = setInterval(runCheck, CHECK_INTERVAL_MS);
|
||||
}, 5_000);
|
||||
log.server.info('Calendar reminder job started');
|
||||
}
|
||||
|
||||
export function stopCalendarReminders() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
154
server/jobs/federationSync.js
Normal file
154
server/jobs/federationSync.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import { getDb } from '../config/database.js';
|
||||
import { log, fmtDuration } from '../config/logger.js';
|
||||
import {
|
||||
isFederationEnabled,
|
||||
getFederationDomain,
|
||||
signPayload,
|
||||
discoverInstance,
|
||||
parseAddress,
|
||||
} from '../config/federation.js';
|
||||
|
||||
const SYNC_INTERVAL_MS = 60_000; // 1 minute
|
||||
|
||||
let syncTimer = null;
|
||||
|
||||
/**
|
||||
* Periodic federation sync job.
|
||||
* Groups federated rooms by origin domain, then batch-queries each origin
|
||||
* for current room settings. Updates local records if settings changed or
|
||||
* if the room was deleted on the origin.
|
||||
*/
|
||||
async function runSync() {
|
||||
if (!isFederationEnabled()) return;
|
||||
|
||||
const syncStart = Date.now();
|
||||
let totalUpdated = 0;
|
||||
let totalDeleted = 0;
|
||||
let totalRooms = 0;
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
// Fetch all non-deleted federated rooms
|
||||
const rooms = await db.all(
|
||||
'SELECT id, meet_id, from_user, room_name, max_participants, allow_recording FROM federated_rooms WHERE deleted = 0'
|
||||
);
|
||||
|
||||
if (rooms.length === 0) return;
|
||||
totalRooms = rooms.length;
|
||||
|
||||
// Group by origin domain
|
||||
const byDomain = new Map();
|
||||
for (const room of rooms) {
|
||||
if (!room.meet_id) continue; // no room UID, can't sync
|
||||
const { domain } = parseAddress(room.from_user);
|
||||
if (!domain) continue;
|
||||
if (!byDomain.has(domain)) byDomain.set(domain, []);
|
||||
byDomain.get(domain).push(room);
|
||||
}
|
||||
|
||||
// Query each origin domain
|
||||
for (const [domain, domainRooms] of byDomain) {
|
||||
try {
|
||||
const roomUids = [...new Set(domainRooms.map(r => r.meet_id))];
|
||||
|
||||
const payload = {
|
||||
room_uids: roomUids,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const signature = signPayload(payload);
|
||||
const { baseUrl: remoteApi } = await discoverInstance(domain);
|
||||
|
||||
const response = await fetch(`${remoteApi}/room-sync`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Federation-Signature': signature,
|
||||
'X-Federation-Origin': getFederationDomain(),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
log.fedSync.warn(`${domain} responded with status ${response.status}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const remoteRooms = data.rooms || {};
|
||||
|
||||
// Update local records
|
||||
for (const localRoom of domainRooms) {
|
||||
const remote = remoteRooms[localRoom.meet_id];
|
||||
if (!remote) continue; // UID not in response, skip
|
||||
|
||||
if (remote.deleted) {
|
||||
// Room was deleted on origin
|
||||
await db.run(
|
||||
'UPDATE federated_rooms SET deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[localRoom.id]
|
||||
);
|
||||
totalDeleted++;
|
||||
log.fedSync.info(`Room ${localRoom.meet_id} deleted on origin ${domain}`);
|
||||
} else {
|
||||
// Check if settings changed
|
||||
const changed =
|
||||
localRoom.room_name !== remote.room_name ||
|
||||
(localRoom.max_participants ?? 0) !== (remote.max_participants ?? 0) ||
|
||||
(localRoom.allow_recording ?? 1) !== (remote.allow_recording ?? 1);
|
||||
|
||||
if (changed) {
|
||||
await db.run(
|
||||
`UPDATE federated_rooms
|
||||
SET room_name = ?, max_participants = ?, allow_recording = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
[remote.room_name, remote.max_participants ?? 0, remote.allow_recording ?? 1, localRoom.id]
|
||||
);
|
||||
totalUpdated++;
|
||||
log.fedSync.info(`Room ${localRoom.meet_id} settings updated from ${domain}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.fedSync.warn(`Sync with ${domain} failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary log (only if something happened)
|
||||
if (totalUpdated > 0 || totalDeleted > 0) {
|
||||
log.fedSync.info(
|
||||
`Sync complete: ${totalRooms} rooms, ${totalUpdated} updated, ${totalDeleted} deleted (${fmtDuration(Date.now() - syncStart)})`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
log.fedSync.error(`Sync job failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the periodic federation sync job.
|
||||
*/
|
||||
export function startFederationSync() {
|
||||
if (!isFederationEnabled()) {
|
||||
log.fedSync.info('Disabled (federation not configured)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Run first sync after a short delay to let the server fully start
|
||||
setTimeout(() => {
|
||||
runSync();
|
||||
syncTimer = setInterval(runSync, SYNC_INTERVAL_MS);
|
||||
log.fedSync.info('Started (interval: 60s)');
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the periodic federation sync job.
|
||||
*/
|
||||
export function stopFederationSync() {
|
||||
if (syncTimer) {
|
||||
clearInterval(syncTimer);
|
||||
syncTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,70 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getDb } from '../config/database.js';
|
||||
import redis from '../config/redis.js';
|
||||
import { log } from '../config/logger.js';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-change-me';
|
||||
if (!process.env.JWT_SECRET) {
|
||||
log.auth.error('FATAL: JWT_SECRET environment variable is not set.');
|
||||
process.exit(1);
|
||||
}
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
export async function authenticateToken(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Authentifizierung erforderlich' });
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
// Check JWT blacklist in DragonflyDB (revoked tokens via logout)
|
||||
if (decoded.jti) {
|
||||
try {
|
||||
const revoked = await redis.get(`blacklist:${decoded.jti}`);
|
||||
if (revoked) {
|
||||
return res.status(401).json({ error: 'Token has been revoked' });
|
||||
}
|
||||
} catch (redisErr) {
|
||||
// Graceful degradation: if Redis is unavailable, allow the request
|
||||
log.auth.warn(`Redis blacklist check skipped: ${redisErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const user = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [decoded.userId]);
|
||||
const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified, oauth_provider, totp_enabled FROM users WHERE id = ?', [decoded.userId]);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Benutzer nicht gefunden' });
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(403).json({ error: 'Ungültiges Token' });
|
||||
return res.status(403).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdmin(req, res, next) {
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Administratorrechte erforderlich' });
|
||||
return res.status(403).json({ error: 'Admin rights required' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function generateToken(userId) {
|
||||
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '7d' });
|
||||
const jti = uuidv4();
|
||||
return jwt.sign({ userId, jti }, JWT_SECRET, { expiresIn: '7d' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the public base URL for the application.
|
||||
* Prefers APP_URL env var. Falls back to X-Forwarded-Proto + Host header
|
||||
* so that links are correct behind a TLS-terminating reverse proxy.
|
||||
*/
|
||||
export function getBaseUrl(req) {
|
||||
if (process.env.APP_URL) return process.env.APP_URL.replace(/\/+$/, '');
|
||||
const proto = req.get('x-forwarded-proto')?.split(',')[0]?.trim() || req.protocol;
|
||||
return `${proto}://${req.get('host')}`;
|
||||
}
|
||||
|
||||
25
server/middleware/logging.js
Normal file
25
server/middleware/logging.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { log, fmtDuration, fmtStatus, fmtMethod } from '../config/logger.js';
|
||||
|
||||
export default function requestResponseLogger(req, res, next) {
|
||||
const start = Date.now();
|
||||
const { method, originalUrl } = req;
|
||||
|
||||
res.on('finish', () => {
|
||||
try {
|
||||
const duration = Date.now() - start;
|
||||
const status = res.statusCode;
|
||||
const contentType = (res.getHeader?.('content-type') || '').toString().toLowerCase();
|
||||
const format = contentType.includes('json') ? 'json' : contentType.includes('html') ? 'html' : '';
|
||||
const formatStr = format ? ` ${format}` : '';
|
||||
|
||||
// METHOD /path → status (duration)
|
||||
log.http.info(
|
||||
`${fmtMethod(method)} ${originalUrl} → ${fmtStatus(status)}${formatStr} (${fmtDuration(duration)})`
|
||||
);
|
||||
} catch {
|
||||
// never break the request pipeline
|
||||
}
|
||||
});
|
||||
|
||||
return next();
|
||||
}
|
||||
@@ -1,21 +1,47 @@
|
||||
import { Router } from 'express';
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
|
||||
import { authenticateToken, requireAdmin, getBaseUrl } from '../middleware/auth.js';
|
||||
import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js';
|
||||
import { log } from '../config/logger.js';
|
||||
import {
|
||||
getOAuthConfig,
|
||||
saveOAuthConfig,
|
||||
deleteOAuthConfig,
|
||||
discoverOIDC,
|
||||
} from '../config/oauth.js';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/admin/users - Create user (admin)
|
||||
router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { name, email, password, role } = req.body;
|
||||
const { name, display_name, email, password, role } = req.body;
|
||||
|
||||
if (!name || !email || !password) {
|
||||
return res.status(400).json({ error: 'Alle Felder sind erforderlich' });
|
||||
if (!name || !display_name || !email || !password) {
|
||||
return res.status(400).json({ error: 'All fields are required' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
// L4: display_name length limit
|
||||
if (display_name.length > 100) {
|
||||
return res.status(400).json({ error: 'Display name must not exceed 100 characters' });
|
||||
}
|
||||
|
||||
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
|
||||
if (!usernameRegex.test(name)) {
|
||||
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters long' });
|
||||
}
|
||||
|
||||
// M9: email format validation
|
||||
if (!EMAIL_RE.test(email)) {
|
||||
return res.status(400).json({ error: 'Invalid email address' });
|
||||
}
|
||||
|
||||
const validRole = ['user', 'admin'].includes(role) ? role : 'user';
|
||||
@@ -23,20 +49,25 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
|
||||
|
||||
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'E-Mail wird bereits verwendet' });
|
||||
return res.status(409).json({ error: 'Email is already in use' });
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?)', [name]);
|
||||
if (existingUsername) {
|
||||
return res.status(409).json({ error: 'Username is already taken' });
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
const result = await db.run(
|
||||
'INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)',
|
||||
[name, email.toLowerCase(), hash, validRole]
|
||||
'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)',
|
||||
[name, display_name, email.toLowerCase(), hash, validRole]
|
||||
);
|
||||
|
||||
const user = await db.get('SELECT id, name, email, role, created_at FROM users WHERE id = ?', [result.lastInsertRowid]);
|
||||
const user = await db.get('SELECT id, name, display_name, email, role, created_at FROM users WHERE id = ?', [result.lastInsertRowid]);
|
||||
res.status(201).json({ user });
|
||||
} catch (err) {
|
||||
console.error('Create user error:', err);
|
||||
res.status(500).json({ error: 'Benutzer konnte nicht erstellt werden' });
|
||||
log.admin.error(`Create user error: ${err.message}`);
|
||||
res.status(500).json({ error: 'User could not be created' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -45,7 +76,7 @@ router.get('/users', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const users = await db.all(`
|
||||
SELECT id, name, email, role, language, theme, avatar_color, avatar_image, created_at,
|
||||
SELECT id, name, display_name, email, role, language, theme, avatar_color, avatar_image, created_at,
|
||||
(SELECT COUNT(*) FROM rooms WHERE rooms.user_id = users.id) as room_count
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
@@ -53,8 +84,8 @@ router.get('/users', authenticateToken, requireAdmin, async (req, res) => {
|
||||
|
||||
res.json({ users });
|
||||
} catch (err) {
|
||||
console.error('List users error:', err);
|
||||
res.status(500).json({ error: 'Benutzer konnten nicht geladen werden' });
|
||||
log.admin.error(`List users error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Users could not be loaded' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -63,7 +94,7 @@ router.put('/users/:id/role', authenticateToken, requireAdmin, async (req, res)
|
||||
try {
|
||||
const { role } = req.body;
|
||||
if (!['user', 'admin'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Ungültige Rolle' });
|
||||
return res.status(400).json({ error: 'Invalid role' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
@@ -73,17 +104,22 @@ router.put('/users/:id/role', authenticateToken, requireAdmin, async (req, res)
|
||||
const adminCount = await db.get('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin']);
|
||||
const currentUser = await db.get('SELECT role FROM users WHERE id = ?', [req.params.id]);
|
||||
if (currentUser?.role === 'admin' && adminCount.count <= 1) {
|
||||
return res.status(400).json({ error: 'Der letzte Admin kann nicht herabgestuft werden' });
|
||||
return res.status(400).json({ error: 'The last admin cannot be demoted' });
|
||||
}
|
||||
}
|
||||
|
||||
await db.run('UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [role, req.params.id]);
|
||||
const updated = await db.get('SELECT id, name, email, role, created_at FROM users WHERE id = ?', [req.params.id]);
|
||||
|
||||
// S7: verify user actually exists
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json({ user: updated });
|
||||
} catch (err) {
|
||||
console.error('Update role error:', err);
|
||||
res.status(500).json({ error: 'Rolle konnte nicht aktualisiert werden' });
|
||||
log.admin.error(`Update role error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Role could not be updated' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -93,27 +129,27 @@ router.delete('/users/:id', authenticateToken, requireAdmin, async (req, res) =>
|
||||
const db = getDb();
|
||||
|
||||
if (parseInt(req.params.id) === req.user.id) {
|
||||
return res.status(400).json({ error: 'Sie können sich nicht selbst löschen' });
|
||||
return res.status(400).json({ error: 'You cannot delete yourself' });
|
||||
}
|
||||
|
||||
const user = await db.get('SELECT id, role FROM users WHERE id = ?', [req.params.id]);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Check if it's the last admin
|
||||
if (user.role === 'admin') {
|
||||
const adminCount = await db.get('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin']);
|
||||
if (adminCount.count <= 1) {
|
||||
return res.status(400).json({ error: 'Der letzte Admin kann nicht gelöscht werden' });
|
||||
return res.status(400).json({ error: 'The last admin cannot be deleted' });
|
||||
}
|
||||
}
|
||||
|
||||
await db.run('DELETE FROM users WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: 'Benutzer gelöscht' });
|
||||
res.json({ message: 'User deleted' });
|
||||
} catch (err) {
|
||||
console.error('Delete user error:', err);
|
||||
res.status(500).json({ error: 'Benutzer konnte nicht gelöscht werden' });
|
||||
log.admin.error(`Delete user error: ${err.message}`);
|
||||
res.status(500).json({ error: 'User could not be deleted' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -121,18 +157,208 @@ router.delete('/users/:id', authenticateToken, requireAdmin, async (req, res) =>
|
||||
router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { newPassword } = req.body;
|
||||
if (!newPassword || newPassword.length < 6) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters long' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const hash = bcrypt.hashSync(newPassword, 12);
|
||||
const hash = await bcrypt.hash(newPassword, 12);
|
||||
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.params.id]);
|
||||
|
||||
res.json({ message: 'Passwort zurückgesetzt' });
|
||||
res.json({ message: 'Password reset' });
|
||||
} catch (err) {
|
||||
console.error('Reset password error:', err);
|
||||
res.status(500).json({ error: 'Passwort konnte nicht zurückgesetzt werden' });
|
||||
log.admin.error(`Reset password error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Password could not be reset' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── User Invite System ─────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/admin/invites - Create and send an invite
|
||||
router.post('/invites', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
if (!email || !EMAIL_RE.test(email)) {
|
||||
return res.status(400).json({ error: 'A valid email address is required' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Check if user with this email already exists
|
||||
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'A user with this email already exists' });
|
||||
}
|
||||
|
||||
// Check if there's already a pending invite for this email
|
||||
const existingInvite = await db.get(
|
||||
'SELECT id FROM user_invites WHERE email = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP',
|
||||
[email.toLowerCase()]
|
||||
);
|
||||
if (existingInvite) {
|
||||
return res.status(409).json({ error: 'There is already a pending invite for this email' });
|
||||
}
|
||||
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days
|
||||
|
||||
await db.run(
|
||||
'INSERT INTO user_invites (token, email, created_by, expires_at) VALUES (?, ?, ?, ?)',
|
||||
[token, email.toLowerCase(), req.user.id, expiresAt]
|
||||
);
|
||||
|
||||
// Send invite email if SMTP is configured
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const inviteUrl = `${baseUrl}/register?invite=${token}`;
|
||||
|
||||
// Load app name
|
||||
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'app_name'");
|
||||
const appName = brandingSetting?.value || 'Redlight';
|
||||
|
||||
if (isMailerConfigured()) {
|
||||
try {
|
||||
await sendInviteEmail(email.toLowerCase(), inviteUrl, appName, 'en');
|
||||
} catch (mailErr) {
|
||||
log.admin.warn(`Invite email failed (non-fatal): ${mailErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({ invite: { token, email: email.toLowerCase(), expiresAt, inviteUrl } });
|
||||
} catch (err) {
|
||||
log.admin.error(`Create invite error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Invite could not be created' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/admin/invites - List all invites
|
||||
router.get('/invites', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const invites = await db.all(`
|
||||
SELECT ui.id, ui.token, ui.email, ui.expires_at, ui.created_at, ui.used_at,
|
||||
creator.name as created_by_name,
|
||||
used_user.name as used_by_name
|
||||
FROM user_invites ui
|
||||
LEFT JOIN users creator ON creator.id = ui.created_by
|
||||
LEFT JOIN users used_user ON used_user.id = ui.used_by
|
||||
ORDER BY ui.created_at DESC
|
||||
`);
|
||||
res.json({ invites });
|
||||
} catch (err) {
|
||||
log.admin.error(`List invites error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Invites could not be loaded' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/admin/invites/:id - Delete an invite
|
||||
router.delete('/invites/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const invite = await db.get('SELECT id FROM user_invites WHERE id = ?', [req.params.id]);
|
||||
if (!invite) {
|
||||
return res.status(404).json({ error: 'Invite not found' });
|
||||
}
|
||||
await db.run('DELETE FROM user_invites WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: 'Invite deleted' });
|
||||
} catch (err) {
|
||||
log.admin.error(`Delete invite error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Invite could not be deleted' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── OAuth / SSO Configuration (admin only) ──────────────────────────────────
|
||||
|
||||
// GET /api/admin/oauth - Get current OAuth configuration
|
||||
router.get('/oauth', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const config = await getOAuthConfig();
|
||||
if (!config) {
|
||||
return res.json({ configured: false, config: null });
|
||||
}
|
||||
// Never expose the decrypted client secret to the frontend
|
||||
res.json({
|
||||
configured: true,
|
||||
config: {
|
||||
issuer: config.issuer,
|
||||
clientId: config.clientId,
|
||||
hasClientSecret: !!config.clientSecret,
|
||||
displayName: config.displayName || 'SSO',
|
||||
autoRegister: config.autoRegister ?? true,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
log.admin.error(`Get OAuth config error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Could not load OAuth configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/admin/oauth - Save OAuth configuration
|
||||
router.put('/oauth', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { issuer, clientId, clientSecret, displayName, autoRegister } = req.body;
|
||||
|
||||
if (!issuer || !clientId) {
|
||||
return res.status(400).json({ error: 'Issuer URL and Client ID are required' });
|
||||
}
|
||||
|
||||
// Validate issuer URL
|
||||
try {
|
||||
const parsed = new URL(issuer);
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
return res.status(400).json({ error: 'Issuer URL must use https:// (or http:// for development)' });
|
||||
}
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'Invalid Issuer URL' });
|
||||
}
|
||||
|
||||
// Validate display name length
|
||||
if (displayName && displayName.length > 50) {
|
||||
return res.status(400).json({ error: 'Display name must not exceed 50 characters' });
|
||||
}
|
||||
|
||||
// Check if the existing config has a secret and none is being sent (keep old one)
|
||||
let finalSecret = clientSecret;
|
||||
if (!clientSecret) {
|
||||
const existing = await getOAuthConfig();
|
||||
if (existing?.clientSecret) {
|
||||
finalSecret = existing.clientSecret;
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt OIDC discovery to validate the issuer endpoint
|
||||
try {
|
||||
await discoverOIDC(issuer);
|
||||
} catch (discErr) {
|
||||
return res.status(400).json({
|
||||
error: `Could not discover OIDC configuration at ${issuer}: ${discErr.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
await saveOAuthConfig({
|
||||
issuer,
|
||||
clientId,
|
||||
clientSecret: finalSecret || '',
|
||||
displayName: displayName || 'SSO',
|
||||
autoRegister: autoRegister !== false,
|
||||
});
|
||||
|
||||
log.admin.info(`OAuth configuration saved by admin (issuer: ${issuer})`);
|
||||
res.json({ message: 'OAuth configuration saved' });
|
||||
} catch (err) {
|
||||
log.admin.error(`Save OAuth config error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Could not save OAuth configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/admin/oauth - Remove OAuth configuration
|
||||
router.delete('/oauth', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await deleteOAuthConfig();
|
||||
log.admin.info('OAuth configuration removed by admin');
|
||||
res.json({ message: 'OAuth configuration removed' });
|
||||
} catch (err) {
|
||||
log.admin.error(`Delete OAuth config error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Could not remove OAuth configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
309
server/routes/analytics.js
Normal file
309
server/routes/analytics.js
Normal file
@@ -0,0 +1,309 @@
|
||||
import { Router } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import ExcelJS from 'exceljs';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
import { log } from '../config/logger.js';
|
||||
import { getAnalyticsToken } from '../config/bbb.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/analytics/callback/:uid?token=... - BBB Learning Analytics callback (token-secured)
|
||||
router.post('/callback/:uid', async (req, res) => {
|
||||
try {
|
||||
const { token } = req.query;
|
||||
const expectedToken = getAnalyticsToken(req.params.uid);
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if (!token || token.length !== expectedToken.length ||
|
||||
!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) {
|
||||
return res.status(403).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT id, uid, learning_analytics FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
|
||||
if (!room.learning_analytics) {
|
||||
return res.status(403).json({ error: 'Learning analytics not enabled for this room' });
|
||||
}
|
||||
|
||||
const data = req.body;
|
||||
if (!data || typeof data !== 'object') {
|
||||
return res.status(400).json({ error: 'Invalid analytics data' });
|
||||
}
|
||||
|
||||
// Extract meeting info from BBB learning analytics payload
|
||||
// Format: { meeting_id, internal_meeting_id, data: { metadata: { meeting_name }, duration, attendees, ... } }
|
||||
const meetingId = data.internal_meeting_id || data.meeting_id || room.uid;
|
||||
const meetingName = data.data?.metadata?.meeting_name || data.meeting_id || room.uid;
|
||||
|
||||
// Upsert: update if same meeting already exists (BBB sends updates during the meeting)
|
||||
const existing = await db.get(
|
||||
'SELECT id FROM learning_analytics_data WHERE room_id = ? AND meeting_id = ?',
|
||||
[room.id, meetingId]
|
||||
);
|
||||
|
||||
const jsonData = JSON.stringify(data);
|
||||
|
||||
if (existing) {
|
||||
await db.run(
|
||||
'UPDATE learning_analytics_data SET data = ?, meeting_name = ?, created_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[jsonData, meetingName, existing.id]
|
||||
);
|
||||
} else {
|
||||
await db.run(
|
||||
'INSERT INTO learning_analytics_data (room_id, meeting_id, meeting_name, data) VALUES (?, ?, ?, ?)',
|
||||
[room.id, meetingId, meetingName, jsonData]
|
||||
);
|
||||
}
|
||||
|
||||
log.server.info(`Analytics callback received for room ${room.uid} (meeting: ${meetingId})`);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.server.error(`Analytics callback error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Error processing analytics data' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/analytics/room/:uid - Get analytics for a room (authenticated)
|
||||
router.get('/room/:uid', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT id, user_id, analytics_visibility FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
|
||||
// Check access: owner, shared (if visibility allows), or admin
|
||||
if (room.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
if (room.analytics_visibility !== 'shared') {
|
||||
return res.status(403).json({ error: 'No permission to view analytics for this room' });
|
||||
}
|
||||
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||
if (!share) {
|
||||
return res.status(403).json({ error: 'No permission to view analytics for this room' });
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await db.all(
|
||||
'SELECT id, meeting_id, meeting_name, data, created_at FROM learning_analytics_data WHERE room_id = ? ORDER BY created_at DESC',
|
||||
[room.id]
|
||||
);
|
||||
|
||||
const analytics = rows.map(row => ({
|
||||
id: row.id,
|
||||
meetingId: row.meeting_id,
|
||||
meetingName: row.meeting_name,
|
||||
data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data,
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
|
||||
res.json({ analytics });
|
||||
} catch (err) {
|
||||
log.server.error(`Get analytics error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Error fetching analytics' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/analytics/:id - Delete analytics entry (authenticated, owner only)
|
||||
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const entry = await db.get(
|
||||
`SELECT la.id, la.room_id, r.user_id
|
||||
FROM learning_analytics_data la
|
||||
JOIN rooms r ON la.room_id = r.id
|
||||
WHERE la.id = ?`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
if (!entry) {
|
||||
return res.status(404).json({ error: 'Analytics entry not found' });
|
||||
}
|
||||
|
||||
if (entry.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'No permission to delete this entry' });
|
||||
}
|
||||
|
||||
await db.run('DELETE FROM learning_analytics_data WHERE id = ?', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.server.error(`Delete analytics error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Error deleting analytics entry' });
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: extract flat attendee rows from analytics entry
|
||||
function extractRows(entry) {
|
||||
const data = typeof entry.data === 'string' ? JSON.parse(entry.data) : entry.data;
|
||||
const attendees = data?.data?.attendees || [];
|
||||
const meetingName = data?.data?.metadata?.meeting_name || entry.meeting_name || '';
|
||||
const meetingDuration = data?.data?.duration || 0;
|
||||
const meetingStart = data?.data?.start || '';
|
||||
const meetingFinish = data?.data?.finish || '';
|
||||
|
||||
return attendees.map(a => ({
|
||||
meetingName,
|
||||
meetingStart,
|
||||
meetingFinish,
|
||||
meetingDuration,
|
||||
name: a.name || '',
|
||||
role: a.moderator ? 'Moderator' : 'Viewer',
|
||||
duration: a.duration || 0,
|
||||
talkTime: a.engagement?.talk_time || 0,
|
||||
chats: a.engagement?.chats || 0,
|
||||
talks: a.engagement?.talks || 0,
|
||||
raiseHand: a.engagement?.raisehand || 0,
|
||||
emojis: a.engagement?.emojis || 0,
|
||||
pollVotes: a.engagement?.poll_votes || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
const COLUMNS = [
|
||||
{ header: 'Meeting', key: 'meetingName', width: 25 },
|
||||
{ header: 'Start', key: 'meetingStart', width: 20 },
|
||||
{ header: 'End', key: 'meetingFinish', width: 20 },
|
||||
{ header: 'Meeting Duration (s)', key: 'meetingDuration', width: 18 },
|
||||
{ header: 'Name', key: 'name', width: 25 },
|
||||
{ header: 'Role', key: 'role', width: 12 },
|
||||
{ header: 'Duration (s)', key: 'duration', width: 14 },
|
||||
{ header: 'Talk Time (s)', key: 'talkTime', width: 14 },
|
||||
{ header: 'Chats', key: 'chats', width: 8 },
|
||||
{ header: 'Talks', key: 'talks', width: 8 },
|
||||
{ header: 'Raise Hand', key: 'raiseHand', width: 12 },
|
||||
{ header: 'Emojis', key: 'emojis', width: 8 },
|
||||
{ header: 'Poll Votes', key: 'pollVotes', width: 10 },
|
||||
];
|
||||
|
||||
// GET /api/analytics/:id/export/:format - Export a single analytics entry (csv, xlsx, pdf)
|
||||
router.get('/:id/export/:format', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const format = req.params.format;
|
||||
if (!['csv', 'xlsx', 'pdf'].includes(format)) {
|
||||
return res.status(400).json({ error: 'Unsupported format. Use csv, xlsx, or pdf.' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const entry = await db.get(
|
||||
`SELECT la.*, r.user_id, r.analytics_visibility
|
||||
FROM learning_analytics_data la
|
||||
JOIN rooms r ON la.room_id = r.id
|
||||
WHERE la.id = ?`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
if (!entry) {
|
||||
return res.status(404).json({ error: 'Analytics entry not found' });
|
||||
}
|
||||
|
||||
if (entry.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
if (entry.analytics_visibility !== 'shared') {
|
||||
return res.status(403).json({ error: 'No permission to export this entry' });
|
||||
}
|
||||
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [entry.room_id, req.user.id]);
|
||||
if (!share) {
|
||||
return res.status(403).json({ error: 'No permission to export this entry' });
|
||||
}
|
||||
}
|
||||
|
||||
const rows = extractRows(entry);
|
||||
const safeName = (entry.meeting_name || 'analytics').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
|
||||
if (format === 'csv') {
|
||||
const header = COLUMNS.map(c => c.header).join(',');
|
||||
const csvRows = rows.map(r =>
|
||||
COLUMNS.map(c => {
|
||||
const val = r[c.key];
|
||||
if (typeof val === 'string' && (val.includes(',') || val.includes('"') || val.includes('\n'))) {
|
||||
return '"' + val.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return val;
|
||||
}).join(',')
|
||||
);
|
||||
const csv = [header, ...csvRows].join('\n');
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.csv"`);
|
||||
return res.send('\uFEFF' + csv); // BOM for Excel UTF-8
|
||||
}
|
||||
|
||||
if (format === 'xlsx') {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('Analytics');
|
||||
sheet.columns = COLUMNS;
|
||||
rows.forEach(r => sheet.addRow(r));
|
||||
// Style header row
|
||||
sheet.getRow(1).font = { bold: true };
|
||||
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.xlsx"`);
|
||||
await workbook.xlsx.write(res);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
if (format === 'pdf') {
|
||||
const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 30 });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.pdf"`);
|
||||
doc.pipe(res);
|
||||
|
||||
// Title
|
||||
doc.fontSize(16).text(entry.meeting_name || 'Learning Analytics', { align: 'center' });
|
||||
doc.moveDown(0.5);
|
||||
|
||||
// Table header columns for PDF (subset for readability)
|
||||
const pdfCols = [
|
||||
{ header: 'Name', key: 'name', width: 120 },
|
||||
{ header: 'Role', key: 'role', width: 65 },
|
||||
{ header: 'Duration (s)', key: 'duration', width: 75 },
|
||||
{ header: 'Talk Time (s)', key: 'talkTime', width: 75 },
|
||||
{ header: 'Chats', key: 'chats', width: 50 },
|
||||
{ header: 'Talks', key: 'talks', width: 50 },
|
||||
{ header: 'Raise Hand', key: 'raiseHand', width: 65 },
|
||||
{ header: 'Emojis', key: 'emojis', width: 50 },
|
||||
{ header: 'Poll Votes', key: 'pollVotes', width: 60 },
|
||||
];
|
||||
|
||||
const startX = doc.x;
|
||||
let y = doc.y;
|
||||
|
||||
// Header
|
||||
doc.fontSize(8).font('Helvetica-Bold');
|
||||
pdfCols.forEach((col, i) => {
|
||||
const x = startX + pdfCols.slice(0, i).reduce((s, c) => s + c.width, 0);
|
||||
doc.text(col.header, x, y, { width: col.width, align: 'left' });
|
||||
});
|
||||
y += 14;
|
||||
doc.moveTo(startX, y).lineTo(startX + pdfCols.reduce((s, c) => s + c.width, 0), y).stroke();
|
||||
y += 4;
|
||||
|
||||
// Rows
|
||||
doc.font('Helvetica').fontSize(8);
|
||||
rows.forEach(r => {
|
||||
if (y > doc.page.height - 50) {
|
||||
doc.addPage();
|
||||
y = 30;
|
||||
}
|
||||
pdfCols.forEach((col, i) => {
|
||||
const x = startX + pdfCols.slice(0, i).reduce((s, c) => s + c.width, 0);
|
||||
doc.text(String(r[col.key]), x, y, { width: col.width, align: 'left' });
|
||||
});
|
||||
y += 14;
|
||||
});
|
||||
|
||||
doc.end();
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
log.server.error(`Export analytics error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Error exporting analytics data' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,12 +1,113 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
import { RedisStore } from 'rate-limit-redis';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken, generateToken } from '../middleware/auth.js';
|
||||
import redis from '../config/redis.js';
|
||||
import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js';
|
||||
import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
|
||||
import { getOAuthConfig, discoverOIDC } from '../config/oauth.js';
|
||||
import { log } from '../config/logger.js';
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
log.auth.error('FATAL: JWT_SECRET environment variable is not set.');
|
||||
process.exit(1);
|
||||
}
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
// ── Rate Limiting ────────────────────────────────────────────────────────────
|
||||
function makeRedisStore(prefix) {
|
||||
try {
|
||||
return new RedisStore({
|
||||
sendCommand: (...args) => redis.call(...args),
|
||||
prefix,
|
||||
});
|
||||
} catch {
|
||||
return undefined; // falls back to in-memory if Redis unavailable
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation helpers ─────────────────────────────────────────────────────
|
||||
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
|
||||
|
||||
// Simple format check for theme/language IDs (actual validation happens on the frontend)
|
||||
const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;
|
||||
|
||||
// Allowlist for CSS color values - only permits hsl(), hex (#rgb/#rrggbb) and plain names
|
||||
const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
// ── Rate Limiters ────────────────────────────────────────────────────────────
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many login attempts. Please try again in 15 minutes.' },
|
||||
store: makeRedisStore('rl:login:'),
|
||||
});
|
||||
|
||||
const registerLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many registration attempts. Please try again later.' },
|
||||
store: makeRedisStore('rl:register:'),
|
||||
});
|
||||
|
||||
const profileLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many profile update attempts. Please try again later.' },
|
||||
store: makeRedisStore('rl:profile:'),
|
||||
});
|
||||
|
||||
const passwordLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many password change attempts. Please try again later.' },
|
||||
store: makeRedisStore('rl:password:'),
|
||||
});
|
||||
|
||||
const avatarLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many avatar upload attempts. Please try again later.' },
|
||||
store: makeRedisStore('rl:avatar:'),
|
||||
});
|
||||
|
||||
// S1: rate limit resend-verification to prevent SMTP abuse
|
||||
const resendVerificationLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests. Please try again later.' },
|
||||
store: makeRedisStore('rl:resend:'),
|
||||
});
|
||||
|
||||
const twoFaLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many 2FA attempts. Please try again later.' },
|
||||
store: makeRedisStore('rl:2fa:'),
|
||||
});
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -20,25 +121,65 @@ if (!fs.existsSync(uploadsDir)) {
|
||||
const router = Router();
|
||||
|
||||
// POST /api/auth/register
|
||||
router.post('/register', async (req, res) => {
|
||||
router.post('/register', registerLimiter, async (req, res) => {
|
||||
try {
|
||||
const { name, email, password } = req.body;
|
||||
|
||||
if (!name || !email || !password) {
|
||||
return res.status(400).json({ error: 'Alle Felder sind erforderlich' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
}
|
||||
const { username, display_name, email, password, invite_token } = req.body;
|
||||
|
||||
// Check registration mode
|
||||
const db = getDb();
|
||||
const regModeSetting = await db.get("SELECT value FROM settings WHERE key = 'registration_mode'");
|
||||
const registrationMode = regModeSetting?.value || 'open';
|
||||
|
||||
let validatedInvite = null;
|
||||
if (registrationMode === 'invite') {
|
||||
if (!invite_token) {
|
||||
return res.status(403).json({ error: 'Registration is currently invite-only. You need an invitation link to register.' });
|
||||
}
|
||||
// Validate the invite token
|
||||
validatedInvite = await db.get(
|
||||
'SELECT * FROM user_invites WHERE token = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP',
|
||||
[invite_token]
|
||||
);
|
||||
if (!validatedInvite) {
|
||||
return res.status(403).json({ error: 'Invalid or expired invitation link.' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!username || !display_name || !email || !password) {
|
||||
return res.status(400).json({ error: 'All fields are required' });
|
||||
}
|
||||
|
||||
// L3: display_name length limit (consistent with profile update)
|
||||
if (display_name.length > 100) {
|
||||
return res.status(400).json({ error: 'Display name must not exceed 100 characters' });
|
||||
}
|
||||
|
||||
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
|
||||
if (!usernameRegex.test(username)) {
|
||||
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
|
||||
}
|
||||
|
||||
// M1: email format
|
||||
if (!EMAIL_RE.test(email)) {
|
||||
return res.status(400).json({ error: 'Invalid email address' });
|
||||
}
|
||||
|
||||
// M4: minimum password length
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
|
||||
}
|
||||
|
||||
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'E-Mail wird bereits verwendet' });
|
||||
return res.status(409).json({ error: 'Email is already in use' });
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?)', [username]);
|
||||
if (existingUsername) {
|
||||
return res.status(409).json({ error: 'Username is already taken' });
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
|
||||
// If SMTP is configured, require email verification
|
||||
if (isMailerConfigured()) {
|
||||
@@ -46,12 +187,20 @@ router.post('/register', async (req, res) => {
|
||||
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]
|
||||
'INSERT INTO users (name, display_name, email, password_hash, email_verified, verification_token, verification_token_expires) VALUES (?, ?, ?, ?, 0, ?, ?)',
|
||||
[username, display_name, email.toLowerCase(), hash, verificationToken, expires]
|
||||
);
|
||||
|
||||
// Mark invite as used if applicable
|
||||
if (validatedInvite) {
|
||||
const newUser = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
if (newUser) {
|
||||
await db.run('UPDATE user_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?', [newUser.id, validatedInvite.id]);
|
||||
}
|
||||
}
|
||||
|
||||
// Build verification URL
|
||||
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
|
||||
|
||||
// Load app name from branding settings
|
||||
@@ -61,24 +210,35 @@ router.post('/register', async (req, res) => {
|
||||
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' });
|
||||
try {
|
||||
await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName, 'en');
|
||||
} catch (mailErr) {
|
||||
log.auth.error(`Verification mail failed: ${mailErr.message}`);
|
||||
// Account is created but email failed — user can resend from login page
|
||||
return res.status(201).json({ needsVerification: true, emailFailed: true, message: 'Account created but verification email could not be sent. Please try resending.' });
|
||||
}
|
||||
|
||||
// No SMTP configured – register and login immediately (legacy behaviour)
|
||||
return res.status(201).json({ needsVerification: true, message: 'Verification email has been sent' });
|
||||
}
|
||||
|
||||
// No SMTP configured - register and login immediately (legacy behaviour)
|
||||
const result = await db.run(
|
||||
'INSERT INTO users (name, email, password_hash, email_verified) VALUES (?, ?, ?, 1)',
|
||||
[name, email.toLowerCase(), hash]
|
||||
'INSERT INTO users (name, display_name, email, password_hash, email_verified) VALUES (?, ?, ?, ?, 1)',
|
||||
[username, display_name, email.toLowerCase(), hash]
|
||||
);
|
||||
|
||||
// Mark invite as used if applicable
|
||||
if (validatedInvite) {
|
||||
await db.run('UPDATE user_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?', [result.lastInsertRowid, validatedInvite.id]);
|
||||
}
|
||||
|
||||
const token = generateToken(result.lastInsertRowid);
|
||||
const user = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [result.lastInsertRowid]);
|
||||
const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [result.lastInsertRowid]);
|
||||
|
||||
res.status(201).json({ token, user });
|
||||
} catch (err) {
|
||||
console.error('Register error:', err);
|
||||
res.status(500).json({ error: 'Registrierung fehlgeschlagen' });
|
||||
log.auth.error(`Register error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Registration failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -87,7 +247,7 @@ router.get('/verify-email', async (req, res) => {
|
||||
try {
|
||||
const { token } = req.query;
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: 'Token fehlt' });
|
||||
return res.status(400).json({ error: 'Token is missing' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
@@ -97,11 +257,11 @@ router.get('/verify-email', async (req, res) => {
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).json({ error: 'Ungültiger oder bereits verwendeter Token' });
|
||||
return res.status(400).json({ error: 'Invalid or already used token' });
|
||||
}
|
||||
|
||||
if (new Date(user.verification_token_expires) < new Date()) {
|
||||
return res.status(400).json({ error: 'Token ist abgelaufen. Bitte registriere dich erneut.' });
|
||||
return res.status(400).json({ error: 'Token has expired. Please register again.' });
|
||||
}
|
||||
|
||||
await db.run(
|
||||
@@ -109,42 +269,52 @@ router.get('/verify-email', async (req, res) => {
|
||||
[user.id]
|
||||
);
|
||||
|
||||
res.json({ verified: true, message: 'E-Mail erfolgreich verifiziert' });
|
||||
res.json({ verified: true, message: 'Email verified successfully' });
|
||||
} catch (err) {
|
||||
console.error('Verify email error:', err);
|
||||
res.status(500).json({ error: 'Verifizierung fehlgeschlagen' });
|
||||
log.auth.error(`Verify email error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Verification failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/resend-verification
|
||||
router.post('/resend-verification', async (req, res) => {
|
||||
router.post('/resend-verification', resendVerificationLimiter, async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
if (!email) {
|
||||
return res.status(400).json({ error: 'E-Mail ist erforderlich' });
|
||||
return res.status(400).json({ error: 'Email is required' });
|
||||
}
|
||||
|
||||
if (!isMailerConfigured()) {
|
||||
return res.status(400).json({ error: 'SMTP ist nicht konfiguriert' });
|
||||
return res.status(400).json({ error: 'SMTP is not configured' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const user = await db.get('SELECT id, name, email_verified FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
const user = await db.get('SELECT id, name, display_name, language, email_verified, verification_resend_at FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
|
||||
if (!user || user.email_verified) {
|
||||
// Don't reveal whether account exists
|
||||
return res.json({ message: 'Falls ein Konto existiert, wurde eine neue E-Mail gesendet.' });
|
||||
return res.json({ message: 'If an account exists, a new email has been sent.' });
|
||||
}
|
||||
|
||||
// Server-side 60s rate limit
|
||||
if (user.verification_resend_at) {
|
||||
const secondsAgo = (Date.now() - new Date(user.verification_resend_at).getTime()) / 1000;
|
||||
if (secondsAgo < 60) {
|
||||
const waitSeconds = Math.ceil(60 - secondsAgo);
|
||||
return res.status(429).json({ error: `Please wait ${waitSeconds} seconds before requesting another email.`, waitSeconds });
|
||||
}
|
||||
}
|
||||
|
||||
const verificationToken = uuidv4();
|
||||
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await db.run(
|
||||
'UPDATE users SET verification_token = ?, verification_token_expires = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[verificationToken, expires, user.id]
|
||||
'UPDATE users SET verification_token = ?, verification_token_expires = ?, verification_resend_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[verificationToken, expires, now, user.id]
|
||||
);
|
||||
|
||||
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
|
||||
|
||||
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
|
||||
@@ -153,42 +323,154 @@ router.post('/resend-verification', async (req, res) => {
|
||||
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
|
||||
}
|
||||
|
||||
await sendVerificationEmail(email.toLowerCase(), user.name, verifyUrl, appName);
|
||||
try {
|
||||
await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName, user.language || 'en');
|
||||
} catch (mailErr) {
|
||||
log.auth.error(`Resend verification mail failed: ${mailErr.message}`);
|
||||
return res.status(502).json({ error: 'Email could not be sent. Please check your SMTP configuration.' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Falls ein Konto existiert, wurde eine neue E-Mail gesendet.' });
|
||||
res.json({ message: 'If an account exists, a new email has been sent.' });
|
||||
} catch (err) {
|
||||
console.error('Resend verification error:', err);
|
||||
res.status(500).json({ error: 'E-Mail konnte nicht gesendet werden' });
|
||||
log.auth.error(`Resend verification error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post('/login', async (req, res) => {
|
||||
router.post('/login', loginLimiter, async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' });
|
||||
return res.status(400).json({ error: 'Email and password are required' });
|
||||
}
|
||||
|
||||
// M1: basic email format check - invalid format can never match a real account
|
||||
if (!EMAIL_RE.test(email)) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
|
||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
if (!user.email_verified && isMailerConfigured()) {
|
||||
return res.status(403).json({ error: 'E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach.', needsVerification: true });
|
||||
return res.status(403).json({ error: 'Email address not yet verified. Please check your inbox.', needsVerification: true });
|
||||
}
|
||||
|
||||
// ── 2FA check ────────────────────────────────────────────────────────
|
||||
if (user.totp_enabled) {
|
||||
const tempToken = jwt.sign({ userId: user.id, purpose: '2fa' }, JWT_SECRET, { expiresIn: '5m' });
|
||||
return res.json({ requires2FA: true, tempToken });
|
||||
}
|
||||
|
||||
const token = generateToken(user.id);
|
||||
const { password_hash, ...safeUser } = user;
|
||||
const { password_hash, verification_token, verification_token_expires, verification_resend_at, totp_secret, ...safeUser } = user;
|
||||
|
||||
res.json({ token, user: safeUser });
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
res.status(500).json({ error: 'Anmeldung fehlgeschlagen' });
|
||||
log.auth.error(`Login error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/login/2fa - Verify TOTP code and complete login
|
||||
router.post('/login/2fa', twoFaLimiter, async (req, res) => {
|
||||
try {
|
||||
const { tempToken, code } = req.body;
|
||||
if (!tempToken || !code) {
|
||||
return res.status(400).json({ error: 'Token and code are required' });
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(tempToken, JWT_SECRET);
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid or expired token. Please log in again.' });
|
||||
}
|
||||
|
||||
if (decoded.purpose !== '2fa') {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const user = await db.get('SELECT * FROM users WHERE id = ?', [decoded.userId]);
|
||||
if (!user || !user.totp_enabled || !user.totp_secret) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
const totp = new OTPAuth.TOTP({
|
||||
secret: OTPAuth.Secret.fromBase32(user.totp_secret),
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
});
|
||||
|
||||
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
|
||||
if (delta === null) {
|
||||
return res.status(401).json({ error: 'Invalid 2FA code' });
|
||||
}
|
||||
|
||||
const token = generateToken(user.id);
|
||||
const { password_hash, verification_token, verification_token_expires, verification_resend_at, totp_secret, ...safeUser } = user;
|
||||
|
||||
res.json({ token, user: safeUser });
|
||||
} catch (err) {
|
||||
log.auth.error(`2FA login error: ${err.message}`);
|
||||
res.status(500).json({ error: '2FA verification failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/logout - revoke JWT via DragonflyDB blacklist
|
||||
router.post('/logout', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
const decoded = jwt.decode(token);
|
||||
|
||||
if (decoded?.jti && decoded?.exp) {
|
||||
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
|
||||
if (ttl > 0) {
|
||||
try {
|
||||
await redis.setex(`blacklist:${decoded.jti}`, ttl, '1');
|
||||
} catch (redisErr) {
|
||||
log.auth.warn(`Redis blacklist write failed: ${redisErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── RP-Initiated Logout for OIDC/Keycloak users ──────────────────────
|
||||
let keycloakLogoutUrl = null;
|
||||
if (req.user.oauth_provider === 'oidc') {
|
||||
try {
|
||||
const config = await getOAuthConfig();
|
||||
if (config) {
|
||||
const oidc = await discoverOIDC(config.issuer);
|
||||
if (oidc.end_session_endpoint) {
|
||||
const idToken = await redis.get(`oidc:id_token:${req.user.id}`);
|
||||
await redis.del(`oidc:id_token:${req.user.id}`);
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const params = new URLSearchParams({
|
||||
post_logout_redirect_uri: `${baseUrl}/`,
|
||||
client_id: config.clientId,
|
||||
});
|
||||
if (idToken) params.set('id_token_hint', idToken);
|
||||
keycloakLogoutUrl = `${oidc.end_session_endpoint}?${params.toString()}`;
|
||||
}
|
||||
}
|
||||
} catch (oidcErr) {
|
||||
log.auth.warn(`Could not build Keycloak logout URL: ${oidcErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: 'Logged out successfully', keycloakLogoutUrl });
|
||||
} catch (err) {
|
||||
log.auth.error(`Logout error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -198,84 +480,154 @@ router.get('/me', authenticateToken, (req, res) => {
|
||||
});
|
||||
|
||||
// PUT /api/auth/profile
|
||||
router.put('/profile', authenticateToken, async (req, res) => {
|
||||
router.put('/profile', authenticateToken, profileLimiter, async (req, res) => {
|
||||
try {
|
||||
const { name, email, theme, language, avatar_color } = req.body;
|
||||
const { name, display_name, email, theme, language, avatar_color } = req.body;
|
||||
const db = getDb();
|
||||
|
||||
// M1: validate new email format
|
||||
if (email && !EMAIL_RE.test(email)) {
|
||||
return res.status(400).json({ error: 'Invalid email address' });
|
||||
}
|
||||
|
||||
if (email && email !== req.user.email) {
|
||||
const existing = await db.get('SELECT id FROM users WHERE email = ? AND id != ?', [email.toLowerCase(), req.user.id]);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'E-Mail wird bereits verwendet' });
|
||||
return res.status(409).json({ error: 'Email is already in use' });
|
||||
}
|
||||
}
|
||||
|
||||
// M2: display_name length limit
|
||||
if (display_name !== undefined && display_name !== null && display_name.length > 100) {
|
||||
return res.status(400).json({ error: 'Display name must not exceed 100 characters' });
|
||||
}
|
||||
|
||||
// Theme and language: basic format validation (frontend handles actual ID matching)
|
||||
if (theme !== undefined && theme !== null && (typeof theme !== 'string' || !SAFE_ID_RE.test(theme))) {
|
||||
return res.status(400).json({ error: 'Invalid theme' });
|
||||
}
|
||||
if (language !== undefined && language !== null && (typeof language !== 'string' || !SAFE_ID_RE.test(language))) {
|
||||
return res.status(400).json({ error: 'Invalid language' });
|
||||
}
|
||||
|
||||
// L5: validate avatar_color format/length
|
||||
if (avatar_color !== undefined && avatar_color !== null) {
|
||||
if (typeof avatar_color !== 'string' || !SAFE_COLOR_RE.test(avatar_color)) {
|
||||
return res.status(400).json({ error: 'Invalid avatar color' });
|
||||
}
|
||||
}
|
||||
|
||||
if (name && name !== req.user.name) {
|
||||
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
|
||||
if (!usernameRegex.test(name)) {
|
||||
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
|
||||
}
|
||||
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?) AND id != ?', [name, req.user.id]);
|
||||
if (existingUsername) {
|
||||
return res.status(409).json({ error: 'Username is already taken' });
|
||||
}
|
||||
}
|
||||
|
||||
await db.run(`
|
||||
UPDATE users SET
|
||||
name = COALESCE(?, name),
|
||||
display_name = COALESCE(?, display_name),
|
||||
email = COALESCE(?, email),
|
||||
theme = COALESCE(?, theme),
|
||||
language = COALESCE(?, language),
|
||||
avatar_color = COALESCE(?, avatar_color),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`, [name, email?.toLowerCase(), theme, language, avatar_color, req.user.id]);
|
||||
`, [name, display_name, email?.toLowerCase(), theme, language, avatar_color, req.user.id]);
|
||||
|
||||
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
|
||||
const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]);
|
||||
res.json({ user: updated });
|
||||
} catch (err) {
|
||||
console.error('Profile update error:', err);
|
||||
res.status(500).json({ error: 'Profil konnte nicht aktualisiert werden' });
|
||||
log.auth.error(`Profile update error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Profile could not be updated' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/auth/password
|
||||
router.put('/password', authenticateToken, async (req, res) => {
|
||||
router.put('/password', authenticateToken, passwordLimiter, async (req, res) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
// M6: guard against missing/non-string body values
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: 'currentPassword and newPassword are required' });
|
||||
}
|
||||
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
|
||||
return res.status(400).json({ error: 'Invalid input' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const user = await db.get('SELECT password_hash FROM users WHERE id = ?', [req.user.id]);
|
||||
if (!bcrypt.compareSync(currentPassword, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'Aktuelles Passwort ist falsch' });
|
||||
return res.status(401).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return res.status(400).json({ error: 'Neues Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
// M4: minimum password length
|
||||
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
||||
return res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(newPassword, 12);
|
||||
const hash = await bcrypt.hash(newPassword, 12);
|
||||
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.user.id]);
|
||||
|
||||
res.json({ message: 'Passwort erfolgreich geändert' });
|
||||
res.json({ message: 'Password changed successfully' });
|
||||
} catch (err) {
|
||||
console.error('Password change error:', err);
|
||||
res.status(500).json({ error: 'Passwort konnte nicht geändert werden' });
|
||||
log.auth.error(`Password change error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Password could not be changed' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/avatar - Upload avatar image
|
||||
router.post('/avatar', authenticateToken, async (req, res) => {
|
||||
router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
|
||||
try {
|
||||
const buffer = await new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', chunk => chunks.push(chunk));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
|
||||
// Validate content type
|
||||
// Validate file content by checking magic bytes (file signatures)
|
||||
const contentType = req.headers['content-type'];
|
||||
if (!contentType || !contentType.startsWith('image/')) {
|
||||
return res.status(400).json({ error: 'Nur Bilddateien sind erlaubt' });
|
||||
return res.status(400).json({ error: 'Only image files are allowed' });
|
||||
}
|
||||
|
||||
// Max 2MB
|
||||
if (buffer.length > 2 * 1024 * 1024) {
|
||||
return res.status(400).json({ error: 'Bild darf maximal 2MB groß sein' });
|
||||
// M15: stream-level size limit - abort as soon as 2 MB is exceeded
|
||||
const MAX_AVATAR_SIZE = 2 * 1024 * 1024;
|
||||
const buffer = await new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let totalSize = 0;
|
||||
req.on('data', chunk => {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_AVATAR_SIZE) {
|
||||
req.destroy();
|
||||
return reject(new Error('LIMIT_EXCEEDED'));
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
}).catch(err => {
|
||||
if (err.message === 'LIMIT_EXCEEDED') return null;
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (!buffer) {
|
||||
return res.status(400).json({ error: 'Image must not exceed 2MB' });
|
||||
}
|
||||
|
||||
const ext = contentType.includes('png') ? 'png' : contentType.includes('gif') ? 'gif' : contentType.includes('webp') ? 'webp' : 'jpg';
|
||||
// Validate magic bytes to prevent Content-Type spoofing
|
||||
const magicBytes = buffer.slice(0, 8);
|
||||
const isJPEG = magicBytes[0] === 0xFF && magicBytes[1] === 0xD8 && magicBytes[2] === 0xFF;
|
||||
const isPNG = magicBytes[0] === 0x89 && magicBytes[1] === 0x50 && magicBytes[2] === 0x4E && magicBytes[3] === 0x47;
|
||||
const isGIF = magicBytes[0] === 0x47 && magicBytes[1] === 0x49 && magicBytes[2] === 0x46;
|
||||
const isWEBP = magicBytes[0] === 0x52 && magicBytes[1] === 0x49 && magicBytes[2] === 0x46 && magicBytes[3] === 0x46
|
||||
&& buffer.length > 11 && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50;
|
||||
if (!isJPEG && !isPNG && !isGIF && !isWEBP) {
|
||||
return res.status(400).json({ error: 'File content does not match a supported image format (JPEG, PNG, GIF, WebP)' });
|
||||
}
|
||||
|
||||
const ext = isPNG ? 'png' : isGIF ? 'gif' : isWEBP ? 'webp' : 'jpg';
|
||||
const filename = `${req.user.id}_${Date.now()}.${ext}`;
|
||||
const filepath = path.join(uploadsDir, filename);
|
||||
|
||||
@@ -283,53 +635,70 @@ router.post('/avatar', authenticateToken, async (req, res) => {
|
||||
const db = getDb();
|
||||
const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]);
|
||||
if (current?.avatar_image) {
|
||||
const oldPath = path.join(uploadsDir, current.avatar_image);
|
||||
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||
// S8: defense-in-depth path traversal check on DB-stored filename
|
||||
const oldPath = path.resolve(uploadsDir, current.avatar_image);
|
||||
if (oldPath.startsWith(uploadsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filepath, buffer);
|
||||
|
||||
await db.run('UPDATE users SET avatar_image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [filename, req.user.id]);
|
||||
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
|
||||
|
||||
const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]);
|
||||
res.json({ user: updated });
|
||||
} catch (err) {
|
||||
console.error('Avatar upload error:', err);
|
||||
res.status(500).json({ error: 'Avatar konnte nicht hochgeladen werden' });
|
||||
log.auth.error(`Avatar upload error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Avatar could not be uploaded' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/auth/avatar - Remove avatar image
|
||||
router.delete('/avatar', authenticateToken, async (req, res) => {
|
||||
router.delete('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]);
|
||||
if (current?.avatar_image) {
|
||||
const oldPath = path.join(uploadsDir, current.avatar_image);
|
||||
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||
// S8: defense-in-depth path traversal check on DB-stored filename
|
||||
const oldPath = path.resolve(uploadsDir, current.avatar_image);
|
||||
if (oldPath.startsWith(uploadsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||
}
|
||||
await db.run('UPDATE users SET avatar_image = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]);
|
||||
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
|
||||
const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]);
|
||||
res.json({ user: updated });
|
||||
} catch (err) {
|
||||
console.error('Avatar delete error:', err);
|
||||
res.status(500).json({ error: 'Avatar konnte nicht entfernt werden' });
|
||||
log.auth.error(`Avatar delete error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Avatar could not be removed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Escape XML special characters to prevent XSS in SVG text/attribute contexts
|
||||
function escapeXml(str) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// C1 fix: validate color against a strict allowlist before embedding in SVG attribute
|
||||
const rawColor = req.query.color || '';
|
||||
const color = SAFE_COLOR_RE.test(rawColor) ? rawColor : generateColorFromName(name);
|
||||
|
||||
// C2 fix: XML-escape initials before embedding in SVG text node
|
||||
const rawInitials = name
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2) || '?';
|
||||
const initials = escapeXml(rawInitials);
|
||||
|
||||
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}"/>
|
||||
<rect width="128" height="128" rx="64" fill="${escapeXml(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>`;
|
||||
|
||||
@@ -349,9 +718,13 @@ function generateColorFromName(name) {
|
||||
|
||||
// GET /api/auth/avatar/:filename - Serve avatar image
|
||||
router.get('/avatar/:filename', (req, res) => {
|
||||
const filepath = path.join(uploadsDir, req.params.filename);
|
||||
// H1 fix: resolve the path and ensure it stays inside uploadsDir (prevent path traversal)
|
||||
const filepath = path.resolve(uploadsDir, req.params.filename);
|
||||
if (!filepath.startsWith(uploadsDir + path.sep)) {
|
||||
return res.status(400).json({ error: 'Invalid filename' });
|
||||
}
|
||||
if (!fs.existsSync(filepath)) {
|
||||
return res.status(404).json({ error: 'Avatar nicht gefunden' });
|
||||
return res.status(404).json({ error: 'Avatar not found' });
|
||||
}
|
||||
const ext = path.extname(filepath).slice(1);
|
||||
const mimeMap = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp' };
|
||||
@@ -360,4 +733,125 @@ router.get('/avatar/:filename', (req, res) => {
|
||||
fs.createReadStream(filepath).pipe(res);
|
||||
});
|
||||
|
||||
// ── 2FA Management ──────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/auth/2fa/status
|
||||
router.get('/2fa/status', authenticateToken, async (req, res) => {
|
||||
const db = getDb();
|
||||
const user = await db.get('SELECT totp_enabled FROM users WHERE id = ?', [req.user.id]);
|
||||
res.json({ enabled: !!user?.totp_enabled });
|
||||
});
|
||||
|
||||
// POST /api/auth/2fa/setup - Generate TOTP secret + provisioning URI
|
||||
router.post('/2fa/setup', authenticateToken, twoFaLimiter, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const user = await db.get('SELECT totp_enabled FROM users WHERE id = ?', [req.user.id]);
|
||||
if (user?.totp_enabled) {
|
||||
return res.status(400).json({ error: '2FA is already enabled' });
|
||||
}
|
||||
|
||||
const secret = new OTPAuth.Secret({ size: 20 });
|
||||
|
||||
// Load app name from branding settings
|
||||
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
|
||||
let issuer = 'Redlight';
|
||||
if (brandingSetting?.value) {
|
||||
try { issuer = JSON.parse(brandingSetting.value).appName || issuer; } catch {}
|
||||
}
|
||||
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer,
|
||||
label: req.user.email,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret,
|
||||
});
|
||||
|
||||
// Store the secret (but don't enable yet — user must verify first)
|
||||
await db.run('UPDATE users SET totp_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [secret.base32, req.user.id]);
|
||||
|
||||
res.json({ secret: secret.base32, uri: totp.toString() });
|
||||
} catch (err) {
|
||||
log.auth.error(`2FA setup error: ${err.message}`);
|
||||
res.status(500).json({ error: '2FA setup failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/2fa/enable - Verify code and activate 2FA
|
||||
router.post('/2fa/enable', authenticateToken, twoFaLimiter, async (req, res) => {
|
||||
try {
|
||||
const { code } = req.body;
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Code is required' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const user = await db.get('SELECT totp_secret, totp_enabled FROM users WHERE id = ?', [req.user.id]);
|
||||
if (!user?.totp_secret) {
|
||||
return res.status(400).json({ error: 'Please run 2FA setup first' });
|
||||
}
|
||||
if (user.totp_enabled) {
|
||||
return res.status(400).json({ error: '2FA is already enabled' });
|
||||
}
|
||||
|
||||
const totp = new OTPAuth.TOTP({
|
||||
secret: OTPAuth.Secret.fromBase32(user.totp_secret),
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
});
|
||||
|
||||
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
|
||||
if (delta === null) {
|
||||
return res.status(401).json({ error: 'Invalid code. Please try again.' });
|
||||
}
|
||||
|
||||
await db.run('UPDATE users SET totp_enabled = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]);
|
||||
res.json({ enabled: true, message: '2FA has been enabled' });
|
||||
} catch (err) {
|
||||
log.auth.error(`2FA enable error: ${err.message}`);
|
||||
res.status(500).json({ error: '2FA could not be enabled' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/2fa/disable - Disable 2FA (requires password + TOTP code)
|
||||
router.post('/2fa/disable', authenticateToken, twoFaLimiter, async (req, res) => {
|
||||
try {
|
||||
const { password, code } = req.body;
|
||||
if (!password || !code) {
|
||||
return res.status(400).json({ error: 'Password and code are required' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const user = await db.get('SELECT password_hash, totp_secret, totp_enabled FROM users WHERE id = ?', [req.user.id]);
|
||||
if (!user?.totp_enabled) {
|
||||
return res.status(400).json({ error: '2FA is not enabled' });
|
||||
}
|
||||
|
||||
if (!bcrypt.compareSync(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'Invalid password' });
|
||||
}
|
||||
|
||||
const totp = new OTPAuth.TOTP({
|
||||
secret: OTPAuth.Secret.fromBase32(user.totp_secret),
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
});
|
||||
|
||||
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
|
||||
if (delta === null) {
|
||||
return res.status(401).json({ error: 'Invalid 2FA code' });
|
||||
}
|
||||
|
||||
await db.run('UPDATE users SET totp_enabled = 0, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]);
|
||||
res.json({ enabled: false, message: '2FA has been disabled' });
|
||||
} catch (err) {
|
||||
log.auth.error(`2FA disable error: ${err.message}`);
|
||||
res.status(500).json({ error: '2FA could not be disabled' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -5,12 +5,26 @@ import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
|
||||
import { log } from '../config/logger.js';
|
||||
import { getOAuthConfig } from '../config/oauth.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const router = Router();
|
||||
|
||||
const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;
|
||||
|
||||
// Validate that a URL uses a safe scheme (http/https only)
|
||||
function isSafeUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure uploads/branding directory exists
|
||||
const brandingDir = path.join(__dirname, '..', '..', 'uploads', 'branding');
|
||||
if (!fs.existsSync(brandingDir)) {
|
||||
@@ -76,15 +90,40 @@ function findLogoFile() {
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const appName = await getSetting('app_name');
|
||||
const defaultTheme = await getSetting('default_theme');
|
||||
const logoFile = findLogoFile();
|
||||
|
||||
const registrationMode = await getSetting('registration_mode');
|
||||
const imprintUrl = await getSetting('imprint_url');
|
||||
const privacyUrl = await getSetting('privacy_url');
|
||||
|
||||
// OAuth: expose whether OAuth is enabled + display name for login page
|
||||
let oauthEnabled = false;
|
||||
let oauthDisplayName = null;
|
||||
try {
|
||||
const oauthConfig = await getOAuthConfig();
|
||||
if (oauthConfig) {
|
||||
oauthEnabled = true;
|
||||
oauthDisplayName = oauthConfig.displayName || 'SSO';
|
||||
}
|
||||
} catch { /* not configured */ }
|
||||
|
||||
const hideAppName = await getSetting('hide_app_name');
|
||||
|
||||
res.json({
|
||||
appName: appName || 'Redlight',
|
||||
hasLogo: !!logoFile,
|
||||
logoUrl: logoFile ? '/api/branding/logo' : null,
|
||||
defaultTheme: defaultTheme || null,
|
||||
registrationMode: registrationMode || 'open',
|
||||
imprintUrl: imprintUrl || null,
|
||||
privacyUrl: privacyUrl || null,
|
||||
oauthEnabled,
|
||||
oauthDisplayName,
|
||||
hideAppName: hideAppName === 'true',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Get branding error:', err);
|
||||
log.branding.error('Get branding error:', err);
|
||||
res.status(500).json({ error: 'Could not load branding' });
|
||||
}
|
||||
});
|
||||
@@ -95,6 +134,15 @@ router.get('/logo', (req, res) => {
|
||||
if (!logoFile) {
|
||||
return res.status(404).json({ error: 'No logo found' });
|
||||
}
|
||||
// H5: serve SVG as attachment (Content-Disposition) to prevent in-browser script execution.
|
||||
// For non-SVG images, inline display is fine.
|
||||
const ext = path.extname(logoFile).toLowerCase();
|
||||
if (ext === '.svg') {
|
||||
res.setHeader('Content-Type', 'image/svg+xml');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="logo.svg"');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
return res.sendFile(logoFile);
|
||||
}
|
||||
res.sendFile(logoFile);
|
||||
});
|
||||
|
||||
@@ -103,7 +151,7 @@ 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.code === 'LIMIT_FILE_SIZE' ? 'File too large (max 5MB)' : err.message });
|
||||
}
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
@@ -136,7 +184,7 @@ router.delete('/logo', authenticateToken, requireAdmin, async (req, res) => {
|
||||
}
|
||||
res.json({ message: 'Logo removed' });
|
||||
} catch (err) {
|
||||
console.error('Delete logo error:', err);
|
||||
log.branding.error('Delete logo error:', err);
|
||||
res.status(500).json({ error: 'Could not remove logo' });
|
||||
}
|
||||
});
|
||||
@@ -148,12 +196,112 @@ router.put('/name', authenticateToken, requireAdmin, async (req, res) => {
|
||||
if (!appName || !appName.trim()) {
|
||||
return res.status(400).json({ error: 'App name is required' });
|
||||
}
|
||||
if (appName.trim().length > 100) {
|
||||
return res.status(400).json({ error: 'App name must not exceed 100 characters' });
|
||||
}
|
||||
await setSetting('app_name', appName.trim());
|
||||
res.json({ appName: appName.trim() });
|
||||
} catch (err) {
|
||||
console.error('Update app name error:', err);
|
||||
log.branding.error('Update app name error:', err);
|
||||
res.status(500).json({ error: 'Could not update app name' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/branding/default-theme - Set default theme for unauthenticated pages (admin only)
|
||||
router.put('/default-theme', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { defaultTheme } = req.body;
|
||||
if (!defaultTheme || !defaultTheme.trim()) {
|
||||
return res.status(400).json({ error: 'defaultTheme is required' });
|
||||
}
|
||||
// Basic format validation for theme ID
|
||||
if (!SAFE_ID_RE.test(defaultTheme.trim())) {
|
||||
return res.status(400).json({ error: 'Invalid theme' });
|
||||
}
|
||||
await setSetting('default_theme', defaultTheme.trim());
|
||||
res.json({ defaultTheme: defaultTheme.trim() });
|
||||
} catch (err) {
|
||||
log.branding.error('Update default theme error:', err);
|
||||
res.status(500).json({ error: 'Could not update default theme' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/branding/registration-mode - Set registration mode (admin only)
|
||||
router.put('/registration-mode', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { registrationMode } = req.body;
|
||||
if (!registrationMode || !['open', 'invite'].includes(registrationMode)) {
|
||||
return res.status(400).json({ error: 'registrationMode must be "open" or "invite"' });
|
||||
}
|
||||
await setSetting('registration_mode', registrationMode);
|
||||
res.json({ registrationMode });
|
||||
} catch (err) {
|
||||
log.branding.error('Update registration mode error:', err);
|
||||
res.status(500).json({ error: 'Could not update registration mode' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/branding/imprint-url - Set imprint URL (admin only)
|
||||
router.put('/imprint-url', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { imprintUrl } = req.body;
|
||||
if (imprintUrl && imprintUrl.length > 500) {
|
||||
return res.status(400).json({ error: 'URL must not exceed 500 characters' });
|
||||
}
|
||||
if (imprintUrl && imprintUrl.trim() && !isSafeUrl(imprintUrl.trim())) {
|
||||
return res.status(400).json({ error: 'URL must start with http:// or https://' });
|
||||
}
|
||||
if (imprintUrl && imprintUrl.trim()) {
|
||||
await setSetting('imprint_url', imprintUrl.trim());
|
||||
} else {
|
||||
await deleteSetting('imprint_url');
|
||||
}
|
||||
res.json({ imprintUrl: imprintUrl?.trim() || null });
|
||||
} catch (err) {
|
||||
log.branding.error('Update imprint URL error:', err);
|
||||
res.status(500).json({ error: 'Could not update imprint URL' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/branding/privacy-url - Set privacy policy URL (admin only)
|
||||
router.put('/privacy-url', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { privacyUrl } = req.body;
|
||||
if (privacyUrl && privacyUrl.length > 500) {
|
||||
return res.status(400).json({ error: 'URL must not exceed 500 characters' });
|
||||
}
|
||||
if (privacyUrl && privacyUrl.trim() && !isSafeUrl(privacyUrl.trim())) {
|
||||
return res.status(400).json({ error: 'URL must start with http:// or https://' });
|
||||
}
|
||||
if (privacyUrl && privacyUrl.trim()) {
|
||||
await setSetting('privacy_url', privacyUrl.trim());
|
||||
} else {
|
||||
await deleteSetting('privacy_url');
|
||||
}
|
||||
res.json({ privacyUrl: privacyUrl?.trim() || null });
|
||||
} catch (err) {
|
||||
log.branding.error('Update privacy URL error:', err);
|
||||
res.status(500).json({ error: 'Could not update privacy URL' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/branding/hide-app-name - Toggle app name visibility (admin only)
|
||||
router.put('/hide-app-name', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { hideAppName } = req.body;
|
||||
if (typeof hideAppName !== 'boolean') {
|
||||
return res.status(400).json({ error: 'hideAppName must be a boolean' });
|
||||
}
|
||||
if (hideAppName) {
|
||||
await setSetting('hide_app_name', 'true');
|
||||
} else {
|
||||
await deleteSetting('hide_app_name');
|
||||
}
|
||||
res.json({ hideAppName });
|
||||
} catch (err) {
|
||||
log.branding.error('Update hide app name error:', err);
|
||||
res.status(500).json({ error: 'Could not update setting' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
537
server/routes/caldav.js
Normal file
537
server/routes/caldav.js
Normal file
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* CalDAV server for Redlight
|
||||
*
|
||||
* Supports PROPFIND, REPORT, GET, PUT, DELETE, OPTIONS — enough for
|
||||
* Thunderbird/Lightning, Apple Calendar, GNOME Calendar and DAVx⁵ (Android).
|
||||
*
|
||||
* Authentication: HTTP Basic Auth → email:caldav_token
|
||||
* Token management: POST/GET/DELETE /api/calendar/caldav-tokens
|
||||
*
|
||||
* Mounted at: /caldav
|
||||
*/
|
||||
|
||||
import { Router, text } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { log } from '../config/logger.js';
|
||||
import { getBaseUrl } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ── Body parsing for XML and iCalendar payloads ────────────────────────────
|
||||
router.use(text({ type: ['application/xml', 'text/xml', 'text/calendar', 'application/octet-stream'] }));
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function xmlHeader() {
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>';
|
||||
}
|
||||
|
||||
function escapeXml(str) {
|
||||
return String(str || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeICS(str) {
|
||||
return String(str || '')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\n/g, '\\n');
|
||||
}
|
||||
|
||||
function foldICSLine(line) {
|
||||
// RFC 5545: fold lines longer than 75 octets
|
||||
const bytes = Buffer.from(line, 'utf8');
|
||||
if (bytes.length <= 75) return line;
|
||||
const chunks = [];
|
||||
let offset = 0;
|
||||
let first = true;
|
||||
while (offset < bytes.length) {
|
||||
const chunk = first ? bytes.slice(0, 75) : bytes.slice(offset, offset + 74);
|
||||
chunks.push((first ? '' : ' ') + chunk.toString('utf8'));
|
||||
offset += first ? 75 : 74;
|
||||
first = false;
|
||||
}
|
||||
return chunks.join('\r\n');
|
||||
}
|
||||
|
||||
function toICSDate(dateStr) {
|
||||
const d = new Date(dateStr);
|
||||
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
}
|
||||
|
||||
function parseICSDate(str) {
|
||||
if (!str) return null;
|
||||
// Strip TZID= prefix if present
|
||||
const raw = str.includes(':') ? str.split(':').pop() : str;
|
||||
if (raw.length === 8) {
|
||||
// All-day: YYYYMMDD
|
||||
return new Date(`${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}T00:00:00Z`);
|
||||
}
|
||||
return new Date(
|
||||
`${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}T` +
|
||||
`${raw.slice(9, 11)}:${raw.slice(11, 13)}:${raw.slice(13, 15)}` +
|
||||
(raw.endsWith('Z') ? 'Z' : 'Z'),
|
||||
);
|
||||
}
|
||||
|
||||
function getICSProp(ics, key) {
|
||||
const re = new RegExp(`^${key}(?:;[^:]*)?:(.+)$`, 'im');
|
||||
const m = ics.match(re);
|
||||
if (!m) return null;
|
||||
// Unfold: join continuation lines
|
||||
let v = m[1];
|
||||
const unfoldRe = /\r?\n[ \t](.+)/g;
|
||||
v = v.replace(unfoldRe, '$1');
|
||||
return v.trim();
|
||||
}
|
||||
|
||||
function eventToICS(event, base, user) {
|
||||
// Determine the most useful join URL
|
||||
const joinUrl = event.federated_join_url
|
||||
|| (event.room_uid ? `${base}/join/${event.room_uid}` : null);
|
||||
const roomUrl = event.room_uid ? `${base}/rooms/${event.room_uid}` : null;
|
||||
|
||||
const lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Redlight//CalDAV//EN',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${event.uid}`,
|
||||
`SUMMARY:${escapeICS(event.title)}`,
|
||||
`DTSTART:${toICSDate(event.start_time)}`,
|
||||
`DTEND:${toICSDate(event.end_time)}`,
|
||||
`DTSTAMP:${toICSDate(event.updated_at || event.created_at)}`,
|
||||
`LAST-MODIFIED:${toICSDate(event.updated_at || event.created_at)}`,
|
||||
];
|
||||
|
||||
// LOCATION: show join link so calendar apps display "where" the meeting is
|
||||
if (joinUrl) {
|
||||
lines.push(`LOCATION:${escapeICS(joinUrl)}`);
|
||||
lines.push(`URL:${joinUrl}`);
|
||||
} else if (roomUrl) {
|
||||
lines.push(`LOCATION:${escapeICS(roomUrl)}`);
|
||||
lines.push(`URL:${roomUrl}`);
|
||||
}
|
||||
|
||||
// DESCRIPTION: combine user description + join link hint
|
||||
const descParts = [];
|
||||
if (event.description) descParts.push(event.description);
|
||||
if (joinUrl) {
|
||||
descParts.push(`Join meeting: ${joinUrl}`);
|
||||
}
|
||||
if (roomUrl && roomUrl !== joinUrl) {
|
||||
descParts.push(`Room page: ${roomUrl}`);
|
||||
}
|
||||
if (descParts.length > 0) {
|
||||
lines.push(`DESCRIPTION:${escapeICS(descParts.join('\n'))}`);
|
||||
}
|
||||
|
||||
// ORGANIZER
|
||||
if (user) {
|
||||
const cn = user.display_name || user.name || user.email;
|
||||
lines.push(`ORGANIZER;CN=${escapeICS(cn)}:mailto:${user.email}`);
|
||||
}
|
||||
|
||||
if (event.room_uid) {
|
||||
lines.push(`X-REDLIGHT-ROOM-UID:${event.room_uid}`);
|
||||
}
|
||||
if (joinUrl) {
|
||||
lines.push(`X-REDLIGHT-JOIN-URL:${escapeICS(joinUrl)}`);
|
||||
}
|
||||
if (event.reminder_minutes) {
|
||||
lines.push(
|
||||
'BEGIN:VALARM',
|
||||
'ACTION:DISPLAY',
|
||||
`DESCRIPTION:${escapeICS(event.title)}`,
|
||||
`TRIGGER:-PT${event.reminder_minutes}M`,
|
||||
'END:VALARM',
|
||||
);
|
||||
}
|
||||
lines.push('END:VEVENT', 'END:VCALENDAR');
|
||||
return lines.map(foldICSLine).join('\r\n');
|
||||
}
|
||||
|
||||
function parseICSBody(body) {
|
||||
const uid = getICSProp(body, 'UID');
|
||||
const summary = (getICSProp(body, 'SUMMARY') || '')
|
||||
.replace(/\\n/g, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';');
|
||||
const description = (getICSProp(body, 'DESCRIPTION') || '')
|
||||
.replace(/\\n/g, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';');
|
||||
const dtstart = getICSProp(body, 'DTSTART');
|
||||
const dtend = getICSProp(body, 'DTEND');
|
||||
return {
|
||||
uid: uid || null,
|
||||
title: summary || 'Untitled',
|
||||
description: description || null,
|
||||
start_time: parseICSDate(dtstart),
|
||||
end_time: parseICSDate(dtend),
|
||||
};
|
||||
}
|
||||
|
||||
// Build etag from updated_at
|
||||
function etag(event) {
|
||||
return `"${Buffer.from(event.updated_at || event.created_at).toString('base64')}"`;
|
||||
}
|
||||
|
||||
// ── CalDAV authentication middleware ───────────────────────────────────────
|
||||
async function caldavAuth(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
||||
res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"');
|
||||
return res.status(401).end();
|
||||
}
|
||||
try {
|
||||
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8');
|
||||
const colonIdx = decoded.indexOf(':');
|
||||
if (colonIdx === -1) throw new Error('no colon');
|
||||
const email = decoded.slice(0, colonIdx);
|
||||
const token = decoded.slice(colonIdx + 1);
|
||||
|
||||
const db = getDb();
|
||||
const user = await db.get(
|
||||
'SELECT id, name, display_name, email FROM users WHERE email = ?',
|
||||
[email],
|
||||
);
|
||||
if (!user) {
|
||||
res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"');
|
||||
return res.status(401).end();
|
||||
}
|
||||
// Hash the provided token with SHA-256 for constant-time comparison in SQL
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
const tokenRow = await db.get(
|
||||
'SELECT * FROM caldav_tokens WHERE user_id = ? AND token_hash = ?',
|
||||
[user.id, tokenHash],
|
||||
);
|
||||
// Fallback: also check legacy plaintext tokens for backward compatibility
|
||||
const tokenRowLegacy = !tokenRow ? await db.get(
|
||||
'SELECT * FROM caldav_tokens WHERE user_id = ? AND token = ?',
|
||||
[user.id, token],
|
||||
) : null;
|
||||
const matchedToken = tokenRow || tokenRowLegacy;
|
||||
if (!matchedToken) {
|
||||
res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"');
|
||||
return res.status(401).end();
|
||||
}
|
||||
// Migrate legacy plaintext token to hashed version
|
||||
if (tokenRowLegacy && !tokenRow) {
|
||||
db.run("UPDATE caldav_tokens SET token_hash = ?, token = '' WHERE id = ?", [tokenHash, matchedToken.id]).catch(() => {});
|
||||
}
|
||||
// Update last_used_at (fire and forget)
|
||||
db.run('UPDATE caldav_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?', [matchedToken.id]).catch(() => {});
|
||||
req.caldavUser = user;
|
||||
next();
|
||||
} catch (err) {
|
||||
log.server.error(`CalDAV auth error: ${err.message}`);
|
||||
res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"');
|
||||
return res.status(401).end();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Common response headers ────────────────────────────────────────────────
|
||||
function setDAVHeaders(res) {
|
||||
res.set('DAV', '1, 2, calendar-access');
|
||||
res.set('Allow', 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT');
|
||||
res.set('MS-Author-Via', 'DAV');
|
||||
}
|
||||
|
||||
// ── CalDAV username authorization ──────────────────────────────────────────
|
||||
// Ensures the :username param matches the authenticated user's email
|
||||
function validateCalDAVUser(req, res, next) {
|
||||
if (req.params.username && decodeURIComponent(req.params.username) !== req.caldavUser.email) {
|
||||
return res.status(403).end();
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// ── Base URL helper (uses shared getBaseUrl from auth.js) ──────────────────
|
||||
const baseUrl = getBaseUrl;
|
||||
|
||||
// ── PROPFIND response builders ─────────────────────────────────────────────
|
||||
|
||||
function multistatus(responses) {
|
||||
return `${xmlHeader()}
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"
|
||||
xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
${responses.join('\n')}
|
||||
</d:multistatus>`;
|
||||
}
|
||||
|
||||
function propResponse(href, props, status = '200 OK') {
|
||||
const propXml = Object.entries(props)
|
||||
.map(([k, v]) => ` <${k}>${v}</${k}>`)
|
||||
.join('\n');
|
||||
return ` <d:response>
|
||||
<d:href>${escapeXml(href)}</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
${propXml}
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 ${status}</d:status>
|
||||
</d:propstat>
|
||||
</d:response>`;
|
||||
}
|
||||
|
||||
// ── OPTIONS ────────────────────────────────────────────────────────────────
|
||||
router.options('*', (req, res) => {
|
||||
setDAVHeaders(res);
|
||||
res.status(200).end();
|
||||
});
|
||||
|
||||
// ── PROPFIND / ─────────────────────────────────────────────────────────────
|
||||
// Service discovery root: tells the client where the user principal lives.
|
||||
router.all('/', caldavAuth, async (req, res) => {
|
||||
if (req.method !== 'PROPFIND') {
|
||||
setDAVHeaders(res);
|
||||
return res.status(405).end();
|
||||
}
|
||||
const base = baseUrl(req);
|
||||
const principalHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/`;
|
||||
setDAVHeaders(res);
|
||||
res.status(207).type('application/xml; charset=utf-8').send(
|
||||
multistatus([
|
||||
propResponse('/caldav/', {
|
||||
'd:current-user-principal': `<d:href>${principalHref}</d:href>`,
|
||||
'd:principal-URL': `<d:href>${principalHref}</d:href>`,
|
||||
'd:resourcetype': '<d:collection/>',
|
||||
'd:displayname': 'Redlight CalDAV',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// ── PROPFIND /{username}/ ──────────────────────────────────────────────────
|
||||
// User principal: tells the client where the calendar home is.
|
||||
router.all('/:username/', caldavAuth, validateCalDAVUser, async (req, res) => {
|
||||
if (req.method !== 'PROPFIND') {
|
||||
setDAVHeaders(res);
|
||||
return res.status(405).end();
|
||||
}
|
||||
const principalHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/`;
|
||||
const calendarHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/calendar/`;
|
||||
setDAVHeaders(res);
|
||||
res.status(207).type('application/xml; charset=utf-8').send(
|
||||
multistatus([
|
||||
propResponse(principalHref, {
|
||||
'd:resourcetype': '<d:collection/><d:principal/>',
|
||||
'd:displayname': escapeXml(req.caldavUser.display_name || req.caldavUser.name),
|
||||
'd:principal-URL': `<d:href>${principalHref}</d:href>`,
|
||||
'c:calendar-home-set': `<d:href>${calendarHref}</d:href>`,
|
||||
'd:current-user-principal': `<d:href>${principalHref}</d:href>`,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// ── PROPFIND + REPORT /{username}/calendar/ ────────────────────────────────
|
||||
router.all('/:username/calendar/', caldavAuth, validateCalDAVUser, async (req, res) => {
|
||||
const db = getDb();
|
||||
const calendarHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/calendar/`;
|
||||
|
||||
// PROPFIND: return calendar collection metadata
|
||||
if (req.method === 'PROPFIND') {
|
||||
const latestEvent = await db.get(
|
||||
'SELECT updated_at, created_at FROM calendar_events WHERE user_id = ? ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 1',
|
||||
[req.caldavUser.id],
|
||||
);
|
||||
const ctag = latestEvent
|
||||
? Buffer.from(String(latestEvent.updated_at || latestEvent.created_at)).toString('base64')
|
||||
: '0';
|
||||
|
||||
// If Depth: 1, also return all event hrefs
|
||||
const depth = req.headers.depth || '0';
|
||||
const responses = [
|
||||
propResponse(calendarHref, {
|
||||
'd:resourcetype': '<d:collection/><c:calendar/>',
|
||||
'd:displayname': 'Redlight Calendar',
|
||||
'c:supported-calendar-component-set': '<c:comp name="VEVENT"/>',
|
||||
'cs:getctag': escapeXml(ctag),
|
||||
'd:sync-token': escapeXml(ctag),
|
||||
}),
|
||||
];
|
||||
|
||||
if (depth === '1') {
|
||||
const events = await db.all(
|
||||
'SELECT uid, updated_at, created_at FROM calendar_events WHERE user_id = ?',
|
||||
[req.caldavUser.id],
|
||||
);
|
||||
for (const ev of events) {
|
||||
responses.push(
|
||||
propResponse(`${calendarHref}${ev.uid}.ics`, {
|
||||
'd:resourcetype': '',
|
||||
'd:getcontenttype': 'text/calendar; charset=utf-8',
|
||||
'd:getetag': escapeXml(etag(ev)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setDAVHeaders(res);
|
||||
return res.status(207).type('application/xml; charset=utf-8').send(multistatus(responses));
|
||||
}
|
||||
|
||||
// REPORT: calendar-query or calendar-multiget
|
||||
if (req.method === 'REPORT') {
|
||||
const body = typeof req.body === 'string' ? req.body : '';
|
||||
|
||||
// calendar-multiget: client sends explicit hrefs
|
||||
if (body.includes('calendar-multiget')) {
|
||||
const hrefMatches = [...body.matchAll(/<[^:>]*:?href[^>]*>([^<]+)<\//gi)];
|
||||
const uids = hrefMatches
|
||||
.map(m => m[1].trim())
|
||||
.filter(h => h.endsWith('.ics'))
|
||||
.map(h => h.split('/').pop().replace('.ics', ''));
|
||||
|
||||
const responses = [];
|
||||
for (const uid of uids) {
|
||||
const ev = await db.get(
|
||||
'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?',
|
||||
[uid, req.caldavUser.id],
|
||||
);
|
||||
if (!ev) continue;
|
||||
const ics = eventToICS(ev);
|
||||
responses.push(
|
||||
propResponse(`${calendarHref}${ev.uid}.ics`, {
|
||||
'd:getetag': escapeXml(etag(ev)),
|
||||
'c:calendar-data': escapeXml(eventToICS(ev, baseUrl(req), req.caldavUser)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
setDAVHeaders(res);
|
||||
return res.status(207).type('application/xml; charset=utf-8').send(multistatus(responses));
|
||||
}
|
||||
|
||||
// calendar-query: filter by time range
|
||||
let sql = 'SELECT * FROM calendar_events WHERE user_id = ?';
|
||||
const params = [req.caldavUser.id];
|
||||
|
||||
const startMatch = body.match(/start="([^"]+)"/i);
|
||||
const endMatch = body.match(/end="([^"]+)"/i);
|
||||
if (startMatch) {
|
||||
const startIso = parseICSDate(startMatch[1])?.toISOString();
|
||||
if (startIso) { sql += ' AND end_time >= ?'; params.push(startIso); }
|
||||
}
|
||||
if (endMatch) {
|
||||
const endIso = parseICSDate(endMatch[1])?.toISOString();
|
||||
if (endIso) { sql += ' AND start_time <= ?'; params.push(endIso); }
|
||||
}
|
||||
sql += ' ORDER BY start_time ASC';
|
||||
|
||||
const events = await db.all(sql, params);
|
||||
const responses = events.map(ev =>
|
||||
propResponse(`${calendarHref}${ev.uid}.ics`, {
|
||||
'd:getetag': escapeXml(etag(ev)),
|
||||
'c:calendar-data': escapeXml(eventToICS(ev, baseUrl(req), req.caldavUser)),
|
||||
}),
|
||||
);
|
||||
setDAVHeaders(res);
|
||||
return res.status(207).type('application/xml; charset=utf-8').send(multistatus(responses));
|
||||
}
|
||||
|
||||
setDAVHeaders(res);
|
||||
res.status(405).end();
|
||||
});
|
||||
|
||||
// ── GET /{username}/calendar/{uid}.ics ────────────────────────────────────
|
||||
router.get('/:username/calendar/:filename', caldavAuth, validateCalDAVUser, async (req, res) => {
|
||||
const uid = req.params.filename.replace(/\.ics$/, '');
|
||||
const db = getDb();
|
||||
const ev = await db.get(
|
||||
'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?',
|
||||
[uid, req.caldavUser.id],
|
||||
);
|
||||
if (!ev) return res.status(404).end();
|
||||
setDAVHeaders(res);
|
||||
res.set('ETag', etag(ev));
|
||||
res.set('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.send(eventToICS(ev, baseUrl(req), req.caldavUser));
|
||||
});
|
||||
|
||||
// ── PUT /{username}/calendar/{uid}.ics — create or update ─────────────────
|
||||
router.put('/:username/calendar/:filename', caldavAuth, validateCalDAVUser, async (req, res) => {
|
||||
const uid = req.params.filename.replace(/\.ics$/, '');
|
||||
const body = typeof req.body === 'string' ? req.body : '';
|
||||
|
||||
if (!body) return res.status(400).end();
|
||||
|
||||
const parsed = parseICSBody(body);
|
||||
if (!parsed.start_time || !parsed.end_time) return res.status(400).end();
|
||||
|
||||
// Normalize UID: prefer from ICS, fall back to filename
|
||||
const eventUid = parsed.uid || uid;
|
||||
|
||||
const db = getDb();
|
||||
const existing = await db.get(
|
||||
'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?',
|
||||
[eventUid, req.caldavUser.id],
|
||||
);
|
||||
|
||||
try {
|
||||
if (existing) {
|
||||
await db.run(
|
||||
`UPDATE calendar_events SET title = ?, description = ?, start_time = ?, end_time = ?,
|
||||
updated_at = CURRENT_TIMESTAMP WHERE uid = ? AND user_id = ?`,
|
||||
[
|
||||
parsed.title,
|
||||
parsed.description,
|
||||
parsed.start_time.toISOString(),
|
||||
parsed.end_time.toISOString(),
|
||||
eventUid,
|
||||
req.caldavUser.id,
|
||||
],
|
||||
);
|
||||
const updated = await db.get('SELECT * FROM calendar_events WHERE uid = ?', [eventUid]);
|
||||
setDAVHeaders(res);
|
||||
res.set('ETag', etag(updated));
|
||||
return res.status(204).end();
|
||||
} else {
|
||||
await db.run(
|
||||
`INSERT INTO calendar_events (uid, title, description, start_time, end_time, user_id, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
eventUid,
|
||||
parsed.title,
|
||||
parsed.description,
|
||||
parsed.start_time.toISOString(),
|
||||
parsed.end_time.toISOString(),
|
||||
req.caldavUser.id,
|
||||
'#6366f1',
|
||||
],
|
||||
);
|
||||
const created = await db.get('SELECT * FROM calendar_events WHERE uid = ?', [eventUid]);
|
||||
setDAVHeaders(res);
|
||||
res.set('ETag', etag(created));
|
||||
return res.status(201).end();
|
||||
}
|
||||
} catch (err) {
|
||||
log.server.error(`CalDAV PUT error: ${err.message}`);
|
||||
return res.status(500).end();
|
||||
}
|
||||
});
|
||||
|
||||
// ── DELETE /{username}/calendar/{uid}.ics ─────────────────────────────────
|
||||
router.delete('/:username/calendar/:filename', caldavAuth, validateCalDAVUser, async (req, res) => {
|
||||
const uid = req.params.filename.replace(/\.ics$/, '');
|
||||
const db = getDb();
|
||||
const ev = await db.get(
|
||||
'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?',
|
||||
[uid, req.caldavUser.id],
|
||||
);
|
||||
if (!ev) return res.status(404).end();
|
||||
await db.run('DELETE FROM calendar_events WHERE uid = ? AND user_id = ?', [uid, req.caldavUser.id]);
|
||||
setDAVHeaders(res);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ── Fallback ───────────────────────────────────────────────────────────────
|
||||
router.all('*', caldavAuth, (req, res) => {
|
||||
setDAVHeaders(res);
|
||||
res.status(405).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
786
server/routes/calendar.js
Normal file
786
server/routes/calendar.js
Normal file
@@ -0,0 +1,786 @@
|
||||
import { Router } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
|
||||
import { log } from '../config/logger.js';
|
||||
import { sendCalendarInviteEmail } from '../config/mailer.js';
|
||||
import {
|
||||
isFederationEnabled,
|
||||
getFederationDomain,
|
||||
signPayload,
|
||||
verifyPayload,
|
||||
discoverInstance,
|
||||
parseAddress,
|
||||
} from '../config/federation.js';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Allowlist for CSS color values - only permits hsl(), hex (#rgb/#rrggbb) and plain names
|
||||
const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
|
||||
|
||||
// Allowed reminder intervals in minutes
|
||||
const VALID_REMINDERS = new Set([5, 15, 30, 60, 120, 1440]);
|
||||
|
||||
// Rate limit for federation calendar receive
|
||||
const calendarFederationLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 100,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests. Please try again later.' },
|
||||
});
|
||||
|
||||
// ── GET /api/calendar/events — List events for the current user ─────────────
|
||||
router.get('/events', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const { from, to } = req.query;
|
||||
|
||||
let sql = `
|
||||
SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name
|
||||
FROM calendar_events ce
|
||||
JOIN users u ON ce.user_id = u.id
|
||||
WHERE (ce.user_id = ? OR ce.id IN (
|
||||
SELECT event_id FROM calendar_event_shares WHERE user_id = ?
|
||||
))
|
||||
`;
|
||||
const params = [req.user.id, req.user.id];
|
||||
|
||||
if (from) {
|
||||
sql += ' AND ce.end_time >= ?';
|
||||
params.push(from);
|
||||
}
|
||||
if (to) {
|
||||
sql += ' AND ce.start_time <= ?';
|
||||
params.push(to);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY ce.start_time ASC';
|
||||
const events = await db.all(sql, params);
|
||||
|
||||
// Mark shared events
|
||||
for (const ev of events) {
|
||||
ev.is_owner = ev.user_id === req.user.id;
|
||||
}
|
||||
|
||||
res.json({ events });
|
||||
} catch (err) {
|
||||
log.server.error(`Calendar list error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Events could not be loaded' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/calendar/events/:id — Get single event ─────────────────────────
|
||||
router.get('/events/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const event = await db.get(`
|
||||
SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name
|
||||
FROM calendar_events ce
|
||||
JOIN users u ON ce.user_id = u.id
|
||||
WHERE ce.id = ?
|
||||
`, [req.params.id]);
|
||||
|
||||
if (!event) return res.status(404).json({ error: 'Event not found' });
|
||||
|
||||
// Check access
|
||||
if (event.user_id !== req.user.id) {
|
||||
const share = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, req.user.id]);
|
||||
if (!share) return res.status(403).json({ error: 'No permission' });
|
||||
}
|
||||
|
||||
// Get shared users
|
||||
const sharedUsers = await db.all(`
|
||||
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
|
||||
FROM calendar_event_shares ces
|
||||
JOIN users u ON ces.user_id = u.id
|
||||
WHERE ces.event_id = ?
|
||||
`, [event.id]);
|
||||
|
||||
event.is_owner = event.user_id === req.user.id;
|
||||
|
||||
let pendingInvitations = [];
|
||||
if (event.user_id === req.user.id) {
|
||||
pendingInvitations = await db.all(`
|
||||
SELECT cli.id, cli.to_user_id as user_id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
|
||||
FROM calendar_local_invitations cli
|
||||
JOIN users u ON cli.to_user_id = u.id
|
||||
WHERE cli.event_id = ? AND cli.status = 'pending'
|
||||
`, [event.id]);
|
||||
}
|
||||
|
||||
res.json({ event, sharedUsers, pendingInvitations });
|
||||
} catch (err) {
|
||||
log.server.error(`Calendar get event error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Event could not be loaded' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/calendar/events — Create event ────────────────────────────────
|
||||
router.post('/events', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { title, description, start_time, end_time, room_uid, color, reminder_minutes } = req.body;
|
||||
|
||||
if (!title || !title.trim()) return res.status(400).json({ error: 'Title is required' });
|
||||
if (!start_time || !end_time) return res.status(400).json({ error: 'Start and end time are required' });
|
||||
if (title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' });
|
||||
if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' });
|
||||
|
||||
// Validate color format
|
||||
if (color && !SAFE_COLOR_RE.test(color)) {
|
||||
return res.status(400).json({ error: 'Invalid color format' });
|
||||
}
|
||||
|
||||
const startDate = new Date(start_time);
|
||||
const endDate = new Date(end_time);
|
||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||
return res.status(400).json({ error: 'Invalid date format' });
|
||||
}
|
||||
if (endDate <= startDate) {
|
||||
return res.status(400).json({ error: 'End time must be after start time' });
|
||||
}
|
||||
|
||||
// Verify room exists if specified
|
||||
const db = getDb();
|
||||
if (room_uid) {
|
||||
const room = await db.get('SELECT id FROM rooms WHERE uid = ?', [room_uid]);
|
||||
if (!room) return res.status(400).json({ error: 'Linked room not found' });
|
||||
}
|
||||
|
||||
const uid = crypto.randomBytes(12).toString('hex');
|
||||
const validReminder = (reminder_minutes != null && VALID_REMINDERS.has(Number(reminder_minutes)))
|
||||
? Number(reminder_minutes) : null;
|
||||
|
||||
const result = await db.run(`
|
||||
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, reminder_minutes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
uid,
|
||||
title.trim(),
|
||||
description || null,
|
||||
startDate.toISOString(),
|
||||
endDate.toISOString(),
|
||||
room_uid || null,
|
||||
req.user.id,
|
||||
color || '#6366f1',
|
||||
validReminder,
|
||||
]);
|
||||
|
||||
const event = await db.get('SELECT * FROM calendar_events WHERE id = ?', [result.lastInsertRowid]);
|
||||
res.status(201).json({ event });
|
||||
} catch (err) {
|
||||
log.server.error(`Calendar create error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Event could not be created' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── PUT /api/calendar/events/:id — Update event ─────────────────────────────
|
||||
router.put('/events/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
|
||||
|
||||
const { title, description, start_time, end_time, room_uid, color, reminder_minutes } = req.body;
|
||||
|
||||
if (title && title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' });
|
||||
if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' });
|
||||
|
||||
// Validate color format
|
||||
if (color && !SAFE_COLOR_RE.test(color)) {
|
||||
return res.status(400).json({ error: 'Invalid color format' });
|
||||
}
|
||||
|
||||
if (start_time && end_time) {
|
||||
const s = new Date(start_time);
|
||||
const e = new Date(end_time);
|
||||
if (isNaN(s.getTime()) || isNaN(e.getTime())) return res.status(400).json({ error: 'Invalid date format' });
|
||||
if (e <= s) return res.status(400).json({ error: 'End time must be after start time' });
|
||||
}
|
||||
|
||||
if (room_uid) {
|
||||
const room = await db.get('SELECT id FROM rooms WHERE uid = ?', [room_uid]);
|
||||
if (!room) return res.status(400).json({ error: 'Linked room not found' });
|
||||
}
|
||||
|
||||
const validReminder = (reminder_minutes !== undefined)
|
||||
? ((reminder_minutes != null && VALID_REMINDERS.has(Number(reminder_minutes))) ? Number(reminder_minutes) : null)
|
||||
: undefined;
|
||||
// Reset reminder_sent_at when start_time or reminder_minutes changes so the job re-fires
|
||||
const resetReminder = (start_time !== undefined && start_time !== event.start_time)
|
||||
|| (reminder_minutes !== undefined && validReminder !== event.reminder_minutes);
|
||||
|
||||
await db.run(`
|
||||
UPDATE calendar_events SET
|
||||
title = COALESCE(?, title),
|
||||
description = ?,
|
||||
start_time = COALESCE(?, start_time),
|
||||
end_time = COALESCE(?, end_time),
|
||||
room_uid = ?,
|
||||
color = COALESCE(?, color),
|
||||
reminder_minutes = COALESCE(?, reminder_minutes),
|
||||
reminder_sent_at = ${resetReminder ? 'NULL' : 'reminder_sent_at'},
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`, [
|
||||
title || null,
|
||||
description !== undefined ? description : event.description,
|
||||
start_time || null,
|
||||
end_time || null,
|
||||
room_uid !== undefined ? (room_uid || null) : event.room_uid,
|
||||
color || null,
|
||||
validReminder !== undefined ? validReminder : null,
|
||||
req.params.id,
|
||||
]);
|
||||
|
||||
const updated = await db.get('SELECT * FROM calendar_events WHERE id = ?', [req.params.id]);
|
||||
res.json({ event: updated });
|
||||
} catch (err) {
|
||||
log.server.error(`Calendar update error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Event could not be updated' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── DELETE /api/calendar/events/:id — Delete event ──────────────────────────
|
||||
router.delete('/events/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
|
||||
|
||||
// Propagate deletion to all remote instances that received this event
|
||||
if (isFederationEnabled()) {
|
||||
try {
|
||||
const outbound = await db.all(
|
||||
'SELECT remote_domain FROM calendar_event_outbound WHERE event_uid = ?',
|
||||
[event.uid]
|
||||
);
|
||||
for (const { remote_domain } of outbound) {
|
||||
try {
|
||||
const payload = {
|
||||
event_uid: event.uid,
|
||||
from_user: `@${req.user.name}@${getFederationDomain()}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const signature = signPayload(payload);
|
||||
const { baseUrl: remoteApi } = await discoverInstance(remote_domain);
|
||||
await fetch(`${remoteApi}/calendar-event-deleted`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Federation-Signature': signature,
|
||||
'X-Federation-Origin': getFederationDomain(),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch (remoteErr) {
|
||||
log.server.warn(`Calendar deletion propagation failed for ${remote_domain}: ${remoteErr.message}`);
|
||||
}
|
||||
}
|
||||
await db.run('DELETE FROM calendar_event_outbound WHERE event_uid = ?', [event.uid]);
|
||||
} catch (propErr) {
|
||||
log.server.warn(`Calendar deletion propagation error: ${propErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await db.run('DELETE FROM calendar_events WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: 'Event deleted' });
|
||||
} catch (err) {
|
||||
log.server.error(`Calendar delete error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Event could not be deleted' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/calendar/events/:id/share — Invite local user to event ────────
|
||||
router.post('/events/:id/share', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { user_id } = req.body;
|
||||
if (!user_id) return res.status(400).json({ error: 'User ID is required' });
|
||||
|
||||
const db = getDb();
|
||||
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
|
||||
if (user_id === req.user.id) return res.status(400).json({ error: 'Cannot share with yourself' });
|
||||
|
||||
const existing = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, user_id]);
|
||||
if (existing) return res.status(400).json({ error: 'Already shared with this user' });
|
||||
|
||||
const pendingCheck = await db.get(
|
||||
"SELECT id FROM calendar_local_invitations WHERE event_id = ? AND to_user_id = ? AND status = 'pending'",
|
||||
[event.id, user_id]
|
||||
);
|
||||
if (pendingCheck) return res.status(400).json({ error: 'Invitation already pending for this user' });
|
||||
|
||||
await db.run(
|
||||
'INSERT INTO calendar_local_invitations (event_id, from_user_id, to_user_id) VALUES (?, ?, ?)',
|
||||
[event.id, req.user.id, user_id]
|
||||
);
|
||||
|
||||
// Send notification email (fire-and-forget)
|
||||
const targetUser = await db.get('SELECT name, display_name, email, language FROM users WHERE id = ?', [user_id]);
|
||||
if (targetUser?.email) {
|
||||
const appUrl = getBaseUrl(req);
|
||||
const inboxUrl = `${appUrl}/federation/inbox`;
|
||||
const appName = process.env.APP_NAME || 'Redlight';
|
||||
const senderUser = await db.get('SELECT name, display_name FROM users WHERE id = ?', [req.user.id]);
|
||||
const fromDisplay = (senderUser?.display_name && senderUser.display_name !== '') ? senderUser.display_name : (senderUser?.name || req.user.name);
|
||||
sendCalendarInviteEmail(
|
||||
targetUser.email,
|
||||
(targetUser.display_name && targetUser.display_name !== '') ? targetUser.display_name : targetUser.name,
|
||||
fromDisplay,
|
||||
event.title, event.start_time, event.end_time, event.description,
|
||||
inboxUrl, appName, targetUser.language || 'en'
|
||||
).catch(mailErr => {
|
||||
log.server.warn('Calendar local share mail failed (non-fatal):', mailErr.message);
|
||||
});
|
||||
}
|
||||
|
||||
const sharedUsers = await db.all(`
|
||||
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
|
||||
FROM calendar_event_shares ces
|
||||
JOIN users u ON ces.user_id = u.id
|
||||
WHERE ces.event_id = ?
|
||||
`, [event.id]);
|
||||
|
||||
const pendingInvitations = await db.all(`
|
||||
SELECT cli.id, cli.to_user_id as user_id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
|
||||
FROM calendar_local_invitations cli
|
||||
JOIN users u ON cli.to_user_id = u.id
|
||||
WHERE cli.event_id = ? AND cli.status = 'pending'
|
||||
`, [event.id]);
|
||||
|
||||
res.json({ sharedUsers, pendingInvitations });
|
||||
} catch (err) {
|
||||
log.server.error(`Calendar share error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Could not share event' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── DELETE /api/calendar/events/:id/share/:userId — Remove share or cancel invitation ──
|
||||
router.delete('/events/:id/share/:userId', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
|
||||
|
||||
// Remove accepted share
|
||||
await db.run('DELETE FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, parseInt(req.params.userId)]);
|
||||
|
||||
// Also cancel any pending local invitation for this user
|
||||
await db.run(
|
||||
"UPDATE calendar_local_invitations SET status = 'declined' WHERE event_id = ? AND to_user_id = ? AND status = 'pending'",
|
||||
[event.id, parseInt(req.params.userId)]
|
||||
);
|
||||
|
||||
const sharedUsers = await db.all(`
|
||||
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
|
||||
FROM calendar_event_shares ces
|
||||
JOIN users u ON ces.user_id = u.id
|
||||
WHERE ces.event_id = ?
|
||||
`, [event.id]);
|
||||
|
||||
const pendingInvitations = await db.all(`
|
||||
SELECT cli.id, cli.to_user_id as user_id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
|
||||
FROM calendar_local_invitations cli
|
||||
JOIN users u ON cli.to_user_id = u.id
|
||||
WHERE cli.event_id = ? AND cli.status = 'pending'
|
||||
`, [event.id]);
|
||||
|
||||
res.json({ sharedUsers, pendingInvitations });
|
||||
} catch (err) {
|
||||
log.server.error(`Calendar unshare error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Could not remove share' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/calendar/local-invitations — List local calendar invitations for current user ──
|
||||
router.get('/local-invitations', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const invitations = await db.all(`
|
||||
SELECT
|
||||
cli.id, cli.event_id, cli.status, cli.created_at,
|
||||
ce.title, ce.start_time, ce.end_time, ce.description, ce.color,
|
||||
COALESCE(NULLIF(u.display_name,''), u.name) as from_name
|
||||
FROM calendar_local_invitations cli
|
||||
JOIN calendar_events ce ON cli.event_id = ce.id
|
||||
JOIN users u ON cli.from_user_id = u.id
|
||||
WHERE cli.to_user_id = ?
|
||||
ORDER BY cli.created_at DESC
|
||||
`, [req.user.id]);
|
||||
res.json({ invitations });
|
||||
} catch (err) {
|
||||
log.server.error(`Calendar local invitations error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Could not load invitations' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/calendar/local-invitations/:id/accept — Accept local invitation ──
|
||||
router.post('/local-invitations/:id/accept', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const inv = await db.get(
|
||||
"SELECT * FROM calendar_local_invitations WHERE id = ? AND to_user_id = ? AND status = 'pending'",
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
if (!inv) return res.status(404).json({ error: 'Invitation not found' });
|
||||
|
||||
await db.run("UPDATE calendar_local_invitations SET status = 'accepted' WHERE id = ?", [inv.id]);
|
||||
// Insert into calendar_event_shares so the event appears in the user's calendar
|
||||
const existingShare = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [inv.event_id, req.user.id]);
|
||||
if (!existingShare) {
|
||||
await db.run('INSERT INTO calendar_event_shares (event_id, user_id) VALUES (?, ?)', [inv.event_id, req.user.id]);
|
||||
}
|
||||
res.json({ message: 'Invitation accepted' });
|
||||
} catch (err) {
|
||||
log.server.error(`Calendar local invitation accept error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Could not accept invitation' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── DELETE /api/calendar/local-invitations/:id — Decline/remove local invitation ──
|
||||
router.delete('/local-invitations/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const inv = await db.get(
|
||||
'SELECT * FROM calendar_local_invitations WHERE id = ? AND to_user_id = ?',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
if (!inv) return res.status(404).json({ error: 'Invitation not found' });
|
||||
|
||||
if (inv.status === 'pending') {
|
||||
await db.run("UPDATE calendar_local_invitations SET status = 'declined' WHERE id = ?", [inv.id]);
|
||||
} else {
|
||||
// Accepted/declined - remove the share too if it was accepted
|
||||
if (inv.status === 'accepted') {
|
||||
await db.run('DELETE FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [inv.event_id, req.user.id]);
|
||||
}
|
||||
await db.run('DELETE FROM calendar_local_invitations WHERE id = ?', [inv.id]);
|
||||
}
|
||||
res.json({ message: 'Invitation removed' });
|
||||
} catch (err) {
|
||||
log.server.error(`Calendar local invitation delete error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Could not remove invitation' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/calendar/events/:id/ics — Download event as ICS ────────────────
|
||||
router.get('/events/:id/ics', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const event = await db.get(`
|
||||
SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name, u.email as organizer_email
|
||||
FROM calendar_events ce
|
||||
JOIN users u ON ce.user_id = u.id
|
||||
WHERE ce.id = ?
|
||||
`, [req.params.id]);
|
||||
|
||||
if (!event) return res.status(404).json({ error: 'Event not found' });
|
||||
|
||||
// Check access
|
||||
if (event.user_id !== req.user.id) {
|
||||
const share = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, req.user.id]);
|
||||
if (!share) return res.status(403).json({ error: 'No permission' });
|
||||
}
|
||||
|
||||
// Build room join URL if linked
|
||||
const baseUrl = getBaseUrl(req);
|
||||
let location = '';
|
||||
if (event.room_uid) {
|
||||
location = `${baseUrl}/join/${event.room_uid}`;
|
||||
}
|
||||
|
||||
const ics = generateICS(event, location, baseUrl);
|
||||
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(event.title)}.ics"`);
|
||||
res.send(ics);
|
||||
} catch (err) {
|
||||
log.server.error(`ICS download error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Could not generate ICS file' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/calendar/events/:id/federation — Send event to remote user ────
|
||||
router.post('/events/:id/federation', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
if (!isFederationEnabled()) {
|
||||
return res.status(400).json({ error: 'Federation is not configured on this instance' });
|
||||
}
|
||||
|
||||
const { to } = req.body;
|
||||
if (!to) return res.status(400).json({ error: 'Remote address is required' });
|
||||
|
||||
const { username, domain } = parseAddress(to);
|
||||
if (!domain) return res.status(400).json({ error: 'Remote address must be in format username@domain' });
|
||||
if (domain === getFederationDomain()) {
|
||||
return res.status(400).json({ error: 'Cannot send to your own instance. Use local sharing instead.' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
|
||||
|
||||
const baseUrl = getBaseUrl(req);
|
||||
let joinUrl = null;
|
||||
if (event.room_uid) {
|
||||
joinUrl = `${baseUrl}/join/${event.room_uid}`;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
type: 'calendar_event',
|
||||
event_uid: event.uid,
|
||||
title: event.title,
|
||||
description: event.description || '',
|
||||
start_time: event.start_time,
|
||||
end_time: event.end_time,
|
||||
room_uid: event.room_uid || null,
|
||||
join_url: joinUrl,
|
||||
from_user: `@${req.user.name}@${getFederationDomain()}`,
|
||||
to_user: to,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const signature = signPayload(payload);
|
||||
const { baseUrl: remoteApi } = await discoverInstance(domain);
|
||||
|
||||
const response = await fetch(`${remoteApi}/calendar-event`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Federation-Signature': signature,
|
||||
'X-Federation-Origin': getFederationDomain(),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Remote server responded with ${response.status}`);
|
||||
}
|
||||
|
||||
// Track outbound send for deletion propagation
|
||||
try {
|
||||
await db.run(
|
||||
`INSERT INTO calendar_event_outbound (event_uid, remote_domain) VALUES (?, ?)
|
||||
ON CONFLICT(event_uid, remote_domain) DO NOTHING`,
|
||||
[event.uid, domain]
|
||||
);
|
||||
} catch { /* table may not exist yet on upgrade */ }
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.server.error(`Calendar federation send error: ${err.message}`);
|
||||
res.status(500).json({ error: err.message || 'Could not send event to remote instance' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /receive-event or /calendar-event — Receive calendar event from remote ──
|
||||
// '/receive-event' when mounted at /api/calendar
|
||||
// '/calendar-event' when mounted at /api/federation (for remote instance discovery)
|
||||
router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, async (req, res) => {
|
||||
try {
|
||||
if (!isFederationEnabled()) {
|
||||
return res.status(400).json({ error: 'Federation is not configured on this instance' });
|
||||
}
|
||||
|
||||
const signature = req.headers['x-federation-signature'];
|
||||
const payload = req.body || {};
|
||||
|
||||
if (!signature) return res.status(401).json({ error: 'Missing federation signature' });
|
||||
|
||||
const { event_uid, title, description, start_time, end_time, room_uid, join_url, from_user, to_user } = payload;
|
||||
|
||||
if (!event_uid || !title || !start_time || !end_time || !from_user || !to_user) {
|
||||
return res.status(400).json({ error: 'Incomplete event payload' });
|
||||
}
|
||||
|
||||
// Validate lengths
|
||||
if (event_uid.length > 100 || title.length > 200 || (description && description.length > 5000) ||
|
||||
from_user.length > 200 || to_user.length > 200 || (join_url && join_url.length > 2000)) {
|
||||
return res.status(400).json({ error: 'Payload fields exceed maximum allowed length' });
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
const { domain: senderDomain } = parseAddress(from_user);
|
||||
if (!senderDomain) return res.status(400).json({ error: 'Sender address must include a domain' });
|
||||
|
||||
const { publicKey } = await discoverInstance(senderDomain);
|
||||
if (!publicKey) return res.status(400).json({ error: 'Sender instance did not provide a public key' });
|
||||
if (!verifyPayload(payload, signature, publicKey)) {
|
||||
return res.status(403).json({ error: 'Invalid federation signature' });
|
||||
}
|
||||
|
||||
// Find local user
|
||||
const { username } = parseAddress(to_user);
|
||||
const db = getDb();
|
||||
const targetUser = await db.get('SELECT id, name, email, language FROM users WHERE LOWER(name) = LOWER(?)', [username]);
|
||||
if (!targetUser) return res.status(404).json({ error: 'User not found on this instance' });
|
||||
|
||||
// Check duplicate (already in invitations or already accepted into calendar)
|
||||
const existingInv = await db.get('SELECT id FROM calendar_invitations WHERE event_uid = ? AND to_user_id = ?', [event_uid, targetUser.id]);
|
||||
if (existingInv) return res.json({ success: true, message: 'Calendar invitation already received' });
|
||||
|
||||
// Store as pending invitation — user must accept before it appears in calendar
|
||||
await db.run(`
|
||||
INSERT INTO calendar_invitations (event_uid, title, description, start_time, end_time, room_uid, join_url, from_user, to_user_id, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
event_uid,
|
||||
title,
|
||||
description || null,
|
||||
start_time,
|
||||
end_time,
|
||||
room_uid || null,
|
||||
join_url || null,
|
||||
from_user,
|
||||
targetUser.id,
|
||||
'#6366f1',
|
||||
]);
|
||||
|
||||
// Send notification email (fire-and-forget)
|
||||
if (targetUser.email) {
|
||||
const appUrl = getBaseUrl(req);
|
||||
const inboxUrl = `${appUrl}/federation/inbox`;
|
||||
const appName = process.env.APP_NAME || 'Redlight';
|
||||
sendCalendarInviteEmail(
|
||||
targetUser.email, targetUser.name, from_user,
|
||||
title, start_time, end_time, description || null,
|
||||
inboxUrl, appName, targetUser.language || 'en'
|
||||
).catch(mailErr => {
|
||||
log.server.warn('Calendar invite mail failed (non-fatal):', mailErr.message);
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.server.error(`Calendar federation receive error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Failed to process calendar event' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Helper: Generate ICS content ────────────────────────────────────────────
|
||||
function generateICS(event, location, prodIdDomain) {
|
||||
const formatDate = (dateStr) => {
|
||||
const d = new Date(dateStr);
|
||||
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
};
|
||||
|
||||
const escapeICS = (str) => {
|
||||
if (!str) return '';
|
||||
return str.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
|
||||
};
|
||||
|
||||
const now = formatDate(new Date().toISOString());
|
||||
const dtStart = formatDate(event.start_time);
|
||||
const dtEnd = formatDate(event.end_time);
|
||||
|
||||
let ics = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
`PRODID:-//${prodIdDomain}//Redlight Calendar//EN`,
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${event.uid}@${prodIdDomain}`,
|
||||
`DTSTAMP:${now}`,
|
||||
`DTSTART:${dtStart}`,
|
||||
`DTEND:${dtEnd}`,
|
||||
`SUMMARY:${escapeICS(event.title)}`,
|
||||
];
|
||||
|
||||
if (event.description) {
|
||||
ics.push(`DESCRIPTION:${escapeICS(event.description)}`);
|
||||
}
|
||||
if (location) {
|
||||
ics.push(`LOCATION:${escapeICS(location)}`);
|
||||
ics.push(`URL:${location}`);
|
||||
}
|
||||
if (event.organizer_name && event.organizer_email) {
|
||||
ics.push(`ORGANIZER;CN=${escapeICS(event.organizer_name)}:mailto:${event.organizer_email}`);
|
||||
}
|
||||
|
||||
if (event.reminder_minutes) {
|
||||
ics.push(
|
||||
'BEGIN:VALARM',
|
||||
'ACTION:DISPLAY',
|
||||
`DESCRIPTION:Reminder: ${escapeICS(event.title)}`,
|
||||
`TRIGGER:-PT${event.reminder_minutes}M`,
|
||||
'END:VALARM',
|
||||
);
|
||||
}
|
||||
ics.push('END:VEVENT', 'END:VCALENDAR');
|
||||
return ics.join('\r\n');
|
||||
}
|
||||
|
||||
// ── CalDAV token management ────────────────────────────────────────────────
|
||||
|
||||
// GET /api/calendar/caldav-tokens
|
||||
router.get('/caldav-tokens', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const tokens = await db.all(
|
||||
'SELECT id, name, created_at, last_used_at FROM caldav_tokens WHERE user_id = ? ORDER BY created_at DESC',
|
||||
[req.user.id],
|
||||
);
|
||||
res.json({ tokens });
|
||||
} catch (err) {
|
||||
log.server.error(`CalDAV list tokens error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Could not load tokens' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/calendar/caldav-tokens
|
||||
router.post('/caldav-tokens', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'Token name is required' });
|
||||
}
|
||||
const db = getDb();
|
||||
const count = await db.get(
|
||||
'SELECT COUNT(*) as c FROM caldav_tokens WHERE user_id = ?',
|
||||
[req.user.id],
|
||||
);
|
||||
if (count.c >= 10) {
|
||||
return res.status(400).json({ error: 'Maximum of 10 tokens allowed' });
|
||||
}
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
const result = await db.run(
|
||||
// Store only the hash — never the plaintext — to limit exposure on DB breach.
|
||||
'INSERT INTO caldav_tokens (user_id, token_hash, name) VALUES (?, ?, ?)',
|
||||
[req.user.id, tokenHash, name.trim()],
|
||||
);
|
||||
res.status(201).json({
|
||||
token: { id: result.lastInsertRowid, name: name.trim() },
|
||||
plainToken: token,
|
||||
});
|
||||
} catch (err) {
|
||||
log.server.error(`CalDAV create token error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Could not create token' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/calendar/caldav-tokens/:id
|
||||
router.delete('/caldav-tokens/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const result = await db.run(
|
||||
'DELETE FROM caldav_tokens WHERE id = ? AND user_id = ?',
|
||||
[req.params.id, req.user.id],
|
||||
);
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: 'Token not found' });
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
log.server.error(`CalDAV delete token error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Could not delete token' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
690
server/routes/federation.js
Normal file
690
server/routes/federation.js
Normal file
@@ -0,0 +1,690 @@
|
||||
import { Router } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
|
||||
import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js';
|
||||
import { log } from '../config/logger.js';
|
||||
import { createNotification } from '../config/notifications.js';
|
||||
|
||||
// M13: rate limit the unauthenticated federation receive endpoint
|
||||
const federationReceiveLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many federation requests. Please try again later.' },
|
||||
});
|
||||
|
||||
import {
|
||||
getFederationDomain,
|
||||
isFederationEnabled,
|
||||
getPublicKey,
|
||||
signPayload,
|
||||
verifyPayload,
|
||||
discoverInstance,
|
||||
parseAddress,
|
||||
} from '../config/federation.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ── Well-known discovery endpoint ───────────────────────────────────────────
|
||||
// Mounted at /.well-known/redlight in index.js
|
||||
export function wellKnownHandler(req, res) {
|
||||
const domain = getFederationDomain();
|
||||
if (!domain) {
|
||||
return res.status(404).json({ error: 'Federation not configured' });
|
||||
}
|
||||
res.json({
|
||||
domain,
|
||||
federation_api: '/api/federation',
|
||||
public_key: getPublicKey(),
|
||||
software: 'Redlight',
|
||||
version: '2.1.2',
|
||||
});
|
||||
}
|
||||
|
||||
// ── POST /api/federation/invite — Send invitation to remote user ────────────
|
||||
router.post('/invite', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
if (!isFederationEnabled()) {
|
||||
return res.status(400).json({ error: 'Federation is not configured on this instance' });
|
||||
}
|
||||
|
||||
const { room_uid, to, message } = req.body;
|
||||
if (!room_uid || !to) {
|
||||
return res.status(400).json({ error: 'room_uid and to are required' });
|
||||
}
|
||||
|
||||
const { username, domain } = parseAddress(to);
|
||||
if (!domain) {
|
||||
return res.status(400).json({ error: 'Remote address must be in format username@domain' });
|
||||
}
|
||||
|
||||
// Don't allow inviting to own instance
|
||||
if (domain === getFederationDomain()) {
|
||||
return res.status(400).json({ error: 'Cannot send federation invite to your own instance. Use local sharing instead.' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Verify room exists and user has access
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [room_uid]);
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
|
||||
const isOwner = room.user_id === req.user.id;
|
||||
if (!isOwner) {
|
||||
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||
if (!share) {
|
||||
return res.status(403).json({ error: 'No permission to invite from this room' });
|
||||
}
|
||||
}
|
||||
|
||||
// Build guest join URL for the remote user
|
||||
// If the room has an access code, embed it so the recipient can join without manual entry
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const joinUrl = room.access_code
|
||||
? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}`
|
||||
: `${baseUrl}/join/${room.uid}`;
|
||||
|
||||
// Build invitation payload
|
||||
const inviteId = uuidv4();
|
||||
const payload = {
|
||||
invite_id: inviteId,
|
||||
from_user: `@${req.user.name}@${getFederationDomain()}`,
|
||||
to_user: to,
|
||||
room_name: room.name,
|
||||
room_uid: room.uid,
|
||||
max_participants: room.max_participants ?? 0,
|
||||
allow_recording: room.record_meeting ?? 1,
|
||||
message: message || null,
|
||||
join_url: joinUrl,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Sign and send to remote instance
|
||||
const signature = signPayload(payload);
|
||||
const { baseUrl: remoteApi } = await discoverInstance(domain);
|
||||
|
||||
const response = await fetch(`${remoteApi}/receive`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Federation-Signature': signature,
|
||||
'X-Federation-Origin': getFederationDomain(),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(15_000), // 15 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Remote server responded with ${response.status}`);
|
||||
}
|
||||
|
||||
// Track outbound invite for deletion propagation
|
||||
try {
|
||||
await db.run(
|
||||
`INSERT INTO federation_outbound_invites (room_uid, remote_domain) VALUES (?, ?)
|
||||
ON CONFLICT(room_uid, remote_domain) DO NOTHING`,
|
||||
[room.uid, domain]
|
||||
);
|
||||
} catch { /* table may not exist yet on upgrade */ }
|
||||
|
||||
res.json({ success: true, invite_id: inviteId });
|
||||
} catch (err) {
|
||||
log.federation.error('Federation invite error:', err);
|
||||
res.status(500).json({ error: err.message || 'Failed to send federation invite' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/federation/receive — Accept incoming invitation from remote ───
|
||||
router.post('/receive', federationReceiveLimiter, async (req, res) => {
|
||||
try {
|
||||
if (!isFederationEnabled()) {
|
||||
return res.status(400).json({ error: 'Federation is not configured on this instance' });
|
||||
}
|
||||
|
||||
const signature = req.headers['x-federation-signature'];
|
||||
const payload = req.body || {};
|
||||
|
||||
if (!signature) {
|
||||
return res.status(401).json({ error: 'Missing federation signature' });
|
||||
}
|
||||
|
||||
// Extract expected fields from the incoming payload
|
||||
const { invite_id, from_user, to_user, room_name, room_uid, max_participants, allow_recording, message, join_url } = payload;
|
||||
|
||||
if (!invite_id || !from_user || !to_user || !room_name || !join_url) {
|
||||
return res.status(400).json({ error: 'Incomplete invitation payload' });
|
||||
}
|
||||
|
||||
// Validate join_url scheme to prevent javascript: or other malicious URIs
|
||||
try {
|
||||
const parsedUrl = new URL(join_url);
|
||||
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
|
||||
return res.status(400).json({ error: 'join_url must use https:// or http://' });
|
||||
}
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'Invalid join_url format' });
|
||||
}
|
||||
|
||||
// S4: validate field lengths from remote to prevent oversized DB entries
|
||||
if (invite_id.length > 100 || from_user.length > 200 || to_user.length > 200 ||
|
||||
room_name.length > 200 || join_url.length > 2000 || (message && message.length > 5000)) {
|
||||
return res.status(400).json({ error: 'Payload fields exceed maximum allowed length' });
|
||||
}
|
||||
|
||||
// Fetch the sender's public key dynamically
|
||||
const { domain: senderDomain } = parseAddress(from_user);
|
||||
if (!senderDomain) {
|
||||
return res.status(400).json({ error: 'Sender address must include a domain' });
|
||||
}
|
||||
|
||||
const { publicKey } = await discoverInstance(senderDomain);
|
||||
if (!publicKey) {
|
||||
return res.status(400).json({ error: 'Sender instance did not provide a public key' });
|
||||
}
|
||||
|
||||
if (!verifyPayload(payload, signature, publicKey)) {
|
||||
return res.status(403).json({ error: 'Invalid federation signature' });
|
||||
}
|
||||
|
||||
// Parse the target address and find local user
|
||||
const { username } = parseAddress(to_user);
|
||||
const db = getDb();
|
||||
|
||||
// Look up user by name (case-insensitive)
|
||||
const targetUser = await db.get(
|
||||
'SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ error: 'User not found on this instance' });
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await db.get(
|
||||
'SELECT id FROM federation_invitations WHERE invite_id = ?',
|
||||
[invite_id]
|
||||
);
|
||||
if (existing) {
|
||||
return res.json({ success: true, message: 'Invitation already received' });
|
||||
}
|
||||
|
||||
// Store the invitation
|
||||
await db.run(
|
||||
`INSERT INTO federation_invitations (invite_id, from_user, to_user_id, room_name, message, join_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[invite_id, from_user, targetUser.id, room_name, message || null, join_url]
|
||||
);
|
||||
|
||||
// Store room_uid, max_participants, allow_recording if those columns already exist
|
||||
// (we update after initial insert to stay compatible with old schema)
|
||||
const inv = await db.get('SELECT id FROM federation_invitations WHERE invite_id = ? AND to_user_id = ?', [invite_id, targetUser.id]);
|
||||
if (inv && room_uid !== undefined) {
|
||||
try {
|
||||
await db.run(
|
||||
'UPDATE federation_invitations SET room_uid = ?, max_participants = ?, allow_recording = ? WHERE id = ?',
|
||||
[room_uid || null, max_participants ?? 0, allow_recording ?? 1, inv.id]
|
||||
);
|
||||
} catch { /* column may not exist on very old installs */ }
|
||||
}
|
||||
|
||||
// Send notification email (truly fire-and-forget - never blocks the response)
|
||||
if (targetUser.email) {
|
||||
const appUrl = getBaseUrl(req);
|
||||
const inboxUrl = `${appUrl}/federation/inbox`;
|
||||
const appName = process.env.APP_NAME || 'Redlight';
|
||||
sendFederationInviteEmail(
|
||||
targetUser.email, targetUser.name, from_user,
|
||||
room_name, message || null, inboxUrl, appName, targetUser.language || 'en'
|
||||
).catch(mailErr => {
|
||||
log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message);
|
||||
});
|
||||
}
|
||||
|
||||
// In-app notification
|
||||
await createNotification(
|
||||
targetUser.id,
|
||||
'federation_invite_received',
|
||||
from_user,
|
||||
room_name,
|
||||
'/federation/inbox',
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.federation.error('Federation receive error:', err);
|
||||
res.status(500).json({ error: 'Failed to process federation invitation' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/federation/invitations — List invitations for current user ─────
|
||||
router.get('/invitations', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const invitations = await db.all(
|
||||
`SELECT * FROM federation_invitations
|
||||
WHERE to_user_id = ?
|
||||
ORDER BY created_at DESC`,
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ invitations });
|
||||
} catch (err) {
|
||||
log.federation.error('List invitations error:', err);
|
||||
res.status(500).json({ error: 'Failed to load invitations' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/federation/invitations/pending-count — Badge count ─────────────
|
||||
router.get('/invitations/pending-count', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const roomResult = await db.get(
|
||||
`SELECT COUNT(*) as count FROM federation_invitations
|
||||
WHERE to_user_id = ? AND status = 'pending'`,
|
||||
[req.user.id]
|
||||
);
|
||||
let calResult = { count: 0 };
|
||||
try {
|
||||
calResult = await db.get(
|
||||
`SELECT COUNT(*) as count FROM calendar_invitations
|
||||
WHERE to_user_id = ? AND status = 'pending'`,
|
||||
[req.user.id]
|
||||
);
|
||||
} catch { /* table may not exist yet */ }
|
||||
let localCalResult = { count: 0 };
|
||||
try {
|
||||
localCalResult = await db.get(
|
||||
`SELECT COUNT(*) as count FROM calendar_local_invitations
|
||||
WHERE to_user_id = ? AND status = 'pending'`,
|
||||
[req.user.id]
|
||||
);
|
||||
} catch { /* table may not exist yet */ }
|
||||
res.json({ count: (roomResult?.count || 0) + (calResult?.count || 0) + (localCalResult?.count || 0) });
|
||||
} catch (err) {
|
||||
res.json({ count: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/federation/invitations/:id/accept — Accept an invitation ──────
|
||||
router.post('/invitations/:id/accept', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const invitation = await db.get(
|
||||
'SELECT * FROM federation_invitations WHERE id = ? AND to_user_id = ?',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (!invitation) {
|
||||
return res.status(404).json({ error: 'Invitation not found' });
|
||||
}
|
||||
|
||||
await db.run(
|
||||
"UPDATE federation_invitations SET status = 'accepted' WHERE id = ?",
|
||||
[invitation.id]
|
||||
);
|
||||
|
||||
// Upsert into federated_rooms so the room appears in the user's dashboard
|
||||
const existing = await db.get(
|
||||
'SELECT id FROM federated_rooms WHERE invite_id = ? AND user_id = ?',
|
||||
[invitation.invite_id, req.user.id]
|
||||
);
|
||||
if (!existing) {
|
||||
await db.run(
|
||||
`INSERT INTO federated_rooms (user_id, invite_id, room_name, from_user, join_url, meet_id, max_participants, allow_recording)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
req.user.id, invitation.invite_id, invitation.room_name,
|
||||
invitation.from_user, invitation.join_url,
|
||||
invitation.room_uid || null,
|
||||
invitation.max_participants ?? 0,
|
||||
invitation.allow_recording ?? 1,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ success: true, join_url: invitation.join_url });
|
||||
} catch (err) {
|
||||
log.federation.error('Accept invitation error:', err);
|
||||
res.status(500).json({ error: 'Failed to accept invitation' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── DELETE /api/federation/invitations/:id — Decline/dismiss invitation ─────
|
||||
router.delete('/invitations/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const invitation = await db.get(
|
||||
'SELECT * FROM federation_invitations WHERE id = ? AND to_user_id = ?',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (!invitation) {
|
||||
return res.status(404).json({ error: 'Invitation not found' });
|
||||
}
|
||||
|
||||
await db.run('DELETE FROM federation_invitations WHERE id = ?', [invitation.id]);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.federation.error('Decline invitation error:', err);
|
||||
res.status(500).json({ error: 'Failed to decline invitation' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/federation/calendar-invitations — List calendar invitations ─────
|
||||
router.get('/calendar-invitations', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const invitations = await db.all(
|
||||
`SELECT * FROM calendar_invitations
|
||||
WHERE to_user_id = ?
|
||||
ORDER BY created_at DESC`,
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ invitations });
|
||||
} catch (err) {
|
||||
log.federation.error('List calendar invitations error:', err);
|
||||
res.status(500).json({ error: 'Failed to load calendar invitations' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/federation/calendar-invitations/:id/accept ─────────────────────
|
||||
router.post('/calendar-invitations/:id/accept', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const inv = await db.get(
|
||||
`SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`,
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' });
|
||||
if (inv.status === 'accepted') return res.status(400).json({ error: 'Already accepted' });
|
||||
|
||||
await db.run(
|
||||
`UPDATE calendar_invitations SET status = 'accepted' WHERE id = ?`,
|
||||
[inv.id]
|
||||
);
|
||||
|
||||
// Check if event was already previously accepted (duplicate guard)
|
||||
const existing = await db.get(
|
||||
'SELECT id FROM calendar_events WHERE uid = ? AND user_id = ?',
|
||||
[inv.event_uid, req.user.id]
|
||||
);
|
||||
if (!existing) {
|
||||
await db.run(`
|
||||
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, federated_from, federated_join_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
inv.event_uid,
|
||||
inv.title,
|
||||
inv.description || null,
|
||||
inv.start_time,
|
||||
inv.end_time,
|
||||
inv.room_uid || null,
|
||||
req.user.id,
|
||||
inv.color || '#6366f1',
|
||||
inv.from_user,
|
||||
inv.join_url || null,
|
||||
]);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.federation.error('Accept calendar invitation error:', err);
|
||||
res.status(500).json({ error: 'Failed to accept calendar invitation' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── DELETE /api/federation/calendar-invitations/:id — Decline/dismiss ────────
|
||||
router.delete('/calendar-invitations/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const inv = await db.get(
|
||||
`SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`,
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' });
|
||||
|
||||
if (inv.status === 'pending') {
|
||||
// mark as declined
|
||||
await db.run(`UPDATE calendar_invitations SET status = 'declined' WHERE id = ?`, [inv.id]);
|
||||
} else {
|
||||
// accepted or declined — permanently remove from inbox
|
||||
await db.run('DELETE FROM calendar_invitations WHERE id = ?', [inv.id]);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.federation.error('Delete calendar invitation error:', err);
|
||||
res.status(500).json({ error: 'Failed to remove calendar invitation' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/federation/federated-rooms — List saved federated rooms ────────
|
||||
router.get('/federated-rooms', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const rooms = await db.all(
|
||||
`SELECT * FROM federated_rooms WHERE user_id = ? ORDER BY created_at DESC`,
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ rooms });
|
||||
} catch (err) {
|
||||
log.federation.error('List federated rooms error:', err);
|
||||
res.status(500).json({ error: 'Failed to load federated rooms' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── DELETE /api/federation/federated-rooms/:id — Remove a federated room ────
|
||||
router.delete('/federated-rooms/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const room = await db.get(
|
||||
'SELECT * FROM federated_rooms WHERE id = ? AND user_id = ?',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
if (!room) return res.status(404).json({ error: 'Room not found' });
|
||||
await db.run('DELETE FROM federated_rooms WHERE id = ?', [room.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.federation.error('Delete federated room error:', err);
|
||||
res.status(500).json({ error: 'Failed to remove room' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/federation/room-sync — Remote instances query room settings ───
|
||||
// Called by federated instances to pull current room info for one or more UIDs.
|
||||
// Signed request from remote, no auth token needed.
|
||||
router.post('/room-sync', federationReceiveLimiter, async (req, res) => {
|
||||
try {
|
||||
if (!isFederationEnabled()) {
|
||||
return res.status(400).json({ error: 'Federation is not configured on this instance' });
|
||||
}
|
||||
|
||||
const signature = req.headers['x-federation-signature'];
|
||||
const originDomain = req.headers['x-federation-origin'];
|
||||
const payload = req.body || {};
|
||||
|
||||
if (!signature || !originDomain) {
|
||||
return res.status(401).json({ error: 'Missing federation signature or origin' });
|
||||
}
|
||||
|
||||
// Verify signature using the remote instance's public key
|
||||
const { publicKey } = await discoverInstance(originDomain);
|
||||
if (!publicKey || !verifyPayload(payload, signature, publicKey)) {
|
||||
return res.status(403).json({ error: 'Invalid federation signature' });
|
||||
}
|
||||
|
||||
const { room_uids } = payload;
|
||||
if (!Array.isArray(room_uids) || room_uids.length === 0 || room_uids.length > 100) {
|
||||
return res.status(400).json({ error: 'room_uids must be an array of 1-100 UIDs' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const result = {};
|
||||
|
||||
for (const uid of room_uids) {
|
||||
if (typeof uid !== 'string' || uid.length > 100) continue;
|
||||
const room = await db.get('SELECT uid, name, max_participants, record_meeting FROM rooms WHERE uid = ?', [uid]);
|
||||
if (room) {
|
||||
result[uid] = {
|
||||
room_name: room.name,
|
||||
max_participants: room.max_participants ?? 0,
|
||||
allow_recording: room.record_meeting ?? 1,
|
||||
deleted: false,
|
||||
};
|
||||
} else {
|
||||
result[uid] = { deleted: true };
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ rooms: result });
|
||||
} catch (err) {
|
||||
log.federation.error('Room-sync error:', err);
|
||||
res.status(500).json({ error: 'Failed to process room sync request' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/federation/calendar-event-deleted — Receive calendar deletion ─
|
||||
router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res) => {
|
||||
try {
|
||||
if (!isFederationEnabled()) {
|
||||
return res.status(400).json({ error: 'Federation is not configured on this instance' });
|
||||
}
|
||||
|
||||
const signature = req.headers['x-federation-signature'];
|
||||
const originDomain = req.headers['x-federation-origin'];
|
||||
const payload = req.body || {};
|
||||
|
||||
if (!signature || !originDomain) {
|
||||
return res.status(401).json({ error: 'Missing federation signature or origin' });
|
||||
}
|
||||
|
||||
const { publicKey } = await discoverInstance(originDomain);
|
||||
if (!publicKey || !verifyPayload(payload, signature, publicKey)) {
|
||||
return res.status(403).json({ error: 'Invalid federation signature' });
|
||||
}
|
||||
|
||||
const { event_uid } = payload;
|
||||
if (!event_uid || typeof event_uid !== 'string') {
|
||||
return res.status(400).json({ error: 'event_uid is required' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Escape LIKE special characters in originDomain to prevent wildcard injection.
|
||||
const safeDomain = originDomain.replace(/[\\%_]/g, '\\$&');
|
||||
|
||||
// Collect all affected users before deleting (for email notifications)
|
||||
let affectedUsers = [];
|
||||
try {
|
||||
// Users with pending/declined invitations
|
||||
const invUsers = await db.all(
|
||||
`SELECT u.email, u.name, u.language, ci.title, ci.from_user
|
||||
FROM calendar_invitations ci
|
||||
JOIN users u ON ci.to_user_id = u.id
|
||||
WHERE ci.event_uid = ? AND ci.from_user LIKE ? ESCAPE '\\'`,
|
||||
[event_uid, `%@${safeDomain}`]
|
||||
);
|
||||
// Users who already accepted (event in their calendar)
|
||||
const calUsers = await db.all(
|
||||
`SELECT u.email, u.name, u.language, ce.title, ce.federated_from AS from_user
|
||||
FROM calendar_events ce
|
||||
JOIN users u ON ce.user_id = u.id
|
||||
WHERE ce.uid = ? AND ce.federated_from LIKE ? ESCAPE '\\'`,
|
||||
[event_uid, `%@${safeDomain}`]
|
||||
);
|
||||
// Merge, deduplicate by email
|
||||
const seen = new Set();
|
||||
for (const row of [...invUsers, ...calUsers]) {
|
||||
if (row.email && !seen.has(row.email)) {
|
||||
seen.add(row.email);
|
||||
affectedUsers.push(row);
|
||||
}
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
|
||||
// Remove from calendar_invitations for all users on this instance
|
||||
await db.run(
|
||||
`DELETE FROM calendar_invitations
|
||||
WHERE event_uid = ? AND from_user LIKE ? ESCAPE '\\'`,
|
||||
[event_uid, `%@${safeDomain}`]
|
||||
);
|
||||
|
||||
// Remove from calendar_events (accepted invitations) for all users on this instance
|
||||
await db.run(
|
||||
`DELETE FROM calendar_events
|
||||
WHERE uid = ? AND federated_from LIKE ? ESCAPE '\\'`,
|
||||
[event_uid, `%@${safeDomain}`]
|
||||
);
|
||||
|
||||
log.federation.info(`Calendar event ${event_uid} deleted (origin: ${originDomain})`);
|
||||
|
||||
// Notify affected users by email (fire-and-forget)
|
||||
if (affectedUsers.length > 0) {
|
||||
const appName = process.env.APP_NAME || 'Redlight';
|
||||
for (const u of affectedUsers) {
|
||||
sendCalendarEventDeletedEmail(u.email, u.name, u.from_user, u.title, appName, u.language || 'en')
|
||||
.catch(mailErr => {
|
||||
log.federation.warn(`Calendar deletion mail to ${u.email} failed (non-fatal): ${mailErr.message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.federation.error('Calendar-event-deleted error:', err);
|
||||
res.status(500).json({ error: 'Failed to process calendar event deletion' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/federation/room-deleted — Receive deletion notification ───────
|
||||
// Origin instance pushes this to notify that a room has been deleted.
|
||||
router.post('/room-deleted', federationReceiveLimiter, async (req, res) => {
|
||||
try {
|
||||
if (!isFederationEnabled()) {
|
||||
return res.status(400).json({ error: 'Federation is not configured on this instance' });
|
||||
}
|
||||
|
||||
const signature = req.headers['x-federation-signature'];
|
||||
const originDomain = req.headers['x-federation-origin'];
|
||||
const payload = req.body || {};
|
||||
|
||||
if (!signature || !originDomain) {
|
||||
return res.status(401).json({ error: 'Missing federation signature or origin' });
|
||||
}
|
||||
|
||||
const { publicKey } = await discoverInstance(originDomain);
|
||||
if (!publicKey || !verifyPayload(payload, signature, publicKey)) {
|
||||
return res.status(403).json({ error: 'Invalid federation signature' });
|
||||
}
|
||||
|
||||
const { room_uid } = payload;
|
||||
if (!room_uid || typeof room_uid !== 'string') {
|
||||
return res.status(400).json({ error: 'room_uid is required' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
// Escape LIKE special characters in originDomain to prevent wildcard injection.
|
||||
const safeDomain = originDomain.replace(/[\\%_]/g, '\\$&');
|
||||
// Mark all federated_rooms with this meet_id (room_uid) from this origin as deleted
|
||||
await db.run(
|
||||
`UPDATE federated_rooms SET deleted = 1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE meet_id = ? AND from_user LIKE ? ESCAPE '\\'`,
|
||||
[room_uid, `%@${safeDomain}`]
|
||||
);
|
||||
|
||||
log.federation.info(`Room ${room_uid} marked as deleted (origin: ${originDomain})`);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.federation.error('Room-deleted error:', err);
|
||||
res.status(500).json({ error: 'Failed to process deletion notification' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
74
server/routes/notifications.js
Normal file
74
server/routes/notifications.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Router } from 'express';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/notifications — List recent notifications for the current user
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const notifications = await db.all(
|
||||
`SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50`,
|
||||
[req.user.id],
|
||||
);
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
res.json({ notifications, unreadCount });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Failed to load notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/notifications/read-all — Mark all notifications as read
|
||||
// NOTE: Must be declared before /:id/read to avoid routing collision
|
||||
router.post('/read-all', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.run('UPDATE notifications SET read = 1 WHERE user_id = ?', [req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Failed to update notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/notifications/:id/read — Mark a single notification as read
|
||||
router.post('/:id/read', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.run(
|
||||
'UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?',
|
||||
[req.params.id, req.user.id],
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Failed to update notification' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/notifications/all — Delete all notifications for current user
|
||||
// NOTE: Declared before /:id to avoid routing collision
|
||||
router.delete('/all', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.run('DELETE FROM notifications WHERE user_id = ?', [req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Failed to delete notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/notifications/:id — Delete a single notification
|
||||
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.run(
|
||||
'DELETE FROM notifications WHERE id = ? AND user_id = ?',
|
||||
[req.params.id, req.user.id],
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Failed to delete notification' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
272
server/routes/oauth.js
Normal file
272
server/routes/oauth.js
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* OAuth / OpenID Connect routes for Redlight.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Client calls GET /api/oauth/providers → returns enabled provider info
|
||||
* 2. Client calls GET /api/oauth/authorize → server generates PKCE + state, redirects to IdP
|
||||
* 3. IdP redirects to GET /api/oauth/callback with code + state
|
||||
* 4. Server exchanges code for tokens, fetches user info, creates/links account, returns JWT
|
||||
*
|
||||
* Security:
|
||||
* - PKCE (S256) everywhere
|
||||
* - Cryptographic state token (single-use, 10-min TTL)
|
||||
* - Client secret never leaves the server
|
||||
* - OAuth user info validated and sanitized before DB insertion
|
||||
* - Rate limited callback endpoint
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { generateToken, getBaseUrl } from '../middleware/auth.js';
|
||||
import { log } from '../config/logger.js';
|
||||
import redis from '../config/redis.js';
|
||||
import {
|
||||
getOAuthConfig,
|
||||
discoverOIDC,
|
||||
createOAuthState,
|
||||
consumeOAuthState,
|
||||
generateCodeVerifier,
|
||||
computeCodeChallenge,
|
||||
exchangeCode,
|
||||
fetchUserInfo,
|
||||
} from '../config/oauth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Rate limit the callback to prevent brute-force of authorization codes
|
||||
const callbackLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many OAuth attempts. Please try again later.' },
|
||||
});
|
||||
|
||||
// ── GET /api/oauth/providers — List available OAuth providers (public) ───────
|
||||
router.get('/providers', async (req, res) => {
|
||||
try {
|
||||
const config = await getOAuthConfig();
|
||||
if (!config) {
|
||||
return res.json({ providers: [] });
|
||||
}
|
||||
|
||||
// Never expose client secret or issuer details to the client
|
||||
res.json({
|
||||
providers: [
|
||||
{
|
||||
id: 'oidc',
|
||||
name: config.displayName || 'SSO',
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (err) {
|
||||
log.auth.error(`OAuth providers error: ${err.message}`);
|
||||
res.json({ providers: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/oauth/authorize — Start OAuth flow (redirects to IdP) ──────────
|
||||
router.get('/authorize', async (req, res) => {
|
||||
try {
|
||||
const config = await getOAuthConfig();
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'OAuth is not configured' });
|
||||
}
|
||||
|
||||
// Discover OIDC endpoints
|
||||
const oidc = await discoverOIDC(config.issuer);
|
||||
|
||||
// Generate PKCE pair
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = computeCodeChallenge(codeVerifier);
|
||||
|
||||
// Optional return_to from query (validate it's a relative path)
|
||||
let returnTo = req.query.return_to || null;
|
||||
if (returnTo && (typeof returnTo !== 'string' || !returnTo.startsWith('/') || returnTo.startsWith('//'))) {
|
||||
returnTo = null; // prevent open redirect
|
||||
}
|
||||
|
||||
// Create server-side state
|
||||
const state = await createOAuthState('oidc', codeVerifier, returnTo);
|
||||
|
||||
// Build callback URL
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const redirectUri = `${baseUrl}/api/oauth/callback`;
|
||||
|
||||
// Build authorization URL
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: config.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'openid email profile',
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
const authUrl = `${oidc.authorization_endpoint}?${params.toString()}`;
|
||||
res.redirect(authUrl);
|
||||
} catch (err) {
|
||||
log.auth.error(`OAuth authorize error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Could not start OAuth flow' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/oauth/callback — Handle IdP callback ──────────────────────────
|
||||
router.get('/callback', callbackLimiter, async (req, res) => {
|
||||
try {
|
||||
const { code, state, error: oauthError, error_description } = req.query;
|
||||
|
||||
// Build frontend error redirect helper
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const errorRedirect = (msg) =>
|
||||
res.redirect(`${baseUrl}/oauth/callback?error=${encodeURIComponent(msg)}`);
|
||||
|
||||
// Handle IdP errors
|
||||
if (oauthError) {
|
||||
log.auth.warn(`OAuth IdP error: ${oauthError} – ${error_description || ''}`);
|
||||
return errorRedirect(error_description || oauthError);
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return errorRedirect('Missing authorization code or state');
|
||||
}
|
||||
|
||||
// Validate and consume state (CSRF + PKCE verifier retrieval)
|
||||
const stateData = await consumeOAuthState(state);
|
||||
if (!stateData) {
|
||||
return errorRedirect('Invalid or expired OAuth state. Please try again.');
|
||||
}
|
||||
|
||||
// Load provider config
|
||||
const config = await getOAuthConfig();
|
||||
if (!config) {
|
||||
return errorRedirect('OAuth is not configured');
|
||||
}
|
||||
|
||||
// Discover OIDC endpoints
|
||||
const oidc = await discoverOIDC(config.issuer);
|
||||
|
||||
// Exchange code for tokens
|
||||
const redirectUri = `${baseUrl}/api/oauth/callback`;
|
||||
const tokenResponse = await exchangeCode(
|
||||
oidc, code, redirectUri, config.clientId, config.clientSecret, stateData.code_verifier,
|
||||
);
|
||||
|
||||
if (!tokenResponse.access_token) {
|
||||
return errorRedirect('Token exchange failed: no access token received');
|
||||
}
|
||||
|
||||
// Fetch user info from the IdP
|
||||
let userInfo;
|
||||
if (oidc.userinfo_endpoint) {
|
||||
userInfo = await fetchUserInfo(oidc.userinfo_endpoint, tokenResponse.access_token);
|
||||
} else {
|
||||
return errorRedirect('Provider does not support userinfo endpoint');
|
||||
}
|
||||
|
||||
// Extract and validate user attributes
|
||||
const email = (userInfo.email || '').toLowerCase().trim();
|
||||
const sub = userInfo.sub || '';
|
||||
const name = userInfo.preferred_username || userInfo.name || email.split('@')[0] || '';
|
||||
const displayName = userInfo.name || userInfo.preferred_username || '';
|
||||
|
||||
if (!email || !sub) {
|
||||
return errorRedirect('OAuth provider did not return an email or subject');
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
|
||||
if (!EMAIL_RE.test(email)) {
|
||||
return errorRedirect('OAuth provider returned an invalid email address');
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// ── Link or create user ──────────────────────────────────────────────
|
||||
// 1. Check if there's already a user linked with this OAuth provider + sub
|
||||
let user = await db.get(
|
||||
'SELECT id, name, display_name, email, role, email_verified FROM users WHERE oauth_provider = ? AND oauth_provider_id = ?',
|
||||
['oidc', sub],
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
// 2. Check if a user with this email already exists (link accounts)
|
||||
user = await db.get(
|
||||
'SELECT id, name, display_name, email, role, email_verified, oauth_provider FROM users WHERE email = ?',
|
||||
[email],
|
||||
);
|
||||
|
||||
if (user) {
|
||||
// Link OAuth to existing account
|
||||
await db.run(
|
||||
'UPDATE users SET oauth_provider = ?, oauth_provider_id = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
['oidc', sub, user.id],
|
||||
);
|
||||
log.auth.info(`Linked OAuth (oidc/${sub}) to existing user ${user.email}`);
|
||||
} else {
|
||||
// 3. Auto-register new user (if enabled)
|
||||
if (!config.autoRegister) {
|
||||
return errorRedirect('No account found for this email. Please register first or ask an admin.');
|
||||
}
|
||||
|
||||
// Check registration mode
|
||||
const regModeSetting = await db.get("SELECT value FROM settings WHERE key = 'registration_mode'");
|
||||
const registrationMode = regModeSetting?.value || 'open';
|
||||
if (registrationMode === 'invite') {
|
||||
return errorRedirect('Registration is invite-only. An administrator must create your account first.');
|
||||
}
|
||||
|
||||
// Sanitize username: only allow safe characters
|
||||
const safeUsername = name.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 30) || `user_${Date.now()}`;
|
||||
|
||||
// Ensure username is unique
|
||||
let finalUsername = safeUsername;
|
||||
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?)', [safeUsername]);
|
||||
if (existingUsername) {
|
||||
finalUsername = `${safeUsername}_${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
// No password needed for OAuth users — use a random hash that can't be guessed
|
||||
const randomPasswordHash = `oauth:${uuidv4()}`;
|
||||
|
||||
const result = await db.run(
|
||||
`INSERT INTO users (name, display_name, email, password_hash, email_verified, oauth_provider, oauth_provider_id)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)`,
|
||||
[finalUsername, displayName.slice(0, 100) || finalUsername, email, randomPasswordHash, 'oidc', sub],
|
||||
);
|
||||
|
||||
user = await db.get(
|
||||
'SELECT id, name, display_name, email, role, email_verified FROM users WHERE id = ?',
|
||||
[result.lastInsertRowid],
|
||||
);
|
||||
log.auth.info(`Created new OAuth user: ${email} (oidc/${sub})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
const token = generateToken(user.id);
|
||||
|
||||
// Store id_token in Redis for RP-Initiated Logout (Keycloak SLO)
|
||||
if (tokenResponse.id_token) {
|
||||
try {
|
||||
await redis.setex(`oidc:id_token:${user.id}`, 7 * 24 * 3600, tokenResponse.id_token);
|
||||
} catch (redisErr) {
|
||||
log.auth.warn(`Failed to cache OIDC id_token: ${redisErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to frontend callback page with token.
|
||||
// Use a hash fragment so the token is never sent to the server (not logged, not in Referer headers).
|
||||
const returnTo = stateData.return_to || '/dashboard';
|
||||
res.redirect(`${baseUrl}/oauth/callback#token=${encodeURIComponent(token)}&return_to=${encodeURIComponent(returnTo)}`);
|
||||
} catch (err) {
|
||||
log.auth.error(`OAuth callback error: ${err.message}`);
|
||||
const baseUrl = getBaseUrl(req);
|
||||
res.redirect(`${baseUrl}/oauth/callback?error=${encodeURIComponent('OAuth authentication failed. Please try again.')}`);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { log } from '../config/logger.js';
|
||||
import {
|
||||
getRecordings,
|
||||
getRecordingByRecordId,
|
||||
deleteRecording,
|
||||
publishRecording,
|
||||
} from '../config/bbb.js';
|
||||
@@ -13,6 +15,25 @@ const router = Router();
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { meetingID } = req.query;
|
||||
|
||||
// M11: verify user has access to the room if a meetingID is specified
|
||||
if (meetingID) {
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [meetingID]);
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
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: 'No permission to view recordings for this room' });
|
||||
}
|
||||
}
|
||||
} else if (req.user.role !== 'admin') {
|
||||
// Non-admins must specify a meetingID
|
||||
return res.status(400).json({ error: 'meetingID query parameter is required' });
|
||||
}
|
||||
|
||||
const recordings = await getRecordings(meetingID || undefined);
|
||||
|
||||
// Format recordings
|
||||
@@ -26,7 +47,7 @@ router.get('/', authenticateToken, async (req, res) => {
|
||||
return {
|
||||
recordID: rec.recordID,
|
||||
meetingID: rec.meetingID,
|
||||
name: rec.name || 'Aufnahme',
|
||||
name: rec.name || 'Recording',
|
||||
state: rec.state,
|
||||
published: rec.published === 'true',
|
||||
startTime: rec.startTime,
|
||||
@@ -45,8 +66,8 @@ router.get('/', authenticateToken, async (req, res) => {
|
||||
|
||||
res.json({ recordings: formatted });
|
||||
} catch (err) {
|
||||
console.error('Get recordings error:', err);
|
||||
res.status(500).json({ error: 'Aufnahmen konnten nicht geladen werden', recordings: [] });
|
||||
log.recordings.error(`Get recordings error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Recordings could not be loaded', recordings: [] });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -57,7 +78,15 @@ router.get('/room/:uid', authenticateToken, async (req, res) => {
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||
return res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
|
||||
// H9: verify requesting user has access to this room
|
||||
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: 'No permission to view recordings for this room' });
|
||||
}
|
||||
}
|
||||
|
||||
const recordings = await getRecordings(room.uid);
|
||||
@@ -89,31 +118,69 @@ router.get('/room/:uid', authenticateToken, async (req, res) => {
|
||||
|
||||
res.json({ recordings: formatted });
|
||||
} catch (err) {
|
||||
console.error('Get room recordings error:', err);
|
||||
res.status(500).json({ error: 'Aufnahmen konnten nicht geladen werden', recordings: [] });
|
||||
log.recordings.error(`Get room recordings error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Recordings could not be loaded', recordings: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/recordings/:recordID
|
||||
router.delete('/:recordID', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
// M14 fix: look up the recording from BBB to find its meetingID (room UID),
|
||||
// then verify the user owns or shares that room.
|
||||
if (req.user.role !== 'admin') {
|
||||
const rec = await getRecordingByRecordId(req.params.recordID);
|
||||
if (!rec) {
|
||||
return res.status(404).json({ error: 'Recording not found' });
|
||||
}
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]);
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
if (room.user_id !== req.user.id) {
|
||||
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||
if (!share) {
|
||||
return res.status(403).json({ error: 'No permission to delete this recording' });
|
||||
}
|
||||
}
|
||||
}
|
||||
await deleteRecording(req.params.recordID);
|
||||
res.json({ message: 'Aufnahme gelöscht' });
|
||||
res.json({ message: 'Recording deleted' });
|
||||
} catch (err) {
|
||||
console.error('Delete recording error:', err);
|
||||
res.status(500).json({ error: 'Aufnahme konnte nicht gelöscht werden' });
|
||||
log.recordings.error(`Delete recording error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Recording could not be deleted' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/recordings/:recordID/publish
|
||||
router.put('/:recordID/publish', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
// M14 fix: look up the recording from BBB to find its meetingID (room UID),
|
||||
// then verify the user owns or shares that room.
|
||||
if (req.user.role !== 'admin') {
|
||||
const rec = await getRecordingByRecordId(req.params.recordID);
|
||||
if (!rec) {
|
||||
return res.status(404).json({ error: 'Recording not found' });
|
||||
}
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]);
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
if (room.user_id !== req.user.id) {
|
||||
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||
if (!share) {
|
||||
return res.status(403).json({ error: 'No permission to update this recording' });
|
||||
}
|
||||
}
|
||||
}
|
||||
const { publish } = req.body;
|
||||
await publishRecording(req.params.recordID, publish);
|
||||
res.json({ message: publish ? 'Aufnahme veröffentlicht' : 'Aufnahme nicht mehr öffentlich' });
|
||||
res.json({ message: publish ? 'Recording published' : 'Recording unpublished' });
|
||||
} catch (err) {
|
||||
console.error('Publish recording error:', err);
|
||||
res.status(500).json({ error: 'Aufnahme konnte nicht aktualisiert werden' });
|
||||
log.recordings.error(`Publish recording error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Recording could not be updated' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,25 +1,61 @@
|
||||
import { Router } from 'express';
|
||||
import { Router } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
|
||||
import { log } from '../config/logger.js';
|
||||
import { createNotification } from '../config/notifications.js';
|
||||
import {
|
||||
createMeeting,
|
||||
joinMeeting,
|
||||
endMeeting,
|
||||
getMeetingInfo,
|
||||
isMeetingRunning,
|
||||
getAnalyticsToken,
|
||||
} from '../config/bbb.js';
|
||||
import {
|
||||
isFederationEnabled,
|
||||
getFederationDomain,
|
||||
signPayload,
|
||||
discoverInstance,
|
||||
} from '../config/federation.js';
|
||||
|
||||
// L6: constant-time string comparison for access/moderator codes
|
||||
function timingSafeEqual(a, b) {
|
||||
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
||||
const bufA = Buffer.from(a);
|
||||
const bufB = Buffer.from(b);
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return crypto.timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const presentationsDir = path.join(__dirname, '..', '..', 'uploads', 'presentations');
|
||||
if (!fs.existsSync(presentationsDir)) fs.mkdirSync(presentationsDir, { recursive: true });
|
||||
|
||||
// M8: rate limit unauthenticated guest-join to prevent access_code brute-force
|
||||
const guestJoinLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 15,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many join attempts. Please try again later.' },
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Build avatar URL for a user (uploaded image or generated initials)
|
||||
function getUserAvatarURL(req, user) {
|
||||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
||||
const baseUrl = getBaseUrl(req);
|
||||
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}`;
|
||||
return `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(user.display_name || user.name)}${color}`;
|
||||
}
|
||||
|
||||
// GET /api/rooms - List user's rooms (owned + shared)
|
||||
@@ -27,7 +63,7 @@ router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const ownRooms = await db.all(`
|
||||
SELECT r.*, u.name as owner_name, 0 as shared
|
||||
SELECT r.*, COALESCE(NULLIF(u.display_name,''), u.name) as owner_name, 0 as shared
|
||||
FROM rooms r
|
||||
JOIN users u ON r.user_id = u.id
|
||||
WHERE r.user_id = ?
|
||||
@@ -35,7 +71,7 @@ router.get('/', authenticateToken, async (req, res) => {
|
||||
`, [req.user.id]);
|
||||
|
||||
const sharedRooms = await db.all(`
|
||||
SELECT r.*, u.name as owner_name, 1 as shared
|
||||
SELECT r.*, COALESCE(NULLIF(u.display_name,''), 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
|
||||
@@ -45,8 +81,8 @@ router.get('/', authenticateToken, async (req, res) => {
|
||||
|
||||
res.json({ rooms: [...ownRooms, ...sharedRooms] });
|
||||
} catch (err) {
|
||||
console.error('List rooms error:', err);
|
||||
res.status(500).json({ error: 'Räume konnten nicht geladen werden' });
|
||||
log.rooms.error(`List rooms error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Rooms could not be loaded' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -60,15 +96,15 @@ router.get('/users/search', authenticateToken, async (req, res) => {
|
||||
const db = getDb();
|
||||
const searchTerm = `%${q}%`;
|
||||
const users = await db.all(`
|
||||
SELECT id, name, email, avatar_color, avatar_image
|
||||
SELECT id, name, display_name, email, avatar_color, avatar_image
|
||||
FROM users
|
||||
WHERE (name LIKE ? OR email LIKE ?) AND id != ?
|
||||
WHERE (name LIKE ? OR display_name LIKE ? OR email LIKE ?) AND id != ?
|
||||
LIMIT 10
|
||||
`, [searchTerm, searchTerm, req.user.id]);
|
||||
`, [searchTerm, searchTerm, searchTerm, req.user.id]);
|
||||
res.json({ users });
|
||||
} catch (err) {
|
||||
console.error('Search users error:', err);
|
||||
res.status(500).json({ error: 'Benutzersuche fehlgeschlagen' });
|
||||
log.rooms.error(`Search users error: ${err.message}`);
|
||||
res.status(500).json({ error: 'User search failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -77,28 +113,28 @@ router.get('/:uid', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const room = await db.get(`
|
||||
SELECT r.*, u.name as owner_name
|
||||
SELECT r.*, COALESCE(NULLIF(u.display_name,''), u.name) as owner_name
|
||||
FROM rooms r
|
||||
JOIN users u ON r.user_id = u.id
|
||||
WHERE r.uid = ?
|
||||
`, [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||
return res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
|
||||
// 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' });
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
}
|
||||
room.shared = 1;
|
||||
}
|
||||
|
||||
// Get shared users
|
||||
const sharedUsers = await db.all(`
|
||||
SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image
|
||||
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
|
||||
FROM room_shares rs
|
||||
JOIN users u ON rs.user_id = u.id
|
||||
WHERE rs.room_id = ?
|
||||
@@ -106,8 +142,8 @@ router.get('/:uid', authenticateToken, async (req, res) => {
|
||||
|
||||
res.json({ room, sharedUsers });
|
||||
} catch (err) {
|
||||
console.error('Get room error:', err);
|
||||
res.status(500).json({ error: 'Raum konnte nicht geladen werden' });
|
||||
log.rooms.error(`Get room error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Room could not be loaded' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -129,7 +165,31 @@ router.post('/', authenticateToken, async (req, res) => {
|
||||
} = req.body;
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'Raumname ist erforderlich' });
|
||||
return res.status(400).json({ error: 'Room name is required' });
|
||||
}
|
||||
if (name.trim().length < 2) {
|
||||
return res.status(400).json({ error: 'Room name must be at least 2 characters' });
|
||||
}
|
||||
|
||||
// M7: field length limits
|
||||
if (name.trim().length > 100) {
|
||||
return res.status(400).json({ error: 'Room name must not exceed 100 characters' });
|
||||
}
|
||||
if (welcome_message && welcome_message.length > 2000) {
|
||||
return res.status(400).json({ error: 'Welcome message must not exceed 2000 characters' });
|
||||
}
|
||||
if (access_code && access_code.length > 50) {
|
||||
return res.status(400).json({ error: 'Access code must not exceed 50 characters' });
|
||||
}
|
||||
if (moderator_code && moderator_code.length > 50) {
|
||||
return res.status(400).json({ error: 'Moderator code must not exceed 50 characters' });
|
||||
}
|
||||
// S2: validate max_participants as non-negative integer
|
||||
if (max_participants !== undefined && max_participants !== null) {
|
||||
const mp = Number(max_participants);
|
||||
if (!Number.isInteger(mp) || mp < 0 || mp > 10000) {
|
||||
return res.status(400).json({ error: 'max_participants must be a non-negative integer (max 10000)' });
|
||||
}
|
||||
}
|
||||
|
||||
const uid = crypto.randomBytes(8).toString('hex');
|
||||
@@ -157,8 +217,8 @@ router.post('/', authenticateToken, async (req, res) => {
|
||||
const room = await db.get('SELECT * FROM rooms WHERE id = ?', [result.lastInsertRowid]);
|
||||
res.status(201).json({ room });
|
||||
} catch (err) {
|
||||
console.error('Create room error:', err);
|
||||
res.status(500).json({ error: 'Raum konnte nicht erstellt werden' });
|
||||
log.rooms.error(`Create room error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Room could not be created' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -169,7 +229,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' });
|
||||
return res.status(404).json({ error: 'Room not found or no permission' });
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -184,8 +244,34 @@ router.put('/:uid', authenticateToken, async (req, res) => {
|
||||
record_meeting,
|
||||
guest_access,
|
||||
moderator_code,
|
||||
learning_analytics,
|
||||
analytics_visibility,
|
||||
} = req.body;
|
||||
|
||||
// M12: field length limits (same as create)
|
||||
if (name && name.trim().length < 2) {
|
||||
return res.status(400).json({ error: 'Room name must be at least 2 characters' });
|
||||
}
|
||||
if (name && name.trim().length > 100) {
|
||||
return res.status(400).json({ error: 'Room name must not exceed 100 characters' });
|
||||
}
|
||||
if (welcome_message && welcome_message.length > 2000) {
|
||||
return res.status(400).json({ error: 'Welcome message must not exceed 2000 characters' });
|
||||
}
|
||||
if (access_code && access_code.length > 50) {
|
||||
return res.status(400).json({ error: 'Access code must not exceed 50 characters' });
|
||||
}
|
||||
if (moderator_code && moderator_code.length > 50) {
|
||||
return res.status(400).json({ error: 'Moderator code must not exceed 50 characters' });
|
||||
}
|
||||
// S2: validate max_participants on update
|
||||
if (max_participants !== undefined && max_participants !== null) {
|
||||
const mp = Number(max_participants);
|
||||
if (!Number.isInteger(mp) || mp < 0 || mp > 10000) {
|
||||
return res.status(400).json({ error: 'max_participants must be a non-negative integer (max 10000)' });
|
||||
}
|
||||
}
|
||||
|
||||
await db.run(`
|
||||
UPDATE rooms SET
|
||||
name = COALESCE(?, name),
|
||||
@@ -199,6 +285,8 @@ router.put('/:uid', authenticateToken, async (req, res) => {
|
||||
record_meeting = COALESCE(?, record_meeting),
|
||||
guest_access = COALESCE(?, guest_access),
|
||||
moderator_code = ?,
|
||||
learning_analytics = COALESCE(?, learning_analytics),
|
||||
analytics_visibility = COALESCE(?, analytics_visibility),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE uid = ?
|
||||
`, [
|
||||
@@ -213,14 +301,16 @@ router.put('/:uid', authenticateToken, async (req, res) => {
|
||||
record_meeting !== undefined ? (record_meeting ? 1 : 0) : null,
|
||||
guest_access !== undefined ? (guest_access ? 1 : 0) : null,
|
||||
moderator_code !== undefined ? (moderator_code || null) : room.moderator_code,
|
||||
learning_analytics !== undefined ? (learning_analytics ? 1 : 0) : null,
|
||||
analytics_visibility && ['owner', 'shared'].includes(analytics_visibility) ? analytics_visibility : null,
|
||||
req.params.uid,
|
||||
]);
|
||||
|
||||
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
res.json({ room: updated });
|
||||
} catch (err) {
|
||||
console.error('Update room error:', err);
|
||||
res.status(500).json({ error: 'Raum konnte nicht aktualisiert werden' });
|
||||
log.rooms.error(`Update room error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Room could not be updated' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -231,18 +321,51 @@ router.delete('/:uid', authenticateToken, async (req, res) => {
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||
return res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
|
||||
if (room.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
}
|
||||
|
||||
// Notify federated instances about deletion (fire-and-forget)
|
||||
if (isFederationEnabled()) {
|
||||
try {
|
||||
const outbound = await db.all(
|
||||
'SELECT remote_domain FROM federation_outbound_invites WHERE room_uid = ?',
|
||||
[room.uid]
|
||||
);
|
||||
for (const { remote_domain } of outbound) {
|
||||
const payload = {
|
||||
room_uid: room.uid,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const signature = signPayload(payload);
|
||||
discoverInstance(remote_domain).then(({ baseUrl: remoteApi }) => {
|
||||
fetch(`${remoteApi}/room-deleted`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Federation-Signature': signature,
|
||||
'X-Federation-Origin': getFederationDomain(),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}).catch(err => log.federation.warn(`Delete notify to ${remote_domain} failed: ${err.message}`));
|
||||
}).catch(err => log.federation.warn(`Discovery for ${remote_domain} failed: ${err.message}`));
|
||||
}
|
||||
// Clean up outbound records
|
||||
await db.run('DELETE FROM federation_outbound_invites WHERE room_uid = ?', [room.uid]);
|
||||
} catch (fedErr) {
|
||||
log.federation.warn(`Delete notification error (non-fatal): ${fedErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await db.run('DELETE FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
res.json({ message: 'Raum erfolgreich gelöscht' });
|
||||
res.json({ message: 'Room deleted successfully' });
|
||||
} catch (err) {
|
||||
console.error('Delete room error:', err);
|
||||
res.status(500).json({ error: 'Raum konnte nicht gelöscht werden' });
|
||||
log.rooms.error(`Delete room error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Room could not be deleted' });
|
||||
}
|
||||
});
|
||||
// GET /api/rooms/:uid/shares - Get shared users for a room
|
||||
@@ -251,18 +374,18 @@ router.get('/:uid/shares', authenticateToken, async (req, res) => {
|
||||
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' });
|
||||
return res.status(404).json({ error: 'Room not found or no permission' });
|
||||
}
|
||||
const shares = await db.all(`
|
||||
SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image
|
||||
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
|
||||
FROM room_shares rs
|
||||
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' });
|
||||
log.rooms.error(`Get shares error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Error loading shares' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -271,32 +394,41 @@ 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' });
|
||||
return res.status(400).json({ error: 'User ID is required' });
|
||||
}
|
||||
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' });
|
||||
return res.status(404).json({ error: 'Room not found or no permission' });
|
||||
}
|
||||
if (user_id === req.user.id) {
|
||||
return res.status(400).json({ error: 'Du kannst den Raum nicht mit dir selbst teilen' });
|
||||
return res.status(400).json({ error: 'You cannot share the room with yourself' });
|
||||
}
|
||||
// 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' });
|
||||
return res.status(400).json({ error: 'Room is already shared with this user' });
|
||||
}
|
||||
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
|
||||
SELECT u.id, u.name, u.display_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]);
|
||||
// Notify the user who was given access
|
||||
const sharerName = req.user.display_name || req.user.name;
|
||||
await createNotification(
|
||||
user_id,
|
||||
'room_share_added',
|
||||
room.name,
|
||||
sharerName,
|
||||
`/rooms/${room.uid}`,
|
||||
);
|
||||
res.json({ shares });
|
||||
} catch (err) {
|
||||
console.error('Share room error:', err);
|
||||
res.status(500).json({ error: 'Fehler beim Teilen des Raums' });
|
||||
log.rooms.error(`Share room error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Error sharing room' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -306,19 +438,28 @@ router.delete('/:uid/shares/:userId', authenticateToken, async (req, res) => {
|
||||
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' });
|
||||
return res.status(404).json({ error: 'Room not found or no permission' });
|
||||
}
|
||||
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, parseInt(req.params.userId)]);
|
||||
const removedUserId = parseInt(req.params.userId);
|
||||
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, removedUserId]);
|
||||
const shares = await db.all(`
|
||||
SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image
|
||||
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
|
||||
FROM room_shares rs
|
||||
JOIN users u ON rs.user_id = u.id
|
||||
WHERE rs.room_id = ?
|
||||
`, [room.id]);
|
||||
// Notify the user whose access was removed
|
||||
await createNotification(
|
||||
removedUserId,
|
||||
'room_share_removed',
|
||||
room.name,
|
||||
null,
|
||||
'/dashboard',
|
||||
);
|
||||
res.json({ shares });
|
||||
} catch (err) {
|
||||
console.error('Remove share error:', err);
|
||||
res.status(500).json({ error: 'Fehler beim Entfernen der Freigabe' });
|
||||
log.rooms.error(`Remove share error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Error removing share' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -329,25 +470,34 @@ router.post('/:uid/start', authenticateToken, async (req, res) => {
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||
return res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
|
||||
// Check access: owner or shared user
|
||||
// Check access: owner or shared
|
||||
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' });
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
}
|
||||
}
|
||||
|
||||
await createMeeting(room, `${req.protocol}://${req.get('host')}`);
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const loginURL = `${baseUrl}/join/${room.uid}`;
|
||||
const presentationUrl = room.presentation_file
|
||||
? `${baseUrl}/uploads/presentations/${room.presentation_file}`
|
||||
: null;
|
||||
const analyticsCallbackURL = room.learning_analytics
|
||||
? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}`
|
||||
: null;
|
||||
await createMeeting(room, baseUrl, loginURL, presentationUrl, analyticsCallbackURL);
|
||||
const avatarURL = getUserAvatarURL(req, req.user);
|
||||
const joinUrl = await joinMeeting(room.uid, req.user.name, true, avatarURL);
|
||||
const displayName = req.user.display_name || req.user.name;
|
||||
const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL);
|
||||
res.json({ joinUrl });
|
||||
} catch (err) {
|
||||
console.error('Start meeting error:', err);
|
||||
res.status(500).json({ error: 'Meeting konnte nicht gestartet werden' });
|
||||
log.rooms.error(`Start meeting error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Meeting could not be started' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -358,18 +508,18 @@ router.post('/:uid/join', authenticateToken, async (req, res) => {
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||
return res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
|
||||
// Check access code if set
|
||||
if (room.access_code && req.body.access_code !== room.access_code) {
|
||||
return res.status(403).json({ error: 'Falscher Zugangscode' });
|
||||
if (room.access_code && !timingSafeEqual(req.body.access_code || '', room.access_code)) {
|
||||
return res.status(403).json({ error: 'Wrong access code' });
|
||||
}
|
||||
|
||||
// Check if meeting is running
|
||||
const running = await isMeetingRunning(room.uid);
|
||||
if (!running) {
|
||||
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 is not running. Please wait for the moderator to start the meeting.' });
|
||||
}
|
||||
|
||||
// Owner and shared users join as moderator
|
||||
@@ -377,11 +527,11 @@ router.post('/:uid/join', authenticateToken, async (req, res) => {
|
||||
const isShared = !isOwner && await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||
const isModerator = isOwner || !!isShared || room.all_join_moderator;
|
||||
const avatarURL = getUserAvatarURL(req, req.user);
|
||||
const joinUrl = await joinMeeting(room.uid, req.user.name, isModerator, avatarURL);
|
||||
const joinUrl = await joinMeeting(room.uid, req.user.display_name || req.user.name, isModerator, avatarURL);
|
||||
res.json({ joinUrl });
|
||||
} catch (err) {
|
||||
console.error('Join meeting error:', err);
|
||||
res.status(500).json({ error: 'Meeting konnte nicht beigetreten werden' });
|
||||
log.rooms.error(`Join meeting error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Could not join meeting' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -392,7 +542,7 @@ router.post('/:uid/end', authenticateToken, async (req, res) => {
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||
return res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
|
||||
// Check access: owner or shared user
|
||||
@@ -400,15 +550,15 @@ router.post('/:uid/end', authenticateToken, async (req, res) => {
|
||||
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' });
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
}
|
||||
}
|
||||
|
||||
await endMeeting(room.uid);
|
||||
res.json({ message: 'Meeting beendet' });
|
||||
res.json({ message: 'Meeting ended' });
|
||||
} catch (err) {
|
||||
console.error('End meeting error:', err);
|
||||
res.status(500).json({ error: 'Meeting konnte nicht beendet werden' });
|
||||
log.rooms.error(`End meeting error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Meeting could not be ended' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -417,15 +567,15 @@ router.get('/:uid/public', async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const room = await db.get(`
|
||||
SELECT r.uid, r.name, r.welcome_message, r.access_code,
|
||||
u.name as owner_name
|
||||
SELECT r.uid, r.name, r.welcome_message, r.access_code, r.record_meeting, r.max_participants, r.anyone_can_start,
|
||||
COALESCE(NULLIF(u.display_name,''), u.name) as owner_name
|
||||
FROM rooms r
|
||||
JOIN users u ON r.user_id = u.id
|
||||
WHERE r.uid = ?
|
||||
`, [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||
return res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
|
||||
const running = await isMeetingRunning(room.uid);
|
||||
@@ -437,60 +587,73 @@ router.get('/:uid/public', async (req, res) => {
|
||||
owner_name: room.owner_name,
|
||||
welcome_message: room.welcome_message,
|
||||
has_access_code: !!room.access_code,
|
||||
allow_recording: !!room.record_meeting,
|
||||
max_participants: room.max_participants ?? 0,
|
||||
anyone_can_start: !!room.anyone_can_start,
|
||||
},
|
||||
running,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Public room info error:', err);
|
||||
res.status(500).json({ error: 'Rauminfos konnten nicht geladen werden' });
|
||||
log.rooms.error(`Public room info error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Room info could not be loaded' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rooms/:uid/guest-join - Join meeting as guest (no auth needed)
|
||||
router.post('/:uid/guest-join', async (req, res) => {
|
||||
router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
|
||||
try {
|
||||
const { name, access_code, moderator_code } = req.body;
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'Name ist erforderlich' });
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
}
|
||||
|
||||
// L1: limit guest name length
|
||||
if (name.trim().length > 100) {
|
||||
return res.status(400).json({ error: 'Name must not exceed 100 characters' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||
return res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
|
||||
// Check access code if set
|
||||
if (room.access_code && access_code !== room.access_code) {
|
||||
return res.status(403).json({ error: 'Falscher Zugangscode' });
|
||||
if (room.access_code && !timingSafeEqual(access_code || '', room.access_code)) {
|
||||
return res.status(403).json({ error: 'Wrong access code' });
|
||||
}
|
||||
|
||||
// Check if meeting is running (or if anyone_can_start is enabled)
|
||||
const running = await isMeetingRunning(room.uid);
|
||||
if (!running && !room.anyone_can_start) {
|
||||
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 is not running. Please wait for the moderator to start the meeting.' });
|
||||
}
|
||||
|
||||
// If meeting not running but anyone_can_start, create it
|
||||
if (!running && room.anyone_can_start) {
|
||||
await createMeeting(room, `${req.protocol}://${req.get('host')}`);
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const loginURL = `${baseUrl}/join/${room.uid}`;
|
||||
const analyticsCallbackURL = room.learning_analytics
|
||||
? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}`
|
||||
: null;
|
||||
await createMeeting(room, baseUrl, loginURL, null, analyticsCallbackURL);
|
||||
}
|
||||
|
||||
// Check moderator code
|
||||
let isModerator = !!room.all_join_moderator;
|
||||
if (!isModerator && moderator_code && room.moderator_code && moderator_code === room.moderator_code) {
|
||||
if (!isModerator && moderator_code && room.moderator_code && timingSafeEqual(moderator_code, room.moderator_code)) {
|
||||
isModerator = true;
|
||||
}
|
||||
|
||||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
|
||||
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL);
|
||||
res.json({ joinUrl });
|
||||
} catch (err) {
|
||||
console.error('Guest join error:', err);
|
||||
res.status(500).json({ error: 'Beitritt als Gast fehlgeschlagen' });
|
||||
log.rooms.error(`Guest join error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Guest join failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -516,4 +679,104 @@ router.get('/:uid/status', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rooms/:uid/presentation - Upload a presentation file for the room
|
||||
router.post('/:uid/presentation', 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: 'Room not found or no permission' });
|
||||
|
||||
// M16: stream-level size limit - abort as soon as 50 MB is exceeded
|
||||
const MAX_PRESENTATION_SIZE = 50 * 1024 * 1024;
|
||||
const buffer = await new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let totalSize = 0;
|
||||
req.on('data', chunk => {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_PRESENTATION_SIZE) {
|
||||
req.destroy();
|
||||
return reject(new Error('LIMIT_EXCEEDED'));
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
}).catch(err => {
|
||||
if (err.message === 'LIMIT_EXCEEDED') return null;
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (!buffer) {
|
||||
return res.status(400).json({ error: 'File must not exceed 50MB' });
|
||||
}
|
||||
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const extMap = {
|
||||
'application/pdf': 'pdf',
|
||||
'application/vnd.ms-powerpoint': 'ppt',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
|
||||
'application/vnd.oasis.opendocument.presentation': 'odp',
|
||||
'application/msword': 'doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
||||
};
|
||||
const ext = extMap[contentType];
|
||||
if (!ext) return res.status(400).json({ error: 'Unsupported file type. Allowed: PDF, PPT, PPTX, ODP, DOC, DOCX' });
|
||||
|
||||
// Validate magic bytes to prevent Content-Type spoofing
|
||||
const magic = buffer.slice(0, 8);
|
||||
const isPDF = magic[0] === 0x25 && magic[1] === 0x50 && magic[2] === 0x44 && magic[3] === 0x46; // %PDF
|
||||
const isZip = magic[0] === 0x50 && magic[1] === 0x4B && magic[2] === 0x03 && magic[3] === 0x04; // PK (PPTX, DOCX, ODP, etc.)
|
||||
const isOle = magic[0] === 0xD0 && magic[1] === 0xCF && magic[2] === 0x11 && magic[3] === 0xE0; // OLE2 (PPT, DOC)
|
||||
if (ext === 'pdf' && !isPDF) return res.status(400).json({ error: 'File content does not match PDF format' });
|
||||
if (['pptx', 'docx', 'odp'].includes(ext) && !isZip) return res.status(400).json({ error: 'File content does not match expected archive format' });
|
||||
if (['ppt', 'doc'].includes(ext) && !isOle) return res.status(400).json({ error: 'File content does not match expected document format' });
|
||||
|
||||
// Preserve original filename (sent as X-Filename header)
|
||||
const rawName = req.headers['x-filename'];
|
||||
const originalName = rawName
|
||||
? decodeURIComponent(rawName).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, 200)
|
||||
: `presentation.${ext}`;
|
||||
|
||||
const filename = `${room.uid}_${Date.now()}.${ext}`;
|
||||
const filepath = path.join(presentationsDir, filename);
|
||||
|
||||
// Remove old presentation file if exists
|
||||
if (room.presentation_file) {
|
||||
// S8: defense-in-depth path traversal check
|
||||
const oldPath = path.resolve(presentationsDir, room.presentation_file);
|
||||
if (oldPath.startsWith(presentationsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filepath, buffer);
|
||||
await db.run('UPDATE rooms SET presentation_file = ?, presentation_name = ?, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [filename, originalName, req.params.uid]);
|
||||
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
res.json({ room: updated });
|
||||
} catch (err) {
|
||||
log.rooms.error(`Presentation upload error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Presentation could not be uploaded' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/rooms/:uid/presentation - Remove presentation file
|
||||
router.delete('/:uid/presentation', 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: 'Room not found or no permission' });
|
||||
|
||||
if (room.presentation_file) {
|
||||
// S8: defense-in-depth path traversal check
|
||||
const filepath = path.resolve(presentationsDir, room.presentation_file);
|
||||
if (filepath.startsWith(presentationsDir + path.sep) && fs.existsSync(filepath)) fs.unlinkSync(filepath);
|
||||
}
|
||||
|
||||
await db.run('UPDATE rooms SET presentation_file = NULL, presentation_name = NULL, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [req.params.uid]);
|
||||
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
res.json({ room: updated });
|
||||
} catch (err) {
|
||||
log.rooms.error(`Presentation delete error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Presentation could not be removed' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
13
src/App.jsx
13
src/App.jsx
@@ -14,6 +14,11 @@ import RoomDetail from './pages/RoomDetail';
|
||||
import Settings from './pages/Settings';
|
||||
import Admin from './pages/Admin';
|
||||
import GuestJoin from './pages/GuestJoin';
|
||||
import FederationInbox from './pages/FederationInbox';
|
||||
import FederatedRoomDetail from './pages/FederatedRoomDetail';
|
||||
import Calendar from './pages/Calendar';
|
||||
import OAuthCallback from './pages/OAuthCallback';
|
||||
import NotFound from './pages/NotFound';
|
||||
|
||||
export default function App() {
|
||||
const { user, loading } = useAuth();
|
||||
@@ -47,18 +52,22 @@ export default function App() {
|
||||
<Route path="/login" element={user ? <Navigate to="/dashboard" /> : <Login />} />
|
||||
<Route path="/register" element={user ? <Navigate to="/dashboard" /> : <Register />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/oauth/callback" element={<OAuthCallback />} />
|
||||
<Route path="/join/:uid" element={<GuestJoin />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/rooms/:uid" element={<RoomDetail />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/federation/inbox" element={<FederationInbox />} />
|
||||
<Route path="/federation/rooms/:id" element={<FederatedRoomDetail />} />
|
||||
</Route>
|
||||
|
||||
{/* Catch all */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
{/* 404 */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
261
src/components/AnalyticsList.jsx
Normal file
261
src/components/AnalyticsList.jsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import { BarChart3, Trash2, Clock, Users, MessageSquare, Video, Mic, ChevronDown, ChevronUp, Hand, BarChart2, Download } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import api from '../services/api';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function AnalyticsList({ analytics, onRefresh, isOwner = true }) {
|
||||
const [loading, setLoading] = useState({});
|
||||
const [expanded, setExpanded] = useState({});
|
||||
const [exportMenu, setExportMenu] = useState({});
|
||||
const { t, language } = useLanguage();
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '—';
|
||||
return new Date(dateStr).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatDurationSec = (sec) => {
|
||||
if (!sec || sec <= 0) return '0m';
|
||||
const minutes = Math.floor(sec / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
const secs = sec % 60;
|
||||
if (hours > 0) return `${hours}h ${mins}m`;
|
||||
if (minutes > 0) return `${mins}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm(t('analytics.deleteConfirm'))) return;
|
||||
setLoading(prev => ({ ...prev, [id]: 'deleting' }));
|
||||
try {
|
||||
await api.delete(`/analytics/${id}`);
|
||||
toast.success(t('analytics.deleted'));
|
||||
onRefresh?.();
|
||||
} catch {
|
||||
toast.error(t('analytics.deleteFailed'));
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, [id]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const toggleExportMenu = (id) => {
|
||||
setExportMenu(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const handleExport = async (id, format) => {
|
||||
setExportMenu(prev => ({ ...prev, [id]: false }));
|
||||
setLoading(prev => ({ ...prev, [id]: 'exporting' }));
|
||||
try {
|
||||
const response = await api.get(`/analytics/${id}/export/${format}`, { responseType: 'blob' });
|
||||
const disposition = response.headers['content-disposition'];
|
||||
const match = disposition?.match(/filename="?([^"]+)"?/);
|
||||
const filename = match?.[1] || `analytics.${format}`;
|
||||
const url = window.URL.createObjectURL(response.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success(t('analytics.exportSuccess'));
|
||||
} catch {
|
||||
toast.error(t('analytics.exportFailed'));
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, [id]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
// Extract user summary from BBB learning analytics callback data
|
||||
// Payload: { meeting_id, data: { duration, start, finish, attendees: [{ name, moderator, duration, engagement: { chats, talks, raisehand, emojis, poll_votes, talk_time } }] } }
|
||||
const getUserSummary = (data) => {
|
||||
const attendees = data?.data?.attendees;
|
||||
if (!Array.isArray(attendees)) return [];
|
||||
return attendees.map(a => ({
|
||||
name: a.name || '—',
|
||||
isModerator: !!a.moderator,
|
||||
duration: a.duration || 0,
|
||||
talkTime: a.engagement?.talk_time || 0,
|
||||
chats: a.engagement?.chats || 0,
|
||||
talks: a.engagement?.talks || 0,
|
||||
raiseHand: a.engagement?.raisehand || 0,
|
||||
emojis: a.engagement?.emojis || 0,
|
||||
pollVotes: a.engagement?.poll_votes || 0,
|
||||
}));
|
||||
};
|
||||
|
||||
const getMeetingSummary = (data) => ({
|
||||
duration: data?.data?.duration || 0,
|
||||
start: data?.data?.start || null,
|
||||
finish: data?.data?.finish || null,
|
||||
files: data?.data?.files || [],
|
||||
polls: data?.data?.polls || [],
|
||||
});
|
||||
|
||||
if (!analytics || analytics.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<BarChart3 size={48} className="mx-auto text-th-text-s/40 mb-3" />
|
||||
<p className="text-th-text-s text-sm">{t('analytics.noData')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{analytics.map(entry => {
|
||||
const users = getUserSummary(entry.data);
|
||||
const meeting = getMeetingSummary(entry.data);
|
||||
const isExpanded = expanded[entry.id];
|
||||
const totalParticipants = users.length;
|
||||
const totalMessages = users.reduce((sum, u) => sum + u.chats, 0);
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="card p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="text-sm font-medium text-th-text truncate">
|
||||
{entry.meetingName || entry.meetingId}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-th-text-s">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
{meeting.start ? formatDate(meeting.start) : formatDate(entry.createdAt)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<BarChart2 size={12} />
|
||||
{formatDurationSec(meeting.duration)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={12} />
|
||||
{totalParticipants} {t('analytics.participants')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare size={12} />
|
||||
{totalMessages} {t('analytics.messages')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => toggleExportMenu(entry.id)}
|
||||
disabled={loading[entry.id] === 'exporting'}
|
||||
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-text transition-colors"
|
||||
title={t('analytics.export')}
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
{exportMenu[entry.id] && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-th-bg border border-th-border rounded-lg shadow-lg z-10 min-w-[120px] py-1">
|
||||
<button
|
||||
onClick={() => handleExport(entry.id, 'csv')}
|
||||
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||
>
|
||||
CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport(entry.id, 'xlsx')}
|
||||
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||
>
|
||||
Excel (.xlsx)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport(entry.id, 'pdf')}
|
||||
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||
>
|
||||
PDF
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleExpand(entry.id)}
|
||||
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-text transition-colors"
|
||||
title={isExpanded ? t('analytics.collapse') : t('analytics.expand')}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</button>
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => handleDelete(entry.id)}
|
||||
disabled={loading[entry.id] === 'deleting'}
|
||||
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && users.length > 0 && (
|
||||
<div className="mt-4 border-t border-th-border pt-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-th-text-s border-b border-th-border">
|
||||
<th className="pb-2 pr-4 font-medium">{t('analytics.userName')}</th>
|
||||
<th className="pb-2 pr-4 font-medium">{t('analytics.role')}</th>
|
||||
<th className="pb-2 pr-4 font-medium">
|
||||
<span className="flex items-center gap-1"><Clock size={11} />{t('analytics.duration')}</span>
|
||||
</th>
|
||||
<th className="pb-2 pr-4 font-medium">
|
||||
<span className="flex items-center gap-1"><Mic size={11} />{t('analytics.talkTime')}</span>
|
||||
</th>
|
||||
<th className="pb-2 pr-4 font-medium">
|
||||
<span className="flex items-center gap-1"><MessageSquare size={11} />{t('analytics.messages')}</span>
|
||||
</th>
|
||||
<th className="pb-2 pr-4 font-medium">
|
||||
<span className="flex items-center gap-1"><Hand size={11} />{t('analytics.raiseHand')}</span>
|
||||
</th>
|
||||
<th className="pb-2 font-medium">{t('analytics.reactions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u, i) => (
|
||||
<tr key={i} className="border-b border-th-border/50 last:border-0">
|
||||
<td className="py-2 pr-4 text-th-text font-medium">{u.name}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${
|
||||
u.isModerator
|
||||
? 'bg-th-accent/15 text-th-accent'
|
||||
: 'bg-th-bg-s text-th-text-s'
|
||||
}`}>
|
||||
{u.isModerator ? t('analytics.moderator') : t('analytics.viewer')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-th-text-s">{formatDurationSec(u.duration)}</td>
|
||||
<td className="py-2 pr-4 text-th-text-s">{formatDurationSec(u.talkTime)}</td>
|
||||
<td className="py-2 pr-4 text-th-text-s">{u.chats}</td>
|
||||
<td className="py-2 pr-4 text-th-text-s">{u.raiseHand}</td>
|
||||
<td className="py-2 text-th-text-s">{u.emojis}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,24 +2,25 @@ 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' },
|
||||
sm: { box: 'w-8 h-8', h: 'h-8', maxW: 'max-w-[8rem]', rounded: 'rounded-lg', icon: 16, text: 'text-lg' },
|
||||
md: { box: 'w-9 h-9', h: 'h-12', maxW: 'max-w-[10rem]', rounded: 'rounded-lg', icon: 20, text: 'text-xl' },
|
||||
lg: { box: 'w-10 h-10', h: 'h-10', maxW: 'max-w-[12rem]', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' },
|
||||
};
|
||||
|
||||
export default function BrandLogo({ size = 'md', className = '' }) {
|
||||
const { appName, hasLogo, logoUrl } = useBranding();
|
||||
const { appName, hasLogo, logoUrl, hideAppName } = useBranding();
|
||||
const s = sizes[size] || sizes.md;
|
||||
|
||||
if (hasLogo && logoUrl) {
|
||||
// When the app name is hidden, let the logo expand to its natural aspect
|
||||
// ratio (w-auto) rather than being clamped into a tiny square.
|
||||
const imgClass = hideAppName
|
||||
? `${s.h} w-auto ${s.maxW} ${s.rounded} object-contain`
|
||||
: `${s.box} ${s.rounded} object-contain`;
|
||||
return (
|
||||
<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 className={`flex items-center ${hideAppName ? 'justify-center' : 'gap-2.5'} ${className}`}>
|
||||
<img src={logoUrl} alt={appName} className={imgClass} />
|
||||
{!hideAppName && <span className={`${s.text} font-bold gradient-text`}>{appName}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
105
src/components/DateTimePicker.jsx
Normal file
105
src/components/DateTimePicker.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import flatpickr from 'flatpickr';
|
||||
import 'flatpickr/dist/flatpickr.min.css';
|
||||
import { German } from 'flatpickr/dist/l10n/de.js';
|
||||
import { Calendar as CalendarIcon, Clock } from 'lucide-react';
|
||||
|
||||
// Register German as default locale
|
||||
flatpickr.localize(German);
|
||||
|
||||
/**
|
||||
* Themed DateTimePicker using flatpickr.
|
||||
* flatpickr uses position:fixed for its calendar dropdown — no overflow,
|
||||
* no scroll issues, no Popper.js needed. CSS variables drive all theming.
|
||||
*
|
||||
* Props:
|
||||
* value – local datetime string 'YYYY-MM-DDTHH:mm' (or '')
|
||||
* onChange – (localDatetimeString) => void
|
||||
* label – string
|
||||
* required – bool
|
||||
* minDate – Date | null
|
||||
* icon – 'calendar' (default) | 'clock'
|
||||
*/
|
||||
export default function DateTimePicker({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
required = false,
|
||||
minDate = null,
|
||||
icon = 'calendar',
|
||||
}) {
|
||||
const inputRef = useRef(null);
|
||||
const fpRef = useRef(null);
|
||||
// Always keep a current ref to onChange so flatpickr's closure never goes stale
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => { onChangeRef.current = onChange; });
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputRef.current) return;
|
||||
|
||||
fpRef.current = flatpickr(inputRef.current, {
|
||||
enableTime: true,
|
||||
time_24hr: true,
|
||||
dateFormat: 'd.m.Y H:i',
|
||||
minuteIncrement: 15,
|
||||
minDate: minDate || undefined,
|
||||
defaultDate: value || undefined,
|
||||
appendTo: document.body, // portal to body → never clipped
|
||||
static: false,
|
||||
onChange: (selectedDates) => {
|
||||
if (selectedDates.length === 0) { onChangeRef.current(''); return; }
|
||||
const d = selectedDates[0];
|
||||
const y = d.getFullYear();
|
||||
const mo = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
onChangeRef.current(`${y}-${mo}-${day}T${h}:${mi}`);
|
||||
},
|
||||
});
|
||||
|
||||
return () => fpRef.current?.destroy();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Sync value from outside
|
||||
useEffect(() => {
|
||||
if (!fpRef.current) return;
|
||||
const current = fpRef.current.selectedDates[0];
|
||||
const incoming = value ? new Date(value) : null;
|
||||
// Only setDate if actually different (avoid loops)
|
||||
if (incoming && (!current || Math.abs(incoming - current) > 60000)) {
|
||||
fpRef.current.setDate(incoming, false);
|
||||
} else if (!incoming && current) {
|
||||
fpRef.current.clear(false);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Sync minDate
|
||||
useEffect(() => {
|
||||
if (!fpRef.current) return;
|
||||
fpRef.current.set('minDate', minDate || undefined);
|
||||
}, [minDate]);
|
||||
|
||||
const Icon = icon === 'clock' ? Clock : CalendarIcon;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">
|
||||
{label}{required && ' *'}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<Icon size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none z-[1]" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
required={required}
|
||||
readOnly
|
||||
placeholder="Datum & Uhrzeit wählen…"
|
||||
className="input-field pl-9 text-sm w-full cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
src/components/FederatedRoomCard.jsx
Normal file
117
src/components/FederatedRoomCard.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Globe, Trash2, ExternalLink, Hash, Users, Video, VideoOff, AlertTriangle } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function FederatedRoomCard({ room, onRemove }) {
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isDeleted = room.deleted === 1 || room.deleted === true;
|
||||
|
||||
const handleJoin = (e) => {
|
||||
e.stopPropagation();
|
||||
if (isDeleted) return;
|
||||
window.open(room.join_url, '_blank');
|
||||
};
|
||||
|
||||
const handleRemove = async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(t('federation.removeRoomConfirm'))) return;
|
||||
try {
|
||||
await api.delete(`/federation/federated-rooms/${room.id}`);
|
||||
toast.success(t('federation.roomRemoved'));
|
||||
onRemove?.();
|
||||
} catch {
|
||||
toast.error(t('federation.roomRemoveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const recordingOn = room.allow_recording === 1 || room.allow_recording === true;
|
||||
|
||||
return (
|
||||
<div className={`card-hover group p-5 cursor-pointer ${isDeleted ? 'opacity-60' : ''}`} onClick={() => navigate(`/federation/rooms/${room.id}`)}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-th-accent flex-shrink-0" />
|
||||
<h3 className="text-base font-semibold text-th-text truncate group-hover:text-th-accent transition-colors">
|
||||
{room.room_name}
|
||||
</h3>
|
||||
{isDeleted ? (
|
||||
<span className="flex-shrink-0 px-2 py-0.5 bg-red-500/15 text-red-500 rounded-full text-xs font-medium flex items-center gap-1">
|
||||
<AlertTriangle size={10} />
|
||||
{t('federation.roomDeleted')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-shrink-0 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
|
||||
{t('federation.federated')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s mt-0.5 truncate">
|
||||
{t('federation.from')}: <span className="font-medium">{room.from_user}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic room info */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
{room.meet_id && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-th-text-s">
|
||||
<Hash size={12} className="text-th-accent flex-shrink-0" />
|
||||
<span className="truncate font-mono" title={room.meet_id}>{room.meet_id.slice(0, 10)}…</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-xs text-th-text-s">
|
||||
<Users size={12} className="text-th-accent flex-shrink-0" />
|
||||
<span>
|
||||
{t('federation.maxParticipants')}:{' '}
|
||||
<span className="text-th-text font-medium">
|
||||
{room.max_participants > 0 ? room.max_participants : t('federation.unlimited')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs col-span-2">
|
||||
{recordingOn ? (
|
||||
<>
|
||||
<Video size={12} className="text-amber-500 flex-shrink-0" />
|
||||
<span className="text-amber-500 font-medium">{t('federation.recordingOn')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<VideoOff size={12} className="text-th-text-s flex-shrink-0" />
|
||||
<span className="text-th-text-s">{t('federation.recordingOff')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read-only notice */}
|
||||
<p className="text-xs text-th-text-s mb-4 italic">
|
||||
{isDeleted ? t('federation.roomDeletedNotice') : t('federation.readOnlyNotice')}
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-th-border">
|
||||
{!isDeleted && (
|
||||
<button
|
||||
onClick={handleJoin}
|
||||
className="btn-primary text-xs py-1.5 px-3 flex-1"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('federation.joinMeeting')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"
|
||||
title={t('federation.removeRoom')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,44 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Navbar from './Navbar';
|
||||
import Sidebar from './Sidebar';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const [resendCooldown, setResendCooldown] = useState(0);
|
||||
const [resending, setResending] = useState(false);
|
||||
|
||||
// Countdown timer for resend cooldown
|
||||
useEffect(() => {
|
||||
if (resendCooldown <= 0) return;
|
||||
const timer = setTimeout(() => setResendCooldown(c => c - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [resendCooldown]);
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
if (resendCooldown > 0 || resending) return;
|
||||
setResending(true);
|
||||
try {
|
||||
await api.post('/auth/resend-verification', { email: user.email });
|
||||
toast.success(t('auth.emailVerificationResendSuccess'));
|
||||
setResendCooldown(60);
|
||||
} catch (err) {
|
||||
const wait = err.response?.data?.waitSeconds;
|
||||
if (wait) {
|
||||
setResendCooldown(wait);
|
||||
}
|
||||
toast.error(err.response?.data?.error || t('auth.emailVerificationResendFailed'));
|
||||
} finally {
|
||||
setResending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-th-bg flex">
|
||||
@@ -14,6 +48,25 @@ export default function Layout() {
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-h-screen lg:ml-64">
|
||||
<Navbar onMenuClick={() => setSidebarOpen(true)} />
|
||||
|
||||
{/* Email verification banner */}
|
||||
{user && user.email_verified === 0 && (
|
||||
<div className="bg-amber-500/15 border-b border-amber-500/30 px-4 py-2.5 flex items-center justify-center gap-3 text-sm">
|
||||
<AlertTriangle size={15} className="text-amber-400 flex-shrink-0" />
|
||||
<span className="text-amber-200">{t('auth.emailVerificationBanner')}</span>
|
||||
<button
|
||||
onClick={handleResendVerification}
|
||||
disabled={resendCooldown > 0 || resending}
|
||||
className="flex items-center gap-1.5 text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors disabled:opacity-60 disabled:no-underline disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw size={13} className={resending ? 'animate-spin' : ''} />
|
||||
{resendCooldown > 0
|
||||
? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown)
|
||||
: t('auth.emailVerificationResend')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex-1 p-4 md:p-6 lg:p-8 max-w-7xl w-full mx-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
@@ -29,3 +82,4 @@ export default function Layout() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ export default function Modal({ title, children, onClose, maxWidth = 'max-w-lg'
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className={`relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full ${maxWidth} overflow-hidden`}>
|
||||
<div className={`relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full ${maxWidth}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-th-border">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-th-border rounded-t-2xl">
|
||||
<h2 className="text-lg font-semibold text-th-text">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
import NotificationBell from './NotificationBell';
|
||||
|
||||
export default function Navbar({ onMenuClick }) {
|
||||
const { user, logout } = useAuth();
|
||||
@@ -27,8 +28,8 @@ export default function Navbar({ onMenuClick }) {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const initials = user?.name
|
||||
? user.name
|
||||
const initials = (user?.display_name || user?.name)
|
||||
? (user.display_name || user.name)
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
@@ -51,6 +52,9 @@ export default function Navbar({ onMenuClick }) {
|
||||
|
||||
{/* Right section */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Notification bell */}
|
||||
<NotificationBell />
|
||||
|
||||
{/* User dropdown */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
@@ -72,15 +76,15 @@ export default function Navbar({ onMenuClick }) {
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden md:block text-sm font-medium text-th-text">
|
||||
{user?.name}
|
||||
{user?.display_name || user?.name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="absolute right-0 mt-2 w-56 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-th-border">
|
||||
<p className="text-sm font-medium text-th-text">{user?.name}</p>
|
||||
<p className="text-xs text-th-text-s">{user?.email}</p>
|
||||
<p className="text-sm font-medium text-th-text">{user?.display_name || user?.name}</p>
|
||||
<p className="text-xs text-th-text-s">@{user?.name}</p>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<button
|
||||
|
||||
191
src/components/NotificationBell.jsx
Normal file
191
src/components/NotificationBell.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { Bell, BellOff, CheckCheck, ExternalLink, Trash2, X } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNotifications } from '../contexts/NotificationContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
function timeAgo(dateStr, lang) {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const m = Math.floor(diff / 60_000);
|
||||
const h = Math.floor(diff / 3_600_000);
|
||||
const d = Math.floor(diff / 86_400_000);
|
||||
if (lang === 'de') {
|
||||
if (m < 1) return 'gerade eben';
|
||||
if (m < 60) return `vor ${m} Min.`;
|
||||
if (h < 24) return `vor ${h} Std.`;
|
||||
return `vor ${d} Tag${d !== 1 ? 'en' : ''}`;
|
||||
}
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m}m ago`;
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${d}d ago`;
|
||||
}
|
||||
|
||||
function notificationIcon(type) {
|
||||
switch (type) {
|
||||
case 'room_share_added': return '🔗';
|
||||
case 'room_share_removed': return '🚫';
|
||||
case 'federation_invite_received': return '📩';
|
||||
default: return '🔔';
|
||||
}
|
||||
}
|
||||
|
||||
function notificationSubtitle(n, t, lang) {
|
||||
switch (n.type) {
|
||||
case 'room_share_added':
|
||||
return n.body
|
||||
? (lang === 'de' ? `Geteilt von ${n.body}` : `Shared by ${n.body}`)
|
||||
: t('notifications.roomShareAdded');
|
||||
case 'room_share_removed':
|
||||
return t('notifications.roomShareRemoved');
|
||||
case 'federation_invite_received':
|
||||
return n.body
|
||||
? (lang === 'de' ? `Raum: ${n.body}` : `Room: ${n.body}`)
|
||||
: t('notifications.federationInviteReceived');
|
||||
default:
|
||||
return n.body || '';
|
||||
}
|
||||
}
|
||||
|
||||
export default function NotificationBell() {
|
||||
const { notifications, unreadCount, markRead, markAllRead, deleteNotification, clearAll } = useNotifications();
|
||||
const { t, language } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutsideClick(e) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
return () => document.removeEventListener('mousedown', handleOutsideClick);
|
||||
}, []);
|
||||
|
||||
const handleNotificationClick = async (n) => {
|
||||
if (!n.read) await markRead(n.id);
|
||||
if (n.link) navigate(n.link);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (e, id) => {
|
||||
e.stopPropagation();
|
||||
await deleteNotification(id);
|
||||
};
|
||||
|
||||
const recent = notifications.slice(0, 20);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={containerRef}>
|
||||
{/* Bell button */}
|
||||
<button
|
||||
onClick={() => setOpen(prev => !prev)}
|
||||
className="relative p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||
title={t('notifications.bell')}
|
||||
>
|
||||
<Bell size={20} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 min-w-[16px] h-4 px-0.5 flex items-center justify-center rounded-full bg-th-accent text-th-accent-t text-[10px] font-bold leading-none">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-2 w-80 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden z-50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-th-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell size={16} className="text-th-accent" />
|
||||
<span className="text-sm font-semibold text-th-text">{t('notifications.bell')}</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-th-accent text-th-accent-t text-xs font-bold">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||
title={t('notifications.markAllRead')}
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
{t('notifications.markAllRead')}
|
||||
</button>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-error transition-colors"
|
||||
title={t('notifications.clearAll')}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{t('notifications.clearAll')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{recent.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-th-text-s gap-2">
|
||||
<BellOff size={24} />
|
||||
<span className="text-sm">{t('notifications.noNotifications')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{recent.map(n => (
|
||||
<li
|
||||
key={n.id}
|
||||
onClick={() => handleNotificationClick(n)}
|
||||
className={`group flex items-start gap-3 px-4 py-3 cursor-pointer transition-colors border-b border-th-border/50 last:border-0
|
||||
${n.read ? 'hover:bg-th-hover' : 'bg-th-accent/5 hover:bg-th-accent/10'}`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<span className="text-lg flex-shrink-0 mt-0.5">{notificationIcon(n.type)}</span>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm truncate ${n.read ? 'text-th-text-s' : 'text-th-text font-medium'}`}>
|
||||
{n.title}
|
||||
</p>
|
||||
<p className="text-xs text-th-text-s truncate">
|
||||
{notificationSubtitle(n, t, language)}
|
||||
</p>
|
||||
<p className="text-xs text-th-text-s/70 mt-0.5">
|
||||
{timeAgo(n.created_at, language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right side: unread dot, link icon, delete button */}
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
{!n.read && (
|
||||
<span className="w-2 h-2 rounded-full bg-th-accent mt-1" />
|
||||
)}
|
||||
{n.link && (
|
||||
<ExternalLink size={12} className="text-th-text-s/50" />
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => handleDelete(e, n.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:text-th-error transition-all text-th-text-s/50"
|
||||
title={t('notifications.delete')}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,9 +122,9 @@ export default function RecordingList({ recordings, onRefresh }) {
|
||||
href={format.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-th-accent/10 text-th-accent text-xs font-medium hover:bg-th-accent/20 transition-colors"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-th-accent/10 text-th-accent text-sm font-medium hover:bg-th-accent/20 transition-colors"
|
||||
>
|
||||
<Play size={12} />
|
||||
<Play size={14} />
|
||||
{format.type === 'presentation' ? t('recordings.presentation') : format.type}
|
||||
</a>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Users, Play, Trash2, Radio, Loader2, Share2 } from 'lucide-react';
|
||||
import { Users, Play, Trash2, Radio, Loader2, Share2, Copy, Link } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import api from '../services/api';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -10,6 +10,24 @@ export default function RoomCard({ room, onDelete }) {
|
||||
const { t } = useLanguage();
|
||||
const [status, setStatus] = useState({ running: false, participantCount: 0 });
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [showCopyMenu, setShowCopyMenu] = useState(false);
|
||||
const copyMenuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (copyMenuRef.current && !copyMenuRef.current.contains(e.target)) {
|
||||
setShowCopyMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = (url) => {
|
||||
navigator.clipboard.writeText(url);
|
||||
toast.success(t('room.linkCopied'));
|
||||
setShowCopyMenu(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkStatus = async () => {
|
||||
@@ -69,7 +87,7 @@ export default function RoomCard({ room, onDelete }) {
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-th-border">
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-th-border" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -99,6 +117,33 @@ export default function RoomCard({ room, onDelete }) {
|
||||
{starting ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
|
||||
{status.running ? t('room.join') : t('room.startMeeting')}
|
||||
</button>
|
||||
<div className="relative" ref={copyMenuRef}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowCopyMenu(v => !v); }}
|
||||
className="btn-ghost text-xs py-1.5 px-2"
|
||||
title={t('room.copyLink')}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
{showCopyMenu && (
|
||||
<div className="absolute bottom-full right-0 mb-2 bg-th-surface border border-th-border rounded-lg shadow-lg z-50 min-w-[150px] py-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); copyToClipboard(`${window.location.origin}/rooms/${room.uid}`); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
|
||||
>
|
||||
<Link size={12} />
|
||||
{t('room.copyRoomLink')}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); copyToClipboard(`${window.location.origin}/join/${room.uid}`); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
|
||||
>
|
||||
<Users size={12} />
|
||||
{t('room.copyGuestLink')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onDelete && !room.shared && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(room); }}
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { LayoutDashboard, Settings, Shield, X, Palette } from 'lucide-react';
|
||||
import { LayoutDashboard, Settings, Shield, X, Palette, Globe, CalendarDays, FileText, Lock } from 'lucide-react';
|
||||
import BrandLogo from './BrandLogo';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useBranding } from '../contexts/BrandingContext';
|
||||
import ThemeSelector from './ThemeSelector';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
|
||||
export default function Sidebar({ open, onClose }) {
|
||||
const { user } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const { imprintUrl, privacyUrl } = useBranding();
|
||||
const [themeOpen, setThemeOpen] = useState(false);
|
||||
const [federationCount, setFederationCount] = useState(0);
|
||||
|
||||
// Fetch pending federation invitation count
|
||||
useEffect(() => {
|
||||
const fetchCount = async () => {
|
||||
try {
|
||||
const res = await api.get('/federation/invitations/pending-count');
|
||||
setFederationCount(res.data.count || 0);
|
||||
} catch {
|
||||
// Ignore — federation may not be enabled
|
||||
}
|
||||
};
|
||||
fetchCount();
|
||||
const interval = setInterval(fetchCount, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const navItems = [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: t('nav.dashboard') },
|
||||
{ to: '/calendar', icon: CalendarDays, label: t('nav.calendar') },
|
||||
{ to: '/federation/inbox', icon: Globe, label: t('nav.federation'), badge: federationCount },
|
||||
{ to: '/settings', icon: Settings, label: t('nav.settings') },
|
||||
];
|
||||
|
||||
@@ -21,8 +42,7 @@ export default function Sidebar({ open, onClose }) {
|
||||
}
|
||||
|
||||
const linkClasses = ({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive
|
||||
? 'bg-th-accent text-th-accent-t shadow-sm'
|
||||
: 'text-th-text-s hover:text-th-text hover:bg-th-hover'
|
||||
}`;
|
||||
@@ -37,7 +57,7 @@ export default function Sidebar({ open, onClose }) {
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-between h-16 px-4 border-b border-th-border">
|
||||
<BrandLogo size="sm" />
|
||||
<BrandLogo size="md" className="flex-1 min-w-0" />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||
@@ -60,6 +80,11 @@ export default function Sidebar({ open, onClose }) {
|
||||
>
|
||||
<item.icon size={18} />
|
||||
{item.label}
|
||||
{item.badge > 0 && (
|
||||
<span className="ml-auto px-1.5 py-0.5 rounded-full bg-th-accent text-th-accent-t text-xs font-bold">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
@@ -81,16 +106,55 @@ export default function Sidebar({ open, onClose }) {
|
||||
<div className="p-4 border-t border-th-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0"
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0 overflow-hidden"
|
||||
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
|
||||
>
|
||||
{user?.name?.[0]?.toUpperCase() || '?'}
|
||||
{user?.avatar_image ? (
|
||||
<img
|
||||
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
|
||||
alt="Avatar"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
(user?.display_name || user?.name)?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) || '?'
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-th-text truncate">{user?.name}</p>
|
||||
<p className="text-xs text-th-text-s truncate">{user?.email}</p>
|
||||
<p className="text-sm font-medium text-th-text truncate">{user?.display_name || user?.name}</p>
|
||||
<p className="text-xs text-th-text-s truncate">@{user?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Imprint / Privacy Policy links */}
|
||||
{(imprintUrl || privacyUrl) && (
|
||||
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-th-border/60">
|
||||
{imprintUrl && (
|
||||
<a
|
||||
href={imprintUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||
>
|
||||
<FileText size={11} />
|
||||
{t('nav.imprint')}
|
||||
</a>
|
||||
)}
|
||||
{imprintUrl && privacyUrl && (
|
||||
<span className="text-th-border text-xs">·</span>
|
||||
)}
|
||||
{privacyUrl && (
|
||||
<a
|
||||
href={privacyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||
>
|
||||
<Lock size={11} />
|
||||
{t('nav.privacy')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -23,13 +23,25 @@ export function AuthProvider({ children }) {
|
||||
|
||||
const login = useCallback(async (email, password) => {
|
||||
const res = await api.post('/auth/login', { email, password });
|
||||
if (res.data.requires2FA) {
|
||||
return { requires2FA: true, tempToken: res.data.tempToken };
|
||||
}
|
||||
localStorage.setItem('token', res.data.token);
|
||||
setUser(res.data.user);
|
||||
return res.data.user;
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (name, email, password) => {
|
||||
const res = await api.post('/auth/register', { name, email, password });
|
||||
const verify2FA = useCallback(async (tempToken, code) => {
|
||||
const res = await api.post('/auth/login/2fa', { tempToken, code });
|
||||
localStorage.setItem('token', res.data.token);
|
||||
setUser(res.data.user);
|
||||
return res.data.user;
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (username, displayName, email, password, inviteToken) => {
|
||||
const payload = { username, display_name: displayName, email, password };
|
||||
if (inviteToken) payload.invite_token = inviteToken;
|
||||
const res = await api.post('/auth/register', payload);
|
||||
if (res.data.needsVerification) {
|
||||
return { needsVerification: true };
|
||||
}
|
||||
@@ -38,17 +50,42 @@ export function AuthProvider({ children }) {
|
||||
return res.data.user;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
const logout = useCallback(async () => {
|
||||
let keycloakLogoutUrl = null;
|
||||
try {
|
||||
const res = await api.post('/auth/logout');
|
||||
keycloakLogoutUrl = res.data?.keycloakLogoutUrl || null;
|
||||
} catch {
|
||||
// ignore — token is removed locally regardless
|
||||
}
|
||||
localStorage.removeItem('token');
|
||||
if (keycloakLogoutUrl) {
|
||||
// Redirect to Keycloak BEFORE clearing React state to avoid
|
||||
// flash-rendering the login page while the redirect is pending.
|
||||
window.location.href = keycloakLogoutUrl;
|
||||
return;
|
||||
}
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const loginWithOAuth = useCallback(async (token) => {
|
||||
localStorage.setItem('token', token);
|
||||
try {
|
||||
const res = await api.get('/auth/me');
|
||||
setUser(res.data.user);
|
||||
return res.data.user;
|
||||
} catch (err) {
|
||||
localStorage.removeItem('token');
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateUser = useCallback((updatedUser) => {
|
||||
setUser(updatedUser);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, updateUser }}>
|
||||
<AuthContext.Provider value={{ user, loading, login, verify2FA, register, logout, loginWithOAuth, updateUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import api from '../services/api';
|
||||
import { useTheme } from './ThemeContext';
|
||||
|
||||
const BrandingContext = createContext();
|
||||
|
||||
export function BrandingProvider({ children }) {
|
||||
const { applyBrandingDefault } = useTheme();
|
||||
const [branding, setBranding] = useState({
|
||||
appName: 'Redlight',
|
||||
hasLogo: false,
|
||||
logoUrl: null,
|
||||
defaultTheme: null,
|
||||
imprintUrl: null,
|
||||
privacyUrl: null,
|
||||
hideAppName: false,
|
||||
});
|
||||
|
||||
const fetchBranding = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get('/branding');
|
||||
setBranding(res.data);
|
||||
if (res.data.defaultTheme) {
|
||||
applyBrandingDefault(res.data.defaultTheme);
|
||||
}
|
||||
} catch {
|
||||
// keep defaults
|
||||
}
|
||||
}, []);
|
||||
}, [applyBrandingDefault]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBranding();
|
||||
|
||||
189
src/contexts/NotificationContext.jsx
Normal file
189
src/contexts/NotificationContext.jsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAuth } from './AuthContext';
|
||||
import api from '../services/api';
|
||||
|
||||
// Lazily created Audio instance — reused across calls to avoid memory churn
|
||||
let _audio = null;
|
||||
let _audioUnlocked = false;
|
||||
|
||||
function getAudio() {
|
||||
if (!_audio) {
|
||||
_audio = new Audio('/sounds/notification.mp3');
|
||||
_audio.volume = 0.5;
|
||||
}
|
||||
return _audio;
|
||||
}
|
||||
|
||||
/** Called once on the first user gesture to silently play→pause the element,
|
||||
* which "unlocks" it so later timer-based .play() calls are not blocked. */
|
||||
function unlockAudio() {
|
||||
if (_audioUnlocked) return;
|
||||
_audioUnlocked = true;
|
||||
const audio = getAudio();
|
||||
audio.muted = true;
|
||||
audio.play().then(() => {
|
||||
audio.pause();
|
||||
audio.muted = false;
|
||||
audio.currentTime = 0;
|
||||
}).catch(() => {
|
||||
audio.muted = false;
|
||||
});
|
||||
}
|
||||
|
||||
function playNotificationSound() {
|
||||
try {
|
||||
const audio = getAudio();
|
||||
audio.currentTime = 0;
|
||||
audio.play().catch(() => {
|
||||
// Autoplay still blocked — silent fail
|
||||
});
|
||||
} catch {
|
||||
// Ignore any other errors (e.g. unsupported format)
|
||||
}
|
||||
}
|
||||
|
||||
const NotificationContext = createContext();
|
||||
|
||||
export function NotificationProvider({ children }) {
|
||||
const { user } = useAuth();
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const activeUserId = useRef(null);
|
||||
// Track seen IDs to detect genuinely new arrivals and show toasts
|
||||
const seenIds = useRef(new Set());
|
||||
const initialized = useRef(false);
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
const requestUserId = user?.id;
|
||||
if (!requestUserId) return;
|
||||
try {
|
||||
const res = await api.get('/notifications');
|
||||
|
||||
// Ignore stale responses that arrived after logout or account switch.
|
||||
if (activeUserId.current !== requestUserId) return;
|
||||
|
||||
const incoming = res.data.notifications || [];
|
||||
setNotifications(incoming);
|
||||
setUnreadCount(res.data.unreadCount || 0);
|
||||
|
||||
// First fetch: just seed the seen-set without toasting
|
||||
if (!initialized.current) {
|
||||
incoming.forEach(n => seenIds.current.add(n.id));
|
||||
initialized.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Subsequent fetches: toast new unread notifications
|
||||
const newItems = incoming.filter(n => !n.read && !seenIds.current.has(n.id));
|
||||
if (newItems.length > 0) {
|
||||
playNotificationSound();
|
||||
}
|
||||
newItems.forEach(n => {
|
||||
seenIds.current.add(n.id);
|
||||
const icon = notificationIcon(n.type);
|
||||
toast(`${icon} ${n.title}`, { duration: 5000 });
|
||||
// Browser notification for calendar reminders
|
||||
if (n.type === 'calendar_reminder' && 'Notification' in window) {
|
||||
const fire = () => new Notification(n.title, { body: n.body || '', icon: '/favicon.ico' });
|
||||
if (Notification.permission === 'granted') {
|
||||
fire();
|
||||
} else if (Notification.permission !== 'denied') {
|
||||
Notification.requestPermission().then(p => { if (p === 'granted') fire(); });
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
/* silent – server may not be reachable */
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Unlock audio playback only for authenticated sessions.
|
||||
// This avoids any audio interaction while logged out (e.g. anonymous/incognito tabs).
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
const events = ['click', 'keydown', 'pointerdown'];
|
||||
const handler = () => {
|
||||
unlockAudio();
|
||||
events.forEach(e => window.removeEventListener(e, handler));
|
||||
};
|
||||
events.forEach(e => window.addEventListener(e, handler, { once: true }));
|
||||
return () => events.forEach(e => window.removeEventListener(e, handler));
|
||||
}, [user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
activeUserId.current = user?.id ?? null;
|
||||
if (!user) {
|
||||
_audioUnlocked = false;
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
seenIds.current = new Set();
|
||||
initialized.current = false;
|
||||
return;
|
||||
}
|
||||
fetch();
|
||||
const interval = setInterval(fetch, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [user, fetch]);
|
||||
|
||||
const markRead = async (id) => {
|
||||
try {
|
||||
await api.post(`/notifications/${id}/read`);
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === id ? { ...n, read: 1 } : n)),
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await api.post('/notifications/read-all');
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: 1 })));
|
||||
setUnreadCount(0);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
const deleteNotification = async (id) => {
|
||||
try {
|
||||
await api.delete(`/notifications/${id}`);
|
||||
setNotifications(prev => {
|
||||
const removed = prev.find(n => n.id === id);
|
||||
if (removed && !removed.read) setUnreadCount(c => Math.max(0, c - 1));
|
||||
return prev.filter(n => n.id !== id);
|
||||
});
|
||||
seenIds.current.delete(id);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
const clearAll = async () => {
|
||||
try {
|
||||
await api.delete('/notifications/all');
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
seenIds.current = new Set();
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={{ notifications, unreadCount, markRead, markAllRead, deleteNotification, clearAll, refresh: fetch }}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
const ctx = useContext(NotificationContext);
|
||||
if (!ctx) throw new Error('useNotifications must be used within NotificationProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function notificationIcon(type) {
|
||||
switch (type) {
|
||||
case 'room_share_added': return '🔗';
|
||||
case 'room_share_removed': return '🚫';
|
||||
case 'federation_invite_received': return '📩';
|
||||
case 'calendar_reminder': return '🔔';
|
||||
default: return '🔔';
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,33 @@ const ThemeContext = createContext(null);
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setThemeState] = useState(() => {
|
||||
return localStorage.getItem('theme') || 'dark';
|
||||
// Personal preference > last known branding default > hardcoded fallback
|
||||
return localStorage.getItem('theme') || localStorage.getItem('branding-default-theme') || 'dark';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
// Called by user intentionally (Settings page etc.) — saves as personal preference
|
||||
const setTheme = useCallback((newTheme) => {
|
||||
localStorage.setItem('theme', newTheme);
|
||||
setThemeState(newTheme);
|
||||
}, []);
|
||||
|
||||
// Called by BrandingContext after loading the admin-configured default theme.
|
||||
// Only takes effect when the user has no personal preference stored.
|
||||
const applyBrandingDefault = useCallback((newDefault) => {
|
||||
if (!newDefault) return;
|
||||
localStorage.setItem('branding-default-theme', newDefault);
|
||||
if (!localStorage.getItem('theme')) {
|
||||
setThemeState(newDefault);
|
||||
document.documentElement.setAttribute('data-theme', newDefault);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
<ThemeContext.Provider value={{ theme, setTheme, applyBrandingDefault }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
|
||||
378
src/i18n/de.json
378
src/i18n/de.json
@@ -31,7 +31,11 @@
|
||||
"admin": "Administration",
|
||||
"appearance": "Darstellung",
|
||||
"changeTheme": "Theme ändern",
|
||||
"navigation": "Navigation"
|
||||
"navigation": "Navigation",
|
||||
"calendar": "Kalender",
|
||||
"federation": "Einladungen",
|
||||
"imprint": "Impressum",
|
||||
"privacy": "Datenschutz"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
@@ -71,10 +75,42 @@
|
||||
"verifyFailed": "Verifizierung fehlgeschlagen",
|
||||
"verifyFailedTitle": "Verifizierung fehlgeschlagen",
|
||||
"verifyTokenMissing": "Kein Verifizierungstoken vorhanden.",
|
||||
"emailNotVerified": "E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach."
|
||||
"emailNotVerified": "E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach.",
|
||||
"username": "Benutzername",
|
||||
"usernamePlaceholder": "z.B. maxmuster",
|
||||
"usernameHint": "Nur Buchstaben, Zahlen, _ und - erlaubt (3-30 Zeichen)",
|
||||
"displayName": "Anzeigename",
|
||||
"displayNamePlaceholder": "Max Mustermann",
|
||||
"usernameTaken": "Benutzername ist bereits vergeben",
|
||||
"usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, _ und - enthalten (3-30 Zeichen)",
|
||||
"usernameRequired": "Benutzername ist erforderlich",
|
||||
"displayNameRequired": "Anzeigename ist erforderlich",
|
||||
"emailVerificationBanner": "Deine E-Mail-Adresse wurde noch nicht verifiziert.",
|
||||
"emailVerificationResend": "Hier klicken um eine neue Verifizierungsmail zu erhalten",
|
||||
"emailVerificationResendCooldown": "Erneut senden in {seconds}s",
|
||||
"emailVerificationResendSuccess": "Verifizierungsmail wurde gesendet!",
|
||||
"emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden",
|
||||
"inviteOnly": "Nur mit Einladung",
|
||||
"inviteOnlyDesc": "Die Registrierung ist derzeit eingeschränkt. Sie benötigen einen Einladungslink von einem Administrator, um ein Konto zu erstellen.",
|
||||
"orContinueWith": "oder weiter mit",
|
||||
"loginWithOAuth": "Anmelden mit {provider}",
|
||||
"registerWithOAuth": "Registrieren mit {provider}",
|
||||
"backToLogin": "Zurück zum Login",
|
||||
"oauthError": "Anmeldung fehlgeschlagen",
|
||||
"oauthNoToken": "Kein Authentifizierungstoken erhalten.",
|
||||
"oauthLoginFailed": "Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.",
|
||||
"oauthRedirecting": "Du wirst angemeldet...",
|
||||
"2fa": {
|
||||
"title": "Zwei-Faktor-Authentifizierung",
|
||||
"prompt": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
|
||||
"codeLabel": "Bestätigungscode",
|
||||
"verify": "Bestätigen",
|
||||
"verifyFailed": "Überprüfung fehlgeschlagen",
|
||||
"backToLogin": "← Zurück zum Login"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"poweredBy": "Powered by BigBlueButton",
|
||||
"madeFor": "Made for BigBlueButton",
|
||||
"heroTitle": "Meetings neu ",
|
||||
"heroTitleHighlight": "definiert",
|
||||
"heroSubtitle": "Das moderne, selbst gehostete BigBlueButton-Frontend. Erstellen Sie Räume, verwalten Sie Aufnahmen und genießen Sie ein wunderschönes Interface mit über 15 Themes.",
|
||||
@@ -122,7 +158,9 @@
|
||||
"roomDeleteFailed": "Raum konnte nicht gelöscht werden",
|
||||
"roomDeleteConfirm": "Raum \"{name}\" wirklich löschen?",
|
||||
"loadFailed": "Räume konnten nicht geladen werden",
|
||||
"sharedWithMe": "Mit mir geteilt"
|
||||
"sharedWithMe": "Mit mir geteilt",
|
||||
"federatedRooms": "Räume von anderen Instanzen",
|
||||
"federatedRoomsSubtitle": "Angenommene Meeting-Einladungen von anderen Redlight-Instanzen. Einstellungen können hier nicht geändert werden."
|
||||
},
|
||||
"room": {
|
||||
"backToDashboard": "Zurück zum Dashboard",
|
||||
@@ -136,6 +174,8 @@
|
||||
"settings": "Einstellungen",
|
||||
"participants": "{count} Teilnehmer",
|
||||
"copyLink": "Link kopieren",
|
||||
"copyRoomLink": "Raum-Link",
|
||||
"copyGuestLink": "Gast-Link",
|
||||
"linkCopied": "Link kopiert!",
|
||||
"meetingDetails": "Meeting-Details",
|
||||
"meetingId": "Meeting ID",
|
||||
@@ -190,6 +230,11 @@
|
||||
"guestModeratorPlaceholder": "Nur wenn Sie Moderator sind",
|
||||
"guestJoinButton": "Meeting beitreten",
|
||||
"guestWaitingMessage": "Das Meeting wurde noch nicht gestartet. Bitte warten Sie, bis der Moderator es startet.",
|
||||
"guestWaitingTitle": "Warte auf Meeting-Start...",
|
||||
"guestWaitingHint": "Du wirst automatisch beigetreten, sobald das Meeting gestartet wird.",
|
||||
"guestCancelWaiting": "Abbrechen",
|
||||
"guestMeetingStartedJoining": "Meeting gestartet! Trete jetzt bei...",
|
||||
"waitingToJoin": "Warten...",
|
||||
"guestAccessDenied": "Zugang nicht möglich",
|
||||
"guestNameRequired": "Name ist erforderlich",
|
||||
"guestJoinFailed": "Beitritt fehlgeschlagen",
|
||||
@@ -198,14 +243,34 @@
|
||||
"guestHasAccount": "Haben Sie ein Konto?",
|
||||
"guestSignIn": "Anmelden",
|
||||
"guestRoomNotFound": "Raum nicht gefunden",
|
||||
"guestRecordingNotice": "Dieses Meeting könnte aufgenommen werden, inkl. Ihrer Audio / Video.",
|
||||
"guestRecordingConsent": "Ich bin damit einverstanden, dass dieses Meeting aufgenommen werden kann.",
|
||||
"shared": "Geteilt",
|
||||
"presentationTitle": "Standard-Präsentation",
|
||||
"presentationDesc": "Diese Datei wird beim Start des Meetings automatisch in BBB vorgeladen.",
|
||||
"presentationUpload": "Präsentation hochladen",
|
||||
"presentationRemove": "Präsentation entfernen",
|
||||
"presentationUploaded": "Präsentation hochgeladen",
|
||||
"presentationRemoved": "Präsentation entfernt",
|
||||
"presentationUploadFailed": "Präsentation konnte nicht hochgeladen werden",
|
||||
"presentationRemoveFailed": "Präsentation konnte nicht entfernt werden",
|
||||
"presentationAllowedTypes": "PDF, PPT, PPTX, ODP, DOC, DOCX · max. 50 MB",
|
||||
"presentationCurrent": "Aktuell:",
|
||||
"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"
|
||||
"shareRemove": "Freigabe entfernen",
|
||||
"defaultWelcome": "Willkommen zum Meeting!",
|
||||
"analytics": "Lernanalyse",
|
||||
"enableAnalytics": "Lernanalyse aktivieren",
|
||||
"enableAnalyticsHint": "Sammelt Engagement-Daten der Teilnehmer nach jedem Meeting.",
|
||||
"analyticsVisibility": "Wer kann die Analyse sehen?",
|
||||
"analyticsOwnerOnly": "Nur Raumbesitzer",
|
||||
"analyticsSharedUsers": "Alle geteilten Benutzer",
|
||||
"analyticsVisibilityHint": "Legt fest, wer die Analysedaten dieses Raums einsehen und exportieren kann."
|
||||
},
|
||||
"recordings": {
|
||||
"title": "Aufnahmen",
|
||||
@@ -223,6 +288,30 @@
|
||||
"publish": "Veröffentlichen",
|
||||
"loadFailed": "Aufnahmen konnten nicht geladen werden"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Lernanalyse",
|
||||
"noData": "Keine Analysedaten vorhanden",
|
||||
"participants": "Teilnehmer",
|
||||
"messages": "Nachrichten",
|
||||
"expand": "Details anzeigen",
|
||||
"collapse": "Details ausblenden",
|
||||
"deleteConfirm": "Analysedaten wirklich löschen?",
|
||||
"deleted": "Analysedaten gelöscht",
|
||||
"deleteFailed": "Fehler beim Löschen",
|
||||
"userName": "Name",
|
||||
"role": "Rolle",
|
||||
"moderator": "Moderator",
|
||||
"viewer": "Teilnehmer",
|
||||
"talkTime": "Sprechzeit",
|
||||
"webcamTime": "Webcam-Zeit",
|
||||
"duration": "Dauer",
|
||||
"meetingDuration": "Meeting-Dauer",
|
||||
"raiseHand": "Handheben",
|
||||
"reactions": "Reaktionen",
|
||||
"export": "Herunterladen",
|
||||
"exportSuccess": "Download gestartet",
|
||||
"exportFailed": "Fehler beim Herunterladen"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"subtitle": "Verwalten Sie Ihr Profil und Ihre Einstellungen",
|
||||
@@ -252,7 +341,49 @@
|
||||
"passwordChanged": "Passwort geändert",
|
||||
"passwordChangeFailed": "Fehler beim Ändern",
|
||||
"passwordMismatch": "Passwörter stimmen nicht überein",
|
||||
"selectLanguage": "Sprache auswählen"
|
||||
"selectLanguage": "Sprache auswählen",
|
||||
"security": {
|
||||
"title": "Sicherheit",
|
||||
"subtitle": "Schütze dein Konto mit Zwei-Faktor-Authentifizierung (2FA). Nach der Aktivierung benötigst du sowohl dein Passwort als auch einen Code aus deiner Authenticator-App zum Anmelden.",
|
||||
"statusEnabled": "2FA ist aktiviert",
|
||||
"statusEnabledDesc": "Dein Konto ist durch Zwei-Faktor-Authentifizierung geschützt.",
|
||||
"statusDisabled": "2FA ist nicht aktiviert",
|
||||
"statusDisabledDesc": "Aktiviere die Zwei-Faktor-Authentifizierung für zusätzliche Sicherheit.",
|
||||
"enable": "2FA aktivieren",
|
||||
"disable": "2FA deaktivieren",
|
||||
"enabled": "Zwei-Faktor-Authentifizierung aktiviert!",
|
||||
"disabled": "Zwei-Faktor-Authentifizierung deaktiviert.",
|
||||
"enableFailed": "2FA konnte nicht aktiviert werden",
|
||||
"disableFailed": "2FA konnte nicht deaktiviert werden",
|
||||
"setupFailed": "2FA-Einrichtung konnte nicht gestartet werden",
|
||||
"scanQR": "Scanne diesen QR-Code mit deiner Authenticator-App (Google Authenticator, Authy, etc.).",
|
||||
"manualKey": "Oder gib diesen Schlüssel manuell ein:",
|
||||
"verifyCode": "Gib den Code aus deiner App zur Überprüfung ein",
|
||||
"codeLabel": "6-stelliger Code",
|
||||
"disableConfirm": "Gib dein Passwort und einen aktuellen 2FA-Code ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren."
|
||||
},
|
||||
"caldav": {
|
||||
"title": "CalDAV",
|
||||
"subtitle": "Verbinde deine Kalender-App (z. B. Apple Kalender, Thunderbird, DAVx⁵) über das CalDAV-Protokoll. Verwende deine E-Mail-Adresse und ein App-Token als Passwort.",
|
||||
"serverUrl": "Server-URL",
|
||||
"username": "Benutzername (E-Mail)",
|
||||
"hint": "Gib niemals dein echtes Redlight-Passwort in einer Kalender-App ein. Verwende stattdessen ein App-Token.",
|
||||
"newToken": "Neues App-Token generieren",
|
||||
"tokenNamePlaceholder": "z. B. \"iPhone\" oder \"Thunderbird\"",
|
||||
"generate": "Generieren",
|
||||
"existingTokens": "Aktive Tokens",
|
||||
"noTokens": "Noch keine Tokens erstellt.",
|
||||
"created": "Erstellt",
|
||||
"lastUsed": "Zuletzt verwendet",
|
||||
"revoke": "Widerrufen",
|
||||
"revokeConfirm": "Dieses Token wirklich widerrufen? Alle Kalender-Apps, die dieses Token verwenden, verlieren den Zugriff.",
|
||||
"revoked": "Token widerrufen",
|
||||
"revokeFailed": "Token konnte nicht widerrufen werden",
|
||||
"createFailed": "Token konnte nicht erstellt werden",
|
||||
"newTokenCreated": "Token erstellt — jetzt kopieren!",
|
||||
"newTokenHint": "Dieses Token wird nur einmal angezeigt. Kopiere es und trage es als Passwort in deiner Kalender-App ein.",
|
||||
"dismiss": "Ich habe das Token kopiert"
|
||||
}
|
||||
},
|
||||
"themes": {
|
||||
"selectTheme": "Theme auswählen",
|
||||
@@ -300,6 +431,239 @@
|
||||
"logoRemoveFailed": "Logo konnte nicht entfernt werden",
|
||||
"appNameLabel": "App-Name",
|
||||
"appNameUpdated": "App-Name aktualisiert",
|
||||
"appNameUpdateFailed": "App-Name konnte nicht aktualisiert werden"
|
||||
"appNameUpdateFailed": "App-Name konnte nicht aktualisiert werden",
|
||||
"hideAppNameLabel": "App-Namen ausblenden",
|
||||
"hideAppNameHint": "Nur das Logo anzeigen, den App-Namen daneben ausblenden.",
|
||||
"hideAppNameFailed": "Einstellung konnte nicht gespeichert werden",
|
||||
"defaultThemeLabel": "Standard-Theme",
|
||||
"defaultThemeDesc": "Wird für nicht angemeldete Seiten (Gast-Join, Login, Startseite) verwendet, wenn keine persönliche Einstellung gesetzt ist.",
|
||||
"defaultThemeSaved": "Standard-Theme gespeichert",
|
||||
"defaultThemeUpdateFailed": "Standard-Theme konnte nicht aktualisiert werden",
|
||||
"regModeTitle": "Registrierungsmodus",
|
||||
"regModeDescription": "Steuern Sie, wie sich neue Benutzer registrieren können. \"Offen\" erlaubt jedem die Anmeldung. \"Nur mit Einladung\" erfordert einen Einladungslink.",
|
||||
"regModeOpen": "Offene Registrierung",
|
||||
"regModeInvite": "Nur mit Einladung",
|
||||
"regModeSaved": "Registrierungsmodus aktualisiert",
|
||||
"regModeFailed": "Registrierungsmodus konnte nicht aktualisiert werden",
|
||||
"inviteTitle": "Benutzer-Einladungen",
|
||||
"inviteDescription": "Laden Sie neue Benutzer per E-Mail ein. Sie erhalten einen Registrierungslink, der 7 Tage gültig ist.",
|
||||
"sendInvite": "Einladung senden",
|
||||
"inviteSent": "Einladung gesendet!",
|
||||
"inviteFailed": "Einladung konnte nicht gesendet werden",
|
||||
"inviteDeleted": "Einladung gelöscht",
|
||||
"inviteDeleteFailed": "Einladung konnte nicht gelöscht werden",
|
||||
"inviteLinkCopied": "Einladungslink kopiert!",
|
||||
"copyInviteLink": "Einladungslink kopieren",
|
||||
"inviteExpired": "Abgelaufen",
|
||||
"inviteUsedBy": "Verwendet von",
|
||||
"inviteExpiresAt": "Läuft ab am",
|
||||
"noInvites": "Noch keine Einladungen",
|
||||
"legalLinksTitle": "Rechtliche Links",
|
||||
"legalLinksDesc": "Impressum- und Datenschutz-Links am unteren Rand der Seitenleiste anzeigen. Leer lassen zum Ausblenden.",
|
||||
"imprintUrl": "Impressum-URL",
|
||||
"privacyUrl": "Datenschutz-URL",
|
||||
"imprintUrlSaved": "Impressum-URL gespeichert",
|
||||
"privacyUrlSaved": "Datenschutz-URL gespeichert",
|
||||
"imprintUrlFailed": "Impressum-URL konnte nicht gespeichert werden",
|
||||
"privacyUrlFailed": "Datenschutz-URL konnte nicht gespeichert werden",
|
||||
"oauthTitle": "OAuth / SSO",
|
||||
"oauthDescription": "OpenID-Connect-Anbieter verbinden (z. B. Keycloak, Authentik, Google) für Single Sign-On.",
|
||||
"oauthIssuer": "Issuer-URL",
|
||||
"oauthIssuerHint": "Die OIDC-Issuer-URL, z. B. https://auth.example.com/realms/main",
|
||||
"oauthClientId": "Client-ID",
|
||||
"oauthClientSecret": "Client-Secret",
|
||||
"oauthClientSecretHint": "Leer lassen, um das bestehende Secret beizubehalten",
|
||||
"oauthDisplayName": "Button-Beschriftung",
|
||||
"oauthDisplayNameHint": "Wird auf der Login-Seite angezeigt, z. B. Firmen-SSO",
|
||||
"oauthAutoRegister": "Neue Benutzer automatisch registrieren",
|
||||
"oauthAutoRegisterHint": "Erstellt automatisch Konten für Benutzer, die sich zum ersten Mal per OAuth anmelden.",
|
||||
"oauthSaved": "OAuth-Konfiguration gespeichert",
|
||||
"oauthSaveFailed": "OAuth-Konfiguration konnte nicht gespeichert werden",
|
||||
"oauthRemoved": "OAuth-Konfiguration entfernt",
|
||||
"oauthRemoveFailed": "OAuth-Konfiguration konnte nicht entfernt werden",
|
||||
"oauthRemoveConfirm": "OAuth-Konfiguration wirklich entfernen? Benutzer können sich dann nicht mehr per SSO anmelden.",
|
||||
"oauthNotConfigured": "OAuth ist noch nicht konfiguriert.",
|
||||
"oauthSave": "OAuth speichern",
|
||||
"oauthRemove": "OAuth entfernen"
|
||||
},
|
||||
"notifications": {
|
||||
"bell": "Benachrichtigungen",
|
||||
"markAllRead": "Alle gelesen",
|
||||
"clearAll": "Alle löschen",
|
||||
"delete": "Löschen",
|
||||
"noNotifications": "Keine Benachrichtigungen",
|
||||
"roomShareAdded": "Raum wurde mit dir geteilt",
|
||||
"roomShareRemoved": "Raumzugriff wurde entfernt",
|
||||
"federationInviteReceived": "Neue Meeting-Einladung"
|
||||
},
|
||||
"federation": {
|
||||
"inbox": "Einladungen",
|
||||
"inboxSubtitle": "Meeting-Einladungen von anderen Redlight-Instanzen",
|
||||
"inviteTitle": "Remote-Benutzer einladen",
|
||||
"inviteSubtitle": "Einen Benutzer von einer anderen Redlight-Instanz zu diesem Meeting einladen.",
|
||||
"addressLabel": "Benutzeradresse",
|
||||
"addressPlaceholder": "@benutzer@andere-instanz.com",
|
||||
"addressHint": "Format: @Benutzername@Domain der Redlight-Instanz",
|
||||
"messageLabel": "Nachricht (optional)",
|
||||
"messagePlaceholder": "Hallo, ich lade dich zu unserem Meeting ein!",
|
||||
"send": "Einladung senden",
|
||||
"sent": "Einladung gesendet!",
|
||||
"sendFailed": "Einladung konnte nicht gesendet werden",
|
||||
"from": "Von",
|
||||
"accept": "Annehmen",
|
||||
"decline": "Ablehnen",
|
||||
"accepted": "Einladung angenommen",
|
||||
"declined": "Einladung abgelehnt",
|
||||
"acceptFailed": "Fehler beim Annehmen",
|
||||
"declineFailed": "Fehler beim Ablehnen",
|
||||
"pending": "Ausstehend",
|
||||
"previousInvites": "Frühere Einladungen",
|
||||
"noInvitations": "Keine Einladungen",
|
||||
"noInvitationsSubtitle": "Wenn Sie von einer anderen Redlight-Instanz eingeladen werden, erscheint die Einladung hier.",
|
||||
"statusAccepted": "Angenommen",
|
||||
"statusDeclined": "Abgelehnt",
|
||||
"openLink": "Meeting öffnen",
|
||||
"loadFailed": "Einladungen konnten nicht geladen werden",
|
||||
"inviteRemote": "Remote einladen",
|
||||
"federated": "Fremd-Instanz",
|
||||
"readOnlyNotice": "Dieser Raum gehört einer anderen Instanz. Einstellungen können nicht geändert werden.",
|
||||
"joinMeeting": "Meeting beitreten",
|
||||
"removeRoom": "Raum entfernen",
|
||||
"removeRoomConfirm": "Raum wirklich entfernen?",
|
||||
"roomRemoved": "Raum entfernt",
|
||||
"roomRemoveFailed": "Raum konnte nicht entfernt werden",
|
||||
"acceptedSaved": "Einladung angenommen - Raum wurde in deinem Dashboard gespeichert!",
|
||||
"meetingId": "Meeting ID",
|
||||
"maxParticipants": "Max. Teilnehmer",
|
||||
"recordingOn": "Aufnahme aktiviert",
|
||||
"recordingOff": "Aufnahme deaktiviert",
|
||||
"unlimited": "Unbegrenzt",
|
||||
"backToDashboard": "Zurück zum Dashboard",
|
||||
"participantLimit": "Teilnehmerlimit gesetzt",
|
||||
"recordingLabel": "Aufnahme",
|
||||
"recordingOnHint": "Meetings in diesem Raum können aufgezeichnet werden",
|
||||
"recordingOffHint": "Meetings in diesem Raum werden nicht aufgezeichnet",
|
||||
"roomDetails": "Raumdetails",
|
||||
"joinUrl": "Beitritts-URL",
|
||||
"roomDeleted": "Gelöscht",
|
||||
"roomDeletedNotice": "Dieser Raum wurde vom Besitzer auf der Ursprungsinstanz gelöscht und ist nicht mehr verfügbar.",
|
||||
"calendarEvent": "Kalendereinladung",
|
||||
"calendarAccepted": "Kalender-Event angenommen und in deinen Kalender eingetragen!",
|
||||
"localCalendarEvent": "Lokale Kalendereinladung",
|
||||
"calendarLocalAccepted": "Einladung angenommen - Event wurde in deinen Kalender eingetragen!",
|
||||
"invitationRemoved": "Einladung entfernt",
|
||||
"removeInvitation": "Einladung entfernen"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalender",
|
||||
"subtitle": "Meetings planen und verwalten",
|
||||
"newEvent": "Neues Event",
|
||||
"createEvent": "Event erstellen",
|
||||
"editEvent": "Event bearbeiten",
|
||||
"eventTitle": "Titel",
|
||||
"eventTitlePlaceholder": "z.B. Team Meeting",
|
||||
"description": "Beschreibung",
|
||||
"descriptionPlaceholder": "Beschreibung hinzufügen...",
|
||||
"startTime": "Beginn",
|
||||
"endTime": "Ende",
|
||||
"linkedRoom": "Verknüpfter Raum",
|
||||
"noRoom": "Kein Raum (kein Videomeeting)",
|
||||
"linkedRoomHint": "Verknüpfe einen Raum, um die Beitritts-URL automatisch ins Event einzufügen.",
|
||||
"reminderLabel": "Erinnerung",
|
||||
"reminderNone": "Keine Erinnerung",
|
||||
"reminder5": "5 Minuten vorher",
|
||||
"reminder15": "15 Minuten vorher",
|
||||
"reminder30": "30 Minuten vorher",
|
||||
"reminder60": "1 Stunde vorher",
|
||||
"reminder120": "2 Stunden vorher",
|
||||
"reminder1440": "1 Tag vorher",
|
||||
"timezone": "Zeitzone",
|
||||
"color": "Farbe",
|
||||
"eventCreated": "Event erstellt!",
|
||||
"eventUpdated": "Event aktualisiert!",
|
||||
"eventDeleted": "Event gelöscht",
|
||||
"saveFailed": "Event konnte nicht gespeichert werden",
|
||||
"deleteFailed": "Event konnte nicht gelöscht werden",
|
||||
"deleteConfirm": "Dieses Event wirklich löschen?",
|
||||
"loadFailed": "Events konnten nicht geladen werden",
|
||||
"today": "Heute",
|
||||
"month": "Monat",
|
||||
"week": "Woche",
|
||||
"more": "weitere",
|
||||
"mon": "Mo",
|
||||
"tue": "Di",
|
||||
"wed": "Mi",
|
||||
"thu": "Do",
|
||||
"fri": "Fr",
|
||||
"sat": "Sa",
|
||||
"sun": "So",
|
||||
"downloadICS": "ICS herunterladen",
|
||||
"addToOutlook": "Zu Outlook hinzufügen",
|
||||
"addToGoogleCalendar": "Zu Google Kalender",
|
||||
"icsDownloaded": "ICS-Datei heruntergeladen",
|
||||
"icsFailed": "ICS-Datei konnte nicht heruntergeladen werden",
|
||||
"share": "Teilen",
|
||||
"shareEvent": "Event teilen",
|
||||
"shareAdded": "Benutzer zum Event hinzugefügt",
|
||||
"shareRemoved": "Freigabe entfernt",
|
||||
"shareFailed": "Event konnte nicht geteilt werden",
|
||||
"invitationSent": "Einladung gesendet!",
|
||||
"invitationCancelled": "Einladung widerrufen",
|
||||
"invitationPending": "Einladung ausstehend",
|
||||
"pendingInvitations": "Ausstehende Einladungen",
|
||||
"accepted": "Angenommen",
|
||||
"sendFederated": "An Remote senden",
|
||||
"sendFederatedTitle": "Event an Remote-Instanz senden",
|
||||
"sendFederatedDesc": "Sende dieses Kalender-Event an einen Benutzer auf einer anderen Redlight-Instanz. Der Empfänger muss die Einladung zuerst annehmen, bevor das Event in seinem Kalender erscheint.",
|
||||
"send": "Senden",
|
||||
"fedSent": "Kalendereinladung gesendet! Der Empfänger muss diese zuerst annehmen.",
|
||||
"fedFailed": "Event konnte nicht an Remote-Instanz gesendet werden",
|
||||
"openRoom": "Verknüpften Raum öffnen",
|
||||
"organizer": "Organisator",
|
||||
"federatedFrom": "Von Remote-Instanz",
|
||||
"joinFederatedMeeting": "Remote-Meeting beitreten"
|
||||
},
|
||||
"email": {
|
||||
"greeting": "Hey {name} 👋",
|
||||
"viewInvitation": "Einladung anzeigen",
|
||||
"invitationFooter": "Öffne den Link oben, um die Einladung anzunehmen oder abzulehnen.",
|
||||
"linkHint": "Oder kopiere diesen Link in deinen Browser:",
|
||||
"verify": {
|
||||
"subject": "{appName} - E-Mail-Adresse bestätigen",
|
||||
"intro": "Bitte bestätige deine E-Mail-Adresse, indem du auf den Button klickst:",
|
||||
"button": "E-Mail bestätigen",
|
||||
"validity": "Dieser Link ist 24 Stunden gültig.",
|
||||
"footer": "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren."
|
||||
},
|
||||
"invite": {
|
||||
"subject": "{appName} - Du wurdest eingeladen",
|
||||
"title": "Du wurdest eingeladen! 🎉",
|
||||
"intro": "Du wurdest eingeladen, ein Konto auf {appName} zu erstellen.",
|
||||
"prompt": "Klicke auf den Button, um dich zu registrieren:",
|
||||
"button": "Konto erstellen",
|
||||
"validity": "Dieser Link ist 7 Tage gültig.",
|
||||
"footer": "Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren."
|
||||
},
|
||||
"federationInvite": {
|
||||
"subject": "{appName} - Meeting-Einladung von {fromUser}",
|
||||
"intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.",
|
||||
"roomLabel": "Raum:"
|
||||
},
|
||||
"calendarInvite": {
|
||||
"subject": "{appName} - Kalendereinladung von {fromUser}",
|
||||
"intro": "Du hast eine Kalendereinladung von {fromUser} erhalten."
|
||||
},
|
||||
"calendarDeleted": {
|
||||
"subject": "{appName} - Kalendereintrag abgesagt: {title}",
|
||||
"intro": "Der folgende Kalendereintrag wurde vom Organisator ({fromUser}) gelöscht und ist nicht mehr verfügbar:",
|
||||
"note": "Der Termin wurde automatisch aus deinem Kalender entfernt.",
|
||||
"footer": "Diese Nachricht wurde automatisch von {appName} versendet."
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Seite nicht gefunden",
|
||||
"description": "Die Seite, die du suchst, existiert nicht oder wurde verschoben.",
|
||||
"goBack": "Zurück",
|
||||
"goHome": "Zur Startseite"
|
||||
}
|
||||
}
|
||||
380
src/i18n/en.json
380
src/i18n/en.json
@@ -31,7 +31,11 @@
|
||||
"admin": "Administration",
|
||||
"appearance": "Appearance",
|
||||
"changeTheme": "Change theme",
|
||||
"navigation": "Navigation"
|
||||
"navigation": "Navigation",
|
||||
"calendar": "Calendar",
|
||||
"federation": "Invitations",
|
||||
"imprint": "Imprint",
|
||||
"privacy": "Privacy Policy"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Sign in",
|
||||
@@ -71,10 +75,42 @@
|
||||
"verifyFailed": "Verification failed",
|
||||
"verifyFailedTitle": "Verification failed",
|
||||
"verifyTokenMissing": "No verification token provided.",
|
||||
"emailNotVerified": "Email not yet verified. Please check your inbox."
|
||||
"emailNotVerified": "Email not yet verified. Please check your inbox.",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "e.g. johndoe",
|
||||
"usernameHint": "Letters, numbers, _ and - only (3-30 chars)",
|
||||
"displayName": "Display Name",
|
||||
"displayNamePlaceholder": "John Doe",
|
||||
"usernameTaken": "Username is already taken",
|
||||
"usernameInvalid": "Username may only contain letters, numbers, _ and - (3-30 chars)",
|
||||
"usernameRequired": "Username is required",
|
||||
"displayNameRequired": "Display name is required",
|
||||
"emailVerificationBanner": "Your email address has not been verified yet.",
|
||||
"emailVerificationResend": "Click here to receive a new verification email",
|
||||
"emailVerificationResendCooldown": "Resend in {seconds}s",
|
||||
"emailVerificationResendSuccess": "Verification email sent!",
|
||||
"emailVerificationResendFailed": "Could not send verification email",
|
||||
"inviteOnly": "Invite Only",
|
||||
"inviteOnlyDesc": "Registration is currently restricted. You need an invitation link from an administrator to create an account.",
|
||||
"orContinueWith": "or continue with",
|
||||
"loginWithOAuth": "Sign in with {provider}",
|
||||
"registerWithOAuth": "Sign up with {provider}",
|
||||
"backToLogin": "Back to login",
|
||||
"oauthError": "Authentication failed",
|
||||
"oauthNoToken": "No authentication token received.",
|
||||
"oauthLoginFailed": "Could not complete sign in. Please try again.",
|
||||
"oauthRedirecting": "Signing you in...",
|
||||
"2fa": {
|
||||
"title": "Two-Factor Authentication",
|
||||
"prompt": "Enter the 6-digit code from your authenticator app.",
|
||||
"codeLabel": "Verification code",
|
||||
"verify": "Verify",
|
||||
"verifyFailed": "Verification failed",
|
||||
"backToLogin": "← Back to login"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"poweredBy": "Powered by BigBlueButton",
|
||||
"madeFor": "Made for BigBlueButton",
|
||||
"heroTitle": "Meetings re",
|
||||
"heroTitleHighlight": "defined",
|
||||
"heroSubtitle": "The modern, self-hosted BigBlueButton frontend. Create rooms, manage recordings and enjoy a beautiful interface with over 15 themes.",
|
||||
@@ -122,7 +158,9 @@
|
||||
"roomDeleteFailed": "Room could not be deleted",
|
||||
"roomDeleteConfirm": "Really delete room \"{name}\"?",
|
||||
"loadFailed": "Rooms could not be loaded",
|
||||
"sharedWithMe": "Shared with me"
|
||||
"sharedWithMe": "Shared with me",
|
||||
"federatedRooms": "Rooms from other instances",
|
||||
"federatedRoomsSubtitle": "Accepted meeting invitations from other Redlight instances. Settings cannot be changed here."
|
||||
},
|
||||
"room": {
|
||||
"backToDashboard": "Back to Dashboard",
|
||||
@@ -136,6 +174,8 @@
|
||||
"settings": "Settings",
|
||||
"participants": "{count} participants",
|
||||
"copyLink": "Copy link",
|
||||
"copyRoomLink": "Room Link",
|
||||
"copyGuestLink": "Guest Link",
|
||||
"linkCopied": "Link copied!",
|
||||
"meetingDetails": "Meeting details",
|
||||
"meetingId": "Meeting ID",
|
||||
@@ -190,6 +230,11 @@
|
||||
"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.",
|
||||
"guestWaitingTitle": "Waiting for meeting to start...",
|
||||
"guestWaitingHint": "You will be joined automatically as soon as the meeting starts.",
|
||||
"guestCancelWaiting": "Cancel",
|
||||
"guestMeetingStartedJoining": "Meeting started! Joining now...",
|
||||
"waitingToJoin": "Waiting...",
|
||||
"guestAccessDenied": "Access denied",
|
||||
"guestNameRequired": "Name is required",
|
||||
"guestJoinFailed": "Join failed",
|
||||
@@ -198,14 +243,34 @@
|
||||
"guestHasAccount": "Have an account?",
|
||||
"guestSignIn": "Sign in",
|
||||
"guestRoomNotFound": "Room not found",
|
||||
"guestRecordingNotice": "This meeting may be recorded, including your audio and video.",
|
||||
"guestRecordingConsent": "I understand that this meeting may be recorded.",
|
||||
"shared": "Shared",
|
||||
"shareTitle": "Share room",
|
||||
"presentationTitle": "Default Presentation",
|
||||
"presentationDesc": "This file will be automatically pre-loaded in BBB when the meeting starts.",
|
||||
"presentationUpload": "Upload presentation",
|
||||
"presentationRemove": "Remove presentation",
|
||||
"presentationUploaded": "Presentation uploaded",
|
||||
"presentationRemoved": "Presentation removed",
|
||||
"presentationUploadFailed": "Could not upload presentation",
|
||||
"presentationRemoveFailed": "Could not remove presentation",
|
||||
"presentationAllowedTypes": "PDF, PPT, PPTX, ODP, DOC, DOCX · max. 50 MB",
|
||||
"presentationCurrent": "Current:",
|
||||
"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"
|
||||
"shareRemove": "Remove share",
|
||||
"defaultWelcome": "Welcome to the meeting!",
|
||||
"analytics": "Learning Analytics",
|
||||
"enableAnalytics": "Enable learning analytics",
|
||||
"enableAnalyticsHint": "Collects participant engagement data after each meeting.",
|
||||
"analyticsVisibility": "Who can see analytics?",
|
||||
"analyticsOwnerOnly": "Room owner only",
|
||||
"analyticsSharedUsers": "All shared users",
|
||||
"analyticsVisibilityHint": "Controls who can view and export analytics data for this room."
|
||||
},
|
||||
"recordings": {
|
||||
"title": "Recordings",
|
||||
@@ -223,6 +288,30 @@
|
||||
"publish": "Publish",
|
||||
"loadFailed": "Recordings could not be loaded"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Learning Analytics",
|
||||
"noData": "No analytics data available",
|
||||
"participants": "Participants",
|
||||
"messages": "Messages",
|
||||
"expand": "Show details",
|
||||
"collapse": "Hide details",
|
||||
"deleteConfirm": "Really delete analytics data?",
|
||||
"deleted": "Analytics data deleted",
|
||||
"deleteFailed": "Error deleting data",
|
||||
"userName": "Name",
|
||||
"role": "Role",
|
||||
"moderator": "Moderator",
|
||||
"viewer": "Viewer",
|
||||
"talkTime": "Talk time",
|
||||
"webcamTime": "Webcam time",
|
||||
"duration": "Duration",
|
||||
"meetingDuration": "Meeting duration",
|
||||
"raiseHand": "Raise hand",
|
||||
"reactions": "Reactions",
|
||||
"export": "Download",
|
||||
"exportSuccess": "Download started",
|
||||
"exportFailed": "Error downloading data"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Manage your profile and settings",
|
||||
@@ -252,7 +341,49 @@
|
||||
"passwordChanged": "Password changed",
|
||||
"passwordChangeFailed": "Error changing password",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"selectLanguage": "Select language"
|
||||
"selectLanguage": "Select language",
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"subtitle": "Protect your account with two-factor authentication (2FA). After enabling, you will need both your password and a code from your authenticator app to sign in.",
|
||||
"statusEnabled": "2FA is enabled",
|
||||
"statusEnabledDesc": "Your account is protected with two-factor authentication.",
|
||||
"statusDisabled": "2FA is not enabled",
|
||||
"statusDisabledDesc": "Enable two-factor authentication for an extra layer of security.",
|
||||
"enable": "Enable 2FA",
|
||||
"disable": "Disable 2FA",
|
||||
"enabled": "Two-factor authentication enabled!",
|
||||
"disabled": "Two-factor authentication disabled.",
|
||||
"enableFailed": "Could not enable 2FA",
|
||||
"disableFailed": "Could not disable 2FA",
|
||||
"setupFailed": "Could not start 2FA setup",
|
||||
"scanQR": "Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.).",
|
||||
"manualKey": "Or enter this key manually:",
|
||||
"verifyCode": "Enter the code from your app to verify",
|
||||
"codeLabel": "6-digit code",
|
||||
"disableConfirm": "Enter your password and a current 2FA code to disable two-factor authentication."
|
||||
},
|
||||
"caldav": {
|
||||
"title": "CalDAV",
|
||||
"subtitle": "Connect your calendar app (e.g. Apple Calendar, Thunderbird, DAVx⁵) using the CalDAV protocol. Use your email address and an app token as password.",
|
||||
"serverUrl": "Server URL",
|
||||
"username": "Username (Email)",
|
||||
"hint": "Never enter your real Redlight password in a calendar app. Use an app token instead.",
|
||||
"newToken": "Generate new app token",
|
||||
"tokenNamePlaceholder": "e.g. \"iPhone\" or \"Thunderbird\"",
|
||||
"generate": "Generate",
|
||||
"existingTokens": "Active tokens",
|
||||
"noTokens": "No tokens created yet.",
|
||||
"created": "Created",
|
||||
"lastUsed": "Last used",
|
||||
"revoke": "Revoke",
|
||||
"revokeConfirm": "Really revoke this token? All connected calendar apps using this token will lose access.",
|
||||
"revoked": "Token revoked",
|
||||
"revokeFailed": "Could not revoke token",
|
||||
"createFailed": "Could not create token",
|
||||
"newTokenCreated": "Token created — copy it now!",
|
||||
"newTokenHint": "This token will only be shown once. Copy it and enter it as the password in your calendar app.",
|
||||
"dismiss": "I have copied the token"
|
||||
}
|
||||
},
|
||||
"themes": {
|
||||
"selectTheme": "Select theme",
|
||||
@@ -300,6 +431,239 @@
|
||||
"logoRemoveFailed": "Could not remove logo",
|
||||
"appNameLabel": "App name",
|
||||
"appNameUpdated": "App name updated",
|
||||
"appNameUpdateFailed": "Could not update app name"
|
||||
"appNameUpdateFailed": "Could not update app name",
|
||||
"hideAppNameLabel": "Hide app name",
|
||||
"hideAppNameHint": "Only show the logo, hide the app name text next to it.",
|
||||
"hideAppNameFailed": "Could not update setting",
|
||||
"defaultThemeLabel": "Default Theme",
|
||||
"defaultThemeDesc": "Applied to unauthenticated pages (guest join, login, home) when no personal preference is set.",
|
||||
"defaultThemeSaved": "Default theme saved",
|
||||
"defaultThemeUpdateFailed": "Could not update default theme",
|
||||
"regModeTitle": "Registration Mode",
|
||||
"regModeDescription": "Control how new users can register. \"Open\" allows everyone to sign up. \"Invite only\" requires an invitation link.",
|
||||
"regModeOpen": "Open registration",
|
||||
"regModeInvite": "Invite only",
|
||||
"regModeSaved": "Registration mode updated",
|
||||
"regModeFailed": "Could not update registration mode",
|
||||
"inviteTitle": "User Invitations",
|
||||
"inviteDescription": "Invite new users by email. They will receive a registration link valid for 7 days.",
|
||||
"sendInvite": "Send invite",
|
||||
"inviteSent": "Invitation sent!",
|
||||
"inviteFailed": "Could not send invitation",
|
||||
"inviteDeleted": "Invitation deleted",
|
||||
"inviteDeleteFailed": "Could not delete invitation",
|
||||
"inviteLinkCopied": "Invite link copied!",
|
||||
"copyInviteLink": "Copy invite link",
|
||||
"inviteExpired": "Expired",
|
||||
"inviteUsedBy": "Used by",
|
||||
"inviteExpiresAt": "Expires",
|
||||
"noInvites": "No invitations yet",
|
||||
"legalLinksTitle": "Legal Links",
|
||||
"legalLinksDesc": "Show Imprint and Privacy Policy links at the bottom of the sidebar. Leave blank to hide.",
|
||||
"imprintUrl": "Imprint URL",
|
||||
"privacyUrl": "Privacy Policy URL",
|
||||
"imprintUrlSaved": "Imprint URL saved",
|
||||
"privacyUrlSaved": "Privacy Policy URL saved",
|
||||
"imprintUrlFailed": "Could not save Imprint URL",
|
||||
"privacyUrlFailed": "Could not save Privacy Policy URL",
|
||||
"oauthTitle": "OAuth / SSO",
|
||||
"oauthDescription": "Connect an OpenID Connect provider (e.g. Keycloak, Authentik, Google) to allow Single Sign-On.",
|
||||
"oauthIssuer": "Issuer URL",
|
||||
"oauthIssuerHint": "The OIDC issuer URL, e.g. https://auth.example.com/realms/main",
|
||||
"oauthClientId": "Client ID",
|
||||
"oauthClientSecret": "Client Secret",
|
||||
"oauthClientSecretHint": "Leave blank to keep the existing secret",
|
||||
"oauthDisplayName": "Button label",
|
||||
"oauthDisplayNameHint": "Shown on the login page, e.g. \"Company SSO\"",
|
||||
"oauthAutoRegister": "Auto-register new users",
|
||||
"oauthAutoRegisterHint": "Automatically create accounts for users signing in via OAuth for the first time.",
|
||||
"oauthSaved": "OAuth configuration saved",
|
||||
"oauthSaveFailed": "Could not save OAuth configuration",
|
||||
"oauthRemoved": "OAuth configuration removed",
|
||||
"oauthRemoveFailed": "Could not remove OAuth configuration",
|
||||
"oauthRemoveConfirm": "Really remove OAuth configuration? Users will no longer be able to sign in with SSO.",
|
||||
"oauthNotConfigured": "OAuth is not configured yet.",
|
||||
"oauthSave": "Save OAuth",
|
||||
"oauthRemove": "Remove OAuth"
|
||||
},
|
||||
"notifications": {
|
||||
"bell": "Notifications",
|
||||
"markAllRead": "Mark all read",
|
||||
"clearAll": "Clear all",
|
||||
"delete": "Delete",
|
||||
"noNotifications": "No notifications yet",
|
||||
"roomShareAdded": "Room shared with you",
|
||||
"roomShareRemoved": "Room access removed",
|
||||
"federationInviteReceived": "New meeting invitation"
|
||||
},
|
||||
"federation": {
|
||||
"inbox": "Invitations",
|
||||
"inboxSubtitle": "Meeting invitations from other Redlight instances",
|
||||
"inviteTitle": "Invite Remote User",
|
||||
"inviteSubtitle": "Invite a user from another Redlight instance to this meeting.",
|
||||
"addressLabel": "User address",
|
||||
"addressPlaceholder": "@user@other-instance.com",
|
||||
"addressHint": "Format: @username@domain of the Redlight instance",
|
||||
"messageLabel": "Message (optional)",
|
||||
"messagePlaceholder": "Hi, I'd like to invite you to our meeting!",
|
||||
"send": "Send invitation",
|
||||
"sent": "Invitation sent!",
|
||||
"sendFailed": "Could not send invitation",
|
||||
"from": "From",
|
||||
"accept": "Accept",
|
||||
"decline": "Decline",
|
||||
"accepted": "Invitation accepted",
|
||||
"declined": "Invitation declined",
|
||||
"acceptFailed": "Error accepting invitation",
|
||||
"declineFailed": "Error declining invitation",
|
||||
"pending": "Pending",
|
||||
"previousInvites": "Previous Invitations",
|
||||
"noInvitations": "No invitations",
|
||||
"noInvitationsSubtitle": "When you receive an invitation from another Redlight instance, it will appear here.",
|
||||
"statusAccepted": "Accepted",
|
||||
"statusDeclined": "Declined",
|
||||
"openLink": "Open meeting",
|
||||
"loadFailed": "Could not load invitations",
|
||||
"inviteRemote": "Invite remote",
|
||||
"federated": "Federated",
|
||||
"readOnlyNotice": "This room belongs to another instance. Settings cannot be changed.",
|
||||
"joinMeeting": "Join meeting",
|
||||
"removeRoom": "Remove room",
|
||||
"removeRoomConfirm": "Really remove this room?",
|
||||
"roomRemoved": "Room removed",
|
||||
"roomRemoveFailed": "Could not remove room",
|
||||
"acceptedSaved": "Invitation accepted - room saved to your dashboard!",
|
||||
"meetingId": "Meeting ID",
|
||||
"maxParticipants": "Max. participants",
|
||||
"recordingOn": "Recording enabled",
|
||||
"recordingOff": "Recording disabled",
|
||||
"unlimited": "Unlimited",
|
||||
"backToDashboard": "Back to Dashboard",
|
||||
"participantLimit": "Participant limit set",
|
||||
"recordingLabel": "Recording",
|
||||
"recordingOnHint": "Meetings in this room may be recorded",
|
||||
"recordingOffHint": "Meetings in this room will not be recorded",
|
||||
"roomDetails": "Room Details",
|
||||
"joinUrl": "Join URL",
|
||||
"roomDeleted": "Deleted",
|
||||
"roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available.",
|
||||
"calendarEvent": "Calendar Invitation",
|
||||
"calendarAccepted": "Calendar event accepted and added to your calendar!",
|
||||
"localCalendarEvent": "Local Calendar Invitation",
|
||||
"calendarLocalAccepted": "Invitation accepted - event added to your calendar!",
|
||||
"invitationRemoved": "Invitation removed",
|
||||
"removeInvitation": "Remove invitation"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendar",
|
||||
"subtitle": "Plan and manage your meetings",
|
||||
"newEvent": "New Event",
|
||||
"createEvent": "Create Event",
|
||||
"editEvent": "Edit Event",
|
||||
"eventTitle": "Title",
|
||||
"eventTitlePlaceholder": "e.g. Team Meeting",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Add a description...",
|
||||
"startTime": "Start",
|
||||
"endTime": "End",
|
||||
"linkedRoom": "Linked Room",
|
||||
"noRoom": "No room (no video meeting)",
|
||||
"linkedRoomHint": "Link a room to automatically include the join-URL in the event.",
|
||||
"reminderLabel": "Reminder",
|
||||
"reminderNone": "No reminder",
|
||||
"reminder5": "5 minutes before",
|
||||
"reminder15": "15 minutes before",
|
||||
"reminder30": "30 minutes before",
|
||||
"reminder60": "1 hour before",
|
||||
"reminder120": "2 hours before",
|
||||
"reminder1440": "1 day before",
|
||||
"timezone": "Timezone",
|
||||
"color": "Color",
|
||||
"eventCreated": "Event created!",
|
||||
"eventUpdated": "Event updated!",
|
||||
"eventDeleted": "Event deleted",
|
||||
"saveFailed": "Could not save event",
|
||||
"deleteFailed": "Could not delete event",
|
||||
"deleteConfirm": "Really delete this event?",
|
||||
"loadFailed": "Events could not be loaded",
|
||||
"today": "Today",
|
||||
"month": "Month",
|
||||
"week": "Week",
|
||||
"more": "more",
|
||||
"mon": "Mon",
|
||||
"tue": "Tue",
|
||||
"wed": "Wed",
|
||||
"thu": "Thu",
|
||||
"fri": "Fri",
|
||||
"sat": "Sat",
|
||||
"sun": "Sun",
|
||||
"downloadICS": "Download ICS",
|
||||
"addToOutlook": "Add to Outlook",
|
||||
"addToGoogleCalendar": "Google Calendar",
|
||||
"icsDownloaded": "ICS file downloaded",
|
||||
"icsFailed": "Could not download ICS file",
|
||||
"share": "Share",
|
||||
"shareEvent": "Share Event",
|
||||
"shareAdded": "User added to event",
|
||||
"shareRemoved": "Share removed",
|
||||
"shareFailed": "Could not share event",
|
||||
"invitationSent": "Invitation sent!",
|
||||
"invitationCancelled": "Invitation cancelled",
|
||||
"invitationPending": "Invitation pending",
|
||||
"pendingInvitations": "Pending Invitations",
|
||||
"accepted": "Accepted",
|
||||
"sendFederated": "Send to remote",
|
||||
"sendFederatedTitle": "Send Event to Remote Instance",
|
||||
"sendFederatedDesc": "Send this calendar event to a user on another Redlight instance. The recipient must accept the invitation before the event appears in their calendar.",
|
||||
"send": "Send",
|
||||
"fedSent": "Calendar invitation sent! The recipient must accept it first.",
|
||||
"fedFailed": "Could not send event to remote instance",
|
||||
"openRoom": "Open linked room",
|
||||
"organizer": "Organizer",
|
||||
"federatedFrom": "From remote instance",
|
||||
"joinFederatedMeeting": "Join remote meeting"
|
||||
},
|
||||
"email": {
|
||||
"greeting": "Hey {name} 👋",
|
||||
"viewInvitation": "View Invitation",
|
||||
"invitationFooter": "Open the link above to accept or decline the invitation.",
|
||||
"linkHint": "Or copy this link in your browser:",
|
||||
"verify": {
|
||||
"subject": "{appName} - Verify your email",
|
||||
"intro": "Please verify your email address by clicking the button below:",
|
||||
"button": "Verify Email",
|
||||
"validity": "This link is valid for 24 hours.",
|
||||
"footer": "If you didn't register, please ignore this email."
|
||||
},
|
||||
"invite": {
|
||||
"subject": "{appName} - You've been invited",
|
||||
"title": "You've been invited! 🎉",
|
||||
"intro": "You have been invited to create an account on {appName}.",
|
||||
"prompt": "Click the button below to register:",
|
||||
"button": "Create Account",
|
||||
"validity": "This link is valid for 7 days.",
|
||||
"footer": "If you didn't expect this invitation, you can safely ignore this email."
|
||||
},
|
||||
"federationInvite": {
|
||||
"subject": "{appName} - Meeting invitation from {fromUser}",
|
||||
"intro": "You have received a meeting invitation from {fromUser}.",
|
||||
"roomLabel": "Room:"
|
||||
},
|
||||
"calendarInvite": {
|
||||
"subject": "{appName} - Calendar invitation from {fromUser}",
|
||||
"intro": "You have received a calendar invitation from {fromUser}."
|
||||
},
|
||||
"calendarDeleted": {
|
||||
"subject": "{appName} - Calendar event cancelled: {title}",
|
||||
"intro": "The following calendar event was deleted by the organiser ({fromUser}) and is no longer available:",
|
||||
"note": "The event has been automatically removed from your calendar.",
|
||||
"footer": "This message was sent automatically by {appName}."
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Page not found",
|
||||
"description": "The page you are looking for doesn't exist or has been moved.",
|
||||
"goBack": "Go back",
|
||||
"goHome": "Back to home"
|
||||
}
|
||||
}
|
||||
512
src/index.css
512
src/index.css
@@ -6,6 +6,8 @@
|
||||
/* ===== DEFAULT LIGHT ===== */
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--picker-icon-filter: none;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8fafc;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
@@ -32,6 +34,8 @@
|
||||
|
||||
/* ===== DEFAULT DARK ===== */
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--picker-icon-filter: invert(0.8);
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
@@ -58,6 +62,8 @@
|
||||
|
||||
/* ===== DRACULA ===== */
|
||||
[data-theme="dracula"] {
|
||||
color-scheme: dark;
|
||||
--picker-icon-filter: invert(0.8);
|
||||
--bg-primary: #282a36;
|
||||
--bg-secondary: #44475a;
|
||||
--bg-tertiary: #383a4c;
|
||||
@@ -84,6 +90,8 @@
|
||||
|
||||
/* ===== CATPPUCCIN MOCHA ===== */
|
||||
[data-theme="mocha"] {
|
||||
color-scheme: dark;
|
||||
--picker-icon-filter: invert(0.8);
|
||||
--bg-primary: #1e1e2e;
|
||||
--bg-secondary: #313244;
|
||||
--bg-tertiary: #45475a;
|
||||
@@ -110,6 +118,8 @@
|
||||
|
||||
/* ===== CATPPUCCIN LATTE (Light) ===== */
|
||||
[data-theme="latte"] {
|
||||
color-scheme: light;
|
||||
--picker-icon-filter: none;
|
||||
--bg-primary: #eff1f5;
|
||||
--bg-secondary: #e6e9ef;
|
||||
--bg-tertiary: #dce0e8;
|
||||
@@ -136,6 +146,8 @@
|
||||
|
||||
/* ===== NORD ===== */
|
||||
[data-theme="nord"] {
|
||||
color-scheme: dark;
|
||||
--picker-icon-filter: invert(0.8);
|
||||
--bg-primary: #2e3440;
|
||||
--bg-secondary: #3b4252;
|
||||
--bg-tertiary: #434c5e;
|
||||
@@ -162,6 +174,8 @@
|
||||
|
||||
/* ===== TOKYO NIGHT ===== */
|
||||
[data-theme="tokyo-night"] {
|
||||
color-scheme: dark;
|
||||
--picker-icon-filter: invert(0.8);
|
||||
--bg-primary: #1a1b26;
|
||||
--bg-secondary: #24283b;
|
||||
--bg-tertiary: #2f3349;
|
||||
@@ -188,6 +202,8 @@
|
||||
|
||||
/* ===== GRUVBOX DARK ===== */
|
||||
[data-theme="gruvbox-dark"] {
|
||||
color-scheme: dark;
|
||||
--picker-icon-filter: invert(0.8);
|
||||
--bg-primary: #282828;
|
||||
--bg-secondary: #3c3836;
|
||||
--bg-tertiary: #504945;
|
||||
@@ -214,6 +230,8 @@
|
||||
|
||||
/* ===== GRUVBOX LIGHT ===== */
|
||||
[data-theme="gruvbox-light"] {
|
||||
color-scheme: light;
|
||||
--picker-icon-filter: none;
|
||||
--bg-primary: #fbf1c7;
|
||||
--bg-secondary: #ebdbb2;
|
||||
--bg-tertiary: #d5c4a1;
|
||||
@@ -240,6 +258,8 @@
|
||||
|
||||
/* ===== ROSE PINE ===== */
|
||||
[data-theme="rose-pine"] {
|
||||
color-scheme: dark;
|
||||
--picker-icon-filter: invert(0.8);
|
||||
--bg-primary: #191724;
|
||||
--bg-secondary: #1f1d2e;
|
||||
--bg-tertiary: #26233a;
|
||||
@@ -266,6 +286,8 @@
|
||||
|
||||
/* ===== ROSE PINE DAWN (Light) ===== */
|
||||
[data-theme="rose-pine-dawn"] {
|
||||
color-scheme: light;
|
||||
--picker-icon-filter: none;
|
||||
--bg-primary: #faf4ed;
|
||||
--bg-secondary: #fffaf3;
|
||||
--bg-tertiary: #f2e9e1;
|
||||
@@ -292,6 +314,8 @@
|
||||
|
||||
/* ===== SOLARIZED DARK ===== */
|
||||
[data-theme="solarized-dark"] {
|
||||
color-scheme: dark;
|
||||
--picker-icon-filter: invert(0.8);
|
||||
--bg-primary: #002b36;
|
||||
--bg-secondary: #073642;
|
||||
--bg-tertiary: #0a4050;
|
||||
@@ -318,6 +342,8 @@
|
||||
|
||||
/* ===== SOLARIZED LIGHT ===== */
|
||||
[data-theme="solarized-light"] {
|
||||
color-scheme: light;
|
||||
--picker-icon-filter: none;
|
||||
--bg-primary: #fdf6e3;
|
||||
--bg-secondary: #eee8d5;
|
||||
--bg-tertiary: #e4ddc8;
|
||||
@@ -344,6 +370,8 @@
|
||||
|
||||
/* ===== ONE DARK ===== */
|
||||
[data-theme="one-dark"] {
|
||||
color-scheme: dark;
|
||||
--picker-icon-filter: invert(0.8);
|
||||
--bg-primary: #282c34;
|
||||
--bg-secondary: #2c313a;
|
||||
--bg-tertiary: #353b45;
|
||||
@@ -370,6 +398,8 @@
|
||||
|
||||
/* ===== GITHUB DARK ===== */
|
||||
[data-theme="github-dark"] {
|
||||
color-scheme: dark;
|
||||
--picker-icon-filter: invert(0.8);
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
@@ -414,6 +444,259 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== SCRUNKLY.CAT DARK ===== */
|
||||
[data-theme="scrunkly-cat"] {
|
||||
color-scheme: dark;
|
||||
--picker-icon-filter: invert(0.8);
|
||||
--bg-primary: #161924;
|
||||
--bg-secondary: #161924;
|
||||
--bg-tertiary: #1b2130;
|
||||
--text-primary: #dadada;
|
||||
--text-secondary: #94a3b8;
|
||||
--accent: #b30051;
|
||||
--accent-hover: #d6336a;
|
||||
--accent-text: #ffffff;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--card-bg: #1b2130;
|
||||
--input-bg: rgba(255, 255, 255, 0.05);
|
||||
--input-border: rgba(255, 255, 255, 0.1);
|
||||
--nav-bg: #1b2130;
|
||||
--sidebar-bg: #161924;
|
||||
--hover-bg: rgba(255, 255, 255, 0.03);
|
||||
--success: #86b300;
|
||||
--warning: #ecb637;
|
||||
--error: #ec4137;
|
||||
--ring: #b30051;
|
||||
--shadow-color: rgba(0, 0, 0, 0.3);
|
||||
--gradient-start: #b30051;
|
||||
--gradient-end: #d6336a;
|
||||
}
|
||||
|
||||
/* ===== RED MODULAR LIGHT ===== */
|
||||
[data-theme="red-modular-light"] {
|
||||
color-scheme: light;
|
||||
--picker-icon-filter: none;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8fafc;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--text-primary: #000000;
|
||||
--text-secondary: #333333;
|
||||
--accent: #e60000;
|
||||
--accent-hover: #ff3333;
|
||||
--accent-text: #ffffff;
|
||||
--border: #e2e8f0;
|
||||
--card-bg: #ffffff;
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #cbd5e1;
|
||||
--nav-bg: #ffffff;
|
||||
--sidebar-bg: #f8fafc;
|
||||
--hover-bg: #f1f5f9;
|
||||
--success: #86b300;
|
||||
--warning: #ecb637;
|
||||
--error: #ec4137;
|
||||
--ring: #e60000;
|
||||
--shadow-color: rgba(0, 0, 0, 0.3);
|
||||
--gradient-start: #e60000;
|
||||
--gradient-end: #ff3333;
|
||||
}
|
||||
|
||||
/* ===== EVERFOREST DARK ===== */
|
||||
[data-theme="everforest-dark"] {
|
||||
color-scheme: dark;
|
||||
--picker-icon-filter: invert(0.8);
|
||||
--bg-primary: #2d353b;
|
||||
--bg-secondary: #343f44;
|
||||
--bg-tertiary: #3d484d;
|
||||
--text-primary: #d3c6aa;
|
||||
--text-secondary: #859289;
|
||||
--accent: #a7c080;
|
||||
--accent-hover: #bdd4a0;
|
||||
--accent-text: #2d353b;
|
||||
--border: #4f585e;
|
||||
--card-bg: #343f44;
|
||||
--input-bg: #343f44;
|
||||
--input-border: #4f585e;
|
||||
--nav-bg: #272e33;
|
||||
--sidebar-bg: #272e33;
|
||||
--hover-bg: #3d484d;
|
||||
--success: #a7c080;
|
||||
--warning: #e69875;
|
||||
--error: #e67e80;
|
||||
--ring: #a7c080;
|
||||
--shadow-color: rgba(0, 0, 0, 0.35);
|
||||
--gradient-start: #a7c080;
|
||||
--gradient-end: #83c092;
|
||||
}
|
||||
|
||||
/* ===== EVERFOREST LIGHT ===== */
|
||||
[data-theme="everforest-light"] {
|
||||
color-scheme: light;
|
||||
--picker-icon-filter: none;
|
||||
--bg-primary: #fdf6e3;
|
||||
--bg-secondary: #f4f0d9;
|
||||
--bg-tertiary: #eae4ca;
|
||||
--text-primary: #5c6a72;
|
||||
--text-secondary: #829181;
|
||||
--accent: #8da101;
|
||||
--accent-hover: #6e8c00;
|
||||
--accent-text: #fdf6e3;
|
||||
--border: #d5ceb5;
|
||||
--card-bg: #f4f0d9;
|
||||
--input-bg: #fdf6e3;
|
||||
--input-border: #c5bda0;
|
||||
--nav-bg: #f4f0d9;
|
||||
--sidebar-bg: #eae4ca;
|
||||
--hover-bg: #eae4ca;
|
||||
--success: #8da101;
|
||||
--warning: #dfa000;
|
||||
--error: #f85552;
|
||||
--ring: #8da101;
|
||||
--shadow-color: rgba(92, 106, 114, 0.1);
|
||||
--gradient-start: #8da101;
|
||||
--gradient-end: #35a77c;
|
||||
}
|
||||
|
||||
/* ===== KANAGAWA ===== */
|
||||
[data-theme="kanagawa"] {
|
||||
color-scheme: dark;
|
||||
--picker-icon-filter: invert(0.8);
|
||||
--bg-primary: #1f1f28;
|
||||
--bg-secondary: #2a2a37;
|
||||
--bg-tertiary: #363646;
|
||||
--text-primary: #dcd7ba;
|
||||
--text-secondary: #727169;
|
||||
--accent: #7e9cd8;
|
||||
--accent-hover: #98b4e8;
|
||||
--accent-text: #1f1f28;
|
||||
--border: #363646;
|
||||
--card-bg: #2a2a37;
|
||||
--input-bg: #2a2a37;
|
||||
--input-border: #363646;
|
||||
--nav-bg: #16161d;
|
||||
--sidebar-bg: #16161d;
|
||||
--hover-bg: #363646;
|
||||
--success: #76946a;
|
||||
--warning: #dca561;
|
||||
--error: #c34043;
|
||||
--ring: #7e9cd8;
|
||||
--shadow-color: rgba(0, 0, 0, 0.45);
|
||||
--gradient-start: #7e9cd8;
|
||||
--gradient-end: #957fb8;
|
||||
}
|
||||
|
||||
/* ===== AYU DARK ===== */
|
||||
[data-theme="ayu-dark"] {
|
||||
color-scheme: dark;
|
||||
--picker-icon-filter: invert(0.8);
|
||||
--bg-primary: #0d1017;
|
||||
--bg-secondary: #131721;
|
||||
--bg-tertiary: #1a212e;
|
||||
--text-primary: #bfbdb6;
|
||||
--text-secondary: #6c7380;
|
||||
--accent: #39bae6;
|
||||
--accent-hover: #59ccf0;
|
||||
--accent-text: #0d1017;
|
||||
--border: #1a212e;
|
||||
--card-bg: #131721;
|
||||
--input-bg: #0d1017;
|
||||
--input-border: #242b38;
|
||||
--nav-bg: #0d1017;
|
||||
--sidebar-bg: #0d1017;
|
||||
--hover-bg: #1a212e;
|
||||
--success: #aad94c;
|
||||
--warning: #ffb454;
|
||||
--error: #f07178;
|
||||
--ring: #39bae6;
|
||||
--shadow-color: rgba(0, 0, 0, 0.5);
|
||||
--gradient-start: #39bae6;
|
||||
--gradient-end: #6a9ff7;
|
||||
}
|
||||
|
||||
/* ===== MOONLIGHT ===== */
|
||||
[data-theme="moonlight"] {
|
||||
color-scheme: dark;
|
||||
--picker-icon-filter: invert(0.8);
|
||||
--bg-primary: #212337;
|
||||
--bg-secondary: #2b2d3f;
|
||||
--bg-tertiary: #353750;
|
||||
--text-primary: #c8d3f5;
|
||||
--text-secondary: #828dae;
|
||||
--accent: #82aaff;
|
||||
--accent-hover: #9dbdff;
|
||||
--accent-text: #212337;
|
||||
--border: #353750;
|
||||
--card-bg: #2b2d3f;
|
||||
--input-bg: #2b2d3f;
|
||||
--input-border: #444668;
|
||||
--nav-bg: #1e2030;
|
||||
--sidebar-bg: #1e2030;
|
||||
--hover-bg: #353750;
|
||||
--success: #c3e88d;
|
||||
--warning: #ffc777;
|
||||
--error: #ff757f;
|
||||
--ring: #82aaff;
|
||||
--shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--gradient-start: #82aaff;
|
||||
--gradient-end: #c099ff;
|
||||
}
|
||||
|
||||
/* ===== CYBERPUNK ===== */
|
||||
[data-theme="cyberpunk"] {
|
||||
color-scheme: dark;
|
||||
--picker-icon-filter: invert(0.8);
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-secondary: #0e0e1a;
|
||||
--bg-tertiary: #141428;
|
||||
--text-primary: #e0e0ff;
|
||||
--text-secondary: #7878bb;
|
||||
--accent: #ff0080;
|
||||
--accent-hover: #ff33a0;
|
||||
--accent-text: #ffffff;
|
||||
--border: #1e1e3a;
|
||||
--card-bg: #0e0e1a;
|
||||
--input-bg: #0d0d18;
|
||||
--input-border: #1e1e3a;
|
||||
--nav-bg: #07070f;
|
||||
--sidebar-bg: #07070f;
|
||||
--hover-bg: #141428;
|
||||
--success: #00ff9f;
|
||||
--warning: #ffdd00;
|
||||
--error: #ff3333;
|
||||
--ring: #ff0080;
|
||||
--shadow-color: rgba(255, 0, 128, 0.15);
|
||||
--gradient-start: #ff0080;
|
||||
--gradient-end: #00e5ff;
|
||||
}
|
||||
|
||||
/* ===== COTTON CANDY LIGHT ===== */
|
||||
[data-theme="cotton-candy-light"] {
|
||||
color-scheme: light;
|
||||
--picker-icon-filter: none;
|
||||
--bg-primary: #fff5f9;
|
||||
--bg-secondary: #ffe8f2;
|
||||
--bg-tertiary: #ffd6e8;
|
||||
--text-primary: #8b2635;
|
||||
--text-secondary: #b05470;
|
||||
--accent: #ff85a2;
|
||||
--accent-hover: #ff6b8d;
|
||||
--accent-text: #ffffff;
|
||||
--border: #ffc2d9;
|
||||
--card-bg: #ffe8f2;
|
||||
--input-bg: #fff5f9;
|
||||
--input-border: #ffaac8;
|
||||
--nav-bg: #ffe8f2;
|
||||
--sidebar-bg: #ffd6e8;
|
||||
--hover-bg: #ffd6e8;
|
||||
--success: #5cb85c;
|
||||
--warning: #f0ad4e;
|
||||
--error: #d9534f;
|
||||
--ring: #ff85a2;
|
||||
--shadow-color: rgba(255, 133, 162, 0.15);
|
||||
--gradient-start: #ff85a2;
|
||||
--gradient-end: #c084fc;
|
||||
}
|
||||
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
|
||||
@@ -484,3 +767,232 @@
|
||||
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
FLATPICKR THEMED OVERRIDES — fully driven by CSS variables
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Calendar container — appended to body */
|
||||
.flatpickr-calendar {
|
||||
background: var(--card-bg) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 0.75rem !important;
|
||||
box-shadow: 0 10px 25px -5px var(--shadow-color), 0 4px 10px -6px var(--shadow-color) !important;
|
||||
font-family: inherit !important;
|
||||
color: var(--text-primary) !important;
|
||||
z-index: 9999 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.flatpickr-calendar::before,
|
||||
.flatpickr-calendar::after {
|
||||
display: none !important; /* hide arrow */
|
||||
}
|
||||
|
||||
/* ── Month navigation ─────────────────────────────────────────── */
|
||||
.flatpickr-months {
|
||||
background: var(--bg-secondary) !important;
|
||||
border-bottom: 1px solid var(--border) !important;
|
||||
padding: 0 !important;
|
||||
align-items: center !important;
|
||||
height: 2.75rem !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.flatpickr-months .flatpickr-month {
|
||||
background: transparent !important;
|
||||
color: var(--text-primary) !important;
|
||||
height: 2.75rem !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.flatpickr-current-month {
|
||||
font-size: 0.875rem !important;
|
||||
font-weight: 600 !important;
|
||||
color: var(--text-primary) !important;
|
||||
padding: 0 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
position: relative !important;
|
||||
left: 0 !important;
|
||||
}
|
||||
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months {
|
||||
background: var(--bg-secondary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border: none !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 0.875rem !important;
|
||||
appearance: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months option {
|
||||
background: var(--card-bg) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.flatpickr-current-month input.cur-year {
|
||||
color: var(--text-primary) !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 0.875rem !important;
|
||||
}
|
||||
|
||||
.flatpickr-months .flatpickr-prev-month,
|
||||
.flatpickr-months .flatpickr-next-month {
|
||||
color: var(--text-secondary) !important;
|
||||
fill: var(--text-secondary) !important;
|
||||
padding: 0.5rem 0.625rem !important;
|
||||
transition: color 0.15s !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
height: 2.75rem !important;
|
||||
top: 0 !important;
|
||||
}
|
||||
|
||||
.flatpickr-months .flatpickr-prev-month:hover,
|
||||
.flatpickr-months .flatpickr-next-month:hover {
|
||||
color: var(--text-primary) !important;
|
||||
fill: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.flatpickr-months .flatpickr-prev-month svg,
|
||||
.flatpickr-months .flatpickr-next-month svg {
|
||||
fill: inherit !important;
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
}
|
||||
|
||||
/* ── Day names row ────────────────────────────────────────────── */
|
||||
.flatpickr-weekdays {
|
||||
background: var(--bg-secondary) !important;
|
||||
border-bottom: 1px solid var(--border) !important;
|
||||
padding: 0.125rem 0 !important;
|
||||
}
|
||||
|
||||
span.flatpickr-weekday {
|
||||
color: var(--text-secondary) !important;
|
||||
font-size: 0.6875rem !important;
|
||||
font-weight: 600 !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.05em !important;
|
||||
}
|
||||
|
||||
/* ── Days grid ────────────────────────────────────────────────── */
|
||||
.flatpickr-days {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.dayContainer {
|
||||
padding: 0.25rem !important;
|
||||
}
|
||||
|
||||
.flatpickr-day {
|
||||
color: var(--text-primary) !important;
|
||||
border: none !important;
|
||||
border-radius: 0.375rem !important;
|
||||
font-size: 0.8125rem !important;
|
||||
transition: background 0.12s, color 0.12s !important;
|
||||
}
|
||||
|
||||
.flatpickr-day:hover:not(.selected):not(.flatpickr-disabled) {
|
||||
background: var(--hover-bg) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.flatpickr-day.selected,
|
||||
.flatpickr-day.selected:hover {
|
||||
background: var(--accent) !important;
|
||||
color: var(--accent-text) !important;
|
||||
border: none !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.flatpickr-day.today:not(.selected) {
|
||||
font-weight: 700 !important;
|
||||
color: var(--accent) !important;
|
||||
border: none !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.flatpickr-day.today:not(.selected)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.flatpickr-day.prevMonthDay,
|
||||
.flatpickr-day.nextMonthDay {
|
||||
color: var(--text-secondary) !important;
|
||||
opacity: 0.4 !important;
|
||||
}
|
||||
|
||||
.flatpickr-day.flatpickr-disabled,
|
||||
.flatpickr-day.flatpickr-disabled:hover {
|
||||
color: var(--text-secondary) !important;
|
||||
opacity: 0.3 !important;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
/* ── Time picker ──────────────────────────────────────────────── */
|
||||
.flatpickr-time {
|
||||
border-top: 1px solid var(--border) !important;
|
||||
background: var(--bg-secondary) !important;
|
||||
max-height: none !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.flatpickr-time input {
|
||||
color: var(--text-primary) !important;
|
||||
background: transparent !important;
|
||||
font-size: 0.9375rem !important;
|
||||
font-weight: 600 !important;
|
||||
font-variant-numeric: tabular-nums !important;
|
||||
}
|
||||
|
||||
.flatpickr-time input:hover,
|
||||
.flatpickr-time input:focus {
|
||||
background: var(--hover-bg) !important;
|
||||
border-radius: 0.375rem !important;
|
||||
}
|
||||
|
||||
.flatpickr-time .flatpickr-time-separator {
|
||||
color: var(--text-secondary) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.flatpickr-time .flatpickr-am-pm {
|
||||
color: var(--text-primary) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.flatpickr-time .numInputWrapper span {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.flatpickr-time .numInputWrapper span.arrowUp::after {
|
||||
border-bottom-color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.flatpickr-time .numInputWrapper span.arrowDown::after {
|
||||
border-top-color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.flatpickr-time .numInputWrapper:hover span.arrowUp::after {
|
||||
border-bottom-color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.flatpickr-time .numInputWrapper:hover span.arrowDown::after {
|
||||
border-top-color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import { BrandingProvider } from './contexts/BrandingContext';
|
||||
import { NotificationProvider } from './contexts/NotificationContext';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
@@ -16,9 +17,11 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<ThemeProvider>
|
||||
<BrandingProvider>
|
||||
<AuthProvider>
|
||||
<NotificationProvider>
|
||||
<App />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
containerStyle={{ top: 70 }}
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
@@ -28,6 +31,7 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</NotificationProvider>
|
||||
</AuthProvider>
|
||||
</BrandingProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -3,18 +3,20 @@ import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
||||
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
||||
Upload, X as XIcon, Image, Type,
|
||||
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
|
||||
ShieldCheck, Globe, Link as LinkIcon, LogIn,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useBranding } from '../contexts/BrandingContext';
|
||||
import { themes } from '../themes';
|
||||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Admin() {
|
||||
const { user } = useAuth();
|
||||
const { t, language } = useLanguage();
|
||||
const { appName, hasLogo, logoUrl, refreshBranding } = useBranding();
|
||||
const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, imprintUrl, privacyUrl, hideAppName, refreshBranding } = useBranding();
|
||||
const navigate = useNavigate();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -24,13 +26,34 @@ export default function Admin() {
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [showCreateUser, setShowCreateUser] = useState(false);
|
||||
const [creatingUser, setCreatingUser] = useState(false);
|
||||
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'user' });
|
||||
const [newUser, setNewUser] = useState({ name: '', display_name: '', email: '', password: '', role: 'user' });
|
||||
const menuBtnRefs = useRef({});
|
||||
const [menuPos, setMenuPos] = useState(null);
|
||||
|
||||
// Invite state
|
||||
const [invites, setInvites] = useState([]);
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [sendingInvite, setSendingInvite] = useState(false);
|
||||
const [savingRegMode, setSavingRegMode] = useState(false);
|
||||
|
||||
// Branding state
|
||||
const [editAppName, setEditAppName] = useState('');
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||
const logoInputRef = useRef(null);
|
||||
const [editDefaultTheme, setEditDefaultTheme] = useState('');
|
||||
const [savingDefaultTheme, setSavingDefaultTheme] = useState(false);
|
||||
const [editImprintUrl, setEditImprintUrl] = useState('');
|
||||
const [savingImprintUrl, setSavingImprintUrl] = useState(false);
|
||||
const [editPrivacyUrl, setEditPrivacyUrl] = useState('');
|
||||
const [savingPrivacyUrl, setSavingPrivacyUrl] = useState(false);
|
||||
const [savingHideAppName, setSavingHideAppName] = useState(false);
|
||||
|
||||
// OAuth state
|
||||
const [oauthConfig, setOauthConfig] = useState(null);
|
||||
const [oauthLoading, setOauthLoading] = useState(true);
|
||||
const [oauthForm, setOauthForm] = useState({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true });
|
||||
const [savingOauth, setSavingOauth] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role !== 'admin') {
|
||||
@@ -38,12 +61,26 @@ export default function Admin() {
|
||||
return;
|
||||
}
|
||||
fetchUsers();
|
||||
fetchInvites();
|
||||
fetchOauthConfig();
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditAppName(appName || 'Redlight');
|
||||
}, [appName]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditDefaultTheme(defaultTheme || 'dark');
|
||||
}, [defaultTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditImprintUrl(imprintUrl || '');
|
||||
}, [imprintUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditPrivacyUrl(privacyUrl || '');
|
||||
}, [privacyUrl]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await api.get('/admin/users');
|
||||
@@ -55,6 +92,15 @@ export default function Admin() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchInvites = async () => {
|
||||
try {
|
||||
const res = await api.get('/admin/invites');
|
||||
setInvites(res.data.invites);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (userId, newRole) => {
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/role`, { role: newRole });
|
||||
@@ -64,6 +110,7 @@ export default function Admin() {
|
||||
toast.error(err.response?.data?.error || t('admin.roleUpdateFailed'));
|
||||
}
|
||||
setOpenMenu(null);
|
||||
setMenuPos(null);
|
||||
};
|
||||
|
||||
const handleDelete = async (userId, userName) => {
|
||||
@@ -76,6 +123,7 @@ export default function Admin() {
|
||||
toast.error(err.response?.data?.error || t('admin.userDeleteFailed'));
|
||||
}
|
||||
setOpenMenu(null);
|
||||
setMenuPos(null);
|
||||
};
|
||||
|
||||
const handleResetPassword = async (e) => {
|
||||
@@ -121,6 +169,18 @@ export default function Admin() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleHideAppNameToggle = async (value) => {
|
||||
setSavingHideAppName(true);
|
||||
try {
|
||||
await api.put('/branding/hide-app-name', { hideAppName: value });
|
||||
refreshBranding();
|
||||
} catch {
|
||||
toast.error(t('admin.hideAppNameFailed'));
|
||||
} finally {
|
||||
setSavingHideAppName(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppNameSave = async () => {
|
||||
if (!editAppName.trim()) return;
|
||||
setSavingName(true);
|
||||
@@ -135,6 +195,20 @@ export default function Admin() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDefaultThemeSave = async () => {
|
||||
if (!editDefaultTheme) return;
|
||||
setSavingDefaultTheme(true);
|
||||
try {
|
||||
await api.put('/branding/default-theme', { defaultTheme: editDefaultTheme });
|
||||
toast.success(t('admin.defaultThemeSaved'));
|
||||
refreshBranding();
|
||||
} catch {
|
||||
toast.error(t('admin.defaultThemeUpdateFailed'));
|
||||
} finally {
|
||||
setSavingDefaultTheme(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateUser = async (e) => {
|
||||
e.preventDefault();
|
||||
setCreatingUser(true);
|
||||
@@ -142,7 +216,7 @@ export default function Admin() {
|
||||
await api.post('/admin/users', newUser);
|
||||
toast.success(t('admin.userCreated'));
|
||||
setShowCreateUser(false);
|
||||
setNewUser({ name: '', email: '', password: '', role: 'user' });
|
||||
setNewUser({ name: '', display_name: '', email: '', password: '', role: 'user' });
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.userCreateFailed'));
|
||||
@@ -151,8 +225,130 @@ export default function Admin() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendInvite = async (e) => {
|
||||
e.preventDefault();
|
||||
setSendingInvite(true);
|
||||
try {
|
||||
const res = await api.post('/admin/invites', { email: inviteEmail });
|
||||
toast.success(t('admin.inviteSent'));
|
||||
setInviteEmail('');
|
||||
fetchInvites();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.inviteFailed'));
|
||||
} finally {
|
||||
setSendingInvite(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteInvite = async (id) => {
|
||||
try {
|
||||
await api.delete(`/admin/invites/${id}`);
|
||||
toast.success(t('admin.inviteDeleted'));
|
||||
fetchInvites();
|
||||
} catch {
|
||||
toast.error(t('admin.inviteDeleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyInviteLink = (token) => {
|
||||
const baseUrl = window.location.origin;
|
||||
navigator.clipboard.writeText(`${baseUrl}/register?invite=${token}`);
|
||||
toast.success(t('admin.inviteLinkCopied'));
|
||||
};
|
||||
|
||||
const handleRegModeChange = async (mode) => {
|
||||
setSavingRegMode(true);
|
||||
try {
|
||||
await api.put('/branding/registration-mode', { registrationMode: mode });
|
||||
toast.success(t('admin.regModeSaved'));
|
||||
refreshBranding();
|
||||
} catch {
|
||||
toast.error(t('admin.regModeFailed'));
|
||||
} finally {
|
||||
setSavingRegMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImprintUrlSave = async () => {
|
||||
setSavingImprintUrl(true);
|
||||
try {
|
||||
await api.put('/branding/imprint-url', { imprintUrl: editImprintUrl.trim() });
|
||||
toast.success(t('admin.imprintUrlSaved'));
|
||||
refreshBranding();
|
||||
} catch {
|
||||
toast.error(t('admin.imprintUrlFailed'));
|
||||
} finally {
|
||||
setSavingImprintUrl(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrivacyUrlSave = async () => {
|
||||
setSavingPrivacyUrl(true);
|
||||
try {
|
||||
await api.put('/branding/privacy-url', { privacyUrl: editPrivacyUrl.trim() });
|
||||
toast.success(t('admin.privacyUrlSaved'));
|
||||
refreshBranding();
|
||||
} catch {
|
||||
toast.error(t('admin.privacyUrlFailed'));
|
||||
} finally {
|
||||
setSavingPrivacyUrl(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── OAuth handlers ──────────────────────────────────────────────────────
|
||||
const fetchOauthConfig = async () => {
|
||||
setOauthLoading(true);
|
||||
try {
|
||||
const res = await api.get('/admin/oauth');
|
||||
if (res.data.configured) {
|
||||
setOauthConfig(res.data.config);
|
||||
setOauthForm({
|
||||
issuer: res.data.config.issuer || '',
|
||||
clientId: res.data.config.clientId || '',
|
||||
clientSecret: '',
|
||||
displayName: res.data.config.displayName || 'SSO',
|
||||
autoRegister: res.data.config.autoRegister ?? true,
|
||||
});
|
||||
} else {
|
||||
setOauthConfig(null);
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setOauthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOauthSave = async (e) => {
|
||||
e.preventDefault();
|
||||
setSavingOauth(true);
|
||||
try {
|
||||
await api.put('/admin/oauth', oauthForm);
|
||||
toast.success(t('admin.oauthSaved'));
|
||||
fetchOauthConfig();
|
||||
refreshBranding();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.oauthSaveFailed'));
|
||||
} finally {
|
||||
setSavingOauth(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOauthRemove = async () => {
|
||||
if (!confirm(t('admin.oauthRemoveConfirm'))) return;
|
||||
try {
|
||||
await api.delete('/admin/oauth');
|
||||
toast.success(t('admin.oauthRemoved'));
|
||||
setOauthConfig(null);
|
||||
setOauthForm({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true });
|
||||
refreshBranding();
|
||||
} catch {
|
||||
toast.error(t('admin.oauthRemoveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(u =>
|
||||
u.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
@@ -264,8 +460,334 @@ export default function Admin() {
|
||||
{savingName ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
{hasLogo && (
|
||||
<div className="flex items-center justify-between mt-3 p-3 rounded-lg bg-th-bg-s border border-th-border">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-th-text">{t('admin.hideAppNameLabel')}</p>
|
||||
<p className="text-xs text-th-text-s mt-0.5">{t('admin.hideAppNameHint')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={savingHideAppName}
|
||||
onClick={() => handleHideAppNameToggle(!hideAppName)}
|
||||
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-1 disabled:opacity-50 ml-4 ${
|
||||
hideAppName ? 'bg-th-accent' : 'bg-th-border'
|
||||
}`}
|
||||
aria-checked={hideAppName}
|
||||
role="switch"
|
||||
>
|
||||
<span className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
hideAppName ? 'translate-x-4' : 'translate-x-0'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default theme */}
|
||||
<div className="mt-6 pt-6 border-t border-th-border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Palette size={16} className="text-th-accent" />
|
||||
<label className="block text-sm font-medium text-th-text">{t('admin.defaultThemeLabel')}</label>
|
||||
</div>
|
||||
<p className="text-xs text-th-text-s mb-3">{t('admin.defaultThemeDesc')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={editDefaultTheme}
|
||||
onChange={e => setEditDefaultTheme(e.target.value)}
|
||||
className="input-field text-sm flex-1"
|
||||
>
|
||||
{themes.map(th => (
|
||||
<option key={th.id} value={th.id}>
|
||||
{th.name} ({th.type === 'light' ? t('themes.light') : t('themes.dark')})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleDefaultThemeSave}
|
||||
disabled={savingDefaultTheme || editDefaultTheme === (defaultTheme || 'dark')}
|
||||
className="btn-primary text-sm px-4 flex-shrink-0"
|
||||
>
|
||||
{savingDefaultTheme ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legal links */}
|
||||
<div className="mt-6 pt-6 border-t border-th-border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<LinkIcon size={16} className="text-th-accent" />
|
||||
<label className="block text-sm font-medium text-th-text">{t('admin.legalLinksTitle')}</label>
|
||||
</div>
|
||||
<p className="text-xs text-th-text-s mb-4">{t('admin.legalLinksDesc')}</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Imprint */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.imprintUrl')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={editImprintUrl}
|
||||
onChange={e => setEditImprintUrl(e.target.value)}
|
||||
className="input-field text-sm flex-1"
|
||||
placeholder="https://example.com/imprint"
|
||||
maxLength={500}
|
||||
/>
|
||||
<button
|
||||
onClick={handleImprintUrlSave}
|
||||
disabled={savingImprintUrl || editImprintUrl === (imprintUrl || '')}
|
||||
className="btn-primary text-sm px-4 flex-shrink-0"
|
||||
>
|
||||
{savingImprintUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Privacy Policy */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.privacyUrl')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={editPrivacyUrl}
|
||||
onChange={e => setEditPrivacyUrl(e.target.value)}
|
||||
className="input-field text-sm flex-1"
|
||||
placeholder="https://example.com/privacy"
|
||||
maxLength={500}
|
||||
/>
|
||||
<button
|
||||
onClick={handlePrivacyUrlSave}
|
||||
disabled={savingPrivacyUrl || editPrivacyUrl === (privacyUrl || '')}
|
||||
className="btn-primary text-sm px-4 flex-shrink-0"
|
||||
>
|
||||
{savingPrivacyUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration Mode */}
|
||||
<div className="card p-6 mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<ShieldCheck size={20} className="text-th-accent" />
|
||||
<h2 className="text-lg font-semibold text-th-text">{t('admin.regModeTitle')}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s mb-5">{t('admin.regModeDescription')}</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleRegModeChange('open')}
|
||||
disabled={savingRegMode}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border text-sm font-medium transition-colors ${
|
||||
registrationMode === 'open'
|
||||
? 'border-th-accent bg-th-accent/10 text-th-accent'
|
||||
: 'border-th-border text-th-text-s hover:bg-th-hover'
|
||||
}`}
|
||||
>
|
||||
<Globe size={16} />
|
||||
{t('admin.regModeOpen')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRegModeChange('invite')}
|
||||
disabled={savingRegMode}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border text-sm font-medium transition-colors ${
|
||||
registrationMode === 'invite'
|
||||
? 'border-th-accent bg-th-accent/10 text-th-accent'
|
||||
: 'border-th-border text-th-text-s hover:bg-th-hover'
|
||||
}`}
|
||||
>
|
||||
<Mail size={16} />
|
||||
{t('admin.regModeInvite')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Invites */}
|
||||
<div className="card p-6 mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Send size={20} className="text-th-accent" />
|
||||
<h2 className="text-lg font-semibold text-th-text">{t('admin.inviteTitle')}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s mb-5">{t('admin.inviteDescription')}</p>
|
||||
|
||||
{/* Send invite form */}
|
||||
<form onSubmit={handleSendInvite} className="flex items-center gap-2 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={e => setInviteEmail(e.target.value)}
|
||||
className="input-field pl-9 text-sm"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sendingInvite || !inviteEmail.trim()}
|
||||
className="btn-primary text-sm px-4 flex-shrink-0"
|
||||
>
|
||||
{sendingInvite ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
|
||||
{t('admin.sendInvite')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Invite list */}
|
||||
{invites.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{invites.map(inv => {
|
||||
const isExpired = new Date(inv.expires_at) < new Date();
|
||||
const isUsed = !!inv.used_at;
|
||||
return (
|
||||
<div key={inv.id} className="flex items-center justify-between gap-3 p-3 rounded-xl bg-th-bg border border-th-border">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
isUsed ? 'bg-green-500/15 text-green-400' : isExpired ? 'bg-red-500/15 text-red-400' : 'bg-th-accent/15 text-th-accent'
|
||||
}`}>
|
||||
{isUsed ? <Check size={14} /> : isExpired ? <XIcon size={14} /> : <Clock size={14} />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-th-text truncate">{inv.email}</p>
|
||||
<p className="text-xs text-th-text-s">
|
||||
{isUsed
|
||||
? `${t('admin.inviteUsedBy')} ${inv.used_by_name}`
|
||||
: isExpired
|
||||
? t('admin.inviteExpired')
|
||||
: `${t('admin.inviteExpiresAt')} ${new Date(inv.expires_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{!isUsed && !isExpired && (
|
||||
<button
|
||||
onClick={() => handleCopyInviteLink(inv.token)}
|
||||
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||
title={t('admin.copyInviteLink')}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteInvite(inv.id)}
|
||||
className="p-1.5 rounded-lg hover:bg-th-hover text-th-error transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invites.length === 0 && (
|
||||
<p className="text-sm text-th-text-s text-center py-4">{t('admin.noInvites')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OAuth / SSO Configuration */}
|
||||
<div className="card p-6 mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<LogIn size={20} className="text-th-accent" />
|
||||
<h2 className="text-lg font-semibold text-th-text">{t('admin.oauthTitle')}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s mb-5">{t('admin.oauthDescription')}</p>
|
||||
|
||||
{oauthLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 size={20} className="animate-spin text-th-accent" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleOauthSave} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthIssuer')}</label>
|
||||
<input
|
||||
type="url"
|
||||
value={oauthForm.issuer}
|
||||
onChange={e => setOauthForm(f => ({ ...f, issuer: e.target.value }))}
|
||||
className="input-field text-sm"
|
||||
placeholder="https://auth.example.com/realms/main"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-th-text-s mt-1">{t('admin.oauthIssuerHint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientId')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={oauthForm.clientId}
|
||||
onChange={e => setOauthForm(f => ({ ...f, clientId: e.target.value }))}
|
||||
className="input-field text-sm"
|
||||
placeholder="redlight"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientSecret')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oauthForm.clientSecret}
|
||||
onChange={e => setOauthForm(f => ({ ...f, clientSecret: e.target.value }))}
|
||||
className="input-field text-sm"
|
||||
placeholder={oauthConfig?.hasClientSecret ? '••••••••' : ''}
|
||||
/>
|
||||
{oauthConfig?.hasClientSecret && (
|
||||
<p className="text-xs text-th-text-s mt-1">{t('admin.oauthClientSecretHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthDisplayName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={oauthForm.displayName}
|
||||
onChange={e => setOauthForm(f => ({ ...f, displayName: e.target.value }))}
|
||||
className="input-field text-sm"
|
||||
placeholder="Company SSO"
|
||||
maxLength={50}
|
||||
/>
|
||||
<p className="text-xs text-th-text-s mt-1">{t('admin.oauthDisplayNameHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={oauthForm.autoRegister}
|
||||
onChange={e => setOauthForm(f => ({ ...f, autoRegister: e.target.checked }))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-th-border rounded-full peer peer-checked:bg-th-accent transition-colors after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full" />
|
||||
</label>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-th-text">{t('admin.oauthAutoRegister')}</span>
|
||||
<p className="text-xs text-th-text-s">{t('admin.oauthAutoRegisterHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button type="submit" disabled={savingOauth} className="btn-primary text-sm px-5">
|
||||
{savingOauth ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||
{t('admin.oauthSave')}
|
||||
</button>
|
||||
{oauthConfig && (
|
||||
<button type="button" onClick={handleOauthRemove} className="btn-secondary text-sm px-5 text-red-400 hover:text-red-300">
|
||||
<Trash2 size={14} />
|
||||
{t('admin.oauthRemove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
@@ -321,12 +843,12 @@ export default function Admin() {
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
u.name[0]?.toUpperCase()
|
||||
(u.display_name || u.name)[0]?.toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-th-text">{u.name}</p>
|
||||
<p className="text-xs text-th-text-s">{u.email}</p>
|
||||
<p className="text-sm font-medium text-th-text">{u.display_name || u.name}</p>
|
||||
<p className="text-xs text-th-text-s">@{u.name} · {u.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -347,43 +869,32 @@ export default function Admin() {
|
||||
{new Date(u.created_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center justify-end relative">
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
onClick={() => setOpenMenu(openMenu === u.id ? null : u.id)}
|
||||
ref={el => { menuBtnRefs.current[u.id] = el; }}
|
||||
onClick={() => {
|
||||
if (openMenu === u.id) {
|
||||
setOpenMenu(null);
|
||||
setMenuPos(null);
|
||||
} else {
|
||||
const rect = menuBtnRefs.current[u.id]?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const menuHeight = 130;
|
||||
const spaceAbove = rect.top;
|
||||
if (spaceAbove >= menuHeight) {
|
||||
setMenuPos({ top: rect.top - menuHeight - 4, left: rect.right - 192 });
|
||||
} else {
|
||||
setMenuPos({ top: rect.bottom + 4, left: rect.right - 192 });
|
||||
}
|
||||
}
|
||||
setOpenMenu(u.id);
|
||||
}
|
||||
}}
|
||||
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||
disabled={u.id === user.id}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
|
||||
{openMenu === u.id && u.id !== user.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpenMenu(null)} />
|
||||
<div className="absolute right-0 top-8 z-20 w-48 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => handleRoleChange(u.id, u.role === 'admin' ? 'user' : 'admin')}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||
>
|
||||
{u.role === 'admin' ? <UserX size={14} /> : <UserCheck size={14} />}
|
||||
{u.role === 'admin' ? t('admin.makeUser') : t('admin.makeAdmin')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setResetPwModal(u.id); setOpenMenu(null); }}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||
>
|
||||
<Key size={14} />
|
||||
{t('admin.resetPassword')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(u.id, u.name)}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-error hover:bg-th-hover transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('admin.deleteUser')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -400,6 +911,43 @@ export default function Admin() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context menu portal */}
|
||||
{openMenu && menuPos && openMenu !== user.id && (() => {
|
||||
const u = users.find(u => u.id === openMenu);
|
||||
if (!u) return null;
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => { setOpenMenu(null); setMenuPos(null); }} />
|
||||
<div
|
||||
className="fixed z-50 w-48 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden"
|
||||
style={{ top: menuPos.top, left: menuPos.left }}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleRoleChange(u.id, u.role === 'admin' ? 'user' : 'admin')}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||
>
|
||||
{u.role === 'admin' ? <UserX size={14} /> : <UserCheck size={14} />}
|
||||
{u.role === 'admin' ? t('admin.makeUser') : t('admin.makeAdmin')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setResetPwModal(u.id); setOpenMenu(null); setMenuPos(null); }}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||
>
|
||||
<Key size={14} />
|
||||
{t('admin.resetPassword')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { handleDelete(u.id, u.name); setMenuPos(null); }}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-error hover:bg-th-hover transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('admin.deleteUser')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Reset password modal */}
|
||||
{resetPwModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
@@ -440,7 +988,7 @@ export default function Admin() {
|
||||
<h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.createUserTitle')}</h3>
|
||||
<form onSubmit={handleCreateUser} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.name')}</label>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
|
||||
<div className="relative">
|
||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
@@ -448,10 +996,24 @@ export default function Admin() {
|
||||
value={newUser.name}
|
||||
onChange={e => setNewUser({ ...newUser, name: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.namePlaceholder')}
|
||||
placeholder={t('auth.usernamePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
|
||||
<div className="relative">
|
||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={newUser.display_name}
|
||||
onChange={e => setNewUser({ ...newUser, display_name: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.displayNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
||||
|
||||
888
src/pages/Calendar.jsx
Normal file
888
src/pages/Calendar.jsx
Normal file
@@ -0,0 +1,888 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
ChevronLeft, ChevronRight, Plus, Clock, Video, Bell,
|
||||
Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import Modal from '../components/Modal';
|
||||
import DateTimePicker from '../components/DateTimePicker';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const COLORS = ['#6366f1', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||
|
||||
export default function Calendar() {
|
||||
const { user } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [view, setView] = useState('month'); // month | week
|
||||
const [rooms, setRooms] = useState([]);
|
||||
|
||||
// Modal state
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showDetail, setShowDetail] = useState(null);
|
||||
const [editingEvent, setEditingEvent] = useState(null);
|
||||
const [showShare, setShowShare] = useState(null);
|
||||
const [showFedShare, setShowFedShare] = useState(null);
|
||||
|
||||
// Create/Edit form
|
||||
const [form, setForm] = useState({
|
||||
title: '', description: '', start_time: '', end_time: '',
|
||||
room_uid: '', color: '#6366f1', reminder_minutes: null,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Share state
|
||||
const [shareSearch, setShareSearch] = useState('');
|
||||
const [shareResults, setShareResults] = useState([]);
|
||||
const [sharedUsers, setSharedUsers] = useState([]);
|
||||
const [pendingInvitations, setPendingInvitations] = useState([]);
|
||||
const [fedAddress, setFedAddress] = useState('');
|
||||
const [fedSending, setFedSending] = useState(false);
|
||||
|
||||
// Load events on month change
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
}, [currentDate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms();
|
||||
}, []);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
const from = new Date(year, month - 1, 1).toISOString();
|
||||
const to = new Date(year, month + 2, 0).toISOString();
|
||||
const res = await api.get(`/calendar/events?from=${from}&to=${to}`);
|
||||
setEvents(res.data.events || []);
|
||||
} catch {
|
||||
toast.error(t('calendar.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
const res = await api.get('/rooms');
|
||||
setRooms(res.data.rooms || []);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
// Calendar grid computation
|
||||
const calendarDays = useMemo(() => {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
// Start from Monday (ISO week)
|
||||
let startOffset = firstDay.getDay() - 1;
|
||||
if (startOffset < 0) startOffset = 6;
|
||||
const calStart = new Date(year, month, 1 - startOffset);
|
||||
|
||||
const days = [];
|
||||
const current = new Date(calStart);
|
||||
for (let i = 0; i < 42; i++) {
|
||||
days.push(new Date(current));
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
return days;
|
||||
}, [currentDate]);
|
||||
|
||||
const weekDays = useMemo(() => {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
const date = currentDate.getDate();
|
||||
const dayOfWeek = currentDate.getDay();
|
||||
let mondayOffset = dayOfWeek - 1;
|
||||
if (mondayOffset < 0) mondayOffset = 6;
|
||||
const monday = new Date(year, month, date - mondayOffset);
|
||||
const days = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
days.push(new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + i));
|
||||
}
|
||||
return days;
|
||||
}, [currentDate]);
|
||||
|
||||
const eventsForDay = (day) => {
|
||||
const dayStr = toLocalDateStr(day);
|
||||
return events.filter(ev => {
|
||||
const start = toLocalDateStr(new Date(ev.start_time));
|
||||
const end = toLocalDateStr(new Date(ev.end_time));
|
||||
return dayStr >= start && dayStr <= end;
|
||||
});
|
||||
};
|
||||
|
||||
const isToday = (day) => {
|
||||
const today = new Date();
|
||||
return day.toDateString() === today.toDateString();
|
||||
};
|
||||
|
||||
const isCurrentMonth = (day) => {
|
||||
return day.getMonth() === currentDate.getMonth();
|
||||
};
|
||||
|
||||
const navigatePrev = () => {
|
||||
const d = new Date(currentDate);
|
||||
if (view === 'month') d.setMonth(d.getMonth() - 1);
|
||||
else d.setDate(d.getDate() - 7);
|
||||
setCurrentDate(d);
|
||||
};
|
||||
|
||||
const navigateNext = () => {
|
||||
const d = new Date(currentDate);
|
||||
if (view === 'month') d.setMonth(d.getMonth() + 1);
|
||||
else d.setDate(d.getDate() + 7);
|
||||
setCurrentDate(d);
|
||||
};
|
||||
|
||||
const goToToday = () => setCurrentDate(new Date());
|
||||
|
||||
const monthLabel = currentDate.toLocaleString('default', { month: 'long', year: 'numeric' });
|
||||
|
||||
const openCreateForDay = (day) => {
|
||||
const start = new Date(day);
|
||||
start.setHours(9, 0, 0, 0);
|
||||
const end = new Date(day);
|
||||
end.setHours(10, 0, 0, 0);
|
||||
|
||||
setForm({
|
||||
title: '', description: '',
|
||||
start_time: toLocalDateTimeStr(start),
|
||||
end_time: toLocalDateTimeStr(end),
|
||||
room_uid: '', color: '#6366f1', reminder_minutes: null,
|
||||
});
|
||||
setEditingEvent(null);
|
||||
setShowCreate(true);
|
||||
};
|
||||
|
||||
const openEdit = (ev) => {
|
||||
setForm({
|
||||
title: ev.title,
|
||||
description: ev.description || '',
|
||||
start_time: toLocalDateTimeStr(new Date(ev.start_time)),
|
||||
end_time: toLocalDateTimeStr(new Date(ev.end_time)),
|
||||
room_uid: ev.room_uid || '',
|
||||
color: ev.color || '#6366f1',
|
||||
reminder_minutes: ev.reminder_minutes ?? null,
|
||||
});
|
||||
setEditingEvent(ev);
|
||||
setShowDetail(null);
|
||||
setShowCreate(true);
|
||||
};
|
||||
|
||||
const handleSave = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = {
|
||||
...form,
|
||||
start_time: new Date(form.start_time).toISOString(),
|
||||
end_time: new Date(form.end_time).toISOString(),
|
||||
};
|
||||
|
||||
if (editingEvent) {
|
||||
await api.put(`/calendar/events/${editingEvent.id}`, data);
|
||||
toast.success(t('calendar.eventUpdated'));
|
||||
} else {
|
||||
await api.post('/calendar/events', data);
|
||||
toast.success(t('calendar.eventCreated'));
|
||||
}
|
||||
setShowCreate(false);
|
||||
setEditingEvent(null);
|
||||
fetchEvents();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('calendar.saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (ev) => {
|
||||
if (!confirm(t('calendar.deleteConfirm'))) return;
|
||||
try {
|
||||
await api.delete(`/calendar/events/${ev.id}`);
|
||||
toast.success(t('calendar.eventDeleted'));
|
||||
setShowDetail(null);
|
||||
fetchEvents();
|
||||
} catch {
|
||||
toast.error(t('calendar.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadICS = async (ev) => {
|
||||
try {
|
||||
const res = await api.get(`/calendar/events/${ev.id}/ics`, { responseType: 'blob' });
|
||||
const url = window.URL.createObjectURL(res.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${ev.title}.ics`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success(t('calendar.icsDownloaded'));
|
||||
} catch {
|
||||
toast.error(t('calendar.icsFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const buildOutlookUrl = (ev) => {
|
||||
const start = new Date(ev.start_time);
|
||||
const end = new Date(ev.end_time);
|
||||
const fmt = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
const baseUrl = window.location.origin;
|
||||
const location = ev.room_uid ? `${baseUrl}/join/${ev.room_uid}` : '';
|
||||
const body = [ev.description || '', location ? `\n\nMeeting: ${location}` : ''].join('');
|
||||
const params = new URLSearchParams({
|
||||
rru: 'addevent',
|
||||
subject: ev.title,
|
||||
startdt: start.toISOString(),
|
||||
enddt: end.toISOString(),
|
||||
body: body.trim(),
|
||||
location,
|
||||
allday: 'false',
|
||||
path: '/calendar/action/compose',
|
||||
});
|
||||
return `https://outlook.live.com/calendar/0/action/compose?${params.toString()}`;
|
||||
};
|
||||
|
||||
const buildGoogleCalUrl = (ev) => {
|
||||
const fmt = (d) => new Date(d).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
const baseUrl = window.location.origin;
|
||||
const location = ev.room_uid ? `${baseUrl}/join/${ev.room_uid}` : '';
|
||||
const details = [ev.description || '', location ? `\nMeeting: ${location}` : ''].join('');
|
||||
const params = new URLSearchParams({
|
||||
action: 'TEMPLATE',
|
||||
text: ev.title,
|
||||
dates: `${fmt(ev.start_time)}/${fmt(ev.end_time)}`,
|
||||
details: details.trim(),
|
||||
location,
|
||||
});
|
||||
return `https://calendar.google.com/calendar/render?${params.toString()}`;
|
||||
};
|
||||
|
||||
// Share functions
|
||||
const openShareModal = async (ev) => {
|
||||
setShowShare(ev);
|
||||
setShareSearch('');
|
||||
setShareResults([]);
|
||||
setPendingInvitations([]);
|
||||
try {
|
||||
const res = await api.get(`/calendar/events/${ev.id}`);
|
||||
setSharedUsers(res.data.sharedUsers || []);
|
||||
setPendingInvitations(res.data.pendingInvitations || []);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const searchUsers = async (query) => {
|
||||
setShareSearch(query);
|
||||
if (query.length < 2) { setShareResults([]); return; }
|
||||
try {
|
||||
const res = await api.get(`/rooms/users/search?q=${encodeURIComponent(query)}`);
|
||||
const sharedIds = new Set(sharedUsers.map(u => u.id));
|
||||
const pendingIds = new Set(pendingInvitations.map(u => u.user_id));
|
||||
setShareResults(res.data.users.filter(u => !sharedIds.has(u.id) && !pendingIds.has(u.id)));
|
||||
} catch { setShareResults([]); }
|
||||
};
|
||||
|
||||
const handleShare = async (userId) => {
|
||||
if (!showShare) return;
|
||||
try {
|
||||
const res = await api.post(`/calendar/events/${showShare.id}/share`, { user_id: userId });
|
||||
setSharedUsers(res.data.sharedUsers);
|
||||
setPendingInvitations(res.data.pendingInvitations || []);
|
||||
setShareSearch('');
|
||||
setShareResults([]);
|
||||
toast.success(t('calendar.invitationSent'));
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('calendar.shareFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnshare = async (userId) => {
|
||||
if (!showShare) return;
|
||||
try {
|
||||
const res = await api.delete(`/calendar/events/${showShare.id}/share/${userId}`);
|
||||
setSharedUsers(res.data.sharedUsers);
|
||||
setPendingInvitations(res.data.pendingInvitations || []);
|
||||
toast.success(t('calendar.shareRemoved'));
|
||||
} catch { toast.error(t('calendar.shareFailed')); }
|
||||
};
|
||||
|
||||
const handleCancelInvitation = async (userId) => {
|
||||
if (!showShare) return;
|
||||
try {
|
||||
const res = await api.delete(`/calendar/events/${showShare.id}/share/${userId}`);
|
||||
setSharedUsers(res.data.sharedUsers);
|
||||
setPendingInvitations(res.data.pendingInvitations || []);
|
||||
toast.success(t('calendar.invitationCancelled'));
|
||||
} catch { toast.error(t('calendar.shareFailed')); }
|
||||
};
|
||||
|
||||
const handleFedSend = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!showFedShare) return;
|
||||
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
|
||||
if (!normalized.includes('@') || normalized.endsWith('@')) {
|
||||
toast.error(t('federation.addressHint'));
|
||||
return;
|
||||
}
|
||||
setFedSending(true);
|
||||
try {
|
||||
await api.post(`/calendar/events/${showFedShare.id}/federation`, { to: fedAddress });
|
||||
toast.success(t('calendar.fedSent'));
|
||||
setShowFedShare(null);
|
||||
setFedAddress('');
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('calendar.fedFailed'));
|
||||
} finally {
|
||||
setFedSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dayNames = [
|
||||
t('calendar.mon'), t('calendar.tue'), t('calendar.wed'),
|
||||
t('calendar.thu'), t('calendar.fri'), t('calendar.sat'), t('calendar.sun'),
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 size={32} className="animate-spin text-th-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-th-text">{t('calendar.title')}</h1>
|
||||
<p className="text-sm text-th-text-s mt-1">{t('calendar.subtitle')}</p>
|
||||
</div>
|
||||
<button onClick={() => {
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours() + 1, 0, 0, 0);
|
||||
const end = new Date(now);
|
||||
end.setHours(end.getHours() + 1);
|
||||
setForm({
|
||||
title: '', description: '',
|
||||
start_time: toLocalDateTimeStr(now),
|
||||
end_time: toLocalDateTimeStr(end),
|
||||
room_uid: '', color: '#6366f1',
|
||||
});
|
||||
setEditingEvent(null);
|
||||
setShowCreate(true);
|
||||
}} className="btn-primary">
|
||||
<Plus size={18} />
|
||||
<span className="hidden sm:inline">{t('calendar.newEvent')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="card p-3 mb-4 flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={navigatePrev} className="btn-ghost p-2">
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<button onClick={goToToday} className="btn-ghost text-sm px-3 py-1.5">
|
||||
{t('calendar.today')}
|
||||
</button>
|
||||
<button onClick={navigateNext} className="btn-ghost p-2">
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold text-th-text ml-2">{monthLabel}</h2>
|
||||
</div>
|
||||
<div className="flex items-center bg-th-bg-s rounded-lg border border-th-border p-0.5">
|
||||
<button
|
||||
onClick={() => setView('month')}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
view === 'month' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
|
||||
}`}
|
||||
>
|
||||
{t('calendar.month')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('week')}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
view === 'week' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
|
||||
}`}
|
||||
>
|
||||
{t('calendar.week')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className="card overflow-hidden">
|
||||
{/* Day headers */}
|
||||
<div className="grid grid-cols-7 border-b border-th-border">
|
||||
{dayNames.map((name, i) => (
|
||||
<div key={i} className="py-2.5 text-center text-xs font-semibold text-th-text-s uppercase tracking-wider border-r border-th-border last:border-r-0">
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Days */}
|
||||
{view === 'month' ? (
|
||||
<div className="grid grid-cols-7">
|
||||
{calendarDays.map((day, i) => {
|
||||
const dayEvents = eventsForDay(day);
|
||||
const today = isToday(day);
|
||||
const inMonth = isCurrentMonth(day);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => openCreateForDay(day)}
|
||||
className={`min-h-[100px] p-1.5 border-r border-b border-th-border last:border-r-0 cursor-pointer hover:bg-th-hover/50 transition-colors
|
||||
${!inMonth ? 'opacity-40' : ''}`}
|
||||
>
|
||||
<div className={`text-xs font-medium mb-1 w-6 h-6 flex items-center justify-center rounded-full
|
||||
${today ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s'}`}>
|
||||
{day.getDate()}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{dayEvents.slice(0, 3).map(ev => (
|
||||
<div
|
||||
key={ev.id}
|
||||
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
|
||||
className="text-[10px] leading-tight px-1.5 py-0.5 rounded truncate text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
|
||||
style={{ backgroundColor: ev.color || '#6366f1' }}
|
||||
title={ev.title}
|
||||
>
|
||||
{formatTime(ev.start_time)} {ev.title}
|
||||
</div>
|
||||
))}
|
||||
{dayEvents.length > 3 && (
|
||||
<div className="text-[10px] text-th-text-s font-medium px-1.5">
|
||||
+{dayEvents.length - 3} {t('calendar.more')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* Week view */
|
||||
<div className="grid grid-cols-7">
|
||||
{weekDays.map((day, i) => {
|
||||
const dayEvents = eventsForDay(day);
|
||||
const today = isToday(day);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => openCreateForDay(day)}
|
||||
className="min-h-[300px] p-2 border-r border-b border-th-border last:border-r-0 cursor-pointer hover:bg-th-hover/50 transition-colors"
|
||||
>
|
||||
<div className={`text-sm font-medium mb-2 w-7 h-7 flex items-center justify-center rounded-full
|
||||
${today ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s'}`}>
|
||||
{day.getDate()}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{dayEvents.map(ev => (
|
||||
<div
|
||||
key={ev.id}
|
||||
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
|
||||
className="text-xs px-2 py-1.5 rounded text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
|
||||
style={{ backgroundColor: ev.color || '#6366f1' }}
|
||||
>
|
||||
<div className="flex items-center gap-1 truncate">
|
||||
{ev.reminder_minutes && <Bell size={9} className="flex-shrink-0 opacity-70" />}
|
||||
<span className="truncate">{ev.title}</span>
|
||||
</div>
|
||||
<div className="opacity-80 text-[10px]">{formatTime(ev.start_time)} - {formatTime(ev.end_time)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{showCreate && (
|
||||
<Modal title={editingEvent ? t('calendar.editEvent') : t('calendar.createEvent')} onClose={() => { setShowCreate(false); setEditingEvent(null); }}>
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.eventTitle')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={e => setForm({ ...form, title: e.target.value })}
|
||||
className="input-field"
|
||||
placeholder={t('calendar.eventTitlePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.description')}</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={e => setForm({ ...form, description: e.target.value })}
|
||||
className="input-field resize-none"
|
||||
rows={2}
|
||||
placeholder={t('calendar.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DateTimePicker
|
||||
label={t('calendar.startTime')}
|
||||
value={form.start_time}
|
||||
onChange={v => setForm({ ...form, start_time: v })}
|
||||
required
|
||||
icon="calendar"
|
||||
/>
|
||||
<DateTimePicker
|
||||
label={t('calendar.endTime')}
|
||||
value={form.end_time}
|
||||
onChange={v => setForm({ ...form, end_time: v })}
|
||||
required
|
||||
icon="clock"
|
||||
minDate={form.start_time ? new Date(form.start_time) : null}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 -mt-2 text-xs text-th-text-s">
|
||||
<Globe size={12} className="flex-shrink-0" />
|
||||
<span>{getLocalTimezone()}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.linkedRoom')}</label>
|
||||
<select
|
||||
value={form.room_uid}
|
||||
onChange={e => setForm({ ...form, room_uid: e.target.value })}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="">{t('calendar.noRoom')}</option>
|
||||
{rooms.map(r => (
|
||||
<option key={r.uid} value={r.uid}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-th-text-s mt-1">{t('calendar.linkedRoomHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.reminderLabel')}</label>
|
||||
<div className="relative">
|
||||
<Bell size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none" />
|
||||
<select
|
||||
value={form.reminder_minutes ?? ''}
|
||||
onChange={e => setForm({ ...form, reminder_minutes: e.target.value === '' ? null : Number(e.target.value) })}
|
||||
className="input-field pl-9"
|
||||
>
|
||||
<option value="">{t('calendar.reminderNone')}</option>
|
||||
<option value="5">{t('calendar.reminder5')}</option>
|
||||
<option value="15">{t('calendar.reminder15')}</option>
|
||||
<option value="30">{t('calendar.reminder30')}</option>
|
||||
<option value="60">{t('calendar.reminder60')}</option>
|
||||
<option value="120">{t('calendar.reminder120')}</option>
|
||||
<option value="1440">{t('calendar.reminder1440')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.color')}</label>
|
||||
<div className="flex gap-2">
|
||||
{COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, color: c })}
|
||||
className={`w-7 h-7 rounded-full border-2 transition-all ${form.color === c ? 'border-th-text scale-110' : 'border-transparent'}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-th-border">
|
||||
<button type="button" onClick={() => { setShowCreate(false); setEditingEvent(null); }} className="btn-secondary flex-1">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="btn-primary flex-1">
|
||||
{saving ? <Loader2 size={18} className="animate-spin" /> : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Event Detail Modal */}
|
||||
{showDetail && (
|
||||
<Modal title={showDetail.title} onClose={() => setShowDetail(null)}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-th-text-s">
|
||||
<Clock size={14} />
|
||||
<span>
|
||||
{new Date(showDetail.start_time).toLocaleString()} - {new Date(showDetail.end_time).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-th-text-s opacity-70 -mt-2">
|
||||
<Globe size={12} />
|
||||
<span>{getLocalTimezone()}</span>
|
||||
</div>
|
||||
|
||||
{showDetail.description && (
|
||||
<p className="text-sm text-th-text">{showDetail.description}</p>
|
||||
)}
|
||||
|
||||
{showDetail.room_uid && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Video size={14} className="text-th-accent" />
|
||||
<a
|
||||
href={`/rooms/${showDetail.room_uid}`}
|
||||
className="text-th-accent hover:underline"
|
||||
onClick={(e) => { e.preventDefault(); window.location.href = `/rooms/${showDetail.room_uid}`; }}
|
||||
>
|
||||
{t('calendar.openRoom')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDetail.federated_from && (
|
||||
<div className="flex items-center gap-2 text-xs text-th-text-s">
|
||||
<Globe size={12} />
|
||||
<span>{t('calendar.federatedFrom')}: {showDetail.federated_from}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDetail.federated_join_url && (
|
||||
<a
|
||||
href={showDetail.federated_join_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-primary text-sm w-full justify-center"
|
||||
>
|
||||
<Video size={14} />
|
||||
{t('calendar.joinFederatedMeeting')}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{showDetail.organizer_name && (
|
||||
<div className="text-xs text-th-text-s">
|
||||
{t('calendar.organizer')}: {showDetail.organizer_name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap items-center gap-2 pt-4 border-t border-th-border">
|
||||
<a
|
||||
href={buildOutlookUrl(showDetail)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-ghost text-xs py-1.5 px-3 inline-flex items-center gap-1.5 no-underline"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('calendar.addToOutlook')}
|
||||
</a>
|
||||
<a
|
||||
href={buildGoogleCalUrl(showDetail)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-ghost text-xs py-1.5 px-3 inline-flex items-center gap-1.5 no-underline"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('calendar.addToGoogleCalendar')}
|
||||
</a>
|
||||
<button onClick={() => handleDownloadICS(showDetail)} className="btn-ghost text-xs py-1.5 px-3">
|
||||
<Download size={14} />
|
||||
{t('calendar.downloadICS')}
|
||||
</button>
|
||||
|
||||
{showDetail.is_owner && (
|
||||
<>
|
||||
<button onClick={() => openEdit(showDetail)} className="btn-ghost text-xs py-1.5 px-3">
|
||||
<Edit size={14} />
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<button onClick={() => openShareModal(showDetail)} className="btn-ghost text-xs py-1.5 px-3">
|
||||
<Share2 size={14} />
|
||||
{t('calendar.share')}
|
||||
</button>
|
||||
<button onClick={() => { setShowFedShare(showDetail); setShowDetail(null); }} className="btn-ghost text-xs py-1.5 px-3">
|
||||
<Globe size={14} />
|
||||
{t('calendar.sendFederated')}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(showDetail)} className="btn-ghost text-xs py-1.5 px-3 text-th-error hover:text-th-error">
|
||||
<Trash2 size={14} />
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Share Modal */}
|
||||
{showShare && (
|
||||
<Modal title={t('calendar.shareEvent')} onClose={() => setShowShare(null)}>
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<UserPlus size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={shareSearch}
|
||||
onChange={e => searchUsers(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('room.shareSearchPlaceholder')}
|
||||
/>
|
||||
{shareResults.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-th-card border border-th-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||
{shareResults.map(u => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => handleShare(u.id)}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
|
||||
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
||||
>
|
||||
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
|
||||
<div className="text-xs text-th-text-s truncate">{u.email}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendingInvitations.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-th-text-s uppercase tracking-wider">{t('calendar.pendingInvitations')}</p>
|
||||
{pendingInvitations.map(u => (
|
||||
<div key={u.user_id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border border-dashed">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
|
||||
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
||||
>
|
||||
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
|
||||
<div className="text-xs text-th-warning">{t('calendar.invitationPending')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => handleCancelInvitation(u.user_id)} className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sharedUsers.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{pendingInvitations.length > 0 && (
|
||||
<p className="text-xs font-semibold text-th-text-s uppercase tracking-wider">{t('calendar.accepted')}</p>
|
||||
)}
|
||||
{sharedUsers.map(u => (
|
||||
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
|
||||
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
||||
>
|
||||
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
|
||||
<div className="text-xs text-th-text-s truncate">{u.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => handleUnshare(u.id)} className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Federation Share Modal */}
|
||||
{showFedShare && (
|
||||
<Modal title={t('calendar.sendFederatedTitle')} onClose={() => setShowFedShare(null)}>
|
||||
<p className="text-sm text-th-text-s mb-4">{t('calendar.sendFederatedDesc')}</p>
|
||||
<form onSubmit={handleFedSend} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fedAddress}
|
||||
onChange={e => setFedAddress(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder={t('federation.addressPlaceholder')}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-2 border-t border-th-border">
|
||||
<button type="button" onClick={() => setShowFedShare(null)} className="btn-secondary flex-1">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={fedSending} className="btn-primary flex-1">
|
||||
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||
{t('calendar.send')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function toLocalDateStr(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function toLocalDateTimeStr(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const h = String(date.getHours()).padStart(2, '0');
|
||||
const min = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}T${h}:${min}`;
|
||||
}
|
||||
|
||||
function formatTime(dateStr) {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function getLocalTimezone() {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch {
|
||||
const offset = -new Date().getTimezoneOffset();
|
||||
const sign = offset >= 0 ? '+' : '-';
|
||||
const h = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0');
|
||||
const m = String(Math.abs(offset) % 60).padStart(2, '0');
|
||||
return `UTC${sign}${h}:${m}`;
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,14 @@ import { Plus, Video, Loader2, LayoutGrid, List } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import RoomCard from '../components/RoomCard';
|
||||
import FederatedRoomCard from '../components/FederatedRoomCard';
|
||||
import Modal from '../components/Modal';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useLanguage();
|
||||
const [rooms, setRooms] = useState([]);
|
||||
const [federatedRooms, setFederatedRooms] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [viewMode, setViewMode] = useState('grid');
|
||||
@@ -33,8 +35,18 @@ export default function Dashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFederatedRooms = async () => {
|
||||
try {
|
||||
const res = await api.get('/federation/federated-rooms');
|
||||
setFederatedRooms(res.data.rooms || []);
|
||||
} catch {
|
||||
// Federation may not be enabled
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms();
|
||||
fetchFederatedRooms();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
@@ -118,7 +130,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
|
||||
{/* Room grid/list */}
|
||||
{rooms.length === 0 ? (
|
||||
{rooms.length === 0 && federatedRooms.length === 0 ? (
|
||||
<div className="card p-12 text-center">
|
||||
<Video size={48} className="mx-auto text-th-text-s/40 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-th-text mb-2">{t('dashboard.noRooms')}</h3>
|
||||
@@ -160,6 +172,23 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Federated rooms (from other instances) */}
|
||||
{federatedRooms.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-semibold text-th-text mb-1">{t('dashboard.federatedRooms')}</h2>
|
||||
<p className="text-sm text-th-text-s mb-4">{t('dashboard.federatedRoomsSubtitle')}</p>
|
||||
<div className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||
: 'space-y-3'
|
||||
}>
|
||||
{federatedRooms.map(room => (
|
||||
<FederatedRoomCard key={room.id} room={room} onRemove={fetchFederatedRooms} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -176,6 +205,7 @@ export default function Dashboard() {
|
||||
className="input-field"
|
||||
placeholder={t('dashboard.roomNamePlaceholder')}
|
||||
required
|
||||
minLength={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
215
src/pages/FederatedRoomDetail.jsx
Normal file
215
src/pages/FederatedRoomDetail.jsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, Globe, ExternalLink, Trash2, Hash, Users,
|
||||
Video, VideoOff, Loader2, Link2, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function FederatedRoomDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLanguage();
|
||||
|
||||
const [room, setRoom] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = async () => {
|
||||
try {
|
||||
const res = await api.get('/federation/federated-rooms');
|
||||
const found = (res.data.rooms || []).find(r => String(r.id) === String(id));
|
||||
if (!found) {
|
||||
toast.error(t('room.notFound'));
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
}
|
||||
setRoom(found);
|
||||
} catch {
|
||||
toast.error(t('room.notFound'));
|
||||
navigate('/dashboard');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetch();
|
||||
}, [id]);
|
||||
|
||||
const handleJoin = () => {
|
||||
// Validate URL scheme to prevent javascript: or other malicious URIs
|
||||
try {
|
||||
const url = new URL(room.join_url);
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
toast.error(t('federation.invalidJoinUrl'));
|
||||
return;
|
||||
}
|
||||
window.open(room.join_url, '_blank');
|
||||
} catch {
|
||||
toast.error(t('federation.invalidJoinUrl'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!confirm(t('federation.removeRoomConfirm'))) return;
|
||||
setRemoving(true);
|
||||
try {
|
||||
await api.delete(`/federation/federated-rooms/${room.id}`);
|
||||
toast.success(t('federation.roomRemoved'));
|
||||
navigate('/dashboard');
|
||||
} catch {
|
||||
toast.error(t('federation.roomRemoveFailed'));
|
||||
setRemoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 size={32} className="animate-spin text-th-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!room) return null;
|
||||
|
||||
const recordingOn = room.allow_recording === 1 || room.allow_recording === true;
|
||||
const isDeleted = room.deleted === 1 || room.deleted === true;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Back */}
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="flex items-center gap-2 text-sm text-th-text-s hover:text-th-text transition-colors mb-6"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
{t('federation.backToDashboard')}
|
||||
</button>
|
||||
|
||||
{/* Deleted banner */}
|
||||
{isDeleted && (
|
||||
<div className="card p-4 mb-4 border-red-500/30 bg-red-500/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle size={20} className="text-red-500 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-red-500">{t('federation.roomDeleted')}</p>
|
||||
<p className="text-xs text-th-text-s mt-0.5">{t('federation.roomDeletedNotice')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="card p-6 mb-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${isDeleted ? 'bg-red-500/15' : 'bg-th-accent/15'}`}>
|
||||
{isDeleted ? <AlertTriangle size={20} className="text-red-500" /> : <Globe size={20} className="text-th-accent" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-th-text truncate">{room.room_name}</h1>
|
||||
{isDeleted ? (
|
||||
<span className="px-2 py-0.5 bg-red-500/15 text-red-500 rounded-full text-xs font-medium">
|
||||
{t('federation.roomDeleted')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
|
||||
{t('federation.federated')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s mt-1">
|
||||
{t('federation.from')}: <span className="font-medium text-th-text">{room.from_user}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{!isDeleted && (
|
||||
<button
|
||||
onClick={handleJoin}
|
||||
className="btn-primary"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
{t('federation.joinMeeting')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
disabled={removing}
|
||||
className="btn-ghost text-th-error hover:text-th-error px-3"
|
||||
title={t('federation.removeRoom')}
|
||||
>
|
||||
{removing ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||
{/* Max participants */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center gap-2 mb-1 text-xs font-semibold text-th-text-s uppercase tracking-wider">
|
||||
<Users size={13} />
|
||||
{t('federation.maxParticipants')}
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-th-text">
|
||||
{room.max_participants > 0 ? room.max_participants : '∞'}
|
||||
</p>
|
||||
<p className="text-xs text-th-text-s mt-0.5">
|
||||
{room.max_participants > 0 ? t('federation.participantLimit') : t('federation.unlimited')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Recording */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center gap-2 mb-1 text-xs font-semibold text-th-text-s uppercase tracking-wider">
|
||||
{recordingOn ? <Video size={13} /> : <VideoOff size={13} />}
|
||||
{t('federation.recordingLabel')}
|
||||
</div>
|
||||
<p className={`text-lg font-bold ${recordingOn ? 'text-amber-400' : 'text-th-text-s'}`}>
|
||||
{recordingOn ? t('federation.recordingOn') : t('federation.recordingOff')}
|
||||
</p>
|
||||
<p className="text-xs text-th-text-s mt-0.5">
|
||||
{recordingOn ? t('federation.recordingOnHint') : t('federation.recordingOffHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="card p-5 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider">
|
||||
{t('federation.roomDetails')}
|
||||
</h2>
|
||||
|
||||
{room.meet_id && (
|
||||
<div className="flex items-start gap-3">
|
||||
<Hash size={16} className="text-th-accent flex-shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-th-text-s mb-0.5">{t('federation.meetingId')}</p>
|
||||
<p className="text-sm font-mono text-th-text break-all">{room.meet_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Link2 size={16} className="text-th-accent flex-shrink-0 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-th-text-s mb-0.5">{t('federation.joinUrl')}</p>
|
||||
<p className="text-sm font-mono text-th-text break-all opacity-60 select-all">{room.join_url}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read-only notice */}
|
||||
<p className="text-xs text-th-text-s mt-4 text-center italic">
|
||||
{isDeleted ? t('federation.roomDeletedNotice') : t('federation.readOnlyNotice')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
407
src/pages/FederationInbox.jsx
Normal file
407
src/pages/FederationInbox.jsx
Normal file
@@ -0,0 +1,407 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Globe, Mail, Check, X, ExternalLink, Loader2, Inbox, Calendar, Trash2 } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function FederationInbox() {
|
||||
const { t } = useLanguage();
|
||||
const [invitations, setInvitations] = useState([]);
|
||||
const [calendarInvitations, setCalendarInvitations] = useState([]);
|
||||
const [localCalInvitations, setLocalCalInvitations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchInvitations = async () => {
|
||||
try {
|
||||
const [roomRes, calRes, localCalRes] = await Promise.all([
|
||||
api.get('/federation/invitations'),
|
||||
api.get('/federation/calendar-invitations').catch(() => ({ data: { invitations: [] } })),
|
||||
api.get('/calendar/local-invitations').catch(() => ({ data: { invitations: [] } })),
|
||||
]);
|
||||
setInvitations(roomRes.data.invitations || []);
|
||||
setCalendarInvitations(calRes.data.invitations || []);
|
||||
setLocalCalInvitations(localCalRes.data.invitations || []);
|
||||
} catch {
|
||||
toast.error(t('federation.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvitations();
|
||||
}, []);
|
||||
|
||||
// ── Room invitation actions ──────────────────────────────────────────────
|
||||
const handleAccept = async (id) => {
|
||||
try {
|
||||
await api.post(`/federation/invitations/${id}/accept`);
|
||||
toast.success(t('federation.acceptedSaved'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.acceptFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecline = async (id) => {
|
||||
try {
|
||||
await api.delete(`/federation/invitations/${id}`);
|
||||
toast.success(t('federation.declined'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.declineFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteInvitation = async (id) => {
|
||||
try {
|
||||
await api.delete(`/federation/invitations/${id}`);
|
||||
toast.success(t('federation.invitationRemoved'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.declineFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// ── Calendar invitation actions ──────────────────────────────────────────
|
||||
const handleCalAccept = async (id) => {
|
||||
try {
|
||||
await api.post(`/federation/calendar-invitations/${id}/accept`);
|
||||
toast.success(t('federation.calendarAccepted'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.acceptFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCalDecline = async (id) => {
|
||||
try {
|
||||
await api.delete(`/federation/calendar-invitations/${id}`);
|
||||
toast.success(t('federation.declined'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.declineFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCalDelete = async (id) => {
|
||||
try {
|
||||
await api.delete(`/federation/calendar-invitations/${id}`);
|
||||
toast.success(t('federation.invitationRemoved'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.declineFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// ── Local calendar invitation actions ───────────────────────────────────
|
||||
const handleLocalCalAccept = async (id) => {
|
||||
try {
|
||||
await api.post(`/calendar/local-invitations/${id}/accept`);
|
||||
toast.success(t('federation.calendarLocalAccepted'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.acceptFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalCalDecline = async (id) => {
|
||||
try {
|
||||
await api.delete(`/calendar/local-invitations/${id}`);
|
||||
toast.success(t('federation.declined'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.declineFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalCalDelete = async (id) => {
|
||||
try {
|
||||
await api.delete(`/calendar/local-invitations/${id}`);
|
||||
toast.success(t('federation.invitationRemoved'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.declineFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 size={32} className="animate-spin text-th-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pendingRooms = invitations.filter(i => i.status === 'pending');
|
||||
const pastRooms = invitations.filter(i => i.status !== 'pending');
|
||||
const pendingCal = calendarInvitations.filter(i => i.status === 'pending');
|
||||
const pastCal = calendarInvitations.filter(i => i.status !== 'pending');
|
||||
const pendingLocalCal = localCalInvitations.filter(i => i.status === 'pending');
|
||||
const pastLocalCal = localCalInvitations.filter(i => i.status !== 'pending');
|
||||
|
||||
const totalPending = pendingRooms.length + pendingCal.length + pendingLocalCal.length;
|
||||
const totalPast = pastRooms.length + pastCal.length + pastLocalCal.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<Globe size={24} className="text-th-accent" />
|
||||
<h1 className="text-2xl font-bold text-th-text">{t('federation.inbox')}</h1>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s mt-1">{t('federation.inboxSubtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Pending invitations */}
|
||||
{totalPending > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
|
||||
{t('federation.pending')} ({totalPending})
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{/* Pending room invitations */}
|
||||
{pendingRooms.map(inv => (
|
||||
<div key={`room-${inv.id}`} className="card p-5 border-l-4 border-l-th-accent">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Mail size={16} className="text-th-accent flex-shrink-0" />
|
||||
<h3 className="text-base font-semibold text-th-text truncate">{inv.room_name}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s">
|
||||
{t('federation.from')}: <span className="font-medium text-th-text">{inv.from_user}</span>
|
||||
</p>
|
||||
{inv.message && (
|
||||
<p className="text-sm text-th-text-s mt-2 italic">"{inv.message}"</p>
|
||||
)}
|
||||
<p className="text-xs text-th-text-s mt-1">
|
||||
{new Date(inv.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button onClick={() => handleAccept(inv.id)} className="btn-primary text-sm">
|
||||
<Check size={16} />
|
||||
{t('federation.accept')}
|
||||
</button>
|
||||
<button onClick={() => handleDecline(inv.id)} className="btn-secondary text-sm">
|
||||
<X size={16} />
|
||||
{t('federation.decline')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Pending calendar invitations */}
|
||||
{pendingCal.map(inv => (
|
||||
<div key={`cal-${inv.id}`} className="card p-5 border-l-4 border-l-th-success">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Calendar size={16} className="text-th-success flex-shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-th-success mr-1">
|
||||
{t('federation.calendarEvent')}
|
||||
</span>
|
||||
<h3 className="text-base font-semibold text-th-text truncate">{inv.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s">
|
||||
{t('federation.from')}: <span className="font-medium text-th-text">{inv.from_user}</span>
|
||||
</p>
|
||||
<p className="text-sm text-th-text-s mt-1">
|
||||
{new Date(inv.start_time).toLocaleString()} - {new Date(inv.end_time).toLocaleString()}
|
||||
</p>
|
||||
{inv.description && (
|
||||
<p className="text-sm text-th-text-s mt-1 italic">"{inv.description}"</p>
|
||||
)}
|
||||
<p className="text-xs text-th-text-s mt-1">
|
||||
{new Date(inv.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button onClick={() => handleCalAccept(inv.id)} className="btn-primary text-sm">
|
||||
<Check size={16} />
|
||||
{t('federation.accept')}
|
||||
</button>
|
||||
<button onClick={() => handleCalDecline(inv.id)} className="btn-secondary text-sm">
|
||||
<X size={16} />
|
||||
{t('federation.decline')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Pending local calendar invitations */}
|
||||
{pendingLocalCal.map(inv => (
|
||||
<div key={`localcal-${inv.id}`} className="card p-5 border-l-4 border-l-th-accent">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Calendar size={16} className="text-th-accent flex-shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-th-accent mr-1">
|
||||
{t('federation.localCalendarEvent')}
|
||||
</span>
|
||||
<h3 className="text-base font-semibold text-th-text truncate">{inv.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s">
|
||||
{t('federation.from')}: <span className="font-medium text-th-text">{inv.from_name}</span>
|
||||
</p>
|
||||
<p className="text-sm text-th-text-s mt-1">
|
||||
{new Date(inv.start_time).toLocaleString()} - {new Date(inv.end_time).toLocaleString()}
|
||||
</p>
|
||||
{inv.description && (
|
||||
<p className="text-sm text-th-text-s mt-1 italic">"{inv.description}"</p>
|
||||
)}
|
||||
<p className="text-xs text-th-text-s mt-1">
|
||||
{new Date(inv.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button onClick={() => handleLocalCalAccept(inv.id)} className="btn-primary text-sm">
|
||||
<Check size={16} />
|
||||
{t('federation.accept')}
|
||||
</button>
|
||||
<button onClick={() => handleLocalCalDecline(inv.id)} className="btn-secondary text-sm">
|
||||
<X size={16} />
|
||||
{t('federation.decline')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Past invitations */}
|
||||
{totalPast > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
|
||||
{t('federation.previousInvites')}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{/* Past room invitations */}
|
||||
{pastRooms.map(inv => (
|
||||
<div key={`room-past-${inv.id}`} className="card p-4 opacity-70">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<Mail size={14} className="text-th-text-s flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3>
|
||||
<p className="text-xs text-th-text-s">{inv.from_user}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
|
||||
? 'bg-th-success/15 text-th-success'
|
||||
: 'bg-th-error/15 text-th-error'
|
||||
}`}>
|
||||
{inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')}
|
||||
</span>
|
||||
{inv.status === 'accepted' && (
|
||||
<button
|
||||
onClick={() => window.open(inv.join_url, '_blank')}
|
||||
className="btn-ghost text-xs py-1.5 px-2"
|
||||
title={t('federation.openLink')}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteInvitation(inv.id)}
|
||||
className="btn-ghost text-xs py-1.5 px-2 text-th-text-s hover:text-th-error"
|
||||
title={t('federation.removeInvitation')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Past calendar invitations */}
|
||||
{pastCal.map(inv => (
|
||||
<div key={`cal-past-${inv.id}`} className="card p-4 opacity-70">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<Calendar size={14} className="text-th-text-s flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
|
||||
<p className="text-xs text-th-text-s">{inv.from_user} · {new Date(inv.start_time).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
|
||||
? 'bg-th-success/15 text-th-success'
|
||||
: 'bg-th-error/15 text-th-error'
|
||||
}`}>
|
||||
{inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')}
|
||||
</span>
|
||||
{inv.status === 'accepted' && inv.join_url && (
|
||||
<button
|
||||
onClick={() => window.open(inv.join_url, '_blank')}
|
||||
className="btn-ghost text-xs py-1.5 px-2"
|
||||
title={t('federation.openLink')}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleCalDelete(inv.id)}
|
||||
className="btn-ghost text-xs py-1.5 px-2 text-th-text-s hover:text-th-error"
|
||||
title={t('federation.removeInvitation')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Past local calendar invitations */}
|
||||
{pastLocalCal.map(inv => (
|
||||
<div key={`localcal-past-${inv.id}`} className="card p-4 opacity-70">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<Calendar size={14} className="text-th-text-s flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
|
||||
<p className="text-xs text-th-text-s">{inv.from_name} · {new Date(inv.start_time).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
|
||||
? 'bg-th-success/15 text-th-success'
|
||||
: 'bg-th-error/15 text-th-error'
|
||||
}`}>
|
||||
{inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleLocalCalDelete(inv.id)}
|
||||
className="btn-ghost text-xs py-1.5 px-2 text-th-text-s hover:text-th-error"
|
||||
title={t('federation.removeInvitation')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{totalPending === 0 && totalPast === 0 && (
|
||||
<div className="card p-12 text-center">
|
||||
<Inbox size={48} className="mx-auto text-th-text-s/40 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-th-text mb-2">{t('federation.noInvitations')}</h3>
|
||||
<p className="text-sm text-th-text-s">{t('federation.noInvitationsSubtitle')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,56 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio } from 'lucide-react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, Link, useSearchParams } from 'react-router-dom';
|
||||
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText, Clock, X } from 'lucide-react';
|
||||
import BrandLogo from '../components/BrandLogo';
|
||||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useBranding } from '../contexts/BrandingContext';
|
||||
|
||||
export default function GuestJoin() {
|
||||
const { uid } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { t } = useLanguage();
|
||||
const { user } = useAuth();
|
||||
const { imprintUrl, privacyUrl } = useBranding();
|
||||
const isLoggedIn = !!user;
|
||||
const [roomInfo, setRoomInfo] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [joining, setJoining] = useState(false);
|
||||
const [name, setName] = useState(user?.name || '');
|
||||
const [accessCode, setAccessCode] = useState('');
|
||||
const [name, setName] = useState(user?.display_name || user?.name || '');
|
||||
const [accessCode, setAccessCode] = useState(searchParams.get('ac') || '');
|
||||
const [moderatorCode, setModeratorCode] = useState('');
|
||||
const [status, setStatus] = useState({ running: false });
|
||||
const [recordingConsent, setRecordingConsent] = useState(false);
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
const prevRunningRef = useRef(false);
|
||||
|
||||
const joinMeeting = async () => {
|
||||
setJoining(true);
|
||||
try {
|
||||
const res = await api.post(`/rooms/${uid}/guest-join`, {
|
||||
name: name.trim(),
|
||||
access_code: accessCode || undefined,
|
||||
moderator_code: moderatorCode || undefined,
|
||||
});
|
||||
if (res.data.joinUrl) {
|
||||
window.location.href = res.data.joinUrl;
|
||||
}
|
||||
} catch (err) {
|
||||
const errStatus = err.response?.status;
|
||||
if (errStatus === 403) {
|
||||
toast.error(t('room.guestWrongAccessCode'));
|
||||
setWaiting(false);
|
||||
} else {
|
||||
toast.error(t('room.guestJoinFailed'));
|
||||
setWaiting(false);
|
||||
}
|
||||
} finally {
|
||||
setJoining(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRoom = async () => {
|
||||
@@ -27,6 +58,7 @@ export default function GuestJoin() {
|
||||
const res = await api.get(`/rooms/${uid}/public`);
|
||||
setRoomInfo(res.data.room);
|
||||
setStatus({ running: res.data.running });
|
||||
prevRunningRef.current = res.data.running;
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
if (status === 403) {
|
||||
@@ -49,40 +81,36 @@ export default function GuestJoin() {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 10000);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [uid]);
|
||||
|
||||
// Auto-join when meeting starts while waiting
|
||||
useEffect(() => {
|
||||
if (!prevRunningRef.current && status.running && waiting) {
|
||||
new Audio('/sounds/meeting-started.mp3').play().catch(() => { });
|
||||
toast.success(t('room.guestMeetingStartedJoining'));
|
||||
joinMeeting();
|
||||
}
|
||||
prevRunningRef.current = status.running;
|
||||
}, [status.running]);
|
||||
|
||||
const handleJoin = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
toast.error(t('room.guestNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setJoining(true);
|
||||
try {
|
||||
const res = await api.post(`/rooms/${uid}/guest-join`, {
|
||||
name: name.trim(),
|
||||
access_code: accessCode || undefined,
|
||||
moderator_code: moderatorCode || undefined,
|
||||
});
|
||||
if (res.data.joinUrl) {
|
||||
window.location.href = res.data.joinUrl;
|
||||
if (roomInfo?.allow_recording && !recordingConsent) {
|
||||
toast.error(t('room.guestRecordingConsent'));
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
if (status === 403) {
|
||||
toast.error(t('room.guestWrongAccessCode'));
|
||||
} else if (status === 400) {
|
||||
toast.error(t('room.guestWaitingMessage'));
|
||||
} else {
|
||||
toast.error(t('room.guestJoinFailed'));
|
||||
}
|
||||
} finally {
|
||||
setJoining(false);
|
||||
if (!status.running && !roomInfo?.anyone_can_start) {
|
||||
setWaiting(true);
|
||||
return;
|
||||
}
|
||||
await joinMeeting();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -155,6 +183,33 @@ export default function GuestJoin() {
|
||||
</div>
|
||||
|
||||
{/* Join form */}
|
||||
{waiting ? (
|
||||
<div className="flex flex-col items-center gap-5 py-4">
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center bg-th-accent/10">
|
||||
<Clock size={28} className="text-th-accent animate-pulse" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-semibold text-th-text mb-1">{t('room.guestWaitingTitle')}</p>
|
||||
<p className="text-sm text-th-text-s">{t('room.guestWaitingHint')}</p>
|
||||
</div>
|
||||
{joining && (
|
||||
<div className="flex items-center gap-2 text-sm text-th-success font-medium">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{t('room.guestMeetingStartedJoining')}
|
||||
</div>
|
||||
)}
|
||||
{!joining && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWaiting(false)}
|
||||
className="btn-ghost flex items-center gap-2 text-sm"
|
||||
>
|
||||
<X size={16} />
|
||||
{t('room.guestCancelWaiting')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleJoin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label>
|
||||
@@ -206,9 +261,28 @@ export default function GuestJoin() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recording consent notice */}
|
||||
{roomInfo.allow_recording && (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle size={16} className="text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-amber-400">{t('room.guestRecordingNotice')}</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={recordingConsent}
|
||||
onChange={e => setRecordingConsent(e.target.checked)}
|
||||
className="w-4 h-4 rounded accent-amber-500 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-th-text">{t('room.guestRecordingConsent')}</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={joining || (!status.running && !roomInfo.anyone_can_start)}
|
||||
disabled={joining || (roomInfo.allow_recording && !recordingConsent)}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{joining ? (
|
||||
@@ -221,12 +295,13 @@ export default function GuestJoin() {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!status.running && (
|
||||
{!status.running && !roomInfo?.anyone_can_start && (
|
||||
<p className="text-xs text-th-text-s text-center">
|
||||
{t('room.guestWaitingMessage')}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!isLoggedIn && (
|
||||
<div className="mt-6 pt-4 border-t border-th-border text-center">
|
||||
@@ -235,6 +310,36 @@ export default function GuestJoin() {
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(imprintUrl || privacyUrl) && (
|
||||
<div className="flex items-center justify-center gap-4 mt-4 pt-4 border-t border-th-border/60">
|
||||
{imprintUrl && (
|
||||
<a
|
||||
href={imprintUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||
>
|
||||
<FileText size={11} />
|
||||
{t('nav.imprint')}
|
||||
</a>
|
||||
)}
|
||||
{imprintUrl && privacyUrl && (
|
||||
<span className="text-th-border text-xs">·</span>
|
||||
)}
|
||||
{privacyUrl && (
|
||||
<a
|
||||
href={privacyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||
>
|
||||
<Lock size={11} />
|
||||
{t('nav.privacy')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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, FileText, Lock } from 'lucide-react';
|
||||
import BrandLogo from '../components/BrandLogo';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useBranding } from '../contexts/BrandingContext';
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useLanguage();
|
||||
const { registrationMode, imprintUrl, privacyUrl } = useBranding();
|
||||
const isInviteOnly = registrationMode === 'invite';
|
||||
|
||||
const features = [
|
||||
{
|
||||
@@ -54,10 +57,12 @@ export default function Home() {
|
||||
<Link to="/login" className="btn-ghost text-sm">
|
||||
{t('auth.login')}
|
||||
</Link>
|
||||
{!isInviteOnly && (
|
||||
<Link to="/register" className="btn-primary text-sm">
|
||||
{t('auth.register')}
|
||||
<ArrowRight size={16} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -65,7 +70,7 @@ export default function Home() {
|
||||
<div className="relative z-10 max-w-4xl mx-auto text-center px-6 pt-20 pb-32">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-th-accent/10 text-th-accent text-sm font-medium mb-6">
|
||||
<Zap size={14} />
|
||||
{t('home.poweredBy')}
|
||||
{t('home.madeFor')}
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl md:text-6xl lg:text-7xl font-extrabold text-th-text mb-6 leading-tight tracking-tight">
|
||||
@@ -78,11 +83,13 @@ export default function Home() {
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 justify-center">
|
||||
{!isInviteOnly && (
|
||||
<Link to="/register" className="btn-primary text-base px-8 py-3">
|
||||
{t('home.getStarted')}
|
||||
<ArrowRight size={18} />
|
||||
</Link>
|
||||
<Link to="/login" className="btn-secondary text-base px-8 py-3">
|
||||
)}
|
||||
<Link to="/login" className={`${isInviteOnly ? 'btn-primary' : 'btn-secondary'} text-base px-8 py-3`}>
|
||||
{t('auth.login')}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -136,6 +143,35 @@ export default function Home() {
|
||||
<p className="text-sm text-th-text-s">
|
||||
{t('home.footer', { year: new Date().getFullYear() })}
|
||||
</p>
|
||||
{(imprintUrl || privacyUrl) && (
|
||||
<div className="flex items-center justify-center gap-4 mt-3">
|
||||
{imprintUrl && (
|
||||
<a
|
||||
href={imprintUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||
>
|
||||
<FileText size={12} />
|
||||
{t('nav.imprint')}
|
||||
</a>
|
||||
)}
|
||||
{imprintUrl && privacyUrl && (
|
||||
<span className="text-th-border text-xs">·</span>
|
||||
)}
|
||||
{privacyUrl && (
|
||||
<a
|
||||
href={privacyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||
>
|
||||
<Lock size={12} />
|
||||
{t('nav.privacy')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,33 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { useBranding } from '../contexts/BrandingContext';
|
||||
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw, LogIn, ShieldCheck } from 'lucide-react';
|
||||
import BrandLogo from '../components/BrandLogo';
|
||||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const [needsVerification, setNeedsVerification] = useState(false);
|
||||
const [resendCooldown, setResendCooldown] = useState(0);
|
||||
const [resending, setResending] = useState(false);
|
||||
// 2FA state
|
||||
const [needs2FA, setNeeds2FA] = useState(false);
|
||||
const [tempToken, setTempToken] = useState('');
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [verifying2FA, setVerifying2FA] = useState(false);
|
||||
const totpInputRef = useRef(null);
|
||||
|
||||
const { login, verify2FA } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (resendCooldown <= 0) return;
|
||||
const timer = setTimeout(() => setResendCooldown(c => c - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [resendCooldown]);
|
||||
|
||||
// Auto-focus TOTP input when 2FA screen appears
|
||||
useEffect(() => {
|
||||
if (needs2FA && totpInputRef.current) {
|
||||
totpInputRef.current.focus();
|
||||
}
|
||||
}, [needs2FA]);
|
||||
|
||||
const handleResend = async () => {
|
||||
if (resendCooldown > 0 || resending) return;
|
||||
setResending(true);
|
||||
try {
|
||||
await api.post('/auth/resend-verification', { email });
|
||||
toast.success(t('auth.emailVerificationResendSuccess'));
|
||||
setResendCooldown(60);
|
||||
} catch (err) {
|
||||
const wait = err.response?.data?.waitSeconds;
|
||||
if (wait) {
|
||||
setResendCooldown(wait);
|
||||
}
|
||||
toast.error(err.response?.data?.error || t('auth.emailVerificationResendFailed'));
|
||||
} finally {
|
||||
setResending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
const result = await login(email, password);
|
||||
if (result?.requires2FA) {
|
||||
setTempToken(result.tempToken);
|
||||
setNeeds2FA(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
toast.success(t('auth.loginSuccess'));
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
if (err.response?.data?.needsVerification) {
|
||||
setNeedsVerification(true);
|
||||
} else {
|
||||
toast.error(err.response?.data?.error || t('auth.loginFailed'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handle2FASubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setVerifying2FA(true);
|
||||
try {
|
||||
await verify2FA(tempToken, totpCode);
|
||||
toast.success(t('auth.loginSuccess'));
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('auth.2fa.verifyFailed'));
|
||||
setTotpCode('');
|
||||
} finally {
|
||||
setVerifying2FA(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setNeeds2FA(false);
|
||||
setTempToken('');
|
||||
setTotpCode('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
||||
{/* Animated background */}
|
||||
@@ -47,6 +122,64 @@ export default function Login() {
|
||||
<BrandLogo size="lg" />
|
||||
</div>
|
||||
|
||||
{needs2FA ? (
|
||||
<>
|
||||
{/* 2FA verification step */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-th-accent/10 mb-4">
|
||||
<ShieldCheck size={28} className="text-th-accent" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.2fa.title')}</h2>
|
||||
<p className="text-th-text-s text-sm">
|
||||
{t('auth.2fa.prompt')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handle2FASubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.2fa.codeLabel')}</label>
|
||||
<div className="relative">
|
||||
<ShieldCheck size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
ref={totpInputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={totpCode}
|
||||
onChange={e => setTotpCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
|
||||
className="input-field pl-11 text-center text-lg tracking-[0.3em] font-mono"
|
||||
placeholder="000 000"
|
||||
required
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={verifying2FA || totpCode.replace(/\s/g, '').length < 6}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{verifying2FA ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{t('auth.2fa.verify')}
|
||||
<ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="block mt-4 w-full text-center text-sm text-th-text-s hover:text-th-text transition-colors"
|
||||
>
|
||||
{t('auth.2fa.backToLogin')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.welcomeBack')}</h2>
|
||||
<p className="text-th-text-s">
|
||||
@@ -101,16 +234,59 @@ export default function Login() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{oauthEnabled && (
|
||||
<>
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-th-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="px-3 bg-th-card/80 text-th-text-s">{t('auth.orContinueWith')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/api/oauth/authorize"
|
||||
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
|
||||
>
|
||||
<LogIn size={18} />
|
||||
{t('auth.loginWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
{needsVerification && (
|
||||
<div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle size={16} className="text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleResend}
|
||||
disabled={resendCooldown > 0 || resending}
|
||||
className="flex items-center gap-1.5 text-sm text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors disabled:opacity-60 disabled:no-underline disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw size={13} className={resending ? 'animate-spin' : ''} />
|
||||
{resendCooldown > 0
|
||||
? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown)
|
||||
: t('auth.emailVerificationResend')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registrationMode !== 'invite' && (
|
||||
<p className="mt-6 text-center text-sm text-th-text-s">
|
||||
{t('auth.noAccount')}{' '}
|
||||
<Link to="/register" className="text-th-accent hover:underline font-medium">
|
||||
{t('auth.signUpNow')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
||||
{t('auth.backToHome')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
59
src/pages/NotFound.jsx
Normal file
59
src/pages/NotFound.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Ghost, ArrowLeft, Home } from 'lucide-react';
|
||||
|
||||
export default function NotFound() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
||||
{/* Animated background */}
|
||||
<div className="absolute inset-0 bg-th-bg">
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<div className="absolute top-1/3 left-1/3 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
|
||||
<div className="absolute bottom-1/3 right-1/3 w-72 h-72 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '3s' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-md text-center">
|
||||
<div className="card p-10 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
||||
{/* Ghost icon with subtle animation */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="w-20 h-20 bg-th-accent/10 rounded-full flex items-center justify-center animate-bounce" style={{ animationDuration: '2s' }}>
|
||||
<Ghost size={40} className="text-th-accent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 404 number */}
|
||||
<h1 className="text-7xl font-extrabold text-th-text mb-2 tracking-tight">404</h1>
|
||||
|
||||
<h2 className="text-xl font-semibold text-th-text mb-2">
|
||||
{t('notFound.title')}
|
||||
</h2>
|
||||
|
||||
<p className="text-th-text-s mb-8">
|
||||
{t('notFound.description')}
|
||||
</p>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="btn-secondary w-full sm:w-auto px-5 py-2.5 flex items-center justify-center gap-2"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
{t('notFound.goBack')}
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className="btn-primary w-full sm:w-auto px-5 py-2.5 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Home size={16} />
|
||||
{t('notFound.goHome')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/pages/OAuthCallback.jsx
Normal file
79
src/pages/OAuthCallback.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Loader2, AlertTriangle } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function OAuthCallback() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [error, setError] = useState(null);
|
||||
const { loginWithOAuth } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// Token is passed via hash fragment (never sent to server, not logged, not in Referer).
|
||||
// Error is still a regular query param since it contains no sensitive data.
|
||||
const hash = window.location.hash.slice(1); // strip leading '#'
|
||||
const hashParams = new URLSearchParams(hash);
|
||||
const token = hashParams.get('token');
|
||||
const errorMsg = searchParams.get('error');
|
||||
const returnTo = hashParams.get('return_to') || searchParams.get('return_to') || '/dashboard';
|
||||
|
||||
if (errorMsg) {
|
||||
setError(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
setError(t('auth.oauthNoToken'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Store token and redirect
|
||||
loginWithOAuth(token)
|
||||
.then(() => {
|
||||
toast.success(t('auth.loginSuccess'));
|
||||
navigate(returnTo, { replace: true });
|
||||
})
|
||||
.catch(() => {
|
||||
setError(t('auth.oauthLoginFailed'));
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6">
|
||||
<div className="absolute inset-0 bg-th-bg" />
|
||||
<div className="relative w-full max-w-md">
|
||||
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-12 h-12 bg-red-500/20 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle size={24} className="text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-th-text mb-2">{t('auth.oauthError')}</h2>
|
||||
<p className="text-th-text-s mb-6">{error}</p>
|
||||
<button
|
||||
onClick={() => navigate('/login', { replace: true })}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{t('auth.backToLogin')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6">
|
||||
<div className="absolute inset-0 bg-th-bg" />
|
||||
<div className="relative flex flex-col items-center gap-4">
|
||||
<Loader2 size={32} className="animate-spin text-th-accent" />
|
||||
<p className="text-th-text-s">{t('auth.oauthRedirecting')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle } from 'lucide-react';
|
||||
import { useBranding } from '../contexts/BrandingContext';
|
||||
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle, ShieldAlert, LogIn } from 'lucide-react';
|
||||
import BrandLogo from '../components/BrandLogo';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Register() {
|
||||
const [name, setName] = useState('');
|
||||
const [searchParams] = useSearchParams();
|
||||
const inviteToken = searchParams.get('invite') || '';
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
@@ -15,8 +19,12 @@ export default function Register() {
|
||||
const [needsVerification, setNeedsVerification] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Invite-only mode without a token → show blocked message
|
||||
const isBlocked = registrationMode === 'invite' && !inviteToken;
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -25,14 +33,14 @@ export default function Register() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
if (password.length < 8) {
|
||||
toast.error(t('auth.passwordTooShort'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await register(name, email, password);
|
||||
const result = await register(username, displayName, email, password, inviteToken);
|
||||
if (result?.needsVerification) {
|
||||
setNeedsVerification(true);
|
||||
toast.success(t('auth.verificationSent'));
|
||||
@@ -76,6 +84,15 @@ export default function Register() {
|
||||
{t('auth.login')}
|
||||
</Link>
|
||||
</div>
|
||||
) : isBlocked ? (
|
||||
<div className="text-center space-y-4">
|
||||
<ShieldAlert size={48} className="mx-auto text-amber-400" />
|
||||
<h2 className="text-2xl font-bold text-th-text">{t('auth.inviteOnly')}</h2>
|
||||
<p className="text-th-text-s">{t('auth.inviteOnlyDesc')}</p>
|
||||
<Link to="/login" className="btn-primary inline-flex items-center gap-2 mt-4">
|
||||
{t('auth.login')}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
@@ -87,15 +104,31 @@ export default function Register() {
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.name')}</label>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
|
||||
<div className="relative">
|
||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.namePlaceholder')}
|
||||
placeholder={t('auth.usernamePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
|
||||
<div className="relative">
|
||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.displayNamePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -164,6 +197,26 @@ export default function Register() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{oauthEnabled && (
|
||||
<>
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-th-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="px-3 bg-th-card/80 text-th-text-s">{t('auth.orContinueWith')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/api/oauth/authorize"
|
||||
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
|
||||
>
|
||||
<LogIn size={18} />
|
||||
{t('auth.registerWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-center text-sm text-th-text-s">
|
||||
{t('auth.hasAccount')}{' '}
|
||||
<Link to="/login" className="text-th-accent hover:underline font-medium">
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio,
|
||||
Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck,
|
||||
Shield, Save, UserPlus, X, Share2,
|
||||
Shield, Save, UserPlus, X, Share2, Globe, Send,
|
||||
FileText, Upload, Trash2, Link, BarChart3,
|
||||
} from 'lucide-react';
|
||||
import Modal from '../components/Modal';
|
||||
import api from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import RecordingList from '../components/RecordingList';
|
||||
import AnalyticsList from '../components/AnalyticsList';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function RoomDetail() {
|
||||
@@ -20,6 +23,7 @@ export default function RoomDetail() {
|
||||
const [room, setRoom] = useState(null);
|
||||
const [status, setStatus] = useState({ running: false, participantCount: 0, moderatorCount: 0 });
|
||||
const [recordings, setRecordings] = useState([]);
|
||||
const [analytics, setAnalytics] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
@@ -29,6 +33,31 @@ export default function RoomDetail() {
|
||||
const [shareSearch, setShareSearch] = useState('');
|
||||
const [shareResults, setShareResults] = useState([]);
|
||||
const [shareSearching, setShareSearching] = useState(false);
|
||||
const [waitingToJoin, setWaitingToJoin] = useState(false);
|
||||
const prevRunningRef = useRef(false);
|
||||
const [showCopyMenu, setShowCopyMenu] = useState(false);
|
||||
const copyMenuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (copyMenuRef.current && !copyMenuRef.current.contains(e.target)) {
|
||||
setShowCopyMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Federation invite state
|
||||
const [showFedInvite, setShowFedInvite] = useState(false);
|
||||
const [fedAddress, setFedAddress] = useState('');
|
||||
const [fedMessage, setFedMessage] = useState('');
|
||||
const [fedSending, setFedSending] = useState(false);
|
||||
|
||||
// Presentation state
|
||||
const [uploadingPresentation, setUploadingPresentation] = useState(false);
|
||||
const [removingPresentation, setRemovingPresentation] = useState(false);
|
||||
const presentationInputRef = useRef(null);
|
||||
|
||||
const isOwner = room && user && room.user_id === user.id;
|
||||
const isShared = room && !!room.shared;
|
||||
@@ -66,14 +95,39 @@ export default function RoomDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAnalytics = async () => {
|
||||
try {
|
||||
const res = await api.get(`/analytics/room/${uid}`);
|
||||
setAnalytics(res.data.analytics || []);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoom();
|
||||
fetchStatus();
|
||||
fetchRecordings();
|
||||
fetchAnalytics();
|
||||
const interval = setInterval(fetchStatus, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [uid]);
|
||||
|
||||
// Auto-join when meeting starts while waiting
|
||||
useEffect(() => {
|
||||
if (!prevRunningRef.current && status.running && waitingToJoin) {
|
||||
new Audio('/sounds/meeting-started.mp3').play().catch(() => {});
|
||||
toast.success(t('room.meetingStarted'));
|
||||
setWaitingToJoin(false);
|
||||
setActionLoading('join');
|
||||
api.post(`/rooms/${uid}/join`, {})
|
||||
.then(res => { if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank'); })
|
||||
.catch(err => toast.error(err.response?.data?.error || t('room.joinFailed')))
|
||||
.finally(() => setActionLoading(null));
|
||||
}
|
||||
prevRunningRef.current = status.running;
|
||||
}, [status.running]);
|
||||
|
||||
const handleStart = async () => {
|
||||
setActionLoading('start');
|
||||
try {
|
||||
@@ -91,6 +145,12 @@ export default function RoomDetail() {
|
||||
};
|
||||
|
||||
const handleJoin = async () => {
|
||||
if (!status.running) {
|
||||
setWaitingToJoin(true);
|
||||
toast(t('room.guestWaitingTitle'), { icon: '🕐' });
|
||||
return;
|
||||
}
|
||||
setWaitingToJoin(false);
|
||||
setActionLoading('join');
|
||||
try {
|
||||
const data = room.access_code ? { access_code: prompt(t('room.enterAccessCode')) } : {};
|
||||
@@ -135,6 +195,8 @@ export default function RoomDetail() {
|
||||
record_meeting: !!editRoom.record_meeting,
|
||||
guest_access: !!editRoom.guest_access,
|
||||
moderator_code: editRoom.moderator_code,
|
||||
learning_analytics: !!editRoom.learning_analytics,
|
||||
analytics_visibility: editRoom.analytics_visibility || 'owner',
|
||||
});
|
||||
setRoom(res.data.room);
|
||||
setEditRoom(res.data.room);
|
||||
@@ -146,9 +208,86 @@ export default function RoomDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const copyLink = () => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/rooms/${uid}`);
|
||||
const copyToClipboard = (url) => {
|
||||
navigator.clipboard.writeText(url);
|
||||
toast.success(t('room.linkCopied'));
|
||||
setShowCopyMenu(false);
|
||||
};
|
||||
|
||||
// Federation invite handler
|
||||
const handlePresentationUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/vnd.oasis.opendocument.presentation',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
toast.error('Unsupported file type. Allowed: PDF, PPT, PPTX, ODP, DOC, DOCX');
|
||||
return;
|
||||
}
|
||||
setUploadingPresentation(true);
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const res = await api.post(`/rooms/${uid}/presentation`, arrayBuffer, {
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
'X-Filename': encodeURIComponent(file.name),
|
||||
},
|
||||
});
|
||||
setRoom(res.data.room);
|
||||
setEditRoom(res.data.room);
|
||||
toast.success(t('room.presentationUploaded'));
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('room.presentationUploadFailed'));
|
||||
} finally {
|
||||
setUploadingPresentation(false);
|
||||
if (presentationInputRef.current) presentationInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresentationRemove = async () => {
|
||||
setRemovingPresentation(true);
|
||||
try {
|
||||
const res = await api.delete(`/rooms/${uid}/presentation`);
|
||||
setRoom(res.data.room);
|
||||
setEditRoom(res.data.room);
|
||||
toast.success(t('room.presentationRemoved'));
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('room.presentationRemoveFailed'));
|
||||
} finally {
|
||||
setRemovingPresentation(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFedInvite = async (e) => {
|
||||
e.preventDefault();
|
||||
// Accept @user@domain or user@domain — must have a domain part
|
||||
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
|
||||
if (!normalized.includes('@') || normalized.endsWith('@')) {
|
||||
toast.error(t('federation.addressHint'));
|
||||
return;
|
||||
}
|
||||
setFedSending(true);
|
||||
try {
|
||||
await api.post('/federation/invite', {
|
||||
room_uid: uid,
|
||||
to: fedAddress,
|
||||
message: fedMessage || undefined,
|
||||
});
|
||||
toast.success(t('federation.sent'));
|
||||
setShowFedInvite(false);
|
||||
setFedAddress('');
|
||||
setFedMessage('');
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('federation.sendFailed'));
|
||||
} finally {
|
||||
setFedSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Share functions
|
||||
@@ -206,6 +345,7 @@ export default function RoomDetail() {
|
||||
const tabs = [
|
||||
{ id: 'overview', label: t('room.overview'), icon: Play },
|
||||
{ id: 'recordings', label: t('room.recordings'), icon: FileVideo, count: recordings.length },
|
||||
{ id: 'analytics', label: t('room.analytics'), icon: BarChart3, count: analytics.length, hidden: !room.learning_analytics || (isShared && room.analytics_visibility !== 'shared') },
|
||||
...(isOwner ? [{ id: 'settings', label: t('room.settings'), icon: Settings }] : []),
|
||||
];
|
||||
|
||||
@@ -244,26 +384,62 @@ export default function RoomDetail() {
|
||||
{t('common.protected')}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={copyLink} className="flex items-center gap-1 hover:text-th-accent transition-colors">
|
||||
<div className="relative" ref={copyMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowCopyMenu(v => !v)}
|
||||
className="flex items-center gap-1 hover:text-th-accent transition-colors"
|
||||
>
|
||||
<Copy size={14} />
|
||||
{t('room.copyLink')}
|
||||
</button>
|
||||
{showCopyMenu && (
|
||||
<div className="absolute bottom-full left-0 mb-2 bg-th-surface border border-th-border rounded-lg shadow-lg z-50 min-w-[160px] py-1">
|
||||
<button
|
||||
onClick={() => copyToClipboard(`${window.location.origin}/rooms/${uid}`)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
|
||||
>
|
||||
<Link size={12} />
|
||||
{t('room.copyRoomLink')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => copyToClipboard(`${window.location.origin}/join/${uid}`)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
|
||||
>
|
||||
<Users size={12} />
|
||||
{t('room.copyGuestLink')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{canManage && !status.running && (
|
||||
{canManage && (
|
||||
<button
|
||||
onClick={() => setShowFedInvite(true)}
|
||||
className="btn-ghost text-sm"
|
||||
title={t('federation.inviteRemote')}
|
||||
>
|
||||
<Globe size={16} />
|
||||
<span className="hidden sm:inline">{t('federation.inviteRemote')}</span>
|
||||
</button>
|
||||
)}
|
||||
{canManage && !status.running && !waitingToJoin && (
|
||||
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
|
||||
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
|
||||
{t('room.start')}
|
||||
</button>
|
||||
)}
|
||||
{status.running && (
|
||||
<button onClick={handleJoin} disabled={actionLoading === 'join'} className="btn-primary">
|
||||
{actionLoading === 'join' ? <Loader2 size={16} className="animate-spin" /> : <ExternalLink size={16} />}
|
||||
{t('room.join')}
|
||||
<button
|
||||
onClick={waitingToJoin ? () => setWaitingToJoin(false) : handleJoin}
|
||||
disabled={actionLoading === 'join'}
|
||||
className={waitingToJoin ? 'btn-ghost' : 'btn-primary'}
|
||||
title={waitingToJoin ? t('room.guestCancelWaiting') : undefined}
|
||||
>
|
||||
{(actionLoading === 'join' || waitingToJoin) ? <Loader2 size={16} className="animate-spin" /> : <ExternalLink size={16} />}
|
||||
{waitingToJoin ? t('room.waitingToJoin') : t('room.join')}
|
||||
</button>
|
||||
)}
|
||||
{canManage && status.running && (
|
||||
<button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger">
|
||||
{actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />}
|
||||
@@ -276,12 +452,11 @@ export default function RoomDetail() {
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 mb-6 border-b border-th-border">
|
||||
{tabs.map(tab => (
|
||||
{tabs.filter(tab => !tab.hidden).map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${activeTab === tab.id
|
||||
? 'border-th-accent text-th-accent'
|
||||
: 'border-transparent text-th-text-s hover:text-th-text hover:border-th-border'
|
||||
}`}
|
||||
@@ -368,6 +543,10 @@ export default function RoomDetail() {
|
||||
<RecordingList recordings={recordings} onRefresh={fetchRecordings} />
|
||||
)}
|
||||
|
||||
{activeTab === 'analytics' && (
|
||||
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} isOwner={isOwner} />
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && isOwner && editRoom && (
|
||||
<form onSubmit={handleSaveSettings} className="card p-6 space-y-5 max-w-2xl">
|
||||
<div>
|
||||
@@ -378,6 +557,7 @@ export default function RoomDetail() {
|
||||
onChange={e => setEditRoom({ ...editRoom, name: e.target.value })}
|
||||
className="input-field"
|
||||
required
|
||||
minLength={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -460,6 +640,29 @@ export default function RoomDetail() {
|
||||
/>
|
||||
<span className="text-sm text-th-text">{t('room.allowRecording')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!editRoom.learning_analytics}
|
||||
onChange={e => setEditRoom({ ...editRoom, learning_analytics: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
|
||||
/>
|
||||
<span className="text-sm text-th-text">{t('room.enableAnalytics')}</span>
|
||||
</label>
|
||||
{!!editRoom.learning_analytics && (
|
||||
<div className="ml-7">
|
||||
<label className="block text-xs font-medium text-th-text-s mb-1">{t('room.analyticsVisibility')}</label>
|
||||
<select
|
||||
value={editRoom.analytics_visibility || 'owner'}
|
||||
onChange={e => setEditRoom({ ...editRoom, analytics_visibility: e.target.value })}
|
||||
className="input-field text-sm py-1.5 max-w-xs"
|
||||
>
|
||||
<option value="owner">{t('room.analyticsOwnerOnly')}</option>
|
||||
<option value="shared">{t('room.analyticsSharedUsers')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-th-text-s mt-1">{t('room.analyticsVisibilityHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Guest access section */}
|
||||
@@ -497,6 +700,60 @@ export default function RoomDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Presentation section */}
|
||||
<div className="pt-4 border-t border-th-border space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-th-text flex items-center gap-2 mb-1">
|
||||
<FileText size={16} />
|
||||
{t('room.presentationTitle')}
|
||||
</h3>
|
||||
<p className="text-xs text-th-text-s">{t('room.presentationDesc')}</p>
|
||||
</div>
|
||||
|
||||
{room.presentation_file ? (
|
||||
<div className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText size={16} className="text-th-accent flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-th-text-s">{t('room.presentationCurrent')}</p>
|
||||
<p className="text-sm text-th-text font-medium truncate">
|
||||
{room.presentation_name || `presentation.${room.presentation_file?.split('.').pop()}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePresentationRemove}
|
||||
disabled={removingPresentation}
|
||||
className="btn-ghost text-th-error hover:bg-th-error/10 flex-shrink-0 text-xs py-1.5 px-3"
|
||||
>
|
||||
{removingPresentation ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
|
||||
{t('room.presentationRemove')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-th-text-s italic">{/* no presentation */}</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={presentationInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.ppt,.pptx,.odp,.doc,.docx"
|
||||
className="hidden"
|
||||
onChange={handlePresentationUpload}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => presentationInputRef.current?.click()}
|
||||
disabled={uploadingPresentation}
|
||||
className="btn-secondary text-sm flex items-center gap-2"
|
||||
>
|
||||
{uploadingPresentation ? <Loader2 size={15} className="animate-spin" /> : <Upload size={15} />}
|
||||
{t('room.presentationUpload')}
|
||||
</button>
|
||||
<p className="text-xs text-th-text-s">{t('room.presentationAllowedTypes')}</p>
|
||||
</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">
|
||||
@@ -533,11 +790,11 @@ export default function RoomDetail() {
|
||||
{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)
|
||||
(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-th-text truncate">{u.name}</div>
|
||||
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
|
||||
<div className="text-xs text-th-text-s truncate">{u.email}</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -559,11 +816,11 @@ export default function RoomDetail() {
|
||||
{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)
|
||||
(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-th-text truncate">{u.name}</div>
|
||||
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
|
||||
<div className="text-xs text-th-text-s truncate">{u.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -589,6 +846,46 @@ export default function RoomDetail() {
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Federation Invite Modal */}
|
||||
{showFedInvite && (
|
||||
<Modal title={t('federation.inviteTitle')} onClose={() => setShowFedInvite(false)}>
|
||||
<p className="text-sm text-th-text-s mb-4">{t('federation.inviteSubtitle')}</p>
|
||||
<form onSubmit={handleFedInvite} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fedAddress}
|
||||
onChange={e => setFedAddress(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder={t('federation.addressPlaceholder')}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label>
|
||||
<textarea
|
||||
value={fedMessage}
|
||||
onChange={e => setFedMessage(e.target.value)}
|
||||
className="input-field resize-none"
|
||||
rows={2}
|
||||
placeholder={t('federation.messagePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-2 border-t border-th-border">
|
||||
<button type="button" onClick={() => setShowFedInvite(false)} className="btn-secondary flex-1">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={fedSending} className="btn-primary flex-1">
|
||||
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||
{t('federation.send')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X, Calendar, Plus, Trash2, Copy, Eye, EyeOff, Shield, ShieldCheck, ShieldOff } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
@@ -14,6 +14,7 @@ export default function Settings() {
|
||||
|
||||
const [profile, setProfile] = useState({
|
||||
name: user?.name || '',
|
||||
display_name: user?.display_name || '',
|
||||
email: user?.email || '',
|
||||
});
|
||||
const [passwords, setPasswords] = useState({
|
||||
@@ -37,6 +38,121 @@ export default function Settings() {
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// CalDAV token state
|
||||
const [caldavTokens, setCaldavTokens] = useState([]);
|
||||
const [caldavLoading, setCaldavLoading] = useState(false);
|
||||
const [newTokenName, setNewTokenName] = useState('');
|
||||
const [creatingToken, setCreatingToken] = useState(false);
|
||||
const [newlyCreatedToken, setNewlyCreatedToken] = useState(null);
|
||||
const [tokenVisible, setTokenVisible] = useState(false);
|
||||
|
||||
// 2FA state
|
||||
const [twoFaEnabled, setTwoFaEnabled] = useState(!!user?.totp_enabled);
|
||||
const [twoFaLoading, setTwoFaLoading] = useState(false);
|
||||
const [twoFaSetupData, setTwoFaSetupData] = useState(null); // { secret, uri, qrDataUrl }
|
||||
const [twoFaCode, setTwoFaCode] = useState('');
|
||||
const [twoFaEnabling, setTwoFaEnabling] = useState(false);
|
||||
const [twoFaDisablePassword, setTwoFaDisablePassword] = useState('');
|
||||
const [twoFaDisableCode, setTwoFaDisableCode] = useState('');
|
||||
const [twoFaDisabling, setTwoFaDisabling] = useState(false);
|
||||
const [showDisableForm, setShowDisableForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection === 'caldav') {
|
||||
setCaldavLoading(true);
|
||||
api.get('/calendar/caldav-tokens')
|
||||
.then(r => setCaldavTokens(r.data.tokens || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setCaldavLoading(false));
|
||||
}
|
||||
if (activeSection === 'security') {
|
||||
setTwoFaLoading(true);
|
||||
api.get('/auth/2fa/status')
|
||||
.then(r => setTwoFaEnabled(r.data.enabled))
|
||||
.catch(() => {})
|
||||
.finally(() => setTwoFaLoading(false));
|
||||
}
|
||||
}, [activeSection]);
|
||||
|
||||
const handleCreateToken = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newTokenName.trim()) return;
|
||||
setCreatingToken(true);
|
||||
try {
|
||||
const res = await api.post('/calendar/caldav-tokens', { name: newTokenName.trim() });
|
||||
setNewlyCreatedToken(res.data.plainToken);
|
||||
setTokenVisible(false);
|
||||
setNewTokenName('');
|
||||
const r = await api.get('/calendar/caldav-tokens');
|
||||
setCaldavTokens(r.data.tokens || []);
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('settings.caldav.createFailed'));
|
||||
} finally {
|
||||
setCreatingToken(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeToken = async (id) => {
|
||||
if (!confirm(t('settings.caldav.revokeConfirm'))) return;
|
||||
try {
|
||||
await api.delete(`/calendar/caldav-tokens/${id}`);
|
||||
setCaldavTokens(prev => prev.filter(tk => tk.id !== id));
|
||||
toast.success(t('settings.caldav.revoked'));
|
||||
} catch {
|
||||
toast.error(t('settings.caldav.revokeFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 2FA handlers
|
||||
const handleSetup2FA = async () => {
|
||||
setTwoFaLoading(true);
|
||||
try {
|
||||
const res = await api.post('/auth/2fa/setup');
|
||||
// Generate QR code data URL client-side
|
||||
const QRCode = (await import('qrcode')).default;
|
||||
const qrDataUrl = await QRCode.toDataURL(res.data.uri, { width: 200, margin: 2, color: { dark: '#000000', light: '#ffffff' } });
|
||||
setTwoFaSetupData({ secret: res.data.secret, uri: res.data.uri, qrDataUrl });
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('settings.security.setupFailed'));
|
||||
} finally {
|
||||
setTwoFaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnable2FA = async (e) => {
|
||||
e.preventDefault();
|
||||
setTwoFaEnabling(true);
|
||||
try {
|
||||
await api.post('/auth/2fa/enable', { code: twoFaCode });
|
||||
setTwoFaEnabled(true);
|
||||
setTwoFaSetupData(null);
|
||||
setTwoFaCode('');
|
||||
toast.success(t('settings.security.enabled'));
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('settings.security.enableFailed'));
|
||||
setTwoFaCode('');
|
||||
} finally {
|
||||
setTwoFaEnabling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable2FA = async (e) => {
|
||||
e.preventDefault();
|
||||
setTwoFaDisabling(true);
|
||||
try {
|
||||
await api.post('/auth/2fa/disable', { password: twoFaDisablePassword, code: twoFaDisableCode });
|
||||
setTwoFaEnabled(false);
|
||||
setShowDisableForm(false);
|
||||
setTwoFaDisablePassword('');
|
||||
setTwoFaDisableCode('');
|
||||
toast.success(t('settings.security.disabled'));
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('settings.security.disableFailed'));
|
||||
} finally {
|
||||
setTwoFaDisabling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const groups = getThemeGroups();
|
||||
|
||||
const avatarColors = [
|
||||
@@ -52,6 +168,7 @@ export default function Settings() {
|
||||
try {
|
||||
const res = await api.put('/auth/profile', {
|
||||
name: profile.name,
|
||||
display_name: profile.display_name,
|
||||
email: profile.email,
|
||||
theme,
|
||||
language,
|
||||
@@ -135,8 +252,10 @@ export default function Settings() {
|
||||
const sections = [
|
||||
{ id: 'profile', label: t('settings.profile'), icon: User },
|
||||
{ id: 'password', label: t('settings.password'), icon: Lock },
|
||||
{ id: 'security', label: t('settings.security.title'), icon: Shield },
|
||||
{ id: 'language', label: t('settings.language'), icon: Globe },
|
||||
{ id: 'themes', label: t('settings.themes'), icon: Palette },
|
||||
{ id: 'caldav', label: t('settings.caldav.title'), icon: Calendar },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -190,7 +309,7 @@ export default function Settings() {
|
||||
className="w-16 h-16 rounded-full flex items-center justify-center text-white text-xl font-bold"
|
||||
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
|
||||
>
|
||||
{user?.name?.[0]?.toUpperCase() || '?'}
|
||||
{(user?.display_name || user?.name)?.[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
@@ -259,7 +378,7 @@ export default function Settings() {
|
||||
|
||||
<form onSubmit={handleProfileSave} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.name')}</label>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
|
||||
<div className="relative">
|
||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
@@ -270,6 +389,19 @@ export default function Settings() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
|
||||
<div className="relative">
|
||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={profile.display_name}
|
||||
onChange={e => setProfile({ ...profile, display_name: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
||||
@@ -348,6 +480,147 @@ export default function Settings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security / 2FA section */}
|
||||
{activeSection === 'security' && (
|
||||
<div className="space-y-5">
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-th-text mb-1">{t('settings.security.title')}</h2>
|
||||
<p className="text-sm text-th-text-s mb-6">{t('settings.security.subtitle')}</p>
|
||||
|
||||
{twoFaLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={24} className="animate-spin text-th-text-s" />
|
||||
</div>
|
||||
) : twoFaEnabled ? (
|
||||
/* 2FA is enabled */
|
||||
<div>
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 mb-5">
|
||||
<ShieldCheck size={22} className="text-emerald-400 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-emerald-300">{t('settings.security.statusEnabled')}</p>
|
||||
<p className="text-xs text-emerald-400/70">{t('settings.security.statusEnabledDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!showDisableForm ? (
|
||||
<button
|
||||
onClick={() => setShowDisableForm(true)}
|
||||
className="btn-ghost text-th-error hover:text-th-error text-sm"
|
||||
>
|
||||
<ShieldOff size={16} />
|
||||
{t('settings.security.disable')}
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={handleDisable2FA} className="space-y-4 p-4 rounded-xl bg-th-bg-t border border-th-border">
|
||||
<p className="text-sm text-th-text-s">{t('settings.security.disableConfirm')}</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
|
||||
<div className="relative">
|
||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="password"
|
||||
value={twoFaDisablePassword}
|
||||
onChange={e => setTwoFaDisablePassword(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.codeLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={twoFaDisableCode}
|
||||
onChange={e => setTwoFaDisableCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
|
||||
className="input-field text-center text-lg tracking-[0.3em] font-mono"
|
||||
placeholder="000 000"
|
||||
required
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={twoFaDisabling} className="btn-primary bg-red-600 hover:bg-red-700 border-red-600">
|
||||
{twoFaDisabling ? <Loader2 size={14} className="animate-spin" /> : <ShieldOff size={14} />}
|
||||
{t('settings.security.disable')}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setShowDisableForm(false); setTwoFaDisablePassword(''); setTwoFaDisableCode(''); }} className="btn-ghost text-sm">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
) : twoFaSetupData ? (
|
||||
/* Setup flow: show QR code + verification */
|
||||
<div className="space-y-5">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-th-text mb-4">{t('settings.security.scanQR')}</p>
|
||||
<div className="inline-block p-3 bg-white rounded-xl">
|
||||
<img src={twoFaSetupData.qrDataUrl} alt="TOTP QR Code" className="w-[200px] h-[200px]" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-th-text-s mb-1">{t('settings.security.manualKey')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg text-th-accent font-mono break-all">
|
||||
{twoFaSetupData.secret}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(twoFaSetupData.secret); toast.success(t('room.linkCopied')); }}
|
||||
className="btn-ghost py-1.5 px-2 flex-shrink-0"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleEnable2FA} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.verifyCode')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={twoFaCode}
|
||||
onChange={e => setTwoFaCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
|
||||
className="input-field text-center text-lg tracking-[0.3em] font-mono"
|
||||
placeholder="000 000"
|
||||
required
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={twoFaEnabling || twoFaCode.replace(/\s/g, '').length < 6} className="btn-primary">
|
||||
{twoFaEnabling ? <Loader2 size={14} className="animate-spin" /> : <ShieldCheck size={14} />}
|
||||
{t('settings.security.enable')}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setTwoFaSetupData(null); setTwoFaCode(''); }} className="btn-ghost text-sm">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
/* 2FA is disabled — show enable button */
|
||||
<div>
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-th-bg-t border border-th-border mb-5">
|
||||
<ShieldOff size={22} className="text-th-text-s flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-th-text">{t('settings.security.statusDisabled')}</p>
|
||||
<p className="text-xs text-th-text-s">{t('settings.security.statusDisabledDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleSetup2FA} disabled={twoFaLoading} className="btn-primary">
|
||||
{twoFaLoading ? <Loader2 size={16} className="animate-spin" /> : <Shield size={16} />}
|
||||
{t('settings.security.enable')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Language section */}
|
||||
{activeSection === 'language' && (
|
||||
<div className="card p-6">
|
||||
@@ -410,8 +683,126 @@ export default function Settings() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* CalDAV section */}
|
||||
{activeSection === 'caldav' && (
|
||||
<div className="space-y-5">
|
||||
{/* Info Card */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-th-text mb-1">{t('settings.caldav.title')}</h2>
|
||||
<p className="text-sm text-th-text-s mb-5">{t('settings.caldav.subtitle')}</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-th-text-s mb-1">{t('settings.caldav.serverUrl')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg text-th-accent font-mono truncate">
|
||||
{`${window.location.origin}/caldav/`}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(`${window.location.origin}/caldav/`); toast.success(t('room.linkCopied')); }}
|
||||
className="btn-ghost py-1.5 px-2 flex-shrink-0"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-th-text-s mb-1">{t('settings.caldav.username')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg text-th-text font-mono">
|
||||
{user?.email}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(user?.email || ''); toast.success(t('room.linkCopied')); }}
|
||||
className="btn-ghost py-1.5 px-2 flex-shrink-0"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-th-text-s">{t('settings.caldav.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New token was just created */}
|
||||
{newlyCreatedToken && (
|
||||
<div className="card p-5 border-2 border-th-success/40 bg-th-success/5">
|
||||
<p className="text-sm font-semibold text-th-success mb-2">{t('settings.caldav.newTokenCreated')}</p>
|
||||
<p className="text-xs text-th-text-s mb-3">{t('settings.caldav.newTokenHint')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg font-mono text-th-text break-all">
|
||||
{tokenVisible ? newlyCreatedToken : '•'.repeat(48)}
|
||||
</code>
|
||||
<button onClick={() => setTokenVisible(v => !v)} className="btn-ghost py-1.5 px-2 flex-shrink-0">
|
||||
{tokenVisible ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(newlyCreatedToken); toast.success(t('room.linkCopied')); }}
|
||||
className="btn-ghost py-1.5 px-2 flex-shrink-0"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNewlyCreatedToken(null)}
|
||||
className="mt-3 text-xs text-th-text-s hover:text-th-text underline"
|
||||
>
|
||||
{t('settings.caldav.dismiss')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create new token */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-th-text mb-3">{t('settings.caldav.newToken')}</h3>
|
||||
<form onSubmit={handleCreateToken} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTokenName}
|
||||
onChange={e => setNewTokenName(e.target.value)}
|
||||
placeholder={t('settings.caldav.tokenNamePlaceholder')}
|
||||
className="input-field flex-1 text-sm"
|
||||
required
|
||||
/>
|
||||
<button type="submit" disabled={creatingToken || !newTokenName.trim()} className="btn-primary py-1.5 px-4">
|
||||
{creatingToken ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
|
||||
{t('settings.caldav.generate')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Token list */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-th-text mb-3">{t('settings.caldav.existingTokens')}</h3>
|
||||
{caldavLoading ? (
|
||||
<div className="flex items-center justify-center py-6"><Loader2 size={20} className="animate-spin text-th-text-s" /></div>
|
||||
) : caldavTokens.length === 0 ? (
|
||||
<p className="text-sm text-th-text-s py-3">{t('settings.caldav.noTokens')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{caldavTokens.map(tk => (
|
||||
<div key={tk.id} className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg bg-th-bg-t">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-th-text truncate">{tk.name}</p>
|
||||
<p className="text-xs text-th-text-s">
|
||||
{t('settings.caldav.created')}: {new Date(tk.created_at).toLocaleDateString()}
|
||||
{tk.last_used_at && ` · ${t('settings.caldav.lastUsed')}: ${new Date(tk.last_used_at).toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRevokeToken(tk.id)}
|
||||
className="btn-ghost py-1 px-2 text-th-error hover:text-th-error flex-shrink-0"
|
||||
title={t('settings.caldav.revoke')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div> </div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +104,69 @@ export const themes = [
|
||||
group: 'Community',
|
||||
colors: { bg: '#0d1117', accent: '#58a6ff', text: '#c9d1d9' },
|
||||
},
|
||||
{
|
||||
id: 'scrunkly-cat',
|
||||
name: 'scrunkly.cat-dark',
|
||||
type: 'dark',
|
||||
group: 'Community',
|
||||
colors: { bg: '#161924', accent: '#b30051', text: '#dadada' },
|
||||
},
|
||||
{
|
||||
id: 'red-modular-light',
|
||||
name: 'Red Modular Light',
|
||||
type: 'light',
|
||||
group: 'Community',
|
||||
colors: { bg: '#ffffff', accent: '#e60000', text: '#000000' },
|
||||
},
|
||||
{
|
||||
id: 'everforest-dark',
|
||||
name: 'Everforest Dark',
|
||||
type: 'dark',
|
||||
group: 'Everforest',
|
||||
colors: { bg: '#2d353b', accent: '#a7c080', text: '#d3c6aa' },
|
||||
},
|
||||
{
|
||||
id: 'everforest-light',
|
||||
name: 'Everforest Light',
|
||||
type: 'light',
|
||||
group: 'Everforest',
|
||||
colors: { bg: '#fdf6e3', accent: '#8da101', text: '#5c6a72' },
|
||||
},
|
||||
{
|
||||
id: 'kanagawa',
|
||||
name: 'Kanagawa',
|
||||
type: 'dark',
|
||||
group: 'Community',
|
||||
colors: { bg: '#1f1f28', accent: '#7e9cd8', text: '#dcd7ba' },
|
||||
},
|
||||
{
|
||||
id: 'ayu-dark',
|
||||
name: 'Ayu Dark',
|
||||
type: 'dark',
|
||||
group: 'Ayu',
|
||||
colors: { bg: '#0d1017', accent: '#39bae6', text: '#bfbdb6' },
|
||||
},
|
||||
{
|
||||
id: 'moonlight',
|
||||
name: 'Moonlight',
|
||||
type: 'dark',
|
||||
group: 'Community',
|
||||
colors: { bg: '#212337', accent: '#82aaff', text: '#c8d3f5' },
|
||||
},
|
||||
{
|
||||
id: 'cyberpunk',
|
||||
name: 'Cyberpunk',
|
||||
type: 'dark',
|
||||
group: 'Community',
|
||||
colors: { bg: '#0a0a0f', accent: '#ff0080', text: '#e0e0ff' },
|
||||
},
|
||||
{
|
||||
id: 'cotton-candy-light',
|
||||
name: 'Cotton Candy Light',
|
||||
type: 'light',
|
||||
group: 'Community',
|
||||
colors: { bg: '#fff5f9', accent: '#ff85a2', text: '#8b2635' },
|
||||
},
|
||||
];
|
||||
|
||||
export function getThemeById(id) {
|
||||
|
||||
Reference in New Issue
Block a user