diff --git a/README.rst b/README.rst index e6ec3fe..0391fd7 100644 --- a/README.rst +++ b/README.rst @@ -4,6 +4,131 @@ python-matrix-rmapi Matrix service RASENMAEHER integration API service. Serves as a reference implementation for a new integration into the deploy app ecosystem. +Operation +--------- + +matrixrmapi is the Deploy App integration layer for Matrix/Synapse. It has two responsibilities: + +1. **Synapse bootstrap** — on startup it registers an admin bot, creates the deployment's Space and rooms, + and configures their state. +2. **User lifecycle** — it receives CRUD callbacks from Rasenmaeher over mTLS and reflects each event into + Synapse. + +Authentication +^^^^^^^^^^^^^^ + +All ``/api/v1/users/*`` endpoints require a valid mTLS client certificate whose CN matches the Rasenmaeher +service certificate (taken from the kraftwerk manifest). Any other caller receives ``403 Forbidden``. + +Startup sequence +^^^^^^^^^^^^^^^^ + +The startup runs as a background task so the HTTP server is available immediately:: + + 1. Poll GET /health on Synapse until it responds 200 (up to 5 minutes). + 2. Acquire a file lock and register the admin bot via the Synapse HMAC-signed + registration endpoint (idempotent: if a valid token file already exists, + re-registration is skipped). + 3. Remove rate-limiting for the bot user so concurrent room operations never hit 429. + 4. Ensure the Space and four rooms exist (creates them if missing, looks them up by + alias otherwise). + 5. Apply idempotent state events to every room: name, encryption, join rules, + history visibility, topics. + 6. Expose ``app.state.rooms`` — this is the gate that CRUD endpoints check. + 7. Drain any promotions/demotions that arrived while rooms were not yet ready + (see "Deferred queue" below). + +The admin bot is created at power level 200 in all rooms via ``power_level_content_override`` +so it can always demote admins (who are at level 100). This is a Matrix spec requirement: +a user cannot lower the power level of another user at an equal-or-higher level. + +Rooms created +^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + + * - Key + - Alias + - Notes + * - space + - ``#-space:`` + - Top-level Space; join rule: invite + * - general + - ``#-general:`` + - Public room; join rule: restricted (Space membership) + * - helpdesk + - ``#-helpdesk:`` + - Public room; join rule: restricted (Space membership) + * - offtopic + - ``#-offtopic:`` + - Public room; join rule: restricted (Space membership) + * - admin + - ``#-admin:`` + - Private room; only admins are joined + +All non-space rooms use ``m.megolm.v1.aes-sha2`` encryption and ``joined`` history visibility. + +User lifecycle endpoints +^^^^^^^^^^^^^^^^^^^^^^^^ + +``POST /api/v1/users/created`` + New device certificate created. Force-joins the user to the Space and all three public rooms. + If Synapse is not yet ready, logs a warning and returns success — ``auto_join_rooms`` in + ``homeserver.yaml`` will join the user when they first log in via OIDC. + +``POST /api/v1/users/revoked`` + Device certificate revoked. Deactivates and erases the user from Synapse (their messages + are removed from the server). If Synapse is not ready yet, returns success with a warning. + +``POST /api/v1/users/promoted`` + User promoted to admin in Deploy App. Sets the user's power level to 100 in the Space and + all public rooms, then force-joins them to the admin channel. + If Synapse is not yet ready, the action is queued (see "Deferred queue"). + +``POST /api/v1/users/demoted`` + User demoted from admin. Resets power level to 0 in the Space and public rooms, and kicks + the user from the admin channel. + If Synapse is not yet ready, the action is queued (see "Deferred queue"). + +``PUT /api/v1/users/updated`` + Callsign updated. No-op — a callsign change requires a new Matrix account and is not + handled automatically. + +Deferred queue +^^^^^^^^^^^^^^ + +Because Synapse takes time to start, ``/promoted`` and ``/demoted`` calls can arrive before +``app.state.rooms`` is set. In that case the uid and action (``"promote"`` or ``"demote"``) are +stored in ``app.state.pending_promotions``. After startup completes and rooms are exposed, the +queue is drained in order. If a user is promoted and then demoted before Synapse is ready, only +the last action survives (the dict key is overwritten). + +Configuration +^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + + * - Environment variable + - Default + - Description + * - ``SYNAPSE_URL`` + - ``http://synapse:8008`` + - Internal URL of the Synapse homeserver + * - ``SYNAPSE_REGISTRATION_SECRET`` + - *(required)* + - Shared secret for bot registration (HMAC-SHA1) + * - ``SYNAPSE_BOT_USERNAME`` + - ``matrixrmapi-bot`` + - Local part of the admin bot Matrix user + * - ``SYNAPSE_TOKEN_FILE`` + - ``/data/persistent/synapse_admin_token`` + - File where the bot's access token is cached between restarts + * - ``SERVER_DOMAIN`` + - *(from kraftwerk manifest)* + - Matrix server_name; derived automatically from the product DNS label + Docker ------ @@ -82,7 +207,7 @@ TLDR: - Create and activate a Python 3.11 virtualenv (assuming virtualenvwrapper):: - mkvirtualenv -p `which python3.11` my_virtualenv + mkvirtualenv -p `which python3.11` my_virtualenv - change to a branch:: @@ -99,3 +224,15 @@ TLDR: Remember to activate your virtualenv whenever working on the repo, this is needed because pylint and mypy pre-commit hooks use the "system" python for now (because reasons). + +Testing a local Synapse server +------------------------------ +Aka, how to connect a Matrix client to your server to see how things work out. +1. Make sure you trust your localmaeher's self-signed certs at https://synapse.localmaeher.dev.pvarki.fi:4439/. Navigate to that page, click accept the risk. +2. Run the following command to set up an Element Web service as a client: + + docker run --rm -p 8088:80 \ + -e ELEMENT_WEB_CONFIG='{"default_server_config":{"m.homeserver":{"base_url":"https://synapse.localmaeher.dev.pvarki.fi:4439"}}}' \ + vectorim/element-web + +3. Now navigate to http://localhost:8080. Log in to localmaeher's Synapse as usual. diff --git a/poetry.lock b/poetry.lock index e979406..aad7f02 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "aiodns" @@ -216,7 +216,7 @@ version = "4.11.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, @@ -543,7 +543,7 @@ version = "2025.11.12" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, @@ -1256,7 +1256,7 @@ version = "3.19.1" description = "A platform independent file lock." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] markers = "python_version < \"3.11\"" files = [ {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"}, @@ -1269,7 +1269,7 @@ version = "3.20.0" description = "A platform independent file lock." optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] markers = "python_version >= \"3.11\"" files = [ {file = "filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2"}, @@ -1443,7 +1443,7 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -1455,7 +1455,7 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -1530,7 +1530,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -3007,7 +3007,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -3673,4 +3673,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">3.9.1,<4.0" -content-hash = "a42b8e467b621d0b5229fbc49a36ab713de7905eaa1abb1ce7257d9535d19e4f" +content-hash = "bd91109f94dedf5292407acc6e37a08762d44966bd862deba7887fec2d331e14" diff --git a/pyproject.toml b/pyproject.toml index 2dfcb68..296508a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,8 @@ pydantic = ">=2.0,<3.0" jinja2 = "^3.1" uvicorn = {version = ">=0.20,<1.0", extras = ["standard"]} # caret behaviour on 0.x is to lock to 0.x.* gunicorn = "^21.0" +httpx = ">=0.28.1,<1.0" +filelock = "^3.12" [tool.poetry.group.dev.dependencies] pytest = "^8.0" @@ -81,7 +83,6 @@ pre-commit = "^3.6" pytest-asyncio = ">=0.21,<1.0" # caret behaviour on 0.x is to lock to 0.x.* bump2version = "^1.0" detect-secrets = "^1.5" -httpx = ">=0.28.1,<1.0" [build-system] diff --git a/src/matrixrmapi/api/usercrud.py b/src/matrixrmapi/api/usercrud.py index af311ab..724a3b8 100644 --- a/src/matrixrmapi/api/usercrud.py +++ b/src/matrixrmapi/api/usercrud.py @@ -1,13 +1,17 @@ -""" "User actions""" +"""User lifecycle actions""" + +from __future__ import annotations import logging +from typing import Dict, Optional -from fastapi import APIRouter, Depends, Request, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from libpvarki.middleware import MTLSHeader -from libpvarki.schemas.product import UserCRUDRequest from libpvarki.schemas.generic import OperationResultResponse +from libpvarki.schemas.product import UserCRUDRequest -from ..config import get_manifest +from ..config import get_manifest, get_server_domain +from ..synapseutils.synapse_admin import SynapseAdmin, matrix_user_id LOGGER = logging.getLogger(__name__) @@ -15,37 +19,76 @@ def comes_from_rm(request: Request) -> None: - """Check the CN, raises 403 if not""" + """Check mTLS CN — raises 403 if not from Rasenmaeher.""" payload = request.state.mtlsdn manifest = get_manifest() if payload.get("CN") != manifest["rasenmaeher"]["certcn"]: raise HTTPException(status_code=403) +def _synapse(request: Request) -> Optional[SynapseAdmin]: + """Return SynapseAdmin from app state, or None if not yet ready.""" + val: Optional[SynapseAdmin] = getattr(request.app.state, "synapse", None) + return val + + +def _rooms(request: Request) -> Optional[Dict[str, str]]: + """Return room IDs dict from app state, or None if not yet ready.""" + val: Optional[Dict[str, str]] = getattr(request.app.state, "rooms", None) + return val + + +def _public_room_ids(rooms: Dict[str, str]) -> list[str]: + """Room IDs for the space + the three public rooms (not admin channel).""" + return [rooms[k] for k in ("space", "general", "helpdesk", "offtopic") if k in rooms] + + @router.post("/created") async def user_created( user: UserCRUDRequest, request: Request, ) -> OperationResultResponse: - """New device cert was created""" + """New device cert created — force-join user to space and public rooms.""" comes_from_rm(request) - _ = user # TODO: should we validate the cert at this point ?? - result = OperationResultResponse(success=True) - return result + synapse = _synapse(request) + rooms = _rooms(request) + if synapse is None or rooms is None: + LOGGER.warning("Synapse not ready; skipping room joins for %s (auto_join_rooms will handle it)", user.callsign) + return OperationResultResponse(success=True) + try: + uid = matrix_user_id(user.callsign, get_server_domain()) + except ValueError as exc: + LOGGER.error("Invalid callsign for Matrix: %s", exc) + return OperationResultResponse(success=False) + for room_id in _public_room_ids(rooms): + await synapse.force_join(room_id, uid) + LOGGER.info("Force-joined %s to public rooms", uid) + return OperationResultResponse(success=True) -# While delete would be semantically better it takes no body and definitely forces the -# integration layer to keep track of UUIDs @router.post("/revoked") async def user_revoked( user: UserCRUDRequest, request: Request, ) -> OperationResultResponse: - """Device cert was revoked""" + """Device cert revoked — deactivate and erase user from Synapse.""" comes_from_rm(request) - _ = user - result = OperationResultResponse(success=True) - return result + synapse = _synapse(request) + if synapse is None: + LOGGER.warning("Synapse not ready; cannot deactivate %s", user.callsign) + return OperationResultResponse(success=True) + try: + uid = matrix_user_id(user.callsign, get_server_domain()) + except ValueError as exc: + LOGGER.error("Invalid callsign for Matrix: %s", exc) + return OperationResultResponse(success=False) + try: + await synapse.deactivate(uid) + except Exception as exc: # pylint: disable=broad-except + LOGGER.error("Failed to deactivate %s in Synapse: %s", uid, exc) + return OperationResultResponse(success=False) + LOGGER.info("Deactivated and erased %s from Synapse", uid) + return OperationResultResponse(success=True) @router.post("/promoted") @@ -53,11 +96,31 @@ async def user_promoted( user: UserCRUDRequest, request: Request, ) -> OperationResultResponse: - """Device cert was promoted to admin privileges""" + """User promoted to admin — power level 100 in public rooms, invite to admin channel.""" comes_from_rm(request) - _ = user - result = OperationResultResponse(success=True) - return result + try: + uid = matrix_user_id(user.callsign, get_server_domain()) + except ValueError as exc: + LOGGER.error("Invalid callsign for Matrix: %s", exc) + return OperationResultResponse(success=False) + synapse = _synapse(request) + rooms = _rooms(request) + if synapse is None or rooms is None: + pending: Dict[str, str] = getattr(request.app.state, "pending_promotions", {}) + pending[uid] = "promote" + request.app.state.pending_promotions = pending + LOGGER.info("Queued deferred promotion for %s (Synapse not ready yet)", uid) + return OperationResultResponse(success=True) + try: + await synapse.set_power_level_in_rooms(_public_room_ids(rooms), uid, 100) + admin_id = rooms.get("admin") + if admin_id: + await synapse.force_join(admin_id, uid) + except Exception as exc: # pylint: disable=broad-except + LOGGER.error("Failed to promote %s: %s", uid, exc) + return OperationResultResponse(success=False) + LOGGER.info("Promoted %s: power level 100 + force-joined to admin channel", uid) + return OperationResultResponse(success=True) @router.post("/demoted") @@ -65,11 +128,31 @@ async def user_demoted( user: UserCRUDRequest, request: Request, ) -> OperationResultResponse: - """Device cert was demoted to standard privileges""" + """User demoted from admin — remove power level 100, kick from admin channel.""" comes_from_rm(request) - _ = user - result = OperationResultResponse(success=True) - return result + try: + uid = matrix_user_id(user.callsign, get_server_domain()) + except ValueError as exc: + LOGGER.error("Invalid callsign for Matrix: %s", exc) + return OperationResultResponse(success=False) + synapse = _synapse(request) + rooms = _rooms(request) + if synapse is None or rooms is None: + pending: Dict[str, str] = getattr(request.app.state, "pending_promotions", {}) + pending[uid] = "demote" + request.app.state.pending_promotions = pending + LOGGER.info("Queued deferred demotion for %s (Synapse not ready yet)", uid) + return OperationResultResponse(success=True) + try: + await synapse.set_power_level_in_rooms(_public_room_ids(rooms), uid, 0) + admin_id = rooms.get("admin") + if admin_id: + await synapse.kick(admin_id, uid) + except Exception as exc: # pylint: disable=broad-except + LOGGER.error("Failed to demote %s: %s", uid, exc) + return OperationResultResponse(success=False) + LOGGER.info("Demoted %s: power level 0 + kicked from admin channel", uid) + return OperationResultResponse(success=True) @router.put("/updated") @@ -77,8 +160,7 @@ async def user_updated( user: UserCRUDRequest, request: Request, ) -> OperationResultResponse: - """Device callsign updated""" + """Callsign updated — no-op (callsign change requires a new Matrix account).""" comes_from_rm(request) _ = user - result = OperationResultResponse(success=True) - return result + return OperationResultResponse(success=True) diff --git a/src/matrixrmapi/app.py b/src/matrixrmapi/app.py index f2739af..7d97bf7 100644 --- a/src/matrixrmapi/app.py +++ b/src/matrixrmapi/app.py @@ -1,17 +1,248 @@ -""" "factory for the fastpi app""" +"""Factory for the FastAPI app""" +from __future__ import annotations + +import asyncio +import contextlib import logging +from contextlib import asynccontextmanager +from typing import AsyncGenerator, Dict, List, Optional, Tuple +import filelock +import httpx from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from libpvarki.logging import init_logging from matrixrmapi import __version__ -from .config import LOG_LEVEL, get_manifest +from .config import ( + LOG_LEVEL, + SYNAPSE_BOT_USERNAME, + SYNAPSE_REGISTRATION_SECRET, + SYNAPSE_TOKEN_FILE, + SYNAPSE_URL, + get_manifest, + get_server_domain, +) from .api import all_routers, all_routers_v2 +from .synapseutils.synapse_admin import SynapseAdmin LOGGER = logging.getLogger(__name__) +# (key, alias_suffix, display_name, is_space, is_private) +_ROOMS_CONFIG: List[Tuple[str, str, str, bool, bool]] = [ + ("space", "{d}-space", "{d}", True, False), + ("admin", "{d}-admin", "Admin channel", False, True), + ("general", "{d}-general", "98-General-for-all", False, False), + ("helpdesk", "{d}-helpdesk", "99-Helpdesk", False, False), + ("offtopic", "{d}-offtopic", "Off topic", False, False), +] + +_ROOM_TOPICS: Dict[str, str] = { + "general": "Work discussion that does not fit any other room.", + "helpdesk": "Report issues and get help from here.", + "offtopic": "Everything that is not about the topics or work.", +} + + +async def _wait_for_synapse(synapse_url: str, retries: int = 60, interval: float = 5.0) -> bool: + """Poll Synapse /health until it responds 200. Returns True on success.""" + LOGGER.info("Waiting for Synapse at %s ...", synapse_url) + for attempt in range(retries): + try: + async with httpx.AsyncClient() as client: + resp = await client.get(f"{synapse_url}/health", timeout=5.0) + if resp.status_code == 200: + LOGGER.info("Synapse is ready") + return True + except Exception: # pylint: disable=broad-except # nosec B110 + pass + if attempt < retries - 1: + await asyncio.sleep(interval) + LOGGER.error("Synapse not reachable after %d attempts — integration disabled", retries) + return False + + +async def _acquire_bot_token(synapse: SynapseAdmin) -> bool: + """Acquire the admin bot token using a file lock for worker coordination.""" + lock_path = SYNAPSE_TOKEN_FILE.parent / "synapse_init.lock" + lock = filelock.FileLock(str(lock_path)) + + acquired = False + try: + lock.acquire(timeout=0.0) + acquired = True + # We are the init worker — register bot (idempotent: reads file if present) + registration_secret = SYNAPSE_REGISTRATION_SECRET + await synapse.setup(registration_secret, SYNAPSE_BOT_USERNAME, SYNAPSE_TOKEN_FILE) + del registration_secret + return True + except filelock.Timeout: + LOGGER.warning("Another worker is initialising the Synapse bot, waiting ...") + except Exception as exc: # pylint: disable=broad-except + LOGGER.error("Bot token acquisition failed: %s", exc) + return False + finally: + if acquired: + lock.release() + + # Non-init worker: wait for the token file to appear + for _ in range(60): + if SYNAPSE_TOKEN_FILE.exists(): + break + await asyncio.sleep(2) + else: + LOGGER.error("Token file never appeared after waiting — integration disabled") + return False + + # Load and validate the token written by the init worker + try: + await synapse.setup("", SYNAPSE_BOT_USERNAME, SYNAPSE_TOKEN_FILE) + return True + except Exception as exc: # pylint: disable=broad-except + LOGGER.error("Failed to load bot token from file: %s", exc) + return False + + +async def _ensure_room(synapse: SynapseAdmin, name: str, alias: str, is_space: bool, is_private: bool) -> str: + """Return room_id for alias, creating the room if it does not exist.""" + existing = await synapse.room_id_for_alias(alias) + if existing: + LOGGER.info("Room %s already exists: %s", alias, existing) + return existing + room_id = await synapse.create_room(name, alias, is_space=is_space, is_private=is_private) + LOGGER.info("Created room %s -> %s", alias, room_id) + return room_id + + +async def _ensure_rooms(synapse: SynapseAdmin, deployment: str, domain: str) -> Dict[str, str]: + """Create space and rooms if they don't exist; return room IDs dict.""" + room_ids: Dict[str, str] = {} + space_id: Optional[str] = None + + for key, alias_tpl, name_tpl, is_space, is_private in _ROOMS_CONFIG: + alias = f"#{alias_tpl.format(d=deployment)}:{domain}" + room_ids[key] = await _ensure_room(synapse, name_tpl.format(d=deployment), alias, is_space, is_private) + if is_space: + space_id = room_ids[key] + + if space_id: + for key, room_id in room_ids.items(): + if key != "space": + await synapse.add_child_to_space(space_id, room_id) + + return room_ids + + +async def _apply_pending(synapse: SynapseAdmin, rooms: Dict[str, str], pending: Dict[str, str]) -> None: + """Apply promotions/demotions that were queued while Synapse was still starting.""" + public_ids = [rooms[k] for k in ("space", "general", "helpdesk", "offtopic") if k in rooms] + admin_id = rooms.get("admin") + for uid, action in pending.items(): + try: + if action == "promote": + await synapse.set_power_level_in_rooms(public_ids, uid, 100) + if admin_id: + await synapse.force_join(admin_id, uid) + LOGGER.info("Applied deferred promotion for %s", uid) + elif action == "demote": + await synapse.set_power_level_in_rooms(public_ids, uid, 0) + if admin_id: + await synapse.kick(admin_id, uid) + LOGGER.info("Applied deferred demotion for %s", uid) + except Exception as exc: # pylint: disable=broad-except + LOGGER.error("Failed to apply deferred %s for %s: %s", action, uid, exc) + + +async def _configure_rooms_state(synapse: SynapseAdmin, rooms: Dict[str, str], deployment: str) -> None: + """Apply join rules, encryption, history visibility, topics and names to all rooms. + + All state events are idempotent in Matrix — safe to re-apply on every restart. + """ + name_by_key = {key: name_tpl.format(d=deployment) for key, _, name_tpl, _, _ in _ROOMS_CONFIG} + space_id = rooms["space"] + LOGGER.info("Applying room state configuration (idempotent)") + for key, room_id in rooms.items(): + await synapse.set_room_state(room_id, "m.room.name", {"name": name_by_key[key]}) + if key == "space": + await synapse.set_room_state(room_id, "m.room.join_rules", {"join_rule": "invite"}) + # Allow all space members to add child rooms (lower m.space.child to 0) + levels = await synapse.get_power_levels(room_id) + events_levels = dict(levels.get("events", {})) + events_levels["m.space.child"] = 0 + levels["events"] = events_levels + await synapse.set_room_state(room_id, "m.room.power_levels", levels) + continue + await synapse.set_room_state(room_id, "m.room.encryption", {"algorithm": "m.megolm.v1.aes-sha2"}) + await synapse.set_room_state(room_id, "m.room.history_visibility", {"history_visibility": "joined"}) + if key != "admin": + await synapse.set_room_state( + room_id, + "m.room.join_rules", + {"join_rule": "restricted", "allow": [{"type": "m.room_membership", "room_id": space_id}]}, + ) + topic = _ROOM_TOPICS.get(key) + if topic: + await synapse.set_room_state(room_id, "m.room.topic", {"topic": topic}) + LOGGER.info("Room state configuration applied") + + +async def _synapse_startup(app: FastAPI) -> None: + """Background task: connect to Synapse, create bot and rooms.""" + if not await _wait_for_synapse(SYNAPSE_URL): + return + + manifest = get_manifest() + deployment = str(manifest.get("deployment", "pvarki")) + domain = get_server_domain() + + synapse = SynapseAdmin(SYNAPSE_URL, domain) + + if not await _acquire_bot_token(synapse): + await synapse.close() + return + + app.state.synapse = synapse + + try: + room_ids = await _ensure_rooms(synapse, deployment, domain) + except Exception as exc: # pylint: disable=broad-except + LOGGER.error("Room setup failed: %s", exc) + return + + try: + await _configure_rooms_state(synapse, room_ids, deployment) + except Exception as exc: # pylint: disable=broad-except + LOGGER.error("Room configuration failed (rooms still usable): %s", exc) + + # Expose rooms after configuration — prevents /promoted from racing with + # _configure_rooms_state's power-level read-modify-write on the space. + # Set even if configuration partially failed: rooms exist and are usable. + app.state.rooms = room_ids + LOGGER.info("Synapse rooms ready: %s", room_ids) + + # Apply any promotions/demotions that arrived while rooms were not yet set. + pending: Dict[str, str] = getattr(app.state, "pending_promotions", {}) + if pending: + LOGGER.info("Processing %d deferred promotion(s)/demotion(s)", len(pending)) + app.state.pending_promotions = {} + await _apply_pending(synapse, room_ids, pending) + + +@asynccontextmanager +async def app_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """Start Synapse integration as a non-blocking background task.""" + task = asyncio.create_task(_synapse_startup(app)) + try: + yield + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + synapse: Optional[SynapseAdmin] = getattr(app.state, "synapse", None) + if synapse: + await synapse.close() + def get_app() -> FastAPI: """Returns the FastAPI application.""" @@ -21,7 +252,12 @@ def get_app() -> FastAPI: deployment_domain_regex = rm_base.replace(".", r"\.").replace("https://", r"https://(.*\.)?") LOGGER.info("deployment_domain_regex={}".format(deployment_domain_regex)) - app = FastAPI(docs_url="/api/docs", openapi_url="/api/openapi.json", version=__version__) + app = FastAPI( + docs_url="/api/docs", + openapi_url="/api/openapi.json", + lifespan=app_lifespan, + version=__version__, + ) app.add_middleware( CORSMiddleware, allow_origin_regex=deployment_domain_regex, diff --git a/src/matrixrmapi/config.py b/src/matrixrmapi/config.py index 522c132..0a3f6d3 100644 --- a/src/matrixrmapi/config.py +++ b/src/matrixrmapi/config.py @@ -12,6 +12,11 @@ LOG_LEVEL: int = cfg("LOG_LEVEL", default=20, cast=int) TEMPLATES_PATH: Path = cfg("TEMPLATES_PATH", cast=Path, default=Path(__file__).parent / "templates") +SYNAPSE_URL: str = cfg("SYNAPSE_URL", default="http://synapse:8008") +SYNAPSE_REGISTRATION_SECRET: str = cfg("SYNAPSE_REGISTRATION_SECRET", default="") +SYNAPSE_BOT_USERNAME: str = cfg("SYNAPSE_BOT_USERNAME", default="matrixrmapi-bot") +SYNAPSE_TOKEN_FILE: Path = cfg("SYNAPSE_TOKEN_FILE", cast=Path, default=Path("/data/persistent/synapse_admin_token")) + @functools.cache def get_manifest() -> Dict[str, Any]: @@ -33,3 +38,12 @@ def get_manifest() -> Dict[str, Any]: } data = json.loads(pth.read_text(encoding="utf-8")) return cast(Dict[str, Any], data) + + +def get_server_domain() -> str: + """Derive Matrix server_name by stripping the first DNS label from product DNS. + + E.g. 'matrix.golden-monkey.dev.pvarki.fi' -> 'golden-monkey.dev.pvarki.fi' + """ + dns: str = get_manifest()["product"]["dns"] + return ".".join(dns.split(".")[1:]) diff --git a/src/matrixrmapi/synapseutils/__init__.py b/src/matrixrmapi/synapseutils/__init__.py new file mode 100644 index 0000000..7d82813 --- /dev/null +++ b/src/matrixrmapi/synapseutils/__init__.py @@ -0,0 +1 @@ +"""Synapse admin utilities""" diff --git a/src/matrixrmapi/synapseutils/synapse_admin.py b/src/matrixrmapi/synapseutils/synapse_admin.py new file mode 100644 index 0000000..77a45f0 --- /dev/null +++ b/src/matrixrmapi/synapseutils/synapse_admin.py @@ -0,0 +1,327 @@ +"""Synapse admin API helper""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +import os +import re +import secrets +from pathlib import Path +from typing import Any, Dict, List, Optional +from urllib.parse import quote + +import httpx + +LOGGER = logging.getLogger(__name__) + +_MATRIX_LOCALPART_RE = re.compile(r"^[a-z0-9._\-=/+]+$") + + +def matrix_user_id(callsign: str, server_domain: str) -> str: + """Build @localpart:domain from callsign. Raises ValueError for invalid callsigns.""" + localpart = callsign.lower() + if not _MATRIX_LOCALPART_RE.match(localpart): + raise ValueError(f"Callsign {callsign!r} produces invalid Matrix localpart: {localpart!r}") + return f"@{localpart}:{server_domain}" + + +class SynapseAdmin: + """Async wrapper for the Synapse admin API. + + Call setup() once before using any other methods. + Call close() when done (or use as an async context manager). + """ + + def __init__(self, synapse_url: str, server_domain: str) -> None: + self._url = synapse_url.rstrip("/") + self._domain = server_domain + self._token: Optional[str] = None + self._bot_user_id: Optional[str] = None + self._client: httpx.AsyncClient = httpx.AsyncClient() + + async def close(self) -> None: + """Close the underlying HTTP client.""" + await self._client.aclose() + + async def __aenter__(self) -> "SynapseAdmin": + return self + + async def __aexit__(self, *_: Any) -> None: + await self.close() + + # ------------------------------------------------------------------ + # Initialisation + # ------------------------------------------------------------------ + + async def setup(self, registration_secret: str, bot_username: str, token_file: Path) -> None: + """Acquire admin token: load from file or register bot if missing/invalid.""" + self._bot_user_id = f"@{bot_username}:{self._domain}" + if token_file.exists(): + candidate = token_file.read_text().strip() + if await self._validate(candidate): + self._token = candidate + LOGGER.info("Reused bot token from %s", token_file) + await self._exempt_bot_from_ratelimit(bot_username) + return + LOGGER.warning("Stored token invalid, will re-register bot") + + token = await self._register_bot(registration_secret, bot_username) + token_file.parent.mkdir(parents=True, exist_ok=True) + token_file.write_text(token) + os.chmod(token_file, 0o600) + self._token = token + LOGGER.info("Bot registered; token saved to %s", token_file) + + await self._exempt_bot_from_ratelimit(bot_username) + + async def _exempt_bot_from_ratelimit(self, bot_username: str) -> None: + """Remove rate-limit restrictions for the bot user so concurrent room setup never gets 429.""" + user_id = f"@{bot_username}:{self._domain}" + encoded = quote(user_id, safe="") + try: + resp = await self._client.post( + f"{self._url}/_synapse/admin/v1/users/{encoded}/override_ratelimit", + headers=self._auth, + json={"messages_per_second": 0, "burst_count": 0}, + timeout=10.0, + ) + resp.raise_for_status() + LOGGER.info("Rate-limit override applied for %s", user_id) + except Exception as exc: # pylint: disable=broad-except + LOGGER.warning("Failed to override rate limit for %s: %s", user_id, exc) + + async def _validate(self, token: str) -> bool: + """Return True if token is accepted by the admin API.""" + try: + resp = await self._client.get( + f"{self._url}/_synapse/admin/v1/server_version", + headers={"Authorization": f"Bearer {token}"}, + timeout=10.0, + ) + return resp.status_code == 200 + except Exception: # pylint: disable=broad-except + return False + + async def _register_bot(self, registration_secret: str, username: str) -> str: + """Register a new Synapse admin user via HMAC-signed register endpoint.""" + nonce_resp = await self._client.get( + f"{self._url}/_synapse/admin/v1/register", + timeout=10.0, + ) + nonce_resp.raise_for_status() + nonce: str = nonce_resp.json()["nonce"] + + # Synapse uses HMAC-SHA1 for the registration MAC + rand_password = secrets.token_hex(32) + mac_content = f"{nonce}\0{username}\0{rand_password}\0admin" + mac = hmac.new( + registration_secret.encode("utf-8"), + mac_content.encode("utf-8"), + hashlib.sha1, # nosec B324 - required by Synapse registration API + ).hexdigest() + + reg_resp = await self._client.post( + f"{self._url}/_synapse/admin/v1/register", + json={ + "nonce": nonce, + "username": username, + "password": rand_password, + "admin": True, + "mac": mac, + }, + timeout=30.0, + ) + + if reg_resp.status_code == 400 and reg_resp.json().get("errcode") == "M_USER_IN_USE": + LOGGER.critical( + "Bot user @%s:%s already exists but no valid token file was found. " + "Manual recovery: deactivate the bot via Synapse admin UI, " + "delete the token file, then restart matrixrmapi.", + username, + self._domain, + ) + raise RuntimeError(f"Bot user already exists and cannot be recovered automatically: {username}") + + reg_resp.raise_for_status() + return str(reg_resp.json()["access_token"]) + + @property + def _auth(self) -> Dict[str, str]: + if not self._token: + raise RuntimeError("SynapseAdmin.setup() has not been called") + return {"Authorization": f"Bearer {self._token}"} + + # ------------------------------------------------------------------ + # Room / space management + # ------------------------------------------------------------------ + + async def room_id_for_alias(self, alias: str) -> Optional[str]: + """Return room_id for alias, or None if not found.""" + encoded = quote(alias, safe="") + resp = await self._client.get( + f"{self._url}/_matrix/client/v3/directory/room/{encoded}", + headers=self._auth, + timeout=10.0, + ) + if resp.status_code == 404: + return None + resp.raise_for_status() + return str(resp.json()["room_id"]) + + async def create_room( + self, + name: str, + alias: str, + *, + is_space: bool = False, + is_private: bool = False, + ) -> str: + """Create a room or space; return room_id.""" + local_part = alias.split(":")[0].lstrip("#") + body: Dict[str, Any] = { + "name": name, + "room_alias_name": local_part, + "preset": "private_chat" if is_private else "public_chat", + "visibility": "private", + } + if is_space: + body["creation_content"] = {"type": "m.space"} + # Set bot to power level 200 so it can always demote admins (who are at 100). + # Matrix spec: you cannot lower a user at power level >= your own. + if self._bot_user_id: + body["power_level_content_override"] = {"users": {self._bot_user_id: 200}} + + resp = await self._client.post( + f"{self._url}/_matrix/client/v3/createRoom", + headers=self._auth, + json=body, + timeout=30.0, + ) + resp.raise_for_status() + return str(resp.json()["room_id"]) + + async def add_child_to_space(self, space_id: str, room_id: str) -> None: + """Register room as a child of space.""" + encoded_room = quote(room_id, safe="") + resp = await self._client.put( + f"{self._url}/_matrix/client/v3/rooms/{space_id}/state/m.space.child/{encoded_room}", + headers=self._auth, + json={"via": [self._domain], "suggested": False}, + timeout=10.0, + ) + resp.raise_for_status() + + async def set_room_state(self, room_id: str, event_type: str, content: Dict[str, Any], state_key: str = "") -> None: + """Send a room state event.""" + path = f"{self._url}/_matrix/client/v3/rooms/{room_id}/state/{event_type}" + if state_key: + path = f"{path}/{quote(state_key, safe='')}" + resp = await self._client.put(path, headers=self._auth, json=content, timeout=10.0) + resp.raise_for_status() + + # ------------------------------------------------------------------ + # User management + # ------------------------------------------------------------------ + + async def force_join(self, room_id: str, user_id: str) -> None: + """Force-join user to room via admin API. + + Silently skips if the user does not exist in Synapse yet (404) — + auto_join_rooms in homeserver.yaml will handle the initial join. + """ + resp = await self._client.post( + f"{self._url}/_synapse/admin/v1/join/{room_id}", + headers=self._auth, + json={"user_id": user_id}, + timeout=10.0, + ) + if resp.status_code == 404: + LOGGER.info( + "User %s not in Synapse yet; skipping force_join (auto_join_rooms will handle it)", + user_id, + ) + return + if resp.status_code == 403: + body = resp.json() + if body.get("errcode") == "M_FORBIDDEN" and "already in the room" in body.get("error", ""): + LOGGER.info("User %s is already in room %s; skipping force_join", user_id, room_id) + return + resp.raise_for_status() + + async def deactivate(self, user_id: str) -> None: + """Deactivate and erase user. Silently succeeds if user does not exist in Synapse.""" + # Use v1 endpoint — v2 path (/v2/users/{id}/deactivate) is unrecognised in some + # Synapse versions; v1 has been stable since early Synapse releases. + resp = await self._client.post( + f"{self._url}/_synapse/admin/v1/deactivate/{quote(user_id, safe='')}", + headers=self._auth, + json={"erase": True}, + timeout=30.0, + ) + if resp.status_code == 404: + LOGGER.info("User %s not found in Synapse; nothing to deactivate", user_id) + return + resp.raise_for_status() + + async def get_power_levels(self, room_id: str) -> Dict[str, Any]: + """Get the m.room.power_levels state for a room.""" + resp = await self._client.get( + f"{self._url}/_matrix/client/v3/rooms/{room_id}/state/m.room.power_levels", + headers=self._auth, + timeout=10.0, + ) + resp.raise_for_status() + return dict(resp.json()) + + async def set_user_power_level(self, room_id: str, user_id: str, level: int) -> None: + """Set a single user's power level in a room.""" + levels = await self.get_power_levels(room_id) + users: Dict[str, int] = dict(levels.get("users", {})) + if level == 0: + users.pop(user_id, None) + else: + users[user_id] = level + levels["users"] = users + resp = await self._client.put( + f"{self._url}/_matrix/client/v3/rooms/{room_id}/state/m.room.power_levels", + headers=self._auth, + json=levels, + timeout=10.0, + ) + resp.raise_for_status() + + async def invite(self, room_id: str, user_id: str) -> None: + """Invite user to room.""" + resp = await self._client.post( + f"{self._url}/_matrix/client/v3/rooms/{room_id}/invite", + headers=self._auth, + json={"user_id": user_id}, + timeout=10.0, + ) + resp.raise_for_status() + + async def kick(self, room_id: str, user_id: str) -> None: + """Kick user from room. Silently skips if user is not in the room.""" + resp = await self._client.post( + f"{self._url}/_matrix/client/v3/rooms/{room_id}/kick", + headers=self._auth, + json={"user_id": user_id}, + timeout=10.0, + ) + if resp.status_code == 403: + body = resp.json() + if body.get("errcode") == "M_FORBIDDEN" and "not in the room" in body.get("error", ""): + LOGGER.info("User %s is not in room %s; skipping kick", user_id, room_id) + return + resp.raise_for_status() + + # ------------------------------------------------------------------ + # Batch helpers used by usercrud + # ------------------------------------------------------------------ + + async def set_power_level_in_rooms(self, room_ids: List[str], user_id: str, level: int) -> None: + """Set power level for user across multiple rooms.""" + for room_id in room_ids: + await self.set_user_power_level(room_id, user_id, level) diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..700cb5f --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,130 @@ +"""Unit tests for app-level helper functions in matrixrmapi.app.""" + +from __future__ import annotations + +from typing import Dict, cast +from unittest.mock import AsyncMock + +import pytest + +from matrixrmapi.app import _apply_pending, _ensure_room +from matrixrmapi.synapseutils.synapse_admin import SynapseAdmin + +_ROOMS: Dict[str, str] = { + "space": "!space:x", + "admin": "!admin:x", + "general": "!general:x", + "helpdesk": "!helpdesk:x", + "offtopic": "!offtopic:x", +} + +_PUBLIC_IDS = ["!space:x", "!general:x", "!helpdesk:x", "!offtopic:x"] + + +def _mock_synapse() -> AsyncMock: + """AsyncMock shaped like SynapseAdmin; cast to SynapseAdmin at call sites.""" + return AsyncMock(spec=SynapseAdmin) + + +# --------------------------------------------------------------------------- +# _apply_pending +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_apply_pending_promote_sets_power_and_joins_admin() -> None: + """promote: power level 100 in public rooms, force-join admin channel.""" + synapse = _mock_synapse() + await _apply_pending(cast(SynapseAdmin, synapse), _ROOMS, {"@user:x": "promote"}) + + synapse.set_power_level_in_rooms.assert_called_once_with(_PUBLIC_IDS, "@user:x", 100) + synapse.force_join.assert_called_once_with("!admin:x", "@user:x") + + +@pytest.mark.asyncio +async def test_apply_pending_demote_sets_power_and_kicks_admin() -> None: + """demote: power level 0 in public rooms, kick from admin channel.""" + synapse = _mock_synapse() + await _apply_pending(cast(SynapseAdmin, synapse), _ROOMS, {"@user:x": "demote"}) + + synapse.set_power_level_in_rooms.assert_called_once_with(_PUBLIC_IDS, "@user:x", 0) + synapse.kick.assert_called_once_with("!admin:x", "@user:x") + + +@pytest.mark.asyncio +async def test_apply_pending_multiple_users() -> None: + """All queued users are processed.""" + synapse = _mock_synapse() + await _apply_pending( + cast(SynapseAdmin, synapse), + _ROOMS, + {"@alice:x": "promote", "@bob:x": "demote"}, + ) + + assert synapse.set_power_level_in_rooms.call_count == 2 + synapse.set_power_level_in_rooms.assert_any_call(_PUBLIC_IDS, "@alice:x", 100) + synapse.set_power_level_in_rooms.assert_any_call(_PUBLIC_IDS, "@bob:x", 0) + + +@pytest.mark.asyncio +async def test_apply_pending_no_admin_room_skips_join() -> None: + """If admin room is missing from rooms dict, no force_join/kick is attempted.""" + synapse = _mock_synapse() + rooms_no_admin = {k: v for k, v in _ROOMS.items() if k != "admin"} + await _apply_pending(cast(SynapseAdmin, synapse), rooms_no_admin, {"@user:x": "promote"}) + + synapse.force_join.assert_not_called() + + +@pytest.mark.asyncio +async def test_apply_pending_error_is_caught_not_raised() -> None: + """A failure for one user must not propagate — subsequent users still run.""" + synapse = _mock_synapse() + # Make first call raise, second call succeed + synapse.set_power_level_in_rooms.side_effect = [ + RuntimeError("transient failure"), + None, + ] + # Should not raise even though first user fails + await _apply_pending( + cast(SynapseAdmin, synapse), + _ROOMS, + {"@alice:x": "promote", "@bob:x": "promote"}, + ) + assert synapse.set_power_level_in_rooms.call_count == 2 + + +@pytest.mark.asyncio +async def test_apply_pending_empty_queue_is_noop() -> None: + """Empty pending dict must be a no-op — no Synapse calls made.""" + synapse = _mock_synapse() + await _apply_pending(cast(SynapseAdmin, synapse), _ROOMS, {}) + synapse.set_power_level_in_rooms.assert_not_called() + synapse.force_join.assert_not_called() + synapse.kick.assert_not_called() + + +# --------------------------------------------------------------------------- +# _ensure_room +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_ensure_room_returns_existing_room() -> None: + """If the alias already exists, no room is created.""" + synapse = _mock_synapse() + synapse.room_id_for_alias.return_value = "!existing:example.test" + result = await _ensure_room(cast(SynapseAdmin, synapse), "General", "#general:example.test", False, False) + assert result == "!existing:example.test" + synapse.create_room.assert_not_called() + + +@pytest.mark.asyncio +async def test_ensure_room_creates_new_room() -> None: + """If the alias is not found, the room is created.""" + synapse = _mock_synapse() + synapse.room_id_for_alias.return_value = None + synapse.create_room.return_value = "!new:example.test" + result = await _ensure_room(cast(SynapseAdmin, synapse), "General", "#general:example.test", False, False) + assert result == "!new:example.test" + synapse.create_room.assert_called_once() diff --git a/tests/test_crud.py b/tests/test_crud.py index 421c185..1b4c867 100644 --- a/tests/test_crud.py +++ b/tests/test_crud.py @@ -2,8 +2,10 @@ from typing import Dict import logging +import uuid from fastapi.testclient import TestClient +from matrixrmapi.config import get_server_domain from .conftest import APP LOGGER = logging.getLogger(__name__) @@ -62,3 +64,54 @@ def test_demote(norppa11: Dict[str, str], rm_mtlsclient: TestClient) -> None: payload = resp.json() assert "success" in payload assert payload["success"] + + +# --------------------------------------------------------------------------- +# Deferred-queue behaviour (Synapse not yet ready) +# --------------------------------------------------------------------------- + + +def _unique_user() -> Dict[str, str]: + """Return a user dict with a unique callsign to avoid state collisions.""" + tag = uuid.uuid4().hex[:6] + return { + "uuid": str(uuid.uuid4()), + "callsign": f"queue{tag}", + "x509cert": "FIXME: dummy", + } + + +def test_promote_queues_uid_when_synapse_not_ready(rm_mtlsclient: TestClient) -> None: + """When Synapse is not yet initialised, /promoted must enqueue the uid.""" + user = _unique_user() + resp = rm_mtlsclient.post("/api/v1/users/promoted", json=user) + assert resp.status_code == 200 + assert resp.json()["success"] is True + + uid = f"@{user['callsign'].lower()}:{get_server_domain()}" + pending: Dict[str, str] = getattr(APP.state, "pending_promotions", {}) + assert pending.get(uid) == "promote" + + +def test_demote_queues_uid_when_synapse_not_ready(rm_mtlsclient: TestClient) -> None: + """When Synapse is not yet initialised, /demoted must enqueue the uid.""" + user = _unique_user() + resp = rm_mtlsclient.post("/api/v1/users/demoted", json=user) + assert resp.status_code == 200 + assert resp.json()["success"] is True + + uid = f"@{user['callsign'].lower()}:{get_server_domain()}" + pending: Dict[str, str] = getattr(APP.state, "pending_promotions", {}) + assert pending.get(uid) == "demote" + + +def test_demote_overwrites_pending_promote(rm_mtlsclient: TestClient) -> None: + """If a user is promoted then demoted before Synapse is ready, only demote survives.""" + user = _unique_user() + uid = f"@{user['callsign'].lower()}:{get_server_domain()}" + + rm_mtlsclient.post("/api/v1/users/promoted", json=user) + rm_mtlsclient.post("/api/v1/users/demoted", json=user) + + pending: Dict[str, str] = getattr(APP.state, "pending_promotions", {}) + assert pending.get(uid) == "demote" diff --git a/tests/test_synapse_admin.py b/tests/test_synapse_admin.py new file mode 100644 index 0000000..dd826e9 --- /dev/null +++ b/tests/test_synapse_admin.py @@ -0,0 +1,390 @@ +"""Unit tests for SynapseAdmin and matrix_user_id. + +All HTTP calls are intercepted by patching the httpx.AsyncClient method on the +SynapseAdmin instance, so no real network is required. +""" + +from __future__ import annotations + +from typing import Any, Dict +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from matrixrmapi.synapseutils.synapse_admin import SynapseAdmin, matrix_user_id + +# httpx.Response needs a request object to call raise_for_status() cleanly. +_FAKE_REQUEST = httpx.Request("POST", "http://synapse.test/fake") + + +def _fake(status: int, body: Dict[str, Any]) -> httpx.Response: + """Build a minimal fake httpx.Response.""" + return httpx.Response(status, json=body, request=_FAKE_REQUEST) + + +def _make_synapse() -> SynapseAdmin: + """Return a SynapseAdmin ready for unit testing (real token + bot user set directly).""" + sa = SynapseAdmin("http://synapse.test", "example.test") + sa._token = "test-token" # pylint: disable=protected-access # nosec B105 + sa._bot_user_id = "@bot:example.test" # pylint: disable=protected-access + return sa + + +# --------------------------------------------------------------------------- +# matrix_user_id +# --------------------------------------------------------------------------- + + +def test_matrix_user_id_lowercases_callsign() -> None: + """Uppercase callsign must be lowercased in the resulting MXID.""" + assert matrix_user_id("NORPPA11", "example.test") == "@norppa11:example.test" + + +def test_matrix_user_id_already_lowercase() -> None: + """Lowercase callsign must pass through unchanged.""" + assert matrix_user_id("norppa11", "example.test") == "@norppa11:example.test" + + +def test_matrix_user_id_with_dots_and_dashes() -> None: + """Dots and dashes are valid MXID characters and must be preserved.""" + assert matrix_user_id("unit.11-a", "example.test") == "@unit.11-a:example.test" + + +def test_matrix_user_id_space_raises() -> None: + """Callsign with a space is invalid and must raise ValueError.""" + with pytest.raises(ValueError): + matrix_user_id("has space", "example.test") + + +def test_matrix_user_id_at_sign_raises() -> None: + """Callsign containing '@' is invalid and must raise ValueError.""" + with pytest.raises(ValueError): + matrix_user_id("bad@sign", "example.test") + + +def test_matrix_user_id_special_chars_raise() -> None: + """Callsign with other forbidden characters (e.g. '!') must raise ValueError.""" + with pytest.raises(ValueError): + matrix_user_id("excl!ama", "example.test") + + +# --------------------------------------------------------------------------- +# force_join +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_force_join_success() -> None: + """Happy path: 200 response is accepted without error.""" + sa = _make_synapse() + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + mock_post.return_value = _fake(200, {"room_id": "!r:example.test"}) + await sa.force_join("!r:example.test", "@user:example.test") + mock_post.assert_called_once() + + +@pytest.mark.asyncio +async def test_force_join_user_not_in_synapse_skips() -> None: + """404 means user hasn't logged in yet — must silently skip.""" + sa = _make_synapse() + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + mock_post.return_value = _fake(404, {"errcode": "M_NOT_FOUND"}) + await sa.force_join("!r:example.test", "@ghost:example.test") # must not raise + + +@pytest.mark.asyncio +async def test_force_join_already_in_room_skips() -> None: + """403 + already in the room — idempotent, must not raise.""" + sa = _make_synapse() + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + mock_post.return_value = _fake(403, {"errcode": "M_FORBIDDEN", "error": "User is already in the room."}) + await sa.force_join("!r:example.test", "@user:example.test") # must not raise + + +@pytest.mark.asyncio +async def test_force_join_other_403_raises() -> None: + """403 with an unrecognised reason must propagate as HTTPStatusError.""" + sa = _make_synapse() + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + mock_post.return_value = _fake(403, {"errcode": "M_FORBIDDEN", "error": "You do not have permission."}) + with pytest.raises(httpx.HTTPStatusError): + await sa.force_join("!r:example.test", "@user:example.test") + + +# --------------------------------------------------------------------------- +# deactivate +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_deactivate_success() -> None: + """Happy path: 200 response is accepted without error.""" + sa = _make_synapse() + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + mock_post.return_value = _fake(200, {"id_server_unbind_result": "success"}) + await sa.deactivate("@user:example.test") + mock_post.assert_called_once() + + +@pytest.mark.asyncio +async def test_deactivate_user_not_found_skips() -> None: + """User never logged in to Matrix — 404 must silently succeed.""" + sa = _make_synapse() + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + mock_post.return_value = _fake(404, {"errcode": "M_NOT_FOUND"}) + await sa.deactivate("@ghost:example.test") # must not raise + + +@pytest.mark.asyncio +async def test_deactivate_calls_v1_endpoint() -> None: + """Admin API endpoint must be the stable v1 path.""" + sa = _make_synapse() + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + mock_post.return_value = _fake(200, {}) + await sa.deactivate("@user:example.test") + url: str = mock_post.call_args.args[0] + assert "/_synapse/admin/v1/deactivate/" in url + assert "v2" not in url + + +@pytest.mark.asyncio +async def test_deactivate_sends_erase_true() -> None: + """Deactivation must request GDPR erase.""" + sa = _make_synapse() + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + mock_post.return_value = _fake(200, {}) + await sa.deactivate("@user:example.test") + body: Dict[str, Any] = mock_post.call_args.kwargs["json"] + assert body.get("erase") is True + + +# --------------------------------------------------------------------------- +# kick +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_kick_success() -> None: + """Happy path: 200 response is accepted without error.""" + sa = _make_synapse() + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + mock_post.return_value = _fake(200, {}) + await sa.kick("!r:example.test", "@user:example.test") + mock_post.assert_called_once() + + +@pytest.mark.asyncio +async def test_kick_not_in_room_skips() -> None: + """403 + not in the room — user already left; must not raise.""" + sa = _make_synapse() + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + mock_post.return_value = _fake(403, {"errcode": "M_FORBIDDEN", "error": "User is not in the room."}) + await sa.kick("!r:example.test", "@user:example.test") # must not raise + + +@pytest.mark.asyncio +async def test_kick_other_403_raises() -> None: + """403 with an unrecognised reason must propagate.""" + sa = _make_synapse() + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + mock_post.return_value = _fake(403, {"errcode": "M_FORBIDDEN", "error": "You do not have permission."}) + with pytest.raises(httpx.HTTPStatusError): + await sa.kick("!r:example.test", "@user:example.test") + + +# --------------------------------------------------------------------------- +# create_room +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_room_sets_bot_at_power_200() -> None: + """Bot must start at power level 200 so it can demote users at 100.""" + sa = _make_synapse() + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + mock_post.return_value = _fake(200, {"room_id": "!new:example.test"}) + await sa.create_room("TestRoom", "#test-room:example.test") + body: Dict[str, Any] = mock_post.call_args.kwargs["json"] + users: Dict[str, int] = body.get("power_level_content_override", {}).get("users", {}) + assert users.get("@bot:example.test") == 200 + + +@pytest.mark.asyncio +async def test_create_space_sets_creation_content() -> None: + """Spaces need creation_content.type = m.space.""" + sa = _make_synapse() + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + mock_post.return_value = _fake(200, {"room_id": "!space:example.test"}) + await sa.create_room("MySpace", "#my-space:example.test", is_space=True) + body: Dict[str, Any] = mock_post.call_args.kwargs["json"] + assert body.get("creation_content", {}).get("type") == "m.space" + + +@pytest.mark.asyncio +async def test_create_private_room_uses_private_preset() -> None: + """private_chat preset must be used when is_private=True.""" + sa = _make_synapse() + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + mock_post.return_value = _fake(200, {"room_id": "!priv:example.test"}) + await sa.create_room("Admin", "#admin:example.test", is_private=True) + body: Dict[str, Any] = mock_post.call_args.kwargs["json"] + assert body.get("preset") == "private_chat" + + +# --------------------------------------------------------------------------- +# room_id_for_alias +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_room_id_for_alias_found() -> None: + """Resolving a known alias returns the room ID.""" + sa = _make_synapse() + with patch.object(sa._client, "get", new_callable=AsyncMock) as mock_get: # pylint: disable=protected-access + mock_get.return_value = _fake(200, {"room_id": "!abc:example.test"}) + result = await sa.room_id_for_alias("#general:example.test") + assert result == "!abc:example.test" + + +@pytest.mark.asyncio +async def test_room_id_for_alias_not_found_returns_none() -> None: + """404 from directory lookup must return None, not raise.""" + sa = _make_synapse() + with patch.object(sa._client, "get", new_callable=AsyncMock) as mock_get: # pylint: disable=protected-access + mock_get.return_value = _fake(404, {"errcode": "M_NOT_FOUND"}) + result = await sa.room_id_for_alias("#nonexistent:example.test") + assert result is None + + +# --------------------------------------------------------------------------- +# add_child_to_space +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_add_child_to_space() -> None: + """Child room must be linked via m.space.child state event.""" + sa = _make_synapse() + with patch.object(sa._client, "put", new_callable=AsyncMock) as mock_put: # pylint: disable=protected-access + mock_put.return_value = _fake(200, {}) + await sa.add_child_to_space("!space:example.test", "!room:example.test") + mock_put.assert_called_once() + url: str = mock_put.call_args.args[0] + assert "m.space.child" in url + + +# --------------------------------------------------------------------------- +# set_room_state +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_set_room_state_without_state_key() -> None: + """State event type must appear in the PUT URL when no state key is given.""" + sa = _make_synapse() + with patch.object(sa._client, "put", new_callable=AsyncMock) as mock_put: # pylint: disable=protected-access + mock_put.return_value = _fake(200, {}) + await sa.set_room_state("!r:example.test", "m.room.name", {"name": "Test"}) + url: str = mock_put.call_args.args[0] + assert "m.room.name" in url + + +@pytest.mark.asyncio +async def test_set_room_state_with_state_key() -> None: + """State key must be URL-encoded into the PUT path.""" + sa = _make_synapse() + with patch.object(sa._client, "put", new_callable=AsyncMock) as mock_put: # pylint: disable=protected-access + mock_put.return_value = _fake(200, {}) + await sa.set_room_state( + "!r:example.test", "m.space.child", {"via": ["example.test"]}, state_key="!child:example.test" + ) + url: str = mock_put.call_args.args[0] + assert "m.space.child" in url + assert "%21child" in url # state key is URL-encoded into the path + + +# --------------------------------------------------------------------------- +# get_power_levels / set_user_power_level +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_power_levels() -> None: + """Power level state is returned as a dict.""" + sa = _make_synapse() + power_state = {"users": {"@bot:example.test": 200}, "users_default": 0} + with patch.object(sa._client, "get", new_callable=AsyncMock) as mock_get: # pylint: disable=protected-access + mock_get.return_value = _fake(200, power_state) + result = await sa.get_power_levels("!r:example.test") + assert result["users"]["@bot:example.test"] == 200 + + +@pytest.mark.asyncio +async def test_set_user_power_level_nonzero() -> None: + """Setting a non-zero level must PUT the updated power levels state.""" + sa = _make_synapse() + client = sa._client # pylint: disable=protected-access + initial = {"users": {}, "users_default": 0} + with patch.object(client, "get", new_callable=AsyncMock) as mock_get, patch.object( + client, "put", new_callable=AsyncMock + ) as mock_put: + mock_get.return_value = _fake(200, initial) + mock_put.return_value = _fake(200, {}) + await sa.set_user_power_level("!r:example.test", "@user:example.test", 100) + body: Dict[str, Any] = mock_put.call_args.kwargs["json"] + assert body["users"]["@user:example.test"] == 100 + + +@pytest.mark.asyncio +async def test_set_user_power_level_zero_removes_user() -> None: + """Setting level 0 must remove the user entry rather than writing 0.""" + sa = _make_synapse() + client = sa._client # pylint: disable=protected-access + initial = {"users": {"@user:example.test": 100}, "users_default": 0} + with patch.object(client, "get", new_callable=AsyncMock) as mock_get, patch.object( + client, "put", new_callable=AsyncMock + ) as mock_put: + mock_get.return_value = _fake(200, initial) + mock_put.return_value = _fake(200, {}) + await sa.set_user_power_level("!r:example.test", "@user:example.test", 0) + body: Dict[str, Any] = mock_put.call_args.kwargs["json"] + assert "@user:example.test" not in body["users"] + + +# --------------------------------------------------------------------------- +# invite +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_invite_success() -> None: + """Invite POST must include user_id in the request body.""" + sa = _make_synapse() + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + mock_post.return_value = _fake(200, {}) + await sa.invite("!r:example.test", "@user:example.test") + body: Dict[str, Any] = mock_post.call_args.kwargs["json"] + assert body["user_id"] == "@user:example.test" + + +# --------------------------------------------------------------------------- +# set_power_level_in_rooms (batch helper) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_set_power_level_in_rooms_calls_each_room() -> None: + """set_power_level_in_rooms must call get+put once per room.""" + sa = _make_synapse() + client = sa._client # pylint: disable=protected-access + room_ids = ["!r1:example.test", "!r2:example.test", "!r3:example.test"] + initial = {"users": {}, "users_default": 0} + with patch.object(client, "get", new_callable=AsyncMock) as mock_get, patch.object( + client, "put", new_callable=AsyncMock + ) as mock_put: + mock_get.return_value = _fake(200, initial) + mock_put.return_value = _fake(200, {}) + await sa.set_power_level_in_rooms(room_ids, "@user:example.test", 100) + assert mock_get.call_count == 3 + assert mock_put.call_count == 3 diff --git a/ui/package.json b/ui/package.json index 49b4de4..4df4179 100644 --- a/ui/package.json +++ b/ui/package.json @@ -28,7 +28,7 @@ }, "devDependencies": { "@module-federation/enhanced": "^0.21.2", - "@module-federation/vite": "1.7.1", + "@module-federation/vite": "^1.7.1", "@tailwindcss/typography": "^0.5.19", "@types/react": "18.2.79", "@types/react-dom": "18.2.25", @@ -36,7 +36,7 @@ "prettier": "3.6.2", "tw-animate-css": "^1.4.0", "typescript": "5.4.5", - "vite": "5.2.10" + "vite": "^5.4.21" }, "name": "matrix-module-federation", "scripts": { @@ -45,5 +45,16 @@ "preview": "npm run build && vite preview --port 4174" }, "type": "module", - "version": "0.0.0" + "version": "0.0.0", + "pnpm": { + "overrides": { + "seroval": ">=1.4.1", + "rollup": ">=4.59.0", + "flatted": ">=3.4.2", + "picomatch": ">=4.0.4", + "axios": ">=1.13.5", + "koa": ">=3.1.2", + "ajv@>=7.0.0-alpha.0": ">=8.18.0" + } + } } diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 3968e36..737dd2e 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -4,6 +4,15 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + seroval: ">=1.4.1" + rollup: ">=4.59.0" + flatted: ">=3.4.2" + picomatch: ">=4.0.4" + axios: ">=1.13.5" + koa: ">=3.1.2" + ajv@>=7.0.0-alpha.0: ">=8.18.0" + importers: .: dependencies: @@ -30,7 +39,7 @@ importers: version: 1.1.13(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) "@tailwindcss/vite": specifier: ^4.1.16 - version: 4.1.16(vite@5.2.10(@types/node@24.10.0)(lightningcss@1.30.2)) + version: 4.1.16(vite@5.4.21(@types/node@24.10.0)(lightningcss@1.30.2)) "@tanstack/react-query": specifier: ^5.90.11 version: 5.90.11(react@18.3.1) @@ -84,8 +93,8 @@ importers: version: 1.1.2(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: "@module-federation/vite": - specifier: 1.7.1 - version: 1.7.1(rollup@4.52.5) + specifier: ^1.7.1 + version: 1.7.1(rollup@4.60.1) "@tailwindcss/typography": specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.1.16) @@ -97,7 +106,7 @@ importers: version: 18.2.25 "@vitejs/plugin-react": specifier: 4.2.1 - version: 4.2.1(vite@5.2.10(@types/node@24.10.0)(lightningcss@1.30.2)) + version: 4.2.1(vite@5.4.21(@types/node@24.10.0)(lightningcss@1.30.2)) prettier: specifier: 3.6.2 version: 3.6.2 @@ -108,8 +117,8 @@ importers: specifier: 5.4.5 version: 5.4.5 vite: - specifier: 5.2.10 - version: 5.2.10(@types/node@24.10.0)(lightningcss@1.30.2) + specifier: ^5.4.21 + version: 5.4.21(@types/node@24.10.0)(lightningcss@1.30.2) packages: "@babel/code-frame@7.27.1": @@ -277,10 +286,10 @@ packages: integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==, } - "@esbuild/aix-ppc64@0.20.2": + "@esbuild/aix-ppc64@0.21.5": resolution: { - integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==, + integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==, } engines: { node: ">=12" } cpu: [ppc64] @@ -295,10 +304,10 @@ packages: cpu: [ppc64] os: [aix] - "@esbuild/android-arm64@0.20.2": + "@esbuild/android-arm64@0.21.5": resolution: { - integrity: sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==, + integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==, } engines: { node: ">=12" } cpu: [arm64] @@ -313,10 +322,10 @@ packages: cpu: [arm64] os: [android] - "@esbuild/android-arm@0.20.2": + "@esbuild/android-arm@0.21.5": resolution: { - integrity: sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==, + integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==, } engines: { node: ">=12" } cpu: [arm] @@ -331,10 +340,10 @@ packages: cpu: [arm] os: [android] - "@esbuild/android-x64@0.20.2": + "@esbuild/android-x64@0.21.5": resolution: { - integrity: sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==, + integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==, } engines: { node: ">=12" } cpu: [x64] @@ -349,10 +358,10 @@ packages: cpu: [x64] os: [android] - "@esbuild/darwin-arm64@0.20.2": + "@esbuild/darwin-arm64@0.21.5": resolution: { - integrity: sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==, + integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==, } engines: { node: ">=12" } cpu: [arm64] @@ -367,10 +376,10 @@ packages: cpu: [arm64] os: [darwin] - "@esbuild/darwin-x64@0.20.2": + "@esbuild/darwin-x64@0.21.5": resolution: { - integrity: sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==, + integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==, } engines: { node: ">=12" } cpu: [x64] @@ -385,10 +394,10 @@ packages: cpu: [x64] os: [darwin] - "@esbuild/freebsd-arm64@0.20.2": + "@esbuild/freebsd-arm64@0.21.5": resolution: { - integrity: sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==, + integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==, } engines: { node: ">=12" } cpu: [arm64] @@ -403,10 +412,10 @@ packages: cpu: [arm64] os: [freebsd] - "@esbuild/freebsd-x64@0.20.2": + "@esbuild/freebsd-x64@0.21.5": resolution: { - integrity: sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==, + integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==, } engines: { node: ">=12" } cpu: [x64] @@ -421,10 +430,10 @@ packages: cpu: [x64] os: [freebsd] - "@esbuild/linux-arm64@0.20.2": + "@esbuild/linux-arm64@0.21.5": resolution: { - integrity: sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==, + integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==, } engines: { node: ">=12" } cpu: [arm64] @@ -439,10 +448,10 @@ packages: cpu: [arm64] os: [linux] - "@esbuild/linux-arm@0.20.2": + "@esbuild/linux-arm@0.21.5": resolution: { - integrity: sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==, + integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==, } engines: { node: ">=12" } cpu: [arm] @@ -457,10 +466,10 @@ packages: cpu: [arm] os: [linux] - "@esbuild/linux-ia32@0.20.2": + "@esbuild/linux-ia32@0.21.5": resolution: { - integrity: sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==, + integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==, } engines: { node: ">=12" } cpu: [ia32] @@ -475,10 +484,10 @@ packages: cpu: [ia32] os: [linux] - "@esbuild/linux-loong64@0.20.2": + "@esbuild/linux-loong64@0.21.5": resolution: { - integrity: sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==, + integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==, } engines: { node: ">=12" } cpu: [loong64] @@ -493,10 +502,10 @@ packages: cpu: [loong64] os: [linux] - "@esbuild/linux-mips64el@0.20.2": + "@esbuild/linux-mips64el@0.21.5": resolution: { - integrity: sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==, + integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==, } engines: { node: ">=12" } cpu: [mips64el] @@ -511,10 +520,10 @@ packages: cpu: [mips64el] os: [linux] - "@esbuild/linux-ppc64@0.20.2": + "@esbuild/linux-ppc64@0.21.5": resolution: { - integrity: sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==, + integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==, } engines: { node: ">=12" } cpu: [ppc64] @@ -529,10 +538,10 @@ packages: cpu: [ppc64] os: [linux] - "@esbuild/linux-riscv64@0.20.2": + "@esbuild/linux-riscv64@0.21.5": resolution: { - integrity: sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==, + integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==, } engines: { node: ">=12" } cpu: [riscv64] @@ -547,10 +556,10 @@ packages: cpu: [riscv64] os: [linux] - "@esbuild/linux-s390x@0.20.2": + "@esbuild/linux-s390x@0.21.5": resolution: { - integrity: sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==, + integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==, } engines: { node: ">=12" } cpu: [s390x] @@ -565,10 +574,10 @@ packages: cpu: [s390x] os: [linux] - "@esbuild/linux-x64@0.20.2": + "@esbuild/linux-x64@0.21.5": resolution: { - integrity: sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==, + integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==, } engines: { node: ">=12" } cpu: [x64] @@ -592,10 +601,10 @@ packages: cpu: [arm64] os: [netbsd] - "@esbuild/netbsd-x64@0.20.2": + "@esbuild/netbsd-x64@0.21.5": resolution: { - integrity: sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==, + integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==, } engines: { node: ">=12" } cpu: [x64] @@ -619,10 +628,10 @@ packages: cpu: [arm64] os: [openbsd] - "@esbuild/openbsd-x64@0.20.2": + "@esbuild/openbsd-x64@0.21.5": resolution: { - integrity: sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==, + integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==, } engines: { node: ">=12" } cpu: [x64] @@ -637,10 +646,10 @@ packages: cpu: [x64] os: [openbsd] - "@esbuild/sunos-x64@0.20.2": + "@esbuild/sunos-x64@0.21.5": resolution: { - integrity: sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==, + integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==, } engines: { node: ">=12" } cpu: [x64] @@ -655,10 +664,10 @@ packages: cpu: [x64] os: [sunos] - "@esbuild/win32-arm64@0.20.2": + "@esbuild/win32-arm64@0.21.5": resolution: { - integrity: sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==, + integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==, } engines: { node: ">=12" } cpu: [arm64] @@ -673,10 +682,10 @@ packages: cpu: [arm64] os: [win32] - "@esbuild/win32-ia32@0.20.2": + "@esbuild/win32-ia32@0.21.5": resolution: { - integrity: sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==, + integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==, } engines: { node: ">=12" } cpu: [ia32] @@ -691,10 +700,10 @@ packages: cpu: [ia32] os: [win32] - "@esbuild/win32-x64@0.20.2": + "@esbuild/win32-x64@0.21.5": resolution: { - integrity: sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==, + integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==, } engines: { node: ">=12" } cpu: [x64] @@ -1866,183 +1875,207 @@ packages: } engines: { node: ">=14.0.0" } peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + rollup: ">=4.59.0" peerDependenciesMeta: rollup: optional: true - "@rollup/rollup-android-arm-eabi@4.52.5": + "@rollup/rollup-android-arm-eabi@4.60.1": resolution: { - integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==, + integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==, } cpu: [arm] os: [android] - "@rollup/rollup-android-arm64@4.52.5": + "@rollup/rollup-android-arm64@4.60.1": resolution: { - integrity: sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==, + integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==, } cpu: [arm64] os: [android] - "@rollup/rollup-darwin-arm64@4.52.5": + "@rollup/rollup-darwin-arm64@4.60.1": resolution: { - integrity: sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==, + integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==, } cpu: [arm64] os: [darwin] - "@rollup/rollup-darwin-x64@4.52.5": + "@rollup/rollup-darwin-x64@4.60.1": resolution: { - integrity: sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==, + integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==, } cpu: [x64] os: [darwin] - "@rollup/rollup-freebsd-arm64@4.52.5": + "@rollup/rollup-freebsd-arm64@4.60.1": resolution: { - integrity: sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==, + integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==, } cpu: [arm64] os: [freebsd] - "@rollup/rollup-freebsd-x64@4.52.5": + "@rollup/rollup-freebsd-x64@4.60.1": resolution: { - integrity: sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==, + integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==, } cpu: [x64] os: [freebsd] - "@rollup/rollup-linux-arm-gnueabihf@4.52.5": + "@rollup/rollup-linux-arm-gnueabihf@4.60.1": resolution: { - integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==, + integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==, } cpu: [arm] os: [linux] - "@rollup/rollup-linux-arm-musleabihf@4.52.5": + "@rollup/rollup-linux-arm-musleabihf@4.60.1": resolution: { - integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==, + integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==, } cpu: [arm] os: [linux] - "@rollup/rollup-linux-arm64-gnu@4.52.5": + "@rollup/rollup-linux-arm64-gnu@4.60.1": resolution: { - integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==, + integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==, } cpu: [arm64] os: [linux] - "@rollup/rollup-linux-arm64-musl@4.52.5": + "@rollup/rollup-linux-arm64-musl@4.60.1": resolution: { - integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==, + integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==, } cpu: [arm64] os: [linux] - "@rollup/rollup-linux-loong64-gnu@4.52.5": + "@rollup/rollup-linux-loong64-gnu@4.60.1": resolution: { - integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==, + integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==, } cpu: [loong64] os: [linux] - "@rollup/rollup-linux-ppc64-gnu@4.52.5": + "@rollup/rollup-linux-loong64-musl@4.60.1": + resolution: + { + integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==, + } + cpu: [loong64] + os: [linux] + + "@rollup/rollup-linux-ppc64-gnu@4.60.1": + resolution: + { + integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==, + } + cpu: [ppc64] + os: [linux] + + "@rollup/rollup-linux-ppc64-musl@4.60.1": resolution: { - integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==, + integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==, } cpu: [ppc64] os: [linux] - "@rollup/rollup-linux-riscv64-gnu@4.52.5": + "@rollup/rollup-linux-riscv64-gnu@4.60.1": resolution: { - integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==, + integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==, } cpu: [riscv64] os: [linux] - "@rollup/rollup-linux-riscv64-musl@4.52.5": + "@rollup/rollup-linux-riscv64-musl@4.60.1": resolution: { - integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==, + integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==, } cpu: [riscv64] os: [linux] - "@rollup/rollup-linux-s390x-gnu@4.52.5": + "@rollup/rollup-linux-s390x-gnu@4.60.1": resolution: { - integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==, + integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==, } cpu: [s390x] os: [linux] - "@rollup/rollup-linux-x64-gnu@4.52.5": + "@rollup/rollup-linux-x64-gnu@4.60.1": resolution: { - integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==, + integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==, } cpu: [x64] os: [linux] - "@rollup/rollup-linux-x64-musl@4.52.5": + "@rollup/rollup-linux-x64-musl@4.60.1": resolution: { - integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==, + integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==, } cpu: [x64] os: [linux] - "@rollup/rollup-openharmony-arm64@4.52.5": + "@rollup/rollup-openbsd-x64@4.60.1": resolution: { - integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==, + integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==, + } + cpu: [x64] + os: [openbsd] + + "@rollup/rollup-openharmony-arm64@4.60.1": + resolution: + { + integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==, } cpu: [arm64] os: [openharmony] - "@rollup/rollup-win32-arm64-msvc@4.52.5": + "@rollup/rollup-win32-arm64-msvc@4.60.1": resolution: { - integrity: sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==, + integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==, } cpu: [arm64] os: [win32] - "@rollup/rollup-win32-ia32-msvc@4.52.5": + "@rollup/rollup-win32-ia32-msvc@4.60.1": resolution: { - integrity: sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==, + integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==, } cpu: [ia32] os: [win32] - "@rollup/rollup-win32-x64-gnu@4.52.5": + "@rollup/rollup-win32-x64-gnu@4.60.1": resolution: { - integrity: sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==, + integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==, } cpu: [x64] os: [win32] - "@rollup/rollup-win32-x64-msvc@4.52.5": + "@rollup/rollup-win32-x64-msvc@4.60.1": resolution: { - integrity: sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==, + integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==, } cpu: [x64] os: [win32] @@ -2471,7 +2504,7 @@ packages: integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==, } peerDependencies: - ajv: ^8.0.0 + ajv: ">=8.18.0" peerDependenciesMeta: ajv: optional: true @@ -2482,12 +2515,12 @@ packages: integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==, } peerDependencies: - ajv: ^8.8.2 + ajv: ">=8.18.0" - ajv@8.17.1: + ajv@8.18.0: resolution: { - integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==, + integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==, } ansi-colors@4.1.3: @@ -2524,10 +2557,10 @@ packages: } engines: { node: ">= 4.0.0" } - axios@1.13.1: + axios@1.14.0: resolution: { - integrity: sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==, + integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==, } baseline-browser-mapping@2.8.23: @@ -2613,12 +2646,12 @@ packages: } engines: { node: ">=16" } - content-disposition@0.5.4: + content-disposition@1.0.1: resolution: { - integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==, + integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==, } - engines: { node: ">= 0.6" } + engines: { node: ">=18" } content-type@1.0.5: resolution: @@ -2806,10 +2839,10 @@ packages: } engines: { node: ">= 0.4" } - esbuild@0.20.2: + esbuild@0.21.5: resolution: { - integrity: sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==, + integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==, } engines: { node: ">=12" } hasBin: true @@ -2874,10 +2907,10 @@ packages: } engines: { node: ">=8" } - flatted@3.3.3: + flatted@3.4.2: resolution: { - integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==, + integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==, } follow-redirects@1.15.11: @@ -2892,10 +2925,10 @@ packages: debug: optional: true - form-data@4.0.4: + form-data@4.0.5: resolution: { - integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==, + integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==, } engines: { node: ">= 6" } @@ -3162,7 +3195,6 @@ packages: integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==, } engines: { node: ">= 0.6" } - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. koa-compose@4.1.0: resolution: @@ -3170,10 +3202,10 @@ packages: integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==, } - koa@3.0.3: + koa@3.2.0: resolution: { - integrity: sha512-MeuwbCoN1daWS32/Ni5qkzmrOtQO2qrnfdxDHjrm6s4b59yG4nexAJ0pTEFyzjLp0pBVO80CZp0vW8Ze30Ebow==, + integrity: sha512-TrM4/tnNY7uJ1aW55sIIa+dqBvc4V14WRIAlGcWat9wV5pRS9Wr5Zk2ZTjQP1jtfIHDoHiSbPuV08P0fUZo2pg==, } engines: { node: ">= 18" } @@ -3466,10 +3498,10 @@ packages: integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, } - picomatch@4.0.3: + picomatch@4.0.4: resolution: { - integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==, + integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==, } engines: { node: ">=12" } @@ -3495,11 +3527,12 @@ packages: engines: { node: ">=14" } hasBin: true - proxy-from-env@1.1.0: + proxy-from-env@2.1.0: resolution: { - integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==, + integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==, } + engines: { node: ">=10" } radix-ui@1.4.3: resolution: @@ -3639,10 +3672,10 @@ packages: integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==, } - rollup@4.52.5: + rollup@4.60.1: resolution: { - integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==, + integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==, } engines: { node: ">=18.0.0", npm: ">=8.0.0" } hasBin: true @@ -3653,12 +3686,6 @@ packages: integrity: sha512-93DpwwaiRrLz7fJ5z6Uwb171hHBws1VVsWjU6IruLFX63BicLA44QNu0sfn3guKHnBHZMFSKO8akfx5QhjuegQ==, } - safe-buffer@5.2.1: - resolution: - { - integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==, - } - scheduler@0.23.2: resolution: { @@ -3694,12 +3721,12 @@ packages: } engines: { node: ">=10" } peerDependencies: - seroval: ^1.0 + seroval: ">=1.4.1" - seroval@1.4.0: + seroval@1.5.1: resolution: { - integrity: sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==, + integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==, } engines: { node: ">=10" } @@ -3937,10 +3964,10 @@ packages: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc - vite@5.2.10: + vite@5.4.21: resolution: { - integrity: sha512-PAzgUZbP7msvQvqdSD+ErD5qGnSFiGOoWmV5yAKUEI0kdhjbH6nMWVyZQC/hSc4aXwc0oJ9aEdIiF9Oje0JFCw==, + integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==, } engines: { node: ^18.0.0 || >=20.0.0 } hasBin: true @@ -3949,6 +3976,7 @@ packages: less: "*" lightningcss: ^1.21.0 sass: "*" + sass-embedded: "*" stylus: "*" sugarss: "*" terser: ^5.4.0 @@ -3961,6 +3989,8 @@ packages: optional: true sass: optional: true + sass-embedded: + optional: true stylus: optional: true sugarss: @@ -4134,103 +4164,103 @@ snapshots: tslib: 2.8.1 optional: true - "@esbuild/aix-ppc64@0.20.2": + "@esbuild/aix-ppc64@0.21.5": optional: true "@esbuild/aix-ppc64@0.25.5": optional: true - "@esbuild/android-arm64@0.20.2": + "@esbuild/android-arm64@0.21.5": optional: true "@esbuild/android-arm64@0.25.5": optional: true - "@esbuild/android-arm@0.20.2": + "@esbuild/android-arm@0.21.5": optional: true "@esbuild/android-arm@0.25.5": optional: true - "@esbuild/android-x64@0.20.2": + "@esbuild/android-x64@0.21.5": optional: true "@esbuild/android-x64@0.25.5": optional: true - "@esbuild/darwin-arm64@0.20.2": + "@esbuild/darwin-arm64@0.21.5": optional: true "@esbuild/darwin-arm64@0.25.5": optional: true - "@esbuild/darwin-x64@0.20.2": + "@esbuild/darwin-x64@0.21.5": optional: true "@esbuild/darwin-x64@0.25.5": optional: true - "@esbuild/freebsd-arm64@0.20.2": + "@esbuild/freebsd-arm64@0.21.5": optional: true "@esbuild/freebsd-arm64@0.25.5": optional: true - "@esbuild/freebsd-x64@0.20.2": + "@esbuild/freebsd-x64@0.21.5": optional: true "@esbuild/freebsd-x64@0.25.5": optional: true - "@esbuild/linux-arm64@0.20.2": + "@esbuild/linux-arm64@0.21.5": optional: true "@esbuild/linux-arm64@0.25.5": optional: true - "@esbuild/linux-arm@0.20.2": + "@esbuild/linux-arm@0.21.5": optional: true "@esbuild/linux-arm@0.25.5": optional: true - "@esbuild/linux-ia32@0.20.2": + "@esbuild/linux-ia32@0.21.5": optional: true "@esbuild/linux-ia32@0.25.5": optional: true - "@esbuild/linux-loong64@0.20.2": + "@esbuild/linux-loong64@0.21.5": optional: true "@esbuild/linux-loong64@0.25.5": optional: true - "@esbuild/linux-mips64el@0.20.2": + "@esbuild/linux-mips64el@0.21.5": optional: true "@esbuild/linux-mips64el@0.25.5": optional: true - "@esbuild/linux-ppc64@0.20.2": + "@esbuild/linux-ppc64@0.21.5": optional: true "@esbuild/linux-ppc64@0.25.5": optional: true - "@esbuild/linux-riscv64@0.20.2": + "@esbuild/linux-riscv64@0.21.5": optional: true "@esbuild/linux-riscv64@0.25.5": optional: true - "@esbuild/linux-s390x@0.20.2": + "@esbuild/linux-s390x@0.21.5": optional: true "@esbuild/linux-s390x@0.25.5": optional: true - "@esbuild/linux-x64@0.20.2": + "@esbuild/linux-x64@0.21.5": optional: true "@esbuild/linux-x64@0.25.5": @@ -4239,7 +4269,7 @@ snapshots: "@esbuild/netbsd-arm64@0.25.5": optional: true - "@esbuild/netbsd-x64@0.20.2": + "@esbuild/netbsd-x64@0.21.5": optional: true "@esbuild/netbsd-x64@0.25.5": @@ -4248,31 +4278,31 @@ snapshots: "@esbuild/openbsd-arm64@0.25.5": optional: true - "@esbuild/openbsd-x64@0.20.2": + "@esbuild/openbsd-x64@0.21.5": optional: true "@esbuild/openbsd-x64@0.25.5": optional: true - "@esbuild/sunos-x64@0.20.2": + "@esbuild/sunos-x64@0.21.5": optional: true "@esbuild/sunos-x64@0.25.5": optional: true - "@esbuild/win32-arm64@0.20.2": + "@esbuild/win32-arm64@0.21.5": optional: true "@esbuild/win32-arm64@0.25.5": optional: true - "@esbuild/win32-ia32@0.20.2": + "@esbuild/win32-ia32@0.21.5": optional: true "@esbuild/win32-ia32@0.25.5": optional: true - "@esbuild/win32-x64@0.20.2": + "@esbuild/win32-x64@0.21.5": optional: true "@esbuild/win32-x64@0.25.5": @@ -4364,11 +4394,11 @@ snapshots: "@module-federation/third-party-dts-extractor": 0.21.2 adm-zip: 0.5.16 ansi-colors: 4.1.3 - axios: 1.13.1 + axios: 1.14.0 chalk: 3.0.0 fs-extra: 9.1.0 isomorphic-ws: 5.0.0(ws@8.18.0) - koa: 3.0.3 + koa: 3.2.0 lodash.clonedeepwith: 4.5.0 log4js: 6.9.1 node-schedule: 2.1.1 @@ -4493,10 +4523,10 @@ snapshots: fs-extra: 9.1.0 resolve: 1.22.8 - "@module-federation/vite@1.7.1(rollup@4.52.5)": + "@module-federation/vite@1.7.1(rollup@4.60.1)": dependencies: "@module-federation/runtime": 0.17.1 - "@rollup/pluginutils": 5.3.0(rollup@4.52.5) + "@rollup/pluginutils": 5.3.0(rollup@4.60.1) defu: 6.1.4 estree-walker: 2.0.2 magic-string: 0.30.21 @@ -5288,78 +5318,87 @@ snapshots: "@radix-ui/rect@1.1.1": {} - "@rollup/pluginutils@5.3.0(rollup@4.52.5)": + "@rollup/pluginutils@5.3.0(rollup@4.60.1)": dependencies: "@types/estree": 1.0.8 estree-walker: 2.0.2 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: - rollup: 4.52.5 + rollup: 4.60.1 - "@rollup/rollup-android-arm-eabi@4.52.5": + "@rollup/rollup-android-arm-eabi@4.60.1": optional: true - "@rollup/rollup-android-arm64@4.52.5": + "@rollup/rollup-android-arm64@4.60.1": optional: true - "@rollup/rollup-darwin-arm64@4.52.5": + "@rollup/rollup-darwin-arm64@4.60.1": optional: true - "@rollup/rollup-darwin-x64@4.52.5": + "@rollup/rollup-darwin-x64@4.60.1": optional: true - "@rollup/rollup-freebsd-arm64@4.52.5": + "@rollup/rollup-freebsd-arm64@4.60.1": optional: true - "@rollup/rollup-freebsd-x64@4.52.5": + "@rollup/rollup-freebsd-x64@4.60.1": optional: true - "@rollup/rollup-linux-arm-gnueabihf@4.52.5": + "@rollup/rollup-linux-arm-gnueabihf@4.60.1": optional: true - "@rollup/rollup-linux-arm-musleabihf@4.52.5": + "@rollup/rollup-linux-arm-musleabihf@4.60.1": optional: true - "@rollup/rollup-linux-arm64-gnu@4.52.5": + "@rollup/rollup-linux-arm64-gnu@4.60.1": optional: true - "@rollup/rollup-linux-arm64-musl@4.52.5": + "@rollup/rollup-linux-arm64-musl@4.60.1": optional: true - "@rollup/rollup-linux-loong64-gnu@4.52.5": + "@rollup/rollup-linux-loong64-gnu@4.60.1": optional: true - "@rollup/rollup-linux-ppc64-gnu@4.52.5": + "@rollup/rollup-linux-loong64-musl@4.60.1": optional: true - "@rollup/rollup-linux-riscv64-gnu@4.52.5": + "@rollup/rollup-linux-ppc64-gnu@4.60.1": optional: true - "@rollup/rollup-linux-riscv64-musl@4.52.5": + "@rollup/rollup-linux-ppc64-musl@4.60.1": optional: true - "@rollup/rollup-linux-s390x-gnu@4.52.5": + "@rollup/rollup-linux-riscv64-gnu@4.60.1": optional: true - "@rollup/rollup-linux-x64-gnu@4.52.5": + "@rollup/rollup-linux-riscv64-musl@4.60.1": optional: true - "@rollup/rollup-linux-x64-musl@4.52.5": + "@rollup/rollup-linux-s390x-gnu@4.60.1": optional: true - "@rollup/rollup-openharmony-arm64@4.52.5": + "@rollup/rollup-linux-x64-gnu@4.60.1": optional: true - "@rollup/rollup-win32-arm64-msvc@4.52.5": + "@rollup/rollup-linux-x64-musl@4.60.1": optional: true - "@rollup/rollup-win32-ia32-msvc@4.52.5": + "@rollup/rollup-openbsd-x64@4.60.1": optional: true - "@rollup/rollup-win32-x64-gnu@4.52.5": + "@rollup/rollup-openharmony-arm64@4.60.1": optional: true - "@rollup/rollup-win32-x64-msvc@4.52.5": + "@rollup/rollup-win32-arm64-msvc@4.60.1": + optional: true + + "@rollup/rollup-win32-ia32-msvc@4.60.1": + optional: true + + "@rollup/rollup-win32-x64-gnu@4.60.1": + optional: true + + "@rollup/rollup-win32-x64-msvc@4.60.1": optional: true "@rspack/binding-darwin-arm64@1.6.0": @@ -5487,12 +5526,12 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.1.16 - "@tailwindcss/vite@4.1.16(vite@5.2.10(@types/node@24.10.0)(lightningcss@1.30.2))": + "@tailwindcss/vite@4.1.16(vite@5.4.21(@types/node@24.10.0)(lightningcss@1.30.2))": dependencies: "@tailwindcss/node": 4.1.16 "@tailwindcss/oxide": 4.1.16 tailwindcss: 4.1.16 - vite: 5.2.10(@types/node@24.10.0)(lightningcss@1.30.2) + vite: 5.4.21(@types/node@24.10.0)(lightningcss@1.30.2) "@tanstack/history@1.139.0": {} @@ -5532,8 +5571,8 @@ snapshots: "@tanstack/history": 1.139.0 "@tanstack/store": 0.8.0 cookie-es: 2.0.0 - seroval: 1.4.0 - seroval-plugins: 1.4.0(seroval@1.4.0) + seroval: 1.5.1 + seroval-plugins: 1.4.0(seroval@1.5.1) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 @@ -5589,14 +5628,14 @@ snapshots: "@types/semver@7.5.8": {} - "@vitejs/plugin-react@4.2.1(vite@5.2.10(@types/node@24.10.0)(lightningcss@1.30.2))": + "@vitejs/plugin-react@4.2.1(vite@5.4.21(@types/node@24.10.0)(lightningcss@1.30.2))": dependencies: "@babel/core": 7.28.5 "@babel/plugin-transform-react-jsx-self": 7.27.1(@babel/core@7.28.5) "@babel/plugin-transform-react-jsx-source": 7.27.1(@babel/core@7.28.5) "@types/babel__core": 7.20.5 react-refresh: 0.14.2 - vite: 5.2.10(@types/node@24.10.0)(lightningcss@1.30.2) + vite: 5.4.21(@types/node@24.10.0)(lightningcss@1.30.2) transitivePeerDependencies: - supports-color @@ -5607,16 +5646,16 @@ snapshots: adm-zip@0.5.16: {} - ajv-formats@2.1.1(ajv@8.17.1): + ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: - ajv: 8.17.1 + ajv: 8.18.0 - ajv-keywords@5.1.0(ajv@8.17.1): + ajv-keywords@5.1.0(ajv@8.18.0): dependencies: - ajv: 8.17.1 + ajv: 8.18.0 fast-deep-equal: 3.1.3 - ajv@8.17.1: + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 fast-uri: 3.1.0 @@ -5637,11 +5676,11 @@ snapshots: at-least-node@1.0.0: {} - axios@1.13.1: + axios@1.14.0: dependencies: follow-redirects: 1.15.11 - form-data: 4.0.4 - proxy-from-env: 1.1.0 + form-data: 4.0.5 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -5687,9 +5726,7 @@ snapshots: commander@11.1.0: {} - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 + content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -5766,31 +5803,31 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - esbuild@0.20.2: + esbuild@0.21.5: optionalDependencies: - "@esbuild/aix-ppc64": 0.20.2 - "@esbuild/android-arm": 0.20.2 - "@esbuild/android-arm64": 0.20.2 - "@esbuild/android-x64": 0.20.2 - "@esbuild/darwin-arm64": 0.20.2 - "@esbuild/darwin-x64": 0.20.2 - "@esbuild/freebsd-arm64": 0.20.2 - "@esbuild/freebsd-x64": 0.20.2 - "@esbuild/linux-arm": 0.20.2 - "@esbuild/linux-arm64": 0.20.2 - "@esbuild/linux-ia32": 0.20.2 - "@esbuild/linux-loong64": 0.20.2 - "@esbuild/linux-mips64el": 0.20.2 - "@esbuild/linux-ppc64": 0.20.2 - "@esbuild/linux-riscv64": 0.20.2 - "@esbuild/linux-s390x": 0.20.2 - "@esbuild/linux-x64": 0.20.2 - "@esbuild/netbsd-x64": 0.20.2 - "@esbuild/openbsd-x64": 0.20.2 - "@esbuild/sunos-x64": 0.20.2 - "@esbuild/win32-arm64": 0.20.2 - "@esbuild/win32-ia32": 0.20.2 - "@esbuild/win32-x64": 0.20.2 + "@esbuild/aix-ppc64": 0.21.5 + "@esbuild/android-arm": 0.21.5 + "@esbuild/android-arm64": 0.21.5 + "@esbuild/android-x64": 0.21.5 + "@esbuild/darwin-arm64": 0.21.5 + "@esbuild/darwin-x64": 0.21.5 + "@esbuild/freebsd-arm64": 0.21.5 + "@esbuild/freebsd-x64": 0.21.5 + "@esbuild/linux-arm": 0.21.5 + "@esbuild/linux-arm64": 0.21.5 + "@esbuild/linux-ia32": 0.21.5 + "@esbuild/linux-loong64": 0.21.5 + "@esbuild/linux-mips64el": 0.21.5 + "@esbuild/linux-ppc64": 0.21.5 + "@esbuild/linux-riscv64": 0.21.5 + "@esbuild/linux-s390x": 0.21.5 + "@esbuild/linux-x64": 0.21.5 + "@esbuild/netbsd-x64": 0.21.5 + "@esbuild/openbsd-x64": 0.21.5 + "@esbuild/sunos-x64": 0.21.5 + "@esbuild/win32-arm64": 0.21.5 + "@esbuild/win32-ia32": 0.21.5 + "@esbuild/win32-x64": 0.21.5 esbuild@0.25.5: optionalDependencies: @@ -5842,11 +5879,11 @@ snapshots: dependencies: find-file-up: 2.0.1 - flatted@3.3.3: {} + flatted@3.4.2: {} follow-redirects@1.15.11: {} - form-data@4.0.4: + form-data@4.0.5: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 @@ -6005,10 +6042,10 @@ snapshots: koa-compose@4.1.0: {} - koa@3.0.3: + koa@3.2.0: dependencies: accepts: 1.3.8 - content-disposition: 0.5.4 + content-disposition: 1.0.1 content-type: 1.0.5 cookies: 0.9.1 delegates: 1.0.0 @@ -6083,7 +6120,7 @@ snapshots: dependencies: date-format: 4.0.14 debug: 4.4.3 - flatted: 3.3.3 + flatted: 3.4.2 rfdc: 1.4.1 streamroller: 3.1.5 transitivePeerDependencies: @@ -6158,7 +6195,7 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} postcss-selector-parser@6.0.10: dependencies: @@ -6173,7 +6210,7 @@ snapshots: prettier@3.6.2: {} - proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} radix-ui@1.4.3(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: @@ -6310,38 +6347,39 @@ snapshots: rfdc@1.4.1: {} - rollup@4.52.5: + rollup@4.60.1: dependencies: "@types/estree": 1.0.8 optionalDependencies: - "@rollup/rollup-android-arm-eabi": 4.52.5 - "@rollup/rollup-android-arm64": 4.52.5 - "@rollup/rollup-darwin-arm64": 4.52.5 - "@rollup/rollup-darwin-x64": 4.52.5 - "@rollup/rollup-freebsd-arm64": 4.52.5 - "@rollup/rollup-freebsd-x64": 4.52.5 - "@rollup/rollup-linux-arm-gnueabihf": 4.52.5 - "@rollup/rollup-linux-arm-musleabihf": 4.52.5 - "@rollup/rollup-linux-arm64-gnu": 4.52.5 - "@rollup/rollup-linux-arm64-musl": 4.52.5 - "@rollup/rollup-linux-loong64-gnu": 4.52.5 - "@rollup/rollup-linux-ppc64-gnu": 4.52.5 - "@rollup/rollup-linux-riscv64-gnu": 4.52.5 - "@rollup/rollup-linux-riscv64-musl": 4.52.5 - "@rollup/rollup-linux-s390x-gnu": 4.52.5 - "@rollup/rollup-linux-x64-gnu": 4.52.5 - "@rollup/rollup-linux-x64-musl": 4.52.5 - "@rollup/rollup-openharmony-arm64": 4.52.5 - "@rollup/rollup-win32-arm64-msvc": 4.52.5 - "@rollup/rollup-win32-ia32-msvc": 4.52.5 - "@rollup/rollup-win32-x64-gnu": 4.52.5 - "@rollup/rollup-win32-x64-msvc": 4.52.5 + "@rollup/rollup-android-arm-eabi": 4.60.1 + "@rollup/rollup-android-arm64": 4.60.1 + "@rollup/rollup-darwin-arm64": 4.60.1 + "@rollup/rollup-darwin-x64": 4.60.1 + "@rollup/rollup-freebsd-arm64": 4.60.1 + "@rollup/rollup-freebsd-x64": 4.60.1 + "@rollup/rollup-linux-arm-gnueabihf": 4.60.1 + "@rollup/rollup-linux-arm-musleabihf": 4.60.1 + "@rollup/rollup-linux-arm64-gnu": 4.60.1 + "@rollup/rollup-linux-arm64-musl": 4.60.1 + "@rollup/rollup-linux-loong64-gnu": 4.60.1 + "@rollup/rollup-linux-loong64-musl": 4.60.1 + "@rollup/rollup-linux-ppc64-gnu": 4.60.1 + "@rollup/rollup-linux-ppc64-musl": 4.60.1 + "@rollup/rollup-linux-riscv64-gnu": 4.60.1 + "@rollup/rollup-linux-riscv64-musl": 4.60.1 + "@rollup/rollup-linux-s390x-gnu": 4.60.1 + "@rollup/rollup-linux-x64-gnu": 4.60.1 + "@rollup/rollup-linux-x64-musl": 4.60.1 + "@rollup/rollup-openbsd-x64": 4.60.1 + "@rollup/rollup-openharmony-arm64": 4.60.1 + "@rollup/rollup-win32-arm64-msvc": 4.60.1 + "@rollup/rollup-win32-ia32-msvc": 4.60.1 + "@rollup/rollup-win32-x64-gnu": 4.60.1 + "@rollup/rollup-win32-x64-msvc": 4.60.1 fsevents: 2.3.3 rslog@1.3.0: {} - safe-buffer@5.2.1: {} - scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -6349,19 +6387,19 @@ snapshots: schema-utils@4.3.3: dependencies: "@types/json-schema": 7.0.15 - ajv: 8.17.1 - ajv-formats: 2.1.1(ajv@8.17.1) - ajv-keywords: 5.1.0(ajv@8.17.1) + ajv: 8.18.0 + ajv-formats: 2.1.1(ajv@8.18.0) + ajv-keywords: 5.1.0(ajv@8.18.0) semver@6.3.1: {} semver@7.6.3: {} - seroval-plugins@1.4.0(seroval@1.4.0): + seroval-plugins@1.4.0(seroval@1.5.1): dependencies: - seroval: 1.4.0 + seroval: 1.5.1 - seroval@1.4.0: {} + seroval@1.5.1: {} setprototypeof@1.2.0: {} @@ -6467,11 +6505,11 @@ snapshots: - "@types/react" - "@types/react-dom" - vite@5.2.10(@types/node@24.10.0)(lightningcss@1.30.2): + vite@5.4.21(@types/node@24.10.0)(lightningcss@1.30.2): dependencies: - esbuild: 0.20.2 + esbuild: 0.21.5 postcss: 8.5.6 - rollup: 4.52.5 + rollup: 4.60.1 optionalDependencies: "@types/node": 24.10.0 fsevents: 2.3.3 diff --git a/ui/src/components/ElementDownload.tsx b/ui/src/components/ElementDownload.tsx index 82a083f..0319ef4 100644 --- a/ui/src/components/ElementDownload.tsx +++ b/ui/src/components/ElementDownload.tsx @@ -1,10 +1,48 @@ import { PRODUCT_SHORTNAME } from "@/App"; import { useTranslation } from "react-i18next"; import { Button } from "./ui/button"; +import { Platform } from "@/lib/detectPlatform"; -export function ElementDownload() { +interface Props { + platform: Platform; +} + +export function ElementDownload({ platform }: Props) { const { t } = useTranslation(PRODUCT_SHORTNAME); + if (platform === Platform.Android) { + return ( + + ); + } + + if (platform === Platform.iOS) { + return ( + + ); + } + + // Windows, Linux, macOS return (
+ +
+ +
+
+
+
+ ); + + if (isMobile === undefined) return null; + + return isMobile ? ( + + {content} + + ) : ( + + + {title} + {content} + + + ); +} diff --git a/ui/src/components/OnboardingGuide.tsx b/ui/src/components/OnboardingGuide.tsx index 40cb1e1..8cfb887 100644 --- a/ui/src/components/OnboardingGuide.tsx +++ b/ui/src/components/OnboardingGuide.tsx @@ -36,7 +36,7 @@ interface OnboardingStep { description: string; image: string; mobileImage?: string; - customComponent?: React.ComponentType; + customComponent?: React.ComponentType<{ platform: Platform }>; } interface OnboardingGroup { @@ -46,7 +46,7 @@ interface OnboardingGroup { const ONBOARDING_GROUPS: OnboardingGroup[] = [ { - platforms: [Platform.Windows, Platform.Linux], + platforms: [Platform.Windows, Platform.Linux, Platform.macOS], steps: [ { id: "desktop-intro", @@ -361,6 +361,14 @@ export function OnboardingHandler() { />{" "} {t("platform.ios")} + + {" "} + {t("platform.macos")} + - + )} diff --git a/ui/src/index.css b/ui/src/index.css index bb9a6ba..8867ec9 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -18,6 +18,7 @@ --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); + --color-primary-light: var(--primary-light); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); @@ -43,38 +44,39 @@ --color-sidebar-ring: var(--sidebar-ring); } :root { - --background: #121113; - --foreground: #c1c1c1; - --card: #121212; - --card-foreground: #c1c1c1; - --popover: #121113; - --popover-foreground: #c1c1c1; - --primary: #e78a53; - --primary-foreground: #121113; - --secondary: #5f8787; - --secondary-foreground: #121113; - --muted: #222222; - --muted-foreground: #888888; - --accent: #333333; - --accent-foreground: #c1c1c1; - --destructive: #5f8787; - --destructive-foreground: #121113; - --border: #222222; - --input: #222222; - --ring: #e78a53; - --chart-1: #5f8787; - --chart-2: #e78a53; - --chart-3: #fbcb97; - --chart-4: #888888; - --chart-5: #999999; - --sidebar: #121212; - --sidebar-foreground: #c1c1c1; - --sidebar-primary: #e78a53; - --sidebar-primary-foreground: #121113; - --sidebar-accent: #333333; - --sidebar-accent-foreground: #c1c1c1; - --sidebar-border: #222222; - --sidebar-ring: #e78a53; + --background: #1a1a1a; + --foreground: #ffffff; + --card: #222222; + --card-foreground: #ffffff; + --popover: #222222; + --popover-foreground: #ffffff; + --primary: #ff8c42; + --primary-foreground: #ffffff; + --primary-light: #ff6b1a; + --secondary: #ffa500; + --secondary-foreground: #ffffff; + --muted: #333333; + --muted-foreground: #999999; + --accent: #ff7f00; + --accent-foreground: #ffffff; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #333333; + --input: #333333; + --ring: #ff8c42; + --chart-1: #ff8c42; + --chart-2: #ff6b1a; + --chart-3: #ffa500; + --chart-4: #cc7a2e; + --chart-5: #ff9955; + --sidebar: #222222; + --sidebar-foreground: #ffffff; + --sidebar-primary: #ff8c42; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #ff7f00; + --sidebar-accent-foreground: #ffffff; + --sidebar-border: #333333; + --sidebar-ring: #ff8c42; --font-sans: Geist Mono, ui-monospace, monospace; --font-serif: serif; --font-mono: JetBrains Mono, monospace; diff --git a/ui/src/lib/detectPlatform.ts b/ui/src/lib/detectPlatform.ts index a8236a1..410affc 100644 --- a/ui/src/lib/detectPlatform.ts +++ b/ui/src/lib/detectPlatform.ts @@ -3,6 +3,7 @@ export enum Platform { iOS = "ios", Windows = "windows", Linux = "linux", + macOS = "macos", } export const detectPlatform = (): Platform => { @@ -16,8 +17,8 @@ export const detectPlatform = (): Platform => { if (/android/i.test(ua)) return Platform.Android; if (/iPad|iPhone|iPod/.test(ua)) return Platform.iOS; if (/Windows NT/.test(ua)) return Platform.Windows; + if (/Macintosh|Mac OS X/.test(ua)) return Platform.macOS; if (/Linux/.test(ua) && !/android/i.test(ua)) return Platform.Linux; - //Return most likely platform return Platform.Android; }; diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json index cf66f99..b2fd971 100644 --- a/ui/src/locales/en.json +++ b/ui/src/locales/en.json @@ -8,6 +8,61 @@ "description": "Enter the address below into your Matrix-client as the homeserver.", "copyButton": "Copy" }, + "featureguides": { + "title": "Feature guides", + "messaging": { + "button": "Text & voice messages, calls and video meetings", + "title": "Messaging and calls in Matrix", + "steps": { + "intro": { + "title": "Messaging in Element", + "description": "Element supports text messages, voice messages, voice calls and video meetings — all in one app. These work in any room or direct message conversation." + }, + "space": { + "title": "Your Space and rooms", + "description": "When you join, you are automatically added to a Space with pre-created rooms: General, Helpdesk and Off-topic. These are your starting points for team communication." + }, + "rooms": { + "title": "Talking in rooms", + "description": "Send messages in any room or start direct messages with other members. You can also create new rooms for specific topics to keep discussions organised." + }, + "calls": { + "title": "Voice and video calls", + "description": "Start a voice or video call directly from any room or direct message — useful for quick conversations without scheduling a meeting." + }, + "meetings": { + "title": "Video meetings", + "description": "Element supports group video meetings. Start one from any room to bring your team together for a briefing or a collaborative session." + } + } + }, + "unit": { + "button": "Using Matrix in a unit", + "title": "Using Matrix in a unit", + "steps": { + "setup": { + "title": "Setting up your workspace", + "description": "Create rooms in your Space for the topics your unit needs — operations, planning, logistics, or anything else. Rooms can be restricted to Space members so only the right people have access." + }, + "daily": { + "title": "Day-to-day communication", + "description": "Use text messages for written communication and voice messages for quick audio notes when typing is inconvenient. Keeping discussions in topic-specific rooms helps everyone follow what matters to them." + }, + "calls": { + "title": "Calls and meetings", + "description": "Use voice calls for quick coordination. Start video meetings for team briefings or when remote collaboration needs a face-to-face feel." + }, + "users": { + "title": "Managing users", + "description": "Admins can manage room membership and access. Users promoted to admin in Deploy App automatically receive elevated access in the Space and are added to the admin channel." + }, + "federation": { + "title": "Working with other Matrix servers", + "description": "By default, you can invite users from other Matrix homeservers to your rooms. This allows interoperability with allies or partner organisations that also run Matrix." + } + } + } + }, "onboarding": { "back": "Back", "clickToEnlarge": "Click to enlarge", @@ -76,13 +131,16 @@ }, "title": "Onboard to Matrix", "downloads": { - "open_website": "Open download website" + "open_website": "Open download website", + "google_play": "Get on Google Play", + "app_store": "Download on the App Store" } }, "platform": { "select_placeholder": "Select platform", "android": "Android", "ios": "iOS", + "macos": "macOS", "linux": "Linux", "windows": "Windows" } diff --git a/ui/src/locales/fi.json b/ui/src/locales/fi.json index 4c65979..c229d16 100644 --- a/ui/src/locales/fi.json +++ b/ui/src/locales/fi.json @@ -8,6 +8,77 @@ "description": "Syötä alla oleva osoite Matrix-sovelluksesi kotipalvelimeksi.", "copyButton": "Kopioi" }, + "featureguides": { + "title": "Toiminto-oppaat", + "messaging": { + "button": "Viestit, puheviestit, puhelut ja videoneuvottelut", + "title": "Viestintä ja puhelut Matrixissa", + "steps": { + "intro": { + "title": "Viestintä Elementissä", + "description": "Element tukee tekstiviestejä, puheviestejä, ääni- ja videopuheluita sekä videoneuvotteluita. Nämä toimivat missä tahansa huoneessa tai suoraviestikeskustelussa." + }, + "space": { + "title": "Space ja huoneet", + "description": "Liityt automaattisesti tämän palvelimen Spacen jäseneksi. Spaceen kuuluvat kaikki palvelimen jäsenet, ja siinä on valmiiksi luodut huoneet, joilla voit aloittaa kommunikoinnin." + }, + "rooms": { + "title": "Viestintä huoneissa", + "description": "Lähetä viestejä missä tahansa huoneessa tai aloita suoraviesti muiden jäsenten kanssa. Voit myös luoda uusia huoneita, jotka ovat joko kaikkien Spacen jäsenten löydettävissä tai vain kutsulla liityttävissä." + }, + "calls": { + "title": "Ääni- ja videopuhelut", + "description": "Aloita ääni- tai videopuhelu suoraan mistä tahansa huoneesta tai suoraviestistä — kätevä nopeiden keskustelujen käymiseen ilman kokouksen järjestämistä." + }, + "meetings": { + "title": "Ryhmävideopuhelu", + "description": "Element tukee laadukkaita ryhmävideopuheluita huoneissa - täysin vastaava kyky kuin Microsoft Teamsissa, Skypessä tai muissa vastaavissa palveluissa." + }, + "encryption": { + "title": "Salaus", + "description": "Matrixin ominaisuuksia on vahva päästä-päähän salaus, eli ulkopuoliset, mukaan lukien palvelin, ei voi saada yksityisviestejäsi tai ryhmiesi viestejä auki." + } + } + }, + "unit": { + "button": "Matrixin käyttö yksikössä", + "title": "Matrixin käyttö yksikössä", + "steps": { + "setup": { + "title": "Helppo pääviestiväline", + "description": "Matrix täyttää kaikki viesti- ja videopuhelutarpeet hyvin. Kaikki tämän palvelimen jäsenet ovat automaattisesti palvelimesi Spacen jäseniä, minkä avulla voit aloittaa joukkosi viestitoiminnan välittömästi." + }, + "rooms": { + "title": "Esimerkki hyvästä huonejaosta", + "description": "Automaattisesti luotujen huoneiden lisäksi, suosittelemme luomaan esimerkiksi oheiset huoneet ja kutsumaan niihin soveltuvat jäsenet." + }, + "command": { + "title": "Command-huone", + "description": "Command eli komentokanavalle kutsu joukkosi johtajat. Kanava on kuin radiokomentokanava -käskyjä ja niiden kuittaamista varten. Sillä voidaan pitää myös käskynantoja videokokouksen ylitse." + }, + "friendly": { + "title": "Friendly situation-huone", + "description": "Friendly situation-huoneeseen kutsu vähintään joukkosi johtajat ja alijohtajat. Kanavalla liikennöidään tehtävän suoritusvaiheesta, eli missä omat liikkuvat ja miten tehtävien suorittaminen etenee." + }, + "recon": { + "title": "Recon-huone", + "description": "Recon-huoneeseen kutsu vähintään joukkosi johtajat ja alijohtajat. Kerro kanavalla tiedustelu- ja vihollishavainnoista." + }, + "others": { + "title": "Muut huonesuositukset", + "description": "Muita huonesuosituksia on esimerkiksi Guard-huone, mihin vartio- ja partiotehtäviä suorittavat liittyvät tehtävän ajaksi, sekä Call for Fires ja Fires Management-huoneet tulenjohtoa ja tulenkäytön koordinointia varten." + }, + "users": { + "title": "Käyttäjien hallinta", + "description": "Deploy Appissa ylläpitäjäksi asetettu käyttäjä saa automaattisesti ylläpitäjäoikeuden Spaceen ja sen huoneisiin, sekä lisätään adminhuoneeseen. Deploy Appista poistettu käyttäjä taas menettää yhteyden Matrixiin." + }, + "federation": { + "title": "Yhteistoiminta muiden Matrix-palvelinten kanssa", + "description": "Oletuksena voit kutsua muiden Matrix-palvelimien käyttäjiä huoneisiisi. Tämä mahdollistaa yhteistoiminnan muiden Deploy Appia käyttävien joukkojen tai vaikkapa NATO-liittolaisten kanssa, jotka myös käyttävät Matrixia esimerkiksi NI2CE-sovelluksen välityksellä." + } + } + } + }, "onboarding": { "back": "Takaisin", "clickToEnlarge": "Klikkaa suurentaaksesi", @@ -76,13 +147,16 @@ }, "title": "Matrix-perehdytys", "downloads": { - "open_website": "Avaa lataussivusto" + "open_website": "Avaa lataussivusto", + "google_play": "Lataa Google Playsta", + "app_store": "Lataa App Storesta" } }, "platform": { "select_placeholder": "Valitse alusta", "android": "Android", "ios": "iOS", + "macos": "macOS", "linux": "Linux", "windows": "Windows" } diff --git a/ui/src/locales/sv.json b/ui/src/locales/sv.json index 58e79d9..f673055 100644 --- a/ui/src/locales/sv.json +++ b/ui/src/locales/sv.json @@ -8,6 +8,61 @@ "description": "Ange adressen nedan i din Matrix-klient som hemserver.", "copyButton": "Kopiera" }, + "featureguides": { + "title": "Funktionsguider", + "messaging": { + "button": "Text- och röstmeddelanden, samtal och videomöten", + "title": "Meddelanden och samtal i Matrix", + "steps": { + "intro": { + "title": "Meddelanden i Element", + "description": "Element stöder textmeddelanden, röstmeddelanden, röst- och videosamtal samt videomöten — allt i en app. Dessa fungerar i vilket rum eller direktmeddelandekonversation som helst." + }, + "space": { + "title": "Ditt Space och dina rum", + "description": "När du ansluter läggs du automatiskt till i ett Space med förskapade rum: Allmänt, Helpdesk och Fritid. Dessa är dina startpunkter för teamkommunikation." + }, + "rooms": { + "title": "Kommunikation i rum", + "description": "Skicka meddelanden i vilket rum som helst eller starta direktmeddelanden med andra medlemmar. Du kan också skapa nya rum för specifika ämnen för att hålla diskussionerna organiserade." + }, + "calls": { + "title": "Röst- och videosamtal", + "description": "Starta ett röst- eller videosamtal direkt från vilket rum eller direktmeddelande som helst — praktiskt för snabba konversationer utan att behöva boka ett möte." + }, + "meetings": { + "title": "Videomöten", + "description": "Element stöder gruppmöten med video. Starta ett möte från vilket rum som helst för att samla ditt team för en briefing eller ett samarbetssession." + } + } + }, + "unit": { + "button": "Använda Matrix i en enhet", + "title": "Använda Matrix i en enhet", + "steps": { + "setup": { + "title": "Konfigurera din arbetsyta", + "description": "Skapa rum i ditt Space för de ämnen din enhet behöver — operationer, planering, logistik eller annat. Rum kan begränsas till Space-medlemmar så att bara rätt personer har åtkomst." + }, + "daily": { + "title": "Daglig kommunikation", + "description": "Använd textmeddelanden för skriftlig kommunikation och röstmeddelanden för snabba ljudanteckningar när det är opraktiskt att skriva. Att hålla diskussioner i ämnesspecifika rum gör det lättare för alla att följa det som är viktigt för dem." + }, + "calls": { + "title": "Samtal och möten", + "description": "Använd röstsamtal för snabb samordning. Starta videomöten för teambriefingar eller när distanssamarbete kräver känslan av ett personligt möte." + }, + "users": { + "title": "Hantera användare", + "description": "Administratörer kan hantera rummedlemskap och åtkomst. Användare som befordras till administratör i Deploy App får automatiskt förhöjd åtkomst i Space och läggs till i administratörskanalen." + }, + "federation": { + "title": "Samarbeta med andra Matrix-servrar", + "description": "Som standard kan du bjuda in användare från andra Matrix-hemservrar till dina rum. Detta möjliggör interoperabilitet med allierade eller partnerorganisationer som också använder Matrix." + } + } + } + }, "onboarding": { "back": "Bakåt", "clickToEnlarge": "Klicka för att förstora", @@ -43,7 +98,7 @@ }, "desktop-step-4": { "title": "Logga in i Element - 4", - "description": "Tryck på \"Fortsätt med Keycloak\". Fortsätt i din webbläsare, som bör fråga efter ditt mTLS-certifikat några gånger. Autentisera med mTLS." + "description": "Tryck på \"Fortsätt med Keycloak\". Fortsätt i din webbläsare, som bör fråga efter ditt mTLS-certifikat. Autentisera med mTLS." }, "desktop-step-5": { "title": "Elements startsida", @@ -76,13 +131,16 @@ }, "title": "Introduktion till Matrix", "downloads": { - "open_website": "Öppna webbplatsen för nedladdning" + "open_website": "Öppna webbplatsen för nedladdning", + "google_play": "Hämta på Google Play", + "app_store": "Ladda ned från App Store" } }, "platform": { "select_placeholder": "Välj plattform", "android": "Android", "ios": "iOS", + "macos": "macOS", "linux": "Linux", "windows": "Windows" } diff --git a/ui/src/pages/HomePage.tsx b/ui/src/pages/HomePage.tsx index 11ff893..0666ae8 100644 --- a/ui/src/pages/HomePage.tsx +++ b/ui/src/pages/HomePage.tsx @@ -1,15 +1,104 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { PRODUCT_SHORTNAME } from "@/App"; import { Toaster } from "@/components/ui/sonner"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; -import { Copy } from "lucide-react"; +import { Copy, MessageSquare, Users } from "lucide-react"; import { copyToClipboard } from "@/lib/clipboard"; import { OnboardingHandler } from "@/components/OnboardingGuide"; +import { FeatureGuide, FeatureStep } from "@/components/FeatureGuide"; + +const MESSAGING_STEPS: FeatureStep[] = [ + { + id: "messaging-intro", + title: "featureguides.messaging.steps.intro.title", + description: "featureguides.messaging.steps.intro.description", + image: "/ui/matrix/assets/features/messaging/messaging-intro.png", + }, + { + id: "messaging-space", + title: "featureguides.messaging.steps.space.title", + description: "featureguides.messaging.steps.space.description", + image: "/ui/matrix/assets/features/messaging/messaging-space.png", + }, + { + id: "messaging-rooms", + title: "featureguides.messaging.steps.rooms.title", + description: "featureguides.messaging.steps.rooms.description", + image: "/ui/matrix/assets/features/messaging/messaging-rooms.png", + }, + { + id: "messaging-calls", + title: "featureguides.messaging.steps.calls.title", + description: "featureguides.messaging.steps.calls.description", + image: "/ui/matrix/assets/features/messaging/messaging-calls.png", + }, + { + id: "messaging-meetings", + title: "featureguides.messaging.steps.meetings.title", + description: "featureguides.messaging.steps.meetings.description", + image: "/ui/matrix/assets/features/messaging/messaging-meetings.png", + }, +]; + +const UNIT_STEPS: FeatureStep[] = [ + { + id: "unit-setup", + title: "featureguides.unit.steps.setup.title", + description: "featureguides.unit.steps.setup.description", + image: "/ui/matrix/assets/features/unit/unit-setup.png", + }, + { + id: "unit-rooms", + title: "featureguides.unit.steps.rooms.title", + description: "featureguides.unit.steps.rooms.description", + image: "/ui/matrix/assets/features/unit/unit-rooms.png", + }, + { + id: "unit-command", + title: "featureguides.unit.steps.command.title", + description: "featureguides.unit.steps.command.description", + image: "/ui/matrix/assets/features/unit/unit-command.png", + }, + { + id: "unit-friendly", + title: "featureguides.unit.steps.friendly.title", + description: "featureguides.unit.steps.friendly.description", + image: "/ui/matrix/assets/features/unit/unit-friendly.png", + }, + { + id: "unit-recon", + title: "featureguides.unit.steps.recon.title", + description: "featureguides.unit.steps.recon.description", + image: "/ui/matrix/assets/features/unit/unit-recon.png", + }, + { + id: "unit-others", + title: "featureguides.unit.steps.others.title", + description: "featureguides.unit.steps.others.description", + image: "/ui/matrix/assets/features/unit/unit-others.png", + }, + { + id: "unit-users", + title: "featureguides.unit.steps.users.title", + description: "featureguides.unit.steps.users.description", + image: "/ui/matrix/assets/features/unit/unit-users.png", + }, + { + id: "unit-federation", + title: "featureguides.unit.steps.federation.title", + description: "featureguides.unit.steps.federation.description", + image: "/ui/matrix/assets/features/unit/unit-federation.png", + }, +]; export const HomePage = () => { const { t } = useTranslation(PRODUCT_SHORTNAME); + const [activeGuide, setActiveGuide] = useState<"messaging" | "unit" | null>( + null, + ); const currentHost = window.location.host.replace(/^mtls\./, ""); const synapseDomain = `https://synapse.${currentHost}`; @@ -17,33 +106,70 @@ export const HomePage = () => { return (
-
-
- -

- {t("homepage.description")} -

- -
- - -
+ {/* Homeserver URL — big action at the top */} +
+ +

+ {t("homepage.description")} +

+
+ + +
+
+ + {/* Feature guides */} +
+

+ {t("featureguides.title")} +

+
+ +
+ + setActiveGuide(null)} + title={t("featureguides.messaging.title")} + steps={MESSAGING_STEPS} + /> + setActiveGuide(null)} + title={t("featureguides.unit.title")} + steps={UNIT_STEPS} + /> +