Repository: etkecc/synapse-admin Branch: main Commit: cf66c8e3a49e Files: 345 Total size: 1.9 MB Directory structure: gitextract_pt5yit7t/ ├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── CONTRIBUTING.md │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── SECURITY.md │ ├── dependabot.yml │ └── workflows/ │ ├── reuse.yml │ └── workflow.yml ├── .gitignore ├── .prettierignore ├── .watchmanconfig ├── LICENSE ├── LICENSES/ │ ├── Apache-2.0.txt │ ├── BSD-2-Clause.txt │ ├── CC0-1.0.txt │ ├── MIT.txt │ └── OFL-1.1.txt ├── README.md ├── REUSE.toml ├── docker/ │ ├── Dockerfile │ ├── Dockerfile.build │ ├── Dockerfile.subpath-admin │ ├── docker-compose-dev.yml │ └── docker-compose.yml ├── docs/ │ ├── README.md │ ├── apis.md │ ├── availability.md │ ├── components.md │ ├── config.md │ ├── configurable-columns.md │ ├── cors-credentials.md │ ├── csv-import.md │ ├── custom-menu.md │ ├── event-reports.md │ ├── external-auth-provider.md │ ├── federation.md │ ├── media.md │ ├── prefill-login-form.md │ ├── registration-tokens.md │ ├── restrict-hs.md │ ├── reverse-proxy.md │ ├── room-management.md │ ├── screenshots/ │ │ ├── README.md │ │ └── prepare.js │ ├── server-statistics.md │ ├── system-users.md │ ├── testdata/ │ │ ├── element/ │ │ │ ├── config.json │ │ │ └── nginx.conf │ │ ├── mas/ │ │ │ └── config.yaml │ │ ├── nginx/ │ │ │ └── nginx.conf │ │ ├── postgres.initdb/ │ │ │ └── mas.sql │ │ └── synapse/ │ │ ├── homeserver.yaml │ │ ├── synapse.log.config │ │ └── synapse.signing.key │ ├── update-api-docs.ts │ ├── user-badges.md │ ├── user-management.md │ ├── user-search.md │ └── well-known-discovery.md ├── eslint.config.js ├── justfile ├── package.json ├── public/ │ ├── config.json │ ├── data/ │ │ └── example.csv │ └── robots.txt ├── src/ │ ├── App.test.tsx │ ├── App.tsx │ ├── Context.tsx │ ├── TEST_COVERAGE_TODO.md │ ├── assets/ │ │ ├── fonts.css │ │ └── theme.ts │ ├── components/ │ │ ├── MatrixWordmark.tsx │ │ ├── README.md │ │ ├── etke.cc/ │ │ │ ├── BillingPage.tsx │ │ │ ├── BillingStatusBadge.tsx │ │ │ ├── ComponentsPage.tsx │ │ │ ├── CurrentlyRunningCommand.tsx │ │ │ ├── EtkeAttribution.test.tsx │ │ │ ├── EtkeAttribution.tsx │ │ │ ├── InstanceConfig.tsx │ │ │ ├── README.md │ │ │ ├── RichTextEditor.tsx │ │ │ ├── ServerActionsPage.tsx │ │ │ ├── ServerCommandsPanel.tsx │ │ │ ├── ServerNotificationsBadge.test.tsx │ │ │ ├── ServerNotificationsBadge.tsx │ │ │ ├── ServerNotificationsPage.tsx │ │ │ ├── ServerNotificationsUnavailable.test.tsx │ │ │ ├── ServerNotificationsUnavailable.tsx │ │ │ ├── ServerStatusBadge.test.tsx │ │ │ ├── ServerStatusBadge.tsx │ │ │ ├── ServerStatusPage.test.tsx │ │ │ ├── ServerStatusPage.tsx │ │ │ ├── SupportAttachments.tsx │ │ │ ├── SupportPage.tsx │ │ │ ├── SupportRequestPage.tsx │ │ │ ├── hooks/ │ │ │ │ ├── useServerCommands.ts │ │ │ │ └── useUnits.ts │ │ │ └── schedules/ │ │ │ ├── components/ │ │ │ │ ├── recurring/ │ │ │ │ │ ├── RecurringCommandEdit.tsx │ │ │ │ │ ├── RecurringCommandsList.tsx │ │ │ │ │ └── RecurringDeleteButton.tsx │ │ │ │ └── scheduled/ │ │ │ │ ├── ScheduledCommandEdit.tsx │ │ │ │ ├── ScheduledCommandShow.tsx │ │ │ │ ├── ScheduledCommandsList.tsx │ │ │ │ └── ScheduledDeleteButton.tsx │ │ │ └── hooks/ │ │ │ ├── useRecurringCommands.tsx │ │ │ └── useScheduledCommands.tsx │ │ ├── hooks/ │ │ │ ├── useDocTitle.test.tsx │ │ │ └── useDocTitle.tsx │ │ ├── layout/ │ │ │ ├── AdminLayout.test.tsx │ │ │ ├── AdminLayout.tsx │ │ │ ├── Datagrid.test.tsx │ │ │ ├── Datagrid.tsx │ │ │ ├── EmptyState.test.tsx │ │ │ ├── EmptyState.tsx │ │ │ ├── Footer.test.tsx │ │ │ ├── Footer.tsx │ │ │ ├── List.tsx │ │ │ ├── LoginFormBox.tsx │ │ │ └── index.ts │ │ ├── media/ │ │ │ ├── DeleteMediaButton.tsx │ │ │ ├── ProtectMediaButton.tsx │ │ │ ├── PurgeRemoteMediaButton.tsx │ │ │ ├── QuarantineMediaButton.tsx │ │ │ ├── ViewMedia.tsx │ │ │ └── index.ts │ │ ├── rooms/ │ │ │ ├── EventLookupDialog.tsx │ │ │ ├── RoomHierarchy.test.ts │ │ │ ├── RoomHierarchy.tsx │ │ │ └── RoomMessages.tsx │ │ ├── user-import/ │ │ │ ├── ConflictModeCard.tsx │ │ │ ├── ErrorsCard.tsx │ │ │ ├── ResultsCard.tsx │ │ │ ├── StartImportCard.tsx │ │ │ ├── StatsCard.tsx │ │ │ ├── UploadCard.tsx │ │ │ ├── UserImport.tsx │ │ │ ├── types.ts │ │ │ ├── useImportFile.test.ts │ │ │ └── useImportFile.tsx │ │ └── users/ │ │ ├── AdminClientConfigItems.tsx │ │ ├── DeviceDisplayNameInput.tsx │ │ ├── ExperimentalFeatures.tsx │ │ ├── ServerNotices.tsx │ │ ├── UserAccountData.tsx │ │ ├── UserCounts.tsx │ │ ├── UserRateLimits.tsx │ │ ├── buttons/ │ │ │ ├── AllowCrossSigningButton.tsx │ │ │ ├── BlockRoomButton.tsx │ │ │ ├── DeleteAllMediaButton.tsx │ │ │ ├── DeleteRoomButton.tsx │ │ │ ├── DeleteUserButton.tsx │ │ │ ├── DeviceCreateButton.tsx │ │ │ ├── DeviceRemoveButton.tsx │ │ │ ├── FindUserButton.tsx │ │ │ ├── LoginAsUserButton.tsx │ │ │ ├── PurgeHistoryButton.tsx │ │ │ ├── QuarantineAllMediaButton.tsx │ │ │ ├── RenewAccountValidityButton.tsx │ │ │ └── ResetPasswordButton.tsx │ │ └── fields/ │ │ ├── AvatarField.test.tsx │ │ ├── AvatarField.tsx │ │ └── EditableAvatarField.tsx │ ├── entrypoints/ │ │ ├── auth-callback.html │ │ └── index.html │ ├── i18n/ │ │ ├── README.md │ │ ├── de/ │ │ │ ├── base.ts │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── mas.ts │ │ │ ├── misc_resources.ts │ │ │ ├── reports.ts │ │ │ ├── rooms.ts │ │ │ └── users.ts │ │ ├── en/ │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── mas.ts │ │ │ ├── misc_resources.ts │ │ │ ├── reports.ts │ │ │ ├── rooms.ts │ │ │ └── users.ts │ │ ├── fa/ │ │ │ ├── base.ts │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── mas.ts │ │ │ ├── misc_resources.ts │ │ │ ├── reports.ts │ │ │ ├── rooms.ts │ │ │ └── users.ts │ │ ├── fr/ │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── mas.ts │ │ │ ├── misc_resources.ts │ │ │ ├── reports.ts │ │ │ ├── rooms.ts │ │ │ └── users.ts │ │ ├── i18n-keys.test.ts │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── it/ │ │ │ ├── base.ts │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── mas.ts │ │ │ ├── misc_resources.ts │ │ │ ├── reports.ts │ │ │ ├── rooms.ts │ │ │ └── users.ts │ │ ├── ja/ │ │ │ ├── base.ts │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── mas.ts │ │ │ ├── misc_resources.ts │ │ │ ├── reports.ts │ │ │ ├── rooms.ts │ │ │ └── users.ts │ │ ├── pt/ │ │ │ ├── base.ts │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── mas.ts │ │ │ ├── misc_resources.ts │ │ │ ├── reports.ts │ │ │ ├── rooms.ts │ │ │ └── users.ts │ │ ├── ru/ │ │ │ ├── base.ts │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── mas.ts │ │ │ ├── misc_resources.ts │ │ │ ├── reports.ts │ │ │ ├── rooms.ts │ │ │ └── users.ts │ │ ├── types.d.ts │ │ ├── uk/ │ │ │ ├── base.ts │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── mas.ts │ │ │ ├── misc_resources.ts │ │ │ ├── reports.ts │ │ │ ├── rooms.ts │ │ │ └── users.ts │ │ └── zh/ │ │ ├── base.ts │ │ ├── common.ts │ │ ├── index.ts │ │ ├── mas.ts │ │ ├── misc_resources.ts │ │ ├── reports.ts │ │ ├── rooms.ts │ │ └── users.ts │ ├── index.tsx │ ├── pages/ │ │ ├── DonatePage.test.tsx │ │ ├── DonatePage.tsx │ │ ├── LoginPage.test.tsx │ │ ├── LoginPage.tsx │ │ ├── MASPolicyDataPage.test.tsx │ │ ├── MASPolicyDataPage.tsx │ │ ├── auth-callback-error.test.tsx │ │ ├── auth-callback-error.tsx │ │ ├── auth-callback.test.tsx │ │ └── auth-callback.tsx │ ├── providers/ │ │ ├── README.md │ │ ├── auth/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── data/ │ │ │ ├── etke.test.ts │ │ │ ├── etke.ts │ │ │ ├── index.test.ts │ │ │ ├── index.ts │ │ │ ├── lifecycle.ts │ │ │ ├── mas-actions.ts │ │ │ ├── mas-utils.test.ts │ │ │ ├── mas-utils.ts │ │ │ ├── mas.ts │ │ │ ├── scan.ts │ │ │ ├── synapse-actions.ts │ │ │ └── synapse.ts │ │ ├── http.ts │ │ ├── matrix.test.ts │ │ ├── matrix.ts │ │ ├── serverVersion.ts │ │ └── types/ │ │ ├── common.ts │ │ ├── destinations.ts │ │ ├── etke.ts │ │ ├── index.ts │ │ ├── mas.ts │ │ ├── reports.ts │ │ ├── rooms.ts │ │ └── users.ts │ ├── resourceMap.ts │ ├── resources/ │ │ ├── README.md │ │ ├── destinations/ │ │ │ ├── List.tsx │ │ │ └── index.ts │ │ ├── mas/ │ │ │ ├── CompatSessions.tsx │ │ │ ├── OAuth2Sessions.tsx │ │ │ ├── PersonalSessions.tsx │ │ │ ├── UpstreamOAuthLinks.tsx │ │ │ ├── UpstreamOAuthProviders.tsx │ │ │ ├── UserEmails.tsx │ │ │ ├── UserSessions.tsx │ │ │ ├── index.ts │ │ │ └── shared.tsx │ │ ├── registration-tokens/ │ │ │ ├── Create.tsx │ │ │ ├── Edit.tsx │ │ │ ├── List.tsx │ │ │ └── index.ts │ │ ├── reports/ │ │ │ ├── List.tsx │ │ │ ├── Show.tsx │ │ │ └── index.ts │ │ ├── room-directory/ │ │ │ └── index.tsx │ │ ├── rooms/ │ │ │ ├── List.tsx │ │ │ ├── Show.tsx │ │ │ └── index.ts │ │ ├── scheduled-tasks/ │ │ │ ├── List.tsx │ │ │ └── index.ts │ │ ├── statistics/ │ │ │ ├── DatabaseRooms.tsx │ │ │ ├── UserMedia.tsx │ │ │ └── index.ts │ │ └── users/ │ │ ├── Create.tsx │ │ ├── Edit.tsx │ │ ├── List.tsx │ │ └── index.ts │ ├── utils/ │ │ ├── config.test.ts │ │ ├── config.ts │ │ ├── date.test.ts │ │ ├── date.ts │ │ ├── error.test.ts │ │ ├── error.ts │ │ ├── fetchMedia.test.ts │ │ ├── fetchMedia.ts │ │ ├── formatBytes.test.ts │ │ ├── formatBytes.ts │ │ ├── icons.ts │ │ ├── logger.test.ts │ │ ├── logger.ts │ │ ├── mxid.test.ts │ │ ├── mxid.ts │ │ ├── password.test.ts │ │ ├── password.ts │ │ ├── safety.test.ts │ │ ├── safety.ts │ │ ├── version.test.ts │ │ └── version.ts │ └── vitest.setup.ts ├── tsconfig.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ /docs/testdata ================================================ FILE: .editorconfig ================================================ # EditorConfig https://EditorConfig.org # top-most EditorConfig file root = true [*] charset = utf-8 end_of_line = lf indent_size = 2 indent_style = space insert_final_newline = true max_line_length = 120 trim_trailing_whitespace = true ================================================ FILE: .gitattributes ================================================ yarn*.cjs binary ================================================ FILE: .github/CONTRIBUTING.md ================================================ # Contribution Guidelines Table of Contents: * [Did you find a bug?](#did-you-find-a-bug) * [Is it a Security Vulnerability?](#is-it-a-security-vulnerability) * [Is it already a known issue?](#is-it-already-a-known-issue) * [Reporting a Bug](#reporting-a-bug) * [Is there a patch for the bug?](#is-there-a-patch-for-the-bug) * [Do you want to add a new feature?](#do-you-want-to-add-a-new-feature) * [Is it just an idea?](#is-it-just-an-idea) * [Is there a patch for the feature?](#is-there-a-patch-for-the-feature) * [Do you have questions about the Ketesa project or need guidance?](#do-you-have-questions-about-the-ketesa-project-or-need-guidance) ## Did you find a bug? ### Is it a Security Vulnerability? Please follow the [Security Policy](https://github.com/etkecc/ketesa/blob/main/.github/SECURITY.md) for reporting security vulnerabilities. ### Is it already a known issue? Please ensure the bug was not already reported by searching [the Issues section](https://github.com/etkecc/ketesa/issues). ### Reporting a Bug If you think you have found a bug in Ketesa, it is not a security vulnerability, and it is not already reported, please open [a new issue](https://github.com/etkecc/ketesa/issues/new) with: * A proper title and clear description of the problem. * As much relevant information as possible: * The version of Ketesa you are using. * The version of Synapse you are using. * Any relevant browser console logs, failed requests details, and error messages. ### Is there a patch for the bug? If you already have a patch for the bug, please open a pull request with the patch, and mention the issue number in the pull request description. ## Do you want to add a new feature? ### Is it just an idea? Please open [a new issue](https://github.com/etkecc/ketesa/issues/new) with: * A proper title and clear description of the requested feature. * Any relevant information about the feature: * Why do you think this feature is needed? * How do you think it should work? (provide Ketesa API endpoint) * Any relevant screenshots or mockups. ### Is there a patch for the feature? If you already have a patch for the feature, please open a pull request with the patch, and mention the issue number in the pull request description. ## Do you have questions about the Ketesa project or need guidance? Please use the official community Matrix room: [#ketesa:etke.cc](https://matrix.to/#/#ketesa:etke.cc) ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Report a Ketesa bug title: '' labels: bug assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Browser console logs** If applicable, add the browser console's log **Instance configuration:** - Ketesa version: [e.g. v0.10.3-etke39] - Synapse version [v1.127.1] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for Ketesa title: '' labels: enhancement assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Provide related Synapse Admin API endpoints** If applicable, provide links to the Synapse Admin API's endpoints that could be used to implement that feature **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/SECURITY.md ================================================ # Security Policy ## Supported Versions Only [the last published version](https://github.com/etkecc/ketesa/releases/latest) of the project is supported. This means that only the latest version will receive security updates. If you are using an older version, you are strongly encouraged to upgrade to the latest version. ## Reporting a Vulnerability Please contact us using the [#ketesa:etke.cc](https://matrix.to/#/#ketesa:etke.cc) Matrix room. Ketesa is a static JS UI for Matrix servers, so it is unlikely that there are (or will be) any impactful security vulnerabilities in the project itself. However, we do not rule out the possibility of such cases, so we will be happy to receive any reports! ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 30 - package-ecosystem: "docker" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 30 - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 30 ================================================ FILE: .github/workflows/reuse.yml ================================================ --- name: REUSE Compliance Check on: [push, pull_request] permissions: contents: read jobs: reuse-compliance-check: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - name: REUSE Compliance Check uses: fsfe/reuse-action@676e2d560c9a403aa252096d99fcab3e1132b0f5 # v6.0.0 ================================================ FILE: .github/workflows/workflow.yml ================================================ name: CI on: push: branches: [ "main" ] tags: [ "v*" ] env: bunny_version: v0.1.0 base_path: ./ NODE_OPTIONS: --max_old_space_size=4096 permissions: checks: write contents: write packages: write pull-requests: read jobs: build: name: Build runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: actions/setup-node@v6 with: node-version: lts/* cache: yarn - name: Install dependencies run: yarn install --immutable --network-timeout=300000 --pure-lockfile - name: Build run: | yarn build --base=${{ env.base_path }} mv dist dist-root yarn build --base=/admin/ mv dist dist-subpath-admin env: NODE_ENV: production - uses: actions/upload-artifact@v7 with: path: dist-root/ name: dist-root if-no-files-found: error retention-days: 1 compression-level: 0 overwrite: true include-hidden-files: true - uses: actions/upload-artifact@v7 with: path: dist-subpath-admin/ name: dist-subpath-admin if-no-files-found: error retention-days: 1 compression-level: 0 overwrite: true include-hidden-files: true docker: name: Docker needs: build runs-on: self-hosted steps: - uses: actions/checkout@v6 - uses: actions/download-artifact@v8 with: name: dist-root path: dist/ - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Login to ghcr.io uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to hub.docker.com uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: etkecc password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: | ${{ github.repository }} etkecc/synapse-admin ghcr.io/${{ github.repository }} registry.etke.cc/${{ github.repository }} tags: | type=raw,value=latest,enable=${{ github.ref_name == 'main' }} type=semver,pattern={{raw}} type=match,pattern=^v([0-9]+)\..*$,group=1,prefix=v,enable=${{ startsWith(github.ref, 'refs/tags/') }} - name: Build and push uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: platforms: linux/amd64,linux/arm64 context: . push: true file: docker/Dockerfile tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} VCS_REF=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }} BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} docker-subpath-admin: name: Docker (subpath /admin) needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/download-artifact@v8 with: name: dist-subpath-admin path: dist/ - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Login to ghcr.io uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to hub.docker.com uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: etkecc password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: | ${{ github.repository }} etkecc/synapse-admin ghcr.io/${{ github.repository }} tags: | type=raw,value=latest-subpath-admin,enable=${{ github.ref_name == 'main' }} type=semver,pattern={{raw}}-subpath-admin - name: Build and push uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: platforms: linux/amd64,linux/arm64 context: . push: true file: docker/Dockerfile.subpath-admin tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} VCS_REF=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }} BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} cdn: name: CDN needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/download-artifact@v8 with: name: dist-root path: dist/ - name: Upload run: | wget -O bunny-upload.tar.gz https://github.com/etkecc/bunny-upload/releases/download/${{ env.bunny_version }}/bunny-upload_Linux_x86_64.tar.gz tar -xzf bunny-upload.tar.gz echo "${{ secrets.BUNNY_CONFIG }}" > bunny-config.yaml sed -i "s||${{ secrets.CDN_HEAD }}|g" dist/index.html sed -i "s||${{ secrets.CDN_HEAD }}|g" dist/auth-callback/index.html rm dist/robots.txt ./bunny-upload -c bunny-config.yaml github-release: name: Github Release needs: build if: ${{ startsWith(github.ref, 'refs/tags/') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/download-artifact@v8 with: name: dist-root path: dist-root/ - uses: actions/download-artifact@v8 with: name: dist-subpath-admin path: dist-subpath-admin/ - name: Prepare release run: | mv dist-root ketesa tar chvzf ketesa.tar.gz ketesa mv dist-subpath-admin ketesa-subpath-admin tar chvzf ketesa-subpath-admin.tar.gz ketesa-subpath-admin - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: files: | ketesa.tar.gz ketesa-subpath-admin.tar.gz generate_release_notes: true make_latest: "true" draft: false prerelease: false ================================================ FILE: .gitignore ================================================ # Created by https://www.toptal.com/developers/gitignore/api/node,yarn,react,visualstudiocode # Edit at https://www.toptal.com/developers/gitignore?templates=node,yarn,react,visualstudiocode ## local state docs/apis/* docs/testdata/postgres.data docs/testdata/synapse.data ### Node ### # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional stylelint cache .stylelintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # vuepress v2.x temp and cache directory .temp # Docusaurus cache and generated files .docusaurus # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn .pnp.* ### Node Patch ### # Serverless Webpack directories .webpack/ # Optional stylelint cache # SvelteKit build / generate output .svelte-kit ### react ### .DS_* **/*.backup.* **/*.back.* node_modules *.sublime* psd thumb sketch ### VisualStudioCode ### .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json !.vscode/*.code-snippets # Local History for Visual Studio Code .history/ # Built Visual Studio Code Extensions *.vsix ### VisualStudioCode Patch ### # Ignore all local history of files .history .ionide ### yarn ### # https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored .yarn/* # End of https://www.toptal.com/developers/gitignore/api/node,yarn,react,visualstudiocode ================================================ FILE: .prettierignore ================================================ .vscode .yarn **/*.md **/*.woff2 ================================================ FILE: .watchmanconfig ================================================ { "ignore_dirs": ["docs/testdata"] } ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS ================================================ FILE: LICENSES/Apache-2.0.txt ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: LICENSES/BSD-2-Clause.txt ================================================ Copyright (c) < ;match=.+>> All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY <> "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <> BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: LICENSES/CC0-1.0.txt ================================================ Creative Commons Legal Code CC0 1.0 Universal CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER. Statement of Purpose The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. 1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: i. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; ii. moral rights retained by the original author(s) and/or performer(s); iii. publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; iv. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; v. rights protecting the extraction, dissemination, use and reuse of data in a Work; vi. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and vii. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. 2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. 3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. 4. Limitations and Disclaimers. a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. ================================================ FILE: LICENSES/MIT.txt ================================================ MIT License Copyright (c) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: LICENSES/OFL-1.1.txt ================================================ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 PREAMBLE The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. DEFINITIONS "Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. "Reserved Font Name" refers to any names specified as such after the copyright statement(s). "Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). "Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. PERMISSION & CONDITIONS Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. TERMINATION This license becomes null and void if any of the above conditions are not met. DISCLAIMER THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. ================================================ FILE: README.md ================================================

Ketesa Logo

Ketesa
Community room
License REUSE compliance

The evolution of Synapse Admin. Manage, monitor, and maintain your Matrix homeserver from one clean interface. Built for small private servers and large federated communities alike. Formerly Synapse Admin.

--- ![Login](./docs/screenshots/light/login.webp) ![Users List](./docs/screenshots/light/users-list.webp) [View all screenshots →](./docs/screenshots/README.md) ## 📖 About Ketesa is the evolution of Synapse Admin — a fully independent admin interface for Matrix homeservers. What began as a fork of [Awesome-Technologies/synapse-admin](https://github.com/Awesome-Technologies/synapse-admin) has grown into its own project, with a redesigned interface, comprehensive API coverage, multi-language support, and powerful management tools that go far beyond the original. **Ketesa is a fully compatible drop-in replacement for Synapse Admin.** Migration is straightforward and requires no configuration changes: | | Method | How | |---|---|---| | ☁️ | **Hosted (CDN)** | Use [admin.etke.cc](https://admin.etke.cc) — nothing to install | | 🐳 | **Docker** | Replace the image tag with `ghcr.io/etkecc/ketesa:latest` | | 📦 | **Static files** | Replace your existing dist directory with the Ketesa release tarball | Whether you're managing a small private server or a large federated community, Ketesa gives you the visibility and control you need — all from a clean, responsive web interface. > 💬 Questions? Join the [community room](https://matrix.to/#/#ketesa:etke.cc) or open an issue on GitHub. --- ## ✨ Features ### 👥 Complete user management Ketesa covers the full lifecycle of a Matrix user account. You can suspend, [shadow-ban](./docs/user-management.md#-shadow-ban), [deactivate, or permanently erase](./docs/user-management.md#-deactivation-vs-erasure) users. Fine-grained controls let you manage [rate limits](./docs/user-management.md#-rate-limits), [experimental features](./docs/user-management.md#-experimental-features), and [account data](./docs/user-management.md#-account-data). You can view and manage third-party identifiers, connected devices (create, rename, delete), room memberships, and cross-signing keys — all from one place. Need to onboard many users at once? [Bulk registration via CSV import](./docs/csv-import.md) handles it, including third-party identifiers. Passwords can be generated randomly or reset manually. User avatars carry [role badges](./docs/user-badges.md) (admin, bot, support, federated, system-managed) so you can identify account types at a glance. When [Matrix Authentication Service (MAS)](https://github.com/element-hq/matrix-authentication-service) is in use, Ketesa extends user management with [MAS-native capabilities](./docs/user-management.md#-mas-user-management): browsing and revoking active sessions (compat, OAuth2, and personal), managing linked email addresses, reviewing upstream OAuth provider links, and creating users through MAS directly. [📄 User management guide](./docs/user-management.md) ### 🏠 Powerful room management Get a full picture of every room on your server. Block or unblock rooms, purge history, and delete rooms entirely. The [messages viewer](./docs/room-management.md#-messages-viewer) lets you browse room history with filters and jump-to-date navigation. [Spaces are handled natively](./docs/room-management.md#-room-hierarchy) with a dedicated room hierarchy tab. You can assign room admins and join users to rooms directly from the UI. [Media](./docs/media.md) can be quarantined, protected, or deleted at file, user, or room scope. [📄 Room management guide](./docs/room-management.md) · [📄 Media management guide](./docs/media.md) ### 🔐 Flexible authentication Log in with a username and password, a raw access token, or via OIDC/SSO — whatever your server setup requires. Ketesa has first-class support for [Matrix Authentication Service (MAS)](https://github.com/element-hq/matrix-authentication-service), including full session management and [registration token administration](./docs/registration-tokens.md). It also ships a dedicated [external auth provider mode](./docs/external-auth-provider.md) that adapts the interface when Synapse delegates authentication to an external system. [📄 Registration tokens guide](./docs/registration-tokens.md) ### ⚙️ Deep customization Every data table in Ketesa is built with [react-admin's Configurable](https://marmelab.com/react-admin/Configurable.html) component, so users can show, hide, and reorder columns to match their workflow — no code changes needed. [📄 Configurable columns guide](./docs/configurable-columns.md) Beyond the per-user table preferences, Ketesa can be tailored at the deployment level through a [`config.json`](./docs/config.md) file (or via `/.well-known/matrix/client`): [restrict which homeservers](./docs/restrict-hs.md) users can connect to, [add custom navigation menu items](./docs/custom-menu.md), [pre-fill the login form](./docs/prefill-login-form.md), [configure CORS credentials](./docs/cors-credentials.md), and [protect appservice-managed users](./docs/system-users.md) (bridge puppets) from accidental edits. ### 📊 Server statistics and insights Monitor your server with built-in statistics views: [per-user media usage and database room stats](./docs/server-statistics.md) give you a clear picture of what's consuming space. The [federation overview](./docs/federation.md) shows the health and reachability of remote destinations. [Reported events](./docs/event-reports.md) can be reviewed and acted upon directly from the reports list. [📄 Server statistics guide](./docs/server-statistics.md) · [📄 Federation guide](./docs/federation.md) · [📄 Event reports guide](./docs/event-reports.md) ### 🌍 Available in 10 languages Ketesa ships with full translations in English, German, French, Japanese, Russian, Persian, Ukrainian, Chinese, Italian, and Portuguese — every string is fully translated, with no untranslated English stubs left behind. ### 📱 Mobile-friendly by design The interface is fully responsive. Wherever you are, you can manage your server from a phone or tablet without sacrificing functionality. Tables collapse to readable mobile lists, long identifiers wrap gracefully, and every action is reachable on small screens. ### 🌟 Built by etke.cc — and it shows Ketesa is built and actively maintained by [etke.cc](https://etke.cc/?utm_source=github&utm_medium=readme&utm_campaign=ketesa), a managed Matrix hosting provider with a genuine [open-source-first](https://github.com/etkecc) philosophy. Every feature in this project is open source, developed in the open, and free to use by anyone. If you run your Matrix server on etke.cc, Ketesa becomes something even more powerful: a **unified control plane for your entire server**. Instead of juggling separate dashboards, log files, and support channels, everything you need is right here — in the same interface you already use for user and room management: | | Feature | What it does | |---|---|---| | 🟢 | **Server health** | Live status badge in the toolbar + a full dashboard showing every server component with color-coded indicators, error details, and suggested actions. Know what's wrong before your users do. | | 🔔 | **Notifications** | Important server events surface as an in-app feed with an unread badge — nothing slips through the cracks. | | ⚡ | **Server actions** | Trigger management commands on demand, schedule them for a precise date and time, or set up recurring weekly jobs. Routine maintenance becomes a few clicks, not a cron job. | | 🧩 | **Components** | Browse, add, and remove server add-ons — bridges, bots, apps — from a self-service catalogue. See what's active, preview the cost impact, and request changes with one click. | | 💳 | **Billing** | View payment history, transaction details, and download invoices without ever leaving the admin panel. | | 💬 | **Support** | Open support requests, track their progress, and exchange messages with the etke.cc support — right from the interface you use every day. | | 🎨 | **White-labelling** | Custom name, logo, favicon, and background applied automatically from the platform. No extra configuration, no deploy step. | > 💡 **Interested?** [Learn more about etke.cc managed Matrix hosting →](https://etke.cc/?utm_source=github&utm_medium=readme&utm_campaign=ketesa) --- ## 📦 Availability | | Where | Details | |---|---|---| | 🏠 | **[etke.cc](https://etke.cc/?utm_source=github&utm_medium=readme&utm_campaign=ketesa)** | Managed hosting with Ketesa built in | | 🌐 | **[admin.etke.cc](https://admin.etke.cc)** | Hosted instance, always on the latest development version | | 📦 | **[GitHub Releases](https://github.com/etkecc/ketesa/releases)** | Official prebuilt tarballs for root-path and `/admin` deployments | | 🐳 | **[GHCR](https://github.com/etkecc/ketesa/pkgs/container/ketesa) / [Docker Hub](https://hub.docker.com/r/etkecc/ketesa/tags)** | Official container images | | 🔧 | **[Source](https://github.com/etkecc/ketesa)** | Build from source or track `main` directly | Official static builds: - **`ketesa.tar.gz`** for root path deployment, such as `https://admin.example.com` - **`ketesa-subpath-admin.tar.gz`** for `/admin` deployments, such as `https://example.com/admin` For nightly builds, distro packages, Ansible integrations, and IPFS, see the [full availability guide](./docs/availability.md). --- ## ⚙️ Configuration Ketesa can be configured via a `config.json` file placed in the deployment root. Additionally, your homeserver's `/.well-known/matrix/client` file can carry Ketesa-specific configuration under the `cc.etke.ketesa` key — any instance of Ketesa will pick it up automatically, making it easy to distribute settings to your users without touching the deployment itself. Settings in `/.well-known/matrix/client` take precedence over `config.json`. > **Note:** The legacy key `cc.etke.synapse-admin` is still supported for backward compatibility, but is deprecated. > Please migrate to `cc.etke.ketesa` at your convenience. If you use [spantaleev/matrix-docker-ansible-deploy](https://github.com/spantaleev/matrix-docker-ansible-deploy) or [etkecc/ansible](https://github.com/etkecc/ansible), configuration is automatically written to `/.well-known/matrix/client` for you. [📄 Full configuration reference](./docs/config.md) To inject a `config.json` into a Docker container, use a bind mount: ```yml services: ketesa: ... volumes: - ./config.json:/var/public/config.json:ro ... ``` ### 🔗 Prefilling the login form Every field on the login page can be pre-filled via URL query parameters — handy for sharing direct-access links with your users. [Documentation](./docs/prefill-login-form.md) ### 🔒 Restricting available homeservers Lock down the homeserver selection so users can only connect to servers you approve. Useful for managed deployments where the homeserver should never change. [Documentation](./docs/restrict-hs.md) ### 🌐 Configuring CORS credentials Fine-tune the CORS credentials mode for your Ketesa deployment to match your server's cross-origin policies. [Documentation](./docs/cors-credentials.md) ### 🛡️ Protecting appservice-managed users Bridge puppets and other appservice-managed accounts can be shielded from accidental edits. Specify a list of MXID patterns (as regular expressions) to be restricted to display name and avatar changes only. [Documentation](./docs/system-users.md) ### 📋 Adding custom menu items Extend the navigation menu with links to your own tools or documentation — no rebuild required. [Documentation](./docs/custom-menu.md) ### 🔑 External auth provider mode When Synapse delegates authentication to an external provider (OIDC, LDAP, and similar), enable this mode to adjust Ketesa's behavior accordingly and avoid confusing UI elements that don't apply in your setup. [Documentation](./docs/external-auth-provider.md) #### Matrix Authentication Service (MAS) MAS requires a small amount of additional configuration to enable its admin API. See the [designated MAS section](./docs/external-auth-provider.md#matrix-authentication-service-mas) for the details. --- ## 🚀 Usage ### Supported APIs See [📄 Supported APIs](./docs/apis.md) for a full list of API endpoints used by Ketesa. ### Supported Synapse versions Ketesa requires [Synapse](https://github.com/element-hq/synapse) **v1.150.0 or newer** for all features to work correctly. You can verify your server version by calling `/_synapse/admin/v1/server_version`, or simply look at the version indicator that appears below the homeserver URL field on the Ketesa login page. See also: [Synapse version API](https://element-hq.github.io/synapse/latest/admin_api/version_api.html) ### Prerequisites Your browser needs access to the following endpoints on your homeserver: - `/_matrix` - `/_synapse/admin` See also: [Synapse administration endpoints](https://element-hq.github.io/synapse/latest/reverse_proxy.html#synapse-administration-endpoints) ### ☁️ Use without installing anything The hosted version at [admin.etke.cc](https://admin.etke.cc) is always up to date and requires no installation. Just open it in your browser, enter your homeserver URL, and log in with your admin account. > 🔒 Your browser must be able to reach `/_synapse/admin` on your homeserver. The endpoints > do not need to be exposed to the public internet — access from your local network is sufficient. ### 📥 Step-by-step installation Choose your preferred method: | | Method | Best for | |---|---|---| | [1️⃣](#steps-for-1) | **Tarball + webserver** | Any static hosting, full control | | [2️⃣](#steps-for-2) | **Source + Node.js** | Development or custom builds | | [3️⃣](#steps-for-3) | **Docker** | Containerized deployments | #### Steps for 1) - Make sure you have a webserver installed that can serve static files (nginx, Apache, Caddy, or anything else will work) - Configure a virtual host for Ketesa on your webserver - Download the appropriate `.tar.gz` from the [latest release](https://github.com/etkecc/ketesa/releases/latest): - `ketesa.tar.gz` for root path deployment (e.g., `https://admin.example.com`) - `ketesa-subpath-admin.tar.gz` for `/admin` subpath deployment (e.g., `https://example.com/admin`) - Unpack the archive and place the contents in your virtual host's document root - Open the URL in your browser [📄 Reverse proxy configuration examples](./docs/reverse-proxy.md) #### Steps for 2) - Make sure you have git, yarn, and Node.js installed - Clone the repository: `git clone https://github.com/etkecc/ketesa.git` - Enter the directory: `cd ketesa` - Install dependencies: `yarn install` - Start the development server: `yarn start` #### Steps for 3) - Run the Docker container: `docker run -p 8080:8080 ghcr.io/etkecc/ketesa` Or use the provided [docker-compose.yml](docker/docker-compose.yml): ```sh docker-compose -f docker/docker-compose.yml up -d ``` > **Note:** If you're building on a non-amd64 architecture (e.g., Raspberry Pi), set a Node.js > memory cap to prevent OOM failures during the build: `NODE_OPTIONS="--max_old_space_size=1024"`. > **Note:** On IPv4-only systems, set `SERVER_HOST=0.0.0.0` so Ketesa binds correctly. To build your own image from source: ```yml services: ketesa: container_name: ketesa hostname: ketesa build: context: https://github.com/etkecc/ketesa.git dockerfile: docker/Dockerfile.build args: - BUILDKIT_CONTEXT_KEEP_GIT_DIR=1 # - NODE_OPTIONS="--max_old_space_size=1024" # - BASE_PATH="/ketesa" ports: - "8080:8080" restart: unless-stopped ``` - Open http://localhost:8080 in your browser ### 🛤️ Serving Ketesa under a custom path The base path is baked in at build time and cannot be changed at runtime. - For `/admin` specifically: use the prebuilt `ketesa-subpath-admin` tarball from [GitHub Releases](https://github.com/etkecc/ketesa/releases) or the `dist-subpath-admin` artifact from [GitHub Actions](https://github.com/etkecc/ketesa/actions/workflows/workflow.yml), or the `*-subpath-admin` Docker image tag. - For root path: use `ketesa.tar.gz` or the `dist-root` artifact. - For any other prefix: build from source with `yarn build --base=/my-prefix`, or pass the `BASE_PATH` build argument to Docker. If you need a reverse proxy to expose Ketesa under a different base path without rebuilding, here is a Traefik example: `docker-compose.yml` ```yml services: traefik: image: traefik:v3 restart: unless-stopped ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro ketesa: image: ghcr.io/etkecc/ketesa:latest restart: unless-stopped labels: - "traefik.enable=true" - "traefik.http.routers.admin.rule=Host(`example.com`) && PathPrefix(`/admin`)" - "traefik.http.services.admin.loadbalancer.server.port=8080" - "traefik.http.middlewares.admin-slashless-redirect.redirectregex.regex=(/admin)$$" - "traefik.http.middlewares.admin-slashless-redirect.redirectregex.replacement=$${1}/" - "traefik.http.middlewares.admin-strip-prefix.stripprefix.prefixes=/admin" - "traefik.http.routers.admin.middlewares=admin-slashless-redirect,admin-strip-prefix" ``` --- ## 🛠️ Development - See https://yarnpkg.com/getting-started/editor-sdks for IDE setup instructions | Command | What it does | |---|---| | `yarn lint` | Run all style and linter checks | | `yarn test` | Run all unit tests | | `yarn fix` | Auto-fix coding style issues | | `just run-dev` | Spin up the full local development stack | `just run-dev` launches a complete local environment: a Synapse homeserver, Element Web, and a Postgres database. The app starts in development mode at `http://localhost:5173`. (If user creation fails on first run, re-run the command — the server may still be starting up.) Open [http://localhost:5173](http://localhost:5173?username=admin&password=admin&server=http://localhost:8008) and log in with: | Field | Value | |---|---| | Login | `admin` | | Password | `admin` | | Homeserver URL | `http://localhost:8008` | Element Web is available at http://localhost:8080. ================================================ FILE: REUSE.toml ================================================ version = 1 [[annotations]] path = ["**"] SPDX-FileCopyrightText = [ "2018-2023 Awesome Technologies Innovationslabor GmbH", "2024-2026 etke.cc team ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/layout/Datagrid.test.tsx"] SPDX-FileCopyrightText = "2026 Nikita Chernyi " SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = [".dockerignore"] SPDX-FileCopyrightText = "NONE" SPDX-License-Identifier = "CC0-1.0" [[annotations]] path = [".editorconfig"] SPDX-FileCopyrightText = "NONE" SPDX-License-Identifier = "CC0-1.0" [[annotations]] path = [".gitattributes"] SPDX-FileCopyrightText = "NONE" SPDX-License-Identifier = "CC0-1.0" [[annotations]] path = [".github/CONTRIBUTING.md"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = [".github/ISSUE_TEMPLATE/bug_report.md"] SPDX-FileCopyrightText = [ "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = [".github/ISSUE_TEMPLATE/feature_request.md"] SPDX-FileCopyrightText = [ "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = [".github/SECURITY.md"] SPDX-FileCopyrightText = [ "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = [".github/dependabot.yml"] SPDX-FileCopyrightText = [ "2023 Dirk Klimpel", "2024 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = [".github/workflows/reuse.yml"] SPDX-FileCopyrightText = "2022 Free Software Foundation Europe e.V. " SPDX-License-Identifier = "CC0-1.0" [[annotations]] path = [".github/workflows/workflow.yml"] SPDX-FileCopyrightText = [ "2021 sakkiii", "2022 Dominik Fuchß", "2024 Borislav Pantaleev ", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = [".gitignore"] SPDX-FileCopyrightText = "NONE" SPDX-License-Identifier = "CC0-1.0" [[annotations]] path = [".prettierignore"] SPDX-FileCopyrightText = "NONE" SPDX-License-Identifier = "CC0-1.0" [[annotations]] path = [".watchmanconfig"] SPDX-FileCopyrightText = "NONE" SPDX-License-Identifier = "CC0-1.0" [[annotations]] path = ["docker/Dockerfile"] SPDX-FileCopyrightText = [ "2019-2024 Manuel Stahl", "2021 Aaron Raimist", "2022 Leon Schmidt", "2023 Michael Albert", "2024 Gavin Mogan", "2024-2026 Nikita Chernyi ", "2025 Dirk Klimpel", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docker/Dockerfile.build"] SPDX-FileCopyrightText = [ "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["LICENSE"] SPDX-FileCopyrightText = [ "2020 Michael Albert", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["README.md"] SPDX-FileCopyrightText = [ "2020-2024 Dirk Klimpel", "2020-2024 Manuel Stahl", "2020-2021 Michael Albert", "2021 csett86", "2021 Lukas Wolfsteiner", "2022 Leon Schmidt", "2023 Sebastian Wagner", "2023-2024 sebix", "2024 Andreas Schildbach", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", "2025 DrMaxNix", "2025 Max", "2026 mawise", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docker/docker-compose-dev.yml"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docker/docker-compose.yml"] SPDX-FileCopyrightText = [ "2021 Lukas Wolfsteiner", "2023 Dirk Klimpel", "2023 Michael Albert", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/README.md"] SPDX-FileCopyrightText = [ "2024 Borislav Pantaleev ", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/components.md"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/config.md"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/configurable-columns.md"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/cors-credentials.md"] SPDX-FileCopyrightText = [ "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/csv-import.md"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/custom-menu.md"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/event-reports.md"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/external-auth-provider.md"] SPDX-FileCopyrightText = [ "2025-2026 Nikita Chernyi ", "2026 cy1der", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/well-known-discovery.md"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/federation.md"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/media.md"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/prefill-login-form.md"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/registration-tokens.md"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/restrict-hs.md"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/reverse-proxy.md"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", "2026 cy1der", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/room-management.md"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/server-statistics.md"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/system-users.md"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/user-badges.md"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/user-management.md"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/user-search.md"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["eslint.config.js"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/vitest.setup.ts"] SPDX-FileCopyrightText = [ "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["justfile"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["package.json"] SPDX-FileCopyrightText = [ "2020-2024 Manuel Stahl", "2020-2022 Michael Albert", "2021 Nya Candy", "2022-2025 Dirk Klimpel", "2023 Charlie Calendre", "2023 Francesco Carmelo Capria", "2024 Fateme Shamohammadi", "2024 rkfg", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", "2025 Suguru Hirahara ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["public/config.json"] SPDX-FileCopyrightText = [ "2024 Manuel Stahl", "2026 Nikita Chernyi ", "2026 Slavi Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["public/data/example.csv"] SPDX-FileCopyrightText = [ "2020 Michael Albert", "2024 Manuel Stahl", "2025 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["public/favicon.ico"] SPDX-FileCopyrightText = [ "2020 Michael Albert", "2024 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["public/images/logo.webp"] SPDX-FileCopyrightText = [ "2024 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["public/robots.txt"] SPDX-FileCopyrightText = [ "2020 Michael Albert", "2024 Dirk Klimpel", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/screenshots/dark/**"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/screenshots/light/**"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/App.test.tsx"] SPDX-FileCopyrightText = [ "2020-2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", "2025 Dirk Klimpel", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/App.tsx"] SPDX-FileCopyrightText = [ "2020 Michael Albert", "2020-2025 Dirk Klimpel", "2020-2024 Manuel Stahl", "2021 Nya Candy", "2023 Charlie Calendre", "2023 Francesco Carmelo Capria", "2024 rkfg", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", "2025 Suguru Hirahara ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/Context.tsx"] SPDX-FileCopyrightText = [ "2024 Borislav Pantaleev ", "2025 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/README.md"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/layout/AdminLayout.tsx"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/fields/AvatarField.test.tsx"] SPDX-FileCopyrightText = [ "2024 Borislav Pantaleev ", "2024 Manuel Stahl", "2025 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/fields/AvatarField.tsx"] SPDX-FileCopyrightText = [ "2023 Dirk Klimpel", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/buttons/DeleteRoomButton.tsx"] SPDX-FileCopyrightText = [ "2024 Borislav Pantaleev ", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/buttons/DeleteUserButton.tsx"] SPDX-FileCopyrightText = [ "2024 Borislav Pantaleev ", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/buttons/DeviceRemoveButton.tsx"] SPDX-FileCopyrightText = [ "2024 Manuel Stahl", "2024-2025 Nikita Chernyi ", "2025 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/ExperimentalFeatures.tsx"] SPDX-FileCopyrightText = [ "2024-2025 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/layout/Footer.test.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/layout/Footer.tsx"] SPDX-FileCopyrightText = [ "2024 Borislav Pantaleev ", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/MatrixWordmark.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/pages/DonatePage.tsx", "src/pages/DonatePage.test.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/layout/LoginFormBox.tsx"] SPDX-FileCopyrightText = [ "2024 Borislav Pantaleev ", "2024-2025 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/layout/index.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/ServerNotices.tsx"] SPDX-FileCopyrightText = [ "2020-2025 Dirk Klimpel", "2020-2024 Manuel Stahl", "2024 Borislav Pantaleev ", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/UserAccountData.tsx"] SPDX-FileCopyrightText = [ "2025 Nikita Chernyi ", "2025-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/UserRateLimits.tsx"] SPDX-FileCopyrightText = [ "2024-2025 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/BillingStatusBadge.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/BillingPage.tsx"] SPDX-FileCopyrightText = [ "2025-2026 Nikita Chernyi ", "2025-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/CurrentlyRunningCommand.tsx"] SPDX-FileCopyrightText = [ "2025 Borislav Pantaleev ", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/ComponentsPage.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/EtkeAttribution.tsx"] SPDX-FileCopyrightText = [ "2025 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/EtkeAttribution.test.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/InstanceConfig.tsx"] SPDX-FileCopyrightText = [ "2025 Borislav Pantaleev ", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/README.md"] SPDX-FileCopyrightText = [ "2024-2026 Borislav Pantaleev ", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/RichTextEditor.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/ServerActionsPage.tsx"] SPDX-FileCopyrightText = [ "2025 Borislav Pantaleev ", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/ServerCommandsPanel.tsx"] SPDX-FileCopyrightText = [ "2025 Borislav Pantaleev ", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/ServerNotificationsBadge.tsx"] SPDX-FileCopyrightText = [ "2024-2026 Borislav Pantaleev ", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/ServerNotificationsPage.tsx"] SPDX-FileCopyrightText = [ "2024-2025 Borislav Pantaleev ", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/ServerNotificationsUnavailable.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/ServerNotificationsBadge.test.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/ServerNotificationsUnavailable.test.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/data/etke.test.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/ServerStatusBadge.test.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/ServerStatusBadge.tsx"] SPDX-FileCopyrightText = [ "2024-2026 Borislav Pantaleev ", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/ServerStatusPage.test.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/ServerStatusPage.tsx"] SPDX-FileCopyrightText = [ "2024-2025 Borislav Pantaleev ", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/SupportAttachments.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/SupportPage.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/SupportRequestPage.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/hooks/useServerCommands.ts"] SPDX-FileCopyrightText = [ "2025-2026 Nikita Chernyi ", "2025-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/schedules/components/recurring/RecurringCommandEdit.tsx"] SPDX-FileCopyrightText = [ "2025 Borislav Pantaleev ", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/schedules/components/recurring/RecurringCommandsList.tsx"] SPDX-FileCopyrightText = [ "2025 Borislav Pantaleev ", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/schedules/components/recurring/RecurringDeleteButton.tsx"] SPDX-FileCopyrightText = [ "2025 Borislav Pantaleev ", "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/schedules/components/scheduled/ScheduledCommandEdit.tsx"] SPDX-FileCopyrightText = [ "2025 Borislav Pantaleev ", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/schedules/components/scheduled/ScheduledCommandShow.tsx"] SPDX-FileCopyrightText = [ "2025 Borislav Pantaleev ", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/schedules/components/scheduled/ScheduledCommandsList.tsx"] SPDX-FileCopyrightText = [ "2025 Borislav Pantaleev ", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/schedules/components/scheduled/ScheduledDeleteButton.tsx"] SPDX-FileCopyrightText = [ "2025 Borislav Pantaleev ", "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/schedules/hooks/useRecurringCommands.tsx"] SPDX-FileCopyrightText = [ "2025 Borislav Pantaleev ", "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/schedules/hooks/useScheduledCommands.tsx"] SPDX-FileCopyrightText = [ "2025 Borislav Pantaleev ", "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/hooks/useDocTitle.test.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/hooks/useDocTitle.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/media/DeleteMediaButton.tsx"] SPDX-FileCopyrightText = [ "2021-2025 Dirk Klimpel", "2024 Alexander Tumin", "2024 Borislav Pantaleev ", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/media/index.ts"] SPDX-FileCopyrightText = [ "2021-2025 Dirk Klimpel", "2024 Alexander Tumin", "2024 Borislav Pantaleev ", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/media/ProtectMediaButton.tsx"] SPDX-FileCopyrightText = [ "2021-2025 Dirk Klimpel", "2024 Alexander Tumin", "2024 Borislav Pantaleev ", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/media/PurgeRemoteMediaButton.tsx"] SPDX-FileCopyrightText = [ "2021-2025 Dirk Klimpel", "2024 Alexander Tumin", "2024 Borislav Pantaleev ", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/media/QuarantineMediaButton.tsx"] SPDX-FileCopyrightText = [ "2021-2025 Dirk Klimpel", "2024 Alexander Tumin", "2024 Borislav Pantaleev ", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/media/ViewMedia.tsx"] SPDX-FileCopyrightText = [ "2021-2025 Dirk Klimpel", "2024 Alexander Tumin", "2024 Borislav Pantaleev ", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/user-import/ConflictModeCard.tsx"] SPDX-FileCopyrightText = [ "2025 Nikita Chernyi ", "2025 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/user-import/ErrorsCard.tsx"] SPDX-FileCopyrightText = [ "2025 Nikita Chernyi ", "2025 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/user-import/ResultsCard.tsx"] SPDX-FileCopyrightText = [ "2025 Nikita Chernyi ", "2025 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/user-import/StartImportCard.tsx"] SPDX-FileCopyrightText = [ "2025 Nikita Chernyi ", "2025 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/user-import/StatsCard.tsx"] SPDX-FileCopyrightText = [ "2025 Nikita Chernyi ", "2025 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/user-import/UploadCard.tsx"] SPDX-FileCopyrightText = [ "2025 Nikita Chernyi ", "2025 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/user-import/UserImport.tsx"] SPDX-FileCopyrightText = [ "2024 jamazi", "2025 Borislav Pantaleev ", "2025 milkomeda", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/user-import/types.ts"] SPDX-FileCopyrightText = [ "2025 Borislav Pantaleev ", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/user-import/useImportFile.test.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/user-import/useImportFile.tsx"] SPDX-FileCopyrightText = [ "2025 Borislav Pantaleev ", "2025-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/i18n/README.md"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = [ "src/i18n/de/common.ts", "src/i18n/de/index.ts", "src/i18n/de/mas.ts", "src/i18n/de/misc_resources.ts", "src/i18n/de/reports.ts", "src/i18n/de/rooms.ts", "src/i18n/de/users.ts", ] SPDX-FileCopyrightText = [ "2020-2025 Dirk Klimpel", "2020-2024 Manuel Stahl", "2020-2021 Michael Albert", "2022 Nya Candy", "2023 Przemysław Romanik", "2024 Alexander Tumin", "2024 ll-SKY-ll", "2024 Steffo", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = [ "src/i18n/en/common.ts", "src/i18n/en/index.ts", "src/i18n/en/mas.ts", "src/i18n/en/misc_resources.ts", "src/i18n/en/reports.ts", "src/i18n/en/rooms.ts", "src/i18n/en/users.ts", ] SPDX-FileCopyrightText = [ "2020-2024 Dirk Klimpel", "2020-2024 Manuel Stahl", "2020-2022 Michael Albert", "2021 John Francis Sukamto", "2022 Nya Candy", "2023 Charlie Calendre", "2023 Przemysław Romanik", "2024 Alexander Tumin", "2024 rkfg", "2024 Steffo", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", "2025 Huw Carpenter", "2025 Suguru Hirahara ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = [ "src/i18n/fa/common.ts", "src/i18n/fa/index.ts", "src/i18n/fa/mas.ts", "src/i18n/fa/misc_resources.ts", "src/i18n/fa/reports.ts", "src/i18n/fa/rooms.ts", "src/i18n/fa/users.ts", ] SPDX-FileCopyrightText = [ "2024 Fateme Shamohammadi", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = [ "src/i18n/fr/common.ts", "src/i18n/fr/index.ts", "src/i18n/fr/mas.ts", "src/i18n/fr/misc_resources.ts", "src/i18n/fr/reports.ts", "src/i18n/fr/rooms.ts", "src/i18n/fr/users.ts", ] SPDX-FileCopyrightText = [ "2023 Charlie Calendre", "2023 Michael Albert", "2024 Dirk Klimpel", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/i18n/i18n-keys.test.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = [ "src/i18n/it/common.ts", "src/i18n/it/index.ts", "src/i18n/it/mas.ts", "src/i18n/it/misc_resources.ts", "src/i18n/it/reports.ts", "src/i18n/it/rooms.ts", "src/i18n/it/users.ts", ] SPDX-FileCopyrightText = [ "2023 Francesco Carmelo Capria", "2024 Dirk Klimpel", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = [ "src/i18n/ja/common.ts", "src/i18n/ja/index.ts", "src/i18n/ja/mas.ts", "src/i18n/ja/misc_resources.ts", "src/i18n/ja/reports.ts", "src/i18n/ja/rooms.ts", "src/i18n/ja/users.ts", ] SPDX-FileCopyrightText = [ "2025 Suguru Hirahara ", "2025-2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = [ "src/i18n/ru/common.ts", "src/i18n/ru/index.ts", "src/i18n/ru/mas.ts", "src/i18n/ru/misc_resources.ts", "src/i18n/ru/reports.ts", "src/i18n/ru/rooms.ts", "src/i18n/ru/users.ts", ] SPDX-FileCopyrightText = [ "2024 rkfg", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", "2025 Dirk Klimpel", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = [ "src/i18n/uk/common.ts", "src/i18n/uk/index.ts", "src/i18n/uk/mas.ts", "src/i18n/uk/misc_resources.ts", "src/i18n/uk/reports.ts", "src/i18n/uk/rooms.ts", "src/i18n/uk/users.ts", ] SPDX-FileCopyrightText = [ "2025 khvalera", "2025-2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = [ "src/i18n/zh/common.ts", "src/i18n/zh/index.ts", "src/i18n/zh/mas.ts", "src/i18n/zh/misc_resources.ts", "src/i18n/zh/reports.ts", "src/i18n/zh/rooms.ts", "src/i18n/zh/users.ts", ] SPDX-FileCopyrightText = [ "2021-2025 Dirk Klimpel", "2021-2024 Manuel Stahl", "2021-2022 Nya Candy", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", "2025 WriteMemory", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/i18n/de/base.ts"] SPDX-FileCopyrightText = ["Haleos UG (haftungsbeschränkt)"] SPDX-License-Identifier = "MIT" [[annotations]] path = ["src/i18n/fa/base.ts"] SPDX-FileCopyrightText = ["Hamid Feizabadi"] SPDX-License-Identifier = "BSD-2-Clause" [[annotations]] path = ["src/i18n/it/base.ts"] SPDX-FileCopyrightText = ["Stefano Savanelli"] SPDX-License-Identifier = "MIT" [[annotations]] path = ["src/i18n/ja/base.ts"] SPDX-FileCopyrightText = ["2019 Oishi Takanori"] SPDX-License-Identifier = "MIT" [[annotations]] path = ["src/i18n/ru/base.ts"] SPDX-FileCopyrightText = ["Klucherev Alexey"] SPDX-License-Identifier = "MIT" [[annotations]] path = ["src/i18n/uk/base.ts"] SPDX-FileCopyrightText = ["Vasyl Boroviak"] SPDX-License-Identifier = "MIT" [[annotations]] path = ["src/i18n/zh/base.ts"] SPDX-FileCopyrightText = ["Haxqer", "Moca-Tech"] SPDX-License-Identifier = "MIT" [[annotations]] path = [ "src/i18n/pt/common.ts", "src/i18n/pt/index.ts", "src/i18n/pt/mas.ts", "src/i18n/pt/misc_resources.ts", "src/i18n/pt/reports.ts", "src/i18n/pt/rooms.ts", "src/i18n/pt/users.ts", ] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/i18n/pt/base.ts"] SPDX-FileCopyrightText = [ "2017 Wedney Yuri", "2017 Willian Ribeiro Angelo", "2018-2020 Gabriel Marques", "2018 Giuseppe Menti", "2019 Marcos Fernando Costa", "2019 Matheus Wichman", "2020 Alexsander da Rosa", "2020 Eduardo Gomes Bonilha", "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "MIT" [[annotations]] path = ["src/index.tsx"] SPDX-FileCopyrightText = [ "2020 Michael Albert", "2024 Borislav Pantaleev ", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", "2025 Dirk Klimpel", "2025 Patrick Kranz", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resourceMap.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/pages/LoginPage.test.tsx"] SPDX-FileCopyrightText = [ "2020-2024 Manuel Stahl", "2023 Dirk Klimpel", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/pages/LoginPage.tsx"] SPDX-FileCopyrightText = [ "2020 Michael Albert", "2020 teutates", "2020-2025 Dirk Klimpel", "2020-2024 Manuel Stahl", "2021-2022 Nya Candy", "2023 Charlie Calendre", "2023 Francesco Carmelo Capria", "2023 Stefan Möhrle", "2024 Fateme Shamohammadi", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", "2025 Hugo Renard", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/README.md"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/destinations/index.ts", "src/resources/destinations/List.tsx"] SPDX-FileCopyrightText = [ "2023-2024 Dirk Klimpel", "2024 Borislav Pantaleev ", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", "2024-2025 rkfg", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/registration-tokens/Create.tsx", "src/resources/registration-tokens/Edit.tsx", "src/resources/registration-tokens/index.ts", "src/resources/registration-tokens/List.tsx"] SPDX-FileCopyrightText = [ "2022-2024 Dirk Klimpel", "2023-2024 sebix", "2024 Manuel Stahl", "2024 Sebastian Wagner", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/reports/index.ts", "src/resources/reports/List.tsx", "src/resources/reports/Show.tsx"] SPDX-FileCopyrightText = [ "2021-2024 Dirk Klimpel", "2023 Ezwen", "2024 Alexander Tumin", "2024 Manuel Stahl", "2024 Steffo", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/room-directory/index.tsx"] SPDX-FileCopyrightText = [ "2021 Michael Albert", "2021-2025 Dirk Klimpel", "2024 Borislav Pantaleev ", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/statistics/DatabaseRooms.tsx", "src/resources/statistics/index.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/rooms/index.ts"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/rooms/List.tsx"] SPDX-FileCopyrightText = [ "2020-2024 Dirk Klimpel", "2020-2024 Manuel Stahl", "2020-2021 Michael Albert", "2022 Timo Gurr", "2024 rkfg", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/rooms/Show.tsx"] SPDX-FileCopyrightText = [ "2020-2024 Dirk Klimpel", "2020-2024 Manuel Stahl", "2020-2021 Michael Albert", "2022 Timo Gurr", "2024 rkfg", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/statistics/UserMedia.tsx"] SPDX-FileCopyrightText = [ "2021-2024 Dirk Klimpel", "2024 Borislav Pantaleev ", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/mas/CompatSessions.tsx"] SPDX-FileCopyrightText = [ "2026 Borislav Pantaleev ", "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/mas/index.ts"] SPDX-FileCopyrightText = [ "2026 Borislav Pantaleev ", "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/mas/OAuth2Sessions.tsx"] SPDX-FileCopyrightText = [ "2026 Borislav Pantaleev ", "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/mas/PersonalSessions.tsx"] SPDX-FileCopyrightText = [ "2026 Borislav Pantaleev ", "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/mas/shared.tsx"] SPDX-FileCopyrightText = [ "2026 Borislav Pantaleev ", "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/mas/UpstreamOAuthLinks.tsx"] SPDX-FileCopyrightText = [ "2026 Borislav Pantaleev ", "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/mas/UpstreamOAuthProviders.tsx"] SPDX-FileCopyrightText = [ "2026 Borislav Pantaleev ", "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/mas/UserEmails.tsx"] SPDX-FileCopyrightText = [ "2026 Borislav Pantaleev ", "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/mas/UserSessions.tsx"] SPDX-FileCopyrightText = [ "2026 Borislav Pantaleev ", "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/users/Create.tsx"] SPDX-FileCopyrightText = [ "2020 Michael Albert", "2020-2025 Dirk Klimpel", "2020-2024 Manuel Stahl", "2023 Przemysław Romanik", "2024 Alexander Tumin", "2024 rkfg", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", "2025 Huw Carpenter", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/users/Edit.tsx"] SPDX-FileCopyrightText = [ "2020 Michael Albert", "2020-2025 Dirk Klimpel", "2020-2024 Manuel Stahl", "2023 Przemysław Romanik", "2024 Alexander Tumin", "2024 rkfg", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", "2025 Huw Carpenter", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/users/index.ts"] SPDX-FileCopyrightText = [ "2020 Michael Albert", "2020-2025 Dirk Klimpel", "2020-2024 Manuel Stahl", "2023 Przemysław Romanik", "2024 Alexander Tumin", "2024 rkfg", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", "2025 Huw Carpenter", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/users/List.tsx"] SPDX-FileCopyrightText = [ "2020 Michael Albert", "2020-2025 Dirk Klimpel", "2020-2024 Manuel Stahl", "2023 Przemysław Romanik", "2024 Alexander Tumin", "2024 rkfg", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", "2025 Huw Carpenter", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/config.test.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/config.ts"] SPDX-FileCopyrightText = [ "2024 Borislav Pantaleev ", "2024-2026 Nikita Chernyi ", "2026 Slavi Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/date.test.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/date.ts"] SPDX-FileCopyrightText = [ "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", "2025 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/error.test.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/error.ts"] SPDX-FileCopyrightText = [ "2024 Borislav Pantaleev ", "2024-2025 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/fetchMedia.test.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/fetchMedia.ts"] SPDX-FileCopyrightText = [ "2024 Borislav Pantaleev ", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/icons.ts"] SPDX-FileCopyrightText = [ "2024-2025 Nikita Chernyi ", "2025 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/logger.test.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/mxid.ts"] SPDX-FileCopyrightText = [ "2024 Borislav Pantaleev ", "2024-2025 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/mxid.test.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/password.ts"] SPDX-FileCopyrightText = [ "2024-2025 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/password.test.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/testdata/element/config.json"] SPDX-FileCopyrightText = [ "2025 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/testdata/element/nginx.conf"] SPDX-FileCopyrightText = [ "2025 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/testdata/mas/config.yaml"] SPDX-FileCopyrightText = [ "2025-2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", "2026 cy1der", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/testdata/nginx/nginx.conf"] SPDX-FileCopyrightText = [ "2025 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/testdata/postgres.initdb/mas.sql"] SPDX-FileCopyrightText = [ "2025 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/testdata/synapse/homeserver.yaml"] SPDX-FileCopyrightText = [ "2024-2025 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/testdata/synapse/synapse.log.config"] SPDX-FileCopyrightText = [ "2024 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/testdata/synapse/synapse.signing.key"] SPDX-FileCopyrightText = [ "2024 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["tsconfig.eslint.json"] SPDX-FileCopyrightText = [ "2024 Manuel Stahl", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["tsconfig.json"] SPDX-FileCopyrightText = [ "2024 Borislav Pantaleev ", "2024 Manuel Stahl", "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["tsconfig.vite.json"] SPDX-FileCopyrightText = [ "2024 Manuel Stahl", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["vite.config.ts"] SPDX-FileCopyrightText = [ "2024 Dirk Klimpel", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["yarn.lock"] SPDX-FileCopyrightText = "NONE" SPDX-License-Identifier = "CC0-1.0" [[annotations]] path = ["src/entrypoints/index.html"] SPDX-FileCopyrightText = [ "2020 Michael Albert", "2024 Borislav Pantaleev ", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/README.md"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/auth/index.ts"] SPDX-FileCopyrightText = [ "2020 Michael Albert", "2020 teutates", "2020-2024 Manuel Stahl", "2021 Aaron Raimist", "2021-2025 Dirk Klimpel", "2022 Nya Candy", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/auth/index.test.ts"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", "2024-2026 Manuel Stahl", "2025 Dirk Klimpel", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/data/index.ts"] SPDX-FileCopyrightText = [ "2020 Lukas Winkler", "2020 Michael Albert", "2020 rkfg", "2020 teutates", "2020-2025 Dirk Klimpel", "2020-2024 Manuel Stahl", "2023 Charlie Calendre", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", "2025 Huw Carpenter", "2026 cy1der", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/data/index.test.ts"] SPDX-FileCopyrightText = [ "2020-2026 Manuel Stahl", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/matrix.ts"] SPDX-FileCopyrightText = [ "2024 Alexander Tumin", "2024 Manuel Stahl", "2024-2026 Borislav Pantaleev ", "2024-2026 Nikita Chernyi ", "2026 cy1der", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/matrix.test.ts"] SPDX-FileCopyrightText = [ "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/i18n/types.d.ts"] SPDX-FileCopyrightText = [ "2024 Dirk Klimpel", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", "2024-2026 Borislav Pantaleev ", "2025 Huw Carpenter", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/data/etke.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/http.ts"] SPDX-FileCopyrightText = [ "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/data/mas.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/serverVersion.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/data/synapse.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/data/lifecycle.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/data/mas-actions.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/data/mas-utils.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/data/mas-utils.test.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/data/scan.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/data/synapse-actions.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/types/common.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/types/destinations.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/types/etke.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/types/index.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/types/mas.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/types/reports.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/types/rooms.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/providers/types/users.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/i18n/index.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/i18n/index.test.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/assets/webfonts/Inter-*"] SPDX-FileCopyrightText = "2016 The Inter Project Authors (https://github.com/rsms/inter)" SPDX-License-Identifier = "OFL-1.1" [[annotations]] path = ["src/assets/webfonts/WorkSans-*"] SPDX-FileCopyrightText = "2015 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans)" SPDX-License-Identifier = "OFL-1.1" [[annotations]] path = ["docker/Dockerfile.subpath-admin"] SPDX-FileCopyrightText = [ "2019-2024 Manuel Stahl", "2021 Aaron Raimist", "2022 Leon Schmidt", "2023 Michael Albert", "2024 Gavin Mogan", "2024-2026 Nikita Chernyi ", "2025 Dirk Klimpel", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/apis.md"] SPDX-FileCopyrightText = [ "2024-2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/update-api-docs.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/screenshots/prepare.js"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/screenshots/README.md"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/assets/fonts.css"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Slavi Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/assets/theme.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Slavi Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/AdminClientConfigItems.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/buttons/AllowCrossSigningButton.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/buttons/BlockRoomButton.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/buttons/DeviceCreateButton.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/DeviceDisplayNameInput.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/fields/EditableAvatarField.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/layout/Datagrid.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/layout/EmptyState.test.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/layout/EmptyState.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Borislav Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/layout/List.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/buttons/FindUserButton.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/buttons/LoginAsUserButton.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/buttons/PurgeHistoryButton.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/buttons/DeleteAllMediaButton.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/buttons/QuarantineAllMediaButton.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/buttons/RenewAccountValidityButton.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/buttons/ResetPasswordButton.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/rooms/RoomHierarchy.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Slavi Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/rooms/RoomHierarchy.test.ts"] SPDX-FileCopyrightText = ["2026 Nikita Chernyi "] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/rooms/EventLookupDialog.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/rooms/RoomMessages.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/users/UserCounts.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Slavi Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/components/etke.cc/hooks/useUnits.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/entrypoints/auth-callback.html"] SPDX-FileCopyrightText = [ "2020 Michael Albert", "2024 Manuel Stahl", "2024 Borislav Pantaleev ", "2024-2026 Nikita Chernyi ", "2026 Slavi Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/pages/auth-callback-error.test.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/pages/auth-callback-error.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", "2026 Slavi Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/version.test.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/pages/auth-callback.test.tsx"] SPDX-FileCopyrightText = [ "2026 Borislav Pantaleev ", "2026 Nikita Chernyi ", "2026 Slavi Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/pages/auth-callback.tsx"] SPDX-FileCopyrightText = [ "2026 Borislav Pantaleev ", "2026 Nikita Chernyi ", "2026 Slavi Pantaleev ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/pages/MASPolicyDataPage.test.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/pages/MASPolicyDataPage.tsx"] SPDX-FileCopyrightText = [ "2026 Borislav Pantaleev ", "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/resources/scheduled-tasks/index.ts", "src/resources/scheduled-tasks/List.tsx"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/formatBytes.test.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/formatBytes.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/safety.test.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/safety.ts"] SPDX-FileCopyrightText = [ "2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["src/utils/version.ts"] SPDX-FileCopyrightText = [ "2024 Dirk Klimpel", "2024 Manuel Stahl", "2024-2026 Nikita Chernyi ", ] SPDX-License-Identifier = "Apache-2.0" ================================================ FILE: docker/Dockerfile ================================================ FROM ghcr.io/static-web-server/static-web-server:2-alpine ARG VERSION=dev ARG VCS_REF=unknown ARG BUILD_DATE LABEL org.opencontainers.image.title="Ketesa" \ org.opencontainers.image.description="Admin UI for Matrix servers, formerly Synapse Admin" \ org.opencontainers.image.url="https://admin.etke.cc" \ org.opencontainers.image.documentation="https://github.com/etkecc/ketesa#readme" \ org.opencontainers.image.source="https://github.com/etkecc/ketesa" \ org.opencontainers.image.vendor="etke.cc" \ org.opencontainers.image.authors="etke.cc" \ org.opencontainers.image.licenses="Apache-2.0" \ org.opencontainers.image.version="$VERSION" \ org.opencontainers.image.revision="$VCS_REF" \ org.opencontainers.image.created="$BUILD_DATE" \ org.opencontainers.image.base.name="ghcr.io/static-web-server/static-web-server:2-alpine" \ description="Admin UI for Matrix servers, formerly Synapse Admin" \ version="$VERSION" \ maintainer="etke.cc" # You can set environment variables as `docker run` arguments too, # full list: https://static-web-server.net/configuration/environment-variables/ ENV SERVER_FALLBACK_PAGE=/var/public/index.html ENV SERVER_PORT=8080 ENV SERVER_HEALTH=true HEALTHCHECK --interval=30s --timeout=10s CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 USER $SERVER_USER_NAME:$SERVER_GROUP_NAME COPY --chown=$SERVER_USER_NAME:$SERVER_GROUP_NAME ./dist /home/$SERVER_USER_NAME/public ================================================ FILE: docker/Dockerfile.build ================================================ FROM node:lts AS builder ARG BASE_PATH=./ WORKDIR /src COPY . /src RUN yarn config set enableTelemetry 0 && \ yarn install --immutable --network-timeout=300000 --pure-lockfile && \ NODE_ENV=production yarn build --base=$BASE_PATH FROM ghcr.io/static-web-server/static-web-server:2-alpine ARG VERSION=dev ARG VCS_REF=unknown ARG BUILD_DATE LABEL org.opencontainers.image.title="Ketesa" \ org.opencontainers.image.description="Admin UI for Matrix servers, formerly Synapse Admin" \ org.opencontainers.image.url="https://admin.etke.cc" \ org.opencontainers.image.documentation="https://github.com/etkecc/ketesa#readme" \ org.opencontainers.image.source="https://github.com/etkecc/ketesa" \ org.opencontainers.image.vendor="etke.cc" \ org.opencontainers.image.authors="etke.cc" \ org.opencontainers.image.licenses="Apache-2.0" \ org.opencontainers.image.version="$VERSION" \ org.opencontainers.image.revision="$VCS_REF" \ org.opencontainers.image.created="$BUILD_DATE" \ org.opencontainers.image.base.name="ghcr.io/static-web-server/static-web-server:2-alpine" \ description="Admin UI for Matrix servers, formerly Synapse Admin" \ version="$VERSION" \ maintainer="etke.cc" # You can set environment variables as `docker run` arguments too, # full list: https://static-web-server.net/configuration/environment-variables/ ENV SERVER_FALLBACK_PAGE=/var/public/index.html ENV SERVER_PORT=8080 ENV SERVER_HEALTH=true HEALTHCHECK --interval=30s --timeout=10s CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 USER $SERVER_USER_NAME:$SERVER_GROUP_NAME COPY --from=builder --chown=$SERVER_USER_NAME:$SERVER_GROUP_NAME /src/dist /home/$SERVER_USER_NAME/public ================================================ FILE: docker/Dockerfile.subpath-admin ================================================ FROM ghcr.io/static-web-server/static-web-server:2-alpine ARG VERSION=dev ARG VCS_REF=unknown ARG BUILD_DATE LABEL org.opencontainers.image.title="Ketesa (/admin)" \ org.opencontainers.image.description="Admin UI for Matrix servers, formerly Synapse Admin, served from the /admin subpath" \ org.opencontainers.image.url="https://admin.etke.cc" \ org.opencontainers.image.documentation="https://github.com/etkecc/ketesa#readme" \ org.opencontainers.image.source="https://github.com/etkecc/ketesa" \ org.opencontainers.image.vendor="etke.cc" \ org.opencontainers.image.authors="etke.cc" \ org.opencontainers.image.licenses="Apache-2.0" \ org.opencontainers.image.version="$VERSION" \ org.opencontainers.image.revision="$VCS_REF" \ org.opencontainers.image.created="$BUILD_DATE" \ org.opencontainers.image.base.name="ghcr.io/static-web-server/static-web-server:2-alpine" \ description="Admin UI for Matrix servers, formerly Synapse Admin, served from the /admin subpath" \ version="$VERSION" \ maintainer="etke.cc" # You can set environment variables as `docker run` arguments too, # full list: https://static-web-server.net/configuration/environment-variables/ ENV SERVER_FALLBACK_PAGE=/var/public/admin/index.html ENV SERVER_PORT=8080 ENV SERVER_HEALTH=true HEALTHCHECK --interval=30s --timeout=10s CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 USER $SERVER_USER_NAME:$SERVER_GROUP_NAME COPY --chown=$SERVER_USER_NAME:$SERVER_GROUP_NAME ./dist /home/$SERVER_USER_NAME/public/admin ================================================ FILE: docker/docker-compose-dev.yml ================================================ services: synapse: image: ghcr.io/element-hq/synapse:latest entrypoint: python command: "-m synapse.app.homeserver -c /config/homeserver.yaml" volumes: - ../docs/testdata/synapse:/config - ../docs/testdata/synapse.data:/media-store mas: image: ghcr.io/element-hq/matrix-authentication-service:latest ports: - "8007:8007" volumes: - ../docs/testdata/mas/config.yaml:/config.yaml:ro nginx: image: nginx:latest ports: - "8008:8008" volumes: - ../docs/testdata/nginx/nginx.conf:/etc/nginx/conf.d/nginx.conf:ro - ../dist:/var/www/html/admin:ro ketesa-prod: image: ghcr.io/etkecc/ketesa element: image: docker.io/vectorim/element-web:latest depends_on: synapse: condition: service_healthy restart: true ports: - "8080:8080" volumes: - ../docs/testdata/element/nginx.conf:/etc/nginx/nginx.conf:ro - /dev/null:/etc/nginx/conf.d/default.conf:ro - ../docs/testdata/element/config.json:/app/config.json:ro mock-oidc: image: ghcr.io/navikt/mock-oauth2-server:2.1.10 environment: SERVER_PORT: 8006 LOG_LEVEL: DEBUG JSON_CONFIG: > { "interactiveLogin": false, "tokenCallbacks": [ { "issuerId": "default", "requestMappings": [ { "requestParam": "grant_type", "match": ".*", "claims": { "sub": "mock-user", "preferred_username": "mockuser", "name": "Mock User", "email": "mock@localhost" } } ] } ] } ports: - "8006:8006" postgres: image: postgres:17-alpine ports: - "5432:5432" volumes: - ../docs/testdata/postgres.data:/var/lib/postgresql/data - ../docs/testdata/postgres.initdb:/docker-entrypoint-initdb.d:ro environment: POSTGRES_USER: synapse POSTGRES_PASSWORD: synapse POSTGRES_DB: synapse POSTGRES_INITDB_ARGS: "--lc-collate C --lc-ctype C --encoding UTF8" ================================================ FILE: docker/docker-compose.yml ================================================ services: ketesa: container_name: ketesa hostname: ketesa image: ghcr.io/etkecc/ketesa:latest # build: # context: . # dockerfile: docker/Dockerfile.build # to use the docker-compose as standalone without a local repo clone, # replace the context definition with this: # context: https://github.com/etkecc/ketesa.git # args: # - BUILDKIT_CONTEXT_KEEP_GIT_DIR=1 # if you're building on an architecture other than amd64, make sure # to define a maximum ram for node. otherwise the build will fail. # - NODE_OPTIONS="--max_old_space_size=1024" # - BASE_PATH="/ketesa" ports: - "8080:8080" restart: unless-stopped ================================================ FILE: docs/README.md ================================================ # 📚 Documentation Welcome to the Ketesa documentation! This is the central index for all guides covering configuration, supported APIs, and available features. > 📝 **Note:** Documentation is actively evolving — PRs are greatly appreciated! --- ## ⚙️ Configuration [Full configuration reference →](./config.md) Specific topics: | Guide | What it covers | |-------|----------------| | [Customizing CORS credentials](./cors-credentials.md) | Fine-tune cross-origin request behavior | | [Restricting available homeservers](./restrict-hs.md) | Lock down which homeservers users can connect to | | [System / Appservice-managed users](./system-users.md) | Protect bridge puppets from accidental edits | | [Custom menu items](./custom-menu.md) | Add your own links to the navigation menu | | [External auth provider](./external-auth-provider.md) | Adjust behavior when Synapse delegates auth externally | --- ## 🔌 APIs * [Supported APIs](./apis.md) — full list of Synapse and MAS endpoints used by Ketesa --- ## ✨ Features * [User badges](./user-badges.md) — role indicators on user avatars (admin, bot, system-managed, etc.) * [Prefilling the login form](./prefill-login-form.md) — pre-populate login fields via URL parameters * [Configurable columns](./configurable-columns.md) — show, hide, and reorder table columns per your workflow ### 👥 User Management | Guide | What it covers | |-------|----------------| | [User management](./user-management.md) | Login-as-user, shadow ban, rate limits, experimental features, account data, server notices, MAS user management, MAS policy data | | [User search](./user-search.md) | Normal and reverse (`!`) MXID/displayname search | | [Bulk CSV import](./csv-import.md) | Import many users at once from a CSV file | | [Registration tokens](./registration-tokens.md) | Create and manage invite tokens for Synapse and MAS | ### 🏠 Room Management | Guide | What it covers | |-------|----------------| | [Room management](./room-management.md) | Block/unblock, purge history, delete, join users, assign admins, members/state/extremities tabs, messages viewer, Space hierarchy | | [Media management](./media.md) | Quarantine, protect, and delete media at file/user/room scope | ### 🔍 Moderation * [Event reports](./event-reports.md) — review abuse reports submitted by users, use the event lookup tool ### 📡 Federation * [Federation overview](./federation.md) — monitor remote server connections, reconnect failed destinations ### 📊 Statistics & Tasks * [Server statistics & scheduled tasks](./server-statistics.md) — database room sizes, user media usage, background task monitoring --- ### 🌟 etke.cc exclusive features > ⚠️ **Note:** The following features are only available for [etke.cc](https://etke.cc) customers. Due to the specifics of their implementation, they are not available for any other Ketesa deployment. * [Server Status icon](../src/components/etke.cc/README.md#server-status-icon) * [Server Status page](../src/components/etke.cc/README.md#server-status-page) * [Server Actions page](../src/components/etke.cc/README.md#server-actions-page) * [Server Commands Panel](../src/components/etke.cc/README.md#server-commands-panel) * [Server Notifications icon](../src/components/etke.cc/README.md#server-notifications-icon) * [Server Notifications page](../src/components/etke.cc/README.md#server-notifications-page) * [Components page](./components.md) * [Billing page](../src/components/etke.cc/README.md#billing-page) * [Support page](../src/components/etke.cc/README.md#support-page) * [Instance config](../src/components/etke.cc/README.md#instance-config) --- ## 🚀 Deployment * [Availability](./availability.md) — where to get Ketesa: official builds, distro packages, integrations, mirrors, and legacy compatibility links * [Serving Ketesa behind a reverse proxy](./reverse-proxy.md) ================================================ FILE: docs/apis.md ================================================ # 🔌 Supported APIs Ketesa uses various APIs to manage Matrix homeservers and related services. This document lists all supported APIs and their implementation status. > 📝 **Note:** This file was compiled based on Synapse **v1.151.0** and MAS **v1.15.0** documentation. > It is not updated often and is provided just for reference purposes. **Legend:** ✅ fully implemented · 🟡 in progress · ❌ not implemented · ⏭️ superseded (newer version available) --- ## ✅ Synapse Admin API [Synapse Admin API documentation →](https://element-hq.github.io/synapse/latest/usage/administration/admin_api/index.html) ### ✅ Server Version | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_synapse/admin/v1/server_version` | GET | Get running Synapse version | ✅ | ### ✅ Users | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_synapse/admin/v2/users` | GET | List all local user accounts | ⏭️ | | `/_synapse/admin/v3/users` | GET | List all local user accounts (v3) | ✅ | | `/_synapse/admin/v2/users/` | GET | Query user account details | ✅ | | `/_synapse/admin/v2/users/` | PUT | Create or modify user account | ✅ | | `/_synapse/admin/v1/whois/` | GET | Query user sessions/connections | ✅ | | `/_synapse/admin/v1/deactivate/` | POST | Deactivate/erase user account | ✅ | | `/_synapse/admin/v1/suspend/` | PUT | Suspend or unsuspend user | ✅ | | `/_synapse/admin/v1/reset_password/` | POST | Reset user password | ✅ | | `/_synapse/admin/v1/users//admin` | GET | Check if user is admin | ⏭️ | | `/_synapse/admin/v1/users//admin` | PUT | Change user admin status | ⏭️ | | `/_synapse/admin/v1/users//joined_rooms` | GET | List user's joined rooms | ✅ | | `/_synapse/admin/v1/users//memberships` | GET | List user's room memberships | ✅ | | `/_synapse/admin/v1/users//media` | GET | List media uploaded by user | ✅ | | `/_synapse/admin/v1/users//media` | DELETE | Delete all media uploaded by user | ✅ | | `/_synapse/admin/v1/users//accountdata` | GET | Get user account data | ✅ | | `/_synapse/admin/v1/users//pushers` | GET | List user pushers | ✅ | | `/_synapse/admin/v1/users//override_ratelimit` | GET | Get user ratelimit overrides | ✅ | | `/_synapse/admin/v1/users//override_ratelimit` | POST | Set user ratelimit overrides | ✅ | | `/_synapse/admin/v1/users//override_ratelimit` | DELETE | Delete user ratelimit overrides | ✅ | | `/_synapse/admin/v1/users//login` | POST | Login as user (get access token) | ✅ | | `/_synapse/admin/v1/users//shadow_ban` | POST | Shadow-ban a user | ✅ | | `/_synapse/admin/v1/users//shadow_ban` | DELETE | Remove shadow-ban from user | ✅ | | `/_synapse/admin/v1/users//_allow_cross_signing_replacement_without_uia` | POST | Allow cross-signing replacement without UIA | ✅ | | `/_synapse/admin/v1/users//sent_invite_count` | GET | Count invites sent by user | ✅ | | `/_synapse/admin/v1/users//cumulative_joined_room_count` | GET | Cumulative joined room count | ✅ | | `/_synapse/admin/v1/username_available` | GET | Check username availability | ✅ | | `/_synapse/admin/v1/auth_providers//users/` | GET | Find user by auth provider ID | ✅ | | `/_synapse/admin/v1/threepid//users/
` | GET | Find user by third-party ID | ✅ | | `/_synapse/admin/v1/user//redact` | POST | Redact all events from a user | ✅ | | `/_synapse/admin/v1/user/redact_status/` | GET | Check user redaction status | ✅ | ### ✅ User Devices | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_synapse/admin/v2/users//devices` | GET | List all devices for user | ✅ | | `/_synapse/admin/v2/users//devices` | POST | Create a device for user | ✅ | | `/_synapse/admin/v2/users//devices/` | GET | Get single device info | ⏭️ | | `/_synapse/admin/v2/users//devices/` | PUT | Update device metadata | ✅ | | `/_synapse/admin/v2/users//devices/` | DELETE | Delete a device | ✅ | | `/_synapse/admin/v2/users//delete_devices` | POST | Delete multiple devices | ✅ | ### ✅ Rooms | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_synapse/admin/v1/rooms` | GET | List rooms on server | ✅ | | `/_synapse/admin/v1/rooms/` | GET | Get room details | ✅ | | `/_synapse/admin/v1/rooms//members` | GET | Get room members | ✅ | | `/_synapse/admin/v1/rooms//state` | GET | Get room state events | ✅ | | `/_synapse/admin/v1/rooms//messages` | GET | Get messages from a room | ✅ | | `/_synapse/admin/v1/rooms//timestamp_to_event` | GET | Find event by timestamp | ✅ | | `/_synapse/admin/v1/rooms//context/` | GET | Get event context | ✅ | | `/_synapse/admin/v1/rooms//hierarchy` | GET | Get space/room hierarchy | ✅ | | `/_synapse/admin/v1/rooms//block` | PUT | Block or unblock a room | ✅ | | `/_synapse/admin/v1/rooms//block` | GET | Get room block status | ✅ | | `/_synapse/admin/v1/rooms/` | DELETE | Delete a room (v1, synchronous) | ⏭️ | | `/_synapse/admin/v2/rooms/` | DELETE | Delete a room (v2, asynchronous) | ✅ | | `/_synapse/admin/v2/rooms//delete_status` | GET | Query room delete status | ⏭️ | | `/_synapse/admin/v2/rooms/delete_status/` | GET | Query delete status by ID | ✅ | | `/_synapse/admin/v1/rooms//make_room_admin` | POST | Grant user highest power level | ✅ | | `/_synapse/admin/v1/rooms//forward_extremities` | GET | Check forward extremities | ✅ | | `/_synapse/admin/v1/rooms//forward_extremities` | DELETE | Delete forward extremities | ✅ | ### ✅ Registration Tokens | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_synapse/admin/v1/registration_tokens` | GET | List all registration tokens | ✅ | | `/_synapse/admin/v1/registration_tokens/` | GET | Get specific registration token | ✅ | | `/_synapse/admin/v1/registration_tokens/new` | POST | Create a registration token | ✅ | | `/_synapse/admin/v1/registration_tokens/` | PUT | Update a registration token | ✅ | | `/_synapse/admin/v1/registration_tokens/` | DELETE | Delete a registration token | ✅ | ### ✅ Media | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_synapse/admin/v1/room//media` | GET | List all media in a room | ✅ | | `/_synapse/admin/v1/media//` | GET | Query media by ID | ⏭️ | | `/_synapse/admin/v1/media//` | DELETE | Delete specific local media | ✅ | | `/_synapse/admin/v1/media/delete` | POST | Delete local media by date or size | ✅ | | `/_synapse/admin/v1/media//delete` | POST | Delete local media by date or size (deprecated) | ⏭️ | | `/_synapse/admin/v1/purge_media_cache` | POST | Purge old cached remote media | ✅ | | `/_synapse/admin/v1/media/quarantine//` | POST | Quarantine media by ID | ✅ | | `/_synapse/admin/v1/media/unquarantine//` | POST | Remove media from quarantine | ✅ | | `/_synapse/admin/v1/room//media/quarantine` | POST | Quarantine all media in a room | ✅ | | `/_synapse/admin/v1/quarantine_media/` | POST | Quarantine room media (deprecated) | ⏭️ | | `/_synapse/admin/v1/user//media/quarantine` | POST | Quarantine all media of a user | ✅ | | `/_synapse/admin/v1/media/protect/` | POST | Protect media from quarantine | ✅ | | `/_synapse/admin/v1/media/unprotect/` | POST | Unprotect media from quarantine | ✅ | ### ✅ Event Reports | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_synapse/admin/v1/event_reports` | GET | List reported events | ✅ | | `/_synapse/admin/v1/event_reports/` | GET | Get specific event report details | ✅ | | `/_synapse/admin/v1/event_reports/` | DELETE | Delete a specific event report | ✅ | ### ✅ Server Notices | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_synapse/admin/v1/send_server_notice` | POST | Send a server notice to a user | ✅ | | `/_synapse/admin/v1/send_server_notice/{txnId}` | PUT | Send server notice with transaction ID | ⏭️ | ### ✅ Federation | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_synapse/admin/v1/federation/destinations` | GET | List federation destinations | ✅ | | `/_synapse/admin/v1/federation/destinations/` | GET | Get destination details | ✅ | | `/_synapse/admin/v1/federation/destinations//rooms` | GET | List rooms for destination | ✅ | | `/_synapse/admin/v1/federation/destinations//reset_connection` | POST | Reset federation connection | ✅ | ### ✅ Experimental Features | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_synapse/admin/v1/experimental_features/` | GET | List experimental features for user | ✅ | | `/_synapse/admin/v1/experimental_features/` | PUT | Enable/disable experimental features | ✅ | ### ✅ Statistics | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_synapse/admin/v1/statistics/users/media` | GET | Get users' media usage statistics | ✅ | | `/_synapse/admin/v1/statistics/database/rooms` | GET | Get largest rooms by database size | ✅ | ### ✅ Account Validity | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_synapse/admin/v1/account_validity/validity` | POST | Renew account validity | ✅ | ### ✅ Purge History | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_synapse/admin/v1/purge_history/[/]` | POST | Purge room history | ✅ | | `/_synapse/admin/v1/purge_history_status/` | GET | Query purge status | ✅ | ### ✅ Fetch Event | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_synapse/admin/v1/fetch_event/` | GET | Fetch event by ID | ✅ | ### ⏭️ Register (Shared-Secret Registration) — superseded Superseded: redundant with existing user creation via User Admin API (already implemented). Shared-secret registration is designed for CLI bootstrapping without an admin token — pointless when already authenticated in Ketesa. | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_synapse/admin/v1/register` | GET | Get registration nonce | ⏭️ | | `/_synapse/admin/v1/register` | POST | Create user via shared-secret | ⏭️ | ### ✅ Room Membership | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_synapse/admin/v1/join/` | POST | Join a user to a room | ✅ | ### ✅ Scheduled Tasks | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_synapse/admin/v1/scheduled_tasks` | GET | Show scheduled tasks | ✅ | ### ✅ Client-Server API Extensions | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/_matrix/client/v3/user//account_data/io.element.synapse.admin_client_config` | GET | Get admin client configuration | ✅ | | `/_matrix/client/v3/user//account_data/io.element.synapse.admin_client_config` | PUT | Set admin client configuration | ✅ | --- ## ✅ Matrix Authentication Service (MAS) Admin API [MAS Admin API specification →](https://element-hq.github.io/matrix-authentication-service/api/spec.json) ### ✅ OAuth 2.0 | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/oauth2/token` | POST | Refresh access token | ✅ | ### ✅ Server | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/api/admin/v1/site-config` | GET | Retrieve instance configuration | ✅ | | `/api/admin/v1/version` | GET | Retrieve the currently running version | ✅ | ### ✅ Registration Tokens | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/api/admin/v1/user-registration-tokens` | GET | List registration tokens | ✅ | | `/api/admin/v1/user-registration-tokens/{id}` | GET | Get a registration token | ✅ | | `/api/admin/v1/user-registration-tokens` | POST | Create a registration token | ✅ | | `/api/admin/v1/user-registration-tokens/{id}` | PUT | Update a registration token | ✅ | | `/api/admin/v1/user-registration-tokens/{id}/revoke` | POST | Revoke a registration token | ✅ | | `/api/admin/v1/user-registration-tokens/{id}/unrevoke` | POST | Unrevoke a registration token | ✅ | ### ✅ Users | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/api/admin/v1/users` | GET | List users | ✅ | | `/api/admin/v1/users` | POST | Create a new user | ✅ | | `/api/admin/v1/users/{id}` | GET | Get user by ID | ✅ | | `/api/admin/v1/users/by-username/{username}` | GET | Get user by username | ⏭️ | | `/api/admin/v1/users/{id}/set-password` | POST | Set user password | ✅ | | `/api/admin/v1/users/{id}/set-admin` | POST | Toggle admin flag | ✅ | | `/api/admin/v1/users/{id}/deactivate` | POST | Deactivate user | ✅ | | `/api/admin/v1/users/{id}/reactivate` | POST | Reactivate user | ✅ | | `/api/admin/v1/users/{id}/lock` | POST | Lock user | ✅ | | `/api/admin/v1/users/{id}/unlock` | POST | Unlock user | ✅ | ### ✅ User Emails | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/api/admin/v1/user-emails` | GET | List user emails | ✅ | | `/api/admin/v1/user-emails` | POST | Add email to user | ✅ | | `/api/admin/v1/user-emails/{id}` | GET | Get email details | ✅ | | `/api/admin/v1/user-emails/{id}` | DELETE | Remove email from user | ✅ | ### ✅ Compat Sessions | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/api/admin/v1/compat-sessions` | GET | List compatibility sessions | ✅ | | `/api/admin/v1/compat-sessions/{id}` | GET | Get a compatibility session | ✅ | | `/api/admin/v1/compat-sessions/{id}/finish` | POST | Terminate a compatibility session | ✅ | ### ✅ OAuth 2.0 Sessions | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/api/admin/v1/oauth2-sessions` | GET | List OAuth 2.0 sessions | ✅ | | `/api/admin/v1/oauth2-sessions/{id}` | GET | Get an OAuth 2.0 session | ✅ | | `/api/admin/v1/oauth2-sessions/{id}/finish` | POST | Terminate an OAuth 2.0 session | ✅ | ### ✅ Personal Sessions | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/api/admin/v1/personal-sessions` | GET | List personal sessions | ✅ | | `/api/admin/v1/personal-sessions` | POST | Create a personal session | ✅ | | `/api/admin/v1/personal-sessions/{id}` | GET | Get personal session details | ✅ | | `/api/admin/v1/personal-sessions/{id}/revoke` | POST | Revoke a personal session | ✅ | | `/api/admin/v1/personal-sessions/{id}/regenerate` | POST | Regenerate personal session token | ✅ | ### ✅ Browser Sessions | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/api/admin/v1/user-sessions` | GET | List browser sessions | ✅ | | `/api/admin/v1/user-sessions/{id}` | GET | Get a browser session | ✅ | | `/api/admin/v1/user-sessions/{id}/finish` | POST | Terminate a browser session | ✅ | ### ✅ Upstream OAuth Links | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/api/admin/v1/upstream-oauth-links` | GET | List upstream OAuth links | ✅ | | `/api/admin/v1/upstream-oauth-links` | POST | Create an upstream OAuth link | ✅ | | `/api/admin/v1/upstream-oauth-links/{id}` | GET | Get an upstream OAuth link | ✅ | | `/api/admin/v1/upstream-oauth-links/{id}` | DELETE | Remove an upstream OAuth link | ✅ | ### ✅ Upstream OAuth Providers | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/api/admin/v1/upstream-oauth-providers` | GET | List upstream OAuth providers | ✅ | | `/api/admin/v1/upstream-oauth-providers/{id}` | GET | Get an upstream OAuth provider | ✅ | ### ✅ Policy Data | Endpoint | Method | Description | Status | |----------|--------|-------------|:------:| | `/api/admin/v1/policy-data` | POST | Set policy data | ✅ | | `/api/admin/v1/policy-data/latest` | GET | Get latest policy data | ✅ | | `/api/admin/v1/policy-data/{id}` | GET | Get policy data by ID | ⏭️ | ================================================ FILE: docs/availability.md ================================================ # 📦 Availability This is the canonical reference for obtaining Ketesa. > ⚠️ Community-maintained and third-party entries may lag behind official releases. > When in doubt, prefer the official release tarballs, containers, or the hosted instance. ## ✅ Official channels | Channel | Type | Maintainer | Best for | Notes | |---|---|---|---|---| | [etke.cc](https://etke.cc/?utm_source=github&utm_medium=docs&utm_campaign=ketesa) | Managed hosting | Official | Fully managed deployments | Ketesa is [a core component](https://etke.cc/help/faq#what-are-the-base-matrix-components-installed-on-the-server) installed by default | | [admin.etke.cc](https://admin.etke.cc) | Hosted instance | Official | No installation needed | Always on the latest development version | | [GitHub Releases](https://github.com/etkecc/ketesa/releases) | Static builds | Official | Self-hosting behind any web server | Includes `ketesa.tar.gz` and `ketesa-subpath-admin.tar.gz` | | [GHCR](https://github.com/etkecc/ketesa/pkgs/container/ketesa) | Container image | Official | Docker and OCI-based deployments | Main image registry | | [Docker Hub](https://hub.docker.com/r/etkecc/ketesa/tags) | Container image | Official | Docker-first users | Mirrors the official container images | | [matrix-docker-ansible-deploy](https://github.com/spantaleev/matrix-docker-ansible-deploy/blob/master/docs/configuring-playbook-ketesa.md) | Ansible integration | Official | Operators using the MDAD playbook | Maintained by the etke.cc team | | [etkecc/ansible](https://github.com/etkecc/ansible) | Ansible integration | Official | etke.cc-flavoured self-hosted automation | Maintained by the etke.cc team | | [Source repository](https://github.com/etkecc/ketesa) | Source code | Official | Building from source or contributing | For custom builds and development checkouts | ### Prebuilt distributions Ketesa publishes two static distributions: - `ketesa.tar.gz` for root path deployments such as `https://admin.example.com` - `ketesa-subpath-admin.tar.gz` for `/admin` subpath deployments such as `https://example.com/admin` For custom prefixes other than `/admin`, build from source with `yarn build --base=/your-prefix/` or pass the `BASE_PATH` Docker build argument. ## 🌙 Nightly and development builds To get the latest unreleased changes: - Use [admin.etke.cc](https://admin.etke.cc) for the hosted development version - Pull `latest` or `latest-subpath-admin` from [GHCR](https://github.com/etkecc/ketesa/pkgs/container/ketesa) or [Docker Hub](https://hub.docker.com/r/etkecc/ketesa/tags) - Download `dist-root` or `dist-subpath-admin` from the latest successful [GitHub Actions run](https://github.com/etkecc/ketesa/actions/workflows/workflow.yml) - Build the `main` branch from [source](https://github.com/etkecc/ketesa) ## 🐧 Distro packages | Channel | Type | Maintainer | Best for | Notes | |---|---|---|---|---| | [Nixpkgs](https://search.nixos.org/packages?channel=unstable&show=ketesa) | Package index | [@Defelo](https://github.com/Defelo) | Nix and NixOS users | | | [YunoHost](https://apps.yunohost.org/app/ketesa) | App catalog package | [@Josue-T](https://github.com/Josue-T) | YunoHost-based deployments | | ## 🤝 Community-packaged options | Channel | Type | Maintainer | Best for | Notes | |---|---|---|---|---| | [IPFS](#ipfs) | Mirror / alternative delivery | [Fеnикs (@fenuks:sibnsk.net)](https://matrix.to/#/@fenuks:sibnsk.net) | Content-addressed distribution | See the [IPFS](#ipfs) section below for addresses | | [Arch Linux AUR](https://aur.archlinux.org/packages/synapse-admin-etke-git) | AUR package | [@drygdryg](https://github.com/drygdryg) | Arch users who prefer AUR packaging | Legacy package naming — maintained outside this repository | ## IPFS > Maintained by [Fеnикs (@fenuks:sibnsk.net)](https://matrix.to/#/@fenuks:sibnsk.net) **Latest version:** `/ipns/ketesa.sibnsk.net` (`dnslink` key `/ipns/k51qzi5uqu5dj91scsxoqu0ebmy7uqajrt9ohl98vs7fl7l429h0chgozk58i2`) **Archive:** `/ipns/ketesa-archive.sibnsk.net` (`dnslink` key `/ipns/k51qzi5uqu5dhxwc36sld1hn6jn935k71ww8rdyqomrnqcqucixy7re08qeu7z`) ## 📝 Want your package listed? Open a pull request if you maintain a Ketesa package, mirror, or deployment integration — we welcome all distribution channels. Please include: - the package or project link - whether it is official, community-maintained, or a third-party mirror - who maintains it - any important caveats, especially if it still uses legacy `synapse-admin` naming ================================================ FILE: docs/components.md ================================================ # 🧩 Components | Light | Dark | |-------|------| | ![Components (light)](./screenshots/light/components.webp) | ![Components (dark)](./screenshots/dark/components.webp) | Accessible via the **Components** sidebar menu item. The Components page is your self-service catalogue for everything your Matrix server can do. From a single screen you can see what's running, explore what's available, toggle services on or off, and review the cost impact — all without opening a support ticket. --- ## What you can do ### See exactly what's on your server The **Your Server** card shows every component that is currently active on your server — chat bridges, bots, apps, and the services that come included with your plan. Each item displays its monthly price so there are no surprises on your next invoice. ### Explore available add-ons Add-on sections — **Bridges**, **Extras**, **Matrix Apps**, **Matrix Bots**, **Matrix Extras** — list every service available to you. If you have an active pack (for example, the Bridges pack), all components in that section are shown as **Available**, meaning they are included in your pack and ready to enable at no extra charge. Individual add-ons outside a pack show their monthly price upfront. ### Add or remove components in seconds Toggle any component using the switch next to it. The row highlights immediately to confirm your selection. You can stage as many changes as you like before committing — nothing changes on your server until you click **Request changes**. ### Preview the cost before you commit The price strip at the bottom of the page always shows your **current monthly total**. The moment you stage a change, a preview chip appears showing what your new total will be. You see the exact cost before anything is applied. ### Submit changes with one click When you're happy with your selection, click **Request changes**. This automatically opens a support ticket with your staged changes — no manual description needed. The etke.cc team receives it and applies the changes to your server. --- ## Component labels | Label | Meaning | |-------|---------| | **Included** (green, filled) | Part of your plan — no extra charge | | **Free** (green, outlined) | Available at no cost — not yet enabled | | **€X/mo** (green, outlined) | Available — costs this amount per month if added | | **Available** (themed, outlined) | Included in your active pack — enable at no extra charge | When you toggle a component on, its label turns **primary** (blue on light theme, orange on dark). When you toggle one off, it dims — a clear visual cue that you're staging a removal. --- ## Sections | Section | What's in it | |---------|-------------| | **Your Server** | Your currently active components | | **Bridges** | Protocol bridges — connect Matrix to Slack, Discord, Telegram, WhatsApp, IRC, and more | | **Extras** | Standalone add-ons and productivity services | | **Matrix Apps** | Full Matrix applications and bots for your community | | **Matrix Bots** | Automation bots — moderation, utilities, integrations | | **Matrix Extras** | Additional Matrix-ecosystem services | > 💡 Sections only appear if they contain components available to your server. --- > ⚠️ This page is exclusive to [etke.cc](https://etke.cc) customers and is not available in standalone Ketesa deployments. ================================================ FILE: docs/config.md ================================================ # ⚙️ Configuration Ketesa is flexible by design — configure it once and let it work across any number of deployments or homeservers. There are two ways to configure Ketesa (both are optional, and both can be used together): | Method | Best for | |--------|----------| | `config.json` alongside the deployment ([example](https://admin.etke.cc/config.json)) | Self-hosted deployments where you control the Ketesa files | | `cc.etke.ketesa` key in `/.well-known/matrix/client` ([example](https://demo.etke.host/.well-known/matrix/client)) | Any homeserver — works even if you don't host Ketesa yourself | > 📝 **Existing configurations using the legacy `cc.etke.synapse-admin` key continue to work** — Ketesa reads both keys automatically and you don't need to change anything. Migrating to `cc.etke.ketesa` is optional and can be done at your convenience. If you are an [etke.cc](https://etke.cc) customer, or use [spantaleev/matrix-docker-ansible-deploy](https://github.com/spantaleev/matrix-docker-ansible-deploy), or [etkecc/ansible](https://github.com/etkecc/ansible), configuration is added automatically to the `/.well-known/matrix/client` file. > 💡 **Why `/.well-known/matrix/client`?** > > Because any instance of Ketesa will automatically pick up the configuration from the homeserver. > A common use case is when you have a Synapse server running, but don't want (or can't) to deploy Ketesa alongside it. > In this case, you could provide the configuration in the `/.well-known/matrix/client` file, > and any Ketesa instance (e.g., [admin.etke.cc](https://admin.etke.cc)) will pick it up. > > Another common case is when you have multiple Synapse servers running and want to use a single Ketesa instance to manage them all. > In this case, you could provide the configuration in the `/.well-known/matrix/client` file for each of the servers. ## 🔧 Configuration options * `restrictBaseUrl` — restricts the Ketesa instance to work only with specific homeserver(-s). Accepts both a string and an array of strings. The homeserver URL should be the _actual_ homeserver URL, and not the delegated one. Example: `https://matrix.example.com` or `https://synapse.example.net` [More details](restrict-hs.md) * `externalAuthProvider` — set if an external authentication provider is used (e.g., OIDC, LDAP, etc). Accepts a boolean value. [More details](external-auth-provider.md) * `wellKnownDiscovery` — control automatic URL canonicalization via `/.well-known/matrix/client`. Accepts a boolean value. Default: `true` (discovery enabled, per Matrix spec). Set to `false` when the `/_synapse/admin` API is hosted on a separate domain not advertised in well-known (e.g. a VPN-only admin endpoint). When disabled, MXID-based URL auto-fill uses the domain portion of the MXID directly without a well-known lookup. [More details](well-known-discovery.md) * `corsCredentials` — configure the CORS credentials for the Ketesa instance. Accepts the following values: | Value | Behavior | |---|---| | `same-origin` (default) | Cookies are sent only if the request is made from the same origin as the server | | `include` | Cookies are sent regardless of the origin of the request | | `omit` | Cookies are not sent with the request | [More details](cors-credentials.md) * `asManagedUsers` — protect system user accounts managed by appservices (such as bridges) / system (such as bots) from accidental changes. By defining a list of MXID regex patterns, you can protect these accounts from accidental changes. Example: `^@baibot:example\\.com$`, `^@slackbot:example\\.com$`, `^@slack_[a-zA-Z0-9\\-]+:example\\.com$`, `^@telegrambot:example\\.com$`, `^@telegram_[a-zA-Z0-9]+:example\\.com$` [More details](system-users.md) * `menu` — add custom menu items to the main menu (sidebar) by providing a `menu` array in the config. Each `menu` item can contain the following fields: | Field | Required | Description | |---|---|---| | `label` | ✅ Yes | The text to display in the menu | | `icon` | No | Icon name from [src/utils/icons.ts](../src/utils/icons.ts); falls back to a default icon | | `url` | ✅ Yes | The URL to navigate to when the menu item is clicked | [More details](custom-menu.md) ## 📋 Examples ### config.json ```json { "restrictBaseUrl": [ "https://matrix.example.com", "https://synapse.example.net" ], "asManagedUsers": [ "^@baibot:example\\.com$", "^@slackbot:example\\.com$", "^@slack_[a-zA-Z0-9\\-]+:example\\.com$", "^@telegrambot:example\\.com$", "^@telegram_[a-zA-Z0-9]+:example\\.com$" ], "menu": [ { "label": "Contact support", "icon": "SupportAgent", "url": "https://github.com/etkecc/ketesa/issues" } ] } ``` ### `/.well-known/matrix/client` ```json { "cc.etke.ketesa": { "restrictBaseUrl": [ "https://matrix.example.com", "https://synapse.example.net" ], "asManagedUsers": [ "^@baibot:example\\.com$", "^@slackbot:example\\.com$", "^@slack_[a-zA-Z0-9\\-]+:example\\.com$", "^@telegrambot:example\\.com$", "^@telegram_[a-zA-Z0-9]+:example\\.com$" ], "menu": [ { "label": "Contact support", "icon": "SupportAgent", "url": "https://github.com/etkecc/ketesa/issues" } ] } } ``` ================================================ FILE: docs/configurable-columns.md ================================================ # 🗂️ Configurable Table Columns | Light | Dark | |-------|------| | ![Rooms List (light)](./screenshots/light/rooms-list.webp) | ![Rooms List (dark)](./screenshots/dark/rooms-list.webp) | Every data table in Ketesa supports configurable columns — you can show, hide, and reorder columns to match your workflow. Preferences are stored in your browser (localStorage), not on the server, so each browser or device maintains independent settings. --- ## 📋 Supported Tables The following tables support column configuration: | Table | Location | |---|---| | Users list | **Users** → main list | | Rooms list | **Rooms** → main list | | Room directory | **Room Directory** → main list | | Reports list | **Reports** → main list | | Registration tokens list | **Registration Tokens** → main list | | Scheduled tasks list | **Scheduled Tasks** → main list | | Federation (destinations) list | **Federation** → main list | | Users' media statistics | **Statistics** → Users' media | | Database room statistics | **Statistics** → Database rooms | | Room detail — Members tab | **Rooms** → room detail → Members | | Room detail — Media tab | **Rooms** → room detail → Media | | Room detail — State events tab | **Rooms** → room detail → State events | | Room detail — Forward Extremities tab | **Rooms** → room detail → Forward Extremities | | Federation destination — Rooms tab | **Federation** → destination detail → Rooms | | User detail — Devices tab | **Users** → user detail → Devices | | User detail — Connections tab | **Users** → user detail → Connections | | User detail — Media tab | **Users** → user detail → Media | | User detail — Rooms tab | **Users** → user detail → Rooms | | User detail — Memberships tab | **Users** → user detail → Memberships | | User detail — Pushers tab | **Users** → user detail → Pushers | | MAS Upstream OAuth Providers | **MAS** → OAuth Providers | | MAS Upstream OAuth Links | **MAS** → user detail → Upstream OAuth Links | | MAS Browser Sessions | **MAS** → user detail → Browser Sessions | | MAS OAuth2 Sessions | **MAS** → user detail → OAuth2 Sessions | | MAS Compat Sessions | **MAS** → user detail → Compat Sessions | | MAS User Emails | **MAS** → user detail → Emails | | MAS Personal Sessions | **MAS** → user detail → Personal Sessions | --- ## ⚙️ How to Open the Column Configurator 1. Navigate to any supported table (see list above). 2. Look for the **⚙️ settings icon** at the top-right corner of the table toolbar. 3. Click it to open the column picker panel. > 💡 The icon only appears on desktop-width viewports. On mobile, tables switch to a simplified list layout that does not expose the column configurator. --- ## 👁️ How to Show or Hide a Column 1. Open the column picker panel (see above). 2. Find the column name you want to toggle. 3. Click the **checkbox** next to the column name to show or hide it. 4. The table updates immediately — no save button needed. --- ## 🔀 How to Reorder Columns 1. Open the column picker panel (see above). 2. Locate the **drag handle** (⠿ or similar grip icon) to the left of a column name. 3. Click and drag the row to the desired position in the list. 4. Release to drop — the table column order updates immediately. --- ## 📝 Settings Persistence > 📝 Column visibility and order settings persist across page reloads and browser sessions as long as you use the same browser profile. Clearing your browser's site data (cookies, localStorage) will reset all column preferences to their defaults. --- **See also:** [User management](./user-management.md) · [Room management](./room-management.md) · [Documentation index](./README.md) ================================================ FILE: docs/cors-credentials.md ================================================ # 🔐 CORS Credentials Controls how Ketesa sends cookies and credentials when making API requests. Most deployments don't need to touch this — the default works fine for standard setups. You'll typically only need it when adding a reverse-proxy authentication layer in front of your homeserver. **When to change it:** - **`include`** — use this when you have cookie-based auth in front of your homeserver (e.g., [ForwardAuth with Authelia](https://github.com/Awesome-Technologies/synapse-admin/issues/655)). Cookies will be forwarded with every request regardless of origin. - **`omit`** — use this if your setup explicitly must not send any cookies (rare; usually for strict security policies). - **`same-origin`** — the default; works for the vast majority of deployments. ## ⚙️ Configuration > 📚 [MDN reference: credentials option](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#including_credentials) | Value | When to use | Behavior | |-------|-------------|----------| | `same-origin` *(default)* | Standard deployments | Cookies sent only for same-origin requests | | `include` | Cookie-based auth (ForwardAuth, Authelia, etc.) | Cookies sent with every request | | `omit` | Strict no-cookie policies | Cookies never sent | [Configuration options](config.md) ### config.json ```json { "corsCredentials": "include" } ``` ### `/.well-known/matrix/client` ```json { "cc.etke.ketesa": { "corsCredentials": "include" } } ``` ================================================ FILE: docs/csv-import.md ================================================ # 🎯 Bulk CSV User Import The bulk CSV import feature lets you create many Matrix user accounts at once by uploading a structured CSV file. Instead of manually creating accounts one by one through the UI, you can prepare a spreadsheet, export it as CSV, and provision hundreds of users in a single operation — ideal for onboarding a new organisation, migrating users from another platform, or pre-seeding accounts before launch. ## ✨ Overview / Capabilities - Upload any RFC-4180 CSV file with standard user fields - Validate the file and preview parsed statistics before committing any changes - Dry-run mode: simulate the full import without writing anything to the server - Three conflict modes to control what happens when a user ID already exists - Automatic random password generation for rows that have no password - Automatic random MXID generation for rows that have no ID - Third-party ID (3PID, e.g. email) provisioning via a compact inline syntax - Download a CSV of skipped records after the import so you can fix and re-run them --- ## 📋 CSV Format The CSV file must have a header row. Column names are **case-insensitive** and leading/trailing whitespace is stripped automatically. ### 🔑 Required columns | Column | Description | |---|---| | `id` | Matrix user ID. Accepts a full MXID (`@alice:example.com`) or a bare localpart (`alice`). If empty or missing per-row, a random MXID is generated (see [User ID mode](#️-import-options)). | | `displayname` | Human-readable display name shown in Matrix clients. | > ⚠️ The import will be rejected immediately if either `id` or `displayname` is missing from the header row. ### 🗂️ Optional columns | Column | Type | Description | |---|---|---| | `password` | string | Plaintext password for the account. If empty, a random password is generated (controlled by [Password mode](#️-import-options)). | | `admin` | boolean | Whether the user is a server administrator. Accepts `1`, `true`, `yes`, `on` for true; `0`, `false`, `no`, `off`, `null`, `undefined`, or empty for false. | | `is_guest` | boolean | Whether the account is a guest. Same boolean values as `admin`. | | `deactivated` | boolean | Whether the account is deactivated on creation. Same boolean values as `admin`. | | `avatar_url` | string | `mxc://` URI for the user's avatar image. | | `user_type` | string | Synapse user type identifier (e.g. `bot`). Leave empty for a regular user. | | `threepids` | string | Comma-separated list of third-party identifiers in `medium:address` format (e.g. `email:alice@example.com,msisdn:+1234567890`). Pairs with an invalid format are silently ignored. | > 📝 The columns `name` and `is_admin` are recognised as legacy aliases produced by earlier react-admin CSV exports and are silently discarded during import. Use `id` and `admin` respectively. > 💡 Column order in the CSV does not matter. Extra columns not listed above are passed through to the server as-is. ### 📄 Example ```csv id,displayname,password,admin,is_guest,deactivated,avatar_url,threepids alice,Alice Example,s3cr3t,false,false,false,,email:alice@example.com bob,Bob Example,,false,false,false,, @carol:example.com,Carol Example,hunter2,true,false,false,mxc://example.com/abc123,email:carol@example.com ``` --- ## ⚙️ Import Options These options are presented in the UI after a valid CSV has been loaded, before the import is started. | Option | UI control | Values | Default | Description | |---|---|---|---|---| | **Dry-run mode** | Checkbox | enabled / disabled | enabled | When enabled, the import runs through all validation and conflict checks but does **not** create any accounts on the server. Always recommended for a first pass. | | **Conflict mode** | Dropdown | `stop`, `skip` | `stop` | What to do when a user ID from the CSV already exists on the server (see [Handling conflicts](#-how-to-handle-conflicts)). | | **Password mode** | Checkbox | enabled / disabled | enabled | When enabled, passwords supplied in the CSV are used as-is and missing passwords get a randomly generated one. When disabled, no password is set or updated for any user. | | **User ID mode** | Dropdown | `ignore`, `update` | `update` | Controls whether IDs present in the CSV are used (`update`) or discarded in favour of freshly generated random MXIDs (`ignore`). Only shown when at least one row in the CSV has a non-empty `id` value. | --- ## 🛠️ How to Import Users 1. **Navigate to the import page.** In the left sidebar, open **Users**, then click **Import**. 2. **Prepare your CSV.** Create a file with at minimum the `id` and `displayname` columns. Add any optional columns you need (see [CSV Format](#-csv-format)). 3. **Upload the file.** Click the file input and select your CSV. The file is parsed immediately in the browser — no data is sent to the server yet. 4. **Review the parsed statistics.** The stats card shows the total number of users found, how many have guest or admin flags set, how many have explicit IDs, and how many have passwords. Verify these numbers match your expectations. 5. **Configure import options.** Set conflict mode, password mode, and user ID mode as needed (see [Import Options](#️-import-options)). 6. **Run a dry run first.** Make sure the **Simulate only** checkbox is ticked, then click **Run import**. Review the results — check the success count and whether any records were skipped. 7. **Run the real import.** Uncheck **Simulate only**, then click **Run import** again. Progress is shown inline as records are processed. 8. **Review results.** The results card appears when the import completes (see [Reading the Results](#-how-to-read-results)). > ⚠️ Files larger than 100 MB are rejected. For very large imports, split the CSV into chunks below this limit. > 💡 The dry run uses the exact same logic as the real import, including conflict detection. If the dry run shows zero skipped records, the real run will also skip none — assuming no concurrent changes on the server. --- ## ⚔️ How to Handle Conflicts A conflict occurs when the `id` of a CSV row resolves to a Matrix user that already exists on the server. The **Conflict mode** setting controls what happens: | Mode | Value | Behaviour | When to use | |---|---|---|---| | **Stop** | `stop` | The import halts immediately at the first conflicting record. Records processed before the conflict are kept (or simulated). The offending ID is reported in the error card. | Safe default. Use when your CSV should contain only new users and any existing ID indicates a data problem. | | **Skip** | `skip` | Conflicting records are silently skipped and added to the skipped-records list. The import continues with the remaining rows. | Use when you are re-running a partially completed import or when your CSV intentionally mixes new and existing users. | > 📝 In `skip` mode, all skipped records are available for download as a CSV after the import finishes (see [Reading the Results](#-how-to-read-results)). You can edit the downloaded file and re-import only the skipped rows. > ⚠️ There is no **Update** conflict mode. Existing user accounts are never modified by the import, regardless of conflict mode setting. To modify existing users, use the user edit form or the user list bulk actions. --- ## 📊 How to Read Results When the import finishes the results card replaces the import controls and shows: | Item | Description | |---|---| | **Total** | The total number of rows that were processed from the CSV. | | **Successful** | The count of accounts that were (or would be, in dry-run) successfully created. A list of their display names is shown below the count. | | **Skipped** | The count of records that were skipped due to a conflict (only in `skip` conflict mode). A **Download skipped records** button appears — clicking it downloads `skippedRecords.csv` containing those rows in the original CSV format. | | **Errored** | The count of records that could not be processed due to an error. | | **Simulated only** warning | A yellow alert is shown when the results are from a dry run and no accounts were actually created. | After reviewing the results, click **Back** to return to the user list. > 💡 The downloaded `skippedRecords.csv` is a valid import CSV. You can correct it and upload it for a follow-up import without needing to filter out the already-successful rows from your original file. --- **See also:** [User management](./user-management.md) · [Documentation index](./README.md) ================================================ FILE: docs/custom-menu.md ================================================ # 🎯 Custom Menu Items Extend Ketesa's sidebar navigation with your own links — no rebuild required. Custom menu items appear alongside the built-in navigation and work like any other menu entry. **Popular uses:** - Link to your internal runbook or documentation wiki - Add a shortcut to your monitoring dashboard or status page - Point to a support ticketing system or help desk - Add a link to your organization's Matrix community room Items support translations via the `i18n` field, so multilingual teams see the label in their own language automatically. ## ⚙️ Configuration The examples below add a link to the [Ketesa issues](https://github.com/etkecc/ketesa/issues). Each `menu` item can contain the following fields: | Field | Required | Type | Description | |-------|----------|------|-------------| | `url` | ✓ | string | The URL to navigate to when the menu item is clicked. | | `label` | ✓ | string | The text to display in the menu. | | `i18n` | | object | Dictionary of translations for the label. The keys should be [BCP 47 language tags](https://en.wikipedia.org/wiki/IETF_language_tag) (e.g., `en`, `fr`, `de`) supported by Ketesa (see [src/i18n/](../src/i18n)). | | `icon` | | string | The icon to display next to the label, one of the [src/utils/icons.ts](../src/utils/icons.ts) icons, otherwise a default icon will be used. | [Configuration options](config.md) ### config.json ```json { "menu": [ { "label": "Contact support", "i18n": { "de": "Support kontaktieren", "fr": "Contacter le support", "zh": "联系支持" }, "icon": "SupportAgent", "url": "https://github.com/etkecc/ketesa/issues" } ] } ``` ### `/.well-known/matrix/client` ```json { "cc.etke.ketesa": { "menu": [ { "label": "Contact support", "i18n": { "de": "Support kontaktieren", "fr": "Contacter le support", "zh": "联系支持" }, "icon": "SupportAgent", "url": "https://github.com/etkecc/ketesa/issues" } ] } } ``` ================================================ FILE: docs/event-reports.md ================================================ # 🚨 Event Reports Event reports are abuse reports submitted by Matrix users via their client apps. When a user flags a message or event as harmful, inappropriate, or otherwise policy-violating, a report is created on the homeserver. Ketesa lets you review those reports, investigate the flagged content, and dismiss reports once you have handled them. > 📝 Reports in this view correspond to Matrix's `/report` endpoint. Only events reported by users on your homeserver appear here. --- ## 📋 Reports List The reports list displays all pending abuse reports, sorted by most recent first by default. ### 🗂️ Columns | Column | Field | Description | |---|---|---| | **ID** | `id` | Unique numeric identifier for the report. | | **Report Time** | `received_ts` | Date and time the report was received by the homeserver. | | **Reporter** | `user_id` | The Matrix user who submitted the report. | | **Room Name** | `name` | Display name of the room containing the flagged event. | | **Score** | `score` | Severity score assigned by the reporter (see [Severity Score](#-severity-score) below). | > 📝 The reporting user's ID and the room name are truncated in the list view. Click a report to see full values. --- ## 🔢 Severity Score The **Score** field is a numeric value set by the reporter at the time they submitted the report. Per the Matrix specification, the range is **−100 to 0**, where −100 represents the most severe content and 0 represents the least. Not all clients enforce this range — some may send positive values or omit the field entirely. > ⚠️ The score reflects the reporter's subjective judgement, not an objective severity level. Use it as a triage signal, but always review the actual event content before taking action. Common guidance (for spec-compliant clients): | Score range | Typical reporter intent | |---|---| | −100 to −71 | High concern — serious violations (e.g. harassment, illegal content) | | −70 to −31 | Moderate concern — clearly unwanted or offensive content | | −30 to 0 | Low concern — mildly inappropriate or borderline content | --- ## 🔍 Report Detail View Click any row in the reports list to open the detail view for that report. The detail view has two tabs. ### 🗒️ Basic Tab The **Basic** tab displays report metadata and links to the involved parties. | Field | Description | |---|---| | **ID** | Numeric report ID. | | **Report Time** | Timestamp the report was received. | | **Score** | Severity score set by the reporter. | | **Reason** | Free-text reason provided by the reporter (may be empty). | | **Announcer** | The user who filed the report — clicking their avatar or ID navigates to their user detail page. | | **Sender** | The user who sent the flagged event — clicking their avatar or ID navigates to their user detail page. | | **Room** | The room containing the flagged event — clicking navigates to the room detail page. | | **Event ID** | The Matrix event ID of the flagged event. | > 💡 Use the direct links to the reporter, sender, and room to jump straight to the relevant resource without leaving the workflow. ### 🧾 Details Tab The **Details** tab shows the raw JSON of the flagged event (`event_json`). This is the full Matrix event object as stored by the homeserver — including the event type, content, sender, timestamps, and signatures. Use this tab to see the exact message or media content that was reported without having to look it up elsewhere. > 📝 If the event JSON is absent (e.g. the event has been redacted or is no longer available), the Details tab will be empty. --- ## 🔎 Event Lookup Tool The **Event Lookup** tool lets you retrieve any event by its ID, independent of reports. It is useful when you have an event ID from another source (e.g. a user complaint, a log entry, or another admin tool) and want to inspect its content. **Location:** The "Event Lookup" button is in the toolbar at the top-right of the reports list page. **How it works:** Clicking the button opens a dialog. Enter the event ID and press **Fetch** (or press Enter). The raw event JSON is displayed inline in the dialog. > 💡 The Event Lookup tool queries the homeserver directly and is not limited to reported events. You can look up any event ID your admin account has access to. --- ## 📖 How to Review a Report 1. Open **Reported Events** from the left navigation menu. 2. Find the report you want to review. You can sort by **Report Time** to prioritise the most recent ones. 3. Click the report row to open the detail view. 4. Read the **Basic** tab: note the reporter (**Announcer**), the message author (**Sender**), and the **Reason** provided. 5. Switch to the **Details** tab to read the raw event JSON and see the exact content that was flagged. 6. If you need more context, use the links on the **Basic** tab to navigate to the sender's user page or the room page. 7. Take any necessary moderation action on the user or room (e.g. deactivate account, kick from room) through the respective management pages. 8. Return to the report detail view and delete the report to dismiss it (see [How to Delete a Report](#-how-to-delete-a-report)). --- ## 🔎 How to Use the Event Lookup Tool 1. Open **Reported Events** from the left navigation menu. 2. Click the **Event Lookup** button in the top-right toolbar. 3. In the dialog that opens, paste or type the event ID into the **Event ID** field. 4. Press **Fetch** or hit Enter. 5. The raw event JSON is displayed in the dialog. 6. Review the content as needed, then click **Cancel** to close the dialog. > 💡 If the event cannot be found or your account lacks access, an error message is shown in the dialog. --- ## 🗑️ How to Delete a Report 1. Open the report detail view by clicking the report in the list. 2. Click the **Delete** button in the top-right toolbar of the detail view. 3. A confirmation dialog appears asking you to confirm the deletion. 4. Confirm the deletion. > 📝 Deleting a report removes it from the list but does not affect the reported user or the event itself. Take any moderation action on the user or room separately. --- **See also:** [Room management](./room-management.md) · [Documentation index](./README.md) ================================================ FILE: docs/external-auth-provider.md ================================================ # 🔑 External Auth Provider When Synapse delegates authentication to an external provider (OIDC, LDAP, and similar), the Synapse API doesn't announce which provider is in use — especially with seamless/hidden password providers that don't announce themselves. Ketesa needs a hint to adapt its interface accordingly. Setting `externalAuthProvider: true` tells Ketesa to adjust its behavior for these setups. Currently, it makes the following changes: - **No password required when reactivating a user** — because the password lives in the external provider, not in Synapse - **Hides the guests filter** in the users list — external auth providers typically don't support guest accounts > 📝 **Note:** For OIDC ("next-gen auth" / MAS), Ketesa adjusts its behavior automatically — this config option is not required for those setups. ## 🔐 Matrix Authentication Service (MAS) When Synapse uses Matrix Authentication Service (MAS) for OIDC, Ketesa detects this automatically and activates the full MAS integration: registration token management, the MAS user management panel (sessions, emails, upstream OAuth links, policy data), and adapted create/edit workflows for users. See the [MAS user management guide](./user-management.md#-mas-user-management) for the full feature list. Ketesa detects MAS by probing two endpoints in parallel when you enter a homeserver URL: - `/_matrix/client/v3/login` — checks for the `org.matrix.msc3824.delegated_oidc_compatibility` flag on the SSO flow - `/_matrix/client/v1/auth_metadata` — the stable OAuth 2.0 server metadata endpoint defined in [Matrix spec v1.14](https://spec.matrix.org/v1.18/client-server-api/#get_matrixclientv1auth_metadata) Either signal is sufficient to enable the OIDC login button. This means Ketesa correctly handles both configurations: - Synapse with MAS where `/_matrix/client/v3/login` is **disabled** (the default MAS behaviour, which previously caused the OIDC button not to appear) - Synapse with MAS where `/_matrix/client/v3/login` is still active and advertises the MSC3824 flag The MAS admin API is not exposed by default, so it must be reachable from the Ketesa UI. > ⚠️ **Warning:** If the MAS admin API is not exposed, MAS-specific operations (registration tokens, sessions, emails, etc.) will fail. ```yaml http: listeners: - name: web resources: # ... - name: adminapi # Add this binds: - address: '0.0.0.0:8080' # ... ``` ### /auth-callback When using MAS, the `/auth-callback` endpoint is used for handling OIDC callbacks. The Ketesa build includes a dedicated `auth-callback/index.html`, so this endpoint is served as a real static page and does not require SPA fallbacks or copying `index.html`. **Web server configuration** If you are using a web server (like nginx) to serve the Ketesa UI, make sure the `/auth-callback` path serves `auth-callback/index.html` from the build output. A standard static file config already does this. For example, in nginx: ```nginx location / { try_files $uri $uri/ /index.html; } ``` > 💡 This method is used in Ketesa's [Docker images (dist)](../docker/Dockerfile) and [Docker image (build)](../docker/Dockerfile.build) and is recommended for production deployments. ## ⚙️ Configuration `externalAuthProvider` accepts a boolean value: | Value | Behavior | |---|---| | `true` | Enable external auth provider mode | | `false` (default) | Disable external auth provider mode | [Configuration options](config.md) ### config.json ```json { "externalAuthProvider": true } ``` ### `/.well-known/matrix/client` ```json { "cc.etke.ketesa": { "externalAuthProvider": true } } ``` ================================================ FILE: docs/federation.md ================================================ # 🌐 Federation Overview The Federation overview shows every remote Matrix server your homeserver communicates (or has communicated) with. It is your first stop for diagnosing federation problems — slow message delivery, rooms going out of sync, or servers that seem unreachable. In Ketesa the section is labelled **Federation** in the navigation menu and maps to the `destinations` resource in the Synapse Admin API. --- ## 📋 Destinations List The list shows all known federation destinations — remote homeservers your server has attempted to reach. Each row represents one remote server. | Column | Field | Description | |---|---|---| | Destination | `destination` | The hostname of the remote Matrix server (e.g. `matrix.org`). | | Failure timestamp | `failure_ts` | When the current failure streak started. Empty if the destination is healthy. | | Last retry timestamp | `retry_last_ts` | When Synapse last attempted to reach the destination after a failure. | | Retry interval | `retry_interval` | Current backoff interval (in milliseconds) before the next automatic retry. Increases with each failed attempt. | | Last successful stream | `last_successful_stream_ordering` | The stream ordering value of the last event successfully delivered to this destination. Useful for gauging how far behind a destination has fallen. | > 📝 The list defaults to ascending alphabetical order by `destination`. Use the search box to filter by server name. --- ## 🔴 Error Indicators Destinations that are actively failing are visually flagged. When `retry_last_ts` is greater than zero, a red error icon (`ErrorIcon`) is rendered inline next to the destination name in the list. On mobile, the `failure_ts` value is shown beneath the server name alongside the same error icon. A non-empty `failure_ts` is the authoritative signal that a destination is in an error state. `retry_last_ts > 0` drives the red icon; an empty `retry_last_ts` with a set `failure_ts` indicates a failure that has not yet been retried. --- ## 🗂️ Rooms per Destination Clicking any destination row opens a detail view with two tabs: - **Status** — repeats the five fields from the list in a structured layout. - **Rooms** — lists every room your homeserver shares with that remote server. The Rooms tab shows: | Column | Description | |---|---| | Room ID | The full Matrix room ID (`!id:server`). | | Stream | The `stream_ordering` value for this destination/room pair — indicates the event position last synced. | | Name | The human-readable room name, resolved via a reference lookup. | Clicking a room row navigates to that room's detail page. **Why this matters:** When a destination fails, the Rooms tab tells you which rooms are affected and how many. A destination serving a single low-traffic room may not warrant immediate action. A destination that shares dozens of active rooms is a higher-priority investigation. --- ## 🔄 Reconnect Action The **Reconnect** button appears in the list row and in the detail view toolbar, but only when the destination has an active failure (`failure_ts` is set). Clicking Reconnect calls the Synapse API to delete the current failure record for that destination. This resets the exponential backoff counter and triggers an immediate connection attempt — Synapse will try to reach the remote server right away instead of waiting for the next scheduled retry. > 💡 Federation failures are often temporary — Synapse retries automatically with exponential backoff. Use the reconnect action only after you've resolved the underlying issue on your end. **When to use it:** - You have fixed a network routing problem or firewall rule. - A DNS record was updated and the TTL has expired. - The remote server renewed its TLS certificate and you have confirmed it is reachable. - You want to test connectivity immediately without waiting for the backoff timer. > ⚠️ If the underlying problem has not been resolved, the reconnect attempt will fail and Synapse will re-enter the backoff cycle. Repeated forced reconnects do not help and may briefly increase load. --- ## 🔍 How to Identify a Failed Federation Destination 1. Open **Federation** in the left navigation menu. 2. Scan the list for rows with a red error icon next to the destination name. 3. Check the **Failure timestamp** column to see when the failure began. 4. Check the **Last retry timestamp** column to see when Synapse last tried to reconnect. 5. Note the **Retry interval** — a large value means Synapse has been failing for a while and is backing off aggressively. 6. Click the destination to open the detail view and confirm the failure details on the **Status** tab. --- ## 🔌 How to Reconnect a Failed Destination 1. Open **Federation** in the left navigation menu. 2. Find the destination you want to reconnect (use the search box if needed). 3. Confirm the red error icon is present, indicating an active failure. 4. Verify that the root cause (network issue, DNS change, TLS cert) has been resolved. 5. Click the **Reconnect** button in the destination's row. 6. A confirmation notification appears. The row refreshes and the error icon clears if the reconnection succeeds. Alternatively: 1. Click the destination row to open the detail view. 2. Click the **Reconnect** button in the top toolbar. --- ## 🏠 How to See Which Rooms Are Shared with a Remote Server 1. Open **Federation** in the left navigation menu. 2. Click the destination row for the remote server you are interested in. 3. The detail view opens on the **Status** tab by default. 4. Click the **Rooms** tab (folder icon). 5. Browse the list of shared rooms. Each row shows the room ID, stream ordering, and room name. 6. Click any room row to navigate to that room's full detail page. --- **See also:** [Room management](./room-management.md) · [Server statistics](./server-statistics.md) · [Documentation index](./README.md) ================================================ FILE: docs/media.md ================================================ # 🖼️ Media Management Ketesa provides granular media controls at the file, user, and room level — useful for content moderation and storage management. You can quarantine harmful content, protect important media from accidental quarantine, delete individual files or entire user libraries, and purge the remote media cache. --- ## 🗂️ Concepts | Operation | What it does | Reversible? | |-----------|-------------|-------------| | Quarantine | Blocks access to the media for all users | ✅ Yes (unquarantine) | | Protect | Prevents the media from being quarantined | ✅ Yes (unprotect) | | Delete | Permanently removes the media from the server | ❌ No | | Purge remote cache | Removes locally cached copies of media fetched from remote servers | ❌ No | > 📝 Quarantine and protect are mutually exclusive states for a single file. A protected file cannot be quarantined, and an already-quarantined file cannot be protected until it is first unquarantined. --- ## 📄 Per-file Operations Per-file actions are available in two places: - **User edit page → Media tab** — shows all media uploaded by the user, with per-row action buttons - **Room show page → Media tab** — shows all media uploaded into the room, with a per-row delete button The following buttons appear on each file row in the user Media tab: | Button label | What it does | When it appears | |--------------|-------------|-----------------| | Quarantine | Quarantines the file, blocking all access | File is not protected and not already quarantined | | Unquarantine | Lifts the quarantine on the file | File is currently quarantined | | Protect | Marks the file as safe from quarantine | File is not quarantined and not already protected | | Unprotect | Removes the protection flag | File is currently protected | | Delete | Permanently deletes the individual file | Always visible | > 📝 The quarantine button is replaced by a disabled "Protected" indicator when the file is safe from quarantine. The protect button is replaced by a disabled "In quarantine" indicator when the file is already quarantined. --- ## 👤 Per-user Operations **Location:** Users → select a user → Edit → **Media** tab The Media tab on the user edit page shows all media uploaded by that user, with per-file action buttons (see [Per-file Operations](#-per-file-operations) above). At the top of the tab, bulk action buttons are available: | Button label | What it does | When it appears | |--------------|-------------|-----------------| | Quarantine all media | Quarantines every media file uploaded by the user, after confirmation | Always visible | | Delete all media | Permanently deletes every media file uploaded by the user, after confirmation | Always visible | > ⚠️ **Quarantine all media** affects every file the user has ever uploaded to the server. This cannot be undone in bulk — you would need to unquarantine each file individually. Use with care. > ⚠️ **Delete all media** permanently removes all media uploaded by the user. This cannot be undone. The dialog runs in the foreground — do not close it until deletion is complete. --- ## 🚪 Per-room Operations **Location:** Rooms → select a room → Show → **Media** tab The Media tab on the room show page lists all media uploaded into that room. Each row has an individual **Delete** button. At the top of the tab, bulk action buttons are available: | Button label | What it does | When it appears | |--------------|-------------|-----------------| | Quarantine all media | Quarantines every media file uploaded in the room, after confirmation | Always visible | | Delete all media | Permanently deletes all local media in the room, after confirmation | Only for unencrypted rooms | > ⚠️ **Quarantine all media** affects all files uploaded to the room by all members. Use this for rooms with reported illegal or harmful content. > ⚠️ **Delete all media** permanently removes all local media in the room. Only local media from unencrypted rooms is affected — media from external servers and encrypted rooms is excluded. The dialog runs in the foreground and shows live progress — do not close it until deletion is complete. > 📝 It is not possible to delete media that has been uploaded to external media repositories. Only media hosted on your own server is affected. --- ## 🌐 Purge Remote Media Cache **Location:** Statistics → Users' media → toolbar → **Purge remote media** button When your server federates with other Matrix homeservers, it caches copies of media (avatars, images, files) from those remote servers. Over time this cache can grow large. The purge remote media action removes these locally cached copies. > 📝 This action only affects your server's local cache of remote media. It does not affect media uploaded to your own server's media repository, and does not delete the originals from their source servers. Remote users can re-fetch media after a purge. **Dialog options:** | Field | Description | |-------|-------------| | last access before | Only purge remote media that has not been accessed since this date/time. Leave at the default (epoch) to purge all cached remote media. | --- ## 🗑️ Delete Local Media **Location:** Statistics → Users' media → toolbar → **Delete media** button This action deletes local media from your server's disk, including thumbnails and downloaded copies of remote media. It does not affect media uploaded to external media repositories. **Dialog options:** | Field | Description | |-------|-------------| | last access before | Only delete media that has not been accessed since this date/time | | Larger than (in bytes) | Only delete media files larger than this size. Step size is 1024 bytes. | | Keep profile images | When enabled, profile avatars are excluded from deletion (default: on) | > ⚠️ Deleted media cannot be recovered. Files that match all specified criteria are removed permanently from disk. --- ## 🔒 How to Quarantine a File 1. Navigate to **Users** and open the user who uploaded the file. 2. Click **Edit**, then open the **Media** tab. 3. Find the file in the list. The **Quarantine** button appears on rows where the file is not protected and not already quarantined. 4. Click **Quarantine**. The button changes to **Unquarantine** immediately on success. > 💡 To undo, click the **Unquarantine** button on the same row. --- ## 🛡️ How to Protect Media from Accidental Quarantine 1. Navigate to **Users** and open the user who uploaded the file. 2. Click **Edit**, then open the **Media** tab. 3. Find the file in the list. The **Protect** button appears on rows where the file is not quarantined and not already protected. 4. Click **Protect**. The button changes to **Unprotect** immediately on success. > 💡 A protected file cannot be quarantined — the quarantine button is replaced by a disabled "Protected" indicator. To remove protection later, click **Unprotect**. --- ## 🗑️ How to Delete All Media for a User > ⚠️ Deletion is irreversible. All files are permanently removed from disk. There is no undo. **Single user:** 1. Navigate to **Users** and open the user. 2. Click **Edit**, then open the **Media** tab. 3. Click **Delete all media** at the top of the tab. 4. Confirm in the dialog. The dialog runs in the foreground — do not close it until complete. **Multiple users at once (bulk):** 1. Navigate to **Users**. 2. Select the users using the checkboxes on the left. 3. Click **Delete all media** in the bulk action toolbar that appears at the top. 4. Confirm in the dialog. A summary notification reports how many succeeded and how many failed. **Server-wide delete by age/size (Statistics):** The **Delete media** button (found in Statistics → Users' media toolbar) deletes media server-wide based on age and size filters: 1. Navigate to **Statistics → Users' media**. 2. Click **Delete media** in the top toolbar. 3. Set a **last access before** date to target old unused files. 4. Optionally set a **Larger then (in bytes)** threshold to target large files only. 5. Toggle **Keep profile images** off if you also want to remove avatars. 6. Click **Delete media** to confirm. > 💡 Run with conservative filters first (e.g., a recent cutoff date and large size threshold) to limit the blast radius before doing a broad sweep. --- ## 🚪 How to Delete All Media for a Room > ⚠️ Only local media from unencrypted rooms is affected. Media from encrypted rooms and external servers is excluded. Deletion is irreversible. **Single room:** 1. Navigate to **Rooms** and open an unencrypted room. 2. Click **Show**, then open the **Media** tab. 3. Click **Delete all media** at the top of the tab. 4. Confirm in the dialog. The dialog shows live progress and runs in the foreground — do not close it until complete. **Multiple rooms at once (bulk):** 1. Navigate to **Rooms**. 2. Select the rooms using the checkboxes on the left. 3. Click **Delete all media** in the bulk action toolbar that appears at the top. 4. Confirm in the dialog. Encrypted rooms are skipped automatically. A summary notification reports the result. --- **See also:** [User management](./user-management.md) · [Room management](./room-management.md) · [Server statistics](./server-statistics.md) · [Documentation index](./README.md) ================================================ FILE: docs/prefill-login-form.md ================================================ # 🔗 Prefilling the Login Form Ketesa's login form can be pre-populated via URL query parameters — handy for sharing a direct-access link that drops users straight into the right homeserver, or for bookmarking your admin setup. **Common use cases:** - Share a link with your homeserver pre-filled so users don't have to type it: `https://admin.etke.cc?server=https://matrix.example.com` - Pre-fill both username and server for faster login: `https://admin.etke.cc?username=admin&server=https://matrix.example.com` - In development, pre-fill all credentials so you don't have to retype them every time ## 📋 Query Parameters ### Always Available | Parameter | Description | |-----------|-------------| | `username` | The username to prefill in the username field. | | `server` | The server to prefill in the homeserver URL field. | ### Localhost Only > ⚠️ **Warning:** The following parameters only work when Ketesa is loaded from `localhost` or `127.0.0.1`. Never use these in production as they can be easily extracted from the URL. These are only meant for development purposes and local environments. | Parameter | Description | |-----------|-------------| | `password` | The password to prefill in the password field (credentials auth). | | `accessToken` | The access token to prefill in the access token field (access token auth). | ## 📖 Examples ### Production ```bash https://admin.etke.cc?username=admin&server=https://matrix.example.com ``` This will open the `Credentials` (username/password) login form with the username field prefilled with `admin` and the Homeserver URL field prefilled with `https://matrix.example.com`. ### Development and Local Environments #### With Password ```bash http://localhost:8080?username=admin&server=https://matrix.example.com&password=secret ``` This will open the `Credentials` (username/password) login form with the username field prefilled with `admin`, the Homeserver URL field prefilled with `https://matrix.example.com`, and the password field prefilled with `secret`. #### With Access Token ```bash http://localhost:8080?server=https://matrix.example.com&accessToken=secret ``` This will open the `Access Token` login form with the Homeserver URL field prefilled with `https://matrix.example.com` and the access token field prefilled with `secret`. ================================================ FILE: docs/registration-tokens.md ================================================ # 🎟️ Registration Tokens Registration tokens are invite codes that users must provide during account registration. They let you control who can sign up on your server — useful for private or invite-only communities, onboarding specific groups, or preventing open registration while still allowing specific users to join. Each token can be used a limited or unlimited number of times, and can optionally expire after a set datetime. The admin UI supports registration tokens for both **Synapse** and **MAS** (Matrix Authentication Service) backends. --- ## 🔀 Synapse vs MAS The features available for registration tokens depend on which authentication backend your homeserver uses. | Feature | Synapse | MAS | |---|---|---| | Usage counter (`uses_allowed`) | ✅ | ✅ | | Expiry datetime (`expiry_time`) | ✅ | ✅ | | Pending uses (`pending`) | ✅ | ✅ | | Completed uses (`completed`) | ✅ | ✅ | | Creation timestamp (`created_at`) | ❌ | ✅ | | Last-used timestamp (`last_used_at`) | ❌ | ✅ | | Revocation timestamp (`revoked_at`) | ❌ | ✅ | | Revoke / unrevoke | ❌ | ✅ | | Delete | ✅ | ❌ | > 📝 Which backend is active depends on your homeserver configuration. See [External auth provider](./external-auth-provider.md). --- ## 📋 Token Fields | Field | Backend | Editable | Description | |---|---|---|---| | `token` | Both | No | The token string users enter during registration. Consists of characters `A-Z`, `a-z`, `0-9`, `.`, `_`, `~`, `-`, up to 64 characters. | | `uses_allowed` | Both | Yes | Maximum number of times this token may be used. Leave empty for unlimited uses. | | `pending` | Both | No | Number of registrations that started using this token but have not yet completed. | | `completed` | Both | No | Number of registrations that successfully completed using this token. | | `expiry_time` | Both | Yes | Date and time after which the token is no longer valid. Leave empty for no expiry. | | `created_at` | MAS only | No | Timestamp when the token was created. | | `last_used_at` | MAS only | No | Timestamp when the token was last used for a registration. | | `revoked_at` | MAS only | No | Timestamp when the token was revoked. Empty if the token has not been revoked. | > 💡 On mobile, the token list shows `token`, `uses_allowed`, `completed`, and `expiry_time`. All fields are available in the detail/edit view. --- ## ➕ How to Create a Token 1. Navigate to **Registration Tokens** in the sidebar. 2. Click **Create**. 3. Fill in the form fields as needed (all fields are optional — you can save immediately to generate a token with default settings): | Field | Required | Notes | |---|---|---| | `token` | No | A custom token string. Must match `^[A-Za-z0-9._~-]{0,64}$`. If left empty, a token is auto-generated. | | `length` | No | Length of the auto-generated token (max 64). Only applies when `token` is not specified. | | `uses_allowed` | No | Maximum number of registrations allowed. Leave empty for unlimited. | | `expiry_time` | No | Date and time when the token expires. Leave empty for no expiry. | 4. Click **Save**. > 💡 If you leave all fields empty and click **Save**, the server will auto-generate a random token with unlimited uses and no expiry. > ⚠️ The `length` field only takes effect when `token` is empty. If you provide a custom `token` string, `length` is ignored. --- ## ✏️ How to Edit a Token 1. Navigate to **Registration Tokens** in the sidebar. 2. Click on the token you want to edit. 3. Modify the editable fields: | Field | Editable | Notes | |---|---|---| | `token` | No | Read-only after creation. | | `pending` | No | Reflects live registration state. | | `completed` | No | Reflects live registration state. | | `uses_allowed` | Yes | Update to change the usage cap. Set to empty for unlimited. | | `expiry_time` | Yes | Update or clear to change when the token expires. | | `created_at` | No | MAS only. Set at creation time. | | `last_used_at` | No | MAS only. Updated automatically. | | `revoked_at` | No | MAS only. Reflects revocation state. | 4. Click **Save**. > 📝 Usage counters (`pending`, `completed`) are read-only and updated automatically by the server as registrations proceed. --- ## 🚫 How to Revoke a MAS Token (and Unrevoke) Revoking a token immediately prevents it from being used for new registrations, without deleting it. The token remains visible in the list and its usage history is preserved. **To revoke:** 1. Open the token in the edit view. 2. Click **Revoke** in the toolbar. 3. The `revoked_at` field will be populated with the current timestamp. **To unrevoke:** 1. Open a revoked token in the edit view. 2. Click **Unrevoke** in the toolbar. 3. The `revoked_at` field will be cleared and the token becomes valid again (subject to its `uses_allowed` and `expiry_time` constraints). > 💡 Use revoke instead of delete when you want to retain a record of the token and its usage history, or when you may want to re-enable it later. > ⚠️ Revoke and unrevoke are only available on the MAS backend. Synapse tokens use delete instead. --- ## 🗑️ How to Delete a Synapse Token Deleting a token permanently removes it from the server. This action cannot be undone. **From the list view:** 1. Navigate to **Registration Tokens** in the sidebar. 2. On mobile, use the delete button shown in the token row. On desktop, open the token and use the **Delete** button in the edit toolbar. **From the edit view:** 1. Open the token you want to delete. 2. Click **Delete** in the toolbar. 3. Confirm the deletion in the dialog. > ⚠️ Deletion is permanent. If you need to keep a record of the token or may want to re-enable it later, consider using a token with `uses_allowed` set to `0` or setting an `expiry_time` in the past instead. > 📝 Delete is only available on the Synapse backend. MAS tokens use revoke instead. --- ## 🔍 Filtering the Token List The token list includes a **Valid** filter that controls which tokens are shown. | Filter value | Tokens shown | |---|---| | **Valid: Yes** (default) | Tokens that are currently usable — not expired, not fully used, and not revoked (MAS). | | **Valid: No** | Tokens that are expired, fully used, or revoked (MAS). | | *(filter cleared)* | All tokens regardless of validity. | > 💡 The list defaults to showing only valid tokens. Toggle or clear the **Valid** filter to see expired or exhausted tokens. --- **See also:** [External auth provider / MAS](./external-auth-provider.md) · [Documentation index](./README.md) ================================================ FILE: docs/restrict-hs.md ================================================ # 🏠 Restricting Available Homeservers By default, Ketesa lets users connect to any Matrix homeserver. For managed deployments, you'll usually want to lock this down so the homeserver field is either hidden or pre-fixed to a known value. **Common use cases:** - **Managed hosting** — you deploy Ketesa specifically for one server and don't want users accidentally pointing it at another - **Public Ketesa instance** — you run `admin.example.com` and want it to only ever talk to `matrix.example.com` - **Multi-server management** — you manage several homeservers and want to allow exactly those, blocking everything else When `restrictBaseUrl` is set to a single value, the homeserver field on the login page is pre-filled and locked. When set to an array, users can only choose from that list. ## ⚙️ Configuration `restrictBaseUrl` accepts both a single string and an array of strings. > 💡 **Note:** Use the _actual_ homeserver URL, not the delegated one. For example, if you have a homeserver `example.com` where users have MXIDs like `@user:example.com`, but actual Synapse is installed on `matrix.example.com` subdomain, you should use `https://matrix.example.com` in the configuration. The examples below contain the configuration settings to restrict the Ketesa instance to work only with `example.com` (with Synapse running at `matrix.example.com`) and `example.net` (with Synapse running at `synapse.example.net`) homeservers. [Configuration options](config.md) ### config.json ```json { "restrictBaseUrl": [ "https://matrix.example.com", "https://synapse.example.net" ] } ``` ### `/.well-known/matrix/client` ```json { "cc.etke.ketesa": { "restrictBaseUrl": [ "https://matrix.example.com", "https://synapse.example.net" ] } } ``` ================================================ FILE: docs/reverse-proxy.md ================================================ # 🌐 Serving Ketesa behind a reverse proxy Running Ketesa behind a reverse proxy is the recommended approach for any internet-facing deployment. Here you'll find ready-to-use configurations for the most common setups. **Pick the right build for your path:** | Deployment path | Tarball | Docker tag | |----------------|---------|------------| | Root path — `https://admin.example.com` | `ketesa.tar.gz` / `dist-root` | `latest` | | Subpath — `https://example.com/admin` | `ketesa-subpath-admin.tar.gz` / `dist-subpath-admin` | `latest-subpath-admin` | Downloads: [GitHub Releases](https://github.com/etkecc/ketesa/releases) · [GitHub Actions artifacts](https://github.com/etkecc/ketesa/actions/workflows/workflow.yml) ## 🟢 Nginx ### 📦 Prebuilt tarball #### Root path For example, `https://example.com`. Place the config below into `/etc/nginx/conf.d/ketesa.conf` (don't forget to replace `server_name` and `root`): ```nginx server { listen 80; listen [::]:80; server_name example.com; # REPLACE with your domain root /var/www/ketesa; # REPLACE with path where you extracted Ketesa index index.html; location / { try_files $uri $uri/ /index.html; } location ~* \.(?:css|js|jpg|jpeg|gif|png|svg|ico|woff|woff2|ttf|eot|webp)$ { expires 30d; # Set caching for static assets add_header Cache-Control "public"; } gzip on; gzip_types text/plain application/javascript application/json text/css text/xml application/xml+rss; gzip_min_length 1000; } ``` #### `/admin` subpath For example, `https://example.com/admin`. If you are serving Ketesa under `/admin`, extract the `ketesa-subpath-admin` tarball into an `admin/` subdirectory of your web root (e.g. extract into `/var/www/html/admin/`): ```nginx server { listen 80; listen [::]:80; server_name example.com; # REPLACE with your domain root /var/www/html; # REPLACE with the parent of the admin/ directory index index.html; location /admin/ { try_files $uri $uri/ /admin/index.html; } location ~* ^/admin/.*\.(?:css|js|jpg|jpeg|gif|png|svg|ico|woff|woff2|ttf|eot|webp)$ { expires 30d; # Set caching for static assets add_header Cache-Control "public"; } gzip on; gzip_types text/plain application/javascript application/json text/css text/xml application/xml+rss; gzip_min_length 1000; } ``` ### 🐳 Docker The following snippets assume the nginx docker container is used and it is in the same network as Ketesa docker container. #### Root path For example, `https://example.com`. Use Ketesa docker tag **without** the `-subpath-admin` suffix (e.g., `latest`) ```nginx server { listen 80; server_name example.com; # REPLACE with your domain location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://ketesa:8080; } } ``` #### `/admin` subpath For example, `https://example.com/admin`. Use Ketesa docker tag **with** the `-subpath-admin` suffix (e.g., `latest-subpath-admin`) ```nginx server { listen 80; server_name example.com; # REPLACE with your domain location /admin/ { # Trailing slash required here proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://ketesa:8080; # NO trailing slash here } } ``` After you've done that, ensure that the configuration is correct by running `nginx -t` and then reload Nginx (e.g. `systemctl reload nginx`). > ⚠️ **Warning:** This configuration doesn't cover HTTPS, which is highly recommended to use. You can find more information about setting up HTTPS in the [Nginx documentation](https://nginx.org/en/docs/http/configuring_https_servers.html). ## 🔀 Traefik (docker labels) If you are using Traefik as a reverse proxy, you can use the following labels, `docker-compose.yml` example: ```yaml services: ketesa: image: ghcr.io/etkecc/ketesa:latest restart: unless-stopped labels: - "traefik.enable=true" - "traefik.http.routers.ketesa.rule=Host(`example.com`)" ``` ## 🔧 Other reverse proxies There are no examples for other reverse proxies yet, and PRs are greatly appreciated. ================================================ FILE: docs/room-management.md ================================================ # 🏠 Room Management Ketesa gives you deep visibility and control over every room on your server. You can inspect content and structure, take moderation actions, manage memberships, and dig into low-level state — all from the Rooms section of the UI. --- ## ✨ Overview | Capability | Where | |-----------|-------| | Block / unblock rooms | List toolbar, bulk action, Show toolbar | | Publish / unpublish from room directory | Bulk action, Show toolbar | | Delete rooms | Bulk action, Show toolbar | | Purge room history | Show toolbar | | Join a user to a room | Show toolbar | | Assign a room admin | Show toolbar, Members tab | | Browse room members | Members tab | | View raw room state | State tab | | Inspect forward extremities | Forward Extremities tab | | Browse and filter room history | Messages tab | | Navigate Space room trees | Hierarchy tab (Space rooms only) | | Manage room media | Media tab — see [Media management](./media.md) | --- ## 🔧 Room Actions All actions are available from the room detail view (**Rooms → click a room → Show**). Some are also available as bulk actions from the rooms list. --- ### 🚫 Block / Unblock **Block** prevents any user from joining the room. Users already in the room are not affected immediately, but no new joins are allowed. Blocked rooms remain visible to admins. **Unblock** reverses the block and allows joins again. > ⚠️ Blocking a room does not remove existing members. Use **Delete room** if you want to remove members and prevent further access entirely. **How to block a room:** 1. Open the room detail view. 2. Click **Block** in the toolbar. A confirmation dialog shows the room name. 3. Confirm. The toolbar button changes to **Unblock**. **How to block by room ID (without opening the room):** 1. In the **Rooms** list, click the **Block room by ID** button in the toolbar. 2. Enter the full room ID (e.g. `!abc123:example.com`). 3. Confirm. **How to bulk-block rooms:** 1. Select one or more rooms in the list using the checkboxes. 2. Click **Block** in the bulk actions bar. --- ### 📋 Publish / Unpublish from Room Directory Makes a room visible (or invisible) in the public room directory served at `/_matrix/client/v3/publicRooms`. **How to publish / unpublish:** 1. Open the room detail view. 2. Click **Publish to room directory** or **Unpublish from room directory** in the toolbar. **Bulk:** 1. Select rooms in the list. 2. Click **Publish to room directory** or **Unpublish from room directory** in the bulk actions bar. --- ### 🗑️ Delete Room Permanently removes the room. All members are kicked, the room is blocked, and (optionally) local event data is purged. > ⚠️ This action is irreversible. The dialog asks for confirmation before proceeding. **How to delete a room:** 1. Open the room detail view. 2. Click **Delete room** in the toolbar. 3. Confirm the deletion in the dialog. **How to bulk-delete rooms:** 1. Select rooms in the list. 2. Click **Delete room** in the bulk actions bar. --- ### 📜 Purge History Deletes all events in the room before a specified date. Useful for removing old content to reclaim storage or comply with retention policies. | Option | Description | |--------|-------------| | **Purge events before** | Date/time cutoff — all events before this moment are deleted | | **Also delete events sent by local users** | When enabled, events from users on your homeserver are also purged (not just remote events) | > ⚠️ Purged events cannot be recovered. The room itself and its members are not affected — only the event history is removed. > 📝 Large rooms may take time to purge. The dialog shows a progress indicator and notes that you can safely close the window while the purge runs in the background. **How to purge room history:** 1. Open the room detail view. 2. Click **Purge history** in the toolbar. 3. Set the **Purge events before** date/time. 4. Optionally enable **Also delete events sent by local users**. 5. Confirm. The dialog shows progress until the purge completes. --- ### 👤 Join User to Room Joins any Matrix user (from your server or a federated server) to the room as if they had been invited and accepted. **How to join a user:** 1. Open the room detail view. 2. Click **Join user** in the toolbar. 3. Enter the full MXID of the user (e.g. `@alice:example.com`). 4. Click **Join**. --- ### 👑 Assign Room Admin Grants a user the highest power level in the room, making them a room administrator. **How to assign a room admin:** 1. Open the room detail view. 2. Click **Assign admin** in the toolbar (or in the **Members** tab toolbar). 3. Enter the full MXID of the user to promote. 4. Click **Make admin**. --- ## 📑 Room Detail Tabs | Light | |-------| | ![Room View (light)](./screenshots/light/rooms-view.webp) | Open a room and use the tabs to inspect different aspects of it. --- ### 👥 Members Tab Shows all users currently in the room with their account status. | Column | Description | |--------|-------------| | Avatar | User avatar | | User ID | Matrix ID | | Display name | Current display name | | Is guest | Whether the account is a guest account | | Deactivated | Whether the account has been deactivated | | Locked | Whether the account is locked (MAS) | | Erased | Whether the account has been erased | > 💡 Click any user row to navigate to their full user detail page. The **Assign admin** button above the list promotes a user to room admin. --- ### 📊 State Tab Shows the current raw state events of the room — the collection of events that define the room's settings, membership, permissions, and other configuration. | Column | Description | |--------|-------------| | Type | Matrix event type (e.g. `m.room.power_levels`, `m.room.join_rules`) | | Origin server timestamp | When the state event was set | | Content | Raw JSON content of the state event | | Sender | The user who set this state | > 💡 Use the State tab to inspect power levels, join rules, history visibility, and other room settings in their raw form — useful when diagnosing unusual room behaviour. --- ### ⏭️ Forward Extremities Tab Shows the **forward extremities** of the room's event DAG — the most recent events in the room that have no known successors. Under normal circumstances a room should have one or two forward extremities. | Column | Description | |--------|-------------| | ID | Event ID of the extremity | | Received timestamp | When the server received this event | | Depth | Position in the event DAG | | State group | The state group associated with this extremity | > ⚠️ A large number of forward extremities (hundreds or thousands) is a sign of DAG fragmentation, which can degrade room performance significantly. In this case, consider using the **Purge history** action to clean up old events, or consult the Synapse documentation on forward extremity issues. --- ### 💬 Messages Tab Paginated room history with rich filtering and jump-to-date navigation. See [Messages Viewer](#-messages-viewer) below. --- ### 🌳 Hierarchy Tab Available only on **Space** rooms. Shows the full nested room tree. See [Room Hierarchy](#-room-hierarchy) below. --- ### 🖼️ Media Tab Shows all media files associated with the room. Supports per-file deletion and bulk quarantine. See [Media management](./media.md) for full details. --- ## 💬 Messages Viewer | Light | Dark | |-------|------| | ![Room Messages (light)](./screenshots/light/rooms-view-messages.webp) | ![Room Messages (dark)](./screenshots/dark/rooms-view-messages.webp) | The **Messages** tab shows the paginated event history of a room. Each event card displays: | Field | Description | |-------|-------------| | Sender | The Matrix user ID that sent the event | | Timestamp | Server-origin timestamp (`origin_server_ts`), formatted in the browser's locale | | Event type | The Matrix event type (e.g. `m.room.message`) | | Content preview | The event body, membership change, display name, or room name if available; otherwise the raw JSON content | Events are loaded 20 at a time. The viewer opens at the most recent end of the timeline. > 📝 Events without a simple body field are shown as formatted JSON in a monospace block, including the `event_id`. --- ### 🔍 Filters Click the **Filters** button to expand the filter panel. The active filter count is shown in the button label when any filters are set. #### Basic filters | Filter | What it does | |--------|-------------| | **Event types** | Show only events whose `type` matches one or more values. Choose from common types or type a custom value and press Enter. | | **Senders** | Show only events sent by the listed Matrix user IDs. | Common event types offered by the autocomplete: `m.room.message`, `m.room.member`, `m.room.name`, `m.room.topic`, `m.room.avatar`, `m.room.power_levels`, `m.room.join_rules`, `m.room.history_visibility`, `m.room.canonical_alias`, `m.room.encryption`, `m.room.redaction`, `m.room.third_party_invite`, `m.room.pinned_events`, `m.sticker`, `m.reaction` #### Advanced filters Click **Advanced filters** to expand the secondary filter section. | Filter | What it does | |--------|-------------| | **Exclude event types** | Hide events whose `type` matches any of the listed values. | | **Exclude senders** | Hide events sent by the listed Matrix user IDs. | | **Contains URL** | Filter by whether events contain a URL. Options: **Any** (no filter), **With URL only**, **Without URL only**. | Click **Apply** to reload with the current filters. Click **Clear** to reset all filters. > 💡 Basic and advanced filters combine — for example, show only `m.room.message` events while excluding a specific bot sender. --- ### 📅 Jump to Date The **Jump to date** field lets you navigate directly to any point in the room's history. The **Direction** selector controls which event is used as the anchor: | Value | Meaning | |-------|---------| | **Backward** | Find the closest event at or before the given timestamp | | **Forward** | Find the closest event at or after the given timestamp | After jumping, the target event is highlighted and the view centres on it. You can continue paginating in either direction from that point. > ⚠️ If no event exists near the specified timestamp, the viewer shows a warning and the message list is cleared. --- ### ⏩ Pagination | Control | What it does | |---------|-------------| | **Load newer** | Appends the next 20 newer events | | **Load older** | Appends the next 20 older events | --- ### 📖 How to browse room history 1. Open **Rooms** in the left navigation and click a room. 2. Select the **Messages** tab. 3. The viewer loads the most recent 20 events automatically. 4. Click **Load older** to go further back, or **Load newer** to move forward. ### 📅 How to jump to a specific date 1. Open the **Messages** tab. 2. Click **Filters** to expand the filter panel. 3. Fill in the **Jump to date** field. 4. Choose a **Direction** — **Backward** to land just before, **Forward** to land just after. 5. Click **Jump to date**. 6. The viewer reloads centred on the nearest matching event, highlighted. ### 🔎 How to filter by event type or sender 1. Open the **Messages** tab and click **Filters**. 2. In **Event types**, select or type the type(s) you want (press Enter for custom values). 3. In **Senders**, type a Matrix user ID and press Enter. 4. To exclude instead of include, expand **Advanced filters** and use **Exclude event types** / **Exclude senders**. 5. Click **Apply**. The **Filters** button shows the count of active filters. 6. Click **Clear** to reset. --- ## 🌳 Room Hierarchy | Light | Dark | |-------|------| | ![Room Hierarchy (light)](./screenshots/light/rooms-view-hierarchy.webp) | ![Room Hierarchy (dark)](./screenshots/dark/rooms-view-hierarchy.webp) | The **Hierarchy** tab is available only on **Space** rooms (`room_type: m.space`). It shows the full nested structure as an expandable tree. > 📝 Regular (non-Space) rooms do not have a Hierarchy tab. --- ### 🗂️ What is shown per room in the tree | Field | Description | |-------|-------------| | Name | Display name; falls back to the raw `room_id` if no name is set | | Room type chip | **Space** or **Room** — shown when `room_type` is set | | Member count | Number of joined members; hidden when zero | | Suggested badge | Green **Suggested** chip when the parent Space has marked this child as suggested | | Join rule chip | The room's join rule (e.g. `public`, `invite`, `knock`) | Rooms referenced in the hierarchy but not returned by the API are shown as greyed-out placeholder nodes. --- ### ⚙️ Max Depth The **Max depth** selector controls how many levels of nesting are fetched. | Value | Meaning | |-------|---------| | **Unlimited** | Full hierarchy, no depth limit (default) | | 1–10 | Limit to that many levels of nesting | > 💡 For very large Spaces, a lower max depth (e.g. 2 or 3) speeds up loading and reduces noise. --- ### 🔄 Expand / Collapse and Navigation - The first two levels expand automatically. - Click a node with children to toggle it. - Click a leaf room to navigate to its detail view. - Click **Refresh** to reload the hierarchy. - If the hierarchy is paginated, a **Load more** button appears at the bottom. --- ### 🧭 How to explore a Space hierarchy 1. Open **Rooms** and find the Space room. 2. Click it to open the detail view, then select the **Hierarchy** tab. 3. The tree loads with the first two levels expanded. 4. Expand branches as needed. Adjust **Max depth** for deeper or shallower traversal. 5. Click **Refresh** after making server-side changes. 6. Click any leaf room to navigate to its detail view. --- **See also:** [Media management](./media.md) · [User management](./user-management.md) · [Documentation index](./README.md) ================================================ FILE: docs/screenshots/README.md ================================================ # 📸 Screenshots Screenshots are organized by theme. Each section shows both light and dark variants where available. --- ## 🔐 Login | Light | Dark | | ------------------------------------ | ---------------------------------- | | ![Login (light)](./light/login.webp) | ![Login (dark)](./dark/login.webp) | --- ## 👥 Users List | Light | Dark | | ---------------------------------------------- | -------------------------------------------- | | ![Users List (light)](./light/users-list.webp) | ![Users List (dark)](./dark/users-list.webp) | --- ## 👤 User Edit | Light | Dark | | --------------------------------------------- | ------------------------------------------- | | ![User Edit (light)](./light/users-edit.webp) | ![User Edit (dark)](./dark/users-edit.webp) | --- ## 💬 Rooms List | Light | Dark | | ---------------------------------------------- | -------------------------------------------- | | ![Rooms List (light)](./light/rooms-list.webp) | ![Rooms List (dark)](./dark/rooms-list.webp) | --- ## 🏠 Room View | Light | | --------------------------------------------- | | ![Room View (light)](./light/rooms-view.webp) | --- ## 💬 Room Messages | Light | Dark | | ---------------------------------------------------------- | -------------------------------------------------------- | | ![Room Messages (light)](./light/rooms-view-messages.webp) | ![Room Messages (dark)](./dark/rooms-view-messages.webp) | --- ## 🌳 Room Hierarchy | Light | Dark | | ------------------------------------------------------------ | ---------------------------------------------------------- | | ![Room Hierarchy (light)](./light/rooms-view-hierarchy.webp) | ![Room Hierarchy (dark)](./dark/rooms-view-hierarchy.webp) | --- ## 📊 Room Statistics | Light | Dark | | ---------------------------------------------------- | -------------------------------------------------- | | ![Room Statistics (light)](./light/rooms-stats.webp) | ![Room Statistics (dark)](./dark/rooms-stats.webp) | --- ## ⏱️ Scheduled Tasks | Light | Dark | | -------------------------------------------------------- | ------------------------------------------------------ | | ![Scheduled Tasks (light)](./light/scheduled-tasks.webp) | ![Scheduled Tasks (dark)](./dark/scheduled-tasks.webp) | --- ## 👨‍💼 User Menu | Light | Dark | | -------------------------------------------- | ------------------------------------------ | | ![User Menu (light)](./light/user-menu.webp) | ![User Menu (dark)](./dark/user-menu.webp) | --- ## 🌟 etke.cc Exclusive Features ### 🟢 Server Status | Light | Dark | | --------------------------------------------------------- | ------------------------------------------------------- | | ![Server Status Page (light)](./light/server-status.webp) | ![Server Status Page (dark)](./dark/server-status.webp) | --- ### ⚡ Server Actions | Light | Dark | | ------------------------------------------------------ | ---------------------------------------------------- | | ![Server Actions (light)](./light/server-actions.webp) | ![Server Actions (dark)](./dark/server-actions.webp) | --- ### 🔔 Server Notifications | Light | Dark | | ------------------------------------------------------------------ | ---------------------------------------------------------------- | | ![Server Notifications (light)](./light/server-notifications.webp) | ![Server Notifications (dark)](./dark/server-notifications.webp) | --- ### 💳 Billing | Light | Dark | | --------------------------------------------- | ------------------------------------------- | | ![Billing (light)](./light/billing-list.webp) | ![Billing (dark)](./dark/billing-list.webp) | --- ### 💬 Support | Light | Dark | | ---------------------------------------------------------------------- | -------------------------------------------------------------------- | | ![Support — Create Ticket (light)](./light/support-create.webp) | ![Support — Create Ticket (dark)](./dark/support-create.webp) | | ![Support — Open Thread (light)](./light/support-thread-open.webp) | ![Support — Open Thread (dark)](./dark/support-thread-open.webp) | | ![Support — Closed Thread (light)](./light/support-thread-closed.webp) | ![Support — Closed Thread (dark)](./dark/support-thread-closed.webp) | ================================================ FILE: docs/screenshots/prepare.js ================================================ /** * Screenshot preparation script * * PURPOSE: * Replaces the real homeserver hostname in the Ketesa UI with a generic example value * so that screenshots taken for documentation do not expose real infrastructure details. * * HOW TO USE: * 1. Open the Ketesa UI in your browser and navigate to the page you want to screenshot. * 2. Open DevTools (F12 or Cmd+Option+I on Mac) and go to the Console tab. * 3. Paste this entire script and press Enter. * 4. The replacement is active immediately and stays active as React re-renders the page. * 5. Take your screenshot. * * WHY THIS IS NEEDED: * Ketesa displays the homeserver URL and Matrix user IDs (which include the server name) * throughout the UI. Screenshots taken directly would reveal the real hostname of the * server used for testing. This script replaces those values with "example.com" before * the screenshot is captured, keeping infrastructure details out of public documentation. * * NOTE: * The replacement is only visual — it does not change any application state or API calls. * Refreshing the page will reset it; re-paste the script if needed after navigation. */ (function () { const FROM = "test:8008"; const TO = "example.com"; function replaceInNode(node) { if (node.nodeType === Node.TEXT_NODE) { if (node.textContent.includes(FROM)) { node.textContent = node.textContent.replaceAll(FROM, TO); } } else if (node.nodeType === Node.ELEMENT_NODE) { // Also replace values in input and textarea elements if ((node.tagName === "INPUT" || node.tagName === "TEXTAREA") && node.value.includes(FROM)) { node.value = node.value.replaceAll(FROM, TO); } for (const child of node.childNodes) replaceInNode(child); } } // Initial pass over already-rendered DOM replaceInNode(document.body); // Watch for React re-renders and replace text in newly added or changed nodes const observer = new MutationObserver(mutations => { for (const m of mutations) { for (const node of m.addedNodes) replaceInNode(node); if (m.type === "characterData" && m.target.textContent.includes(FROM)) { m.target.textContent = m.target.textContent.replaceAll(FROM, TO); } } }); observer.observe(document.body, { childList: true, subtree: true, characterData: true }); console.log("🔁 Text replacement active: " + FROM + " → " + TO); })(); ================================================ FILE: docs/server-statistics.md ================================================ # 📊 Server Statistics & Scheduled Tasks Ketesa exposes three read-focused views that help administrators understand server health, storage consumption, and background job status without querying the database directly. --- ## 🗄️ Database Room Statistics | Light | Dark | |-------|------| | ![Room Statistics (light)](./screenshots/light/rooms-stats.webp) | ![Room Statistics (dark)](./screenshots/dark/rooms-stats.webp) | ### 📋 What it shows The **Statistics → Database Rooms** page lists every room that exists on your homeserver, ranked by its estimated footprint in the Synapse database. This helps you identify which rooms are consuming the most storage and decide whether to purge history, restrict membership, or take other remediation steps. > 📝 The size shown is an *estimate* produced by Synapse — it reflects database row sizes and may not map 1:1 to disk usage reported by your storage backend. ### 📐 Columns | Column | Description | |--------|-------------| | Avatar | Room avatar image | | `room_id` | Fully-qualified Matrix room ID | | `canonical_alias` | Human-readable alias for the room, if set | | `name` | Display name of the room | | `joined_members` | Number of currently joined members | | `estimated_size` | Estimated database storage consumed (formatted, e.g. `1.4 GB`) | ### 📤 Export An **Export** button is available in the toolbar. It is enabled only when data is present. The export downloads all rows in CSV format. ### 🔍 How to identify large rooms 1. Navigate to **Statistics → Database Rooms** in the left sidebar. 2. The list is returned sorted by `estimated_size` descending by default — the largest rooms appear first. 3. Click any row to open the full room detail page where you can inspect members, aliases, and history-purge options. --- ## 🖼️ User Media Statistics ### 📋 What it shows The **Statistics → User Media** page lists local users ordered by total media storage they have uploaded. Use this view to spot users with abnormally large media footprints before storage becomes a problem. > 📝 The default sort is `media_length` descending, so the heaviest users appear immediately without any manual sort step. ### 📐 Columns | Column | Description | |--------|-------------| | Avatar | User profile picture | | `user_id` | Fully-qualified Matrix user ID | | `displayname` | User's display name | | `media_count` | Number of media items uploaded by this user | | `media_length` | Total size of all uploaded media (formatted, e.g. `820 MB`) | | `is_guest` | Whether the account is a guest account | | `deactivated` | Whether the account has been deactivated | | `locked` | Whether the account is currently locked | | `erased` | Whether the account has been erased | ### 🎛️ Filters A **Search** input is always visible in the toolbar. It filters the list by user ID or display name. ### 🔧 Direct media management actions > 💡 The User Media statistics page has direct **Manage media** action buttons per user — you can jump straight to quarantine or delete a user's media without navigating to their profile first. See [Media management](./media.md). The toolbar contains two global media action buttons that apply across all listed users: - **Delete media** — permanently remove local media files. - **Purge remote media** — remove cached copies of media that originated on remote servers. Clicking a row navigates directly to that user's media management page (`/users//media`), where per-file actions are available. ### 📤 Export An **Export** button is available in the toolbar alongside the media action buttons. It is enabled only when data is present and downloads all rows in CSV format. ### 🔍 How to find users with excessive media 1. Navigate to **Statistics → User Media** in the left sidebar. 2. The list is already sorted by `media_length` descending — the users consuming the most storage appear first. 3. Use the **Search** filter to narrow by a specific user if needed. 4. Click a row to open that user's media management page, or use the toolbar **Delete media** / **Purge remote media** buttons to act immediately. --- ## ⏱️ Scheduled Tasks | Light | Dark | |-------|------| | ![Scheduled Tasks (light)](./screenshots/light/scheduled-tasks.webp) | ![Scheduled Tasks (dark)](./screenshots/dark/scheduled-tasks.webp) | ### 📋 What it is The **Scheduled Tasks** page is a read-only view of background jobs that Synapse registers and executes internally. Common examples include room history purges, user media cleanup, and federation catch-up tasks. Administrators cannot create or cancel tasks from this view — it is intended for inspection and debugging only. > ⚠️ This page reflects the state Synapse reports via its Admin API. Tasks that complete very quickly may already be gone from the list by the time you look. ### 📐 Fields | Field | Source key | Description | |-------|-----------|-------------| | ID | `id` | Internal numeric task identifier | | Action | `action` | Name of the background action being executed (e.g. `purge_history`) | | Status | `status` | Current lifecycle state — see status values below | | Timestamp | `timestamp_ms` | Date and time when the task was last updated | | Resource ID | `resource_id` | Matrix ID (room, user, etc.) the task is operating on | | Result | `result` | Structured JSON result payload returned on completion | | Error | `error` | Error message if the task failed | ### 🔴 Status values | Status | Colour | Meaning | |--------|--------|---------| | `scheduled` | grey | Task is queued and waiting to start | | `active` | blue | Task is currently running | | `complete` | green | Task finished successfully | | `cancelled` | yellow | Task was cancelled before completion | | `failed` | red | Task encountered an error and did not complete | ### 🔎 Filter options | Filter | Input type | Description | |--------|-----------|-------------| | Status | Dropdown (select) | Filter to tasks with a specific lifecycle state | | Action | Text | Filter by action name (e.g. `purge_history`) | | Resource ID | Text | Filter by the Matrix ID the task is associated with | | Max timestamp | Date/time picker | Show only tasks updated at or before this date and time | ### 💡 When to use scheduled tasks - **Debugging a stuck job** — filter by `status = active` and look for tasks that have been running longer than expected. - **Verifying a purge completed** — filter by action name (`purge_history`) and check for `status = complete` alongside the relevant `resource_id`. - **Diagnosing a failed background operation** — filter by `status = failed` and inspect the `error` field for details. - **Auditing recent activity** — use the **Max timestamp** filter together with a status filter to review what ran during a specific window. --- **See also:** [Media management](./media.md) · [Documentation index](./README.md) ================================================ FILE: docs/system-users.md ================================================ # 🤖 System / Appservice-managed Users Matrix bridges work by creating "puppet" accounts for every bridged user — a Telegram bridge, for example, will have one Matrix account for every Telegram contact that participates in bridged rooms. These accounts are entirely managed by the bridge appservice. Accidentally editing, deactivating, locking, or resetting the password of a puppet account will break the bridge for that user, often silently. Ketesa lets you define a list of MXID patterns to mark as appservice-managed. Once marked, these accounts are **protected from destructive changes** while still allowing harmless cosmetic edits (display name and avatar). **Protected operations** (blocked for system-managed users): - Deactivating or erasing the account - Locking / shadow-banning - Resetting the password - Changing admin status **Always allowed** (safe for bridges): - Updating display name - Updating avatar > 💡 **Recovery:** If a system-managed user was locked, deactivated, or erased by mistake (e.g., from a client app or using any other way), Ketesa will still allow you to restore it to an active state. ## 🔍 Filtering When `asManagedUsers` is configured, a **System users** filter appears in the users list. It allows you to: - **Exclude system** — hide system/appservice-managed users from the list - **Only system** — show only system/appservice-managed users The filtering is performed client-side with cached regex results for optimal performance. ## ⚙️ Configuration The examples below contain the configuration settings to mark [Telegram bridge (mautrix-telegram)](https://github.com/mautrix/telegram), [Slack bridge (mautrix-slack)](https://github.com/mautrix/slack), and [Baibot](https://github.com/etkecc/baibot) users of `example.com` homeserver as appservice-managed users. This illustrates the options to protect both specific MXIDs (as in the Baibot example) and all puppets of a bridge (as in the Telegram and Slack examples). [Configuration options](config.md) ### config.json ```json "asManagedUsers": [ "^@baibot:example\\.com$", "^@slackbot:example\\.com$", "^@slack_[a-zA-Z0-9\\-]+:example\\.com$", "^@telegrambot:example\\.com$", "^@telegram_[a-zA-Z0-9]+:example\\.com$" ] ``` ### `/.well-known/matrix/client` ```json "cc.etke.ketesa": { "asManagedUsers": [ "^@baibot:example\\.com$", "^@slackbot:example\\.com$", "^@slack_[a-zA-Z0-9\\-]+:example\\.com$", "^@telegrambot:example\\.com$", "^@telegram_[a-zA-Z0-9]+:example\\.com$" ] } ``` ================================================ FILE: docs/testdata/element/config.json ================================================ { "default_hs_url": "http://localhost:8008", "default_is_url": "https://vector.im", "integrations_ui_url": "https://scalar.vector.im/", "integrations_rest_url": "https://scalar.vector.im/api", "bug_report_endpoint_url": "https://riot.im/bugreports/submit", "enableLabs": true } ================================================ FILE: docs/testdata/element/nginx.conf ================================================ worker_processes 1; error_log /var/log/nginx/error.log warn; pid /tmp/nginx.pid; events { worker_connections 1024; } http { client_body_temp_path /tmp/client_body_temp; proxy_temp_path /tmp/proxy_temp; fastcgi_temp_path /tmp/fastcgi_temp; uwsgi_temp_path /tmp/uwsgi_temp; scgi_temp_path /tmp/scgi_temp; include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; sendfile on; keepalive_timeout 65; server { listen 8080; server_name localhost; location / { root /usr/share/nginx/html; index index.html index.htm; } error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; } } } ================================================ FILE: docs/testdata/mas/config.yaml ================================================ http: listeners: - name: web resources: - name: discovery - name: human - name: oauth - name: compat - name: graphql - name: assets - name: adminapi binds: - address: '[::]:8007' proxy_protocol: false - name: internal resources: - name: health binds: - host: localhost port: 8081 proxy_protocol: false trusted_proxies: - 192.168.0.0/16 - 172.16.0.0/12 - 10.0.0.0/10 - 127.0.0.1/8 - fd00::/8 - ::1/128 public_base: http://localhost:8007/ issuer: http://localhost:8007/ database: uri: postgresql://synapse:synapse@postgres:5432/mas max_connections: 10 min_connections: 0 connect_timeout: 30 idle_timeout: 600 max_lifetime: 1800 email: from: '"Authentication Service" ' reply_to: '"Authentication Service" ' transport: blackhole secrets: encryption: f6a14c18d5636c44a1edacb37c6956a16fd46e4a406d50f46ca0700eb25334ac keys: - key: | -----BEGIN RSA PRIVATE KEY----- MIIEogIBAAKCAQEAqmY3o83Q6v7i38VQN/8BY3ZmAQnPZnGhpIhXL5S9gJI+OJr1 pa4pT6YHvJplS6IcVhHgMFDiHt/p/kXuTyeXhkZsx/7hcnpmw0g+MF/sTY3Oxi8i 2POAKulmzuHQultdWtgZpFD0rqSwGp9UeQho+z7ewZGGdXhE1gwEFzTeUAVhGTir VpHTvqR2nRnmGbzMwINJl9c2cOXKD6av9MfvHVM47ezuGMfu9o/OUw2HvqUYSfvu esiEhERGmcJTQHZ6Z8bfEQpzr4Gw3ADp7rvxqyNIHqPe4hD/rVL4xxcqR56MnYkO Pfn5k40q9HSxWUA35vQFOtyenkHn+nbFPEBKwQIDAQABAoIBAEM2vL3NQppfXih8 UU81NFwCaOwB7aBwNB2Ndi6bkkBz7z0uyrTGxR7Y0/ZuRISX9mtw86i7TuZ01nzp Ir5wVJGvl9svy8f3Ri/DgFxeifxxcl20XB+NtUG3/UifxFCuF13tHfg1wr5c0eY+ cPio+5gXTZC3EX2mqihwZvCsdwDC6VFQOXbg35G98u2zF6OcZUVKgnVT5iSi8l29 G0m55PxYHaDG/Zi932aAobu1klP+eq4jmf2UUnL945VLOZM/cIZ9StO17+teHAJB NHDPqZKU6zyzGP5lw+7jIakBJ4UhdTm4CNyKSstOpF0ymZe//VXK52WpmugtevBD QNeE320CgYEAw6AZ3Eccs4ODmWax99Znyg1ZiyW+/RqTOs7zhWNvNpb8RgHvgrl0 yiIEXJXwWWmzDdfT3l71a6OyOXkJVJS3Zc4Zv0zKS4AUQsVRaeMvdC13Wr8tk+Ns 2oKcML8atfl0cOdAdeEFNdx4BKeQmMl9MRNd/R83PL2/HBpS9hCvKM8CgYEA3v0P sHWaRvSD+cQdyMiQvvylETnF9LRrRG3zkxMkYwVNxOn7wIUrRftBPU8y2SGrjh5l GmfqWVJ9fLZCJr5GTAcOP6dn4ZWdgUHM+WexvbqHrKvM3f+YITMAiGTO05GGNi5G RasMhK1fmlgcgUV7+PRm5FfOX+2jalgpQ8TtF28CgYBcatlswEel4v6bzPFMxYS4 5r+jxgxJZHGjubtAC+6xWnkJ1cZB+r1a9OEcoFUw2IeXhZv5FOFanbYIAs9OnwdZ WBm/z1ZOfs6TkMZagNKdTxFw1JqCoFF/lW6WdOH4kEXbRNSmG80rWeF1SXg9RgsX 0S948nNVBcswSptg1hb57wKBgH5wjfnn5VAAi+kPLDhSicjR5yNKWBX2S7Cki+3E d+hx9HQInQjAOhZXbtm9075NGkLet0Nu0vJNFPynOAFR+PhZM9oiKYQ/Wu0VC44M HzvOzem6DNOAf5mrmsy8JI7QwIJuYMhWQiXlTQVumtMuPCqhIsqtg0bfr764OMXF nw6fAoGAOC0kdOzIgRnKMa8u6MIbgXA+gxCt3dBcKdx4xUuET8eVC70EIdvpXVQL gYp/1Wiwc4mTCHKLJzVihV3ro5xlaFXlQDtXcMroiaRdLozStHwlSwujmWIGU2XO zYsJRX3Uxh/PX/sqr2KKnYUlK7RLk6zaKVbwbqVyfk4dzgbGXRk= -----END RSA PRIVATE KEY----- - key: | -----BEGIN EC PRIVATE KEY----- MHcCAQEEIDH3srryT6YwQXCaAb8/Rl/o4FxhrSBGCTmTxEgXueZOoAoGCCqGSM49 AwEHoUQDQgAEMpz/4UhScbKhDQFwTersMqV0kZMWrzmxGpt7gDV4oOh6DxXopoY1 nZtAA0lb6kLyHeMRCmFhur16q9UhMLtOng== -----END EC PRIVATE KEY----- - key: | -----BEGIN EC PRIVATE KEY----- MIGkAgEBBDAMSMpErTzHV3fWugQGHdIMs9Udjvv3Tap+ORlFpAmT5QwqX3v7KTyQ 1br33HaNHnOgBwYFK4EEACKhZANiAAT249g/MWaq/pyJSpGHv8FsFUxQ0u6tvL6t /ui8V2ArC0v6D5V9noxK0n7pLCCIA4w4sAypLABA5bfzzgX8ZsLw8YzWUF7dUVNZ kEuRbJNxKMUCwwiuJM3BQvqL7cUOiZc= -----END EC PRIVATE KEY----- - key: | -----BEGIN EC PRIVATE KEY----- MHQCAQEEIG+pddhUkZkSP+mJUE9gKdoV3gJx8WSx3VF3ySIHzeqcoAcGBSuBBAAK oUQDQgAE1014gdRlZpBuc5VWChIPRsbCY3fxZSNr99dxhtWOL7TndvuvIgvWuB5Z X6XSFRFiHRl6qp3kY8/N+CkNKK+fWg== -----END EC PRIVATE KEY----- passwords: enabled: true schemes: - version: 1 algorithm: argon2id minimum_complexity: 0 matrix: kind: synapse homeserver: "synapse:8008" secret: ukIYOk4T7sz7VlrOPhZRbgzHHR4wXfir endpoint: "http://synapse:8008/" upstream_oauth2: providers: - id: 01JNQFH8BKACTJ7ME4TQ4D6NSS human_name: Mock OIDC issuer: http://mock-oidc:8006/default client_id: mock-client client_secret: mock-secret token_endpoint_auth_method: client_secret_post scope: "openid email profile" discovery_mode: disabled authorization_endpoint: http://localhost:8006/default/authorize token_endpoint: http://mock-oidc:8006/default/token jwks_uri: http://mock-oidc:8006/default/jwks claims_imports: subject: template: "{{ user.sub }}" localpart: action: force template: "{{ user.preferred_username }}" displayname: action: suggest template: "{{ user.name }}" email: action: suggest template: "{{ user.email }}" set_email_verification: always policy: data: client_registration: allow_insecure_uris: true ================================================ FILE: docs/testdata/nginx/nginx.conf ================================================ server { listen 8008; listen [::]:8008; # For the federation port listen 8448 default_server; listen [::]:8448 default_server; server_name localhost; resolver 127.0.0.11 valid=30s; location ~ ^(/_matrix/client/(.*)/(login|logout|refresh)|/oauth2|/.well-known/openid-configuration|/authorize|/login|/assets|/consent|/logout) { proxy_http_version 1.1; set $mas_upstream http://mas:8007; proxy_pass $mas_upstream; # OR via the Unix domain socket #proxy_pass http://unix:/var/run/mas.sock; # Forward the client IP address proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # or, using the PROXY protocol #proxy_protocol on; } location ~ ^(/_matrix|/_synapse) { # note: do not add a path (even a single /) after the port in `proxy_pass`, # otherwise nginx will canonicalise the URI and cause signature verification # errors. set $synapse_upstream http://synapse:8008; proxy_pass $synapse_upstream; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; # Nginx by default only allows file uploads up to 1M in size # Increase client_max_body_size to match max_upload_size defined in homeserver.yaml client_max_body_size 50M; # Synapse responses may be chunked, which is an HTTP/1.1 feature. proxy_http_version 1.1; } location = /admin { return 301 /admin/; } location /admin/ { root /var/www/html; index index.html; try_files $uri $uri/ /admin/index.html; } location ~* ^/admin/.*\.(?:css|js|jpg|jpeg|gif|png|svg|ico|woff|woff2|ttf|eot|webp)$ { root /var/www/html; expires 30d; add_header Cache-Control "public"; } } ================================================ FILE: docs/testdata/postgres.initdb/mas.sql ================================================ CREATE DATABASE mas; GRANT ALL PRIVILEGES ON DATABASE mas TO synapse; ================================================ FILE: docs/testdata/synapse/homeserver.yaml ================================================ account_threepid_delegates: msisdn: '' alias_creation_rules: - action: allow alias: '*' room_id: '*' user_id: '*' allow_guest_access: false allow_public_rooms_over_federation: true allow_public_rooms_without_auth: true app_service_config_files: [] autocreate_auto_join_rooms: true background_updates: null caches: global_factor: 0.5 per_cache_factors: null cas_config: null matrix_authentication_service: enabled: true endpoint: http://mas:8007 secret: ukIYOk4T7sz7VlrOPhZRbgzHHR4wXfir database: args: cp_max: 10 cp_min: 5 database: synapse host: postgres password: synapse port: 5432 user: synapse name: psycopg2 txn_limit: 0 default_room_version: '10' disable_msisdn_registration: true email: enable_media_repo: true enable_metrics: false enable_registration: false enable_registration_captcha: false enable_registration_without_verification: false enable_room_list_search: true encryption_enabled_by_default_for_room_type: 'off' event_cache_size: 100K federation_rr_transactions_per_room_per_second: 50 form_secret: sLKKoFMsQUZgLAW0vU1PQQ8ca1POGMDheurGtKW0uJ20iGqtxR9O7JQ6Knvs44Wi include_profile_data_on_invite: true instance_map: {} limit_profile_requests_to_users_who_share_rooms: false limit_remote_rooms: null listeners: - bind_addresses: - '::' port: 8008 resources: - compress: false names: - client tls: false type: http x_forwarded: true log_config: /config/synapse.log.config macaroon_secret_key: Lg8DxGGfy95J367eVJZHLxmqP9XtN4FKdKxWpPvBS3mhviq9at8sw7KHRPkGmyqE manhole_settings: null max_spider_size: 10M max_upload_size: 1024M media_retention: local_media_lifetime: 30d remote_media_lifetime: 7d media_storage_providers: [] media_store_path: /media-store metrics_flags: null modules: [] oembed: null oidc_providers: null old_signing_keys: null opentracing: null pid_file: /homeserver.pid presence: enabled: true public_baseurl: http://localhost:8008/ push: include_content: true rc_admin_redaction: burst_count: 50 per_second: 1 rc_federation: concurrent: 3 reject_limit: 50 sleep_delay: 500 sleep_limit: 10 window_size: 1000 rc_invites: per_issuer: burst_count: 10 per_second: 0.3 per_room: burst_count: 10 per_second: 0.3 per_user: burst_count: 5 per_second: 0.003 rc_joins: local: burst_count: 10 per_second: 0.1 remote: burst_count: 10 per_second: 0.01 rc_login: account: burst_count: 3 per_second: 0.17 address: burst_count: 3 per_second: 0.17 failed_attempts: burst_count: 3 per_second: 0.17 rc_message: burst_count: 10 per_second: 0.2 rc_registration: burst_count: 3 per_second: 0.17 recaptcha_private_key: '' recaptcha_public_key: '' redaction_retention_period: 5m redis: enabled: false host: null password: null port: 6379 registration_requires_token: false registration_shared_secret: jBUKJozByo8s3bvKtYFpB350ZAnxGlzXsDpAZkgOFJuQfKAFHhqbc2dw8D54u4T9 report_stats: false require_auth_for_profile_requests: false retention: enabled: true purge_jobs: - interval: 12h room_list_publication_rules: - action: allow alias: '*' room_id: '*' user_id: '*' room_prejoin_state: null saml2_config: sp_config: null user_mapping_provider: config: null server_name: synapse:8008 signing_key_path: /config/synapse.signing.key spam_checker: [] sso: null stats: null stream_writers: {} templates: null tls_certificate_path: null tls_private_key_path: null trusted_key_servers: - server_name: matrix.org turn_allow_guests: false ui_auth: null url_preview_accept_language: - en-US - en url_preview_enabled: true url_preview_ip_range_blacklist: - 127.0.0.0/8 - 10.0.0.0/8 - 172.16.0.0/12 - 192.168.0.0/16 - 100.64.0.0/10 - 192.0.0.0/24 - 169.254.0.0/16 - 192.88.99.0/24 - 198.18.0.0/15 - 192.0.2.0/24 - 198.51.100.0/24 - 203.0.113.0/24 - 224.0.0.0/4 - ::1/128 - fe80::/10 - fc00::/7 - 2001:db8::/32 - ff00::/8 - fec0::/10 user_directory: null user_ips_max_age: 5m ================================================ FILE: docs/testdata/synapse/synapse.log.config ================================================ version: 1 formatters: precise: format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' filters: context: (): synapse.util.logcontext.LoggingContextFilter request: "" handlers: console: class: logging.StreamHandler formatter: precise filters: [context] loggers: synapse: level: INFO shared_secret_authenticator: level: INFO rest_auth_provider: level: INFO synapse.storage.SQL: # beware: increasing this to DEBUG will make synapse log sensitive # information such as access tokens. level: INFO root: level: INFO handlers: [console] ================================================ FILE: docs/testdata/synapse/synapse.signing.key ================================================ ed25519 a_FswB rsh+VxdR4YUv6rFM6393VmSEJJxzaDrdwlVwLe2rcRo ================================================ FILE: docs/update-api-docs.ts ================================================ #!/usr/bin/env node import { spawn } from "child_process"; import fs from "fs"; import path from "path"; import https from "https"; import { promisify } from "util"; import stream from "stream"; const pipeline = promisify(stream.pipeline); // ---------- CONFIG ---------- const MAS_URL = "https://element-hq.github.io/matrix-authentication-service/api/spec.json"; const MAS_DEST = "docs/apis/mas.json"; const SYNAPSE_REPO = "https://github.com/element-hq/synapse.git"; const SYNAPSE_BRANCH = "develop"; const TMP_DIR = ".tmp_synapse_repo"; const SYNAPSE_SRC = "docs/admin_api"; const SYNAPSE_DEST = "docs/apis/synapse"; const MATRIX_SPEC_REPO = "https://github.com/matrix-org/matrix-spec.git"; const MATRIX_SPEC_BRANCH = "main"; const TMP_DIR_MATRIX_SPEC = ".tmp_matrix_spec_repo"; // Extract only the OpenAPI data directory — all endpoint YAML definitions live here. // data/api/ — client-server, server-server, push-gateway, appservice, identity OpenAPI specs // data-definitions/ — shared JSON schema types referenced by the API specs const MATRIX_SPEC_SRCS = ["data/api", "data-definitions"]; const MATRIX_SPEC_DEST = "docs/apis/matrix-spec"; // ---------- UI ---------- const c = { blue: "\x1b[34m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", reset: "\x1b[0m", }; const log = (m: string) => console.log(`${c.blue}[INFO]${c.reset} ${m}`); const ok = (m: string) => console.log(`${c.green}[OK]${c.reset} ${m}`); const warn = (m: string) => console.log(`${c.yellow}[WARN]${c.reset} ${m}`); const fail = (m: string) => console.error(`${c.red}[ERROR]${c.reset} ${m}`); // ---------- UTILS ---------- const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); function ensureDir(p: string) { fs.mkdirSync(p, { recursive: true }); } // ---------- RETRY ---------- async function retry(fn: () => Promise, label: string, retries = 5): Promise { let attempt = 0; while (true) { try { return await fn(); } catch (err) { attempt++; if (attempt >= retries) { throw new Error(`${label} failed after ${retries} attempts`, { cause: err }); } const delay = 500 * 2 ** (attempt - 1); warn(`${label} failed: ${err.message}`); warn(`Retrying in ${delay}ms (${attempt + 1}/${retries})...`); await sleep(delay); } } } // ---------- DOWNLOAD WITH REDIRECT + TIMEOUT ---------- function download(url: string, dest: string): Promise { return new Promise((resolve, reject) => { const tmp = dest + ".tmp"; const request = https.get(url, { timeout: 30000 }, res => { // redirect if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { return resolve(download(res.headers.location, dest)); } if (!res.statusCode || res.statusCode >= 400) { return reject(new Error(`HTTP ${res.statusCode} for ${url}`)); } const file = fs.createWriteStream(tmp); pipeline(res, file) .then(() => { fs.renameSync(tmp, dest); resolve(); }) .catch(reject); }); request.on("timeout", () => { request.destroy(); reject(new Error("Request timed out")); }); request.on("error", reject); }); } // ---------- COPY (portable) ---------- function copyDir(src: string, dest: string) { ensureDir(dest); for (const entry of fs.readdirSync(src, { withFileTypes: true })) { const s = path.join(src, entry.name); const d = path.join(dest, entry.name); if (entry.isDirectory()) { copyDir(s, d); } else { fs.copyFileSync(s, d); } } } // ---------- GIT ---------- function runGit(args: string[]) { return new Promise((resolve, reject) => { const p = spawn("git", args, { stdio: "inherit" }); p.on("close", code => { if (code === 0) resolve(); else reject(new Error(`git ${args.join(" ")} failed (${code})`)); }); }); } // ---------- STEP 1 ---------- async function stepMAS() { log("Downloading MAS spec"); ensureDir(path.dirname(MAS_DEST)); await retry(async () => { await download(MAS_URL, MAS_DEST); // validate JSON const content = fs.readFileSync(MAS_DEST, "utf-8"); JSON.parse(content); }, "MAS download"); ok(`Saved → ${MAS_DEST}`); } // ---------- STEP 2 ---------- async function stepSynapse() { log("Fetching Synapse docs"); ensureDir(path.dirname(SYNAPSE_DEST)); await retry(async () => { if (fs.existsSync(TMP_DIR)) { fs.rmSync(TMP_DIR, { recursive: true, force: true }); } await runGit(["clone", "--depth", "1", "--branch", SYNAPSE_BRANCH, SYNAPSE_REPO, TMP_DIR]); }, "Git clone"); const src = path.join(TMP_DIR, SYNAPSE_SRC); if (!fs.existsSync(src)) { throw new Error(`Missing path: ${SYNAPSE_SRC}`); } if (fs.existsSync(SYNAPSE_DEST)) { fs.rmSync(SYNAPSE_DEST, { recursive: true, force: true }); } copyDir(src, SYNAPSE_DEST); fs.rmSync(TMP_DIR, { recursive: true, force: true }); ok(`Saved → ${SYNAPSE_DEST}`); } // ---------- STEP 3 ---------- async function stepMatrixSpec() { log("Fetching Matrix spec"); ensureDir(path.dirname(MATRIX_SPEC_DEST)); await retry(async () => { if (fs.existsSync(TMP_DIR_MATRIX_SPEC)) { fs.rmSync(TMP_DIR_MATRIX_SPEC, { recursive: true, force: true }); } await runGit(["clone", "--depth", "1", "--branch", MATRIX_SPEC_BRANCH, MATRIX_SPEC_REPO, TMP_DIR_MATRIX_SPEC]); }, "Git clone"); if (fs.existsSync(MATRIX_SPEC_DEST)) { fs.rmSync(MATRIX_SPEC_DEST, { recursive: true, force: true }); } ensureDir(MATRIX_SPEC_DEST); for (const src of MATRIX_SPEC_SRCS) { const srcPath = path.join(TMP_DIR_MATRIX_SPEC, src); if (!fs.existsSync(srcPath)) { warn(`Missing path in matrix-spec: ${src} — skipping`); continue; } // Copy each source subdirectory directly into MATRIX_SPEC_DEST, // preserving the original directory name (e.g. data/api → matrix-spec/api). const destName = path.basename(src); copyDir(srcPath, path.join(MATRIX_SPEC_DEST, destName)); } fs.rmSync(TMP_DIR_MATRIX_SPEC, { recursive: true, force: true }); ok(`Saved → ${MATRIX_SPEC_DEST}`); } // ---------- MAIN ---------- (async () => { console.log("=== Matrix API Docs Updater ===\n"); try { await stepMAS(); console.log(); await stepSynapse(); console.log(); await stepMatrixSpec(); console.log(); ok("All done!"); } catch (e) { fail(e.message); process.exit(1); } })(); ================================================ FILE: docs/user-badges.md ================================================ # 🏷️ User Badges Ketesa displays role badges on user avatars throughout the interface — in the users list, the user detail view, and anywhere an avatar appears. Each badge has a tooltip explaining what it means, and multiple badges can appear on a single avatar when a user has more than one role (e.g., a bot that is also system-managed). This makes it easy to identify account types at a glance without opening individual user records — especially useful when managing large servers with many bridge puppets, bots, and federated users. | Badge | Who it appears on | |-------|------------------| | 🧙 **You** | Your own account | | 👑 **Admin** | Homeserver administrators | | 🛡️ **Appservice/System-managed** | Bridge puppets and other [appservice-managed accounts](./system-users.md) | | 🤖 **Bot** | Accounts with `user_type: bot` | | 📞 **Support** | Accounts with `user_type: support` | | 👤 **Regular User** | Standard local accounts | | 🌐 **Federated** | Remote users from other homeservers | ## 📌 Available Badges ### 🧙 You This badge is displayed on your user's avatar. The tooltip for this badge contains additional information, e.g.: `You (Admin)`. ### 👑 Admin This badge is displayed on homeserver admins' avatars. Tooltip: `Admin`. ### 🛡️ Appservice/System-managed This badge is displayed on users that are managed by an appservice (or system). [Learn more about system users](./system-users.md). The tooltip for this badge contains additional information, e.g.: `System-managed (Bot)`. ### 🤖 Bot This badge is displayed on bots' avatars (users with the `user_type` set to `bot`). Tooltip: `Bot`. ### 📞 Support This badge is displayed on users that are part of the support team (users with the `user_type` set to `support`). Tooltip: `Support`. ### 👤 Regular User This badge is displayed on regular users' avatars. Tooltip: `Regular User`. ### 🌐 Federated (Remote) User This badge is displayed on federated (remote) users' avatars. Tooltip: `Federated User`. ================================================ FILE: docs/user-management.md ================================================ # 👤 User Management | Light | Dark | |-------|------| | ![Users List (light)](./screenshots/light/users-list.webp) | ![Users List (dark)](./screenshots/dark/users-list.webp) | This guide covers all user management features available in Ketesa, from basic account control to advanced MAS-integrated session and email management. --- ## 📋 Contents - [Login as user](#-login-as-user) - [Deactivation vs erasure](#-deactivation-vs-erasure) - [Shadow ban](#-shadow-ban) - [Rate limits](#-rate-limits) - [Experimental features](#-experimental-features) - [Account data](#-account-data) - [Server notices](#-server-notices) - [MAS user management](#-mas-user-management) - [MAS Policy Data](#-mas-policy-data) - [Bulk user import](#-bulk-user-import) --- ## 🔑 Login as user | Light | Dark | |-------|------| | ![User Edit (light)](./screenshots/light/users-edit.webp) | ![User Edit (dark)](./screenshots/dark/users-edit.webp) | The **Login as user** button appears in the top toolbar of the user edit page (only when the user is not deactivated and MAS is not configured). Clicking it generates a short-lived access token scoped to that user account, allowing an administrator to act on their behalf — this is commonly called impersonation. **When to use:** - Reproducing a bug that only manifests for a specific account. - Providing hands-on support for a user who cannot access their account. - Verifying that a permission or room configuration change took effect from the user's perspective. > ⚠️ The generated token carries the full privileges of the target user. Treat it as a sensitive credential: do not share it, do not store it, and revoke it (log out) as soon as the support task is complete. > 📝 This feature is only available in native Synapse mode. When `externalAuthProvider` is set and MAS is active, use the MAS password reset or personal-session creation workflow instead. --- ## 🚫 Deactivation vs erasure Ketesa exposes two levels of account termination. Both are accessible via the **Danger Zone** panel on the user edit page and via the bulk delete button on the user list. | Action | What it does | Reversible? | |--------|-------------|-------------| | **Deactivate** | Disables login, invalidates all access tokens, and kicks the user from all rooms. The account record and message history are preserved. | ✅ Yes — re-enable by unchecking **Deactivated** and setting a new password. | | **Erase** | Performs deactivation and additionally requests that Synapse purge the user's messages and media from rooms (subject to server configuration). | ❌ No — message and media removal cannot be undone. | > ⚠️ The **Erased** checkbox only becomes active once **Deactivated** is checked. Unchecking **Erased** on an already-erased account will also uncheck **Deactivated**, effectively reactivating the account record (message content that was already purged is not restored). > 📝 You cannot deactivate or erase your own admin account. Ketesa prevents this both in the single-user edit view and in bulk actions. --- ## 👁️ Shadow ban A shadow-banned user's outgoing messages are accepted by the server but never distributed to other users. From the banned user's perspective everything appears normal; other participants simply never receive their messages. **When to use shadow banning instead of deactivation:** - Silencing a spam or abuse account without alerting the operator. - Giving a moderation window to investigate without provoking escalation. **How to apply:** On the user edit page, enable the **Shadow banned** toggle in the moderation section and save. **How to remove:** Disable the same toggle and save. The user's subsequent messages will be distributed normally; messages sent while shadow-banned are not retroactively delivered. > ⚠️ As of Synapse v1.149.1, the user list filter for shadow-banned users does not function correctly — it returns all users rather than only shadow-banned ones. The column in the list view is accurate; the filter is disabled in the UI until upstream support lands. --- ## ⏱️ Rate limits Ketesa allows per-user rate limit overrides, which take precedence over the server-wide defaults defined in `homeserver.yaml`. ### 📐 Fields | Field | What it controls | |-------|-----------------| | `messages_per_second` | The sustained rate of messages the user may send, expressed as messages per second. | | `burst_count` | The number of messages the user may send in a burst before the sustained rate limit applies. | ### ✏️ How to override 1. Open the user edit page. 2. Navigate to the **Rate limits** tab (or scroll to the rate limits section). 3. Enter numeric values for `messages_per_second` and/or `burst_count`. 4. Click **Save**. ### 🔄 How to return to server default Clear both fields (leave them empty) and save. When no per-user override is stored, Synapse falls back to the server-wide rate limit configuration. > 💡 Rate limit overrides are useful for bot accounts or integration users that legitimately send more messages than a regular user would. --- ## 🧪 Experimental features Ketesa exposes per-user toggles for Synapse Matrix Spec Change (MSC) experimental features. These are enabled or disabled individually per account and take effect immediately on save — no server restart is required. | MSC identifier | What it enables | |---------------|----------------| | `msc3881` | Remotely toggling push notifications for another client | | `msc3575` | Experimental sliding sync support | > 📝 These flags are per-user. Enabling a flag for one account has no effect on other accounts. Server-wide MSC enablement is controlled through `homeserver.yaml` experimental flags, not through Ketesa. > ⚠️ Experimental features may change or be removed as the Matrix spec evolves. Enable them only when a specific client integration requires it. --- ## 📂 Account data The **Account data** tab on the user edit page displays a read-only JSON view of the account data stored for that user by Synapse. This is the same data accessible via the Matrix `/account_data` client API endpoint. Two scopes are shown, each in a collapsible accordion: | Scope | What it contains | |-------|-----------------| | **Global** | Account-wide key-value data not associated with any specific room. Common entries include push rules (`m.push_rules`), identity server preferences, and client-specific settings. | | **Rooms** | A map of room IDs to per-room account data for that user. Common entries include room tags (`m.tag`), read markers, and room-specific notification overrides. | **When this is useful:** - Diagnosing unexpected push notification behaviour by inspecting `m.push_rules`. - Verifying that a client has correctly stored or migrated user preferences. - Checking whether a user has applied custom tags or notification settings to specific rooms. > 📝 Account data is read-only in the Ketesa UI. Editing it directly requires the Synapse Admin API or a Matrix client with the appropriate access token. --- ## 📣 Server notices Server notices are administrative broadcast messages delivered to users as Matrix messages in a dedicated system room. They appear in the user's client like any other message but originate from the server's notices bot account. ### 📬 Single-user notice To send a notice to one specific user: 1. Open the user's edit page. 2. Click the **Send server notice** button in the top toolbar. 3. A dialog opens. Enter the notice message body in the text area. 4. Click **Send**. The notice is delivered to the user's server notices room immediately. ### 📢 Bulk notice To send the same notice to multiple users at once: 1. Navigate to the **Users** list. 2. Select the target users using the checkboxes in the list. 3. Click the **Send server notice** button in the bulk actions toolbar that appears at the bottom of the page. 4. A dialog opens. Enter the notice message body. 5. Click **Send**. Ketesa sends the notice to every selected user individually; each user receives a message in their own server notices room. > ⚠️ Server notices require the `server_notices` feature to be configured in `homeserver.yaml`. If the feature is not configured, the send request will fail with an error. > 💡 Server notices are a one-way channel — users cannot reply in a way that reaches the admin. For two-way communication, contact the user through a normal Matrix room. --- ## 🔗 MAS user management > 📝 The following features only appear when `externalAuthProvider` is enabled and [Matrix Authentication Service (MAS)](./external-auth-provider.md) is configured. When MAS is active, Ketesa integrates with the MAS Admin API to provide account lifecycle management, session control, email management, and upstream OAuth link management directly within the user edit page. ### ➕ Creating users through MAS When MAS is configured, creating a user is a two-step process: **Step 1 — MAS create form:** A focused form with three fields: | Field | Required | Notes | |---|---|---| | `username` | Yes | The local part of the MXID (no `@` or homeserver suffix). | | `password` | No | Optional initial password. Use the generate button for a strong random password. | | `admin` | No | Grant server administrator status immediately on creation. | On save, MAS creates the account and Synapse provisions the corresponding user record automatically. **Step 2 — Full edit page:** After the account is created you are redirected to the standard user edit page, where you can set the displayname, avatar, threepids, and all other profile details. ### 🔐 Setting a password via MAS The **Set password** button (shown in the top toolbar instead of the standard reset-password button) opens a dialog to set a new password for the user through the MAS API. This is the correct path for password changes when MAS handles authentication. ### 🔄 Account state: deactivate, reactivate, lock, unlock MAS introduces its own account state model alongside Synapse's: | State | Behaviour | How to toggle | |-------|-----------|--------------| | **Deactivated** | Prevents login via MAS; the account is disabled. Displayed with a timestamp chip showing when deactivation occurred. | Toggle the **Deactivated** checkbox in the Danger Zone panel. | | **Locked** | Temporarily prevents login without fully deactivating the account. Displayed with a timestamp chip showing when the lock was applied. | Toggle the **Locked** checkbox in the moderation section. | > 📝 MAS-level deactivation and locking are distinct from Synapse-level deactivation. Synapse deactivation removes the user from rooms and purges tokens; MAS deactivation/locking controls whether the user can authenticate through MAS. Both can be active simultaneously. ### 🖥️ Sessions panel The **Sessions** tab (only shown in MAS mode) provides a sub-tabbed view of all active and historical sessions for the user. Each session type can be terminated from within the panel. | Session type | What it represents | How to revoke | |-------------|-------------------|--------------| | **Personal sessions** | Long-lived API tokens created by admins or by the user directly (e.g. for bots or automation). Shows scope, human name, active status, and expiry. | Click the revoke button on the session row. | | **Browser sessions** | Interactive browser-based login sessions tracked by MAS. Shows IP address, user agent, last active time. | Click the finish button on the session row. | | **OAuth2 sessions** | Sessions established via OAuth 2.0 client applications. Shows client ID, granted scopes (displayed as chips), human name, and last active time. | Click the finish button on the session row. | | **Compat sessions** | Legacy Matrix compatibility sessions that bridge the old Synapse login flow with MAS. Shows device ID, human name, last active IP. | Click the finish button on the session row. | Admins can also create new personal sessions directly from the Personal sessions tab by filling in the name, scope, and optional expiry fields and clicking **Create**. The generated access token is shown once in a dialog — copy it before closing, as it cannot be retrieved again. > ⚠️ Revoking or finishing a session immediately invalidates the associated access token. Any client using that token will be logged out. ### 📧 Emails panel The **3PIDs / Emails** tab in MAS mode shows all email addresses linked to the user's MAS account. - **View:** All linked emails are listed with their registration date. - **Add:** Enter an email address in the field at the bottom of the panel and click **Add**. - **Remove:** Click the remove button on the email row. The email is unlinked from the MAS account immediately. > 📝 Email addresses managed here are stored in MAS, not in Synapse's `threepids` table. Changes take effect in MAS and are reflected in the user's authentication options. ### 🔗 Upstream OAuth links panel The **SSO / Upstream OAuth** tab in MAS mode displays links between the user's MAS account and external OAuth providers (e.g. Google, GitHub, a corporate IdP) that have been configured in MAS. - **View:** All upstream OAuth links are listed, showing the provider ID, subject identifier, and human account name (if set). - **Remove:** Click the delete button on the link row. > ⚠️ Removing an upstream OAuth link means the user can no longer log in using that external provider unless the link is re-created. Ensure the user has an alternative login method (password or another provider) before removing a link. ### 📋 MAS Policy Data The **Policy Data** page (accessible from the MAS section of the sidebar) allows administrators to view and update the MAS consent policy. - **Current policy:** Displays the active policy as formatted JSON, with its creation timestamp. If no policy has been set, a "No policy is currently set" message is shown. - **Set a New Policy:** Enter new policy data as a JSON object in the editor and click **Set Policy**. The editor validates JSON on the fly — the save button is disabled until the input is valid JSON. > ⚠️ Setting a new policy replaces the existing one immediately. MAS users may be prompted to accept the updated policy terms on their next login. --- ## 📥 Bulk user import Ketesa supports creating multiple user accounts at once from a CSV file. The import is accessible from the **CSV Import** button in the Users list toolbar. For the full specification of the CSV format, required and optional columns, and error handling behaviour, see [CSV import](./csv-import.md). --- **See also:** [CSV import](./csv-import.md) · [System users](./system-users.md) · [External auth provider](./external-auth-provider.md) · [User search](./user-search.md) · [Documentation index](./README.md) ================================================ FILE: docs/user-search.md ================================================ # 🔍 User Search The Users list includes a search field that filters by MXID (user ID) and display name. ## Normal search Type a term (e.g. `bot`) to show only users whose MXID or display name **contains** that term. ## Reverse search Prefix the term with `!` (e.g. `!bot`) to show users whose MXID and display name **do not contain** the term. The search field will display a ⏳ hourglass icon when reverse search is active to indicate the operation may take longer than a regular search — the server must be fully scanned to exclude matches. > 💡 Reverse search works in both native Synapse and MAS-backed deployments. ================================================ FILE: docs/well-known-discovery.md ================================================ # 🔍 Well-Known Discovery By default, Ketesa resolves the homeserver URL you enter on the login page via `/.well-known/matrix/client`, replacing it with the `m.homeserver.base_url` value advertised by the server. This follows the [Matrix spec](https://spec.matrix.org/v1.17/client-server-api/#getwell-knownmatrixclient) and is the correct behavior for most setups. The same lookup runs when you type a full Matrix ID (e.g. `@user:example.com`) — Ketesa fetches well-known for `example.com` and auto-fills the homeserver URL field. ## When to disable Some deployments restrict access to `/_synapse/admin` to a separate domain that is **not** advertised in well-known — for example, a VPN-only endpoint. In these cases the automatic URL rewrite replaces your intended admin domain with the public Matrix URL, making it impossible to connect. Setting `wellKnownDiscovery: false` disables this rewrite: - The homeserver URL field is **not** modified after entry — what you type is what gets used. - When you type a full Matrix ID, the homeserver URL is derived directly from the MXID domain (`https://`) without a well-known lookup. > 📝 **Note:** This only affects URL canonicalization on the login page. Ketesa configuration (e.g. `restrictBaseUrl`, `menu`) is still loaded from `/.well-known/matrix/client` as usual. ## ⚙️ Configuration `wellKnownDiscovery` accepts a boolean value: | Value | Behavior | |---|---| | `true` (default) | Resolve homeserver URL via `/.well-known/matrix/client` | | `false` | Use the entered URL as-is, skip well-known lookup | [Configuration options](config.md) ### config.json ```json { "wellKnownDiscovery": false } ``` ### `/.well-known/matrix/client` ```json { "cc.etke.ketesa": { "wellKnownDiscovery": false } } ``` ================================================ FILE: eslint.config.js ================================================ import js from "@eslint/js"; import jsxA11y from "eslint-plugin-jsx-a11y"; import importPlugin from "eslint-plugin-import"; import prettierPlugin from "eslint-plugin-prettier"; import reactHooks from "eslint-plugin-react-hooks"; import unusedImports from "eslint-plugin-unused-imports"; import globals from "globals"; import tseslint from "typescript-eslint"; import tsEslintPlugin from "@typescript-eslint/eslint-plugin"; const baseRule = tsEslintPlugin.rules["consistent-generic-constructors"]; const tsCompat = { rules: { "consistent-generic-constructors": { meta: baseRule.meta, create(context) { const proxyContext = Object.create(context); proxyContext.parserOptions = { isolatedDeclarations: false, ...(context.parserOptions ?? {}) }; return baseRule.create(proxyContext); }, }, }, }; export default [ { ignores: ["coverage/", "dist/", "docs/testdata/", "docs/screenshots/prepare.js", "src/assets/webfonts/**"], }, js.configs.recommended, ...tseslint.configs.recommended.map(config => ({ ...config, files: ["**/*.ts", "**/*.tsx"], languageOptions: { ...config.languageOptions, parser: tseslint.parser, parserOptions: { projectService: { allowDefaultProject: ["vite.config.ts", "docs/*.ts"] }, }, }, })), ...tseslint.configs.stylistic.map(config => ({ ...config, files: ["**/*.ts", "**/*.tsx"], languageOptions: { ...config.languageOptions, parser: tseslint.parser, parserOptions: { projectService: { allowDefaultProject: ["vite.config.ts", "docs/*.ts"] }, }, }, })), { files: ["**/*.ts", "**/*.tsx"], languageOptions: { parser: tseslint.parser, parserOptions: { projectService: { allowDefaultProject: ["vite.config.ts", "docs/*.ts"] }, }, globals: { ...globals.browser, }, }, plugins: { import: importPlugin, "jsx-a11y": jsxA11y, prettier: prettierPlugin, "react-hooks": reactHooks, "unused-imports": unusedImports, "ts-compat": tsCompat, }, settings: { "import/parsers": { "@typescript-eslint/parser": [".ts", ".tsx"], }, }, rules: { ...jsxA11y.flatConfigs.recommended.rules, // autoFocus on MUI Dialog confirm buttons is the correct WAI-ARIA dialog pattern — // dialogs trap focus and focus must be placed inside them on open. "jsx-a11y/no-autofocus": "off", "@typescript-eslint/consistent-generic-constructors": "off", "ts-compat/consistent-generic-constructors": "error", "prettier/prettier": "error", "@typescript-eslint/no-unused-vars": [ "error", { args: "all", argsIgnorePattern: "^_", caughtErrors: "all", caughtErrorsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_", varsIgnorePattern: "^_", ignoreRestSiblings: true, }, ], "react-hooks/exhaustive-deps": "error", "import/no-extraneous-dependencies": [ "error", { devDependencies: [ "**/vite.config.ts", "**/vitest.setup.ts", "**/*.test.ts", "**/*.test.tsx", ], }, ], // eslint-plugin-import@2.32.0 is not compatible with ESLint 10 (see TypeError in lint output). // Re-enable when the plugin supports ESLint 10. "import/order": "off", }, }, ]; ================================================ FILE: justfile ================================================ # Shows help default: @just --list --justfile {{ justfile() }} # build the app build: __install @-rm -rf dist @yarn run build --base=./ update: yarn upgrade-interactive --latest @echo "Cleaning up node_modules and reinstalling to avoid potential issues..." -rm -rf node_modules -rm yarn.lock yarn install --network-timeout=300000 # update local API docs stored in docs/apis/* using docs/update-api-docs.ts script update-api-docs: yarn run update-api-docs # run the app in a development mode run: @yarn start --host 0.0.0.0 # run dev stack and start the app in a development mode run-dev: @echo "Starting the database..." @docker-compose -f docker/docker-compose-dev.yml up -d postgres @echo "Starting Synapse..." @docker-compose -f docker/docker-compose-dev.yml up -d synapse @echo "Starting Mock OIDC provider..." @docker-compose -f docker/docker-compose-dev.yml up -d mock-oidc @echo "Starting Matrix Authenitcation Service..." @docker-compose -f docker/docker-compose-dev.yml up -d mas @echo "Starting nginx reverse proxy (Synapse and MAS)..." @docker-compose -f docker/docker-compose-dev.yml up -d nginx @echo "Starting Element Web..." @docker-compose -f docker/docker-compose-dev.yml up -d element @echo "Ensure admin user is registered..." @docker-compose -f docker/docker-compose-dev.yml exec mas mas-cli manage register-user --yes --admin -p admin admin || true @echo "Starting the pre-built (prod version) of the Ketesa app on http://localhost:8008/admin ..." @docker-compose -f docker/docker-compose-dev.yml up -d ketesa-prod @echo "Starting the app..." @yarn start --host 0.0.0.0 logs-dev *flags: @docker-compose -f docker/docker-compose-dev.yml logs -f {{ flags }} # stop the dev stack stop-dev: @docker-compose -f docker/docker-compose-dev.yml down # register a user in the dev stack register-user localpart password *admin: docker-compose -f docker/docker-compose-dev.yml exec mas mas-cli manage register-user --yes {{ if admin =="1" {"--admin"} else {"--no-admin"} }} -p {{ password }} {{ localpart }} # run fixers, formatters, linters, and tests in a strict order test: @echo "Making linter happy..." @yarn -s run fix --quiet @echo "Formatting code..." @yarn -s run format --log-level warn @echo "Type-checking code..." @yarn -s run typecheck @echo "Running tests..." @yarn -s run test --silent @echo "All checks passed successfully!" # run the app in a production mode run-prod: build @python -m http.server -d dist 1313 # install the project __install: @yarn install --immutable --network-timeout=300000 ================================================ FILE: package.json ================================================ { "name": "ketesa", "version": "1.0.0", "description": "Admin UI for Matrix servers, formerly Synapse Admin", "keywords": [ "matrix", "synapse", "admin", "homeserver", "management", "react", "nodejs", "dashboard", "etkecc", "docker" ], "type": "module", "author": "etke.cc", "license": "Apache-2.0", "homepage": "https://github.com/etkecc/ketesa#readme", "repository": { "type": "git", "url": "git+https://github.com/etkecc/ketesa.git" }, "bugs": { "url": "https://github.com/etkecc/ketesa/issues" }, "funding": [ "https://github.com/sponsors/etkecc", "https://liberapay.com/etkecc" ], "devDependencies": { "@eslint/js": "^10.0.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/lodash": "^4.17.24", "@types/node": "^25.6.0", "@types/papaparse": "^5.5.2", "@types/react": "^19.2.14", "@typescript-eslint/eslint-plugin": "^8.59.2", "@typescript-eslint/parser": "^8.59.2", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.1.5", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-unused-imports": "^4.4.1", "globals": "^17.6.0", "jsdom": "^29.1.1", "prettier": "^3.8.3", "react-test-renderer": "^19.2.5", "ts-node": "^10.9.2", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2", "vite": "^8.0.9", "vitest": "^4.1.5", "vitest-axe": "^0.1.0" }, "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.9", "@mui/material": "^7.3.9", "@mui/utils": "^7.3.9", "@tanstack/react-query": "^5.100.9", "@tiptap/extension-placeholder": "^3.22.5", "@tiptap/react": "^3.22.5", "@tiptap/starter-kit": "^3.22.5", "dompurify": "^3.4.2", "lodash": "^4.18.1", "oidc-client-ts": "^3.5.0", "papaparse": "^5.5.3", "ra-core": "^5.14.6", "ra-i18n-polyglot": "^5.14.6", "ra-language-english": "^5.14.6", "ra-language-french": "^5.14.6", "react": "^19.2.5", "react-admin": "^5.14.6", "react-dom": "^19.2.5", "react-hook-form": "^7.75.0", "react-is": "^19.2.5", "react-router": "^7.15.0", "react-router-dom": "^7.15.0" }, "scripts": { "start": "vite serve", "build": "vite build", "lint": "eslint .", "format": "prettier --write \"src/**/*\"", "typecheck": "tsc --noEmit", "fix": "yarn lint --fix", "test": "vitest run", "test:watch": "vitest", "update-api-docs": "ts-node docs/update-api-docs.ts" }, "prettier": { "printWidth": 120, "tabWidth": 2, "useTabs": false, "semi": true, "singleQuote": false, "trailingComma": "es5", "bracketSpacing": true, "arrowParens": "avoid" }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] } } ================================================ FILE: public/config.json ================================================ {} ================================================ FILE: public/data/example.csv ================================================ id,displayname,password,is_guest,admin,deactivated,threepids testuser22,Jane Doe,secretpassword,false,true,false,"email:test22@example.com,msisdn:1234567890" ,John Doe,,false,false,false,"email:john@example.com,email:doe@example.com" ================================================ FILE: public/robots.txt ================================================ # https://www.robotstxt.org/robotstxt.html User-agent: * Disallow: / ================================================ FILE: src/App.test.tsx ================================================ import { render, screen } from "@testing-library/react"; vi.mock("./providers/auth", () => ({ __esModule: true, default: { logout: vi.fn().mockResolvedValue(undefined), handleCallback: vi.fn().mockResolvedValue({ redirectTo: "/" }), }, })); import polyglotI18nProvider from "ra-i18n-polyglot"; import englishMessages from "./i18n/en"; import App from "./App"; const i18nProvider = polyglotI18nProvider(() => englishMessages, "en"); describe("App", () => { beforeEach(() => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({})))); }); it("renders", async () => { render(); await screen.findAllByText("Welcome to Ketesa"); }); }); ================================================ FILE: src/App.tsx ================================================ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Admin, CustomRoutes, Resource, reactRouterProvider } from "react-admin"; import type { I18nProvider } from "ra-core"; import BillingPage from "./components/etke.cc/BillingPage"; import ComponentsPage from "./components/etke.cc/ComponentsPage"; import { useInstanceConfig } from "./components/etke.cc/InstanceConfig"; import ServerActionsPage from "./components/etke.cc/ServerActionsPage"; import SupportPage from "./components/etke.cc/SupportPage"; import SupportRequestPage from "./components/etke.cc/SupportRequestPage"; import ServerNotificationsPage from "./components/etke.cc/ServerNotificationsPage"; import ServerStatusPage from "./components/etke.cc/ServerStatusPage"; import RecurringCommandEdit from "./components/etke.cc/schedules/components/recurring/RecurringCommandEdit"; import ScheduledCommandEdit from "./components/etke.cc/schedules/components/scheduled/ScheduledCommandEdit"; import ScheduledCommandShow from "./components/etke.cc/schedules/components/scheduled/ScheduledCommandShow"; import UserImport from "./components/user-import/UserImport"; import DonatePage from "./pages/DonatePage"; import LoginPage from "./pages/LoginPage"; import MASPolicyDataPage from "./pages/MASPolicyDataPage"; import { DatabaseRoomStatsList } from "./resources/statistics"; import destinations from "./resources/destinations"; import registrationToken from "./resources/registration-tokens"; import reports from "./resources/reports"; import scheduledTasks from "./resources/scheduled-tasks"; import roomDirectory from "./resources/room-directory"; import rooms from "./resources/rooms"; import userMediaStats from "./resources/statistics/UserMedia"; import users from "./resources/users"; import { masCompatSessions, masOAuth2Sessions, masPersonalSessions, masUpstreamOAuthLinks, masUpstreamOAuthProviders, masUserSessions, } from "./resources/mas"; import authProvider from "./providers/auth"; import dataProvider from "./providers/data"; import { isMAS } from "./providers/data/mas"; import { lightTheme, darkTheme } from "./assets/theme"; import { AdminLayout } from "./components/layout"; const Route = reactRouterProvider.Route; const queryClient = new QueryClient(); export const App = ({ i18nProvider }: { i18nProvider: I18nProvider }) => { const icfg = useInstanceConfig(); const masEnabled = isMAS(); let title = "Ketesa"; if (icfg.name) { title = icfg.name; } return ( } /> } /> {!icfg.disabled.monitoring && } />} {!icfg.disabled.actions && } />} {!icfg.disabled.actions && ( } /> )} {!icfg.disabled.actions && } />} {!icfg.disabled.actions && ( } /> )} {!icfg.disabled.actions && } />} {!icfg.disabled.actions && ( } /> )} {!icfg.disabled.actions && } />} {!icfg.disabled.payments && } />} {!icfg.disabled.payments && } />} {!icfg.disabled.support && } />} {!icfg.disabled.support && } />} {masEnabled && } />} } /> {!icfg.disabled.federation && } {!icfg.disabled.registration_tokens && } {masEnabled && } {masEnabled && } {masEnabled && } {masEnabled && } {masEnabled && } {masEnabled && } {masEnabled && } {masEnabled && } ); }; export default App; ================================================ FILE: src/Context.tsx ================================================ import { createContext, useContext, useEffect, useState } from "react"; import { Config, GetConfig, SubscribeConfig } from "./utils/config"; export const AppContext = createContext({} as Config); export const useAppContext = () => useContext(AppContext) as Config; export const ConfigProvider = ({ children }: { children: React.ReactNode }) => { const [config, setConfig] = useState(GetConfig()); useEffect(() => { return SubscribeConfig(() => { setConfig(GetConfig()); }); }, []); return {children}; }; ================================================ FILE: src/TEST_COVERAGE_TODO.md ================================================ # Test Coverage TODO — Tier 4 These areas were intentionally deferred after the Tier 1–3 coverage pass. Each item requires heavier mocking infrastructure than the earlier tiers. ## Data Providers All files below share the same mocking strategy: mock `jsonClient` / `httpClient` from `src/providers/http.ts` with `vi.mock`, then test each exported function / resource operation in isolation. ### `src/providers/data/etke.ts` - etke.cc-specific CRUD and action endpoints - Test: get, list, create, update, delete operations ### `src/providers/data/synapse.ts` - Synapse admin API resource CRUD (users, rooms, media, etc.) - Test: each resource's getList/getOne/create/update/delete handlers ### `src/providers/data/mas.ts` - MAS resource CRUD (users, sessions, registration tokens, policy data) - Test: each resource's getList/getOne/create/update/delete handlers - Mock `getMASBaseUrl()` via localStorage ### `src/providers/data/synapse-actions.ts` - One-shot admin actions (reset password, deactivate user, purge media, etc.) - Test: each exported action function with success and error paths ### `src/providers/data/mas-actions.ts` - MAS admin actions (lock/unlock user, revoke session, etc.) - Test: each exported action function with success and error paths ### `src/providers/data/lifecycle.ts` - Provider lifecycle hooks (login, logout, checkAuth, checkError, getPermissions) - Mock fetch + localStorage ### `src/providers/auth/index.ts` - OIDC / password auth provider - Test: login, logout, checkAuth, handleCallback, token refresh - Mock fetch + localStorage ### `src/providers/serverVersion.ts` - Synapse and MAS version polling hook - Mock `jsonClient`; test version extraction and MAS detection logic ### `src/providers/http.ts` - `jsonClient` and `httpClient` wrappers - Test: Authorization header injection, base_url construction, error handling - Mock `fetch` with `vi.stubGlobal` --- ## Resource Pages / Views All resource pages should be wrapped in `AdminContext` with a mocked `dataProvider` and the English `i18nProvider` (same pattern as `LoginPage.test.tsx` and `MASPolicyDataPage.test.tsx`). ### `src/resources/users/` (UserList, UserCreate, UserEdit, UserShow) - List: renders user rows, filter by admin/guest/deactivated - Create: form validation, password generation button, device ID field - Edit: avatar upload, rate limit fields, device management ### `src/resources/rooms/` (RoomList, RoomShow) - List: renders room rows, filter - Show: members tab, state events tab, event lookup dialog ### `src/resources/destinations/` - List, Show with federation retry button ### `src/resources/reports/` - List, Show with user/room detail links ### `src/resources/registration-tokens/` - List: MAS vs Synapse token rendering - Create/Edit: expiry date, uses_allowed, revoke button ### `src/resources/statistics/` - Media stats page ### `src/resources/mas/` (users, sessions, compat-sessions, oauth-sessions) - List, Show, Edit for each resource --- ## Complex Hooks These hooks require rendering inside a full react-admin context tree. Use `renderHook` wrapped in a custom `AdminContext` + mocked `dataProvider`. ### `src/components/etke.cc/hooks/useServerCommands.ts` Required mocks: `useDataProvider`, `useInstanceConfig`, `useAppContext` - Returns empty list when data provider throws - Filters commands based on `disabled` config flags - Enables maintenance mode commands when `maintenance` is true ### `src/components/etke.cc/hooks/useUnits.ts` Required mocks: `useDataProvider`, `useLocale` - Returns empty map when data provider throws - Maps unit identifiers to human-readable labels via `toHumanReadable` ### `src/components/user-import/useImportFile.tsx` (hook itself, not pure functions) The pure helper functions (`anyToBoolean`, `validateCsvImport`) are already covered in `useImportFile.test.ts`. The hook itself needs: Required mocks: `useDataProvider`, `useNotify`, `useTranslate` (all from react-admin) - Parses CSV and sets csvData / stats state - Reports errors for invalid CSV - Progress updates during bulk import - Final success / failure notification --- ## Notes on Test Patterns ```ts // Standard mocking for a data provider method const dataProvider = { ...minimalDataProvider, getMASPolicyData: vi.fn().mockResolvedValue(policyData), }; // Wrap a component under test render( ); // Mock jsonClient for provider unit tests vi.mock("../../http", () => ({ jsonClient: vi.fn() })); import { jsonClient } from "../../http"; vi.mocked(jsonClient).mockResolvedValue({ json: { ... }, status: 200, headers: new Headers() }); ``` ================================================ FILE: src/assets/fonts.css ================================================ /* * DatagridConfigurable wraps its content in a styled . * That span has display:inline-block as its base style and no explicit width, causing it * to shrink-to-content inside flex column containers (e.g. TabbedForm tab panels). * This breaks the EmptyState centering when there are no records in a tab's Datagrid. * Setting width:100% here ensures the span fills the tab panel and EmptyState centers correctly. * RaReferenceField-root is the ReferenceManyField wrapper that wraps RaConfigurable-root * in some tabs (e.g. Connections), so it also needs width:100%. */ .RaConfigurable-root, .RaReferenceField-root { width: 100%; } @font-face { font-family: "Inter"; font-style: normal; font-weight: 400; font-display: swap; src: url("./webfonts/Inter-Regular.woff2") format("woff2"); } @font-face { font-family: "Inter"; font-style: normal; font-weight: 500; font-display: swap; src: url("./webfonts/Inter-Medium.woff2") format("woff2"); } @font-face { font-family: "Inter"; font-style: normal; font-weight: 600; font-display: swap; src: url("./webfonts/Inter-SemiBold.woff2") format("woff2"); } @font-face { font-family: "Work Sans"; font-style: normal; font-weight: 500; font-display: swap; src: url("./webfonts/WorkSans-Medium.woff2") format("woff2"); } @font-face { font-family: "Work Sans"; font-style: normal; font-weight: 600; font-display: swap; src: url("./webfonts/WorkSans-SemiBold.woff2") format("woff2"); } ================================================ FILE: src/assets/theme.ts ================================================ import { createTheme, keyframes, type ThemeOptions } from "@mui/material/styles"; const headingFont = '"Work Sans", system-ui, sans-serif'; const bodyFont = '"Inter", "Work Sans", system-ui, sans-serif'; const typography: ThemeOptions["typography"] = { fontFamily: bodyFont, h1: { fontFamily: headingFont, fontWeight: 600 }, h2: { fontFamily: headingFont, fontWeight: 600 }, h3: { fontFamily: headingFont, fontWeight: 600 }, h4: { fontFamily: headingFont, fontWeight: 600 }, h5: { fontFamily: headingFont, fontWeight: 600 }, h6: { fontFamily: headingFont, fontWeight: 400 }, }; const shape = { borderRadius: 8 }; // react-admin's default theme invariants — required for proper form field sizing const raInvariants: ThemeOptions["components"] = { MuiAutocomplete: { defaultProps: { fullWidth: true, }, variants: [ { props: {}, style: ({ theme }) => ({ [(theme as ReturnType).breakpoints.down("sm")]: { width: "100%" }, }), }, ], }, MuiTextField: { defaultProps: { variant: "filled", margin: "dense", size: "small", fullWidth: true, }, variants: [ { props: {}, style: ({ theme }) => ({ [(theme as ReturnType).breakpoints.down("sm")]: { width: "100%" }, }), }, ], }, MuiFormControl: { defaultProps: { variant: "filled", margin: "dense", size: "small", fullWidth: true, }, }, MuiTableCell: { styleOverrides: { root: { "&.MuiTableCell-paddingCheckbox": { padding: "0 8px 0 8px", }, }, }, }, RaSimpleFormIterator: { defaultProps: { fullWidth: true, }, }, RaTranslatableInputs: { defaultProps: { fullWidth: true, }, }, }; const buttonShimmer = keyframes` 0% { transform: translateX(-200%) skewX(-15deg); } 100% { transform: translateX(200%) skewX(-15deg); } `; // Focus ring using primary color — keyboard-only via :focus-visible const focusRing = (color: string) => ({ "&:focus-visible": { outline: "none", boxShadow: `0 0 0 3px ${color}`, }, }); const sharedComponents: ThemeOptions["components"] = { ...raInvariants, MuiButtonBase: { styleOverrides: { root: ({ theme }) => focusRing(theme.palette.mode === "dark" ? "rgba(244, 147, 0, 0.35)" : "rgba(91, 141, 239, 0.4)"), }, }, MuiButton: { styleOverrides: { root: ({ theme }) => ({ borderRadius: 4, textTransform: "none" as const, fontWeight: 500, fontSize: "0.9rem", padding: "6px 18px", transition: "all 150ms ease", position: "relative" as const, overflow: "hidden", "&::after": { content: '""', position: "absolute", top: 0, left: 0, width: "60%", height: "100%", background: "linear-gradient(90deg, transparent, rgba(255,255,255,0.18), transparent)", transform: "translateX(-200%) skewX(-15deg)", pointerEvents: "none", transition: "none", }, "&:hover::after": { animation: `${buttonShimmer} 0.6s ease-out`, }, "&:hover": { boxShadow: theme.palette.mode === "dark" ? "0 0 16px rgba(244,147,0,0.2)" : "0 0 16px rgba(91,141,239,0.25)", }, }), sizeSmall: { fontSize: "0.85rem", padding: "5px 14px", }, sizeLarge: { fontSize: "1rem", padding: "8px 24px", }, }, }, MuiOutlinedInput: { styleOverrides: { root: ({ theme }) => ({ borderRadius: 4, transition: "border-color 150ms ease, box-shadow 150ms ease", ...focusRing(theme.palette.mode === "dark" ? "rgba(244, 147, 0, 0.3)" : "rgba(91, 141, 239, 0.3)"), }), }, }, MuiFilledInput: { styleOverrides: { root: ({ theme }) => focusRing(theme.palette.mode === "dark" ? "rgba(244, 147, 0, 0.3)" : "rgba(91, 141, 239, 0.3)"), }, }, MuiChip: { styleOverrides: { root: ({ theme }) => ({ borderRadius: 4, transition: "background-color 150ms ease, box-shadow 150ms ease", ...focusRing(theme.palette.mode === "dark" ? "rgba(244, 147, 0, 0.35)" : "rgba(91, 141, 239, 0.4)"), }), }, }, MuiLink: { styleOverrides: { root: ({ theme }) => ({ borderRadius: 2, ...focusRing(theme.palette.mode === "dark" ? "rgba(244, 147, 0, 0.35)" : "rgba(91, 141, 239, 0.4)"), }), }, }, MuiCard: { styleOverrides: { root: { transition: "box-shadow 150ms ease, border-color 150ms ease", }, }, }, MuiDialog: { styleOverrides: { paper: { borderRadius: 12, }, }, }, MuiBackdrop: { styleOverrides: { root: { "&:not(.MuiBackdrop-invisible)": { backdropFilter: "blur(4px)", }, }, }, }, MuiTableContainer: { styleOverrides: { root: { borderRadius: 8, }, }, }, MuiTableRow: { styleOverrides: { root: { transition: "background-color 150ms ease", }, }, }, MuiTableHead: { styleOverrides: { root: { "& .MuiTableCell-head": { fontWeight: 600, }, }, }, }, MuiListItemButton: { styleOverrides: { root: { transition: "background-color 150ms ease", }, }, }, MuiSnackbarContent: { styleOverrides: { root: { borderRadius: 8, fontSize: "0.9rem", fontWeight: 500, }, }, }, MuiAlert: { styleOverrides: { root: { borderRadius: 8, fontSize: "0.9rem", fontWeight: 500, alignItems: "center", }, standardSuccess: ({ theme }) => ({ backgroundColor: theme.palette.mode === "dark" ? "rgba(74, 222, 128, 0.12)" : theme.palette.success.light, color: theme.palette.mode === "dark" ? "#4ADE80" : theme.palette.success.main, }), standardError: ({ theme }) => ({ backgroundColor: theme.palette.mode === "dark" ? "rgba(255, 107, 122, 0.12)" : theme.palette.error.light, color: theme.palette.mode === "dark" ? "#FF6B7A" : theme.palette.error.main, }), standardWarning: ({ theme }) => ({ backgroundColor: theme.palette.mode === "dark" ? "rgba(251, 191, 36, 0.12)" : theme.palette.warning.light, color: theme.palette.mode === "dark" ? "#FBBF24" : theme.palette.warning.main, }), standardInfo: ({ theme }) => ({ backgroundColor: theme.palette.mode === "dark" ? "rgba(91, 141, 239, 0.12)" : "#EFF6FF", color: theme.palette.mode === "dark" ? "#5B8DEF" : theme.palette.info.main, }), }, }, }; const scrollbarLight = { scrollbarColor: "rgba(0,0,0,0.2) transparent", "&::-webkit-scrollbar": { width: 8, height: 8 }, "&::-webkit-scrollbar-track": { background: "transparent" }, "&::-webkit-scrollbar-thumb": { background: "rgba(0,0,0,0.2)", borderRadius: 4 }, "&::-webkit-scrollbar-thumb:hover": { background: "rgba(0,0,0,0.3)" }, }; const scrollbarDark = { scrollbarColor: "rgba(255,255,255,0.15) transparent", "&::-webkit-scrollbar": { width: 8, height: 8 }, "&::-webkit-scrollbar-track": { background: "transparent" }, "&::-webkit-scrollbar-thumb": { background: "rgba(255,255,255,0.15)", borderRadius: 4 }, "&::-webkit-scrollbar-thumb:hover": { background: "rgba(255,255,255,0.25)" }, }; const lightComponents: ThemeOptions["components"] = { ...sharedComponents, MuiCssBaseline: { styleOverrides: { body: scrollbarLight, "*": scrollbarLight, }, }, MuiCard: { styleOverrides: { root: { borderRadius: 8, boxShadow: "0 1px 3px rgba(0,0,0,0.08)", border: "1px solid #E5E7EB", }, }, }, MuiFilledInput: { styleOverrides: { root: { backgroundColor: "rgba(0, 0, 0, 0.04)", "&$disabled": { backgroundColor: "rgba(0, 0, 0, 0.04)", }, }, }, }, MuiAppBar: { styleOverrides: { root: { boxShadow: "0 1px 3px rgba(0,0,0,0.08)", borderBottom: "none", }, }, }, RaToolbar: { styleOverrides: { root: { backgroundColor: "transparent", "&.RaToolbar-mobileToolbar": { position: "static", }, }, }, }, RaSidebar: { styleOverrides: { root: { "& .MuiPaper-root": { backgroundColor: "#334258 !important", color: "#FFFFFF", }, }, }, }, }; const darkComponents: ThemeOptions["components"] = { ...sharedComponents, MuiCssBaseline: { styleOverrides: { body: scrollbarDark, "*": scrollbarDark, }, }, MuiCard: { styleOverrides: { root: { borderRadius: 8, boxShadow: "0 1px 3px rgba(0,0,0,0.3)", border: "none", backgroundImage: "none", }, }, }, MuiAppBar: { styleOverrides: { root: { boxShadow: "none", borderBottom: "1px solid #253038", }, }, }, MuiPaper: { styleOverrides: { root: { backgroundImage: "none", }, }, }, RaToolbar: { styleOverrides: { root: { backgroundColor: "transparent", "&.RaToolbar-mobileToolbar": { position: "static", marginBottom: "1rem", }, }, }, }, RaSidebar: { styleOverrides: { root: { "& .MuiPaper-root": { backgroundColor: "#080D12 !important", color: "#E0E0E0", }, }, }, }, }; export const lightTheme = createTheme({ palette: { mode: "light", contrastThreshold: 4.5, primary: { main: "#1858D5" }, secondary: { main: "#334258" }, error: { main: "#DC3545", light: "#FEF2F2" }, warning: { main: "#D97706", light: "#FFFBEB", contrastText: "#7C2D12" }, success: { main: "#248E39", light: "#F0FDF4" }, info: { main: "#1858D5" }, background: { default: "#F5F5F5", paper: "#FFFFFF" }, text: { primary: "#1A1A2E", secondary: "#575E6B" }, divider: "#E5E7EB", }, typography, shape, sidebar: { width: 240, closedWidth: 50 }, components: lightComponents, }); export const darkTheme = createTheme({ palette: { mode: "dark", contrastThreshold: 4.5, primary: { main: "#F49300" }, secondary: { main: "#5B8DAF" }, error: { main: "#FF6B7A" }, warning: { main: "#FBBF24" }, success: { main: "#4ADE80" }, info: { main: "#5B8DEF" }, background: { default: "#0C1318", paper: "#151C24" }, text: { primary: "#E8E8ED", secondary: "#9CA3AF" }, divider: "#253038", }, typography, shape, sidebar: { width: 240, closedWidth: 50 }, components: darkComponents, }); ================================================ FILE: src/components/MatrixWordmark.tsx ================================================ import { Box, SxProps, Theme, useTheme } from "@mui/material"; export const MATRIX_TRADEMARK_TITLE = "This logo is the trademark of The Matrix.org Foundation (https://matrix.org). Use of this logo does not imply endorsement or affiliation."; interface MatrixWordmarkProps { title?: string; sx?: SxProps; } const MatrixWordmark = ({ title = MATRIX_TRADEMARK_TITLE, sx }: MatrixWordmarkProps) => { const theme = useTheme(); const fill = theme.palette.mode === "dark" ? "#FFFFFF" : "#000000"; return ( {title} ); }; export default MatrixWordmark; ================================================ FILE: src/components/README.md ================================================ # components/ Shared UI components, organized by feature domain. ## Structure - `layout/` — App-level layout: AdminLayout, LoginFormBox, EmptyState, Footer - `users/` — User-related components - `buttons/` — Action buttons (DeactivateButton, ResetPasswordButton, …) - `fields/` — Display/input fields (AvatarField, …) - `rooms/` — Room-related components - `media/` — Media quarantine and deletion components - `user-import/` — Bulk CSV user import (self-contained, keep as-is) - `etke.cc/` — ETKE.CC-exclusive features (keep as-is) - `hooks/` — Shared React hooks ## Sub-directory rule Add a sub-directory (`buttons/`, `fields/`, `dialogs/`) only when **3 or more** components of the same semantic type exist in a feature. One or two components stay at the feature dir root. ================================================ FILE: src/components/etke.cc/BillingPage.tsx ================================================ import BuildIcon from "@mui/icons-material/Build"; import EuroSymbolIcon from "@mui/icons-material/EuroSymbol"; import SupportAgentIcon from "@mui/icons-material/SupportAgent"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import CheckIcon from "@mui/icons-material/Check"; import DownloadIcon from "@mui/icons-material/Download"; import PaymentIcon from "@mui/icons-material/Payment"; import { Box, Alert, AlertTitle, Chip, CircularProgress, List, ListItem, Typography, Link, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Tooltip, useMediaQuery, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import { Stack } from "@mui/material"; import IconButton from "@mui/material/IconButton"; import { Link as RouterLink } from "react-router-dom"; import { useState, useEffect } from "react"; import { Title, useDataProvider, useLocale, useNotify, useTranslate } from "react-admin"; import { EtkeAttribution } from "./EtkeAttribution"; import { useAppContext } from "../../Context"; import { useInstanceConfig } from "./InstanceConfig"; import { SynapseDataProvider, Payment, PaymentStatus } from "../../providers/types"; import createLogger from "../../utils/logger"; import { getTimeSince, getTimeUntil } from "../../utils/date"; const log = createLogger("billing"); import { useDocTitle } from "../hooks/useDocTitle"; const TruncatedUUID = ({ uuid }): React.ReactElement => { const short = `${uuid.slice(0, 8)}...${uuid.slice(-6)}`; const [copied, setCopied] = useState(false); useEffect(() => { if (!copied) return; const timer = setTimeout(() => setCopied(false), 1500); return () => clearTimeout(timer); }, [copied]); const copyToClipboard = async () => { if (!navigator.clipboard) return; try { await navigator.clipboard.writeText(uuid); setCopied(true); } catch { // clipboard write failed } }; return ( {short} {copied ? : } ); }; const BillingPage = () => { const { etkeccAdmin } = useAppContext(); const icfg = useInstanceConfig(); const dataProvider = useDataProvider() as SynapseDataProvider; const notify = useNotify(); const locale = useLocale(); const translate = useTranslate(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const [paymentsData, setPaymentsData] = useState([]); const [paymentStatus, setPaymentStatus] = useState(null); const [loading, setLoading] = useState(true); const [maintenance, setMaintenance] = useState(false); const [failure, setFailure] = useState(null); const [downloadingInvoice, setDownloadingInvoice] = useState(null); useDocTitle(translate("etkecc.billing.name")); useEffect(() => { const fetchBillingData = async () => { if (!etkeccAdmin) return; try { setLoading(true); const paymentsResponse = await dataProvider.getPayments(etkeccAdmin, locale); setPaymentsData(paymentsResponse.payments); setMaintenance(paymentsResponse.maintenance); const status = paymentsResponse.status; if (status) { const twoMonthsAgo = new Date(); twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2); if (status.expected_price === 0) { log.warn("payment status: expected_price is 0, treating as missing data"); } else if (new Date(status.due_at) < twoMonthsAgo) { log.warn("payment status: due_at is more than 2 months in the past, treating as stale data", status.due_at); } else if (status.overdue || status.mismatch) { setPaymentStatus(status); } } } catch (error) { log.error("failed to fetch billing data", error); setFailure(error instanceof Error ? error.message : (error as string)); } finally { setLoading(false); } }; fetchBillingData(); }, [etkeccAdmin, dataProvider, notify, locale]); const handleInvoiceDownload = async (transactionId: string) => { if (!etkeccAdmin || downloadingInvoice) return; try { setDownloadingInvoice(transactionId); await dataProvider.getInvoice(etkeccAdmin, locale, transactionId); notify("etkecc.billing.helper.download_started", { type: "info" }); } catch (error) { // Use the specific error message from the dataProvider const errorMessage = error instanceof Error ? error.message : "Error downloading invoice"; notify(errorMessage, { type: "error" }); log.error("failed to download invoice", { transactionId, error }); } finally { setDownloadingInvoice(null); } }; const downloadInvoiceButton = (payment: Payment) => { const isInvoiceAvailable = typeof payment.invoice_id === "string" && /^[0-9]+$/.test(payment.invoice_id); if (!isInvoiceAvailable) { return ( {translate("etkecc.billing.helper.invoice_not_available")} ); } const isDownloading = downloadingInvoice === payment.transaction_id; return ( ); }; const header = ( <> <Box> <Typography variant="h4"> <PaymentIcon sx={{ verticalAlign: "middle", mr: 1 }} /> {translate("etkecc.billing.name")} </Typography> <EtkeAttribution> <Typography variant="body1"> {translate("etkecc.billing.description1")}{" "} <Link href="https://etke.cc/help/payments/" target="_blank"> etke.cc/help/payments </Link> . <br /> {translate("etkecc.billing.description2")}{" "} <Link href="https://etke.cc/help/payments/#how-to-add-company-details-to-the-invoices" target="_blank"> etke.cc/help/payments/#how-to-add-company-details-to-the-invoices </Link> . </Typography> </EtkeAttribution> </Box> </> ); if (loading) { return ( <Stack spacing={3} mt={3}> {header} <Paper elevation={0} sx={theme => ({ p: 4, borderRadius: 3, textAlign: "center", border: theme.palette.mode === "dark" ? "1px solid rgba(255,255,255,0.08)" : "1px solid rgba(0,0,0,0.08)", })} > <CircularProgress size={32} sx={{ mb: 2 }} /> <Typography color="text.secondary">{translate("etkecc.billing.helper.loading")}</Typography> </Paper> </Stack> ); } if (failure) { return ( <Stack spacing={3} mt={3}> {header} <Alert severity="error" sx={{ borderRadius: 3 }}> <AlertTitle>{translate("etkecc.billing.helper.loading_failed1")}</AlertTitle> {translate("etkecc.billing.helper.loading_failed2")} <br /> <EtkeAttribution> <Typography variant="body2">{translate("etkecc.billing.helper.loading_failed3")}</Typography> {!icfg.disabled.support && ( <Button variant="outlined" size="small" startIcon={<SupportAgentIcon />} component={RouterLink} to="/support" sx={{ mt: 1 }} > {translate("etkecc.billing.status.issue.support_link")} </Button> )} </EtkeAttribution> <Typography variant="body2" sx={{ mt: 1, opacity: 0.8 }}> {translate("etkecc.billing.helper.loading_failed4")} <br /> {failure} </Typography> </Alert> </Stack> ); } if (maintenance) { return ( <Stack spacing={3} mt={3}> {header} <Alert severity="info" sx={{ borderRadius: 3 }}> <AlertTitle>{translate("etkecc.maintenance.title")}</AlertTitle> {translate("etkecc.maintenance.try_again")} <br /> {translate("etkecc.maintenance.note")} </Alert> </Stack> ); } const formatCurrency = (amount: number, currency: string) => new Intl.NumberFormat("en", { style: "currency", currency }).format(amount); const renderPaymentStatusAlert = () => { if (!paymentStatus || (!paymentStatus.overdue && !paymentStatus.mismatch)) return null; const mostRecentSubscriptionPayment = paymentsData.find(p => p.is_subscription) ?? paymentsData[0]; const mostRecentPayment = mostRecentSubscriptionPayment; const dueAtDate = new Date(paymentStatus.due_at); const isOverdue = dueAtDate <= new Date(); const { timeI18Nkey, timeI18Nparams } = isOverdue ? getTimeSince(paymentStatus.due_at) : getTimeUntil(paymentStatus.due_at); const relativeTime = translate(timeI18Nkey, timeI18Nparams); const absoluteDueDate = dueAtDate.toLocaleDateString(locale, { year: "numeric", month: "long", day: "numeric" }); const threeMonthsAgo = new Date(); threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); const showLastPaid = mostRecentPayment && new Date(mostRecentPayment.paid_at) >= threeMonthsAgo; return ( <Alert severity="warning" sx={{ borderRadius: 3 }}> <AlertTitle>{translate("etkecc.billing.status.issue.title")}</AlertTitle> <Typography variant="body2" sx={{ mb: 1 }}> {translate("etkecc.billing.status.issue.description")} </Typography> <Box sx={{ display: "flex", flexWrap: "wrap", gap: 2, mb: 1.5 }}> {paymentStatus.overdue && ( <Box> <Typography variant="caption" color="text.secondary"> {isOverdue ? translate("etkecc.billing.status.issue.due_overdue") : translate("etkecc.billing.status.issue.due_upcoming")} </Typography> <Tooltip title={ isOverdue ? `${translate("etkecc.billing.status.issue.due_overdue")} ${absoluteDueDate}` : absoluteDueDate } > <Typography variant="body2" fontWeight="medium" sx={{ cursor: "help" }}> {relativeTime} </Typography> </Tooltip> </Box> )} {paymentStatus.mismatch && ( <Box> <Typography variant="caption" color="text.secondary"> {translate("etkecc.billing.status.issue.expected")} </Typography> <Typography variant="body2" fontWeight="medium"> {mostRecentPayment ? formatCurrency(paymentStatus.expected_price, mostRecentPayment.currency) : paymentStatus.expected_price} </Typography> </Box> )} {paymentStatus.mismatch && showLastPaid && mostRecentPayment && ( <Box> <Typography variant="caption" color="text.secondary"> {translate("etkecc.billing.status.issue.last_paid")} </Typography> <Typography variant="body2" fontWeight="medium"> {formatCurrency(mostRecentPayment.amount, mostRecentPayment.currency)} </Typography> </Box> )} </Box> <Box sx={{ display: "flex", flexDirection: { xs: "column", sm: "row" }, flexWrap: "wrap", gap: 1 }}> {paymentStatus.overdue && ( <Button variant="outlined" size="small" startIcon={<BuildIcon />} fullWidth={isSmall} component={Link} href="https://etke.cc/help/payments/#how-to-fix-a-failing-subscription" target="_blank" > {translate("etkecc.billing.status.issue.fix_link")} </Button> )} {paymentStatus.mismatch && ( <Button variant="outlined" size="small" startIcon={<EuroSymbolIcon />} fullWidth={isSmall} component={Link} href="https://etke.cc/help/payments/#how-to-update-your-subscription-price" target="_blank" > {translate("etkecc.billing.status.issue.fix_mismatch_link")} </Button> )} <Button variant="outlined" size="small" startIcon={<SupportAgentIcon />} fullWidth={isSmall} component={RouterLink} to="/support" > {translate("etkecc.billing.status.issue.support_link")} </Button> </Box> </Alert> ); }; return ( <Stack spacing={3} mt={3}> {header} {renderPaymentStatusAlert()} <Box sx={{ mt: 2 }}> <Typography variant="h5" sx={{ mb: 2 }}> {translate("etkecc.billing.title")} </Typography> {paymentsData.length === 0 ? ( <Typography variant="body1"> {translate("etkecc.billing.no_payments")} <EtkeAttribution> <Typography>{translate("etkecc.billing.no_payments_helper")}</Typography> {!icfg.disabled.support && ( <Button variant="outlined" size="small" startIcon={<SupportAgentIcon />} component={RouterLink} to="/support" sx={{ mt: 1 }} > {translate("etkecc.billing.status.issue.support_link")} </Button> )} </EtkeAttribution> </Typography> ) : isSmall ? ( <List disablePadding> {paymentsData.map(payment => ( <ListItem key={payment.transaction_id} component={Paper} elevation={2} sx={{ mb: 1, p: 2, flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 1, flexWrap: "wrap", }} > <Box> <Typography variant="subtitle1" fontWeight="bold"> {formatCurrency(payment.amount, payment.currency)} </Typography> <Typography variant="body2" color="text.secondary"> {new Date(payment.paid_at).toLocaleDateString(locale)} {" · "} {translate(`etkecc.billing.enums.type.${payment.is_subscription ? "subscription" : "one_time"}`)} </Typography> <Chip label={payment.email} size="small" variant="outlined" sx={{ mt: 0.5 }} /> </Box> <Box>{downloadInvoiceButton(payment)}</Box> </ListItem> ))} </List> ) : ( <TableContainer component={Paper}> <Table> <TableHead> <TableRow> <TableCell>{translate("etkecc.billing.fields.transaction_id")}</TableCell> <TableCell>{translate("etkecc.billing.fields.email")}</TableCell> <TableCell>{translate("etkecc.billing.fields.type")}</TableCell> <TableCell>{translate("etkecc.billing.fields.amount")}</TableCell> <TableCell>{translate("etkecc.billing.fields.paid_at")}</TableCell> <TableCell>{translate("etkecc.billing.helper.download_invoice")}</TableCell> </TableRow> </TableHead> <TableBody> {paymentsData.map(payment => ( <TableRow key={payment.transaction_id}> <TableCell> <TruncatedUUID uuid={payment.transaction_id} /> </TableCell> <TableCell>{payment.email}</TableCell> <TableCell> {translate(`etkecc.billing.enums.type.${payment.is_subscription ? "subscription" : "one_time"}`)} </TableCell> <TableCell>{formatCurrency(payment.amount, payment.currency)}</TableCell> <TableCell>{new Date(payment.paid_at).toLocaleDateString(locale)}</TableCell> <TableCell>{downloadInvoiceButton(payment)}</TableCell> </TableRow> ))} </TableBody> </Table> </TableContainer> )} </Box> </Stack> ); }; export default BillingPage; ================================================ FILE: src/components/etke.cc/BillingStatusBadge.tsx ================================================ import PaymentIcon from "@mui/icons-material/Payment"; import { Badge, Theme } from "@mui/material"; import { BadgeProps } from "@mui/material/Badge"; import { styled } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles"; import { useEffect } from "react"; import { useDataProvider, useLocale, useStore } from "react-admin"; import { useAppContext } from "../../Context"; import { PaymentStatus } from "../../providers/types"; import createLogger from "../../utils/logger"; const log = createLogger("billing-status"); interface StyledBadgeProps extends BadgeProps { backgroundColor: string; badgeColor: string; theme?: Theme; } const StyledBadge = styled(Badge, { shouldForwardProp: prop => !["badgeColor", "backgroundColor"].includes(prop as string), })<StyledBadgeProps>(({ theme, backgroundColor, badgeColor }) => ({ "& .MuiBadge-badge": { backgroundColor: backgroundColor, color: badgeColor, boxShadow: `0 0 0 2px ${theme.palette.background.paper}`, "&::after": { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", borderRadius: "50%", animation: "ripple 2.5s infinite ease-in-out", border: "1px solid currentColor", content: '""', }, }, "@keyframes ripple": { "0%": { transform: "scale(.8)", opacity: 1, }, "100%": { transform: "scale(2.4)", opacity: 0, }, }, })); /** Returns true if the status data should be treated as missing/corrupt. */ const isStatusSentinel = (status: PaymentStatus): boolean => { if (status.expected_price === 0) { log.warn("payment status: expected_price is 0, treating as missing data"); return true; } const dueAt = new Date(status.due_at); const twoMonthsAgo = new Date(); twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2); if (dueAt < twoMonthsAgo) { log.warn("payment status: due_at is more than 2 months in the past, treating as stale data", status.due_at); return true; } return false; }; const useBillingStatus = () => { const [billingStatus, setBillingStatus] = useStore<PaymentStatus | null>("billingStatus", null); const { etkeccAdmin } = useAppContext(); const dataProvider = useDataProvider(); const locale = useLocale(); useEffect(() => { if (!etkeccAdmin) { setBillingStatus(null); return; } dataProvider .getPayments(etkeccAdmin, locale) .then((response: { status?: PaymentStatus }) => { const status = response.status; if (!status) { setBillingStatus(null); return; } if (isStatusSentinel(status)) { setBillingStatus(null); return; } if (!status.overdue && !status.mismatch) { setBillingStatus(null); return; } setBillingStatus(status); }) .catch((err: unknown) => { log.warn("failed to fetch billing status for badge", err); setBillingStatus(null); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [etkeccAdmin]); return billingStatus; }; /** Null-render component; mounts in AdminMenu to trigger the status fetch on app load. */ export const BillingStatusPoller = () => { useBillingStatus(); return null; }; /** Wraps PaymentIcon with a red ripple badge dot when there is a payment issue. Hidden otherwise. */ export const BillingStatusBadge = () => { const theme = useTheme(); const [billingStatus] = useStore<PaymentStatus | null>("billingStatus", null); if (!billingStatus) { return <PaymentIcon aria-hidden="true" />; } const color = theme.palette.error.main; return ( <StyledBadge overlap="circular" anchorOrigin={{ vertical: "bottom", horizontal: "right" }} variant="dot" backgroundColor={color} badgeColor={color} > <PaymentIcon aria-hidden="true" /> </StyledBadge> ); }; ================================================ FILE: src/components/etke.cc/ComponentsPage.tsx ================================================ import ExtensionIcon from "@mui/icons-material/Extension"; import SupportAgentIcon from "@mui/icons-material/SupportAgent"; import { Alert, AlertTitle, Box, Button, Card, CardContent, CardHeader, Chip, CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Grid, Link, List, ListItem, Paper, Stack, Switch, Typography, useMediaQuery, } from "@mui/material"; import { alpha, useTheme } from "@mui/material/styles"; import { useState, useEffect } from "react"; import { Link as RouterLink } from "react-router-dom"; import { Title, useDataProvider, useLocale, useNotify, useRedirect, useTranslate } from "react-admin"; import { EtkeAttribution } from "./EtkeAttribution"; import { useAppContext } from "../../Context"; import { useInstanceConfig } from "./InstanceConfig"; import { SynapseDataProvider, Component, ComponentSection, SupportRequest } from "../../providers/types"; import createLogger from "../../utils/logger"; import { tt } from "../../utils/safety"; import { useDocTitle } from "../hooks/useDocTitle"; const log = createLogger("components"); const ComponentsPage = () => { const { etkeccAdmin } = useAppContext(); const icfg = useInstanceConfig(); const dataProvider = useDataProvider() as SynapseDataProvider; const locale = useLocale(); const translate = useTranslate(); const notify = useNotify(); const redirect = useRedirect(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const [sections, setSections] = useState<ComponentSection[]>([]); const [noSectionItems, setNoSectionItems] = useState<Component[]>([]); const [totalPrice, setTotalPrice] = useState(0); const [currency, setCurrency] = useState("EUR"); const [loading, setLoading] = useState(true); const [failure, setFailure] = useState<string | null>(null); const [toggledOff, setToggledOff] = useState<Set<string>>(new Set()); // active comps to remove const [toggledOn, setToggledOn] = useState<Set<string>>(new Set()); // unavailable comps to add const [submitting, setSubmitting] = useState(false); const [submittedRequest, setSubmittedRequest] = useState<SupportRequest | null>(null); const [sessionRequestId, setSessionRequestId] = useState<number | null>(null); useDocTitle(translate("etkecc.components.name")); useEffect(() => { if (!etkeccAdmin) return; const fetchData = async () => { try { setLoading(true); const response = await dataProvider.getComponents(etkeccAdmin, locale); setSections(response.sections); setNoSectionItems(response.components); setTotalPrice(response.total_price); setCurrency(response.currency); } catch (error) { log.error("failed to fetch components", error); setFailure(error instanceof Error ? error.message : (error as string)); } finally { setLoading(false); } }; fetchData(); }, [etkeccAdmin, dataProvider, locale]); const currencySymbol = currency === "EUR" ? "€" : currency; // Price preview calculation. const allSectionItems = sections.flatMap(s => s.components); const addItems = allSectionItems.filter(c => !c.enabled && toggledOn.has(c.id)); const removeItems = [ ...noSectionItems.filter(c => c.enabled && toggledOff.has(c.id)), ...allSectionItems.filter(c => c.enabled && toggledOff.has(c.id)), ]; // Only sections with a package price count as "activating" — toggling an item inside a // free section (price === 0) does not trigger a section charge. const activatedSections = sections.filter( s => !s.enabled && s.price > 0 && s.components.some(c => toggledOn.has(c.id)) ); const addPrice = addItems.reduce((sum, c) => { const inActivating = activatedSections.some(s => s.components.some(x => x.id === c.id)); return sum + (inActivating ? 0 : c.price); }, 0); const sectionActivationPrice = activatedSections.reduce((sum, s) => sum + s.price, 0); const removePrice = removeItems.reduce((sum, c) => sum + c.price, 0); const previewPrice = totalPrice + addPrice + sectionActivationPrice - removePrice; const hasChanges = toggledOff.size > 0 || toggledOn.size > 0; const handleRequestChanges = async () => { if (!etkeccAdmin) return; setSubmitting(true); try { const compToSection = new Map<string, string>(); sections.forEach(s => s.components.forEach(c => compToSection.set(c.id, s.name))); const removeList = removeItems .map(c => { const sectionName = compToSection.get(c.id); return `<li>${c.name}${sectionName ? ` (${sectionName})` : ""} — remove</li>`; }) .join(""); const addList = addItems .map(c => { const sectionName = compToSection.get(c.id); return `<li>${c.name}${sectionName ? ` (${sectionName})` : ""} — add</li>`; }) .join(""); const message = `<p>Hello,</p><p>I would like to change the following components on my server:</p><ul>${removeList}${addList}</ul><p>Thank you.</p>`; const created = await dataProvider.createSupportRequest(etkeccAdmin, locale, "Component changes", message); setSessionRequestId(created.id); setSubmittedRequest(created); setToggledOff(new Set()); setToggledOn(new Set()); } catch (error) { log.error("failed to submit component change request", error); notify("etkecc.components.request_failure", { type: "error" }); } finally { setSubmitting(false); } }; // priceChip renders a price indicator for a component or section. // packState: "active" = pack owned (show "Available"), "inactive" = pack not yet owned // (no chip by default; show primary chip only when toggled on), false = not a pack section // isToggledOn/Off: overrides chip color to signal pending add (primary) or remove (grayed) const priceChip = ( comp: { price: number; enabled?: boolean }, opts?: { packState?: "active" | "inactive" | false; isToggledOn?: boolean; isToggledOff?: boolean } ) => { const { packState = false, isToggledOn = false, isToggledOff = false } = opts ?? {}; if (packState === "inactive") { if (!isToggledOn) return null; return ( <Chip label={translate("etkecc.components.available_label")} size="small" variant="outlined" color="primary" sx={{ flexShrink: 0 }} /> ); } let label: string; let defaultColor: "primary" | "success" | "default"; let variant: "filled" | "outlined"; if (packState === "active") { label = translate("etkecc.components.available_label"); defaultColor = "primary"; variant = "outlined"; } else if (comp.price === 0) { if (comp.enabled) { label = translate("etkecc.components.included"); defaultColor = "success"; variant = "filled"; } else { label = translate("etkecc.components.free_label"); defaultColor = "success"; variant = "outlined"; } } else { label = `${currencySymbol}${comp.price}${translate("etkecc.components.per_month")}`; defaultColor = "success"; variant = "outlined"; } const chipColor = isToggledOn ? "primary" : isToggledOff ? "default" : defaultColor; return ( <Chip label={label} size="small" variant={variant} color={chipColor} sx={{ flexShrink: 0, ...(isToggledOff && { opacity: 0.5 }) }} /> ); }; const cardSx = (t: typeof theme) => ({ height: "100%", borderRadius: 3, border: t.palette.mode === "dark" ? "1px solid rgba(244,147,0,0.15)" : "1px solid rgba(24,88,213,0.10)", background: t.palette.mode === "dark" ? "rgba(255,255,255,0.03)" : "rgba(24,88,213,0.015)", transition: "border-color 0.2s", "&:hover": { borderColor: t.palette.mode === "dark" ? "rgba(244,147,0,0.30)" : "rgba(24,88,213,0.22)", }, }); const renderCompItem = (comp: Component, showToggle: boolean, packState: "active" | "inactive" | false) => ( <ListItem key={comp.id} disableGutters sx={{ py: 0.5, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, borderRadius: 1, backgroundColor: toggledOn.has(comp.id) ? alpha(theme.palette.primary.main, 0.07) : "transparent", transition: "background-color 0.3s", }} > <Box sx={{ minWidth: 0 }}> {comp.help ? ( <Link href={"https://etke.cc" + comp.help} target="_blank" variant="body2" sx={{ wordBreak: "break-word" }}> {comp.name} </Link> ) : ( <Typography variant="body2" sx={{ wordBreak: "break-word" }}> {comp.name} </Typography> )} </Box> <Box sx={{ display: "flex", alignItems: "center", gap: 0.5, flexShrink: 0 }}> {priceChip(comp, { packState, isToggledOn: toggledOn.has(comp.id), isToggledOff: toggledOff.has(comp.id), })} {!showToggle && comp.enabled && comp.price > 0 && ( <Chip label={translate("etkecc.components.included")} size="small" variant="filled" color="success" sx={{ flexShrink: 0 }} /> )} {showToggle && comp.enabled && ( <Switch size="small" sx={{ ml: 0.5 }} checked={!toggledOff.has(comp.id)} onChange={(_, checked) => setToggledOff(prev => { const next = new Set(prev); if (checked) next.delete(comp.id); else next.add(comp.id); return next; }) } slotProps={{ input: { "aria-label": translate("etkecc.components.remove_aria", { name: comp.name }), }, }} /> )} {showToggle && !comp.enabled && ( <Switch size="small" sx={{ ml: 0.5 }} checked={toggledOn.has(comp.id)} onChange={(_, checked) => setToggledOn(prev => { const next = new Set(prev); if (checked) next.add(comp.id); else next.delete(comp.id); return next; }) } slotProps={{ input: { "aria-label": translate("etkecc.components.add_aria", { name: comp.name }), }, }} /> )} </Box> </ListItem> ); const renderSection = (section: ComponentSection) => { const sectionLabel = tt( translate, `etkecc.components.section.${section.name.toLowerCase().replace(/\s+/g, "_")}`, section.name ); return ( <Grid size={{ xs: 12, sm: 6, md: 4 }} key={section.id}> <Card elevation={0} sx={cardSx}> <CardHeader title={ <Typography variant="subtitle1" sx={{ fontWeight: 600, color: "text.primary" }}> {section.help ? ( <Link href={"https://etke.cc" + section.help} target="_blank" sx={{ color: "inherit", textDecorationColor: "inherit" }} > {sectionLabel} </Link> ) : ( sectionLabel )} </Typography> } action={section.price > 0 ? <Box sx={{ mt: 0.5 }}>{priceChip(section)}</Box> : undefined} sx={{ pb: section.components.length > 0 ? 0 : undefined }} /> {section.components.length > 0 && ( <CardContent sx={{ pt: 1 }}> <List disablePadding> {section.components.map(comp => renderCompItem(comp, true, section.price > 0 ? (section.enabled ? "active" : "inactive") : false) )} </List> </CardContent> )} </Card> </Grid> ); }; const renderNoSection = (items: Component[]) => ( <Grid size={{ xs: 12, sm: 6, md: 4 }} key="__no_section__"> <Card elevation={0} sx={cardSx}> <CardHeader title={ <Typography variant="subtitle1" sx={{ fontWeight: 600, color: "text.primary" }}> {translate("etkecc.components.no_section")} </Typography> } sx={{ pb: items.length > 0 ? 0 : undefined }} /> {items.length > 0 && ( <CardContent sx={{ pt: 1 }}> <List disablePadding>{items.map(comp => renderCompItem(comp, false, false))}</List> </CardContent> )} </Card> </Grid> ); const header = ( <> <Title title={translate("etkecc.components.name")} /> <Box> <Typography variant="h4"> <ExtensionIcon sx={{ verticalAlign: "middle", mr: 1 }} /> {translate("etkecc.components.name")} </Typography> <EtkeAttribution> <Typography variant="body1">{translate("etkecc.components.description")}</Typography> </EtkeAttribution> <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}> {translate("etkecc.components.tagline")} </Typography> </Box> </> ); if (loading) { return ( <Stack spacing={3} mt={3}> {header} <Paper elevation={0} sx={t => ({ p: 4, borderRadius: 3, textAlign: "center", border: t.palette.mode === "dark" ? "1px solid rgba(255,255,255,0.08)" : "1px solid rgba(0,0,0,0.08)", })} > <CircularProgress size={32} sx={{ mb: 2 }} /> <Typography color="text.secondary">{translate("etkecc.components.loading")}</Typography> </Paper> </Stack> ); } if (failure) { return ( <Stack spacing={3} mt={3}> {header} <Alert severity="error" sx={{ borderRadius: 3 }}> <AlertTitle>{translate("etkecc.billing.helper.loading_failed1")}</AlertTitle> {translate("etkecc.billing.helper.loading_failed2")} <br /> <EtkeAttribution> <Typography variant="body2">{translate("etkecc.billing.helper.loading_failed3")}</Typography> {!icfg.disabled.support && ( <Button variant="outlined" size="small" startIcon={<SupportAgentIcon />} component={RouterLink} to="/support" sx={{ mt: 1 }} > {translate("etkecc.billing.status.issue.support_link")} </Button> )} </EtkeAttribution> <Typography variant="body2" sx={{ mt: 1, opacity: 0.8 }}> {translate("etkecc.billing.helper.loading_failed4")} <br /> {failure} </Typography> </Alert> </Stack> ); } const hasItems = noSectionItems.length > 0 || sections.length > 0; return ( <Stack spacing={3} mt={3}> {header} {hasItems && ( <Grid container spacing={2}> {noSectionItems.length > 0 && renderNoSection(noSectionItems)} {sections.map(s => renderSection(s))} </Grid> )} {hasItems && ( <Paper elevation={0} sx={t => ({ p: 2.5, borderRadius: 3, border: t.palette.mode === "dark" ? "1px solid rgba(255,255,255,0.08)" : "1px solid rgba(0,0,0,0.08)", })} > <Stack direction={isSmall ? "column" : "row"} justifyContent="space-between" alignItems={isSmall ? "flex-start" : "center"} gap={2} > <Box> <Typography variant="caption" color="text.secondary"> {translate("etkecc.components.total")} </Typography> <Box sx={{ display: "flex", alignItems: "baseline", gap: 0.5 }}> {hasChanges && previewPrice !== totalPrice && ( <Typography variant="body2" color="text.secondary" sx={{ textDecoration: "line-through" }}> {currencySymbol} {totalPrice} {translate("etkecc.components.per_month")} </Typography> )} <Typography variant="h6" fontWeight={700} color="primary"> {currencySymbol} {hasChanges && previewPrice !== totalPrice ? previewPrice : totalPrice} {translate("etkecc.components.per_month")} {hasChanges && previewPrice !== totalPrice && ( <Chip label={translate("etkecc.components.preview_label")} size="small" color="primary" sx={{ ml: 0.5, height: 18, fontSize: "0.65rem" }} /> )} </Typography> </Box> </Box> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> {hasChanges && sessionRequestId !== null && ( <Alert severity="info" sx={{ py: 0, px: 1, borderRadius: 2, fontSize: "0.75rem" }} action={ <Button size="small" onClick={() => redirect(`/support/${sessionRequestId}`)} sx={{ cursor: "pointer" }} > {translate("etkecc.components.request_already_sent_view")} </Button> } > {translate("etkecc.components.request_already_sent")} </Alert> )} {sessionRequestId === null && ( <Button variant="contained" disabled={!hasChanges || submitting} onClick={handleRequestChanges} sx={{ flexShrink: 0 }} > {submitting ? <CircularProgress size={16} sx={{ mr: 1 }} /> : null} {translate(submitting ? "etkecc.components.requesting" : "etkecc.components.request_changes")} </Button> )} </Box> </Stack> </Paper> )} <Dialog open={submittedRequest !== null} onClose={() => setSubmittedRequest(null)} fullScreen={isSmall}> <DialogTitle>{translate("etkecc.components.request_sent_title")}</DialogTitle> <DialogContent> <DialogContentText>{translate("etkecc.components.request_sent_body")}</DialogContentText> </DialogContent> <DialogActions> <Button onClick={() => setSubmittedRequest(null)}>{translate("etkecc.components.request_sent_close")}</Button> <Button variant="contained" onClick={() => { setSubmittedRequest(null); redirect(`/support/${submittedRequest?.id}`); }} > {translate("etkecc.components.request_sent_view")} </Button> </DialogActions> </Dialog> </Stack> ); }; export default ComponentsPage; ================================================ FILE: src/components/etke.cc/CurrentlyRunningCommand.tsx ================================================ import EngineeringIcon from "@mui/icons-material/Engineering"; import { Tooltip, Typography, Link, Alert } from "@mui/material"; import { useStore, useTranslate } from "react-admin"; import { EtkeAttribution } from "./EtkeAttribution"; import { useInstanceConfig } from "./InstanceConfig"; import { ServerProcessResponse } from "../../providers/types"; import { getTimeSince } from "../../utils/date"; const CurrentlyRunningCommand = () => { const translate = useTranslate(); const [serverProcess, _setServerProcess] = useStore<ServerProcessResponse>("serverProcess", { command: "", locked_at: "", maintenance: false, }); const icfg = useInstanceConfig(); const { command, locked_at, maintenance } = serverProcess; if (!command || !locked_at || maintenance) { return null; } const { timeI18Nkey, timeI18Nparams } = getTimeSince(locked_at); const timeSince = translate(timeI18Nkey, timeI18Nparams); return ( <Alert icon={<EngineeringIcon />} severity="warning"> <Typography variant="h5"> {translate("etkecc.currently_running.command")}{" "} {icfg.disabled.attributions && <Typography>{command}</Typography>} <EtkeAttribution> <Link href={"https://etke.cc/help/extras/scheduler/#" + command} target="_blank"> {command} </Link> </EtkeAttribution> <Tooltip title={locked_at.toString()}> <Typography component="span" color="text.secondary" sx={{ display: "inline-block", ml: 1 }}> {translate("etkecc.currently_running.started_ago", { time: timeSince })} </Typography> </Tooltip> </Typography> </Alert> ); }; export default CurrentlyRunningCommand; ================================================ FILE: src/components/etke.cc/EtkeAttribution.test.tsx ================================================ import { render, screen } from "@testing-library/react"; import { EtkeAttribution } from "./EtkeAttribution"; vi.mock("./InstanceConfig", () => ({ useInstanceConfig: vi.fn(), })); import { useInstanceConfig } from "./InstanceConfig"; const mockUseInstanceConfig = vi.mocked(useInstanceConfig); describe("EtkeAttribution", () => { it("renders children when attributions are not disabled", () => { mockUseInstanceConfig.mockReturnValue({ disabled: { attributions: false } } as ReturnType< typeof useInstanceConfig >); render( <EtkeAttribution> <span>Footer content</span> </EtkeAttribution> ); expect(screen.getByText("Footer content")).toBeTruthy(); }); it("hides children when attributions are disabled", () => { mockUseInstanceConfig.mockReturnValue({ disabled: { attributions: true } } as ReturnType<typeof useInstanceConfig>); render( <EtkeAttribution> <span>Footer content</span> </EtkeAttribution> ); expect(screen.queryByText("Footer content")).toBeNull(); }); }); ================================================ FILE: src/components/etke.cc/EtkeAttribution.tsx ================================================ import { PropsWithChildren } from "react"; import { useInstanceConfig } from "./InstanceConfig"; export const EtkeAttribution: React.FC<PropsWithChildren> = ({ children }) => { const icfg = useInstanceConfig(); if (icfg.disabled.attributions) { return null; } return <>{children}</>; }; ================================================ FILE: src/components/etke.cc/InstanceConfig.tsx ================================================ import { useSyncExternalStore } from "react"; import createLogger from "../../utils/logger"; const log = createLogger("instance-config"); export interface InstanceConfig { name?: string; logo_url?: string; favicon_url?: string; background_url?: string; disabled: DisableFeatures; } export interface DisableFeatures { support?: boolean; actions?: boolean; attributions?: boolean; federation?: boolean; monitoring?: boolean; notifications?: boolean; payments?: boolean; registration_tokens?: boolean; } let instanceConfig: InstanceConfig = { name: "", logo_url: "", favicon_url: "", background_url: "", disabled: {}, }; type InstanceConfigListener = () => void; const instanceConfigListeners = new Set<InstanceConfigListener>(); const notifyInstanceConfigListeners = () => { instanceConfigListeners.forEach(listener => listener()); }; export const FetchInstanceConfig = async (etkeccAdminUrl: string | undefined, locale = "") => { if (!etkeccAdminUrl || etkeccAdminUrl === "") { return; } try { const resp = await fetch(`${etkeccAdminUrl}/config`, { headers: { "Accept-Language": locale, }, }); if (resp.status === 200) { const configJSON = (await resp.json()) as InstanceConfig; instanceConfig = configJSON; log.debug("instance config loaded", { url: etkeccAdminUrl }); notifyInstanceConfigListeners(); return; } switch (resp.status) { case 204: return; case 429: setTimeout(() => FetchInstanceConfig(etkeccAdminUrl, locale), 1000); return; } log.error(`FetchInstanceConfig: HTTP ${resp.status} ${resp.statusText}`, { url: etkeccAdminUrl }); } catch (e) { log.error("FetchInstanceConfig failed", { url: etkeccAdminUrl, error: e }); } }; export const GetInstanceConfig = () => instanceConfig; export const ClearInstanceConfig = () => { instanceConfig = { name: "", logo_url: "", favicon_url: "", background_url: "", disabled: {}, }; notifyInstanceConfigListeners(); }; export const SubscribeInstanceConfig = (listener: InstanceConfigListener) => { instanceConfigListeners.add(listener); return () => { instanceConfigListeners.delete(listener); }; }; export const useInstanceConfig = () => useSyncExternalStore(SubscribeInstanceConfig, GetInstanceConfig); ================================================ FILE: src/components/etke.cc/README.md ================================================ # 🌟 etke.cc-specific components This is where the etke.cc magic lives. We build everything open-source wherever possible — but some things are purpose-built for the [etke.cc](https://etke.cc) platform and simply wouldn't make sense anywhere else. This directory contains those components: deeply integrated features that turn Ketesa into a full control plane for managed Matrix servers. > ⚠️ **Heads up:** These components are only available for [etke.cc](https://etke.cc) customers and are documented here rather than in the [main docs](../../../docs/README.md). They are **not supported** as part of the Ketesa open-source project — no issues, no PRs, no support requests. --- ## 🧩 Components ### 🟢 Server Status icon A live monitoring indicator in the sidebar showing current server health at a glance. It polls your server in the background and updates automatically — no manual refresh needed. | Color | Meaning | |-------|---------| | 🟢 Green | Server is up and running — no issues detected | | 🟡 Yellow | Server is up, but a command is in progress (likely [maintenance](https://etke.cc/help/extras/scheduler/#maintenance)) — temporary issues may occur, that's expected and fine | | 🔴 Red | At least one component has an issue — click to see what and why | --- ### 📊 Server Status page | Light | Dark | |-------|------| | ![Server Status Page (light)](../../../docs/screenshots/light/server-status.webp) | ![Server Status Page (dark)](../../../docs/screenshots/dark/server-status.webp) | Click the [Server Status icon](#-server-status-icon) to open this page. It surfaces the full [monitoring report](https://etke.cc/services/monitoring/) for your server — the same report that etke.cc's monitoring system watches around the clock: - **Overall server status** — up, updating, or has issues - **Currently running command** — if maintenance is in progress, you'll see exactly what's happening - **Per-component breakdown** — every service on your server shown individually with its status, error details, and suggested corrective actions, grouped by category This is the first place to check when something feels off. If a component is red, the suggested action tells you what to do — often it's a single click in the [Server Actions page](#-server-actions-page). --- ### 🔔 Server Notifications icon An unread badge in the application bar showing the count of unread server notifications. Notifications are generated by the etke.cc platform for events like: - Completed or failed server commands - Service alerts and recoveries - Important platform announcements - Scheduled maintenance reminders The badge clears as you read and dismiss notifications. --- ### 📬 Server Notifications page | Light | Dark | |-------|------| | ![Server Notifications (light)](../../../docs/screenshots/light/server-notifications.webp) | ![Server Notifications (dark)](../../../docs/screenshots/dark/server-notifications.webp) | Click any notification in the [Server Notifications icon](#-server-notifications-icon)'s dropdown to open this page. It shows the full text of every server notification in one place, with the most recent at the top. You can dismiss individual notifications or clear them all at once. --- ### ⚡ Server Actions page | Light | Dark | |-------|------| | ![Server Actions (light)](../../../docs/screenshots/light/server-actions.webp) | ![Server Actions (dark)](../../../docs/screenshots/dark/server-actions.webp) | Accessible via the **Server Actions** sidebar menu item. This is your command center for server management — everything you'd normally do over SSH or by contacting support, available in one place: | Action type | What it does | |-------------|-------------| | **Run now** | Execute a management command immediately — result arrives as a notification | | **[Schedule](https://etke.cc/help/extras/scheduler/#schedule)** | Run a command at a specific date and time — useful for planned maintenance windows | | **[Recurring](https://etke.cc/help/extras/scheduler/#recurring)** | Configure a command to run automatically at a set time every week — for routine tasks like backups or cleanups | The page includes a full list of [available management commands](https://etke.cc/help/extras/scheduler/#commands) — things like restarting services, running updates, triggering backups, and more. Each command runs with a single click. Some commands accept optional arguments for fine-grained control. > 💡 Commands that would normally require SSH access or a support ticket are available here directly. No terminal, no waiting. --- ### 🧩 Components page | Light | Dark | |-------|------| | ![Components (light)](../../../docs/screenshots/light/components.webp) | ![Components (dark)](../../../docs/screenshots/dark/components.webp) | Accessible via the **Components** sidebar menu item. A self-service catalogue for your server's add-ons — bridges, bots, apps, and extras: - **Your Server card** — see every active component and its price at a glance - **Add-on sections** — browse what's available: Bridges, Extras, Matrix Apps, Matrix Bots, Matrix Extras - **Live price preview** — stage additions and removals, see the new monthly total before committing - **One-click requests** — hit **Request changes** to automatically submit a support ticket; no manual back-and-forth needed Pack-based components (e.g., Bridges) show **Available** when the pack is active — meaning they're included in your pack at no extra charge. Individual add-ons display their monthly price upfront so there are no surprises. [📄 Full Components guide](../../../docs/components.md) --- ### 💳 Billing page | Light | Dark | |-------|------| | ![Billing (light)](../../../docs/screenshots/light/billing-list.webp) | ![Billing (dark)](../../../docs/screenshots/dark/billing-list.webp) | Accessible via the **Billing** sidebar menu item. Gives you a full view of your etke.cc account's financial history: - **Payment history** — all successful transactions with dates and amounts - **Subscriptions and one-time payments** — both are shown with their details - **Invoice download** — download a PDF invoice for any transaction directly from this page No need to log in to a separate billing portal or contact support to get an invoice. --- ### 💬 Support page | Light | Dark | |-------|------| | ![Support — Create Ticket (light)](../../../docs/screenshots/light/support-create.webp) | ![Support — Create Ticket (dark)](../../../docs/screenshots/dark/support-create.webp) | | ![Support — Open Thread (light)](../../../docs/screenshots/light/support-thread-open.webp) | ![Support — Open Thread (dark)](../../../docs/screenshots/dark/support-thread-open.webp) | | ![Support — Closed Thread (light)](../../../docs/screenshots/light/support-thread-closed.webp) | ![Support — Closed Thread (dark)](../../../docs/screenshots/dark/support-thread-closed.webp) | Accessible via the **Contact support** sidebar menu item. A full support ticketing interface built directly into Ketesa: - **View all your tickets** — see open, pending, and resolved requests in one list - **Create new tickets** — describe your issue and submit without leaving the admin panel - **Bidirectional messaging** — exchange messages with the etke.cc support team and see their replies inline > 💡 All communication is mirrored to email — replies arrive in your inbox and you can respond from there too. Both interfaces stay in sync, so you can use whichever is more convenient. --- ### 🎨 Instance config White-label Ketesa and tailor the feature set for your deployment — all driven by platform configuration, no rebuild or deploy step needed. The configuration is fetched automatically from the etke.cc API when Ketesa loads. **White-labeling** — make Ketesa look like yours: | Setting | What it changes | |---------|----------------| | Application name | Browser tab title and error page headings | | Logo | Image shown on the login page | | Favicon | Browser tab icon | | Background image | Full-page background on the login screen | **Disabling features** — hide sections that aren't relevant to your setup: | Feature | What gets hidden | |---------|-----------------| | Server Actions | The Server Actions page and sidebar entry | | Server Status | The status icon in the sidebar | | Server Notifications | The notifications badge and page | | Billing | The Billing page and sidebar entry | | Support | The Contact support page and sidebar entry | | Federation | The Federation overview page | | Invite tokens | The Registration tokens page | > 📝 etke.cc attributions (footer links and branding) can be removed on the appropriate plan. Contact support if you need this. ================================================ FILE: src/components/etke.cc/RichTextEditor.tsx ================================================ import FormatBoldIcon from "@mui/icons-material/FormatBold"; import FormatItalicIcon from "@mui/icons-material/FormatItalic"; import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted"; import FormatListNumberedIcon from "@mui/icons-material/FormatListNumbered"; import { Box, Divider, IconButton, Paper, Tooltip } from "@mui/material"; import Placeholder from "@tiptap/extension-placeholder"; import { EditorContent, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { useEffect } from "react"; interface RichTextEditorProps { value: string; onChange: (html: string) => void; placeholder?: string; disabled?: boolean; minRows?: number; } const RichTextEditor = ({ value, onChange, placeholder, disabled, minRows = 6 }: RichTextEditorProps) => { const editor = useEditor({ extensions: [StarterKit, Placeholder.configure({ placeholder: placeholder ?? "" })], content: value, editable: !disabled, onUpdate: ({ editor }) => { onChange(editor.isEmpty ? "" : editor.getHTML()); }, }); useEffect(() => { if (!editor) return; if (value === "") { if (!editor.isEmpty) { editor.commands.clearContent(); } return; } if (editor.getHTML() !== value) { editor.commands.setContent(value, { emitUpdate: false }); } }, [value, editor]); useEffect(() => { if (editor) editor.setEditable(!disabled); }, [disabled, editor]); const minHeight = `${minRows * 1.6}em`; return ( <Paper elevation={4} sx={{ border: "1px solid", borderColor: "action.selected", "&:focus-within": { borderColor: "primary.main", borderWidth: "2px" }, opacity: disabled ? 0.6 : 1, }} > <Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 0.5, px: 0.5, py: 0.25, borderBottom: "1px solid", borderColor: "action.selected", }} > <Tooltip title="Bold"> <span> <IconButton size="small" onMouseDown={e => { e.preventDefault(); editor?.chain().focus().toggleBold().run(); }} disabled={disabled} color={editor?.isActive("bold") ? "primary" : "default"} > <FormatBoldIcon fontSize="small" /> </IconButton> </span> </Tooltip> <Tooltip title="Italic"> <span> <IconButton size="small" onMouseDown={e => { e.preventDefault(); editor?.chain().focus().toggleItalic().run(); }} disabled={disabled} color={editor?.isActive("italic") ? "primary" : "default"} > <FormatItalicIcon fontSize="small" /> </IconButton> </span> </Tooltip> <Divider orientation="vertical" flexItem sx={{ my: 0.5 }} /> <Tooltip title="Bullet list"> <span> <IconButton size="small" onMouseDown={e => { e.preventDefault(); editor?.chain().focus().toggleBulletList().run(); }} disabled={disabled} color={editor?.isActive("bulletList") ? "primary" : "default"} > <FormatListBulletedIcon fontSize="small" /> </IconButton> </span> </Tooltip> <Tooltip title="Numbered list"> <span> <IconButton size="small" onMouseDown={e => { e.preventDefault(); editor?.chain().focus().toggleOrderedList().run(); }} disabled={disabled} color={editor?.isActive("orderedList") ? "primary" : "default"} > <FormatListNumberedIcon fontSize="small" /> </IconButton> </span> </Tooltip> </Box> <Box sx={{ cursor: disabled ? "not-allowed" : "text", "& .tiptap": { outline: "none", minHeight, px: 1.5, py: 1, "& > * + *": { marginTop: "0.5em" }, "& ul, & ol": { paddingLeft: "1.5em" }, "& p.is-editor-empty:first-child::before": { color: "text.disabled", content: "attr(data-placeholder)", float: "left", height: 0, pointerEvents: "none", }, }, }} onClick={() => editor?.commands.focus()} > <EditorContent editor={editor} /> </Box> </Paper> ); }; export default RichTextEditor; ================================================ FILE: src/components/etke.cc/ServerActionsPage.tsx ================================================ import RestoreIcon from "@mui/icons-material/Restore"; import ScheduleIcon from "@mui/icons-material/Schedule"; import { Box, Typography, Link, Stack } from "@mui/material"; import { Title, useTranslate } from "react-admin"; import CurrentlyRunningCommand from "./CurrentlyRunningCommand"; import { EtkeAttribution } from "./EtkeAttribution"; import ServerCommandsPanel from "./ServerCommandsPanel"; import { useDocTitle } from "../hooks/useDocTitle"; import RecurringCommandsList from "./schedules/components/recurring/RecurringCommandsList"; import ScheduledCommandsList from "./schedules/components/scheduled/ScheduledCommandsList"; const ServerActionsPage = () => { const translate = useTranslate(); useDocTitle(translate("etkecc.actions.name")); return ( <> <Title title={translate("etkecc.actions.name")} /> <Stack spacing={3} mt={3}> <Stack direction="column"> <CurrentlyRunningCommand /> <ServerCommandsPanel /> </Stack> <Box sx={{ mt: 2 }}> <Typography variant="h5"> <ScheduleIcon sx={{ verticalAlign: "middle", mr: 1 }} /> {translate("etkecc.actions.scheduled_title")} </Typography> <Typography variant="body1">{translate("etkecc.actions.scheduled_description")}</Typography> <EtkeAttribution> <Typography> {translate("etkecc.actions.scheduled_help_intro")}{" "} <Link href="https://etke.cc/help/extras/scheduler/#schedule" target="_blank"> etke.cc/help/extras/scheduler/#schedule </Link> . </Typography> </EtkeAttribution> <ScheduledCommandsList /> </Box> <Box sx={{ mt: 2 }}> <Typography variant="h5"> <RestoreIcon sx={{ verticalAlign: "middle", mr: 1 }} /> {translate("etkecc.actions.recurring_title")} </Typography> <Typography variant="body1">{translate("etkecc.actions.recurring_description")}</Typography> <EtkeAttribution> <Typography> {translate("etkecc.actions.recurring_help_intro")}{" "} <Link href="https://etke.cc/help/extras/scheduler/#recurring" target="_blank"> etke.cc/help/extras/scheduler/#recurring </Link> . </Typography> </EtkeAttribution> <RecurringCommandsList /> </Box> </Stack> </> ); }; export default ServerActionsPage; ================================================ FILE: src/components/etke.cc/ServerCommandsPanel.tsx ================================================ import { PlayArrow, CheckCircle, HelpCenter, Construction } from "@mui/icons-material"; import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Alert, TextField, Autocomplete, Box, Button as MuiButton, Link, Stack, Typography, useMediaQuery, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import { useEffect, useState } from "react"; import { Button, Link as RouterLink, Loading, useDataProvider, useCreatePath, useLocale, useStore, useTranslate, } from "react-admin"; import { EtkeAttribution } from "./EtkeAttribution"; import { useAppContext } from "../../Context"; import { useServerCommands } from "./hooks/useServerCommands"; import { useUnits } from "./hooks/useUnits"; import { ServerCommand, ServerProcessResponse } from "../../providers/types"; import { Icons } from "../../utils/icons"; import createLogger from "../../utils/logger"; const log = createLogger("commands"); const renderIcon = (icon: string) => { /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const IconComponent = Icons[icon] as React.ComponentType<any> | undefined; return IconComponent ? <IconComponent sx={{ verticalAlign: "middle", mr: 1 }} /> : null; }; const ServerCommandsPanel = () => { const { etkeccAdmin } = useAppContext(); const createPath = useCreatePath(); const translate = useTranslate(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const { isLoading, maintenance, serverCommands, setServerCommands } = useServerCommands(); const { units } = useUnits(); const [serverProcess, setServerProcess] = useStore<ServerProcessResponse>("serverProcess", { command: "", locked_at: "", maintenance: false, }); const [commandIsRunning, setCommandIsRunning] = useState<boolean>(serverProcess.command !== ""); const [commandResult, setCommandResult] = useState<React.ReactNode[]>([]); const dataProvider = useDataProvider(); const locale = useLocale(); useEffect(() => { if (serverProcess.command === "") { setCommandIsRunning(false); } }, [serverProcess]); if (!etkeccAdmin) { return null; } const setCommandAdditionalArgs = (command: string, additionalArgs: string) => { const updatedServerCommands = { ...serverCommands }; updatedServerCommands[command].additionalArgs = additionalArgs; setServerCommands(updatedServerCommands); }; const runCommand = async (command: string) => { setCommandResult([]); setCommandIsRunning(true); try { const additionalArgs = serverCommands[command].additionalArgs || ""; const requestParams = additionalArgs ? { args: additionalArgs } : {}; const response = await dataProvider.runServerCommand(etkeccAdmin, command, requestParams); if (response.maintenance) { setCommandIsRunning(false); setCommandResult([ <Box key="maintenance-warning"> {translate("etkecc.actions.maintenance_title")} {translate("etkecc.actions.maintenance_commands_blocked")}{" "} {translate("etkecc.actions.maintenance_note")} </Box>, ]); return; } if (!response.success) { setCommandIsRunning(false); return; } // Update UI with success message const commandResults = buildCommandResultMessages(command, additionalArgs); setCommandResult(commandResults); // Reset the additional args field resetCommandArgs(command); // Update server process status await updateServerProcessStatus(serverCommands[command]); } catch (error) { log.error("command execution failed", error); setCommandIsRunning(false); } }; const buildCommandResultMessages = (command: string, additionalArgs: string): React.ReactNode[] => { const results: React.ReactNode[] = []; let commandScheduledText = translate("etkecc.actions.command_scheduled", { command }); if (additionalArgs) { commandScheduledText += `, ${translate("etkecc.actions.command_scheduled_args", { args: additionalArgs })}`; } results.push(<Box key="command-text">{commandScheduledText}</Box>); results.push( <Box key="notification-link"> {translate("etkecc.actions.expect_prefix")}{" "} <RouterLink to={createPath({ resource: "server_notifications", type: "list" })}> {translate("etkecc.actions.notifications_link")} </RouterLink>{" "} {translate("etkecc.actions.expect_suffix")} </Box> ); return results; }; const resetCommandArgs = (command: string) => { const updatedServerCommands = { ...serverCommands }; updatedServerCommands[command].additionalArgs = ""; setServerCommands(updatedServerCommands); }; const updateServerProcessStatus = async (command: ServerCommand) => { const commandIsLocking = command.with_lock; const serverProcess = await dataProvider.getServerRunningProcess(etkeccAdmin, locale, true); if (!commandIsLocking && serverProcess.command === "") { // if command is not locking, we simulate the "lock" mechanism so notifications will be refetched serverProcess["command"] = command.name; serverProcess["locked_at"] = new Date().toISOString(); } setServerProcess({ ...serverProcess }); }; if (isLoading) { return <Loading />; } if (maintenance) { return ( <Alert severity="info"> {translate("etkecc.actions.maintenance_title")} <br /> {translate("etkecc.actions.maintenance_try_again")} <br /> {translate("etkecc.actions.maintenance_note")} </Alert> ); } return ( <> <Typography variant="h5"> <Construction sx={{ verticalAlign: "middle", mr: 1 }} /> {translate("etkecc.actions.available_title")} </Typography> <EtkeAttribution> <Typography variant="body1" sx={{ mt: 0 }}> {translate("etkecc.actions.available_description")} {translate("etkecc.actions.available_help_intro")}{" "} <Link href="https://etke.cc/help/extras/scheduler/#commands" target="_blank"> etke.cc/help/extras/scheduler/#commands </Link> . </Typography> </EtkeAttribution> {isSmall ? ( <Stack spacing={1} sx={{ mt: 2 }}> {Object.entries(serverCommands).map(([command, { icon, args, description, additionalArgs }]) => ( <Paper key={command} sx={{ p: 2 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}> {renderIcon(icon)} <Typography variant="subtitle2" sx={{ fontWeight: "bold" }}> {command} </Typography> <Link href={"https://etke.cc/help/extras/scheduler/#" + command} target="_blank" sx={{ ml: "auto" }}> <HelpCenter fontSize="small" /> </Link> </Box> {description && ( <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> {description} </Typography> )} <Stack spacing={1}> {args && command === "restart" && ( <Autocomplete freeSolo size="small" options={Object.keys(units)} inputValue={additionalArgs || ""} onInputChange={(_e, value) => { setCommandAdditionalArgs(command, units[value] || value); }} renderInput={params => ( <TextField {...params} variant="standard" label={translate("etkecc.actions.table.arguments")} /> )} /> )} {args && command !== "restart" && ( <TextField size="small" variant="standard" label={translate("etkecc.actions.table.arguments")} onChange={e => { setCommandAdditionalArgs(command, e.target.value); }} value={additionalArgs} fullWidth /> )} <MuiButton variant="contained" color="primary" startIcon={<PlayArrow />} fullWidth onClick={() => { runCommand(command); }} disabled={ commandIsRunning || (args && typeof additionalArgs === "string" && additionalArgs.length === 0) } > {translate("etkecc.actions.buttons.run")} </MuiButton> </Stack> </Paper> ))} </Stack> ) : ( <TableContainer component={Paper} sx={{ mt: 2 }}> <Table sx={{ minWidth: 450 }} size="small" aria-label={translate("etkecc.actions.table.aria_label")}> <TableHead> <TableRow> <TableCell>{translate("etkecc.actions.table.command")}</TableCell> <TableCell></TableCell> <TableCell>{translate("etkecc.actions.table.description")}</TableCell> <TableCell></TableCell> </TableRow> </TableHead> <TableBody> {Object.entries(serverCommands).map(([command, { icon, args, description, additionalArgs }]) => ( <TableRow key={command}> <TableCell scope="row"> <Box> {renderIcon(icon)} {command} </Box> </TableCell> <TableCell> <Link href={"https://etke.cc/help/extras/scheduler/#" + command} target="_blank"> <Button size="small" startIcon={<HelpCenter />} title={translate("etkecc.actions.command_help_title", { command })} /> </Link> </TableCell> <TableCell>{description}</TableCell> <TableCell> {args && command === "restart" && ( <Autocomplete freeSolo size="small" options={Object.keys(units)} inputValue={additionalArgs || ""} onInputChange={(_e, value) => { setCommandAdditionalArgs(command, units[value] || value); }} renderInput={params => ( <TextField {...params} variant="standard" label={translate("etkecc.actions.table.arguments")} /> )} /> )} {args && command !== "restart" && ( <TextField size="small" variant="standard" label={translate("etkecc.actions.table.arguments")} onChange={e => { setCommandAdditionalArgs(command, e.target.value); }} value={additionalArgs} /> )} <Button size="small" variant="contained" color="primary" label={translate("etkecc.actions.buttons.run")} onClick={() => { runCommand(command); }} disabled={ commandIsRunning || (args && typeof additionalArgs === "string" && additionalArgs.length === 0) } > <PlayArrow /> </Button> </TableCell> </TableRow> ))} </TableBody> </Table> </TableContainer> )} {commandResult.length > 0 && ( <Alert icon={<CheckCircle fontSize="inherit" />} severity="success"> {commandResult.map((result, index) => ( <div key={`cmd-result-${index}`}>{result}</div> ))} </Alert> )} </> ); }; export default ServerCommandsPanel; ================================================ FILE: src/components/etke.cc/ServerNotificationsBadge.test.tsx ================================================ import { act, fireEvent, render, screen } from "@testing-library/react"; import { memoryStore } from "ra-core"; import polyglotI18nProvider from "ra-i18n-polyglot"; import { AdminContext, DataProvider } from "react-admin"; import { ServerNotificationsBadge } from "./ServerNotificationsBadge"; import { AppContext } from "../../Context"; import englishMessages from "../../i18n/en"; import { Config } from "../../utils/config"; import { NotificationsStatus, ServerNotification } from "../../providers/types"; const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]); const baseConfig: Config = { restrictBaseUrl: "", corsCredentials: "include", asManagedUsers: [], menu: [], externalAuthProvider: false, etkeccAdmin: "https://example.com", }; interface RenderOpts { status: NotificationsStatus; notifications?: ServerNotification[]; getServerNotifications?: ReturnType<typeof vi.fn>; } const renderBadge = async ({ status, notifications = [], getServerNotifications }: RenderOpts) => { const response = { success: status !== "unavailable", status, notifications }; const fetchFn = getServerNotifications ?? vi.fn().mockResolvedValue(response); const dataProvider = { getServerNotifications: fetchFn, deleteServerNotifications: vi.fn().mockResolvedValue({ success: true }), }; const store = memoryStore({ serverNotifications: response, serverProcess: { command: "", locked_at: "", maintenance: false }, }); await act(async () => { render( <AppContext.Provider value={baseConfig}> <AdminContext i18nProvider={i18nProvider} store={store} dataProvider={dataProvider as unknown as DataProvider}> <ServerNotificationsBadge /> </AdminContext> </AppContext.Provider> ); }); await act(async () => { await Promise.resolve(); }); return { dataProvider }; }; describe("ServerNotificationsBadge", () => { afterEach(() => { vi.clearAllMocks(); }); it("renders count badge when status is ok and list is non-empty", async () => { await renderBadge({ status: "ok", notifications: [ { event_id: "$1", output: "n1", sent_at: "2026-04-22 10:00:00" }, { event_id: "$2", output: "n2", sent_at: "2026-04-22 11:00:00" }, ], }); expect(screen.getByText("2")).toBeInTheDocument(); }); it("renders bell with no-notifications tooltip when status is ok and list is empty", async () => { await renderBadge({ status: "ok", notifications: [] }); expect(screen.getByRole("button", { name: /No notifications yet/i })).toBeInTheDocument(); }); it("renders advisory tooltip when status is advisory", async () => { await renderBadge({ status: "advisory", notifications: [] }); expect(screen.getByRole("button", { name: /You may have missed a notification/i })).toBeInTheDocument(); }); it("renders unavailable tooltip when status is unavailable", async () => { await renderBadge({ status: "unavailable", notifications: [] }); expect(screen.getByRole("button", { name: /Notifications may be unavailable/i })).toBeInTheDocument(); }); it("opens the guidance panel and triggers refetch when clicked in unavailable state", async () => { const refetch = vi.fn().mockResolvedValue({ success: false, status: "unavailable" as NotificationsStatus, notifications: [], }); const { dataProvider } = await renderBadge({ status: "unavailable", notifications: [], getServerNotifications: refetch, }); // Initial mount triggers one fetch; opening the Popper in unavailable state triggers exactly one more. const initialCalls = dataProvider.getServerNotifications.mock.calls.length; await act(async () => { fireEvent.click(screen.getByRole("button", { name: /Notifications may be unavailable/i })); }); expect(screen.getByText(/Notifications may be unavailable right now/i)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Retry/i })).toBeInTheDocument(); expect(dataProvider.getServerNotifications).toHaveBeenCalledTimes(initialCalls + 1); }); }); ================================================ FILE: src/components/etke.cc/ServerNotificationsBadge.tsx ================================================ import DeleteIcon from "@mui/icons-material/Delete"; import NotificationsIcon from "@mui/icons-material/Notifications"; import { Badge, useTheme, Button, Paper, Popper, ClickAwayListener, Box, List, ListItem, ListItemText, Typography, ListSubheader, IconButton, Divider, Tooltip, } from "@mui/material"; import { Fragment, useCallback, useEffect, useState } from "react"; import { useDataProvider, useLocale, useRedirect, useStore, useTranslate } from "react-admin"; import { useAppContext } from "../../Context"; import { ServerNotificationsResponse, ServerProcessResponse } from "../../providers/types"; import { getTimeSince } from "../../utils/date"; import { ServerNotificationsUnavailable } from "./ServerNotificationsUnavailable"; // 5 minutes const SERVER_NOTIFICATIONS_INTERVAL_TIME = 300000; const useServerNotifications = () => { const [serverNotifications, setServerNotifications] = useStore<ServerNotificationsResponse>("serverNotifications", { notifications: [], success: false, status: "ok", }); const [serverProcess, _setServerProcess] = useStore<ServerProcessResponse>("serverProcess", { command: "", locked_at: "", maintenance: false, }); const { command, locked_at } = serverProcess; const { etkeccAdmin } = useAppContext(); const dataProvider = useDataProvider(); const locale = useLocale(); const { notifications, status } = serverNotifications; const fetchNotifications = useCallback(async () => { const notificationsResponse: ServerNotificationsResponse = await dataProvider.getServerNotifications( etkeccAdmin, locale, command !== "" ); const serverNotifications = [...notificationsResponse.notifications]; serverNotifications.reverse(); setServerNotifications({ ...notificationsResponse, notifications: serverNotifications, success: notificationsResponse.success, }); }, [dataProvider, etkeccAdmin, locale, command, setServerNotifications]); const deleteServerNotifications = async () => { const deleteResponse = await dataProvider.deleteServerNotifications(etkeccAdmin, locale); if (deleteResponse.success) { setServerNotifications({ notifications: [], success: true, status: "ok", }); } }; useEffect(() => { let serverNotificationsInterval: NodeJS.Timeout | null = null; let timeoutId: NodeJS.Timeout | null = null; if (etkeccAdmin) { fetchNotifications(); timeoutId = setTimeout(() => { // start the interval after the SERVER_NOTIFICATIONS_INTERVAL_TIME to avoid too many requests serverNotificationsInterval = setInterval(fetchNotifications, SERVER_NOTIFICATIONS_INTERVAL_TIME); }, SERVER_NOTIFICATIONS_INTERVAL_TIME); } return () => { if (timeoutId) { clearTimeout(timeoutId); } if (serverNotificationsInterval) { clearInterval(serverNotificationsInterval); } }; }, [etkeccAdmin, fetchNotifications, locked_at]); return { status, notifications, deleteServerNotifications, refetch: fetchNotifications }; }; export const ServerNotificationsBadge = () => { const redirect = useRedirect(); const { status, notifications, deleteServerNotifications, refetch } = useServerNotifications(); const theme = useTheme(); const translate = useTranslate(); // Modify menu state to work with Popper const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const open = Boolean(anchorEl); const handleOpen = (event: React.MouseEvent<HTMLElement>) => { if (status === "unavailable" && !anchorEl) { refetch(); } setAnchorEl(anchorEl ? null : event.currentTarget); }; const handleClose = () => { setAnchorEl(null); }; const handleSeeAllNotifications = () => { handleClose(); redirect("/server_notifications"); }; const handleClearAllNotifications = async () => { deleteServerNotifications(); handleClose(); }; const handleRetry = () => { refetch(); handleClose(); }; const tooltipTitle = status === "unavailable" ? translate("etkecc.notifications.unavailable_tooltip") : status === "advisory" ? translate("etkecc.notifications.advisory_tooltip") : notifications && notifications.length > 0 ? translate("etkecc.notifications.new_notifications", { smart_count: notifications.length }) : translate("etkecc.notifications.no_notifications"); const bellColor = status === "unavailable" ? theme.palette.action.disabled : theme.palette.common.white; return ( <Box> <IconButton onClick={handleOpen} sx={{ color: bellColor }} aria-label={tooltipTitle}> <Tooltip title={tooltipTitle}> {status === "advisory" ? ( <Badge color="warning" variant="dot" overlap="circular"> <NotificationsIcon /> </Badge> ) : status === "ok" && notifications && notifications.length > 0 ? ( <Badge badgeContent={notifications.length} color="error"> <NotificationsIcon /> </Badge> ) : ( <NotificationsIcon sx={{ opacity: status === "unavailable" ? 0.4 : 0.5 }} /> )} </Tooltip> </IconButton> <Popper open={open} anchorEl={anchorEl} placement="bottom-end" style={{ zIndex: 1300 }}> <ClickAwayListener onClickAway={handleClose}> <Paper elevation={3} sx={{ p: 1, maxHeight: "350px", paddingTop: 0, overflowY: "auto", minWidth: "300px", maxWidth: { xs: "100vw", // Full width on mobile sm: "400px", // Fixed width on desktop }, }} > {status === "unavailable" ? ( <ServerNotificationsUnavailable onRetry={handleRetry} /> ) : !notifications || notifications.length === 0 ? ( <Typography sx={{ p: 1 }} variant="body2"> {translate("etkecc.notifications.no_notifications")} </Typography> ) : ( <List sx={{ p: 0 }} dense={true}> <ListSubheader sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontWeight: "bold", }} > <Typography variant="h6">{translate("etkecc.notifications.title")}</Typography> <Box sx={{ cursor: "pointer", color: theme.palette.primary.main }} onClick={() => handleSeeAllNotifications()} > {translate("etkecc.notifications.see_all")} </Box> </ListSubheader> <Divider /> {notifications.map((notification, index) => { const { timeI18Nkey, timeI18Nparams } = getTimeSince(notification.sent_at); return ( <Fragment key={notification.event_id ? notification.event_id + index : index}> <ListItem onClick={() => handleSeeAllNotifications()} sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start", overflow: "hidden", "&:hover": { backgroundColor: "action.hover", cursor: "pointer", }, }} > <ListItemText primary={ <Typography variant="body2" sx={{ overflow: "hidden", textOverflow: "ellipsis", }} dangerouslySetInnerHTML={{ __html: notification.output.split("\n")[0] }} /> } /> <ListItemText primary={ <Typography variant="body2" sx={{ color: theme.palette.text.secondary }}> {translate(timeI18Nkey, timeI18Nparams) + " " + translate("etkecc.notifications.ago")} </Typography> } /> </ListItem> <Divider /> </Fragment> ); })} <ListItem> <Button key="clear-all-notifications" onClick={() => handleClearAllNotifications()} size="small" color="error" sx={{ pl: 0, pt: 1, verticalAlign: "middle", }} > <DeleteIcon fontSize="small" sx={{ mr: 1 }} /> {translate("etkecc.notifications.clear_all")} </Button> </ListItem> </List> )} </Paper> </ClickAwayListener> </Popper> </Box> ); }; ================================================ FILE: src/components/etke.cc/ServerNotificationsPage.tsx ================================================ import DeleteIcon from "@mui/icons-material/Delete"; import { Box, Typography, Paper, Button } from "@mui/material"; import { Stack } from "@mui/material"; import { Tooltip } from "@mui/material"; import { Title, useLocale, useStore, useTranslate } from "react-admin"; import { useAppContext } from "../../Context"; import dataProvider from "../../providers/data"; import { ServerNotificationsResponse } from "../../providers/types"; import { getTimeSince } from "../../utils/date"; import { useDocTitle } from "../hooks/useDocTitle"; const ServerNotificationsPage = () => { const locale = useLocale(); const translate = useTranslate(); const { etkeccAdmin } = useAppContext(); const [serverNotifications, setServerNotifications] = useStore<ServerNotificationsResponse>("serverNotifications", { notifications: [], success: false, status: "ok", }); useDocTitle(translate("etkecc.notifications.title")); const notifications = serverNotifications.notifications; return ( <> <Title title={translate("etkecc.notifications.title")} /> <Stack spacing={3} mt={3}> <Stack spacing={1} direction="row" alignItems="center"> <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", gap: 1 }}> <Typography variant="h4">{translate("etkecc.notifications.title")}</Typography> <Button variant="text" color="error" onClick={async () => { await dataProvider.deleteServerNotifications(etkeccAdmin!, locale); setServerNotifications({ notifications: [], success: true, status: "ok", }); }} > <DeleteIcon fontSize="small" sx={{ mr: 1 }} /> {translate("etkecc.notifications.clear_all")} </Button> </Box> </Stack> {notifications.length === 0 ? ( <Paper sx={{ p: 2 }}> <Typography>{translate("etkecc.notifications.no_notifications")}</Typography> </Paper> ) : ( notifications.map((notification, index) => { const { timeI18Nkey, timeI18Nparams } = getTimeSince(notification.sent_at); const tooltipTitle = new Date(notification.sent_at.replace(" ", "T") + "Z").toLocaleString(locale); return ( <Paper key={notification.event_id ? notification.event_id : index} sx={{ p: 2 }}> <Stack spacing={1}> <Typography variant="subtitle1" fontWeight="bold" color="text.secondary"> <Tooltip title={tooltipTitle}> <span> {translate(timeI18Nkey, timeI18Nparams) + " " + translate("etkecc.notifications.ago")} </span> </Tooltip> </Typography> <Typography dangerouslySetInnerHTML={{ __html: notification.output }} /> </Stack> </Paper> ); }) )} </Stack> </> ); }; export default ServerNotificationsPage; ================================================ FILE: src/components/etke.cc/ServerNotificationsUnavailable.test.tsx ================================================ import { fireEvent, render, screen } from "@testing-library/react"; import polyglotI18nProvider from "ra-i18n-polyglot"; import { AdminContext } from "react-admin"; import { ServerNotificationsUnavailable } from "./ServerNotificationsUnavailable"; import englishMessages from "../../i18n/en"; const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]); const renderPanel = (onRetry = vi.fn()) => { render( <AdminContext i18nProvider={i18nProvider}> <ServerNotificationsUnavailable onRetry={onRetry} /> </AdminContext> ); return { onRetry }; }; describe("ServerNotificationsUnavailable", () => { it("renders title, body, Matrix/news/email items, and Retry button", () => { renderPanel(); expect(screen.getByText(/Notifications may be unavailable right now/i)).toBeInTheDocument(); expect(screen.getByText(/updates we can't deliver/i)).toBeInTheDocument(); expect(screen.getByText(/Matrix room #news:etke.cc/i)).toBeInTheDocument(); expect(screen.getByText(/Announcements page at etke.cc\/news/i)).toBeInTheDocument(); expect(screen.getByText(/Your email inbox/i)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Retry/i })).toBeInTheDocument(); }); it("calls onRetry when the Retry button is clicked", () => { const { onRetry } = renderPanel(); fireEvent.click(screen.getByRole("button", { name: /Retry/i })); expect(onRetry).toHaveBeenCalledTimes(1); }); it("renders Matrix and news links with target=_blank and correct href", () => { renderPanel(); const matrixLink = screen.getByText(/Matrix room #news:etke.cc/i).closest("a"); expect(matrixLink).toHaveAttribute("href", "https://matrix.to/#/%23news:etke.cc"); expect(matrixLink).toHaveAttribute("target", "_blank"); expect(matrixLink).toHaveAttribute("rel", "noreferrer"); const newsLink = screen.getByText(/Announcements page at etke.cc\/news/i).closest("a"); expect(newsLink).toHaveAttribute("href", "https://etke.cc/news"); expect(newsLink).toHaveAttribute("target", "_blank"); }); }); ================================================ FILE: src/components/etke.cc/ServerNotificationsUnavailable.tsx ================================================ import { Box, Button, Link, List, ListItem, Typography } from "@mui/material"; import { useTranslate } from "react-admin"; interface Props { onRetry: () => void; } export const ServerNotificationsUnavailable = ({ onRetry }: Props) => { const translate = useTranslate(); return ( <Box sx={{ p: 2, maxWidth: { xs: "100vw", sm: "400px" } }}> <Typography variant="subtitle1" sx={{ fontWeight: "bold", mb: 1 }}> {translate("etkecc.notifications.unavailable_title")} </Typography> <Typography variant="body2" sx={{ mb: 1 }}> {translate("etkecc.notifications.unavailable_body")} </Typography> <List dense sx={{ pl: 2 }}> <ListItem sx={{ display: "list-item", listStyleType: "disc", p: 0 }}> <Link href="https://matrix.to/#/%23news:etke.cc" target="_blank" rel="noreferrer"> {translate("etkecc.notifications.unavailable_link_matrix")} </Link> </ListItem> <ListItem sx={{ display: "list-item", listStyleType: "disc", p: 0 }}> <Link href="https://etke.cc/news" target="_blank" rel="noreferrer"> {translate("etkecc.notifications.unavailable_link_news")} </Link> </ListItem> <ListItem sx={{ display: "list-item", listStyleType: "disc", p: 0 }}> <Typography variant="body2">{translate("etkecc.notifications.unavailable_link_email")}</Typography> </ListItem> </List> <Button variant="outlined" size="small" onClick={onRetry} sx={{ mt: 1 }}> {translate("etkecc.notifications.unavailable_retry")} </Button> </Box> ); }; ================================================ FILE: src/components/etke.cc/ServerStatusBadge.test.tsx ================================================ import { act, render, waitFor } from "@testing-library/react"; import { memoryStore } from "ra-core"; import polyglotI18nProvider from "ra-i18n-polyglot"; import { AdminContext, DataProvider } from "react-admin"; import { EtkeStatusPoller } from "./ServerStatusBadge"; import { AppContext } from "../../Context"; import englishMessages from "../../i18n/en"; import { Config } from "../../utils/config"; const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]); const baseConfig: Config = { restrictBaseUrl: "", corsCredentials: "include", asManagedUsers: [], menu: [], externalAuthProvider: false, etkeccAdmin: "https://example.com", }; const renderPoller = async (config: Config = baseConfig) => { const dataProvider = { getServerStatus: vi.fn().mockResolvedValue({ success: true, ok: true, maintenance: false, host: "", results: [] }), getServerRunningProcess: vi.fn().mockResolvedValue({ command: "", locked_at: "", maintenance: false }), }; const store = memoryStore({ serverStatus: { success: false, ok: false, maintenance: false, host: "", results: [] }, serverProcess: { command: "", locked_at: "", maintenance: false }, }); await act(async () => { render( <AppContext.Provider value={config}> <AdminContext i18nProvider={i18nProvider} store={store} dataProvider={dataProvider as unknown as DataProvider}> <EtkeStatusPoller /> </AdminContext> </AppContext.Provider> ); }); await act(async () => { await Promise.resolve(); }); return { dataProvider }; }; describe("EtkeStatusPoller", () => { afterEach(() => { vi.clearAllMocks(); }); it("polls server status and process when etkeccAdmin is set", async () => { const { dataProvider } = await renderPoller(); await waitFor(() => expect(dataProvider.getServerStatus).toHaveBeenCalledTimes(1)); await waitFor(() => expect(dataProvider.getServerRunningProcess).toHaveBeenCalledTimes(1)); }); it("does not poll when etkeccAdmin is not set", async () => { const { dataProvider } = await renderPoller({ ...baseConfig, etkeccAdmin: "" }); expect(dataProvider.getServerStatus).not.toHaveBeenCalled(); expect(dataProvider.getServerRunningProcess).not.toHaveBeenCalled(); }); }); ================================================ FILE: src/components/etke.cc/ServerStatusBadge.tsx ================================================ import MonitorHeartIcon from "@mui/icons-material/MonitorHeart"; import { Avatar, Badge, Box, Theme } from "@mui/material"; import { BadgeProps } from "@mui/material/Badge"; import { styled } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles"; import { visuallyHidden } from "@mui/utils"; import { useCallback, useEffect } from "react"; import { useDataProvider, useLocale, useStore, useTranslate } from "react-admin"; import { useAppContext } from "../../Context"; import { ServerProcessResponse, ServerStatusResponse } from "../../providers/types"; interface StyledBadgeProps extends BadgeProps { backgroundColor: string; badgeColor: string; theme?: Theme; } const StyledBadge = styled(Badge, { shouldForwardProp: prop => !["badgeColor", "backgroundColor"].includes(prop as string), })<StyledBadgeProps>(({ theme, backgroundColor, badgeColor }) => ({ "& .MuiBadge-badge": { backgroundColor: backgroundColor, color: badgeColor, boxShadow: `0 0 0 2px ${theme.palette.background.paper}`, "&::after": { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", borderRadius: "50%", animation: "ripple 2.5s infinite ease-in-out", border: "1px solid currentColor", content: '""', }, }, "@keyframes ripple": { "0%": { transform: "scale(.8)", opacity: 1, }, "100%": { transform: "scale(2.4)", opacity: 0, }, }, })); // every 5 minutes const SERVER_STATUS_INTERVAL_TIME = 5 * 60 * 1000; // every 5 seconds const SERVER_CURRENT_PROCCESS_INTERVAL_TIME = 5 * 1000; const useServerStatus = () => { const [serverStatus, setServerStatus] = useStore<ServerStatusResponse>("serverStatus", { ok: false, success: false, maintenance: false, host: "", results: [], }); const [serverProcess, _setServerProcess] = useStore<ServerProcessResponse>("serverProcess", { command: "", locked_at: "", maintenance: false, }); const { command } = serverProcess; const { etkeccAdmin } = useAppContext(); const dataProvider = useDataProvider(); const locale = useLocale(); const isOkay = serverStatus.ok; const successCheck = serverStatus.success; const maintenance = serverStatus.maintenance; const checkServerStatus = useCallback(async () => { const serverStatus: ServerStatusResponse = await dataProvider.getServerStatus(etkeccAdmin, locale, command !== ""); setServerStatus({ ok: serverStatus.ok, maintenance: serverStatus.maintenance, success: serverStatus.success, host: serverStatus.host, results: serverStatus.results, }); }, [dataProvider, etkeccAdmin, locale, command, setServerStatus]); useEffect(() => { let serverStatusInterval: NodeJS.Timeout | null = null; let timeoutId: NodeJS.Timeout | null = null; if (etkeccAdmin) { checkServerStatus(); timeoutId = setTimeout(() => { // start the interval after 10 seconds to avoid too many requests serverStatusInterval = setInterval(checkServerStatus, SERVER_STATUS_INTERVAL_TIME); }, 10000); } else { setServerStatus({ ok: false, success: false, host: "", results: [] }); } return () => { if (timeoutId) { clearTimeout(timeoutId); } if (serverStatusInterval) { clearInterval(serverStatusInterval); } }; }, [etkeccAdmin, checkServerStatus, setServerStatus]); return { isOkay, successCheck, maintenance }; }; const useCurrentServerProcess = () => { const [serverProcess, setServerProcess] = useStore<ServerProcessResponse>("serverProcess", { command: "", locked_at: "", maintenance: false, }); const { etkeccAdmin } = useAppContext(); const dataProvider = useDataProvider(); const locale = useLocale(); const { command, locked_at, maintenance } = serverProcess; const checkServerRunningProcess = useCallback(async () => { const serverProcess: ServerProcessResponse = await dataProvider.getServerRunningProcess( etkeccAdmin, locale, command !== "" ); setServerProcess({ ...serverProcess, command: serverProcess.command, locked_at: serverProcess.locked_at, maintenance: serverProcess.maintenance, }); }, [dataProvider, etkeccAdmin, locale, command, setServerProcess]); useEffect(() => { let serverCheckInterval: NodeJS.Timeout | null = null; let timeoutId: NodeJS.Timeout | null = null; if (etkeccAdmin) { checkServerRunningProcess(); timeoutId = setTimeout(() => { serverCheckInterval = setInterval(checkServerRunningProcess, SERVER_CURRENT_PROCCESS_INTERVAL_TIME); }, 5000); } else { setServerProcess({ command: "", locked_at: "", maintenance: false }); } return () => { if (timeoutId) { clearTimeout(timeoutId); } if (serverCheckInterval) { clearInterval(serverCheckInterval); } }; }, [etkeccAdmin, checkServerRunningProcess, setServerProcess]); return { command, locked_at, maintenance }; }; export const ServerStatusStyledBadge = ({ command, locked_at, isOkay, isLoaded, isMaintenance = false, inSidebar = false, }: { command: string; locked_at: string; isOkay: boolean; isLoaded: boolean; isMaintenance?: boolean; inSidebar: boolean; }) => { const theme = useTheme(); const translate = useTranslate(); let badgeBackgroundColor = isLoaded && !isMaintenance ? isOkay ? theme.palette.success.main : theme.palette.error.main : theme.palette.grey[600]; let badgeColor = isLoaded && !isMaintenance ? isOkay ? theme.palette.success.main : theme.palette.error.main : theme.palette.grey[600]; let statusKey = "etkecc.status.badge.status_checking"; if (command && locked_at) { badgeBackgroundColor = theme.palette.warning.main; badgeColor = theme.palette.warning.main; statusKey = "etkecc.status.badge.status_process_running"; } else if (isMaintenance) { statusKey = "etkecc.status.badge.status_maintenance"; } else if (isLoaded) { statusKey = isOkay ? "etkecc.status.badge.status_ok" : "etkecc.status.badge.status_error"; } let avatarBackgroundColor = theme.palette.mode === "dark" ? theme.palette.background.default : theme.palette.primary.main; if (inSidebar) { avatarBackgroundColor = theme.palette.grey[600]; } return ( <> <StyledBadge overlap="circular" anchorOrigin={{ vertical: "bottom", horizontal: "right" }} variant="dot" backgroundColor={badgeBackgroundColor} badgeColor={badgeColor} aria-hidden="true" > <Avatar sx={{ height: 24, width: 24, background: avatarBackgroundColor }}> <MonitorHeartIcon sx={{ height: 22, width: 22, color: theme.palette.common.white }} /> </Avatar> </StyledBadge> <Box component="span" sx={visuallyHidden}> {translate(statusKey)} </Box> </> ); }; export const EtkeStatusPoller = () => { useServerStatus(); useCurrentServerProcess(); return null; }; ================================================ FILE: src/components/etke.cc/ServerStatusPage.test.tsx ================================================ import { render, screen } from "@testing-library/react"; import { memoryStore } from "ra-core"; import polyglotI18nProvider from "ra-i18n-polyglot"; import { AdminContext } from "react-admin"; import ServerStatusPage from "./ServerStatusPage"; import englishMessages from "../../i18n/en"; import { ServerProcessResponse, ServerStatusComponent, ServerStatusResponse } from "../../providers/types"; const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]); const renderWithStore = (serverStatus: ServerStatusResponse, serverProcess?: ServerProcessResponse) => { const store = memoryStore({ serverStatus, serverProcess: serverProcess ?? { command: "", locked_at: "", maintenance: false }, }); return render( <AdminContext i18nProvider={i18nProvider} store={store}> <ServerStatusPage /> </AdminContext> ); }; describe("ServerStatusPage", () => { it("shows maintenance notice when maintenance mode is active", () => { renderWithStore({ success: true, maintenance: true, ok: false, host: "", results: [], }); expect( screen.getAllByText((_, node) => node?.textContent?.includes(englishMessages.etkecc.maintenance.title) ?? false) .length ).toBeGreaterThan(0); expect( screen.getAllByText((_, node) => node?.textContent?.includes(englishMessages.etkecc.maintenance.note) ?? false) .length ).toBeGreaterThan(0); }); it("shows loading state when status is not yet available", () => { renderWithStore({ success: false, maintenance: false, ok: false, host: "", results: [], }); screen.getByText(englishMessages.etkecc.status.loading); }); it("renders grouped results, host, and help link when status is loaded", () => { const results: ServerStatusComponent[] = [ { ok: true, category: "HTTP", reason: "", url: "", help: "", label: { url: "https://status.example.com", icon: "", text: "Health endpoint", }, }, { ok: false, category: "Matrix", reason: "Federation <strong>down</strong>", url: "", help: "https://help.example.com", label: { url: "", icon: "", text: "Federation status", }, }, ]; renderWithStore( { success: true, maintenance: false, ok: true, host: "matrix.example.com", results, }, { command: "rolling_restart", locked_at: "", maintenance: false, } ); screen.getByText("Status:"); screen.getByText("matrix.example.com"); screen.getByText("rolling_restart"); screen.getByText(englishMessages.etkecc.status.category.HTTP); screen.getByText(englishMessages.etkecc.status.category.Matrix); screen.getByRole("link", { name: "Health endpoint" }); screen.getByText("Federation status"); expect( screen.getAllByText((_, node) => (node?.textContent ?? "").replace(/\s+/g, " ").includes("Federation down")) .length ).toBeGreaterThan(0); screen.getByRole("link", { name: englishMessages.etkecc.status.help }); }); }); ================================================ FILE: src/components/etke.cc/ServerStatusPage.tsx ================================================ import CheckIcon from "@mui/icons-material/Check"; import CloseIcon from "@mui/icons-material/Close"; import EngineeringIcon from "@mui/icons-material/Engineering"; import { Box, Stack, Typography, Paper, Link, Chip, Divider, ChipProps } from "@mui/material"; import { useStore, useTranslate } from "ra-core"; import { Title } from "react-admin"; import CurrentlyRunningCommand from "./CurrentlyRunningCommand"; import { EtkeAttribution } from "./EtkeAttribution"; import { ServerProcessResponse, ServerStatusComponent, ServerStatusResponse } from "../../providers/types"; import { tt } from "../../utils/safety"; import { useDocTitle } from "../hooks/useDocTitle"; const StatusChip = ({ isOkay, size = "medium", errorLabel = "Error", command, }: { isOkay: boolean; size?: "small" | "medium"; errorLabel?: string; command?: string; }) => { let label = "OK"; let icon = <CheckIcon />; let color: ChipProps["color"] = "success"; if (!isOkay) { label = errorLabel; icon = <CloseIcon />; color = "error"; } if (command) { label = command; color = "warning"; icon = <EngineeringIcon />; } return <Chip icon={icon} label={label} color={color} variant="outlined" size={size} />; }; const ServerComponentText = ({ text }: { text: string }) => { return <Typography variant="body1" dangerouslySetInnerHTML={{ __html: text }} />; }; const ServerStatusPage = () => { const translate = useTranslate(); useDocTitle(translate("etkecc.status.name")); const errorLabel = translate("etkecc.status.error"); const [serverStatus, _setServerStatus] = useStore<ServerStatusResponse>("serverStatus", { ok: false, success: false, maintenance: false, host: "", results: [], }); const [serverProcess, _setServerProcess] = useStore<ServerProcessResponse>("serverProcess", { command: "", locked_at: "", maintenance: false, }); const { command } = serverProcess; const successCheck = serverStatus.success; const isMaintenance = serverStatus.maintenance; const isOkay = serverStatus.ok; const host = serverStatus.host; const results = serverStatus.results; const groupedResults: Record<string, ServerStatusComponent[]> = {}; for (const result of results) { if (!groupedResults[result.category]) { groupedResults[result.category] = []; } groupedResults[result.category].push(result); } if (isMaintenance) { return ( <> <Title title={translate("etkecc.status.name")} /> <Paper elevation={3} sx={{ p: 3, mt: 3 }}> <Stack direction="row" spacing={2} alignItems="center"> <Typography color="info"> {translate("etkecc.maintenance.title")} <br /> {translate("etkecc.maintenance.note")} </Typography> </Stack> </Paper> </> ); } if (!successCheck) { return ( <> <Title title={translate("etkecc.status.name")} /> <Paper elevation={3} sx={{ p: 3, mt: 3 }}> <Stack direction="row" spacing={2} alignItems="center"> <Typography color="info">{translate("etkecc.status.loading")}</Typography> </Stack> </Paper> </> ); } return ( <> <Title title={translate("etkecc.status.name")} /> <Stack spacing={3} mt={3}> <Stack spacing={1} direction={{ xs: "column", sm: "row" }} alignItems={{ xs: "flex-start", sm: "center" }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <Typography variant="h4">{translate("etkecc.status.status")}:</Typography> <StatusChip isOkay={isOkay} command={command} errorLabel={errorLabel} /> </Box> <Typography variant="h5" color="primary" fontWeight="medium" sx={{ wordBreak: "break-all" }}> {host} </Typography> </Stack> <CurrentlyRunningCommand /> <EtkeAttribution> <Typography variant="body1"> {translate("etkecc.status.intro1")}{" "} <Link href="https://etke.cc/services/monitoring/" target="_blank"> etke.cc/services/monitoring </Link> . <br /> {translate("etkecc.status.intro2")}{" "} <Link href="https://etke.cc/services/monitoring/#what-to-do-if-the-monitoring-report-shows-issues" target="_blank" > etke.cc/services/monitoring/#what-to-do-if-the-monitoring-report-shows-issues </Link> . </Typography> </EtkeAttribution> <Stack spacing={2} direction={{ xs: "column", md: "row" }}> {Object.keys(groupedResults).map((category, _idx) => { const categoryName = tt(translate, `etkecc.status.category.${category}`, category); return ( <Box key={`category_${category}`} sx={{ flex: 1 }}> <Typography variant="h5" mb={1}> {categoryName} </Typography> <Paper elevation={2} sx={{ p: 3 }}> <Stack spacing={1} divider={<Divider />}> {groupedResults[category].map((result, idx) => ( <Box key={`${category}_${idx}`}> <Stack spacing={2}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <StatusChip isOkay={result.ok} size="small" errorLabel={errorLabel} /> {result.label.url ? ( <Link href={result.label.url} target="_blank" rel="noopener noreferrer"> <ServerComponentText text={result.label.text} /> </Link> ) : ( <ServerComponentText text={result.label.text} /> )} </Box> {result.reason && ( <Typography color="text.secondary" dangerouslySetInnerHTML={{ __html: result.reason }} /> )} {!result.ok && result.help && ( <EtkeAttribution> <Link href={result.help} target="_blank" rel="noopener noreferrer" sx={{ mt: 1 }}> {translate("etkecc.status.help")} </Link> </EtkeAttribution> )} </Stack> </Box> ))} </Stack> </Paper> </Box> ); })} </Stack> </Stack> </> ); }; export default ServerStatusPage; ================================================ FILE: src/components/etke.cc/SupportAttachments.tsx ================================================ import AttachFileIcon from "@mui/icons-material/AttachFile"; import CloseIcon from "@mui/icons-material/Close"; import { Alert, Box, Button, Chip, Stack, Typography } from "@mui/material"; import { useRef, useState } from "react"; import { useTranslate } from "react-admin"; import type { SupportAttachment } from "../../providers/types"; const MAX_FILES = 5; const MAX_FILE_BYTES = 5 * 1024 * 1024; // 5 MB const MAX_TOTAL_BYTES = 10 * 1024 * 1024; // 10 MB interface Props { onChange: (files: SupportAttachment[]) => void; disabled?: boolean; } const formatBytes = (bytes: number): string => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; const readAsBase64 = (file: File): Promise<string> => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result as string; // strip "data:...;base64," prefix resolve(result.split(",")[1]); }; reader.onerror = reject; reader.readAsDataURL(file); }); interface AttachmentMeta extends SupportAttachment { size: number; // bytes, for display } const SupportAttachments = ({ onChange, disabled }: Props) => { const translate = useTranslate(); const inputRef = useRef<HTMLInputElement>(null); const [warning, setWarning] = useState<string | null>(null); // Keep size metadata alongside attachments for display const [meta, setMeta] = useState<AttachmentMeta[]>([]); const handleFiles = async (files: FileList) => { setWarning(null); const incoming = Array.from(files); if (meta.length + incoming.length > MAX_FILES) { setWarning(translate("etkecc.support.actions.too_many_attachments")); return; } for (const file of incoming) { if (file.size > MAX_FILE_BYTES) { setWarning(translate("etkecc.support.actions.attachment_too_large", { name: file.name })); return; } } const currentTotal = meta.reduce((sum, m) => sum + m.size, 0); const incomingTotal = incoming.reduce((sum, f) => sum + f.size, 0); if (currentTotal + incomingTotal > MAX_TOTAL_BYTES) { setWarning(translate("etkecc.support.actions.total_size_exceeded")); return; } const newItems: AttachmentMeta[] = await Promise.all( incoming.map(async file => ({ fileName: file.name, data: await readAsBase64(file), size: file.size, })) ); const updated = [...meta, ...newItems]; setMeta(updated); onChange(updated.map(({ fileName, data }) => ({ fileName, data }))); if (inputRef.current) inputRef.current.value = ""; }; const handleRemove = (index: number) => { const updated = meta.filter((_, i) => i !== index); setMeta(updated); onChange(updated.map(({ fileName, data }) => ({ fileName, data }))); setWarning(null); }; return ( <Box> <Stack direction="row" alignItems="center" spacing={1} flexWrap="wrap"> <Button size="small" variant="outlined" startIcon={<AttachFileIcon />} disabled={disabled || meta.length >= MAX_FILES} onClick={() => inputRef.current?.click()} > {translate("etkecc.support.buttons.attach_files")} </Button> <Typography variant="caption" color="text.secondary"> {translate("etkecc.support.helper.attachments_limit")} </Typography> </Stack> <input ref={inputRef} type="file" multiple style={{ display: "none" }} disabled={disabled} onChange={e => e.target.files && handleFiles(e.target.files)} /> {warning && ( <Alert severity="warning" onClose={() => setWarning(null)} sx={{ mt: 1 }}> {warning} </Alert> )} {meta.length > 0 && ( <Stack direction="row" flexWrap="wrap" gap={0.5} mt={1}> {meta.map((item, index) => ( <Chip key={index} label={`${item.fileName} (${formatBytes(item.size)})`} size="small" onDelete={() => handleRemove(index)} deleteIcon={<CloseIcon />} variant="outlined" /> ))} </Stack> )} </Box> ); }; export default SupportAttachments; ================================================ FILE: src/components/etke.cc/SupportPage.tsx ================================================ import AddIcon from "@mui/icons-material/Add"; import SupportAgentIcon from "@mui/icons-material/SupportAgent"; import { Alert, Box, Button, Checkbox, Chip, CircularProgress, FormControlLabel, Link, List, ListItemButton, ListItemText, Paper, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography, useMediaQuery, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import { useCallback, useEffect, useState } from "react"; import { Title, useDataProvider, useLocale, useNotify, useRedirect, useTranslate } from "react-admin"; import { EtkeAttribution } from "./EtkeAttribution"; import RichTextEditor from "./RichTextEditor"; import SupportAttachments from "./SupportAttachments"; import { useAppContext } from "../../Context"; import type { SupportAttachment } from "../../providers/types"; import { SynapseDataProvider, SupportRequest } from "../../providers/types"; import { useDocTitle } from "../hooks/useDocTitle"; import createLogger from "../../utils/logger"; const log = createLogger("support"); const CreateRequestForm = ({ onSubmit, onCancel, }: { onSubmit: (subject: string, message: string, attachments: SupportAttachment[]) => Promise<void>; onCancel: () => void; }) => { const translate = useTranslate(); const [subject, setSubject] = useState(""); const [message, setMessage] = useState(""); const [submitting, setSubmitting] = useState(false); const [confirmedScope, setConfirmedScope] = useState(false); const [attachments, setAttachments] = useState<SupportAttachment[]>([]); const handleSubmit = async () => { if (!subject.trim() || !message.trim()) return; setSubmitting(true); try { await onSubmit(subject.trim(), message.trim(), attachments); } finally { setSubmitting(false); } }; return ( <Paper elevation={2} sx={{ p: 3 }}> <Typography variant="h6" sx={{ mb: 2 }}> {translate("etkecc.support.create_title")} </Typography> <Stack spacing={2}> <Paper variant="outlined" sx={{ p: 1.5, bgcolor: "background.default" }}> <Stack spacing={0.5}> <Typography variant="subtitle2">{translate("etkecc.support.helper.before_contact_title")}</Typography> <Typography variant="body2" color="text.secondary"> {translate("etkecc.support.helper.help_pages_prompt")}{" "} <Link href="https://etke.cc/help/" target="_blank" rel="noreferrer"> etke.cc/help </Link>{" "} </Typography> <Typography variant="body2" color="text.secondary"> {translate("etkecc.support.helper.services_prompt")}{" "} <Link href="https://etke.cc/services/" target="_blank" rel="noreferrer"> etke.cc/services </Link> </Typography> <Typography variant="body2" color="text.secondary"> {translate("etkecc.support.helper.topics_prompt")}{" "} <Link href="https://etke.cc/services/support/#topics" target="_blank" rel="noreferrer"> etke.cc/services/support/#topics </Link> </Typography> <FormControlLabel control={ <Checkbox checked={confirmedScope} onChange={event => setConfirmedScope(event.target.checked)} /> } label={translate("etkecc.support.helper.scope_confirm_label")} /> </Stack> </Paper> <TextField label={translate("etkecc.support.fields.subject")} value={subject} onChange={e => setSubject(e.target.value)} fullWidth required disabled={submitting} /> <Box> <Typography variant="subtitle2" sx={{ mb: 0.5 }}> {translate("etkecc.support.fields.message")} * </Typography> <RichTextEditor value={message} onChange={setMessage} placeholder={translate("etkecc.support.helper.reply_placeholder")} disabled={submitting} minRows={4} /> </Box> <SupportAttachments onChange={setAttachments} disabled={submitting} /> <Stack direction="row" spacing={1}> <Button variant="contained" onClick={handleSubmit} disabled={submitting || !subject.trim() || !message.trim() || !confirmedScope} startIcon={submitting ? <CircularProgress size={16} /> : undefined} > {translate("etkecc.support.buttons.submit")} </Button> <Button variant="text" onClick={onCancel} disabled={submitting}> {translate("etkecc.support.buttons.cancel")} </Button> </Stack> </Stack> </Paper> ); }; const SupportPage = () => { const { etkeccAdmin } = useAppContext(); const dataProvider = useDataProvider() as SynapseDataProvider; const navigate = useRedirect(); const notify = useNotify(); const locale = useLocale(); const translate = useTranslate(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const [requests, setRequests] = useState<SupportRequest[]>([]); const [loading, setLoading] = useState(true); const [failure, setFailure] = useState<string | null>(null); const [showCreateForm, setShowCreateForm] = useState(false); useDocTitle(translate("etkecc.support.name")); const fetchRequests = useCallback(async () => { if (!etkeccAdmin) { setLoading(false); return; } try { setLoading(true); setFailure(null); const data = await dataProvider.getSupportRequests(etkeccAdmin, locale); setRequests(data); } catch (error) { log.error("failed to fetch support requests", error); setFailure(error instanceof Error ? error.message : String(error)); } finally { setLoading(false); } }, [etkeccAdmin, locale, dataProvider]); useEffect(() => { fetchRequests(); }, [fetchRequests]); const handleCreate = async (subject: string, message: string, attachments: SupportAttachment[]) => { try { const created = await dataProvider.createSupportRequest( etkeccAdmin as string, locale, subject, message, attachments.length ? attachments : undefined ); notify("etkecc.support.actions.create_success", { type: "success" }); navigate(`/support/${created.id}`); } catch (error) { log.error("failed to create support request", error); const serverMsg = error instanceof Error && !error.message.startsWith("etkecc.") ? error.message : null; const rawLabel = translate("etkecc.support.actions.create_failure"); notify(serverMsg ? `${rawLabel.replace(/\p{P}$/u, "")}: ${serverMsg}` : rawLabel, { type: "error" }); throw error; } }; const header = ( <> <Title title={translate("etkecc.support.name")} /> <Box> <Typography variant="h4"> <SupportAgentIcon sx={{ verticalAlign: "middle", mr: 1 }} /> {translate("etkecc.support.name")} </Typography> <EtkeAttribution> <Typography variant="body1">{translate("etkecc.support.description")}</Typography> </EtkeAttribution> </Box> </> ); if (loading) { return ( <Stack spacing={3} mt={3}> {header} <Box sx={{ display: "flex", alignItems: "center", gap: 1, mt: 2 }}> <CircularProgress size={20} /> <Typography>{translate("etkecc.support.helper.loading")}</Typography> </Box> </Stack> ); } if (failure) { return ( <Stack spacing={3} mt={3}> {header} <Alert severity="error">{failure}</Alert> </Stack> ); } return ( <Stack spacing={3} mt={3}> {header} {showCreateForm ? ( <CreateRequestForm onSubmit={handleCreate} onCancel={() => setShowCreateForm(false)} /> ) : ( <Box> <Button variant="contained" startIcon={<AddIcon />} onClick={() => setShowCreateForm(true)} sx={{ mb: 2 }}> {translate("etkecc.support.buttons.new_request")} </Button> </Box> )} {requests.length === 0 ? ( <Paper elevation={2} sx={{ p: 2 }}> <Typography>{translate("etkecc.support.no_requests")}</Typography> </Paper> ) : isSmall ? ( <List disablePadding> {requests.map(req => ( <ListItemButton key={req.id} onClick={() => navigate(`/support/${req.id}`)} component={Paper} elevation={2} sx={{ mb: 1, flexDirection: "column", alignItems: "flex-start", gap: 0.5 }} > <ListItemText primary={req.subject || req.id} secondary={ req.updated_at ? `${translate("etkecc.support.fields.updated_at")}: ${new Date(req.updated_at).toLocaleString(locale)}` : undefined } /> {req.status && ( <Chip label={translate(`etkecc.support.status.${req.status}`, { _: req.status })} size="small" color={ req.status === "active" || req.status === "open" ? "success" : req.status === "closed" ? "default" : "info" } /> )} </ListItemButton> ))} </List> ) : ( <TableContainer component={Paper} elevation={2}> <Table> <TableHead> <TableRow> <TableCell>{translate("etkecc.support.fields.subject")}</TableCell> <TableCell>{translate("etkecc.support.fields.status")}</TableCell> <TableCell>{translate("etkecc.support.fields.created_at")}</TableCell> <TableCell>{translate("etkecc.support.fields.updated_at")}</TableCell> </TableRow> </TableHead> <TableBody> {requests.map(req => ( <TableRow key={req.id} hover sx={{ cursor: "pointer" }} onClick={() => navigate(`/support/${req.id}`)}> <TableCell>{req.subject || req.id}</TableCell> <TableCell> {req.status && ( <Chip label={translate(`etkecc.support.status.${req.status}`, { _: req.status })} size="small" color={ req.status === "active" || req.status === "open" ? "success" : req.status === "closed" ? "default" : "info" } /> )} </TableCell> <TableCell>{req.created_at ? new Date(req.created_at).toLocaleString(locale) : ""}</TableCell> <TableCell>{req.updated_at ? new Date(req.updated_at).toLocaleString(locale) : ""}</TableCell> </TableRow> ))} </TableBody> </Table> </TableContainer> )} </Stack> ); }; export default SupportPage; ================================================ FILE: src/components/etke.cc/SupportRequestPage.tsx ================================================ import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import SendIcon from "@mui/icons-material/Send"; import { Alert, Avatar, Box, Button, Checkbox, Chip, CircularProgress, Divider, FormControlLabel, IconButton, Link, Paper, Stack, Typography, } from "@mui/material"; import DOMPurify from "dompurify"; import { useCallback, useEffect, useRef, useState } from "react"; import { Title, useDataProvider, useLocale, useNotify, useParams, useRedirect, useStore, useTranslate, } from "react-admin"; import { useAppContext } from "../../Context"; import type { SupportAttachment } from "../../providers/types"; import { SynapseDataProvider, SupportMessage, SupportRequestDetail } from "../../providers/types"; import { fetchAuthenticatedMedia } from "../../utils/fetchMedia"; import { useDocTitle } from "../hooks/useDocTitle"; import RichTextEditor from "./RichTextEditor"; import SupportAttachments from "./SupportAttachments"; import createLogger from "../../utils/logger"; const log = createLogger("support"); interface ResolvedProfile { displayName: string; avatarSrc?: string; } const MXID_REGEX = /^@[^:]+:[^:]+$/; const isMXID = (value: string) => MXID_REGEX.test(value); const MessageRow = ({ msg, locale, resolvedProfile, mxid, }: { msg: SupportMessage; locale: string; resolvedProfile?: ResolvedProfile; mxid?: string; }) => { const navigate = useRedirect(); const isCustomer = msg.type === "customer"; const author = resolvedProfile?.displayName ?? msg.created_by?.firstName ?? msg.type; const avatarUrl = resolvedProfile?.avatarSrc ?? msg.created_by?.avatarUrl; const safeHtml = DOMPurify.sanitize(msg.text, { USE_PROFILES: { html: true } }); return ( <Paper elevation={2} sx={{ overflow: "hidden", border: "1px solid", borderColor: "action.selected", borderLeft: !isCustomer ? "4px solid" : undefined, borderLeftColor: !isCustomer ? "primary.main" : undefined, }} > <Stack direction={{ xs: "column", sm: "row" }}> <Box onClick={mxid ? () => navigate(`/users/${encodeURIComponent(mxid)}`) : undefined} sx={{ width: { xs: "100%", sm: 150 }, flexShrink: 0, p: 1.5, borderRight: { xs: "none", sm: "1px solid" }, borderBottom: { xs: "1px solid", sm: "none" }, borderColor: "action.selected", bgcolor: "background.default", display: "flex", flexDirection: { xs: "row", sm: "column" }, alignItems: "center", gap: { xs: 1, sm: 0.5 }, cursor: mxid ? "pointer" : undefined, "&:hover": mxid ? { bgcolor: "action.hover" } : undefined, }} > <Avatar src={avatarUrl} sx={{ width: 40, height: 40 }}> {author?.[0]?.toUpperCase()} </Avatar> <Typography variant="subtitle2" textAlign="center" sx={{ wordBreak: "break-all", fontSize: "0.8rem" }}> {author} </Typography> {msg.created_at && ( <Typography variant="caption" color="text.secondary" textAlign="center"> {new Date(msg.created_at).toLocaleString(locale)} </Typography> )} </Box> <Box sx={{ flex: 1, p: 2, minWidth: 0 }}> <Typography variant="body2" component="div" dangerouslySetInnerHTML={{ __html: safeHtml }} /> </Box> </Stack> </Paper> ); }; const SupportRequestPage = () => { const { id } = useParams<{ id: string }>(); const { etkeccAdmin } = useAppContext(); const dataProvider = useDataProvider() as SynapseDataProvider; const navigate = useRedirect(); const notify = useNotify(); const locale = useLocale(); const translate = useTranslate(); const [request, setRequest] = useState<SupportRequestDetail | null>(null); const [loading, setLoading] = useState(true); const [failure, setFailure] = useState<string | null>(null); const draftKey = id ? `supportRequests.${id}.draft` : "supportRequests.unknown.draft"; const [newMessage, setNewMessage] = useStore<string>(draftKey, ""); const [sending, setSending] = useState(false); const [attachments, setAttachments] = useState<SupportAttachment[]>([]); const [closeRequest, setCloseRequest] = useState(false); const [profiles, setProfiles] = useState<Record<string, ResolvedProfile>>({}); const fetchedMxids = useRef<Set<string>>(new Set()); const blobUrlsRef = useRef<string[]>([]); const fetchRequest = useCallback( async (burstCache = false) => { if (!etkeccAdmin || !id) { setFailure( translate("etkecc.support.helper.not_configured", { _: "Support is not configured for this server.", }) ); setLoading(false); return; } try { setFailure(null); const data = await dataProvider.getSupportRequest(etkeccAdmin, locale, id, burstCache); setRequest(data); } catch (error) { log.error("failed to fetch support request", { id, error }); setFailure(error instanceof Error ? error.message : String(error)); } finally { setLoading(false); } }, [etkeccAdmin, id, locale, dataProvider, translate] ); useEffect(() => { setLoading(true); setRequest(null); setFailure(null); setProfiles({}); fetchedMxids.current = new Set(); for (const url of blobUrlsRef.current) { URL.revokeObjectURL(url); } blobUrlsRef.current = []; fetchRequest(); }, [fetchRequest]); useEffect(() => { return () => { for (const url of blobUrlsRef.current) { URL.revokeObjectURL(url); } }; }, []); useEffect(() => { if (!request) return; const baseUrl = localStorage.getItem("base_url"); const token = localStorage.getItem("access_token"); if (!baseUrl || !token) return; const mxids = [ ...new Set( request.messages .filter(m => m.type === "customer" && m.created_by?.firstName && isMXID(m.created_by.firstName)) .map(m => m.created_by!.firstName) ), ].filter(mxid => !fetchedMxids.current.has(mxid)); if (mxids.length === 0) return; for (const mxid of mxids) { fetchedMxids.current.add(mxid); } Promise.all( mxids.map(async mxid => { try { const resp = await fetch(`${baseUrl}/_matrix/client/v3/profile/${encodeURIComponent(mxid)}`, { headers: { Authorization: `Bearer ${token}` }, }); if (!resp.ok) return null; const json = await resp.json(); let avatarSrc: string | undefined; if (json.avatar_url) { try { const mediaResp = await fetchAuthenticatedMedia(json.avatar_url as string, "thumbnail"); if (mediaResp.ok) { const blob = await mediaResp.blob(); avatarSrc = URL.createObjectURL(blob); blobUrlsRef.current.push(avatarSrc); } } catch { // avatar fetch failed, skip } } return { mxid, displayName: (json.displayname as string | undefined) ?? mxid, avatarSrc, }; } catch { return null; } }) ).then(results => { const resolved: Record<string, ResolvedProfile> = {}; for (const r of results) { if (r) resolved[r.mxid] = { displayName: r.displayName, avatarSrc: r.avatarSrc }; } setProfiles(prev => ({ ...prev, ...resolved })); }); }, [request]); const handleSend = async () => { if (!newMessage.trim() || !etkeccAdmin || !id) return; setSending(true); const messageText = newMessage.trim(); try { await dataProvider.postSupportMessage( etkeccAdmin, locale, id, messageText, attachments.length ? attachments : undefined, closeRequest || undefined ); setNewMessage(""); setAttachments([]); setCloseRequest(false); const optimisticMsg: SupportMessage = { text: messageText, type: "operator", created_at: new Date().toISOString(), }; setRequest(prev => (prev ? { ...prev, messages: [...prev.messages, optimisticMsg] } : prev)); fetchRequest(true); } catch (error) { log.error("failed to send message", { id, error }); const serverMsg = error instanceof Error && !error.message.startsWith("etkecc.") ? error.message : null; const rawLabel = translate("etkecc.support.actions.send_failure"); notify(serverMsg ? `${rawLabel.replace(/\p{P}$/u, "")}: ${serverMsg}` : rawLabel, { type: "error" }); } finally { setSending(false); } }; const statusChip = request?.status ? ( <Chip label={translate(`etkecc.support.status.${request.status}`, { _: request.status })} size="small" color={ request.status === "active" || request.status === "open" ? "success" : request.status === "closed" ? "default" : "info" } /> ) : null; useDocTitle(request?.subject || translate("etkecc.support.name")); const header = ( <> <Title title={request?.subject || translate("etkecc.support.name")} /> <Stack direction="row" alignItems="flex-start" spacing={1}> <IconButton onClick={() => navigate("/support")} size="small" sx={{ mt: 0.5 }}> <ArrowBackIcon /> </IconButton> <Box> <Typography variant="h5">{request?.subject || translate("etkecc.support.name")}</Typography> <Stack direction="row" alignItems="center" spacing={1} flexWrap="wrap" mt={0.5}> {request?.updated_at && ( <Typography variant="caption" color="text.secondary"> {translate("etkecc.support.fields.updated_at")}: {new Date(request.updated_at).toLocaleString(locale)} </Typography> )} {statusChip} </Stack> </Box> </Stack> </> ); if (loading) { return ( <Stack spacing={3} mt={3}> {header} <Box sx={{ display: "flex", alignItems: "center", gap: 1, mt: 2 }}> <CircularProgress size={20} /> <Typography>{translate("etkecc.support.helper.loading")}</Typography> </Box> </Stack> ); } if (failure) { return ( <Stack spacing={3} mt={3}> {header} <Alert severity="error">{failure}</Alert> <Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/support")} sx={{ alignSelf: "flex-start" }}> {translate("etkecc.support.buttons.back")} </Button> </Stack> ); } const messages = request?.messages ?? []; const getResolvedProfile = (msg: SupportMessage): ResolvedProfile | undefined => { const firstName = msg.created_by?.firstName; if (msg.type === "customer" && firstName && isMXID(firstName)) { return profiles[firstName]; } return undefined; }; return ( <Stack spacing={2} mt={3}> {header} <Divider /> {messages.length === 0 ? ( <Typography color="text.secondary">{translate("etkecc.support.no_messages")}</Typography> ) : ( <Stack spacing={2}> {messages.map((msg, index) => ( <MessageRow key={msg.id ?? index} msg={msg} locale={locale} resolvedProfile={getResolvedProfile(msg)} mxid={ msg.type === "customer" && msg.created_by?.firstName && isMXID(msg.created_by.firstName) ? msg.created_by.firstName : undefined } /> ))} </Stack> )} {request?.status === "closed" ? ( <Alert severity="info">{translate("etkecc.support.closed_message")}</Alert> ) : ( request?.status !== "spam" && ( <Paper elevation={1} sx={{ p: 2, border: "1px solid", borderColor: "action.selected" }}> <Stack spacing={1} sx={{ mb: 2 }}> <Alert severity="info">{translate("etkecc.support.helper.english_only_notice")}</Alert> <Typography variant="caption" color="text.secondary"> {translate("etkecc.support.helper.response_time_prompt")}{" "} <Link href="https://etke.cc/services/support/" target="_blank" rel="noreferrer"> etke.cc/services/support </Link> </Typography> </Stack> <Typography variant="subtitle2" sx={{ mb: 1 }}> {translate("etkecc.support.fields.reply")} </Typography> <Stack spacing={1}> <RichTextEditor value={newMessage} onChange={setNewMessage} placeholder={translate("etkecc.support.helper.reply_placeholder")} disabled={sending} minRows={6} /> <SupportAttachments onChange={setAttachments} disabled={sending} /> <FormControlLabel control={ <Checkbox checked={closeRequest} onChange={e => setCloseRequest(e.target.checked)} disabled={sending} size="small" /> } label={ <Typography variant="body2">{translate("etkecc.support.helper.close_request_label")}</Typography> } /> <Box> <Button variant="contained" endIcon={sending ? <CircularProgress size={16} /> : <SendIcon />} onClick={handleSend} disabled={sending || !newMessage.trim()} > {translate("etkecc.support.buttons.send")} </Button> </Box> </Stack> </Paper> ) )} </Stack> ); }; export default SupportRequestPage; ================================================ FILE: src/components/etke.cc/hooks/useServerCommands.ts ================================================ import { useState, useEffect } from "react"; import { useDataProvider, useLocale } from "react-admin"; import { useAppContext } from "../../../Context"; import { ServerCommand } from "../../../providers/types"; import { useInstanceConfig } from "../InstanceConfig"; export const useServerCommands = () => { const icfg = useInstanceConfig(); const { etkeccAdmin } = useAppContext(); const locale = useLocale(); const [isLoading, setLoading] = useState(true); const [maintenance, setMaintenance] = useState(false); const [serverCommands, setServerCommands] = useState<Record<string, ServerCommand>>({}); const dataProvider = useDataProvider(); useEffect(() => { const fetchServerCommands = async () => { const serverCommandsResponse = await dataProvider.getServerCommands(etkeccAdmin, locale); if (serverCommandsResponse?.maintenance) { setMaintenance(true); setLoading(false); return; } if (serverCommandsResponse) { const serverCommands = serverCommandsResponse.commands; Object.keys(serverCommandsResponse.commands).forEach((command: string) => { serverCommands[command].additionalArgs = ""; }); if (icfg.disabled.payments || icfg.disabled.attributions) { delete serverCommands["price"]; delete serverCommands["payments"]; } setServerCommands(serverCommands); } setLoading(false); }; fetchServerCommands(); }, [dataProvider, etkeccAdmin, locale, icfg.disabled.attributions, icfg.disabled.payments]); return { isLoading, maintenance, serverCommands, setServerCommands }; }; ================================================ FILE: src/components/etke.cc/hooks/useUnits.ts ================================================ import { useState, useEffect } from "react"; import { useDataProvider, useLocale } from "react-admin"; import { useAppContext } from "../../../Context"; const toHumanReadable = (unit: string): string => { let name = unit; if (name.startsWith("matrix-")) { name = name.slice("matrix-".length); } if (name.endsWith(".service")) { name = name.slice(0, -".service".length); } return name; }; export const useUnits = () => { const { etkeccAdmin } = useAppContext(); const locale = useLocale(); const dataProvider = useDataProvider(); const [isLoading, setLoading] = useState(true); const [units, setUnits] = useState<Record<string, string>>({}); useEffect(() => { const fetchUnits = async () => { const unitList = await dataProvider.getUnits(etkeccAdmin, locale); const mapping: Record<string, string> = {}; for (const unit of unitList) { mapping[toHumanReadable(unit)] = unit; } setUnits(mapping); setLoading(false); }; fetchUnits(); }, [dataProvider, etkeccAdmin, locale]); return { units, isLoading }; }; ================================================ FILE: src/components/etke.cc/schedules/components/recurring/RecurringCommandEdit.tsx ================================================ import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import { Card, CardContent, CardHeader, Box, Alert, Autocomplete, TextField, Typography, Link } from "@mui/material"; import { useQueryClient } from "@tanstack/react-query"; import { useState, useEffect } from "react"; import { Form, TextInput, SaveButton, useNotify, useDataProvider, useLocale, Loading, Button, SelectInput, TimeInput, useParams, useRedirect, useTranslate, Title, } from "react-admin"; import { useWatch, useFormContext } from "react-hook-form"; import RecurringDeleteButton from "./RecurringDeleteButton"; import { useAppContext } from "../../../../../Context"; import { RecurringCommand } from "../../../../../providers/types"; import { useDocTitle } from "../../../../hooks/useDocTitle"; import { EtkeAttribution } from "../../../EtkeAttribution"; import { useServerCommands } from "../../../hooks/useServerCommands"; import { useUnits } from "../../../hooks/useUnits"; import { useRecurringCommands } from "../../hooks/useRecurringCommands"; import createLogger from "../../../../../utils/logger"; const log = createLogger("schedules"); /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const transformCommandsToChoices = (commands: Record<string, any>) => { return Object.entries(commands).map(([key, value]) => ({ id: key, name: value.name, description: value.description, })); }; const ArgumentsField = ({ serverCommands, units }) => { const translate = useTranslate(); const { setValue } = useFormContext(); const selectedCommand = useWatch({ name: "command" }); const argsValue = useWatch({ name: "args" }); const showArgs = selectedCommand && serverCommands[selectedCommand]?.args === true; if (!showArgs) return null; if (selectedCommand === "restart") { return ( <Autocomplete freeSolo options={Object.keys(units)} inputValue={argsValue || ""} onInputChange={(_e, value) => { setValue("args", units[value] || value, { shouldDirty: true }); }} renderInput={params => ( <TextField {...params} label={translate("etkecc.actions.table.arguments")} required fullWidth /> )} /> ); } return <TextInput required source="args" label={translate("etkecc.actions.table.arguments")} fullWidth multiline />; }; const RecurringCommandEdit = () => { const { id } = useParams(); const navigate = useRedirect(); const notify = useNotify(); const dataProvider = useDataProvider(); const queryClient = useQueryClient(); const locale = useLocale(); const translate = useTranslate(); const { etkeccAdmin } = useAppContext(); const [command, setCommand] = useState<RecurringCommand | undefined>(undefined); const isCreating = typeof id === "undefined"; const [loading, setLoading] = useState(!isCreating); const { data: recurringCommands, isLoading: isLoadingList } = useRecurringCommands(); const { serverCommands, isLoading: isLoadingServerCommands } = useServerCommands(); const { units } = useUnits(); const pageTitle = isCreating ? translate("etkecc.actions.recurring_title_create") : translate("etkecc.actions.recurring_title_edit"); useDocTitle(pageTitle); const commandChoices = transformCommandsToChoices(serverCommands); const dayOfWeekChoices = [ { id: "Monday", name: translate("etkecc.actions.days.monday") }, { id: "Tuesday", name: translate("etkecc.actions.days.tuesday") }, { id: "Wednesday", name: translate("etkecc.actions.days.wednesday") }, { id: "Thursday", name: translate("etkecc.actions.days.thursday") }, { id: "Friday", name: translate("etkecc.actions.days.friday") }, { id: "Saturday", name: translate("etkecc.actions.days.saturday") }, { id: "Sunday", name: translate("etkecc.actions.days.sunday") }, ]; useEffect(() => { if (!isCreating && recurringCommands) { const commandToEdit = recurringCommands.find(cmd => cmd.id === id); if (commandToEdit) { const timeValue = commandToEdit.time || ""; const timeParts = timeValue.split(" "); const parsedCommand = { ...commandToEdit, day_of_week: timeParts.length > 1 ? timeParts[0] : "Monday", execution_time: timeParts.length > 1 ? timeParts[1] : timeValue, }; setCommand(parsedCommand); } setLoading(false); } }, [id, recurringCommands, isCreating]); const handleSubmit = async data => { try { // Format the time from the Date object to a string in HH:MM format let formattedTime = "00:00"; if (data.execution_time instanceof Date) { const hours = String(data.execution_time.getHours()).padStart(2, "0"); const minutes = String(data.execution_time.getMinutes()).padStart(2, "0"); formattedTime = `${hours}:${minutes}`; } else if (typeof data.execution_time === "string") { formattedTime = data.execution_time; } const submissionData = { ...data, time: `${data.day_of_week} ${formattedTime}`, }; delete submissionData.day_of_week; delete submissionData.execution_time; delete submissionData.scheduled_at; // Only include args when it's required for the selected command const selectedCommand = data.command; if (!selectedCommand || !serverCommands[selectedCommand]?.args) { delete submissionData.args; } if (isCreating) { await dataProvider.createRecurringCommand(etkeccAdmin, locale, submissionData); notify("etkecc.actions.recurring.action.create_success", { type: "success" }); } else { await dataProvider.updateRecurringCommand(etkeccAdmin, locale, { ...submissionData, id: id, }); notify("etkecc.actions.recurring.action.update_success", { type: "success" }); } // Invalidate scheduled commands queries queryClient.invalidateQueries({ queryKey: ["scheduledCommands"] }); navigate("/server_actions"); } catch (error) { log.error("failed to save recurring command", error); notify("etkecc.actions.recurring.action.update_failure", { type: "error" }); } }; if (loading || isLoadingList || isLoadingServerCommands) { return <Loading />; } return ( <> <Title title={pageTitle} /> <Box sx={{ mt: 2, maxWidth: { xs: "100vw", sm: "calc(100vw - 32px)" }, overflowX: "auto" }}> <Button label={translate("etkecc.actions.buttons.back")} onClick={() => navigate("/server_actions")} sx={{ mb: 2 }} > <ArrowBackIcon /> </Button> <Card> <CardHeader title={pageTitle} /> <CardContent> {command && ( <EtkeAttribution> <Alert severity="info"> <Typography variant="body1" sx={{ px: 2 }}> {translate("etkecc.actions.command_details_intro")}{" "} <Link href={`https://etke.cc/help/extras/scheduler/#${command.command}`} target="_blank" sx={{ wordBreak: "break-all" }} > {`etke.cc/help/extras/scheduler/#${command.command}`} </Link> . </Typography> </Alert> </EtkeAttribution> )} <Form defaultValues={command || undefined} onSubmit={handleSubmit} record={command || undefined} warnWhenUnsavedChanges > <Box display="flex" flexDirection="column" gap={2}> {!isCreating && ( <TextInput readOnly source="id" label={translate("etkecc.actions.form.id")} fullWidth required /> )} <SelectInput source="command" choices={commandChoices} label={translate("etkecc.actions.form.command")} fullWidth required /> <ArgumentsField serverCommands={serverCommands} units={units} /> <SelectInput source="day_of_week" choices={dayOfWeekChoices} label={translate("etkecc.actions.form.day_of_week")} fullWidth required /> <TimeInput source="execution_time" label={translate("etkecc.actions.table.time_utc")} fullWidth required /> <Box mt={2} display="flex" justifyContent="space-between"> <SaveButton label={ isCreating ? translate("etkecc.actions.buttons.create") : translate("etkecc.actions.buttons.update") } /> {!isCreating && <RecurringDeleteButton />} </Box> </Box> </Form> </CardContent> </Card> </Box> </> ); }; export default RecurringCommandEdit; ================================================ FILE: src/components/etke.cc/schedules/components/recurring/RecurringCommandsList.tsx ================================================ import AddIcon from "@mui/icons-material/Add"; import { Paper } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { Loading, Button, useLocale, useRedirect, useTranslate } from "react-admin"; import { DateField, useRecordContext } from "react-admin"; import { ListContextProvider, TextField, TopToolbar, Identifier } from "react-admin"; import { ResourceContextProvider, useList } from "react-admin"; import { DATE_FORMAT } from "../../../../../utils/date"; import { useRecurringCommands } from "../../hooks/useRecurringCommands"; import { Datagrid } from "../../../../../components/layout"; const ListActions = () => { const navigate = useRedirect(); const translate = useTranslate(); return ( <TopToolbar> <Button label={translate("etkecc.actions.buttons.create")} onClick={() => navigate("/server_actions/recurring/create")} > <AddIcon /> </Button> </TopToolbar> ); }; const RecurringTimeField = ({ label: _label }: { label?: string }) => { const record = useRecordContext(); const translate = useTranslate(); const locale = useLocale(); const titleCase = (value: string) => { if (!value) { return value; } const [first, ...rest] = value; return first.toLocaleUpperCase(locale) + rest.join(""); }; if (record?.scheduled_at) { const date = new Date(record.scheduled_at); if (!Number.isNaN(date.getTime())) { const formatted = new Intl.DateTimeFormat(locale, { weekday: "long", hour: "2-digit", minute: "2-digit", }).format(date); return <span>{titleCase(formatted)}</span>; } } if (!record?.time) { return null; } const [day, time] = String(record.time).split(" "); if (!day || !time) { return <span>{record.time}</span>; } const dayKey = day.toLowerCase(); const translatedDay = translate(`etkecc.actions.days.${dayKey}`); const dayLabel = translatedDay === `etkecc.actions.days.${dayKey}` ? day : translatedDay; return ( <span> {dayLabel} {time} </span> ); }; const RecurringCommandsList = () => { const locale = useLocale(); const translate = useTranslate(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const { data, isLoading } = useRecurringCommands(); const listContext = useList({ resource: "recurring", sort: { field: "scheduled_at", order: "DESC" }, perPage: 50, data: data || [], isLoading: isLoading, }); if (isLoading) return <Loading />; return ( <ResourceContextProvider value="recurring"> <ListContextProvider value={listContext}> <ListActions /> <Paper> <Datagrid bulkActionButtons={false} /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ rowClick={(id: Identifier, resource: string, record: any) => { if (!record) { return ""; } return `/server_actions/${resource}/${id}`; }} > <TextField source="command" label={translate("etkecc.actions.table.command")} /> {!isSmall && <TextField source="args" label={translate("etkecc.actions.table.arguments")} />} <RecurringTimeField label={translate("etkecc.actions.table.time_local")} /> <DateField options={DATE_FORMAT} showTime source="scheduled_at" label={translate("etkecc.actions.table.next_run_at")} locales={locale} /> </Datagrid> </Paper> </ListContextProvider> </ResourceContextProvider> ); }; export default RecurringCommandsList; ================================================ FILE: src/components/etke.cc/schedules/components/recurring/RecurringDeleteButton.tsx ================================================ import DeleteIcon from "@mui/icons-material/Delete"; import { useTheme } from "@mui/material/styles"; import { useState } from "react"; import { useLocale, useNotify, useDataProvider, useRecordContext, useRedirect, useTranslate } from "react-admin"; import { Button, Confirm } from "react-admin"; import { useAppContext } from "../../../../../Context"; import { RecurringCommand } from "../../../../../providers/types"; const RecurringDeleteButton = () => { const record = useRecordContext() as RecurringCommand; const { etkeccAdmin } = useAppContext(); const dataProvider = useDataProvider(); const locale = useLocale(); const notify = useNotify(); const translate = useTranslate(); const theme = useTheme(); const navigate = useRedirect(); const [open, setOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const handleClick = e => { e.stopPropagation(); setOpen(true); }; const handleConfirm = async () => { setIsDeleting(true); try { await dataProvider.deleteRecurringCommand(etkeccAdmin, locale, record.id); notify("etkecc.actions.recurring.action.delete_success", { type: "success" }); navigate("/server_actions"); } catch (error) { const errorMessage = error instanceof Error ? error.message : translate("etkecc.actions.errors.unknown"); notify(translate("etkecc.actions.errors.delete_failed", { error: errorMessage }), { type: "error" }); } finally { setIsDeleting(false); setOpen(false); } }; const handleCancel = () => { setOpen(false); }; return ( <> <Button sx={{ color: theme.palette.error.main }} label={translate("etkecc.actions.buttons.delete")} onClick={handleClick} disabled={isDeleting} > <DeleteIcon /> </Button> <Confirm isOpen={open} title={translate("etkecc.actions.delete_recurring_title")} content={translate("etkecc.actions.delete_confirm", { command: record?.command || "" })} onConfirm={handleConfirm} onClose={handleCancel} /> </> ); }; export default RecurringDeleteButton; ================================================ FILE: src/components/etke.cc/schedules/components/scheduled/ScheduledCommandEdit.tsx ================================================ import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import { Card, CardContent, CardHeader, Box, Autocomplete, TextField } from "@mui/material"; import { Typography, Link } from "@mui/material"; import { useState, useEffect } from "react"; import { Form, TextInput, DateTimeInput, SaveButton, useNotify, useDataProvider, useLocale, Loading, Button, SelectInput, useParams, useRedirect, useTranslate, Title, } from "react-admin"; import { useWatch, useFormContext } from "react-hook-form"; import ScheduleDeleteButton from "./ScheduledDeleteButton"; import { useAppContext } from "../../../../../Context"; import { ScheduledCommand } from "../../../../../providers/types"; import { useDocTitle } from "../../../../hooks/useDocTitle"; import { EtkeAttribution } from "../../../EtkeAttribution"; import { useServerCommands } from "../../../hooks/useServerCommands"; import { useUnits } from "../../../hooks/useUnits"; import { useScheduledCommands } from "../../hooks/useScheduledCommands"; import createLogger from "../../../../../utils/logger"; const log = createLogger("schedules"); /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const transformCommandsToChoices = (commands: Record<string, any>) => { return Object.entries(commands).map(([key, value]) => ({ id: key, name: value.name, description: value.description, })); }; const ArgumentsField = ({ serverCommands, units }) => { const translate = useTranslate(); const { setValue } = useFormContext(); const selectedCommand = useWatch({ name: "command" }); const argsValue = useWatch({ name: "args" }); const showArgs = selectedCommand && serverCommands[selectedCommand]?.args === true; if (!showArgs) return null; if (selectedCommand === "restart") { return ( <Autocomplete freeSolo options={Object.keys(units)} inputValue={argsValue || ""} onInputChange={(_e, value) => { setValue("args", units[value] || value, { shouldDirty: true }); }} renderInput={params => ( <TextField {...params} label={translate("etkecc.actions.table.arguments")} required fullWidth /> )} /> ); } return <TextInput required source="args" label={translate("etkecc.actions.table.arguments")} fullWidth multiline />; }; const ScheduledCommandEdit = () => { const { id } = useParams(); const navigate = useRedirect(); const notify = useNotify(); const dataProvider = useDataProvider(); const locale = useLocale(); const translate = useTranslate(); const { etkeccAdmin } = useAppContext(); const [command, setCommand] = useState<ScheduledCommand | null>(null); const isCreating = typeof id === "undefined"; const [loading, setLoading] = useState(!isCreating); const { data: scheduledCommands, isLoading: isLoadingList } = useScheduledCommands(); const { serverCommands } = useServerCommands(); const { units } = useUnits(); const pageTitle = isCreating ? translate("etkecc.actions.scheduled_title_create") : translate("etkecc.actions.scheduled_title_edit"); useDocTitle(pageTitle); const commandChoices = transformCommandsToChoices(serverCommands); useEffect(() => { if (!isCreating && scheduledCommands) { const commandToEdit = scheduledCommands.find(cmd => cmd.id === id); if (commandToEdit) { setCommand(commandToEdit); } setLoading(false); } }, [id, scheduledCommands, isCreating]); const handleSubmit = async data => { try { data.scheduled_at = new Date(data.scheduled_at).toISOString(); if (isCreating) { await dataProvider.createScheduledCommand(etkeccAdmin, locale, data); notify("etkecc.actions.scheduled.action.create_success", { type: "success" }); } else { await dataProvider.updateScheduledCommand(etkeccAdmin, locale, { ...data, id: id, }); notify("etkecc.actions.scheduled.action.update_success", { type: "success" }); } navigate("/server_actions"); } catch (error) { log.error("failed to save scheduled command", error); notify("etkecc.actions.scheduled.action.update_failure", { type: "error" }); } }; if (loading || isLoadingList) { return <Loading />; } return ( <> <Title title={pageTitle} /> <Box sx={{ mt: 2, maxWidth: { xs: "100vw", sm: "calc(100vw - 32px)" }, overflowX: "auto" }}> <Button label={translate("etkecc.actions.buttons.back")} onClick={() => navigate("/server_actions")} sx={{ mb: 2 }} > <ArrowBackIcon /> </Button> <Card> <CardHeader title={pageTitle} /> {command && ( <EtkeAttribution> <Typography variant="body1" sx={{ px: 2 }}> {translate("etkecc.actions.command_details_intro")}{" "} <Link href={`https://etke.cc/help/extras/scheduler/#${command.command}`} target="_blank" sx={{ wordBreak: "break-all" }} > {`etke.cc/help/extras/scheduler/#${command.command}`} </Link> . </Typography> </EtkeAttribution> )} <CardContent> <Form defaultValues={command || undefined} onSubmit={handleSubmit} record={command || undefined} warnWhenUnsavedChanges > <Box display="flex" flexDirection="column" gap={2}> {command && ( <TextInput readOnly source="id" label={translate("etkecc.actions.form.id")} fullWidth required /> )} <SelectInput readOnly={!isCreating} source="command" choices={commandChoices} label={translate("etkecc.actions.form.command")} fullWidth required /> <ArgumentsField serverCommands={serverCommands} units={units} /> <DateTimeInput source="scheduled_at" label={translate("etkecc.actions.form.scheduled_at")} fullWidth required /> <Box mt={2} display="flex" justifyContent="space-between"> <SaveButton label={ isCreating ? translate("etkecc.actions.buttons.create") : translate("etkecc.actions.buttons.update") } /> {!isCreating && <ScheduleDeleteButton />} </Box> </Box> </Form> </CardContent> </Card> </Box> </> ); }; export default ScheduledCommandEdit; ================================================ FILE: src/components/etke.cc/schedules/components/scheduled/ScheduledCommandShow.tsx ================================================ import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import { Alert, Box, Card, CardContent, CardHeader, TextField as MuiTextField, Typography, Link } from "@mui/material"; import { useState, useEffect } from "react"; import { Loading, Button, SimpleShowLayout, TextField, BooleanField, DateField, RecordContextProvider, useLocale, useParams, useRedirect, useTranslate, Title, } from "react-admin"; import ScheduledDeleteButton from "./ScheduledDeleteButton"; import { ScheduledCommand } from "../../../../../providers/types"; import { useDocTitle } from "../../../../hooks/useDocTitle"; import { EtkeAttribution } from "../../../EtkeAttribution"; import { useScheduledCommands } from "../../hooks/useScheduledCommands"; const ScheduledCommandShow = () => { const { id } = useParams(); const navigate = useRedirect(); const locale = useLocale(); const translate = useTranslate(); const [command, setCommand] = useState<ScheduledCommand | null>(null); const [loading, setLoading] = useState(true); const { data: scheduledCommands, isLoading: isLoadingList } = useScheduledCommands(); useDocTitle(translate("etkecc.actions.scheduled_details_title")); useEffect(() => { if (scheduledCommands) { const commandToShow = scheduledCommands.find(cmd => cmd.id === id); if (commandToShow) { setCommand(commandToShow); } setLoading(false); } }, [id, scheduledCommands]); if (loading || isLoadingList) { return <Loading />; } if (!command) { return null; } return ( <> <Title title={translate("etkecc.actions.scheduled_details_title")} /> <Box sx={{ mt: 2, maxWidth: { xs: "100vw", sm: "calc(100vw - 32px)" }, overflowX: "auto" }}> <Button label={translate("etkecc.actions.buttons.back")} onClick={() => navigate("/server_actions")} sx={{ mb: 2 }} > <ArrowBackIcon /> </Button> <RecordContextProvider value={command}> <Card> <CardHeader title={translate("etkecc.actions.scheduled_details_title")} /> <CardContent> {command && ( <EtkeAttribution> <Alert severity="info"> <Typography variant="body1" sx={{ px: 2 }}> {translate("etkecc.actions.command_details_intro")}{" "} <Link href={`https://etke.cc/help/extras/scheduler/#${command.command}`} target="_blank" sx={{ wordBreak: "break-all" }} > {`etke.cc/help/extras/scheduler/#${command.command}`} </Link> . </Typography> </Alert> </EtkeAttribution> )} <SimpleShowLayout> <MuiTextField value={command.id} label={translate("etkecc.actions.form.id")} disabled fullWidth variant="filled" size="small" /> <TextField source="command" label={translate("etkecc.actions.form.command")} /> {command.args && <TextField source="args" label={translate("etkecc.actions.table.arguments")} />} <BooleanField source="is_recurring" label={translate("etkecc.actions.table.is_recurring")} /> <DateField source="scheduled_at" label={translate("etkecc.actions.form.scheduled_at")} showTime locales={locale} /> </SimpleShowLayout> {command.is_recurring && ( <Alert severity="warning">{translate("etkecc.actions.recurring_warning")}</Alert> )} </CardContent> </Card> <Box display="flex" justifyContent="flex-end" mt={2}> <ScheduledDeleteButton /> </Box> </RecordContextProvider> </Box> </> ); }; export default ScheduledCommandShow; ================================================ FILE: src/components/etke.cc/schedules/components/scheduled/ScheduledCommandsList.tsx ================================================ import AddIcon from "@mui/icons-material/Add"; import { Paper } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { Loading, Button, useLocale, useTranslate, useRedirect } from "react-admin"; import { ResourceContextProvider, useList } from "react-admin"; import { ListContextProvider, TextField } from "react-admin"; import { BooleanField, DateField, TopToolbar } from "react-admin"; import { Identifier } from "react-admin"; import { DATE_FORMAT } from "../../../../../utils/date"; import { useScheduledCommands } from "../../hooks/useScheduledCommands"; import { Datagrid } from "../../../../../components/layout"; const ListActions = () => { const redirect = useRedirect(); const translate = useTranslate(); const handleCreate = () => { redirect("/server_actions/scheduled/create"); }; return ( <TopToolbar> <Button label={translate("etkecc.actions.buttons.create")} onClick={handleCreate}> <AddIcon /> </Button> </TopToolbar> ); }; const ScheduledCommandsList = () => { const locale = useLocale(); const translate = useTranslate(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const { data, isLoading } = useScheduledCommands(); const listContext = useList({ resource: "scheduled", sort: { field: "scheduled_at", order: "DESC" }, perPage: 50, data: data || [], isLoading: isLoading, }); if (isLoading) return <Loading />; return ( <ResourceContextProvider value="scheduled"> <ListContextProvider value={listContext}> <ListActions /> <Paper> <Datagrid bulkActionButtons={false} /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ rowClick={(id: Identifier, resource: string, record: any) => { if (!record) { return ""; } if (record.is_recurring) { return `/server_actions/${resource}/${id}/show`; } return `/server_actions/${resource}/${id}`; }} > <TextField source="command" label={translate("etkecc.actions.table.command")} /> {!isSmall && <TextField source="args" label={translate("etkecc.actions.table.arguments")} />} <BooleanField source="is_recurring" label={translate("etkecc.actions.table.is_recurring")} /> <DateField options={DATE_FORMAT} showTime source="scheduled_at" label={translate("etkecc.actions.table.run_at")} locales={locale} /> </Datagrid> </Paper> </ListContextProvider> </ResourceContextProvider> ); }; export default ScheduledCommandsList; ================================================ FILE: src/components/etke.cc/schedules/components/scheduled/ScheduledDeleteButton.tsx ================================================ import DeleteIcon from "@mui/icons-material/Delete"; import { useTheme } from "@mui/material/styles"; import { useState } from "react"; import { useLocale, useNotify, useDataProvider, useRecordContext, useRedirect, useTranslate } from "react-admin"; import { Button, Confirm } from "react-admin"; import { useAppContext } from "../../../../../Context"; import { ScheduledCommand } from "../../../../../providers/types"; const ScheduledDeleteButton = () => { const record = useRecordContext() as ScheduledCommand; const { etkeccAdmin } = useAppContext(); const dataProvider = useDataProvider(); const locale = useLocale(); const notify = useNotify(); const translate = useTranslate(); const theme = useTheme(); const navigate = useRedirect(); const [open, setOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const handleClick = e => { e.stopPropagation(); setOpen(true); }; const handleConfirm = async () => { setIsDeleting(true); try { await dataProvider.deleteScheduledCommand(etkeccAdmin, locale, record.id); notify("etkecc.actions.scheduled.action.delete_success", { type: "success" }); navigate("/server_actions"); } catch (error) { const errorMessage = error instanceof Error ? error.message : translate("etkecc.actions.errors.unknown"); notify(translate("etkecc.actions.errors.delete_failed", { error: errorMessage }), { type: "error" }); } finally { setIsDeleting(false); setOpen(false); } }; const handleCancel = () => { setOpen(false); }; return ( <> <Button sx={{ color: theme.palette.error.main }} label={translate("etkecc.actions.buttons.delete")} onClick={handleClick} disabled={isDeleting} > <DeleteIcon /> </Button> <Confirm isOpen={open} title={translate("etkecc.actions.delete_scheduled_title")} content={translate("etkecc.actions.delete_confirm", { command: record?.command || "" })} onConfirm={handleConfirm} onClose={handleCancel} /> </> ); }; export default ScheduledDeleteButton; ================================================ FILE: src/components/etke.cc/schedules/hooks/useRecurringCommands.tsx ================================================ import { useQuery } from "@tanstack/react-query"; import { useDataProvider, useLocale } from "react-admin"; import { useAppContext } from "../../../../Context"; export const useRecurringCommands = () => { const { etkeccAdmin } = useAppContext(); const dataProvider = useDataProvider(); const locale = useLocale(); const { data, isLoading, error } = useQuery({ queryKey: ["recurringCommands", locale], queryFn: () => dataProvider.getRecurringCommands(etkeccAdmin, locale), }); return { data, isLoading, error }; }; ================================================ FILE: src/components/etke.cc/schedules/hooks/useScheduledCommands.tsx ================================================ import { useQuery } from "@tanstack/react-query"; import { useDataProvider, useLocale } from "react-admin"; import { useAppContext } from "../../../../Context"; export const useScheduledCommands = () => { const { etkeccAdmin } = useAppContext(); const dataProvider = useDataProvider(); const locale = useLocale(); const { data, isLoading, error } = useQuery({ queryKey: ["scheduledCommands", locale], queryFn: () => dataProvider.getScheduledCommands(etkeccAdmin, locale), }); return { data, isLoading, error }; }; ================================================ FILE: src/components/hooks/useDocTitle.test.tsx ================================================ import { renderHook } from "@testing-library/react"; import { useDocTitle } from "./useDocTitle"; describe("useDocTitle", () => { const originalTitle = document.title; afterEach(() => { document.title = originalTitle; delete document.head.dataset.baseTitle; }); it("sets document.title to '<title> - <document.title>'", () => { document.title = "Base App"; renderHook(() => useDocTitle("Users")); expect(document.title).toBe("Users - Base App"); }); it("prefers data-base-title over document.title for the base", () => { document.title = "Something Else"; document.head.dataset.baseTitle = "My Admin"; renderHook(() => useDocTitle("Rooms")); expect(document.title).toBe("Rooms - My Admin"); }); it("updates document.title when the title argument changes", () => { // Use data-base-title so the base stays stable across re-renders document.head.dataset.baseTitle = "App"; const { rerender } = renderHook(({ title }: { title: string }) => useDocTitle(title), { initialProps: { title: "First" }, }); expect(document.title).toBe("First - App"); rerender({ title: "Second" }); expect(document.title).toBe("Second - App"); }); }); ================================================ FILE: src/components/hooks/useDocTitle.tsx ================================================ import { useEffect } from "react"; /** * Custom hook to set the document title dynamically. * Appends the provided title to a base title stored in a data attribute. * Based on hacky workaround described in index.tsx and AdminLayout.tsx. * * @param title - The title to set for the document. */ export const useDocTitle = (title: string) => { useEffect(() => { const baseTitle = document.head.dataset.baseTitle || document.title; document.title = `${title} - ${baseTitle}`; }, [title]); }; ================================================ FILE: src/components/layout/AdminLayout.test.tsx ================================================ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { axe } from "vitest-axe"; import { AdminUserMenu, ActiveMenuItemLink, ActiveResourceItem } from "./AdminLayout"; // ── react-router-dom mock ────────────────────────────────────────────────────── // useMatch is the only import used by the two components under test. // matchRef.current lets each test control whether the route is "active". const matchRef: { current: object | null } = { current: null }; vi.mock("react-router-dom", () => ({ useMatch: vi.fn(() => matchRef.current), })); const onClose = vi.fn(); vi.mock("react-admin", () => { // Menu.Item and Menu.ResourceItem forward aria-current so tests can assert on it. const Menu = Object.assign(({ children }) => <div>{children}</div>, { Item: ({ children, primaryText, "aria-current": ariaCurrent, to }) => ( <a href={`#${to}`} aria-current={ariaCurrent}> {primaryText ?? children} </a> ), ResourceItem: ({ "aria-current": ariaCurrent, name }) => ( <a href={`#/${name}`} aria-current={ariaCurrent}> {name} </a> ), }); return { CheckForApplicationUpdate: () => null, AppBar: ({ children }) => <div>{children}</div>, TitlePortal: () => null, InspectorButton: () => null, Confirm: () => null, Layout: ({ children }) => <div>{children}</div>, LoadingIndicator: () => null, Logout: () => <button type="button">Logout</button>, Menu, ToggleThemeButton: () => null, useLogout: () => vi.fn(), UserMenu: ({ children }) => <div>{children}</div>, useNotify: () => vi.fn(), useStore: (_key, defaultValue) => [defaultValue, vi.fn()], useLocaleState: () => ["en", vi.fn()], useLocale: () => "en", useLocales: () => [ { locale: "en", name: "English" }, { locale: "de", name: "Deutsch" }, ], useTranslate: () => key => { const messages = { "etkecc.donate.menu_label": "Donate", "ra.auth.user_menu": "Profile", }; return messages[key] || key; }, useUserMenu: () => ({ onClose }), useResourceDefinitions: () => ({}), }; }); vi.mock("../../providers/data", () => ({ setDataProviderNotifier: vi.fn(), })); vi.mock("../users/AdminClientConfigItems", () => ({ AdminClientConfigItems: () => <div>Admin client config</div>, })); vi.mock("../../providers/serverVersion", () => ({ useServerVersions: () => ({ synapse: "", mas: "" }), })); vi.mock("../../utils/config", () => ({ ClearConfig: vi.fn(), })); vi.mock("../etke.cc/InstanceConfig", () => ({ ClearInstanceConfig: vi.fn(), useInstanceConfig: () => ({ disabled: { notifications: false, monitoring: false, actions: false, payments: false, support: false, federation: false, registration_tokens: false, }, }), })); vi.mock("../etke.cc/ServerNotificationsBadge", () => ({ ServerNotificationsBadge: () => null, })); vi.mock("../etke.cc/ServerStatusBadge", () => ({ EtkeStatusPoller: () => null, ServerStatusStyledBadge: () => null, })); vi.mock("../../providers/data/mas", () => ({ isMAS: () => false, })); vi.mock("../../Context", () => ({ useAppContext: () => ({ menu: [], etkeccAdmin: "", }), })); describe("AdminUserMenu", () => { beforeEach(() => { onClose.mockReset(); window.location.hash = ""; localStorage.clear(); }); it("renders donate above logout and navigates to the donate page", async () => { const user = userEvent.setup(); render(<AdminUserMenu />); const donateItem = screen.getByText("Donate"); const logoutButton = screen.getByRole("button", { name: "Logout" }); expect(donateItem.compareDocumentPosition(logoutButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); await user.click(donateItem); expect(onClose).toHaveBeenCalledTimes(1); expect(window.location.hash).toBe("#/donate"); }); }); describe("ActiveMenuItemLink aria-current", () => { beforeEach(() => { matchRef.current = null; }); it("has no accessibility violations", async () => { matchRef.current = { params: {}, pathname: "/billing", pathnameBase: "/billing" }; const { container } = render(<ActiveMenuItemLink to="/billing" primaryText="Billing" />); const results = await axe(container); expect(results.violations).toHaveLength(0); }); it("sets aria-current='page' when useMatch returns a match", () => { // Simulates the user being on the /billing route: useMatch returns a truthy // PathMatch object, so ActiveMenuItemLink injects aria-current="page". matchRef.current = { params: {}, pathname: "/billing", pathnameBase: "/billing" }; render(<ActiveMenuItemLink to="/billing" primaryText="Billing" />); const link = screen.getByRole("link", { name: "Billing" }); expect(link.getAttribute("aria-current")).toBe("page"); }); it("omits aria-current when useMatch returns null (route not active)", () => { // useMatch returns null → not on /billing → no aria-current attribute. matchRef.current = null; render(<ActiveMenuItemLink to="/billing" primaryText="Billing" />); const link = screen.getByRole("link", { name: "Billing" }); expect(link.getAttribute("aria-current")).toBeNull(); }); }); describe("ActiveResourceItem aria-current", () => { beforeEach(() => { matchRef.current = null; }); it("sets aria-current='page' when useMatch returns a match", () => { // Simulates the user being on /users or a sub-route like /users/123/show: // useMatch({ path: '/users', end: false }) returns a match. matchRef.current = { params: {}, pathname: "/users", pathnameBase: "/users" }; render(<ActiveResourceItem name="users" />); const link = screen.getByRole("link", { name: "users" }); expect(link.getAttribute("aria-current")).toBe("page"); }); it("omits aria-current when useMatch returns null (different resource active)", () => { // useMatch returns null → not on /users → no aria-current. matchRef.current = null; render(<ActiveResourceItem name="users" />); const link = screen.getByRole("link", { name: "users" }); expect(link.getAttribute("aria-current")).toBeNull(); }); }); ================================================ FILE: src/components/layout/AdminLayout.tsx ================================================ import GavelIcon from "@mui/icons-material/Gavel"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import AccountCircleIcon from "@mui/icons-material/AccountCircle"; import ManageHistoryIcon from "@mui/icons-material/ManageHistory"; import ExtensionIcon from "@mui/icons-material/Extension"; import VolunteerActivismIcon from "@mui/icons-material/VolunteerActivism"; import SupportAgentIcon from "@mui/icons-material/SupportAgent"; import TranslateIcon from "@mui/icons-material/Translate"; import { Box, Divider, ListItemIcon, ListItemText, MenuItem, Typography, useMediaQuery, useTheme } from "@mui/material"; import { useEffect, useState, Suspense } from "react"; import { CheckForApplicationUpdate, AppBar, TitlePortal, InspectorButton, Confirm, Layout, LoadingIndicator, Logout, Menu, ToggleThemeButton, useLogout, UserMenu, useNotify, useStore, useLocaleState, useLocale, useLocales, useTranslate, useUserMenu, useResourceDefinitions, } from "react-admin"; import { useMatch } from "react-router-dom"; import { setDataProviderNotifier } from "../../providers/data"; import { AdminClientConfigItems } from "../users/AdminClientConfigItems"; import Footer from "./Footer"; import { LoginMethod } from "../../pages/LoginPage"; import { ServerProcessResponse, ServerStatusResponse } from "../../providers/types"; import { useServerVersions } from "../../providers/serverVersion"; import { ClearConfig } from "../../utils/config"; import { Icons, DefaultIcon } from "../../utils/icons"; import { EtkeAttribution } from "../etke.cc/EtkeAttribution"; import { ClearInstanceConfig, useInstanceConfig } from "../etke.cc/InstanceConfig"; import { ServerNotificationsBadge } from "../etke.cc/ServerNotificationsBadge"; import { EtkeStatusPoller, ServerStatusStyledBadge } from "../etke.cc/ServerStatusBadge"; import { BillingStatusBadge, BillingStatusPoller } from "../etke.cc/BillingStatusBadge"; import { isMAS } from "../../providers/data/mas"; import { useAppContext } from "../../Context"; const ServerVersionItems = () => { const serverVersions = useServerVersions(); if (!serverVersions.synapse && !serverVersions.mas) return null; return ( <> {serverVersions.synapse && ( <MenuItem dense sx={{ pointerEvents: "none" }}> <ListItemIcon> <InfoOutlinedIcon fontSize="small" /> </ListItemIcon> <ListItemText> <Typography variant="body2">Synapse v{serverVersions.synapse}</Typography> </ListItemText> </MenuItem> )} {serverVersions.mas && ( <MenuItem dense sx={{ pointerEvents: "none" }}> <ListItemIcon> <InfoOutlinedIcon fontSize="small" /> </ListItemIcon> <ListItemText> <Typography variant="body2">MAS {serverVersions.mas}</Typography> </ListItemText> </MenuItem> )} <Divider sx={{ my: 0.5 }} /> </> ); }; const AdminAppBarToolbar = () => ( <> <ToggleThemeButton /> <LoadingIndicator /> </> ); const LocaleMenuItems = () => { const locales = useLocales(); const [locale, setLocale] = useLocaleState(); if (!locales || locales.length <= 1) return null; return ( <> {locales.map(loc => ( <MenuItem key={loc.locale} dense selected={locale === loc.locale} onClick={() => setLocale(loc.locale)}> <ListItemIcon> <TranslateIcon fontSize="small" /> </ListItemIcon> <ListItemText>{loc.name}</ListItemText> </MenuItem> ))} <Divider sx={{ my: 0.5 }} /> </> ); }; const ProfileMenuItem = () => { const translate = useTranslate(); const userMenu = useUserMenu(); const onClose = userMenu?.onClose; const userId = localStorage.getItem("user_id"); if (!userId) return null; return ( <MenuItem dense onClick={() => { onClose?.(); window.location.hash = `/users/${encodeURIComponent(userId)}`; }} > <ListItemIcon> <AccountCircleIcon fontSize="small" /> </ListItemIcon> <ListItemText>{translate("ra.auth.user_menu")}</ListItemText> </MenuItem> ); }; const DonateMenuItem = () => { const translate = useTranslate(); const userMenu = useUserMenu(); const onClose = userMenu?.onClose; return ( <MenuItem dense onClick={() => { onClose?.(); window.location.hash = "/donate"; }} > <ListItemIcon> <VolunteerActivismIcon fontSize="small" /> </ListItemIcon> <ListItemText>{translate("etkecc.donate.menu_label")}</ListItemText> </MenuItem> ); }; export const AdminUserMenu = () => { const [open, setOpen] = useState(false); const logout = useLogout(); const checkLoginType = (ev: React.MouseEvent<HTMLDivElement>) => { const loginType: LoginMethod = (localStorage.getItem("login_type") || "credentials") as LoginMethod; if (loginType === "accessToken") { ev.stopPropagation(); setOpen(true); } }; const handleConfirm = () => { setOpen(false); ClearConfig(); ClearInstanceConfig(); logout(); }; const handleDialogClose = () => { setOpen(false); ClearConfig(); ClearInstanceConfig(); window.location.reload(); }; return ( <UserMenu> <ServerVersionItems /> <ProfileMenuItem /> <Divider sx={{ my: 0.5 }} /> <AdminClientConfigItems /> <LocaleMenuItems /> <DonateMenuItem /> <div onClickCapture={checkLoginType}> <Logout /> </div> <Confirm isOpen={open} title="ketesa.auth.logout_access_token_dialog.title" content="ketesa.auth.logout_access_token_dialog.content" onConfirm={handleConfirm} onClose={handleDialogClose} confirm="ketesa.auth.logout_access_token_dialog.confirm" cancel="ketesa.auth.logout_access_token_dialog.cancel" /> </UserMenu> ); }; const AdminAppBar = () => { const icfg = useInstanceConfig(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); return ( <AppBar userMenu={<AdminUserMenu />} toolbar={<AdminAppBarToolbar />}> <TitlePortal sx={{ display: { xs: "none", sm: "block" } }} /> {isSmall && <Box sx={{ flex: 1 }} />} {!icfg.disabled.notifications && <ServerNotificationsBadge />} {!isSmall && <InspectorButton />} </AppBar> ); }; const MAS_RESOURCE_PREFIX = "mas_"; /** * Renders resource menu items, excluding mas_* resources from the auto-list. * MAS resources (sessions, emails, users, upstream links) are managed inline on the user edit page. * Only upstream OAuth providers appear in the sidebar as a global admin config resource. */ const MAS_SESSION_RESOURCES = ["mas_upstream_oauth_providers"]; /** * Wraps Menu.Item and injects aria-current="page" on the active route. * Note: aria-current is forwarded to the underlying <a> via RA's MenuItemLink prop-forwarding. * If a future RA upgrade stops forwarding unknown props, aria-current will silently drop (progressive enhancement). */ export const ActiveMenuItemLink = ({ to, ...props }: React.ComponentProps<typeof Menu.Item> & { to: string }) => { const match = useMatch({ path: to, end: true }); return <Menu.Item to={to} aria-current={match ? "page" : undefined} {...props} />; }; /** Wraps Menu.ResourceItem and injects aria-current="page" on the active resource route. */ export const ActiveResourceItem = ({ name, ...props }: React.ComponentProps<typeof Menu.ResourceItem> & { name: string }) => { const match = useMatch({ path: `/${name}`, end: false }); return <Menu.ResourceItem name={name} aria-current={match ? "page" : undefined} {...props} />; }; const ResourceMenuItems = () => { const resources = useResourceDefinitions(); const masEnabled = isMAS(); return ( <> {Object.keys(resources) .filter(name => !name.startsWith(MAS_RESOURCE_PREFIX) && resources[name].hasList) .map(name => ( <span key={name}> <ActiveResourceItem name={name} /> {name === "users" && masEnabled && MAS_SESSION_RESOURCES.map( masName => resources[masName] && ( <ActiveResourceItem key={masName} name={masName} sx={{ "& .RaMenuItemLink-root": { pl: "20px" } }} /> ) )} </span> ))} </> ); }; const AdminMenu = props => { const locale = useLocale(); const icfg = useInstanceConfig(); const { menu, etkeccAdmin } = useAppContext(); const etkeRoutesEnabled = Boolean(etkeccAdmin); const [serverProcess, _setServerProcess] = useStore<ServerProcessResponse>("serverProcess", { command: "", locked_at: "", }); const [serverStatus, _setServerStatus] = useStore<ServerStatusResponse>("serverStatus", { success: false, ok: false, host: "", results: [], }); return ( <Menu {...props} sx={theme => ({ color: theme.palette.mode === "dark" ? "#E0E0E0" : "#FFFFFF", "& .RaMenuItemLink-root": { justifyContent: "center", padding: "0px 2px 0px 0px", marginBottom: 0, borderLeft: "3px solid transparent", color: "inherit", transition: "background-color 150ms ease, border-color 150ms ease", "&:hover": { backgroundColor: "rgba(255, 255, 255, 0.08)", }, }, "& .RaMenuItemLink-icon": { minWidth: 44, width: 44, height: 44, backgroundColor: "transparent", color: "inherit", display: "flex", alignItems: "center", justifyContent: "center", transition: "box-shadow 150ms ease, transform 150ms ease", }, "& .MuiSvgIcon-root": { color: "inherit", }, "& .RaMenuItemLink-active": { backgroundColor: "rgba(255, 255, 255, 0.12)", borderLeftColor: theme.palette.mode === "dark" ? theme.palette.primary.main : "#FFFFFF", color: (theme.palette.mode === "dark" ? "#E0E0E0" : "#FFFFFF") + " !important", }, })} > {etkeRoutesEnabled && <EtkeStatusPoller />} {etkeRoutesEnabled && !icfg.disabled.payments && <BillingStatusPoller />} {etkeRoutesEnabled && !icfg.disabled.monitoring && ( <ActiveMenuItemLink key="server_status" to="/server_status" leftIcon={ <ServerStatusStyledBadge inSidebar={true} command={serverProcess.command} locked_at={serverProcess.locked_at} isOkay={serverStatus.ok} isLoaded={serverStatus.success} /> } primaryText="etkecc.status.name" /> )} {etkeRoutesEnabled && !icfg.disabled.actions && ( <ActiveMenuItemLink key="server_actions" to="/server_actions" leftIcon={<ManageHistoryIcon aria-hidden />} primaryText="etkecc.actions.name" /> )} <ResourceMenuItems /> {isMAS() && ( <ActiveMenuItemLink key="mas_policy_data" to="/mas_policy_data" leftIcon={<GavelIcon aria-hidden />} primaryText="resources.mas_policy_data.name" /> )} {etkeRoutesEnabled && !icfg.disabled.payments && ( <ActiveMenuItemLink key="billing" to="/billing" leftIcon={<BillingStatusBadge />} primaryText="etkecc.billing.name" /> )} {etkeRoutesEnabled && !icfg.disabled.payments && ( <ActiveMenuItemLink key="components" to="/components" leftIcon={<ExtensionIcon aria-hidden />} primaryText="etkecc.components.name" /> )} {etkeRoutesEnabled && !icfg.disabled.support && ( <ActiveMenuItemLink key="support" to="/support" leftIcon={<SupportAgentIcon aria-hidden />} primaryText="etkecc.support.menu_label" /> )} {menu && menu.map(item => { const { url, icon, label, i18n } = item; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const IconComponent = Icons[icon] as React.ComponentType<any> | undefined; let primaryText = label; if (i18n && i18n[locale]) { primaryText = i18n[locale]; } return ( <Suspense key={url}> <Menu.Item to={url} target="_blank" primaryText={primaryText} leftIcon={IconComponent ? <IconComponent /> : <DefaultIcon />} onClick={props.onMenuClick} /> </Suspense> ); })} </Menu> ); }; const DataProviderNotifierBridge = () => { const notify = useNotify(); useEffect(() => { setDataProviderNotifier((key: string) => notify(key, { type: "info" })); return () => setDataProviderNotifier(() => undefined); }, [notify]); return null; }; export const AdminLayout = ({ children }) => { // Set the document language based on the selected locale const [locale, _setLocale] = useLocaleState(); const icfg = useInstanceConfig(); const translate = useTranslate(); useEffect(() => { document.documentElement.lang = locale; // copy of the code from index.tsx to set base title dynamically document.head.dataset.baseTitle = icfg.name || "Ketesa"; // set <title> based on instance name, only if it's not already set if (icfg.name && !document.title.includes(icfg.name)) { document.title = icfg.name; } if (icfg.favicon_url) { const link: HTMLLinkElement | null = document.querySelector("link[rel~='icon']"); if (link) { link.href = icfg.favicon_url; } else { const newLink = document.createElement("link"); newLink.rel = "icon"; newLink.href = icfg.favicon_url; document.getElementsByTagName("head")[0].appendChild(newLink); } } }, [locale, icfg.name, icfg.favicon_url]); return ( <> <Box component="a" href="#main-content" sx={{ position: "absolute", transform: "translateY(-100%)", "&:focus": { transform: "translateY(0)" }, zIndex: 9999, top: 0, left: 0, p: 1, bgcolor: "background.paper", color: "text.primary", }} > {translate("ra.navigation.skip_nav")} </Box> <DataProviderNotifierBridge /> <Layout appBar={AdminAppBar} menu={AdminMenu} sx={theme => ({ minWidth: 0, ["& .RaLayout-appFrame"]: { minHeight: "90vh", height: "90vh", }, ["& .RaLayout-content"]: { minWidth: 0, marginBottom: { xs: "4rem", sm: "3rem" }, }, ["& .RaLayout-contentWithSidebar > .MuiDrawer-root"]: { "& .MuiPaper-root": { backgroundColor: (theme.palette.mode === "dark" ? "#080D12" : "#334258") + " !important", }, "& .RaSidebar-fixed": { backgroundColor: theme.palette.mode === "dark" ? "#080D12" : "#334258", }, }, })} > <Box id="main-content" tabIndex={-1} sx={{ outline: 0 }}> {children} </Box> <CheckForApplicationUpdate /> </Layout> <EtkeAttribution> <Footer /> </EtkeAttribution> </> ); }; export default AdminLayout; ================================================ FILE: src/components/layout/Datagrid.test.tsx ================================================ // SPDX-FileCopyrightText: 2026 Nikita Chernyi <https://etke.cc> // SPDX-License-Identifier: Apache-2.0 /** * Regression suite for the custom Datagrid component's accessibility features * (src/components/layout/Datagrid.tsx). * * ─── PURPOSE ────────────────────────────────────────────────────────────────── * Verifies correct behavior for all field types handled by injectCellTitles and * all accessibility attributes added by AccessibleRow/AccessibleBody. * Tests are kept even after bugs are fixed — they act as regression guards. * "← PASSES" marks confirmed correct behavior; no "← FAILS" remain. * * ─── HOW THE COMPONENT WORKS ────────────────────────────────────────────────── * Datagrid wraps react-admin's DatagridConfigurable with two custom components: * * AccessibleBody — replaces the default tbody. For every row it: * 1. Resolves the resource via useResourceContext({ resource }) so that a * missing resource prop falls back to the parent List's ResourceContext. * 2. Wraps the record in RecordContextProvider so child fields can read it. * 3. Calls injectCellTitles(children, record, resource, translate): * – Maps over every direct child field element. * – For each field that has a `source` prop: * a. resolveLabel(label, source, resource, translate) * → if label is a string : translate(label) * → else : translate(`resources.${resource}.fields.${source}`) * → ultimate fallback : humanizeSource(source) e.g. "media_id" → "Media Id" * b. Value resolution (field-type dispatch): * DateField : new Date(rawValue).toLocaleString(locales, options) * ReferenceField : record[firstChildSource] if present, else record[source] * FunctionField (render) : render(record) result as string/number, else rawValue * everything else : formatCellValue(record[source], translate) * → null/undefined : "—" * → boolean : translate("ra.boolean.true/false") * → string/number : String(value) * → everything else : "—" * c. Clones the child with title=`${label}: ${value}`. * 4. Renders an AccessibleRow (described below) with the injected children. * * AccessibleRow — replaces the default tr. For clickable rows it: * 1. Adds tabIndex=0 so the row is keyboard-focusable. * 2. Adds a keyDown handler: Enter/Space → currentTarget.click() * (guarded so events bubbled from inner interactive elements are ignored). * 3. Adds aria-rowindex (1-based, offset for header row + pagination page). * 4. Adds aria-label from the rowLabel prop (function or field name). * * ─── FIELD TYPE DETECTION ───────────────────────────────────────────────────── * injectCellTitles detects field types at render time to produce accurate titles: * * DateField : child.type === DateField (real import, works correctly) * ReferenceField : typeof props.reference === "string" * Duck-typed because child.type === ReferenceField triggers TS6133 — TypeScript * treats the === comparison as "never reads" the imported value and raises an * "is declared but its value is never read" error. The same constraint applies * to FunctionField (see below). * FunctionField : typeof props.render === "function" * Same TS6133 issue; duck-typed on the render prop instead. * * ─── CROSS-RESOURCE FIELDS ──────────────────────────────────────────────────── * injectCellTitles only knows the Datagrid's own resource namespace. Fields that * logically belong to a different resource (e.g. displayname from users, rendered * inside a room_members Datagrid) must carry an explicit label prop pointing to * the correct translation key, e.g. label="resources.users.fields.displayname". * Without it, resolveLabel consults the wrong namespace and falls back to the * humanized source name. * * ─── MOCK STRATEGY ──────────────────────────────────────────────────────────── * We use a PARTIAL mock of "react-admin" (importOriginal + spread ...actual) so * that real field components render authentic DOM output: * KEPT REAL : DateField, TextField, BooleanField, FunctionField, * RecordContextProvider, useRecordContext * MOCKED : DatagridConfigurable, DatagridRow, DatagridBody, * DatagridClasses, useListContext, useResourceContext, * useGetRecordRepresentation, useTranslate, ReferenceField * * ReferenceField is the only real field that is mocked because the real * implementation calls useGetManyAggregate → useDataProvider → useQueryClient, * which requires a full QueryClientProvider + DataProvider setup that is * disproportionate for this unit test. Our lightweight mock replicates the * visual behavior (resolves and renders the display name) while remaining * self-contained. */ import React from "react"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; // ─── IMPORTANT: hoisting note ───────────────────────────────────────────────── // vitest hoists vi.mock() calls to the top of the compiled output, before any // import statements. However, the factory functions passed to vi.mock() are // closures — they are REGISTERED at hoist time but EXECUTED lazily, only when // "react-admin" is first imported during test setup. By that point all // module-level const/let declarations below have already been evaluated, so // closures that reference them (e.g. `() => translate` or `mockDataRef.current`) // are safe from Temporal Dead Zone errors even though they appear after the // vi.mock() call in the source. // ─── Translation dictionary ──────────────────────────────────────────────────── // An invented, non-English set of strings that mimics a real locale file. // Using invented strings (rather than importing the real DE locale) keeps the // test self-contained and immune to locale file changes. // // Key design decisions: // - "resources.room_members.fields.displayname" is intentionally ABSENT. // This exposes Bug #5 (wrong resource context). // - No "resources.*.fields.unlabeled_field" key exists anywhere. // This exposes Bug #4 (missing translation → source name fallback). // - "ra.boolean.true/false" ARE present so boolean title tests can PASS, // proving that the boolean translation path works correctly. const TRANSLATIONS: Record<string, string> = { // users_media resource — mirrors src/i18n/de/misc_resources.ts "resources.users_media.fields.media_id": "Medien-ID", "resources.users_media.fields.created_ts": "Erstellt", "resources.users_media.fields.last_access_ts": "Letzter Zugriff", "resources.users_media.fields.media_length": "Größe", // "File Size" "resources.users_media.fields.media_type": "Typ", "resources.users_media.fields.upload_name": "Dateiname", "resources.users_media.fields.quarantined_by": "In Quarantäne von", // users resource — mirrors src/i18n/de/users.ts "resources.users.fields.id": "Benutzer-ID", "resources.users.fields.displayname": "Anzeigename", // "Display name" "resources.users.fields.admin": "Administrator", "resources.users.fields.creation_ts_ms": "Erstellt am", "resources.users.fields.is_guest": "Gastbenutzer", // room_members resource — deliberately missing "displayname" (exposes Bug #5) "resources.room_members.fields.id": "Mitglieds-ID", // boolean labels — react-admin's standard keys used by formatCellValue() "ra.boolean.true": "Ja", // "Yes" "ra.boolean.false": "Nein", // "No" }; /** * Simulates the translate() function returned by useTranslate() in the test. * * Lookup order (matches react-admin's polyglot provider behavior): * 1. Exact key match in TRANSLATIONS. * 2. The `_` option (fallback value supplied by the caller). * 3. The raw key string itself (last-resort fallback). * * This is the function that injectCellTitles calls internally via * useTranslate() when resolving field labels and boolean values. * * @param key - The i18n translation key (e.g. "resources.users.fields.id") * @param opts - Optional object; opts._ is the caller-supplied fallback string * @returns The translated string, the fallback, or the raw key */ const translate = (key: string, opts?: Record<string, unknown>): string => TRANSLATIONS[key] ?? (opts?._ as string | undefined) ?? key; // ─── Reference resolution data ──────────────────────────────────────────────── // Simulates the records that a real DataProvider would return when // ReferenceField fetches users by their Matrix ID. // Used by the ReferenceField mock (see below) to replicate reference resolution // without needing a QueryClient or DataProvider in scope. const RESOLVED_USERS: Record<string, Record<string, unknown>> = { // Key = Matrix user ID (the value of the `source` field on the outer record) // Value = the record that would be returned by GET /users/:id "@alice:example.org": { id: "@alice:example.org", displayname: "Alice" }, }; // ─── Test records ────────────────────────────────────────────────────────────── // Each record mirrors the shape returned by the Synapse Admin API for that // resource. They are typed loosely here and cast to RaRecord at render time // because RaRecord requires Identifier (string | number) for id, while these // objects use concrete types the tests can inspect easily. /** * A users_media record. Mirrors the shape used by UserMediaList in * src/resources/users/Edit.tsx. Key values chosen to expose bugs: * - created_ts/last_access_ts : millisecond timestamps (not Date objects) * so the title will show a raw integer, not a formatted date. * - media_length 1024 : formatBytes(1024) = "1.0 KB" so we can * assert the title should show "1.0 KB" but actually shows "1024". * - quarantined_by null : exercises the "—" fallback in formatCellValue. */ const MEDIA_RECORD = { id: "1", media_id: "mxc://example.org/abc123", created_ts: 1700000000000, // 2023-11-14T22:13:20.000Z in milliseconds last_access_ts: 1699900000000, media_length: 1024, // bytes; formatBytes(1024) = "1.0 KB" media_type: "image/jpeg", upload_name: "photo.jpg", quarantined_by: null, // null → title value should be "—" }; /** * A users record. Mirrors the shape used by UserList in * src/resources/users/List.tsx. Key values: * - id : a Matrix user ID containing special chars (@, :) — these * are valid in Matrix but invalid as HTML id attributes, so DatagridRow * mock must NOT spread `id` onto <tr>. * - creation_ts_ms : same millisecond-timestamp bug as created_ts above. * - admin true : exercises the boolean translation path ("Ja"). * - is_guest false : exercises the boolean false path ("Nein"). */ const USER_RECORD = { id: "@alice:example.org", displayname: "Alice", admin: true, creation_ts_ms: 1700000000000, is_guest: false, }; /** * A room_members record (minimal — only the fields the room-members Datagrid * actually uses). The displayname field is here to support two tests: * - ReferenceField test: the outer record's `id` is the source; the mock * ReferenceField resolves RESOLVED_USERS["@alice:example.org"].displayname. * - Wrong-resource-context test: a plain TextField(source="displayname") * should look up resources.room_members.fields.displayname, which is absent, * so it falls back to the bare source name "displayname". */ const MEMBER_RECORD = { id: "@alice:example.org", displayname: "Alice", }; // ─── Mock control refs ───────────────────────────────────────────────────────── // These plain objects act as mutable cells that the vi.mock() factory closures // read at call-time. We use object wrappers (not bare let variables) because: // a) `const` objects can have their properties mutated. // b) The factory closures capture the binding at registration time, but only // dereference `.current` when the mock function is actually called // (during a test render), by which time renderWith() has already set the // correct values. // // Alternative pattern: vi.fn() stubs + mockReturnValue() in renderWith(). // We chose refs because it avoids importing mock references before they're // needed and keeps renderWith() as the single point of mutation. /** The array of records that DatagridConfigurable mock passes to AccessibleBody as `data`. */ const mockDataRef: { current: Record<string, unknown>[] } = { current: [] }; /** The resource name that DatagridConfigurable and useResourceContext return for the current render. */ const resourceRef: { current: string } = { current: "users" }; // ─── react-admin partial mock ────────────────────────────────────────────────── // `async importOriginal` gives us the real module so we can spread ...actual // and keep real field components working. Only items that need a full // AdminContext (DataProvider, QueryClientProvider, etc.) are replaced. // // Why partial rather than full mock? // Full mock: we write fake implementations for DateField, BooleanField, etc. // → we lose the authentic rendering behavior; a bug in our fake fields // could mask or invent issues that don't exist in production. // Partial mock: real fields render exactly as they do in production. // → the test proves that the rendered output and the injected title disagree, // which is the real bug we want to catch. vi.mock("react-admin", async importOriginal => { // `actual` contains the real, unmocked react-admin exports. // We destructure RecordContextProvider and useRecordContext here so the // ReferenceField mock below can use them without going through the mock // object (which would create a circular reference). const actual = await importOriginal<typeof import("react-admin")>(); const { RecordContextProvider, useRecordContext } = actual; return { // Spread first so all real exports are available as defaults. // Individual overrides below shadow specific keys. ...actual, // ── ReferenceField lightweight mock ──────────────────────────────────────── // Why mocked: the real ReferenceField calls useGetManyAggregate internally. // useGetManyAggregate → useDataProvider → @tanstack/react-query's // useQueryClient, which throws "No QueryClient set" unless the render tree // includes a QueryClientProvider. Setting up QueryClientProvider requires a // DataProvider mock as well, which is disproportionate for this unit test. // // What this mock does instead: // 1. Calls useRecordContext() (REAL — reads from RecordContextProvider set // by AccessibleBody) to get the current row record. // 2. Reads record[source] to find the reference ID (e.g. "@alice:example.org"). // 3. Looks up RESOLVED_USERS[id] to simulate what a real DataProvider would // return for that ID. // 4. Wraps children in RecordContextProvider (REAL) with the resolved record // so the inner TextField reads displayname = "Alice" from context. // 5. Renders a <span title={title}> so the `title` prop injected by // injectCellTitles DOES appear in the DOM — this is the key difference // from the real ReferenceField, which is a pure context provider and // renders NO DOM element, meaning the title would be silently discarded. // // What the test asserts: // injectCellTitles detects ReferenceField via typeof props.reference === "string", // reads the first child's source ("displayname"), and looks it up on the outer // record: record["displayname"] = "Alice". The injected title is therefore // "Anzeigename: Alice" — the resolved display name, not the raw ID. ReferenceField: vi.fn( ({ children, // `source` — the field on the outer record whose value is the reference // ID, e.g. source="id" → record["id"] = "@alice:example.org". source, // `reference` — the resource to fetch from, e.g. "users". reference, // `title` — injected by injectCellTitles as "Label: resolved-display-value". // We pass this through to the DOM <span> so tests can assert on it. title, // Consume all other props (label, link, sortable, etc.) silently // so they don't reach the DOM and trigger React unknown-prop warnings. }: { children?: React.ReactNode; source: string; reference: string; title?: string; [k: string]: unknown; }) => { // Read the current row record from the RecordContextProvider that // AccessibleBody wraps around each row. This is the OUTER record // (e.g. the room_members row), not the RESOLVED user record. const record = useRecordContext() as Record<string, unknown> | undefined; // Derive the reference ID from the outer record using the source prop. // e.g. source="id", record.id = "@alice:example.org" → "@alice:example.org" const sourceId = record ? String(record[source] ?? "") : ""; // Simulate DataProvider.getMany: look up the resolved record. // In production, ReferenceField batches these lookups via React Query. // Here we read directly from RESOLVED_USERS for simplicity. const resolved = reference === "users" ? (RESOLVED_USERS[sourceId] as import("react-admin").RaRecord) : undefined; return ( // The <span title={title}> wrapper is the critical difference from the // real ReferenceField: it makes the injected title visible in the DOM. // The real ReferenceField has no DOM element, so its title prop is lost. <span title={title}> {/* * Provide the RESOLVED record as context so child fields (e.g. * <TextField source="displayname">) render the reference's value * ("Alice") rather than trying to read from the outer record. * Falls back to an empty record when resolution fails. */} <RecordContextProvider value={resolved ?? ({ id: "" } as import("react-admin").RaRecord)}> {children} </RecordContextProvider> </span> ); } ), // ── DatagridConfigurable mock ─────────────────────────────────────────────── // Why mocked: the real DatagridConfigurable renders a MUI Table, sets up // column preferences (requires Store context), and passes data from // ListContext to the body. All of this requires a full AdminContext. // // What this mock does: // It receives `body` (which is `<AccessibleBody rowLabel={...} />` as // constructed by Datagrid.tsx) and cloneElement's it with the test data // and resource. This bypasses the RA table infrastructure while still // exercising AccessibleBody (which is the component under test). // // Data flow: renderWith() sets mockDataRef.current → this mock reads it // when called → passes it to AccessibleBody as the `data` prop. DatagridConfigurable: vi.fn( ({ // `body` is `<AccessibleBody rowLabel={rowLabel} />`. The rowLabel prop // is already baked into the element by Datagrid.tsx before this mock // receives it, so we don't need to thread it through separately. body, // `children` are the field elements (TextField, DateField, etc.) passed // as children of the <Datagrid> in the test. AccessibleBody receives // them via the `children` prop and processes them in injectCellTitles. children, // `rowClick` is forwarded so AccessibleRow can determine whether the row // is clickable (affects tabIndex, keyboard handler, aria attributes). rowClick, // All other DatagridConfigurable props (sx, sort, bulkActionButtons, etc.) // are ignored — they are irrelevant to the accessibility features we test. }: { body: React.ReactElement; children?: React.ReactNode; rowClick?: unknown; [k: string]: unknown; }) => React.cloneElement( // Cast required because body's prop type is unknown to TypeScript here; // at runtime it is AccessibleBodyProps which accepts all these fields. body as React.ReactElement<Record<string, unknown>>, { // Records to render — set by renderWith() before each test render. data: mockDataRef.current, // Resource name — used by resolveLabel() to construct the i18n key // (e.g. "resources.users_media.fields.created_ts"). resource: resourceRef.current, // Forward rowClick so AccessibleRow knows whether rows are clickable. rowClick, // The field elements; AccessibleBody passes them to injectCellTitles. children, // Minimal required RA props: selectedIds: [] as unknown[], // no rows selected hasBulkActions: false, // no bulk-action checkbox column } ) ), // ── DatagridRow mock ──────────────────────────────────────────────────────── // Why mocked: the real DatagridRow renders each child field into a // DatagridCell (<td>) with its own styling, expand logic, and checkbox. // All of this requires Store context (for column show/hide preferences). // // What this mock does: // Renders a bare <tr> forwarding only the DOM-valid props that our // accessibility tests need to inspect. RA-specific props (rowClick, sx, // hover, expand, etc.) are explicitly destructured and discarded to prevent // React's "unknown DOM attribute" warning. // // Why children are direct <tr> children instead of inside <td> wrappers: // The field components render their own DOM (Typography/span/time), so // having them as direct children of <tr> violates HTML table rules but // works fine in jsdom for attribute inspection and role queries. DatagridRow: vi.fn( ({ children, // ── RA-specific props (consumed and ignored) ──────────────────────── // rowClick : handled by AccessibleRow; DatagridRow doesn't need it here. rowClick: _rowClick, // expand : row expansion feature; not tested here. expand: _expand, // hasBulkActions, selectable, selected, onToggleItem : checkbox column. hasBulkActions: _hasBulkActions, selectable: _selectable, selected: _selected, onToggleItem: _onToggleItem, // hover : MUI hover styling; not a DOM attribute. hover: _hover, // resource : used by real DatagridRow for expand/detail; irrelevant here. resource: _resource, // rowIndex : consumed by AccessibleRow before reaching DatagridRow; // included here defensively in case it leaks through. rowIndex: _rowIndex, // rowLabel : same — consumed by AccessibleRow. rowLabel: _rowLabel, // sx : MUI system prop; not a DOM attribute. sx: _sx, // id : would be record.id (e.g. "@alice:example.org"). // Matrix IDs contain "@" and ":" which are invalid in HTML id // attribute values, so we discard this to avoid jsdom warnings. id: _id, // ── DOM-valid props (forwarded to <tr>) ───────────────────────────── // These are set by AccessibleRow on clickable rows. "aria-label": ariaLabel, // set from rowLabel prop or record representation "aria-roledescription": ariaRoledescription, // "link" on clickable rows — signals navigability to AT without breaking table ARIA hierarchy "aria-rowindex": ariaRowIndex, // 1-based, accounting for header row + pagination tabIndex, // 0 on clickable rows, absent on static rows onKeyDown, // AccessibleRow's keyboard handler (Enter/Space → click) // Standard HTML props that may come through from rowSx/rowStyle className, style, }: Record<string, unknown> & { children?: React.ReactNode }) => ( <tr aria-label={ariaLabel as string | undefined} aria-roledescription={ariaRoledescription as string | undefined} aria-rowindex={ariaRowIndex as number | undefined} tabIndex={tabIndex as number | undefined} // onKeyDown is AccessibleRow.handleKeyDown: // if (e.target !== e.currentTarget) return; ← guards against child events // if (e.key === "Enter" || e.key === " ") e.currentTarget.click(); onKeyDown={onKeyDown as React.KeyboardEventHandler<HTMLTableRowElement> | undefined} className={className as string | undefined} style={style as React.CSSProperties | undefined} > {/* * In production, DatagridRow wraps each child in a DatagridCell (<td>). * Here we render children directly in <tr>. This is invalid HTML but * jsdom accepts it and does not affect any attribute we assert on. */} {children} </tr> ) ), // ── DatagridBody mock ──────────────────────────────────────────────────────── // The default DatagridBody from RA. Not exercised in our tests (we use // AccessibleBody instead), but RA imports DatagridBody at module load time // and would throw if it tried to render it. A minimal stub is sufficient. DatagridBody: vi.fn(({ children }: { children?: React.ReactNode }) => <tbody>{children}</tbody>), // ── DatagridClasses mock ──────────────────────────────────────────────────── // CSS class name constants used by AccessibleBody when assembling the // className string for each row. The values don't affect our assertions // but must exist as strings so the template literals in AccessibleBody // don't produce "undefined" in the className. DatagridClasses: { tbody: "datagrid-tbody", row: "datagrid-row", rowEven: "datagrid-row-even", rowOdd: "datagrid-row-odd", }, // ── Hook mocks ────────────────────────────────────────────────────────────── // useListContext: provides `page` and `perPage` used by AccessibleBody to // calculate aria-rowindex offset. // offset = (page - 1) * perPage = (1 - 1) * 10 = 0 // first row aria-rowindex = offset + rowIndex(0) + 2 = 2 // (The +2 accounts for: +1 to convert 0-based rowIndex to 1-based, // +1 for the header row which occupies aria-rowindex 1.) useListContext: vi.fn(() => ({ page: 1, perPage: 10 })), // useResourceContext: returns the current resource name. // AccessibleBody passes this to injectCellTitles, which uses it to build // the translation key: resources.${resource}.fields.${source} // Value is read lazily from resourceRef.current at call time. useResourceContext: vi.fn(() => resourceRef.current), // useGetRecordRepresentation: used by AccessibleRow as the last-resort // aria-label generator when no rowLabel prop is provided. // Returns a function that converts any record to a string. // In production this uses the resource's recordRepresentation setting; // here we always return String(record.id) which is sufficient for testing. useGetRecordRepresentation: vi.fn(() => (record: Record<string, unknown>) => String(record.id)), // useTranslate: returns the `translate` function defined above. // injectCellTitles uses this to: // a) Resolve field labels (resolveLabel) // b) Format boolean values (formatCellValue: "Ja"/"Nein") // Value is read lazily from the `translate` closure at call time. useTranslate: vi.fn(() => translate), }; }); // EmptyState is imported by Datagrid.tsx as the default `empty` prop. // It uses useResourceContext and other hooks; stub it out to avoid // setting up the resource context for renders that produce no rows. vi.mock("./EmptyState", () => ({ default: () => null })); // ─── Post-mock imports ───────────────────────────────────────────────────────── // These must be after the vi.mock() calls in the source so that vitest's // hoisting transformer places the vi.mock() registrations before these imports // are resolved. At runtime, these imports receive the mocked/partial-mocked // versions of the modules. // // BooleanField, DateField, FunctionField, TextField — kept REAL (from ...actual) // so their DOM output is authentic. // ReferenceField — MOCKED (see above). // RaRecord — type only; not mocked. import { BooleanField, DateField, FunctionField, RaRecord, ReferenceField, TextField } from "react-admin"; // DATE_FORMAT — the Intl.DateTimeFormatOptions used by the actual DateField // calls in production (src/utils/date.ts). Imported here so the expected date // string in tests is computed with the same format options. import { DATE_FORMAT } from "../../utils/date"; // formatBytes — the human-readable byte formatter used by FunctionField in the // users media table. Imported here to compute the expected formatted string. import { formatBytes } from "../../utils/formatBytes"; // Datagrid — the component under test. // DatagridProps — needed for typing the rowClick/rowLabel options in renderWith. import Datagrid, { DatagridProps } from "./Datagrid"; // ─── renderWith helper ───────────────────────────────────────────────────────── /** * Renders a Datagrid containing `children` for the given `data` and `resource`, * optionally with row click and row label options. * * Before rendering it updates the mock control refs so DatagridConfigurable and * useResourceContext return the correct values for this specific render. * * @param data - Array of records to render (each becomes one row). * @param resource - The resource name; controls the translation key namespace * used by injectCellTitles (e.g. "users_media", "users"). * @param children - Field elements to render inside the Datagrid. * @param options - Optional rowClick and rowLabel props. * rowClick: when set (e.g. "edit"), rows become keyboard- * focusable and receive aria-* attributes. * rowLabel: function or field name for aria-label. */ function renderWith( data: RaRecord[], resource: string, children: React.ReactNode, options?: { rowClick?: DatagridProps["rowClick"]; rowLabel?: DatagridProps["rowLabel"]; } ) { // Update refs before render so the lazy closures in vi.mock() pick up the // correct values when called during React's render phase. mockDataRef.current = data as Record<string, unknown>[]; resourceRef.current = resource; return render( // No providers needed — RecordContextProvider is supplied per-row by // AccessibleBody (using the real implementation from ...actual), and all // other context requirements are satisfied by the mocked hooks above. <Datagrid rowClick={options?.rowClick} rowLabel={options?.rowLabel}> {children} </Datagrid> ); } // ─── Tests ───────────────────────────────────────────────────────────────────── describe("Datagrid accessibility features", () => { // ── Users media table pattern ───────────────────────────────────────────── // Mirrors src/resources/users/Edit.tsx → UserMediaList. // Fields: DateField (timestamps), FunctionField+formatBytes (file size), // TextField (plain strings), nullable TextField (quarantined_by). describe("cell title attributes — users media table pattern", () => { it("date fields: title should show formatted date, not raw timestamp", () => { // injectCellTitles detects DateField via child.type === DateField and // formats the timestamp using: // new Date(record["created_ts"]).toLocaleString(locales, options) // passing through the field's own locales/options props so the title // matches exactly what DateField renders in the cell. renderWith( [MEDIA_RECORD as unknown as RaRecord], "users_media", // locales="de-DE" and DATE_FORMAT mirror the production usage in // src/resources/users/Edit.tsx so the formatted date string matches. <DateField source="created_ts" showTime options={DATE_FORMAT} locales="de-DE" /> ); // Compute the CORRECT expected title using the same locale and options // that DateField uses in production. const expectedDate = new Date(1700000000000).toLocaleString("de-DE", DATE_FORMAT); // document.querySelector("[title]") finds the <time> or <span> element // that DateField renders. The `title` attribute is injected by // injectCellTitles and forwarded via sanitizeFieldRestProps. const el = document.querySelector("[title]"); expect(el?.getAttribute("title")).toBe(`Erstellt: ${expectedDate}`); // ← PASSES }); it("file size fields (FunctionField): title should show formatted size, not raw bytes", () => { // injectCellTitles detects FunctionField via typeof props.render === "function" // (child.type === FunctionField triggers TS6133, so duck-typing is used). // It calls render(record) and coerces the result to a string, so the title // matches what the cell displays: formatBytes(1024) = "1.0 KB". renderWith( [MEDIA_RECORD as unknown as RaRecord], "users_media", // source="media_length" is required so injectCellTitles processes this // field. Without source, the field would be passed through unchanged. // The render prop is what actually controls the display value ("1.0 KB"); // injectCellTitles ignores it and reads record[source] directly. <FunctionField source="media_length" render={(r: RaRecord) => formatBytes(r.media_length as number)} /> ); const el = document.querySelector("[title]"); expect(el?.getAttribute("title")).toBe("Größe: 1.0 KB"); // ← PASSES }); it("text fields: title has translated label and raw string value", () => { // CORRECT BEHAVIOR — plain string values work fine. // // record["media_type"] = "image/jpeg" (a string). // formatCellValue("image/jpeg") = String("image/jpeg") = "image/jpeg". // resolveLabel(undefined, "media_type", "users_media", translate) // → translate("resources.users_media.fields.media_type") = "Typ". // title = "Typ: image/jpeg" ✓ renderWith([MEDIA_RECORD as unknown as RaRecord], "users_media", <TextField source="media_type" />); const el = document.querySelector("[title]"); expect(el?.getAttribute("title")).toBe("Typ: image/jpeg"); // ← PASSES }); it("null fields: title shows em dash for null values", () => { // CORRECT BEHAVIOR — null values are formatted as the em dash character. // // record["quarantined_by"] = null. // formatCellValue(null) → (value == null) → returns "—". // title = "In Quarantäne von: —" ✓ renderWith([MEDIA_RECORD as unknown as RaRecord], "users_media", <TextField source="quarantined_by" />); const el = document.querySelector("[title]"); expect(el?.getAttribute("title")).toBe("In Quarantäne von: —"); // ← PASSES }); }); // ── Users main table pattern ─────────────────────────────────────────────── // Mirrors src/resources/users/List.tsx → UserList. // Fields: BooleanField (admin, is_guest), DateField (creation_ts_ms). describe("cell title attributes — users main table pattern", () => { it("boolean true: title uses translated ra.boolean.true string", () => { // CORRECT BEHAVIOR — boolean true is translated. // // record["admin"] = true (a boolean). // formatCellValue(true) = translate("ra.boolean.true", { _: "Yes" }) // → TRANSLATIONS["ra.boolean.true"] = "Ja". // resolveLabel → translate("resources.users.fields.admin") = "Administrator". // title = "Administrator: Ja" ✓ // // Note: BooleanField from react-admin renders a checkbox icon. // It passes the injected `title` prop to its root <span> via // sanitizeFieldRestProps, so document.querySelector("[title]") finds it. renderWith([USER_RECORD as unknown as RaRecord], "users", <BooleanField source="admin" />); const el = document.querySelector("[title]"); expect(el?.getAttribute("title")).toBe("Administrator: Ja"); // ← PASSES }); it("boolean false: title uses translated ra.boolean.false string", () => { // CORRECT BEHAVIOR — boolean false is translated. // // record["is_guest"] = false. // formatCellValue(false) = translate("ra.boolean.false", { _: "No" }) = "Nein". // title = "Gastbenutzer: Nein" ✓ renderWith([USER_RECORD as unknown as RaRecord], "users", <BooleanField source="is_guest" />); const el = document.querySelector("[title]"); expect(el?.getAttribute("title")).toBe("Gastbenutzer: Nein"); // ← PASSES }); it("creation_ts_ms DateField: title should show formatted date, not raw timestamp", () => { // Same DateField handling as created_ts above. // creation_ts_ms is produced by normalizeTS() (src/utils/date.ts) and is // always in milliseconds. injectCellTitles formats it via toLocaleString // so the title matches the cell. renderWith( [USER_RECORD as unknown as RaRecord], "users", <DateField source="creation_ts_ms" showTime options={DATE_FORMAT} locales="de-DE" /> ); const expectedDate = new Date(1700000000000).toLocaleString("de-DE", DATE_FORMAT); const el = document.querySelector("[title]"); expect(el?.getAttribute("title")).toBe(`Erstellt am: ${expectedDate}`); // ← PASSES }); }); // ── Room members table pattern ───────────────────────────────────────────── // Mirrors src/resources/rooms/Show.tsx → RoomMembersList. // Fields: ReferenceField (id → users, displayname). describe("cell title attributes — room members table pattern (reference fields)", () => { it("ReferenceField: title should show resolved display value, not raw source ID", () => { // injectCellTitles detects ReferenceField via typeof props.reference === "string" // (child.type === ReferenceField triggers TS6133, so duck-typing is used). // It reads the first child's source ("displayname") and looks it up on the // outer record: record["displayname"] = "Alice". Falls back to the raw // reference ID (record["id"]) when the display field is absent on the row. // // Note on the real ReferenceField: it is a pure React context provider with // no DOM element, so a `title` injected directly on it would be silently // discarded. Our mock wraps children in <span title={title}> to make the // injected value visible in the DOM for this assertion. renderWith( [MEMBER_RECORD as unknown as RaRecord], "room_members", // label is explicit so injectCellTitles resolves "Anzeigename" (from // resources.users.fields.displayname) rather than falling back to // "resources.room_members.fields.id" or the source name "id". <ReferenceField source="id" reference="users" label="resources.users.fields.displayname" link=""> {/* Inner TextField reads displayname from the resolved record context. */} <TextField source="displayname" /> </ReferenceField> ); const el = document.querySelector("[title]"); expect(el?.getAttribute("title")).toBe("Anzeigename: Alice"); // ← PASSES }); }); // ── Field label translation ──────────────────────────────────────────────── describe("cell title attributes — field label translation", () => { it("fields without explicit label: label resolved from resource translation key", () => { // CORRECT BEHAVIOR — implicit label resolution works. // // resolveLabel(undefined, "media_id", "users_media", translate): // label prop is undefined → no explicit label. // Falls through to: translate("resources.users_media.fields.media_id") // → TRANSLATIONS["resources.users_media.fields.media_id"] = "Medien-ID". // formatCellValue("mxc://example.org/abc123") = "mxc://example.org/abc123". // title = "Medien-ID: mxc://example.org/abc123" ✓ renderWith([MEDIA_RECORD as unknown as RaRecord], "users_media", <TextField source="media_id" />); const el = document.querySelector("[title]"); expect(el?.getAttribute("title")).toBe("Medien-ID: mxc://example.org/abc123"); // ← PASSES }); it("explicit label string prop: resolved via translate", () => { // CORRECT BEHAVIOR — explicit translation key label works. // // resolveLabel("resources.users.fields.id", "id", "users", translate): // label prop is the string "resources.users.fields.id". // translate("resources.users.fields.id", { _: "resources.users.fields.id" }) // → TRANSLATIONS["resources.users.fields.id"] = "Benutzer-ID". // formatCellValue("@alice:example.org") = "@alice:example.org". // title = "Benutzer-ID: @alice:example.org" ✓ renderWith( [USER_RECORD as unknown as RaRecord], "users", <TextField source="id" label="resources.users.fields.id" /> ); const el = document.querySelector("[title]"); expect(el?.getAttribute("title")).toBe("Benutzer-ID: @alice:example.org"); // ← PASSES }); it("missing translation key: title uses humanized source name as fallback", () => { // When no translation key exists for a field, resolveLabel falls back to // humanizeSource(source) which converts snake_case/camelCase to Title Case: // "unlabeled_field" → "Unlabeled Field" // This is more readable than the bare API field name and avoids exposing // internal naming to screen-reader users. // // The assertion uses .not.toMatch(/^unlabeled_field:/) to confirm the raw // source name is not used — the exact humanized form is not asserted because // the humanization algorithm may evolve. const record: RaRecord = { id: "1", unlabeled_field: "some-value" }; renderWith([record], "users_media", <TextField source="unlabeled_field" />); const el = document.querySelector("[title]"); expect(el?.getAttribute("title")).not.toMatch(/^unlabeled_field:/); // ← PASSES }); it("cross-resource fields: explicit label prop bypasses resource namespace", () => { // injectCellTitles only knows the Datagrid's own resource ("room_members") // and resolves labels from that namespace. When a field logically belongs // to a different resource (e.g. "displayname" comes from the users resource), // its translation key is absent in the Datagrid's namespace. // // The solution: supply an explicit label prop on cross-resource fields, // e.g. label="resources.users.fields.displayname". resolveLabel then calls // translate(label) directly, bypassing the Datagrid's resource namespace. renderWith( [MEMBER_RECORD as unknown as RaRecord], "room_members", <TextField source="displayname" label="resources.users.fields.displayname" /> ); const el = document.querySelector("[title]"); expect(el?.getAttribute("title")).toBe("Anzeigename: Alice"); // ← PASSES }); }); // ── Row accessibility attributes ─────────────────────────────────────────── describe("row accessibility attributes", () => { it("clickable rows: aria-label provided by rowLabel function", () => { // CORRECT BEHAVIOR — function rowLabel generates a descriptive aria-label. // // AccessibleRow evaluates: typeof rowLabel === "function" // → ariaLabel = rowLabel(record) // → `Benutzer ${record.displayname}` = "Benutzer Alice" // Placed on the <tr> as aria-label="Benutzer Alice". renderWith([USER_RECORD as unknown as RaRecord], "users", <TextField source="id" />, { rowClick: "edit", // Arrow function receives the current row record; we cast because // renderWith accepts RaRecord but the concrete shape is USER_RECORD. rowLabel: r => `Benutzer ${(r as typeof USER_RECORD).displayname}`, }); // screen.getByRole("row", { name }) matches <tr aria-label="Benutzer Alice">. expect(screen.getByRole("row", { name: "Benutzer Alice" })).toBeTruthy(); // ← PASSES }); it("clickable rows: aria-label from rowLabel field name", () => { // CORRECT BEHAVIOR — string rowLabel looks up the named field on the record. // // AccessibleRow evaluates: typeof rowLabel === "string" // → ariaLabel = String(record["displayname"] ?? record.id) = "Alice" renderWith([USER_RECORD as unknown as RaRecord], "users", <TextField source="id" />, { rowClick: "edit", rowLabel: "displayname", // looks up record["displayname"] = "Alice" }); expect(screen.getByRole("row", { name: "Alice" })).toBeTruthy(); // ← PASSES }); it("first data row on page 1 has aria-rowindex 2 (header occupies index 1)", () => { // CORRECT BEHAVIOR — aria-rowindex calculation. // // From AccessibleBody: // page = 1, perPage = 10 (mocked by useListContext) // offset = (1 - 1) * 10 = 0 // For the first row (rowIndex = 0): // aria-rowindex = offset + rowIndex + 2 = 0 + 0 + 2 = 2 // // The +2 formula: +1 because aria-rowindex is 1-based, and the header row // is aria-rowindex=1 so data rows start at 2. // (There is no explicit <tr> for the header in this mock, but the ARIA // spec expects the numbering to account for it.) renderWith([USER_RECORD as unknown as RaRecord], "users", <TextField source="id" />, { rowClick: "edit" }); const row = document.querySelector("tr[aria-rowindex]"); expect(row?.getAttribute("aria-rowindex")).toBe("2"); // ← PASSES }); it("clickable rows have aria-roledescription='link'", () => { // aria-roledescription="link" on the <tr> tells screen readers to announce // the row as "link [aria-label]" in browse mode, signalling navigability // without using role="link" (which would break the table row/cell hierarchy). renderWith([USER_RECORD as unknown as RaRecord], "users", <TextField source="id" />, { rowClick: "edit" }); const row = document.querySelector("tr[tabindex='0']"); expect(row?.getAttribute("aria-roledescription")).toBe("link"); // ← PASSES }); it("rows without rowClick: no aria-rowindex, no aria-label, no tabIndex, no aria-roledescription", () => { // CORRECT BEHAVIOR — non-clickable rows have no accessibility overhead. // // AccessibleRow checks: isClickable = rowClick != null && rowClick !== false // When rowClick is not provided, isClickable = false → the entire // aria/tabIndex block in AccessibleRow is skipped. renderWith([USER_RECORD as unknown as RaRecord], "users", <TextField source="id" />); // No rowClick option → isClickable = false const row = document.querySelector("tr"); expect(row?.getAttribute("aria-rowindex")).toBeNull(); // ← PASSES expect(row?.getAttribute("aria-label")).toBeNull(); // ← PASSES expect(row?.getAttribute("tabindex")).toBeNull(); // ← PASSES expect(row?.getAttribute("aria-roledescription")).toBeNull(); // ← PASSES }); it("Enter key on a focusable row dispatches a click event", async () => { // CORRECT BEHAVIOR — keyboard navigation via Enter key. // // AccessibleRow.handleKeyDown: // if (e.target !== e.currentTarget) return; ← ignores bubbled events // if (e.key === "Enter") e.currentTarget.click(); // // We test this by: // 1. Focusing the <tr> (possible because tabIndex=0 is set). // 2. Sending a keyboard Enter event via userEvent. // 3. Asserting that a "click" DOM event was dispatched on the row. // // We observe the click event directly on the DOM element (not via the // rowClick prop) because our DatagridRow mock does not wire rowClick to // an onClick handler — the real DatagridRow would, but we only need to // verify that AccessibleRow dispatches the click, not that RA handles it. const user = userEvent.setup(); // userEvent v14 requires setup() for keyboard renderWith([USER_RECORD as unknown as RaRecord], "users", <TextField source="id" />, { rowClick: "edit" }); // Cast to HTMLElement because document.querySelector returns Element which // does not have .focus(); HTMLElement does. const row = document.querySelector("tr[tabindex='0']") as HTMLElement | null; expect(row).not.toBeNull(); const clickSpy = vi.fn(); row!.addEventListener("click", clickSpy); // Focus the row so subsequent keyboard events are dispatched on it. row!.focus(); await user.keyboard("{Enter}"); expect(clickSpy).toHaveBeenCalledTimes(1); // ← PASSES }); it("Space key on a focusable row dispatches a click event", async () => { // CORRECT BEHAVIOR — keyboard navigation via Space key. // Same mechanism as Enter above; Space is the alternative activation key // for interactive ARIA roles (role="row" with tabIndex acts like a button). const user = userEvent.setup(); renderWith([USER_RECORD as unknown as RaRecord], "users", <TextField source="id" />, { rowClick: "edit" }); const row = document.querySelector("tr[tabindex='0']") as HTMLElement | null; expect(row).not.toBeNull(); const clickSpy = vi.fn(); row!.addEventListener("click", clickSpy); row!.focus(); await user.keyboard(" "); expect(clickSpy).toHaveBeenCalledTimes(1); // ← PASSES }); }); }); ================================================ FILE: src/components/layout/Datagrid.tsx ================================================ // SPDX-FileCopyrightText: 2026 Nikita Chernyi // SPDX-License-Identifier: Apache-2.0 import React, { ReactNode, useCallback } from "react"; import { SxProps, TableBody } from "@mui/material"; import { DatagridBody, DatagridClasses, DatagridConfigurable, DatagridRow, DateField, Identifier, ListContext, RaRecord, RecordContextProvider, useGetRecordRepresentation, useRecordContext, useResourceContext, useTranslate, } from "react-admin"; import EmptyState from "./EmptyState"; // ───────────────────────────────────────────── // Types // ───────────────────────────────────────────── type DatagridBodyProps = React.ComponentPropsWithRef<typeof DatagridBody>; type DatagridRowProps = React.ComponentPropsWithRef<typeof DatagridRow>; type DatagridConfigurableProps = React.ComponentProps<typeof DatagridConfigurable>; /** Specifies the row's accessible label — either a field name or a function. */ type RowLabel = ((record: RaRecord) => string) | string; type AccessibleRowProps = DatagridRowProps & { rowIndex?: number; rowLabel?: RowLabel; }; type AccessibleBodyProps = Omit<DatagridBodyProps, "row"> & { rowLabel?: RowLabel; }; export type DatagridProps = DatagridConfigurableProps & { rowLabel?: RowLabel; empty?: ReactNode; }; // ───────────────────────────────────────────── // Cell title helpers // ───────────────────────────────────────────── type Translator = ReturnType<typeof useTranslate>; /** * Converts a snake_case or camelCase source name to a readable Title Case label. * Used as the last-resort fallback when no i18n key exists for a field. * e.g. "unlabeled_field" → "Unlabeled Field", "mediaLength" → "Media Length" */ const humanizeSource = (source: string): string => source .replace(/([a-z])([A-Z])/g, "$1 $2") .replace(/_/g, " ") .replace(/\b\w/g, c => c.toUpperCase()); /** * Resolves a field's label prop to a plain string. * Falls back to the resource-scoped translation key, then a humanized source name. */ const resolveLabel = (label: unknown, source: string, resource: string | undefined, translate: Translator): string => { if (typeof label === "string") return translate(label, { _: label }); if (resource) return translate(`resources.${resource}.fields.${source}`, { _: humanizeSource(source) }); return humanizeSource(source); }; /** * Converts a raw record value to a human-readable string for the title attribute. * Booleans use react-admin's standard translation keys (ra.boolean.true/false). */ const formatCellValue = (value: unknown, translate: Translator): string => { if (value == null) return "—"; if (typeof value === "boolean") { return translate(value ? "ra.boolean.true" : "ra.boolean.false", { _: value ? "Yes" : "No" }); } if (typeof value === "string" || typeof value === "number") return String(value); return "—"; }; /** * Clones field children, injecting a `title="Column: Value"` onto each field * that has a `source` prop. Fields without source (icon buttons, etc.) are * passed through unchanged. * * RA's DatagridRow does NOT spread field props onto DatagridCell/TableCell, so * the title ends up on the field's own rendered element (e.g. a Typography span) * via sanitizeFieldRestProps. This provides hover tooltips on cell content rather * than on the <td> itself — still useful for column identification on hover. * * Field type dispatch (run in order, first match wins): * 1. DateField — detected via child.type === DateField; formats the raw * timestamp using new Date(v).toLocaleString(locales, options) mirroring * what the field itself renders. * 2. ReferenceField — detected via typeof props.reference === "string". * Uses child.type === ReferenceField instead would be cleaner, but that * import triggers TS6133 ("declared but its value is never read") because * TypeScript does not consider a runtime === comparison a value read. * The same TS6133 constraint applies to FunctionField below. * 3. FunctionField — detected via typeof props.render === "function". * Calls render(record) and coerces the result to a string. * 4. Everything else — formatCellValue(record[source], translate). */ const injectCellTitles = ( children: ReactNode, record: RaRecord, resource: string | undefined, translate: Translator ): ReactNode => React.Children.map(children, child => { if (!React.isValidElement(child)) return child; const props = child.props as Record<string, unknown>; const source = props.source as string | undefined; if (!source) return child; const label = resolveLabel(props.label, source, resource, translate); const rawValue = record[source]; let value: string; if (child.type === DateField && (typeof rawValue === "number" || typeof rawValue === "string")) { try { value = new Date(rawValue as number | string).toLocaleString( props.locales as string | undefined, props.options as Intl.DateTimeFormatOptions | undefined ); } catch { value = formatCellValue(rawValue, translate); } } else if (typeof props.reference === "string") { // ReferenceField: duck-typed by the `reference` prop (child.type === ReferenceField // triggers TS6133 — the TypeScript compiler treats the import as unread). // // Strategy: read the first child element's `source` (e.g. "displayname") and // look it up on the OUTER record. Many Synapse API endpoints embed display // fields directly on the row record (e.g. room_members includes displayname), // so `record[childSource]` is often available without an async fetch. // Falls back to the raw reference ID (record[source]) when not present. // // Note: only the first child with a `source` prop is inspected, which covers // the common single-child pattern (<TextField source="displayname" />). const childSource = (() => { const kids = React.Children.toArray(props.children as ReactNode); for (const kid of kids) { if (React.isValidElement(kid)) { const kidProps = kid.props as Record<string, unknown>; if (typeof kidProps.source === "string") return kidProps.source; } } return undefined; })(); value = childSource != null && record[childSource] != null ? formatCellValue(record[childSource], translate) : formatCellValue(rawValue, translate); } else if (typeof props.render === "function") { try { const rendered = (props.render as (r: RaRecord) => unknown)(record); if (typeof rendered === "string") value = rendered; else if (typeof rendered === "number") value = String(rendered); else value = formatCellValue(rawValue, translate); } catch { value = formatCellValue(rawValue, translate); } } else { value = formatCellValue(rawValue, translate); } return React.cloneElement(child as React.ReactElement<Record<string, unknown>>, { title: `${label}: ${value}`, }); }) ?? children; // ───────────────────────────────────────────── // AccessibleRow // ───────────────────────────────────────────── const focusSx = { "&:focus-visible": { outline: "2px solid", outlineColor: "primary.main", outlineOffset: "-2px", }, } as const; const AccessibleRow = React.forwardRef<HTMLTableRowElement, AccessibleRowProps>( ({ rowClick, rowIndex, rowLabel, sx: externalSx, ...props }, ref) => { const resource = useResourceContext(props); const record = useRecordContext(props); const getDefaultLabel = useGetRecordRepresentation(resource); const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTableRowElement>) => { // Guard: only handle events targeted directly on the row, not bubbled from inner interactive elements // (checkboxes, buttons in cells). Without this, Space on a focused child triggers both the child and the row. if (e.target !== e.currentTarget) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); (e.currentTarget as HTMLElement).click(); } }, []); // Note: rowClick can be a function that dynamically returns false/void (no-op). // We cannot evaluate that statically, so those rows still get tabIndex/focus ring. // RA's own click handler guards against navigating to undefined, so behaviour is safe. const isClickable = rowClick != null && rowClick !== false; const mergedSx: SxProps | undefined = isClickable ? ((externalSx != null ? [externalSx, focusSx] : focusSx) as SxProps) : (externalSx as SxProps | undefined); const ariaLabel = isClickable && record ? typeof rowLabel === "function" ? rowLabel(record) : typeof rowLabel === "string" ? String((record as Record<string, unknown>)[rowLabel] ?? record.id) : String(getDefaultLabel(record)) : undefined; return ( <DatagridRow ref={ref} rowClick={rowClick} sx={mergedSx} {...(isClickable ? { tabIndex: 0, "aria-roledescription": "link", onKeyDown: handleKeyDown, "aria-rowindex": rowIndex, "aria-label": ariaLabel, } : {})} {...props} /> ); } ); AccessibleRow.displayName = "AccessibleRow"; // ───────────────────────────────────────────── // AccessibleBody // ───────────────────────────────────────────── const defaultData: RaRecord[] = []; // Note: cloneElement onto a module-level constant is the same pattern react-admin uses // internally in DatagridBody. If RA's DatagridBody rendering model changes across major // versions, AccessibleBody will need to be updated accordingly. const defaultRow = <AccessibleRow />; const AccessibleBody = React.forwardRef<HTMLTableSectionElement, AccessibleBodyProps>( ( { children, className, data = defaultData, expand, hasBulkActions = false, hover, onToggleItem, resource, rowClick, rowSx, rowStyle, selectedIds, isRowSelectable, rowLabel, ...rest }, ref ) => { const listCtx = React.useContext(ListContext); const page = listCtx?.page ?? 1; const perPage = listCtx?.perPage ?? 10; const offset = (page - 1) * perPage; const translate = useTranslate(); // resource prop may be undefined when DatagridConfigurable doesn't forward it; // fall back to the ResourceContext set by the parent List. const resolvedResource = useResourceContext({ resource }); return ( <TableBody ref={ref} className={["datagrid-body", className, DatagridClasses.tbody].filter(Boolean).join(" ")} {...rest} > {data.map((record, rowIndex) => ( <RecordContextProvider value={record} key={record.id ?? `row${rowIndex}`}> {React.cloneElement( defaultRow, { className: [ DatagridClasses.row, rowIndex % 2 === 0 ? DatagridClasses.rowEven : DatagridClasses.rowOdd, ].join(" "), expand, hasBulkActions: hasBulkActions && !!selectedIds, hover, id: record.id ?? (`row${rowIndex}` as Identifier), onToggleItem, resource, rowClick, // aria-rowindex is 1-based and must account for the header row (index 1), // so the first data row on page 1 is index 2. rowIndex: offset + rowIndex + 2, rowLabel, selectable: !isRowSelectable || isRowSelectable(record), selected: selectedIds?.includes(record.id), sx: rowSx?.(record, rowIndex), style: rowStyle?.(record, rowIndex), }, injectCellTitles(children, record, resolvedResource, translate) )} </RecordContextProvider> ))} </TableBody> ); } ); // MUI Table requires this to accept the component as a valid child type // eslint-disable-next-line @typescript-eslint/no-explicit-any (AccessibleBody as any).muiName = "TableBody"; AccessibleBody.displayName = "AccessibleBody"; // ───────────────────────────────────────────── // Datagrid (public export) // ───────────────────────────────────────────── /** * Drop-in replacement for react-admin's DatagridConfigurable. * Adds keyboard navigation (Enter/Space), visible focus ring, aria-rowindex, * and aria-label to all clickable rows, and title="Column: Value" to all * data cells for screen reader context. * Defaults: empty=<EmptyState />, width 100%. */ const Datagrid = ({ rowLabel, empty = <EmptyState />, sx, ...props }: DatagridProps) => ( <DatagridConfigurable body={<AccessibleBody rowLabel={rowLabel} />} empty={empty} sx={[{ width: "100%" }, sx as SxProps].filter(Boolean) as SxProps} {...props} /> ); export default Datagrid; ================================================ FILE: src/components/layout/EmptyState.test.tsx ================================================ import { render, screen } from "@testing-library/react"; import EmptyState from "./EmptyState"; vi.mock("react-admin", () => ({ CreateButton: ({ resource }: { resource?: string }) => <button type="button">Create {resource}</button>, FilterContext: { Provider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, }, useTranslate: () => (key: string, opts?: Record<string, unknown>) => { // "resources.users.name" → "users" const resourceNameMatch = key.match(/^resources\.(\w+)\.name$/); if (resourceNameMatch) return resourceNameMatch[1]; if (key === "ra.page.empty") return `No ${opts?.name ?? "items"} yet`; if (key === "ra.page.invite") return "Create your first item"; if (key === "ra.navigation.no_results") return "No results"; return key; }, useResourceContext: vi.fn(() => "users"), useResourceDefinition: vi.fn(() => ({ hasCreate: true })), })); import { useResourceContext, useResourceDefinition } from "react-admin"; const mockResourceContext = vi.mocked(useResourceContext); const mockResourceDefinition = vi.mocked(useResourceDefinition); describe("EmptyState", () => { beforeEach(() => { mockResourceContext.mockReturnValue("users"); mockResourceDefinition.mockReturnValue({ hasCreate: true } as ReturnType<typeof useResourceDefinition>); }); it("renders the empty state message with the resource label", () => { render(<EmptyState />); expect(screen.getByText(/no users yet/i)).toBeTruthy(); }); it("renders Create button when hasCreate is true", () => { render(<EmptyState />); expect(screen.getByRole("button", { name: /create/i })).toBeTruthy(); }); it("renders invite text when hasCreate is true", () => { render(<EmptyState />); expect(screen.getByText("Create your first item")).toBeTruthy(); }); it("hides Create button and shows no-results text when hasCreate is false", () => { mockResourceDefinition.mockReturnValue({ hasCreate: false } as ReturnType<typeof useResourceDefinition>); render(<EmptyState />); expect(screen.queryByRole("button", { name: /create/i })).toBeNull(); expect(screen.getByText("No results")).toBeTruthy(); }); it("renders custom actions when provided", () => { render(<EmptyState actions={<button type="button">Custom Action</button>} />); expect(screen.getByRole("button", { name: "Custom Action" })).toBeTruthy(); }); it("uses the resource prop over context when provided", () => { mockResourceDefinition.mockReturnValue({ hasCreate: false } as ReturnType<typeof useResourceDefinition>); render(<EmptyState resource="rooms" />); // resource label is lowercased via translate — the translate mock includes the name expect(screen.getByText(/no rooms yet/i)).toBeTruthy(); }); }); ================================================ FILE: src/components/layout/EmptyState.tsx ================================================ import InboxIcon from "@mui/icons-material/Inbox"; import { Box, Typography, keyframes } from "@mui/material"; import { ReactNode } from "react"; import { CreateButton, FilterContext, useResourceContext, useResourceDefinition, useTranslate } from "react-admin"; const fadeIn = keyframes` from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } `; const float = keyframes` 0% { transform: translateY(0) rotate(0deg) scale(1); } 25% { transform: translateY(-12px) rotate(2deg) scale(1.03); } 50% { transform: translateY(-4px) rotate(-1deg) scale(0.98); } 75% { transform: translateY(-14px) rotate(1.5deg) scale(1.02); } 100% { transform: translateY(0) rotate(0deg) scale(1); } `; const glow = keyframes` 0%, 100% { opacity: 0.5; transform: scale(1); } 50% { opacity: 0.8; transform: scale(1.15); } `; const shimmer = keyframes` 0% { transform: translateX(-200%) skewX(-15deg); } 100% { transform: translateX(200%) skewX(-15deg); } `; const pulseGlow = keyframes` 0%, 100% { box-shadow: 0 0 12px rgba(244,147,0,0.25), 0 0 0 0 rgba(244,147,0,0); } 50% { box-shadow: 0 0 20px rgba(244,147,0,0.35), 0 0 40px rgba(244,147,0,0.12); } `; const EmptyState = ({ resource: resourceProp, actions }: { resource?: string; actions?: ReactNode }) => { const translate = useTranslate(); const contextResource = useResourceContext(); const resource = resourceProp ?? contextResource; const { hasCreate } = useResourceDefinition({ resource }); const resourceLabel = translate(`resources.${resource}.name`, { smart_count: 2, _: resource || "" }).toLowerCase(); return ( <Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", py: 12, px: 3, animation: `${fadeIn} 500ms ease-out`, }} > {/* Orb container with glow backdrop */} <Box sx={{ position: "relative", mb: 4 }}> {/* Glow ring behind the orb */} <Box sx={theme => ({ position: "absolute", inset: -16, borderRadius: "50%", background: theme.palette.mode === "dark" ? "radial-gradient(circle, rgba(244,147,0,0.12) 0%, transparent 70%)" : "radial-gradient(circle, rgba(24,88,213,0.10) 0%, transparent 70%)", animation: `${glow} 4s ease-in-out infinite`, pointerEvents: "none", })} /> {/* Floating orb */} <Box sx={theme => ({ width: 140, height: 140, borderRadius: "50%", display: "flex", alignItems: "center", justifyContent: "center", position: "relative", background: theme.palette.mode === "dark" ? "radial-gradient(circle at 40% 35%, rgba(244,147,0,0.18) 0%, rgba(244,147,0,0.05) 60%, transparent 100%)" : "radial-gradient(circle at 40% 35%, rgba(24,88,213,0.12) 0%, rgba(24,88,213,0.03) 60%, transparent 100%)", border: theme.palette.mode === "dark" ? "1px solid rgba(244,147,0,0.12)" : "1px solid rgba(24,88,213,0.08)", animation: `${float} 5s ease-in-out infinite`, })} > <InboxIcon sx={theme => ({ fontSize: 64, color: theme.palette.mode === "dark" ? "rgba(244,147,0,0.5)" : "rgba(24,88,213,0.4)", })} /> </Box> </Box> <Typography variant="h5" color="text.secondary" gutterBottom sx={{ fontWeight: 500 }}> {translate("ra.page.empty", { name: resourceLabel })} </Typography> <Typography variant="body1" color="text.secondary" sx={{ mb: 4, opacity: 0.7 }}> {hasCreate ? translate("ra.page.invite", { _: `Create your first ${resourceLabel} to get started.` }) : translate("ra.navigation.no_results", { name: resourceLabel })} </Typography> {hasCreate && ( <Box sx={theme => ({ position: "relative", overflow: "hidden", borderRadius: "6px", animation: `${pulseGlow} 3s ease-in-out infinite`, "& .MuiButton-root": { px: 4, py: 1, fontSize: "0.95rem", }, "&::after": { content: '""', position: "absolute", top: 0, left: 0, width: "60%", height: "100%", background: theme.palette.mode === "dark" ? "linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent)" : "linear-gradient(90deg, transparent, rgba(255,255,255,0.25), transparent)", animation: `${shimmer} 3s ease-in-out infinite`, pointerEvents: "none", }, })} > <CreateButton variant="contained" resource={resource} /> </Box> )} {actions && ( <FilterContext.Provider value={[]}> <Box sx={{ mt: 2, display: "flex", justifyContent: "center", flexWrap: "wrap", gap: 1 }}>{actions}</Box> </FilterContext.Provider> )} </Box> ); }; export default EmptyState; ================================================ FILE: src/components/layout/Footer.test.tsx ================================================ import { render, screen } from "@testing-library/react"; import { createTheme, ThemeProvider } from "@mui/material/styles"; import Footer from "./Footer"; const wrapper = ({ children }: { children: React.ReactNode }) => ( <ThemeProvider theme={createTheme()}>{children}</ThemeProvider> ); describe("Footer", () => { afterEach(() => { document.getElementById("js-version")?.remove(); }); it("renders the Ketesa link pointing to GitHub", () => { render(<Footer />, { wrapper }); const links = screen.getAllByRole("link"); const ketesaLink = links.find(l => (l as HTMLAnchorElement).href.includes("etkecc/ketesa")); expect(ketesaLink).toBeTruthy(); }); it("renders the Matrix room link", () => { render(<Footer />, { wrapper }); const links = screen.getAllByRole("link"); const matrixLink = links.find(l => l.textContent?.includes("#ketesa:etke.cc")); expect(matrixLink).toBeTruthy(); }); it("shows no version when #js-version element is absent", () => { render(<Footer />, { wrapper }); const links = screen.getAllByRole("link"); const ketesaLink = links.find(l => (l as HTMLAnchorElement).href.includes("etkecc/ketesa")); expect(ketesaLink?.textContent?.trim()).toBe("Ketesa"); }); it("reads version from #js-version element when present", () => { const el = document.createElement("span"); el.id = "js-version"; el.textContent = "1.2.3"; document.body.appendChild(el); render(<Footer />, { wrapper }); // The version is set via a useEffect reading the DOM element const links = screen.getAllByRole("link"); const ketesaLink = links.find(l => (l as HTMLAnchorElement).href.includes("etkecc/ketesa")); expect(ketesaLink?.textContent?.trim()).toBe("Ketesa 1.2.3"); }); it("accepts a custom logoSrc prop", () => { render(<Footer logoSrc="./custom-logo.png" />, { wrapper }); const img = document.querySelector("img"); expect(img?.getAttribute("src")).toBe("./custom-logo.png"); }); }); ================================================ FILE: src/components/layout/Footer.tsx ================================================ import { Avatar, Box, Link } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import { useEffect, useState } from "react"; const Footer = ({ logoSrc = "./images/logo.webp" }: { logoSrc?: string }) => { const [version, setVersion] = useState<string | null>(null); const theme = useTheme(); useEffect(() => { const version = document.getElementById("js-version")?.textContent; if (version) { setVersion(version); } }, []); return ( <Box component="footer" sx={{ position: "fixed", zIndex: { xs: 1, sm: 100 }, bottom: 0, width: "100%", bgcolor: theme.palette.mode === "dark" ? "#080D12" : "#334258", color: theme.palette.mode === "dark" ? "#E0E0E0" : "#FFFFFF", boxShadow: theme.palette.mode === "dark" ? "0 -1px 3px rgba(0,0,0,0.3)" : "0 -1px 3px rgba(0,0,0,0.08)", borderTop: "none", fontSize: "0.89rem", display: "flex", alignItems: "center", whiteSpace: "nowrap", p: { xs: "4px 8px", sm: 1 }, gap: "10px", }} > <Avatar src={logoSrc} sx={{ width: "1rem", height: "1rem", display: "inline-block", verticalAlign: "sub" }} />{" "} <Link href="https://github.com/etkecc/ketesa" target="_blank" sx={{ color: "inherit" }}> Ketesa {version} </Link> <Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}> by{" "} <Link href="https://etke.cc/?utm_source=ketesa&utm_medium=footer&utm_campaign=ketesa" target="_blank" sx={{ color: "#f49300", fontWeight: 500 }} > etke.cc </Link> </Box> <Link sx={{ fontWeight: "bold", color: "inherit", display: { xs: "none", sm: "inline" }, ml: "auto" }} href="https://matrix.to/#/#ketesa:etke.cc" target="_blank" > {/* Matrix icon — trademark of The Matrix.org Foundation (https://matrix.org). Use of this logo does not imply endorsement or affiliation. */} <Box component="svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 520" sx={{ width: "1rem", height: "1rem", display: "inline-block", verticalAlign: "sub", fill: "#FFFFFF", mr: "4px", }} > <title> { "This logo is the trademark of The Matrix.org Foundation (https://matrix.org). Use of this logo does not imply endorsement or affiliation." } #ketesa:etke.cc ); }; export default Footer; ================================================ FILE: src/components/layout/List.tsx ================================================ import { cloneElement, isValidElement } from "react"; import { List as RaList, ListProps } from "react-admin"; import EmptyState from "./EmptyState"; /** * Thin wrapper around React-Admin's List that solves a structural limitation: * * React-Admin's ListView renders EITHER the toolbar (actions prop) OR the empty * component — never both. When a resource has no data, the entire toolbar including * custom action buttons (e.g. EventLookupButton on the Reports page) is hidden and * completely inaccessible to the user. * * This wrapper intercepts the `actions` and `empty` props, then injects `actions` * into the empty component via cloneElement so that EmptyState can render the same * toolbar buttons even when there is no data. Each resource List file only needs * to swap its List import to this component — props stay identical. */ const List = ({ actions, empty = , ...rest }: ListProps) => { const emptyWithActions = isValidElement(empty) ? cloneElement(empty as React.ReactElement<{ actions?: React.ReactNode }>, { actions }) : empty; return ; }; export default List; ================================================ FILE: src/components/layout/LoginFormBox.tsx ================================================ import { Box, BoxProps, keyframes } from "@mui/material"; import { styled } from "@mui/material/styles"; interface LoginFormBoxProps extends BoxProps { backgroundUrl: string; } const float1 = keyframes` 0%, 100% { transform: translate(0, 0) scale(1); } 25% { transform: translate(60px, -40px) scale(1.1); } 50% { transform: translate(-30px, 60px) scale(0.95); } 75% { transform: translate(40px, 30px) scale(1.05); } `; const float2 = keyframes` 0%, 100% { transform: translate(0, 0) scale(1); } 33% { transform: translate(-50px, 50px) scale(1.08); } 66% { transform: translate(70px, -20px) scale(0.92); } `; const float3 = keyframes` 0%, 100% { transform: translate(0, 0) scale(1); } 20% { transform: translate(40px, 50px) scale(1.12); } 40% { transform: translate(-60px, 20px) scale(0.9); } 60% { transform: translate(20px, -50px) scale(1.05); } 80% { transform: translate(-30px, -30px) scale(0.95); } `; const pulse = keyframes` 0%, 100% { opacity: 0.6; } 50% { opacity: 0.9; } `; const LoginFormBox = styled(Box, { shouldForwardProp: prop => prop !== "backgroundUrl", })(({ theme, backgroundUrl }) => { const isDark = theme.palette.mode === "dark"; const hasCustomBg = backgroundUrl !== ""; const baseBg = isDark ? "#0C1318" : "#F0F2F5"; return { display: "flex", flexDirection: "column", minHeight: "100vh", alignItems: "center", justifyContent: "flex-start", position: "relative", overflow: "hidden", background: hasCustomBg ? `url(${backgroundUrl})` : baseBg, backgroundColor: isDark ? theme.palette.background.default : theme.palette.background.paper, backgroundRepeat: "no-repeat", backgroundSize: "cover", backgroundPosition: "center", // Orbs layer — only shown when no custom background ...(!hasCustomBg && { "&::before": { content: '""', position: "absolute", inset: 0, zIndex: 0, background: [ // Large primary orb — top-left `radial-gradient(circle 320px at 15% 25%, ${isDark ? "rgba(244,147,0,0.15)" : "rgba(24,88,213,0.12)"} 0%, transparent 70%)`, // Medium accent orb — bottom-right `radial-gradient(circle 250px at 80% 75%, ${isDark ? "rgba(244,147,0,0.10)" : "rgba(244,147,0,0.08)"} 0%, transparent 70%)`, // Small secondary orb — top-right `radial-gradient(circle 200px at 75% 20%, ${isDark ? "rgba(244,147,0,0.08)" : "rgba(24,88,213,0.06)"} 0%, transparent 70%)`, // Subtle warm orb — bottom-left `radial-gradient(circle 280px at 25% 80%, ${isDark ? "rgba(244,147,0,0.06)" : "rgba(24,88,213,0.05)"} 0%, transparent 70%)`, ].join(", "), animation: `${float1} 20s ease-in-out infinite`, }, "&::after": { content: '""', position: "absolute", inset: 0, zIndex: 0, background: [ // Drifting orange orb — center-left `radial-gradient(circle 220px at 35% 55%, ${isDark ? "rgba(244,147,0,0.10)" : "rgba(24,88,213,0.08)"} 0%, transparent 70%)`, // Drifting orange orb — center-right `radial-gradient(circle 180px at 65% 45%, ${isDark ? "rgba(244,147,0,0.08)" : "rgba(244,147,0,0.06)"} 0%, transparent 70%)`, // Small accent — bottom-center `radial-gradient(circle 150px at 50% 85%, ${isDark ? "rgba(255,192,96,0.06)" : "rgba(217,119,6,0.04)"} 0%, transparent 70%)`, ].join(", "), animation: `${float2} 25s ease-in-out infinite, ${pulse} 8s ease-in-out infinite`, }, }), // Animated orb elements (children with .orb class) [`& .login-orb`]: { position: "absolute", borderRadius: "50%", filter: `blur(${isDark ? "80px" : "60px"})`, zIndex: 0, pointerEvents: "none", }, [`& .login-orb-1`]: { width: "400px", height: "400px", top: "-5%", right: "-5%", background: isDark ? "rgba(244,147,0,0.12)" : "rgba(24,88,213,0.10)", animation: `${float3} 22s ease-in-out infinite`, }, [`& .login-orb-2`]: { width: "300px", height: "300px", bottom: "10%", left: "-3%", background: isDark ? "rgba(244,147,0,0.10)" : "rgba(244,147,0,0.07)", animation: `${float1} 18s ease-in-out infinite reverse`, }, [`& .login-orb-3`]: { width: "250px", height: "250px", top: "40%", right: "20%", background: isDark ? "rgba(244,147,0,0.06)" : "rgba(24,88,213,0.05)", animation: `${float2} 30s ease-in-out infinite`, }, [`& .card`]: { position: "relative", zIndex: 1, width: "30rem", marginTop: "6rem", marginBottom: "6rem", backdropFilter: "blur(16px)", backgroundColor: isDark ? "rgba(21, 28, 36, 0.75)" : "rgba(255, 255, 255, 0.80)", boxShadow: isDark ? "0 0 30px rgba(244, 147, 0, 0.1), 0 8px 32px rgba(0, 0, 0, 0.3)" : "0 8px 32px rgba(0, 0, 0, 0.08), 0 1px 4px rgba(0, 0, 0, 0.04)", border: isDark ? "1px solid rgba(244, 147, 0, 0.18)" : "1px solid rgba(229, 231, 235, 0.8)", }, [`@media (max-width: 600px)`]: { [`& .card`]: { width: "100%", marginTop: "0", marginBottom: "2rem", }, }, [`& .avatar`]: { margin: "1.5rem 1rem 1rem", display: "flex", justifyContent: "center", }, [`& .icon`]: { backgroundColor: theme.palette.grey[500], }, [`& .hint`]: { marginTop: "0.5em", marginBottom: "1.5em", display: "flex", justifyContent: "center", color: theme.palette.text.secondary, fontSize: "1.05rem", fontWeight: 500, }, [`& .form`]: { padding: "0 1.5rem 1.5rem 1.5rem", }, [`& .actions`]: { padding: "0 1.5rem 1.5rem 1.5rem", }, [`& .serverVersion`]: { color: theme.palette.text.secondary, fontSize: "0.85rem", marginLeft: "0.5rem", }, [`& .matrixVersions`]: { color: theme.palette.text.secondary, fontSize: "0.8rem", marginBottom: "1rem", marginLeft: "0.5rem", }, }; }); export default LoginFormBox; ================================================ FILE: src/components/layout/index.ts ================================================ // SPDX-FileCopyrightText: 2026 Nikita Chernyi // SPDX-License-Identifier: Apache-2.0 export { default as AdminLayout, AdminUserMenu } from "./AdminLayout"; export { default as Datagrid } from "./Datagrid"; export type { DatagridProps } from "./Datagrid"; export { default as EmptyState } from "./EmptyState"; export { default as Footer } from "./Footer"; export { default as List } from "./List"; export { default as LoginFormBox } from "./LoginFormBox"; ================================================ FILE: src/components/media/DeleteMediaButton.tsx ================================================ import DeleteSweepIcon from "@mui/icons-material/DeleteSweep"; import { Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Button as MuiButton, } from "@mui/material"; import { alpha, useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import { BooleanInput, Button, ButtonProps, DateTimeInput, NumberInput, SaveButton, SimpleForm, useDataProvider, useNotify, useTranslate, } from "react-admin"; import { DeleteMediaParams, SynapseDataProvider } from "../../providers/types"; import { dateParser } from "../../utils/date"; const DeleteMediaDialog = ({ open, onClose, onSubmit }) => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const translate = useTranslate(); return ( {translate("delete_media.action.send")} {translate("delete_media.helper.send")} {translate("ra.action.cancel")} } /> ); }; export const DeleteMediaButton = (props: ButtonProps) => { const theme = useTheme(); const [open, setOpen] = useState(false); const notify = useNotify(); const dataProvider = useDataProvider(); const { mutate: deleteMedia, isPending } = useMutation({ mutationFn: (values: DeleteMediaParams) => dataProvider.deleteMedia(values), onSuccess: data => { if (data.total > 0) { notify("delete_media.action.send_success", { type: "success", messageArgs: { smart_count: data.total }, }); } else { notify("delete_media.action.send_success_none", { type: "warning", }); } closeDialog(); }, /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ onError: (error: any) => { notify(error?.message || "delete_media.action.send_failure", { type: "error", }); }, }); const openDialog = () => setOpen(true); const closeDialog = () => setOpen(false); return ( <> ); }; ================================================ FILE: src/components/media/ProtectMediaButton.tsx ================================================ import ClearIcon from "@mui/icons-material/Clear"; import LockIcon from "@mui/icons-material/Lock"; import LockOpenIcon from "@mui/icons-material/LockOpen"; import { Tooltip } from "@mui/material"; import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import { Button, ButtonProps, useDataProvider, useNotify, useRecordContext, useTranslate } from "react-admin"; export const ProtectMediaButton = (props: ButtonProps) => { const record = useRecordContext(); const translate = useTranslate(); const notify = useNotify(); const dataProvider = useDataProvider(); const [isProtected, setIsProtected] = useState(null); const { mutate: protect, isPending: isProtecting } = useMutation({ mutationFn: () => dataProvider.create("protect_media", { data: record! }), onSuccess: () => { notify("resources.protect_media.action.send_success"); setIsProtected(true); }, /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ onError: (error: any) => notify(error?.message || "resources.protect_media.action.send_failure", { type: "error" }), }); const { mutate: unprotect, isPending: isUnprotecting } = useMutation({ mutationFn: () => dataProvider.delete("protect_media", { id: record!.id, previousData: record }), onSuccess: () => { notify("resources.protect_media.action.send_success"); setIsProtected(false); }, /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ onError: (error: any) => notify(error?.message || "resources.protect_media.action.send_failure", { type: "error" }), }); if (!record) return null; const isLoading = isProtecting || isUnprotecting; const safeFromQuarantine = isProtected ?? record.safe_from_quarantine; return ( /* Wrapping Tooltip with
https://github.com/marmelab/react-admin/issues/4349#issuecomment-578594735 */ <> {record.quarantined_by && (
)} {safeFromQuarantine && !record.quarantined_by && (
)} {!safeFromQuarantine && !record.quarantined_by && (
)} ); }; ================================================ FILE: src/components/media/PurgeRemoteMediaButton.tsx ================================================ import CloudOffIcon from "@mui/icons-material/CloudOff"; import { Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Button as MuiButton, } from "@mui/material"; import { alpha, useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import { Button, ButtonProps, DateTimeInput, SaveButton, SimpleForm, useDataProvider, useNotify, useTranslate, } from "react-admin"; import { DeleteMediaParams, SynapseDataProvider } from "../../providers/types"; import { dateParser } from "../../utils/date"; const PurgeRemoteMediaDialog = ({ open, onClose, onSubmit }) => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const translate = useTranslate(); return ( {translate("purge_remote_media.action.send")} {translate("purge_remote_media.helper.send")} {translate("ra.action.cancel")} } /> ); }; export const PurgeRemoteMediaButton = (props: ButtonProps) => { const theme = useTheme(); const [open, setOpen] = useState(false); const notify = useNotify(); const dataProvider = useDataProvider(); const { mutate: purgeRemoteMedia, isPending } = useMutation({ mutationFn: (values: DeleteMediaParams) => dataProvider.purgeRemoteMedia(values), onSuccess: data => { if (data.total > 0) { notify("purge_remote_media.action.send_success", { type: "success", messageArgs: { smart_count: data.total }, }); } else { notify("purge_remote_media.action.send_success_none", { type: "warning", }); } closeDialog(); }, /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ onError: (error: any) => { notify(error?.message || "purge_remote_media.action.send_failure", { type: "error", }); }, }); const openDialog = () => setOpen(true); const closeDialog = () => setOpen(false); return ( <> ); }; ================================================ FILE: src/components/media/QuarantineMediaButton.tsx ================================================ import BlockIcon from "@mui/icons-material/Block"; import ClearIcon from "@mui/icons-material/Clear"; import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; import { Tooltip } from "@mui/material"; import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import { Button, ButtonProps, useDataProvider, useNotify, useRecordContext, useTranslate } from "react-admin"; export const QuarantineMediaButton = (props: ButtonProps) => { const record = useRecordContext(); const translate = useTranslate(); const notify = useNotify(); const dataProvider = useDataProvider(); const [isQuarantined, setIsQuarantined] = useState(null); const { mutate: quarantine, isPending: isQuarantining } = useMutation({ mutationFn: () => dataProvider.create("quarantine_media", { data: record! }), onSuccess: () => { notify("resources.quarantine_media.action.send_success"); setIsQuarantined(true); }, /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ onError: (error: any) => notify(error?.message || "resources.quarantine_media.action.send_failure", { type: "error", messageArgs: { error: error }, }), }); const { mutate: unquarantine, isPending: isUnquarantining } = useMutation({ mutationFn: () => dataProvider.delete("quarantine_media", { id: record!.id, previousData: record }), onSuccess: () => { notify("resources.quarantine_media.action.send_success"); setIsQuarantined(false); }, /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ onError: (error: any) => notify(error?.message || "resources.quarantine_media.action.send_failure", { type: "error" }), }); if (!record) return null; const isLoading = isQuarantining || isUnquarantining; const quarantinedBy = isQuarantined === null ? record.quarantined_by : isQuarantined ? localStorage.getItem("user_id") || "admin" : ""; return ( <> {record.safe_from_quarantine && (
)} {quarantinedBy && (
)} {!record.safe_from_quarantine && !quarantinedBy && (
)} ); }; ================================================ FILE: src/components/media/ViewMedia.tsx ================================================ import DownloadIcon from "@mui/icons-material/Download"; import DownloadingIcon from "@mui/icons-material/Downloading"; import FileOpenIcon from "@mui/icons-material/FileOpen"; import { Box, Tooltip } from "@mui/material"; import { get } from "lodash"; import { useState } from "react"; import { Button, useNotify, useRecordContext, useTranslate } from "react-admin"; import { decodeURLComponent } from "../../utils/safety"; import { fetchAuthenticatedMedia } from "../../utils/fetchMedia"; import createLogger from "../../utils/logger"; const log = createLogger("media"); export const ViewMediaButton = ({ mxcURL, label, uploadName, mimetype, preview = false }) => { const translate = useTranslate(); const [loading, setLoading] = useState(false); const notify = useNotify(); const isImage = mimetype && mimetype.startsWith("image/") && preview; const openFileInNewTab = (blobURL: string) => { const anchorElement = document.createElement("a"); anchorElement.href = blobURL; anchorElement.target = "_blank"; document.body.appendChild(anchorElement); anchorElement.click(); document.body.removeChild(anchorElement); setTimeout(() => URL.revokeObjectURL(blobURL), 10); }; const downloadFile = async (blobURL: string) => { log.debug("download triggered", { uploadName }); const anchorElement = document.createElement("a"); anchorElement.href = blobURL; anchorElement.download = uploadName; document.body.appendChild(anchorElement); anchorElement.click(); document.body.removeChild(anchorElement); setTimeout(() => URL.revokeObjectURL(blobURL), 10); }; const handleFile = async (preview: boolean) => { setLoading(true); const response = await fetchAuthenticatedMedia(mxcURL, "original"); if (response.ok) { const blob = await response.blob(); const blobURL = URL.createObjectURL(blob); if (preview) { openFileInNewTab(blobURL); } else { downloadFile(blobURL); } } else { const body = await response.json(); notify("resources.room_media.action.error", { messageArgs: { errcode: body.errcode, errstatus: response.status, error: body.error, }, type: "error", }); } setLoading(false); }; return ( <> {isImage && ( )} {label} ); }; export const MediaIDField = ({ source }) => { const record = useRecordContext(); if (!record) { return null; } const homeserver = localStorage.getItem("home_server"); const mediaID = get(record, source)?.toString(); if (!mediaID) { return null; } let uploadName = mediaID; if (get(record, "upload_name")) { uploadName = decodeURLComponent(get(record, "upload_name")?.toString()); } let mxcURL = mediaID; if (!mediaID.startsWith(`mxc://${homeserver}`)) { // this is user's media, where mediaID doesn't have the mxc://home_server/ prefix as it has in the rooms mxcURL = `mxc://${homeserver}/${mediaID}`; } let preview = true; if (get(record, "quarantined_by")) { preview = false; } return ( ); }; export const ReportMediaContent = ({ source }) => { const record = useRecordContext(); if (!record) { return null; } const mxcURL = get(record, source)?.toString(); if (!mxcURL) { return null; } let uploadName = ""; if (get(record, "event_json.content.body")) { uploadName = decodeURLComponent(get(record, "event_json.content.body")?.toString()); } return ; }; ================================================ FILE: src/components/media/index.ts ================================================ export { DeleteMediaButton } from "./DeleteMediaButton"; export { PurgeRemoteMediaButton } from "./PurgeRemoteMediaButton"; export { ProtectMediaButton } from "./ProtectMediaButton"; export { QuarantineMediaButton } from "./QuarantineMediaButton"; export { ViewMediaButton, MediaIDField, ReportMediaContent } from "./ViewMedia"; ================================================ FILE: src/components/rooms/EventLookupDialog.tsx ================================================ import AlertError from "@mui/icons-material/ErrorOutline"; import ActionCheck from "@mui/icons-material/CheckCircle"; import { Box, Button as MuiButton, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField as MuiTextField, Typography, useMediaQuery, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import { ReactNode, useEffect, useState } from "react"; import { useDataProvider, useNotify, useTranslate } from "react-admin"; import { SynapseDataProvider } from "../../providers/types"; /* eslint-disable @typescript-eslint/no-explicit-any */ /** * Renders a JSON string with Matrix event IDs ($...) as clickable elements. */ export const renderWithEventIds = (text: string, onEventIdClick?: (id: string) => void): ReactNode => { if (!onEventIdClick || !text.includes('"$')) return text; // fast path: no event IDs const pattern = /"(\$[A-Za-z0-9\-_]+)"/g; const parts: ReactNode[] = []; let lastIndex = 0; let match: RegExpExecArray | null; while ((match = pattern.exec(text)) !== null) { if (match.index > lastIndex) { parts.push(text.slice(lastIndex, match.index)); } const eventId = match[1]; parts.push('"'); parts.push( onEventIdClick(eventId)} sx={{ all: "unset", cursor: "pointer", color: "primary.main", textDecoration: "underline", fontFamily: "inherit", fontSize: "inherit", }} > {eventId} ); parts.push('"'); lastIndex = match.index + match[0].length; } if (lastIndex < text.length) { parts.push(text.slice(lastIndex)); } return <>{parts}; }; export const EventFields = ({ event, onEventIdClick, }: { event: Record; onEventIdClick?: (id: string) => void; }) => ( {renderWithEventIds(JSON.stringify(event, null, 4), onEventIdClick)} ); /** * Reusable "Look Up Event by ID" dialog. * * Two usage modes: * - Manual lookup (reports toolbar): omit `initialEventId`; renders a text field so the * user can type an event ID, then click "Look Up". * - Auto-lookup (click an event_id in RoomMessages): pass `initialEventId`; the dialog * shows the ID as a subtitle and fetches immediately on open, no input needed. */ export const EventLookupDialog = ({ open, onClose, initialEventId, }: { open: boolean; onClose: () => void; initialEventId?: string; }) => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const [eventId, setEventId] = useState(initialEventId || ""); const [displayEventId, setDisplayEventId] = useState(initialEventId || ""); const [loading, setLoading] = useState(false); const [result, setResult] = useState | null>(null); const [error, setError] = useState(null); const notify = useNotify(); const translate = useTranslate(); const dataProvider = useDataProvider() as SynapseDataProvider; const reset = () => { setResult(null); setError(null); }; const handleClose = () => { reset(); setEventId(initialEventId || ""); setDisplayEventId(initialEventId || ""); onClose(); }; const handleFetch = async (id?: string) => { const target = id ?? eventId; if (!target) return; setLoading(true); reset(); try { const json = await dataProvider.fetchEvent(target); setResult(json); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; setError(message); notify("resources.reports.action.fetch_event_error", { type: "error" }); } finally { setLoading(false); } }; const handleNestedEventClick = (id: string) => { setDisplayEventId(id); handleFetch(id); }; // When opened with a pre-known event ID, sync state and auto-fetch. useEffect(() => { if (open && initialEventId) { setEventId(initialEventId); setDisplayEventId(initialEventId); handleFetch(initialEventId); } if (!open) reset(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, initialEventId]); return ( {translate("resources.reports.action.event_lookup.title")} {initialEventId ? ( {displayEventId} ) : ( setEventId(e.target.value)} sx={{ mb: 2, mt: 1 }} onKeyDown={e => { if (e.key === "Enter") handleFetch(); }} /> )} {loading && } {error && ( {error} )} {result && } }> {translate("ra.action.cancel")} {!initialEventId && ( handleFetch()} disabled={!eventId || loading} className="ra-confirm RaConfirm-confirmPrimary" startIcon={} > {translate("resources.reports.action.event_lookup.fetch")} )} ); }; ================================================ FILE: src/components/rooms/RoomHierarchy.test.ts ================================================ import { describe, expect, it } from "vitest"; import { HierarchyRoom } from "../../providers/types"; import { buildTree, isValidOrder, sortChildren } from "./RoomHierarchy"; const makeChild = ( state_key: string, origin_server_ts: number, order?: string ): HierarchyRoom["children_state"][number] => ({ type: "m.space.child", state_key, content: { via: ["example.org"], ...(order !== undefined ? { order } : {}) }, sender: "@admin:example.org", origin_server_ts, }); const makeRoom = (room_id: string, children: HierarchyRoom["children_state"] = []): HierarchyRoom => ({ room_id, num_joined_members: 1, guest_can_join: false, world_readable: false, children_state: children, }); describe("isValidOrder", () => { it("accepts printable ASCII strings", () => { expect(isValidOrder("1")).toBe(true); expect(isValidOrder("abc")).toBe(true); expect(isValidOrder(" ")).toBe(true); // 0x20 expect(isValidOrder("~")).toBe(true); // 0x7E }); it("rejects empty string", () => { expect(isValidOrder("")).toBe(false); }); it("rejects non-strings", () => { expect(isValidOrder(undefined)).toBe(false); expect(isValidOrder(null)).toBe(false); expect(isValidOrder(1)).toBe(false); }); it("rejects strings with control characters", () => { expect(isValidOrder("\x1F")).toBe(false); expect(isValidOrder("\x7F")).toBe(false); }); it("rejects non-ASCII Unicode that would pass naive string comparison", () => { expect(isValidOrder("\u0100")).toBe(false); // Ā — codepoint 256, above 0x7E expect(isValidOrder("café")).toBe(false); // é is codepoint 233, above 0x7E }); }); describe("sortChildren", () => { it("sorts lexicographically by order field", () => { const children = [makeChild("!c", 1, "3"), makeChild("!a", 2, "1"), makeChild("!b", 3, "2")]; const sorted = sortChildren(children).map(c => c.state_key); expect(sorted).toEqual(["!a", "!b", "!c"]); }); it("sorts without order by origin_server_ts ascending", () => { const children = [makeChild("!c", 300), makeChild("!a", 100), makeChild("!b", 200)]; const sorted = sortChildren(children).map(c => c.state_key); expect(sorted).toEqual(["!a", "!b", "!c"]); }); it("places ordered children before unordered", () => { const children = [makeChild("!unordered", 1), makeChild("!ordered", 999, "1")]; const sorted = sortChildren(children).map(c => c.state_key); expect(sorted).toEqual(["!ordered", "!unordered"]); }); it("treats empty string order as no order", () => { const children = [makeChild("!empty-order", 1, ""), makeChild("!ordered", 999, "1")]; const sorted = sortChildren(children).map(c => c.state_key); expect(sorted).toEqual(["!ordered", "!empty-order"]); }); it("falls back to origin_server_ts then state_key when order values are equal", () => { const children = [makeChild("!z", 2, "1"), makeChild("!a", 1, "1")]; const sorted = sortChildren(children).map(c => c.state_key); expect(sorted).toEqual(["!a", "!z"]); }); it("uses state_key as final tiebreaker when origin_server_ts equal and no order", () => { const children = [makeChild("!z", 100), makeChild("!a", 100)]; const sorted = sortChildren(children).map(c => c.state_key); expect(sorted).toEqual(["!a", "!z"]); }); it("does not mutate the original array", () => { const children = [makeChild("!b", 1, "2"), makeChild("!a", 2, "1")]; const original = [...children]; sortChildren(children); expect(children).toEqual(original); }); }); describe("buildTree", () => { it("returns empty array for empty input", () => { expect(buildTree([])).toEqual([]); }); it("respects order field when building children", () => { const root = makeRoom("!root", [makeChild("!c", 1, "3"), makeChild("!a", 2, "1"), makeChild("!b", 3, "2")]); const roomA = makeRoom("!a"); const roomB = makeRoom("!b"); const roomC = makeRoom("!c"); const [rootNode] = buildTree([root, roomC, roomA, roomB]); expect(rootNode.children.map(n => n.room.room_id)).toEqual(["!a", "!b", "!c"]); }); it("handles cycle detection correctly after sort", () => { const roomA = makeRoom("!a", [makeChild("!b", 1)]); const roomB = makeRoom("!b", [makeChild("!a", 2)]); const [rootNode] = buildTree([roomA, roomB]); expect(rootNode.children).toHaveLength(1); expect(rootNode.children[0].children).toHaveLength(0); }); it("appends orphan rooms after root", () => { const root = makeRoom("!root"); const orphan = makeRoom("!orphan"); const nodes = buildTree([root, orphan]); expect(nodes).toHaveLength(2); expect(nodes[0].room.room_id).toBe("!root"); expect(nodes[1].room.room_id).toBe("!orphan"); }); }); ================================================ FILE: src/components/rooms/RoomHierarchy.tsx ================================================ import AccountTreeIcon from "@mui/icons-material/AccountTree"; import ExpandLessIcon from "@mui/icons-material/ExpandLess"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import MeetingRoomIcon from "@mui/icons-material/MeetingRoom"; import { Box, Button as MuiButton, Chip, CircularProgress, Collapse, FormControl, InputLabel, List, ListItemButton, ListItemIcon, ListItemText, MenuItem, Select, Typography, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useCallback, useEffect, useRef, useState } from "react"; import { useDataProvider, useNotify, useRecordContext, useRedirect, useTranslate } from "react-admin"; import { HierarchyRoom, SynapseDataProvider } from "../../providers/types"; interface TreeNode { key: string; room: HierarchyRoom; children: TreeNode[]; suggested?: boolean; placeholder?: boolean; } let nodeKeyCounter = 0; // Per MSC2946: a valid order value is a non-empty string of printable ASCII characters (0x20–0x7E). // Spread iterates Unicode code points so multi-byte characters are caught correctly. export const isValidOrder = (o: unknown): o is string => typeof o === "string" && o.length > 0 && [...o].every(c => { const cp = c.codePointAt(0) ?? 0; return cp >= 0x20 && cp <= 0x7e; }); // Sorts m.space.child events per the Matrix spec (MSC2946): // 1. Children with a valid `order` string sort first, lexicographically. // 2. Children without a valid `order` sort after, by origin_server_ts ascending. // 3. Equal order strings, or equal timestamps, fall back to state_key for a stable result. export const sortChildren = ( children: T[] ): T[] => [...children].sort((a, b) => { const aOrd = isValidOrder(a.content.order) ? a.content.order : undefined; const bOrd = isValidOrder(b.content.order) ? b.content.order : undefined; if (aOrd !== undefined && bOrd !== undefined) { if (aOrd !== bOrd) return aOrd < bOrd ? -1 : 1; // equal order strings → fall through to ts/state_key tiebreaker below } else if (aOrd !== undefined) { return -1; } else if (bOrd !== undefined) { return 1; } return a.origin_server_ts - b.origin_server_ts || a.state_key.localeCompare(b.state_key); }); const collectChildIds = (rooms: HierarchyRoom[]): Set => { const knownIds = new Set(rooms.map(r => r.room_id)); const missing = new Set(); for (const room of rooms) { if (room.children_state) { for (const child of room.children_state) { if (!knownIds.has(child.state_key)) missing.add(child.state_key); } } } return missing; }; export const buildTree = (rooms: HierarchyRoom[]): TreeNode[] => { if (rooms.length === 0) return []; nodeKeyCounter = 0; const roomMap = new Map(); for (const room of rooms) { if (!roomMap.has(room.room_id)) { roomMap.set(room.room_id, room); } } const createNode = (room: HierarchyRoom, visited: Set, suggested?: boolean): TreeNode => { const key = `${room.room_id}-${nodeKeyCounter++}`; const children: TreeNode[] = []; if (room.children_state) { for (const child of sortChildren(room.children_state)) { if (visited.has(child.state_key)) continue; visited.add(child.state_key); const knownRoom = roomMap.get(child.state_key); const childRoom = knownRoom ?? { room_id: child.state_key, num_joined_members: 0, guest_can_join: false, world_readable: false, children_state: [], }; const childNode = createNode(childRoom, visited, !!child.content?.suggested); if (!knownRoom) childNode.placeholder = true; children.push(childNode); } } return { key, room, children, suggested }; }; const root = rooms[0]; const visited = new Set([root.room_id]); const rootNode = createNode(root, visited); const orphans: TreeNode[] = []; for (const room of rooms) { if (!visited.has(room.room_id)) { visited.add(room.room_id); orphans.push(createNode(room, visited)); } } return [rootNode, ...orphans]; }; const TreeItem = ({ node, depth, indent, translate, navigate, }: { node: TreeNode; depth: number; indent: number; translate: ReturnType; navigate: (path: string) => void; }) => { const [open, setOpen] = useState(depth < 2); const hasChildren = node.children.length > 0; const isSpace = node.room.room_type === "m.space"; const displayName = node.room.name || node.room.room_id; const isClickable = hasChildren || !node.placeholder; return ( <> { if (hasChildren) { setOpen(v => !v); } else if (!node.placeholder) { navigate(`/rooms/${encodeURIComponent(node.room.room_id)}/show`); } }} > {isSpace ? : } {displayName} {node.room.room_type && ( )} {node.room.num_joined_members > 0 && ( {translate("resources.rooms.action.hierarchy.members", { count: node.room.num_joined_members })} )} {node.suggested && ( )} {node.room.join_rule && ( )} } sx={{ minWidth: 0 }} /> {hasChildren && (open ? : )} {hasChildren && ( {node.children.map(child => ( ))} )} ); }; export const RoomHierarchy = () => { const record = useRecordContext(); const translate = useTranslate(); const notify = useNotify(); const navigate = useRedirect(); const dataProvider = useDataProvider(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const [tree, setTree] = useState([]); const allRoomsRef = useRef([]); const [nextBatch, setNextBatch] = useState(undefined); const [loading, setLoading] = useState(true); const [maxDepth, setMaxDepth] = useState(""); const roomId = record?.room_id || record?.id; const indent = isSmall ? 2 : 3; const fetchHierarchy = useCallback( async (from?: string) => { if (!roomId) return; setLoading(true); try { const params: { from?: string; limit?: number; max_depth?: number } = {}; if (from) params.from = from; if (maxDepth !== "") params.max_depth = maxDepth; const result = await dataProvider.getRoomHierarchy(roomId as string, params); if (result.success && result.data) { const newRooms = from ? [...allRoomsRef.current, ...result.data.rooms] : result.data.rooms; const missingIds = collectChildIds(newRooms); if (missingIds.size > 0) { try { const { data: fetched } = await dataProvider.getMany("rooms", { ids: [...missingIds] }); for (const room of fetched) { newRooms.push({ room_id: room.room_id ?? (room.id as string), name: room.name, topic: room.topic, canonical_alias: room.canonical_alias, avatar_url: room.avatar_url, room_type: room.room_type, num_joined_members: room.joined_members ?? room.members ?? 0, join_rule: room.join_rules, guest_can_join: room.guest_can_join ?? false, world_readable: room.world_readable ?? false, children_state: [], }); } } catch { // silently fall back to room_id-only placeholders } } allRoomsRef.current = newRooms; setTree(buildTree(newRooms)); setNextBatch(result.data.next_batch); } else { notify(result.error || translate("resources.rooms.action.hierarchy.failure"), { type: "error" }); } } catch { notify(translate("resources.rooms.action.hierarchy.failure"), { type: "error" }); } setLoading(false); }, [roomId, maxDepth, dataProvider, notify, translate] ); useEffect(() => { if (roomId) { allRoomsRef.current = []; setNextBatch(undefined); fetchHierarchy(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [roomId, maxDepth]); const handleRefresh = () => { allRoomsRef.current = []; setNextBatch(undefined); fetchHierarchy(); }; if (!record) return null; return ( {translate("resources.rooms.action.hierarchy.max_depth")} {translate("resources.rooms.action.hierarchy.refresh")} {loading && tree.length === 0 && ( )} {!loading && tree.length === 0 && ( {translate("resources.rooms.action.hierarchy.no_children")} )} {tree.length > 0 && ( {tree.map(node => ( ))} )} {nextBatch && ( fetchHierarchy(nextBatch)} disabled={loading} sx={{ mt: 1 }}> {translate("resources.rooms.action.hierarchy.load_more")} )} ); }; ================================================ FILE: src/components/rooms/RoomMessages.tsx ================================================ import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import FilterListIcon from "@mui/icons-material/FilterList"; import SearchIcon from "@mui/icons-material/Search"; import { Autocomplete, Box, Button as MuiButton, Card, CardContent, Chip, CircularProgress, Collapse, Divider, FormControl, InputLabel, MenuItem, Select, TextField, Typography, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useCallback, useEffect, useRef, useState } from "react"; import { useDataProvider, useNotify, useRecordContext, useTranslate } from "react-admin"; import { EventLookupDialog, renderWithEventIds } from "./EventLookupDialog"; import { RoomEvent, SynapseDataProvider } from "../../providers/types"; export const COMMON_EVENT_TYPES = [ // ===== Room timeline (message-like events) ===== "m.room.message", "m.room.encrypted", "m.room.redaction", "m.reaction", "m.sticker", "m.call.invite", "m.call.answer", "m.call.hangup", "m.call.candidates", "m.call.reject", "m.call.negotiate", "m.location", "m.beacon", "m.beacon_info", "m.poll.start", "m.poll.response", "m.poll.end", // ===== Room state events ===== "m.room.create", "m.room.name", "m.room.topic", "m.room.avatar", "m.room.canonical_alias", "m.room.join_rules", "m.room.member", "m.room.power_levels", "m.room.history_visibility", "m.room.guest_access", "m.room.server_acl", "m.room.tombstone", "m.room.encryption", "m.room.pinned_events", "m.room.aliases", "m.room.retention", "m.room.third_party_invite", "m.room.related_groups", // ===== Spaces ===== "m.space.child", "m.space.parent", // ===== Ephemeral events ===== "m.typing", "m.receipt", "m.presence", // ===== Account data ===== "m.direct", "m.ignored_user_list", "m.push_rules", "m.tag", "m.fully_read", // ===== To-device events ===== "m.room_key", "m.room_key_request", "m.forwarded_room_key", "m.room_key.withheld", "m.dummy", // ===== Key verification ===== "m.key.verification.request", "m.key.verification.start", "m.key.verification.ready", "m.key.verification.accept", "m.key.verification.key", "m.key.verification.mac", "m.key.verification.done", "m.key.verification.cancel", // ===== Secrets ===== "m.secret.request", "m.secret.send", // ===== Relations (used in content but standardized) ===== "m.annotation", "m.replace", "m.reference", // ===== Legacy / deprecated but still in spec history ===== "m.room.message.feedback", ]; const EventCard = ({ event, isTarget, locale, onEventIdClick, }: { event: RoomEvent; isTarget: boolean; locale: string; onEventIdClick?: (eventId: string) => void; }) => { const theme = useTheme(); const body = event.content?.body || event.content?.membership || event.content?.displayname || event.content?.name || null; const contentStr = body !== null ? String(body) : JSON.stringify(event.content, null, 2); const isSimple = body !== null; return ( {event.sender} {new Date(event.origin_server_ts).toLocaleString(locale)} {event.type} ·{" "} onEventIdClick(event.event_id) : undefined} sx={{ all: "unset", cursor: onEventIdClick ? "pointer" : "text", color: onEventIdClick ? "primary.main" : "inherit", textDecoration: onEventIdClick ? "underline" : "none", wordBreak: "break-all", }} > {event.event_id} {isSimple ? contentStr : renderWithEventIds(contentStr, onEventIdClick)} ); }; const PAGE_SIZE = 20; interface RoomEventFilter { types?: string[]; not_types?: string[]; senders?: string[]; not_senders?: string[]; contains_url?: boolean; } const buildFilter = (filter: RoomEventFilter): string | undefined => { const obj: Record = {}; if (filter.types && filter.types.length > 0) obj.types = filter.types; if (filter.not_types && filter.not_types.length > 0) obj.not_types = filter.not_types; if (filter.senders && filter.senders.length > 0) obj.senders = filter.senders; if (filter.not_senders && filter.not_senders.length > 0) obj.not_senders = filter.not_senders; if (filter.contains_url !== undefined) obj.contains_url = filter.contains_url; return Object.keys(obj).length > 0 ? JSON.stringify(obj) : undefined; }; const ChipInput = ({ label, placeholder, values, onChange, isSmall, }: { label: string; placeholder: string; values: string[]; onChange: (v: string[]) => void; isSmall: boolean; }) => { const [input, setInput] = useState(""); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && input.trim()) { e.preventDefault(); if (!values.includes(input.trim())) { onChange([...values, input.trim()]); } setInput(""); } }; return ( setInput(e.target.value)} onKeyDown={handleKeyDown} slotProps={{ inputLabel: { shrink: true } }} /> {values.length > 0 && ( {values.map(v => ( onChange(values.filter(x => x !== v))} /> ))} )} ); }; export const RoomMessages = () => { const record = useRecordContext(); const translate = useTranslate(); const notify = useNotify(); const dataProvider = useDataProvider(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const [messages, setMessages] = useState([]); const [startToken, setStartToken] = useState(null); const [endToken, setEndToken] = useState(null); const [loading, setLoading] = useState(false); const [initialLoading, setInitialLoading] = useState(true); const [targetEventId, setTargetEventId] = useState(null); const [lookupEventId, setLookupEventId] = useState(null); const [dateInput, setDateInput] = useState(""); const [direction, setDirection] = useState<"f" | "b">("b"); const [showFilters, setShowFilters] = useState(false); const [showAdvancedFilters, setShowAdvancedFilters] = useState(false); const [filterTypes, setFilterTypes] = useState([]); const [filterNotTypes, setFilterNotTypes] = useState([]); const [filterSenders, setFilterSenders] = useState([]); const [filterNotSenders, setFilterNotSenders] = useState([]); const [filterContainsUrl, setFilterContainsUrl] = useState(undefined); const targetRef = useRef(null); const locale = navigator.language || "en"; const roomId = record?.room_id || record?.id; const getFilterObj = useCallback( (): RoomEventFilter => ({ types: filterTypes, not_types: filterNotTypes, senders: filterSenders, not_senders: filterNotSenders, contains_url: filterContainsUrl, }), [filterTypes, filterNotTypes, filterSenders, filterNotSenders, filterContainsUrl] ); const activeFilterCount = (filterTypes.length > 0 ? 1 : 0) + (filterNotTypes.length > 0 ? 1 : 0) + (filterSenders.length > 0 ? 1 : 0) + (filterNotSenders.length > 0 ? 1 : 0) + (filterContainsUrl !== undefined ? 1 : 0); const bootstrap = useCallback( async (filter?: string) => { if (!roomId) return; setInitialLoading(true); setMessages([]); setTargetEventId(null); try { const tsResult = await dataProvider.getEventByTimestamp(roomId as string, Date.now(), "b"); if (!tsResult.success || !tsResult.event_id) { setInitialLoading(false); return; } const ctxResult = await dataProvider.getEventContext(roomId as string, tsResult.event_id, 0); if (!ctxResult.success || !ctxResult.data) { setInitialLoading(false); return; } const msgResult = await dataProvider.getRoomMessages(roomId as string, { from: ctxResult.data.end, dir: "b", limit: PAGE_SIZE, filter, }); if (msgResult.success && msgResult.data) { setMessages(msgResult.data.chunk); setStartToken(msgResult.data.start); setEndToken(msgResult.data.end || null); } } catch { notify(translate("resources.rooms.action.messages.failure"), { type: "error" }); } setInitialLoading(false); }, [roomId, dataProvider, notify, translate] ); useEffect(() => { bootstrap(); }, [bootstrap]); const currentFilter = buildFilter(getFilterObj()); const loadMore = async (dir: "b" | "f") => { const token = dir === "b" ? endToken : startToken; if (!token || !roomId) return; setLoading(true); try { const result = await dataProvider.getRoomMessages(roomId as string, { from: token, dir, limit: PAGE_SIZE, filter: currentFilter, }); if (result.success && result.data) { if (dir === "b") { setMessages(prev => [...prev, ...result.data!.chunk]); setEndToken(result.data.end || null); } else { setMessages(prev => [...result.data!.chunk.reverse(), ...prev]); setStartToken(result.data.end || null); } } } catch { notify(translate("resources.rooms.action.messages.failure"), { type: "error" }); } setLoading(false); }; const handleJumpToDate = async () => { if (!dateInput || !roomId) return; setLoading(true); setMessages([]); setTargetEventId(null); try { const ts = new Date(dateInput).getTime(); const tsResult = await dataProvider.getEventByTimestamp(roomId as string, ts, direction); if (!tsResult.success || !tsResult.event_id) { notify(tsResult.error || translate("resources.rooms.action.event_context.not_found"), { type: "warning" }); setLoading(false); return; } const ctxResult = await dataProvider.getEventContext(roomId as string, tsResult.event_id); if (!ctxResult.success || !ctxResult.data) { notify(ctxResult.error || translate("resources.rooms.action.event_context.failure"), { type: "error" }); setLoading(false); return; } const ctx = ctxResult.data; const allEvents = [...ctx.events_before, ctx.event, ...ctx.events_after]; setMessages(allEvents); setStartToken(ctx.start); setEndToken(ctx.end || null); setTargetEventId(ctx.event.event_id); setTimeout(() => targetRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }), 100); } catch { notify(translate("resources.rooms.action.event_context.failure"), { type: "error" }); } setLoading(false); }; const handleApplyFilters = () => { bootstrap(buildFilter(getFilterObj())); }; const handleClearFilters = () => { setFilterTypes([]); setFilterNotTypes([]); setFilterSenders([]); setFilterNotSenders([]); setFilterContainsUrl(undefined); bootstrap(); }; if (!record) return null; return ( setShowFilters(v => !v)} startIcon={} size="small" color={activeFilterCount > 0 ? "primary" : "inherit"} > {translate("resources.rooms.action.messages.filter")} {activeFilterCount > 0 && ` (${activeFilterCount})`} setDateInput(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} fullWidth={isSmall} sx={{ flex: 1, minWidth: isSmall ? undefined : 250 }} size="small" /> {translate("resources.rooms.action.event_context.direction")} : } sx={{ alignSelf: isSmall ? "stretch" : "center", height: 40 }} > {translate("resources.rooms.action.event_context.jump_to_date")} setFilterTypes(v)} renderInput={params => ( )} sx={{ flex: 1, minWidth: isSmall ? undefined : 300 }} /> setShowAdvancedFilters(v => !v)} endIcon={ } sx={{ mb: showAdvancedFilters ? 1.5 : 0, textTransform: "none" }} > {translate("resources.rooms.action.messages.advanced_filters")} setFilterNotTypes(v)} renderInput={params => ( )} sx={{ flex: 1, minWidth: isSmall ? undefined : 300 }} /> {translate("resources.rooms.action.messages.contains_url")} {activeFilterCount > 0 && ( {translate("resources.rooms.action.messages.clear_filters")} )} {translate("resources.rooms.action.messages.apply_filter")} {initialLoading && ( )} {!initialLoading && messages.length === 0 && ( {translate("resources.rooms.action.messages.no_messages")} )} {!initialLoading && messages.length > 0 && ( <> {startToken && ( loadMore("f")} disabled={loading} sx={{ mb: 1 }}> {translate("resources.rooms.action.messages.load_newer")} )} {messages.map(evt => ( ))} {endToken && ( loadMore("b")} disabled={loading} sx={{ mt: 1 }}> {translate("resources.rooms.action.messages.load_older")} )} )} setLookupEventId(null)} initialEventId={lookupEventId ?? undefined} /> ); }; ================================================ FILE: src/components/user-import/ConflictModeCard.tsx ================================================ import { NativeSelect, Paper } from "@mui/material"; import { CardContent, CardHeader, Container } from "@mui/material"; import { useTranslate } from "ra-core"; import { ChangeEventHandler } from "react"; import { ImportResult, ParsedStats, Progress } from "./types"; const TranslatableOption = ({ value, text }: { value: string; text: string }) => { const translate = useTranslate(); return ; }; const ConflictModeCard = ({ stats, importResults, onConflictModeChanged, conflictMode, progress, }: { stats: ParsedStats | null; importResults: ImportResult | null; onConflictModeChanged: ChangeEventHandler; conflictMode: string; progress: Progress; }) => { const translate = useTranslate(); if (!stats || importResults) { return null; } return ( ); }; export default ConflictModeCard; ================================================ FILE: src/components/user-import/ErrorsCard.tsx ================================================ import { Container, Paper, CardHeader, CardContent, Stack, Typography } from "@mui/material"; import { useTranslate } from "ra-core"; const ErrorsCard = ({ errors }: { errors: string[] }) => { const translate = useTranslate(); if (errors.length === 0) { return null; } return ( {errors.map(e => ( {e} ))} ); }; export default ErrorsCard; ================================================ FILE: src/components/user-import/ResultsCard.tsx ================================================ import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import DownloadIcon from "@mui/icons-material/Download"; import { Alert, Box, CardContent, CardHeader, Container, List, ListItem, ListItemText, Paper, Stack, Typography, } from "@mui/material"; import { Button, Link, useTranslate } from "react-admin"; import { ImportResult } from "./types"; const ResultsCard = ({ importResults, downloadSkippedRecords, }: { importResults: ImportResult | null; downloadSkippedRecords: () => void; }) => { const translate = useTranslate(); if (!importResults) { return null; } return ( {translate("import_users.cards.results.total", importResults.totalRecordCount)} {translate("import_users.cards.results.successful", importResults.succeededRecords.length)} {importResults.succeededRecords.map(record => ( ))} {importResults.skippedRecords.length > 0 && ( {translate("import_users.cards.results.skipped", importResults.skippedRecords.length)} )} {importResults.erroredRecords.length > 0 && ( {translate("import_users.cards.results.skipped", importResults.erroredRecords.length)} )} {importResults.wasDryRun && ( {translate("import_users.cards.results.simulated_only")} )} {progress !== null ? (
{progress.done} of {progress.limit} done
) : null}
); }; export default StartImportCard; ================================================ FILE: src/components/user-import/StatsCard.tsx ================================================ import { Card, Paper, Stack, CardContent, CardHeader, Container, Typography } from "@mui/material"; import { NativeSelect } from "@mui/material"; import { FormControlLabel } from "@mui/material"; import { Checkbox } from "@mui/material"; import { useTranslate } from "ra-core"; import { ChangeEventHandler } from "react"; import { ImportResult, ParsedStats, Progress } from "./types"; const StatsCard = ({ stats, progress, importResults, useridMode, passwordMode, onUseridModeChanged, onPasswordModeChange, }: { stats: ParsedStats | null; progress: Progress; importResults: ImportResult | null; useridMode: string; passwordMode: boolean; onUseridModeChanged: ChangeEventHandler; onPasswordModeChange: ChangeEventHandler; }) => { const translate = useTranslate(); if (!stats) { return null; } if (importResults) { return null; } return ( <> {translate("import_users.cards.importstats.users_total", stats.total)} {translate("import_users.cards.importstats.guest_count", stats.is_guest)} {translate("import_users.cards.importstats.admin_count", stats.admin)} {stats.id === stats.total ? translate("import_users.cards.ids.all_ids_present") : translate("import_users.cards.ids.count_ids_present", stats.id)} {stats.id > 0 && ( )} {stats.password === stats.total ? translate("import_users.cards.passwords.all_passwords_present") : translate("import_users.cards.passwords.count_passwords_present", stats.password)} {stats.password > 0 && ( } label={translate("import_users.cards.passwords.use_passwords")} /> )} ); }; export default StatsCard; ================================================ FILE: src/components/user-import/UploadCard.tsx ================================================ import { CardHeader, CardContent, Container, Link, Stack, Typography, Paper } from "@mui/material"; import { useTranslate } from "ra-core"; import { ChangeEventHandler } from "react"; import { ImportResult, Progress } from "./types"; const UploadCard = ({ importResults, onFileChange, progress, }: { importResults: ImportResult | null; onFileChange: ChangeEventHandler; progress: Progress; }) => { const translate = useTranslate(); if (importResults) { return null; } return ( {translate("import_users.cards.upload.explanation")} example.csv ); }; export default UploadCard; ================================================ FILE: src/components/user-import/UserImport.tsx ================================================ import { Stack } from "@mui/material"; import { useTranslate } from "ra-core"; import { Title } from "react-admin"; import ConflictModeCard from "./ConflictModeCard"; import ErrorsCard from "./ErrorsCard"; import ResultsCard from "./ResultsCard"; import StartImportCard from "./StartImportCard"; import StatsCard from "./StatsCard"; import UploadCard from "./UploadCard"; import useImportFile from "./useImportFile"; import { useDocTitle } from "../hooks/useDocTitle"; const UserImport = () => { const { csvData, dryRun, importResults, progress, errors, stats, conflictMode, passwordMode, useridMode, onFileChange, onDryRunModeChanged, runImport, onConflictModeChanged, onPasswordModeChange, onUseridModeChanged, downloadSkippedRecords, } = useImportFile(); const translate = useTranslate(); useDocTitle(translate("import_users.title")); return ( <UploadCard importResults={importResults} onFileChange={onFileChange} progress={progress} /> <ErrorsCard errors={errors} /> <ConflictModeCard stats={stats} importResults={importResults} conflictMode={conflictMode} onConflictModeChanged={onConflictModeChanged} progress={progress} /> <StatsCard stats={stats} progress={progress} importResults={importResults} passwordMode={passwordMode} useridMode={useridMode} onPasswordModeChange={onPasswordModeChange} onUseridModeChanged={onUseridModeChanged} /> <StartImportCard csvData={csvData} importResults={importResults} progress={progress} dryRun={dryRun} onDryRunModeChanged={onDryRunModeChanged} runImport={runImport} /> <ResultsCard importResults={importResults} downloadSkippedRecords={downloadSkippedRecords} /> </Stack> ); }; export default UserImport; ================================================ FILE: src/components/user-import/types.ts ================================================ import { RaRecord } from "react-admin"; export interface ImportLine { id: string; displayname: string; user_type?: string; name?: string; deactivated?: boolean | string; is_guest?: boolean | string; admin?: boolean | string; is_admin?: boolean | string; password?: string; avatar_url?: string; threepids?: string | { medium: string; address: string }[]; // CSV: comma-separated "medium:address" pairs; after parsing: array of objects } export interface ParsedStats { user_types: Record<string, number>; is_guest: number; admin: number; deactivated: number; password: number; avatar_url: number; id: number; total: number; } export interface ChangeStats { total: number; id: number; is_guest: number; admin: number; password: number; } export type Progress = { done: number; limit: number; } | null; export interface ImportResult { skippedRecords: RaRecord[]; erroredRecords: RaRecord[]; succeededRecords: RaRecord[]; totalRecordCount: number; changeStats: ChangeStats; wasDryRun: boolean; } ================================================ FILE: src/components/user-import/useImportFile.test.ts ================================================ import { parse as parseCsv } from "papaparse"; import { ImportLine } from "./types"; import { anyToBoolean, validateCsvImport } from "./useImportFile"; const translate = (key: string) => key; const parseText = (text: string) => parseCsv<ImportLine>(text, { header: true, skipEmptyLines: true, }); describe("anyToBoolean", () => { it("handles numeric inputs", () => { expect(anyToBoolean(1)).toBe(true); expect(anyToBoolean(-1)).toBe(true); expect(anyToBoolean(0.1)).toBe(true); expect(anyToBoolean(0)).toBe(false); expect(anyToBoolean(Number.NaN)).toBe(false); expect(anyToBoolean(Number.POSITIVE_INFINITY)).toBe(false); }); }); describe("validateCsvImport", () => { const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); afterAll(() => { consoleSpy.mockRestore(); }); it("accepts BOM/whitespace headers and boolean variants", () => { const csv = "\uFEFFID , DisplayName , is_guest , admin , deactivated\r\n" + "@user:hs, Alice, TRUE, 0, no\r\n"; const result = validateCsvImport(parseText(csv), translate); expect(result.ok).toBe(true); expect(result.errors).toEqual([]); expect(result.data[0].id).toBe("@user:hs"); expect(result.data[0].displayname).toBe("Alice"); expect(result.data[0].is_guest).toBe(true); expect(result.data[0].admin).toBe(false); expect(result.data[0].deactivated).toBe(false); expect(result.stats?.is_guest).toBe(1); expect(result.stats?.admin).toBe(0); }); it("handles semicolon delimiters and quoted fields", () => { const csv = "id;displayname;is_guest\n" + '@u:hs;"Doe, John";1\n' + '@v:hs;"Line1\nLine2";0\n'; const result = validateCsvImport(parseText(csv), translate); expect(result.ok).toBe(true); expect(result.data).toHaveLength(2); expect(result.data[0].displayname).toBe("Doe, John"); expect(result.data[1].displayname).toBe("Line1\nLine2"); }); it("handles blank lines and mixed line endings", () => { const csv = "id,displayname\r\n@u:hs,User One\r\n\r\n@v:hs,User Two\n"; const result = validateCsvImport(parseText(csv), translate); expect(result.ok).toBe(true); expect(result.data).toHaveLength(2); expect(result.stats?.total).toBe(2); }); it("errors on missing required fields", () => { const csv = "id,name\n@u:hs,User One\n"; const result = validateCsvImport(parseText(csv), translate); expect(result.ok).toBe(false); expect(result.errors[0]).toBe("import_users.error.required_field"); }); it("errors on invalid boolean values", () => { const csv = "id,displayname,is_guest\n@u:hs,User One,maybe\n"; const result = validateCsvImport(parseText(csv), translate); expect(result.ok).toBe(false); expect(result.errors[0]).toBe("import_users.error.invalid_value"); }); it("strips name/user_type/is_admin fields from records", () => { const csv = "id,displayname,name,user_type,is_admin\n" + "@u:hs,User One,Legacy Name,custom,TRUE\n"; const result = validateCsvImport(parseText(csv), translate); expect(result.ok).toBe(true); expect(result.data[0].name).toBeUndefined(); expect(result.data[0].user_type).toBeUndefined(); expect(result.data[0].is_admin).toBeUndefined(); }); }); ================================================ FILE: src/components/user-import/useImportFile.tsx ================================================ import { parse as parseCsv, unparse as unparseCsv, ParseResult } from "papaparse"; import { ChangeEvent, useState } from "react"; import { useTranslate, useNotify, HttpError } from "react-admin"; import { ImportLine, ParsedStats, Progress, ImportResult, ChangeStats } from "./types"; import dataProvider from "../../providers/data"; import { returnMXID } from "../../utils/mxid"; import { generateRandomMXID } from "../../utils/mxid"; import { generateRandomPassword } from "../../utils/password"; import createLogger from "../../utils/logger"; const log = createLogger("import"); const LOGGING = true; const EXPECTED_FIELDS = ["id", "displayname"].sort(); const FALSE_VALUES = ["", "0", "false", "no", "off", "null", "undefined"]; const TRUE_VALUES = ["1", "true", "yes", "on"]; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ export const anyToBoolean = (value: any): boolean => { if (typeof value === "boolean") { return value; } if (typeof value === "number") { if (!Number.isFinite(value)) { return false; } return value !== 0; } if (typeof value === "string") { const val = value.trim().toLowerCase(); if (TRUE_VALUES.includes(val)) { return true; } if (FALSE_VALUES.includes(val)) { return false; } } return false; }; export interface CsvValidationResult { ok: boolean; data: ImportLine[]; stats: ParsedStats | null; errors: string[]; } export const validateCsvImport = ( { data, meta, errors }: ParseResult<ImportLine>, translate: (key: string, options?: Record<string, unknown>) => string ): CsvValidationResult => { /* First, verify the presence of required fields */ meta.fields = meta.fields?.map(f => f.trim().toLowerCase()); const missingFields = EXPECTED_FIELDS.filter(eF => !meta.fields?.find(mF => eF === mF)); if (missingFields.length > 0) { return { ok: false, data: [], stats: null, errors: [translate("import_users.error.required_field", { field: missingFields[0] })], }; } /* Collect some stats to prevent sneaky csv files from adding admin users or something. */ const stats: ParsedStats = { user_types: { default: 0 }, is_guest: 0, admin: 0, deactivated: 0, password: 0, avatar_url: 0, id: 0, total: data.length, }; const errorMessages = errors.map(e => e.message); // sanitize the data first data = data.map(line => { const newLine = {} as ImportLine; for (const [key, value] of Object.entries(line)) { const normalizedKey = key.trim().toLowerCase(); const normalizedValue = typeof value === "string" ? value.trim() : value; newLine[normalizedKey] = normalizedValue; } return newLine; }); // process the data data.forEach((line, idx) => { if (line.user_type === undefined || line.user_type === "") { stats.user_types.default++; } else { if (stats.user_types[line.user_type] === undefined) { stats.user_types[line.user_type] = 0; } stats.user_types[line.user_type] += 1; } /* XXX correct the csv export that react-admin offers for the users * resource so it gives sensible field names and doesn't duplicate * id as "name"? */ if (meta.fields?.includes("name")) { delete line.name; } if (meta.fields?.includes("user_type")) { delete line.user_type; } if (meta.fields?.includes("is_admin")) { delete line.is_admin; } ["is_guest", "admin", "deactivated"].forEach(f => { const rawValue = line[f]; if (rawValue === undefined || rawValue === "") { line[f] = false; // default values to false return; } if (typeof rawValue === "boolean") { if (rawValue) { stats[f]++; } line[f] = rawValue; return; } const normalizedValue = String(rawValue).trim().toLowerCase(); if (TRUE_VALUES.includes(normalizedValue)) { stats[f]++; line[f] = true; // we need true booleans instead of strings return; } if (FALSE_VALUES.includes(normalizedValue)) { line[f] = false; return; } log.warn("invalid value in CSV", { field: f, row: idx, value: rawValue }); errorMessages.push( translate("import_users.error.invalid_value", { field: f, row: idx, }) ); line[f] = false; // default values to false }); if (line.password !== undefined && line.password !== "") { stats.password++; } if (line.avatar_url !== undefined && line.avatar_url !== "") { stats.avatar_url++; } if (line.id !== undefined && line.id !== "") { stats.id++; } }); if (errorMessages.length > 0) { return { ok: false, data, stats: null, errors: errorMessages }; } return { ok: true, data, stats, errors: [] }; }; const useImportFile = () => { const [csvData, setCsvData] = useState<ImportLine[]>([]); const [errors, setErrors] = useState<string[]>([]); const [stats, setStats] = useState<ParsedStats | null>(null); const [dryRun, setDryRun] = useState(true); const [progress, setProgress] = useState<Progress>(null); const [importResults, setImportResults] = useState<ImportResult | null>(null); const [skippedRecords, setSkippedRecords] = useState<string>(""); const [conflictMode, setConflictMode] = useState<"stop" | "skip">("stop"); const [passwordMode, setPasswordMode] = useState(true); const [useridMode, setUseridMode] = useState<"update" | "ignore">("update"); const translate = useTranslate(); const notify = useNotify(); const onFileChange = async (e: ChangeEvent<HTMLInputElement>) => { if (progress !== null) return; setCsvData([]); setErrors([]); setStats(null); setImportResults(null); const file = e.target.files ? e.target.files[0] : null; if (!file) return; /* Let's refuse some unreasonably big files instead of freezing * up the browser */ if (file.size > 100000000) { const message = translate("import_users.errors.unreasonably_big", { size: (file.size / (1024 * 1024)).toFixed(2), }); notify(message); setErrors([message]); return; } try { parseCsv<ImportLine>(file, { header: true, skipEmptyLines: true /* especially for a final EOL in the csv file */, complete: result => { if (result.errors) { setErrors(result.errors.map(e => e.toString())); } /* Papaparse is very lenient, we may be able to salvage * the data in the file. */ verifyCsv(result); }, }); } catch { setErrors(["Unknown error"]); return null; } }; const verifyCsv = ({ data, meta, errors }: ParseResult<ImportLine>) => { const result = validateCsvImport({ data, meta, errors }, translate); if (!result.ok) { setErrors(result.errors); return false; } setStats(result.stats); setCsvData(result.data); return true; }; const onConflictModeChanged = async (e: ChangeEvent<HTMLSelectElement>) => { if (progress !== null) { return; } const value = e.target.value as "stop" | "skip"; setConflictMode(value); }; const onPasswordModeChange = (e: ChangeEvent<HTMLInputElement>) => { if (progress !== null) { return; } setPasswordMode(e.target.checked); }; const onUseridModeChanged = (e: ChangeEvent<HTMLSelectElement>) => { if (progress !== null) { return; } const value = e.target.value as "update" | "ignore"; setUseridMode(value); }; const onDryRunModeChanged = (e: ChangeEvent<HTMLInputElement>) => { if (progress !== null) { return; } setDryRun(e.target.checked); }; const runImport = async () => { if (progress !== null) { notify("import_users.errors.already_in_progress"); return; } const results = await doImport(); setImportResults(results); // offer CSV download of skipped or errored records // (so that the user doesn't have to filter out successful // records manually when fixing stuff in the CSV) setSkippedRecords(unparseCsv(results.skippedRecords)); if (LOGGING) log.debug("skipped records after parse", { count: skippedRecords.length }); }; const doImport = async (): Promise<ImportResult> => { const skippedRecords: ImportLine[] = []; const erroredRecords: ImportLine[] = []; const succeededRecords: ImportLine[] = []; const changeStats: ChangeStats = { total: 0, id: 0, is_guest: 0, admin: 0, password: 0, }; let entriesDone = 0; const entriesCount = csvData.length; try { setProgress({ done: entriesDone, limit: entriesCount }); for (const entry of csvData) { const userRecord = { ...entry }; userRecord.deactivated = anyToBoolean(userRecord.deactivated); userRecord.is_guest = anyToBoolean(userRecord.is_guest); userRecord.admin = anyToBoolean(userRecord.admin); userRecord.is_admin = anyToBoolean(userRecord.is_admin); // No need to do a bunch of cryptographic random number getting if // we are using neither a generated password nor a generated user id. if (useridMode === "ignore" || userRecord.id === undefined || userRecord.id === "") { userRecord.id = generateRandomMXID(); } if (passwordMode === false || entry.password === undefined || entry.password === "") { userRecord.password = generateRandomPassword(); } // we want to ensure that the ID is always full MXID, otherwise randomly-generated MXIDs will be in the full // form, but the ones from the CSV will be localpart-only. userRecord.id = returnMXID(userRecord.id); // if there are 3PIDs, convert them to objects ("medium:address,..." -> [{medium,address},...]) if (typeof userRecord.threepids === "string" && userRecord.threepids !== "") { const threepids = userRecord.threepids.split(",").map(m => m.trim()); const threepidObjs: { medium: string; address: string }[] = []; for (const threepid of threepids) { const parts = threepid.split(":"); if (parts.length !== 2) { continue; } const medium = parts[0].trim().toLowerCase(); const address = parts[1].trim(); if (address === "") { continue; } threepidObjs.push({ medium, address }); } userRecord.threepids = threepidObjs; } /* TODO record update stats (especially admin no -> yes, deactivated x -> !x, ... */ /* For these modes we will consider the ID that's in the record. * If the mode is "stop", we will not continue adding more records, and * we will offer information on what was already added and what was * skipped. * * If the mode is "skip", we record the record for later, but don't * send it to the server. * * If the mode is "update", we change fields that are reasonable to * update. * - If the "password mode" is "true" (i.e. "use passwords from csv"): * - if the record has a password * - send the password along with the record * - if the record has no password * - generate a new password * - If the "password mode" is "false" * - never generate a new password to update existing users with */ /* We just act as if there are no IDs in the CSV, so every user will be * created anew. * We do a simple retry loop so that an accidental hit on an existing ID * doesn't trip us up. */ if (LOGGING) log.debug("checking existence", { id: userRecord.id }); let retries = 0; const submitRecord = async (recordData: ImportLine) => { try { await dataProvider.getOne("users", { id: recordData.id }); if (LOGGING) log.debug("user already exists", { id: recordData.id }); if (conflictMode === "stop") { throw new Error( translate("import_users.error.id_exits", { id: recordData.id, }) ); } if (conflictMode === "skip" || useridMode === "update") { skippedRecords.push(recordData); return; } const newRecordData = Object.assign({}, recordData, { id: generateRandomMXID(), }); retries++; if (retries > 512) { log.warn("retry loop stuck", { id: recordData.id, retries }); skippedRecords.push(recordData); return; } await submitRecord(newRecordData); } catch (e) { if (!(e instanceof HttpError) || (e.status && e.status !== 404)) { throw e; } if (LOGGING) log.debug("creating record", { id: recordData.id, displayname: recordData.displayname }); if (!dryRun) { await dataProvider.create("users", { data: recordData }); } succeededRecords.push(recordData); } }; await submitRecord(userRecord); entriesDone++; setProgress({ done: entriesDone, limit: csvData.length }); } setProgress(null); } catch (e) { setErrors([ translate("import_users.error.at_entry", { entry: entriesDone + 1, message: e instanceof Error ? e.message : String(e), }), ]); setProgress(null); } return { skippedRecords, erroredRecords, succeededRecords, totalRecordCount: entriesCount, changeStats, wasDryRun: dryRun, }; }; const downloadSkippedRecords = () => { const element = document.createElement("a"); log.info("downloading skipped records"); const file = new Blob([skippedRecords], { type: "text/comma-separated-values", }); element.href = URL.createObjectURL(file); element.download = "skippedRecords.csv"; document.body.appendChild(element); // Required for this to work in FireFox element.click(); }; return { csvData, dryRun, onDryRunModeChanged, runImport, progress, importResults, errors, stats, conflictMode, passwordMode, useridMode, onConflictModeChanged, onPasswordModeChange, onUseridModeChanged, onFileChange, downloadSkippedRecords, }; }; export default useImportFile; ================================================ FILE: src/components/users/AdminClientConfigItems.tsx ================================================ import { Divider, ListItemText, MenuItem, Switch } from "@mui/material"; import { useEffect, useRef, useState } from "react"; import { useDataProvider, useNotify, useTranslate } from "react-admin"; import { AdminClientConfig, SynapseDataProvider } from "../../providers/types"; const defaultConfig: AdminClientConfig = { return_soft_failed_events: false, return_policy_server_spammy_events: false, }; // Module-level cache: survives menu open/close remounts, resets on page reload (unlike localStorage-based stores) let cachedConfig: AdminClientConfig | null = null; export const AdminClientConfigItems = () => { const dataProvider = useDataProvider<SynapseDataProvider>(); const notify = useNotify(); const translate = useTranslate(); const [config, setConfig] = useState<AdminClientConfig>(cachedConfig ?? defaultConfig); const fetched = useRef(false); useEffect(() => { if (fetched.current || cachedConfig) return; fetched.current = true; dataProvider.getAdminClientConfig().then(cfg => { cachedConfig = cfg; setConfig(cfg); }); }, [dataProvider]); const handleToggle = async (key: keyof AdminClientConfig) => { const newConfig = { ...config, [key]: !config[key] }; cachedConfig = newConfig; setConfig(newConfig); try { await dataProvider.setAdminClientConfig(newConfig); notify(translate("ketesa.admin_config.success"), { type: "success" }); } catch { cachedConfig = config; setConfig(config); notify(translate("ketesa.admin_config.failure"), { type: "error" }); } }; return ( <> <MenuItem dense onClick={() => handleToggle("return_soft_failed_events")}> <ListItemText primary={translate("ketesa.admin_config.soft_failed_events")} /> <Switch checked={config.return_soft_failed_events} size="small" edge="end" /> </MenuItem> <MenuItem dense onClick={() => handleToggle("return_policy_server_spammy_events")}> <ListItemText primary={translate("ketesa.admin_config.spam_flagged_events")} /> <Switch checked={config.return_policy_server_spammy_events} size="small" edge="end" /> </MenuItem> <Divider sx={{ my: 0.5 }} /> </> ); }; ================================================ FILE: src/components/users/DeviceDisplayNameInput.tsx ================================================ import SaveIcon from "@mui/icons-material/Save"; import { IconButton, InputAdornment, TextField } from "@mui/material"; import { useState } from "react"; import { useNotify, useRecordContext, useTranslate } from "react-admin"; import { jsonClient } from "../../providers/http"; const DeviceDisplayNameInput = () => { const record = useRecordContext(); const notify = useNotify(); const translate = useTranslate(); const [value, setValue] = useState(record?.display_name || ""); const [saving, setSaving] = useState(false); if (!record) return null; const isDirty = value !== (record.display_name || ""); const handleSave = async () => { if (!isDirty) return; setSaving(true); try { const base_url = localStorage.getItem("base_url"); await jsonClient( `${base_url}/_synapse/admin/v2/users/${encodeURIComponent(record.user_id)}/devices/${encodeURIComponent(record.device_id)}`, { method: "PUT", body: JSON.stringify({ display_name: value }) } ); notify("resources.devices.action.display_name.success", { type: "success" }); } catch { notify("resources.devices.action.display_name.failure", { type: "error" }); } finally { setSaving(false); } }; return ( <TextField value={value} onChange={e => setValue(e.target.value)} onBlur={handleSave} onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); handleSave(); } }} size="small" variant="standard" disabled={saving} label={translate("resources.devices.fields.display_name")} slotProps={{ input: { endAdornment: isDirty ? ( <InputAdornment position="end"> <IconButton size="small" onClick={handleSave} disabled={saving}> <SaveIcon fontSize="small" /> </IconButton> </InputAdornment> ) : undefined, }, }} /> ); }; export default DeviceDisplayNameInput; ================================================ FILE: src/components/users/ExperimentalFeatures.tsx ================================================ import { Stack, Switch, Typography } from "@mui/material"; import { useState, useEffect } from "react"; import { useRecordContext } from "react-admin"; import { useNotify } from "react-admin"; import { useDataProvider } from "react-admin"; import { ExperimentalFeaturesModel, SynapseDataProvider } from "../../providers/types"; const experimentalFeaturesMap = { msc3881: "enable remotely toggling push notifications for another client", msc3575: "enable experimental sliding sync support", }; const ExperimentalFeatureRow = (props: { featureKey: string; featureValue: boolean; updateFeature: (feature_name: string, feature_value: boolean) => void; }) => { const featureKey = props.featureKey; const featureValue = props.featureValue; const featureDescription = experimentalFeaturesMap[featureKey] ?? ""; const [checked, setChecked] = useState(featureValue); const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { setChecked(event.target.checked); props.updateFeature(featureKey, event.target.checked); }; return ( <Stack direction="row" spacing={2} alignItems="start" sx={{ padding: 2, }} > <Switch checked={checked} onChange={handleChange} /> <Stack> <Typography variant="subtitle1" sx={{ fontWeight: "medium", color: "text.primary", }} > {featureKey} </Typography> <Typography variant="body2" color="text.secondary"> {featureDescription} </Typography> </Stack> </Stack> ); }; export const ExperimentalFeaturesList = () => { const record = useRecordContext(); const notify = useNotify(); const dataProvider = useDataProvider() as SynapseDataProvider; const [features, setFeatures] = useState({}); useEffect(() => { if (!record) return; const fetchFeatures = async () => { const features = await dataProvider.getFeatures(record.id); setFeatures(features); }; fetchFeatures(); }, [dataProvider, record]); if (!record) { return null; } const updateFeature = async (feature_name: string, feature_value: boolean) => { const updatedFeatures = { ...features, [feature_name]: feature_value } as ExperimentalFeaturesModel; setFeatures(updatedFeatures); await dataProvider.updateFeatures(record.id, updatedFeatures); notify("ra.notification.updated", { messageArgs: { smart_count: 1 }, type: "success", }); }; return ( <> <Stack direction="column" spacing={1}> {Object.keys(features).map((featureKey: string) => ( <ExperimentalFeatureRow key={featureKey} featureKey={featureKey} featureValue={features[featureKey]} updateFeature={updateFeature} /> ))} </Stack> </> ); }; export default ExperimentalFeaturesList; ================================================ FILE: src/components/users/ServerNotices.tsx ================================================ import IconCancel from "@mui/icons-material/Cancel"; import MessageIcon from "@mui/icons-material/Message"; import { Dialog, DialogContent, DialogContentText, DialogTitle } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import { Button, RaRecord, SaveButton, SimpleForm, TextInput, Toolbar, ToolbarProps, required, useCreate, useDataProvider, useListContext, useNotify, useRecordContext, useTranslate, useUnselectAll, } from "react-admin"; const ServerNoticeDialog = ({ open, onClose, onSubmit }) => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const translate = useTranslate(); const ServerNoticeToolbar = (props: ToolbarProps & { pristine?: boolean }) => ( <Toolbar {...props}> <SaveButton label="resources.servernotices.action.send" disabled={props.pristine} /> <Button label="ra.action.cancel" onClick={onClose}> <IconCancel /> </Button> </Toolbar> ); return ( <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> <DialogTitle>{translate("resources.servernotices.action.send")}</DialogTitle> <DialogContent> <DialogContentText>{translate("resources.servernotices.helper.send")}</DialogContentText> <SimpleForm toolbar={<ServerNoticeToolbar />} onSubmit={onSubmit}> <TextInput source="body" label="resources.servernotices.fields.body" multiline rows="4" resettable validate={required()} /> </SimpleForm> </DialogContent> </Dialog> ); }; export const ServerNoticeButton = () => { const record = useRecordContext(); const [open, setOpen] = useState(false); const notify = useNotify(); const [create, { isLoading }] = useCreate(); const handleDialogOpen = () => setOpen(true); const handleDialogClose = () => setOpen(false); if (!record) { return null; } const handleSend = (values: Partial<RaRecord>) => { create( "servernotices", { data: { id: record.id, ...values } }, { onSuccess: () => { notify("resources.servernotices.action.send_success"); handleDialogClose(); }, /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ onError: (error: any) => notify(error?.message || "resources.servernotices.action.send_failure", { type: "error", }), } ); }; return ( <> <Button label="resources.servernotices.send" onClick={handleDialogOpen} disabled={isLoading}> <MessageIcon /> </Button> <ServerNoticeDialog open={open} onClose={handleDialogClose} onSubmit={handleSend} /> </> ); }; export const ServerNoticeBulkButton = () => { const { selectedIds } = useListContext(); const [open, setOpen] = useState(false); const openDialog = () => setOpen(true); const closeDialog = () => setOpen(false); const notify = useNotify(); const unselectAllUsers = useUnselectAll("users"); const dataProvider = useDataProvider(); const { mutate: sendNotices, isPending } = useMutation({ mutationFn: data => dataProvider.createMany("servernotices", { ids: selectedIds, data: data, }), onSuccess: () => { notify("resources.servernotices.action.send_success"); unselectAllUsers(); closeDialog(); }, /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ onError: (error: any) => notify(error?.message || "resources.servernotices.action.send_failure", { type: "error", }), }); return ( <> <Button label="resources.servernotices.send" onClick={openDialog} disabled={isPending}> <MessageIcon /> </Button> <ServerNoticeDialog open={open} onClose={closeDialog} onSubmit={sendNotices} /> </> ); }; ================================================ FILE: src/components/users/UserAccountData.tsx ================================================ import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; import { Typography, Box, Stack, Accordion, AccordionSummary, AccordionDetails } from "@mui/material"; import { useEffect, useState } from "react"; import { useDataProvider, useRecordContext, useTranslate } from "react-admin"; import EmptyState from "../layout/EmptyState"; import { SynapseDataProvider } from "../../providers/types"; const UserAccountData = () => { const dataProvider = useDataProvider() as SynapseDataProvider; const record = useRecordContext(); const translate = useTranslate(); const [globalAccountData, setGlobalAccountData] = useState({}); const [roomsAccountData, setRoomsAccountData] = useState({}); useEffect(() => { if (!record) return; const fetchAccountData = async () => { const accountData = await dataProvider.getAccountData(record.id); setGlobalAccountData(accountData.account_data.global); setRoomsAccountData(accountData.account_data.rooms); }; fetchAccountData(); }, [dataProvider, record]); if (!record) { return null; } if (Object.keys(globalAccountData).length === 0 && Object.keys(roomsAccountData).length === 0) { return <EmptyState resource="account_data" />; } return ( <> <Stack direction="column" spacing={2} width="100%"> <Typography variant="h6">{translate("resources.users.account_data.title")}</Typography> <Typography variant="body1" component="div"> <Box> <Accordion> <AccordionSummary expandIcon={<ArrowDownwardIcon />}> <Typography variant="h6">{translate("resources.users.account_data.global")}</Typography> </AccordionSummary> <AccordionDetails> <Box component="pre" sx={{ whiteSpace: "pre-wrap", wordBreak: "break-all", m: 0, p: 2, fontSize: { xs: "0.75rem", sm: "0.85rem" }, bgcolor: "action.hover", borderRadius: 1, overflow: "auto", maxWidth: "100%", }} > {JSON.stringify(globalAccountData, null, 4)} </Box> </AccordionDetails> </Accordion> <Accordion> <AccordionSummary expandIcon={<ArrowDownwardIcon />}> <Typography variant="h6">{translate("resources.users.account_data.rooms")}</Typography> </AccordionSummary> <AccordionDetails> <Box component="pre" sx={{ whiteSpace: "pre-wrap", wordBreak: "break-all", m: 0, p: 2, fontSize: { xs: "0.75rem", sm: "0.85rem" }, bgcolor: "action.hover", borderRadius: 1, overflow: "auto", maxWidth: "100%", }} > {JSON.stringify(roomsAccountData, null, 4)} </Box> </AccordionDetails> </Accordion> </Box> </Typography> </Stack> </> ); }; export default UserAccountData; ================================================ FILE: src/components/users/UserCounts.tsx ================================================ import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; import GavelIcon from "@mui/icons-material/Gavel"; import MailOutlineIcon from "@mui/icons-material/MailOutline"; import MeetingRoomIcon from "@mui/icons-material/MeetingRoom"; import { Box, Chip, Tooltip } from "@mui/material"; import { useEffect, useState } from "react"; import { useDataProvider, useLocale, useRecordContext, useTranslate } from "react-admin"; import { SynapseDataProvider } from "../../providers/types"; import { DATE_FORMAT } from "../../utils/date"; import createLogger from "../../utils/logger"; const log = createLogger("users"); const tooltipSx = { tooltip: { sx: { fontSize: "0.875rem" } } }; const UserInfoChips = () => { const dataProvider = useDataProvider() as SynapseDataProvider; const record = useRecordContext(); const translate = useTranslate(); const locale = useLocale(); const [inviteCount, setInviteCount] = useState<number | null>(null); const [joinedRoomCount, setJoinedRoomCount] = useState<number | null>(null); useEffect(() => { if (!record) return; const fetchCounts = async () => { try { const [invites, rooms] = await Promise.all([ dataProvider.getSentInviteCount(record.id), dataProvider.getCumulativeJoinedRoomCount(record.id), ]); setInviteCount(invites); setJoinedRoomCount(rooms); } catch (error) { log.error("failed to fetch user counts", { id: record.id, error }); } }; fetchCounts(); }, [dataProvider, record]); if (!record) { return null; } const createdDate = record.creation_ts_ms ? new Date(record.creation_ts_ms).toLocaleDateString(locale, DATE_FORMAT) : record.created_at ? new Date(String(record.created_at)).toLocaleDateString(locale, DATE_FORMAT) : null; return ( <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", flexDirection: "column", width: "100%", color: "text.primary" }} > {createdDate && ( <Chip icon={<CalendarTodayIcon />} label={`${translate("resources.users.fields.creation_ts_ms")}: ${createdDate}`} variant="outlined" /> )} {record.consent_version && ( <Chip icon={<GavelIcon />} label={`${translate("resources.users.fields.consent_version")}: ${record.consent_version}`} variant="outlined" /> )} {inviteCount !== null && ( <Tooltip title={translate("resources.users.helper.sent_invite_count")} arrow slotProps={tooltipSx}> <Chip icon={<MailOutlineIcon />} label={`${translate("resources.users.fields.sent_invite_count")}: ${inviteCount}`} variant="outlined" sx={{ cursor: "help" }} /> </Tooltip> )} {joinedRoomCount !== null && ( <Tooltip title={translate("resources.users.helper.cumulative_joined_room_count")} arrow slotProps={tooltipSx}> <Chip icon={<MeetingRoomIcon />} label={`${translate("resources.users.fields.cumulative_joined_room_count")}: ${joinedRoomCount}`} variant="outlined" sx={{ cursor: "help" }} /> </Tooltip> )} </Box> ); }; export default UserInfoChips; ================================================ FILE: src/components/users/UserRateLimits.tsx ================================================ import { Stack, Typography } from "@mui/material"; import { TextField } from "@mui/material"; import { useEffect, useState } from "react"; import { useDataProvider, useRecordContext, useTranslate } from "react-admin"; import { useFormContext } from "react-hook-form"; const RateLimitRow = ({ limit, value, updateRateLimit, }: { limit: string; value: object; updateRateLimit: (limit: string, value: number | null) => void; }) => { const translate = useTranslate(); const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { const value = parseInt(event.target.value); if (isNaN(value)) { updateRateLimit(limit, null); return; } updateRateLimit(limit, value); }; return ( <Stack spacing={1} alignItems="start" sx={{ padding: 2, }} > <TextField type="number" value={value} onChange={handleChange} slotProps={{ inputLabel: { shrink: true, }, }} label={translate(`resources.users.limits.${limit}`)} /> <Stack> <Typography variant="body2" color="text.secondary"> {translate(`resources.users.limits.${limit}_text`)} </Typography> </Stack> </Stack> ); }; const UserRateLimits = () => { const record = useRecordContext(); const form = useFormContext(); const dataProvider = useDataProvider(); const [rateLimits, setRateLimits] = useState({ messages_per_second: "", // we are setting string here to make the number field empty by default, null is prohibited by the field validation burst_count: "", }); useEffect(() => { if (!record) return; const fetchRateLimits = async () => { const rateLimits = await dataProvider.getRateLimits(record.id); if (Object.keys(rateLimits).length > 0) { setRateLimits(rateLimits); } }; fetchRateLimits(); }, [dataProvider, record]); if (!record) { return null; } const updateRateLimit = async (limit: string, value: number | null) => { const updatedRateLimits = { ...rateLimits, [limit]: value }; setRateLimits(updatedRateLimits); form.setValue(`rates.${limit}`, value, { shouldDirty: true }); }; return ( <> <Stack direction="column"> {Object.keys(rateLimits).map((limit: string) => ( <RateLimitRow key={limit} limit={limit} value={rateLimits[limit]} updateRateLimit={updateRateLimit} /> ))} </Stack> </> ); }; export default UserRateLimits; ================================================ FILE: src/components/users/buttons/AllowCrossSigningButton.tsx ================================================ import ActionCheck from "@mui/icons-material/CheckCircle"; import AlertError from "@mui/icons-material/ErrorOutline"; import VpnKeyIcon from "@mui/icons-material/VpnKey"; import { Button as MuiButton, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; import { Button, useDataProvider, useLocale, useNotify, useRecordContext, useTranslate } from "react-admin"; import { SynapseDataProvider } from "../../../providers/types"; export const AllowCrossSigningButton = () => { const record = useRecordContext(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const notify = useNotify(); const translate = useTranslate(); const locale = useLocale(); const dataProvider = useDataProvider() as SynapseDataProvider; const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); if (!record) return null; const handleConfirm = async () => { setLoading(true); try { const result = await dataProvider.allowCrossSigningReplacement(record.id as string); if (result.success && result.updatable_without_uia_before_ms) { const deadline = new Date(result.updatable_without_uia_before_ms).toLocaleString(locale); notify(translate("resources.users.action.allow_cross_signing.success", { deadline }), { type: "success" }); setOpen(false); } else if (result.errcode === "M_NOT_FOUND") { notify("resources.users.action.allow_cross_signing.no_key", { type: "warning" }); } else { notify(result.error || "resources.users.action.allow_cross_signing.failure", { type: "error" }); } } catch { notify("resources.users.action.allow_cross_signing.failure", { type: "error" }); } finally { setLoading(false); } }; return ( <> <Button label="resources.users.action.allow_cross_signing.label" onClick={() => setOpen(true)} disabled={loading}> <VpnKeyIcon /> </Button> <Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth fullScreen={fullScreen}> <DialogTitle>{translate("resources.users.action.allow_cross_signing.title")}</DialogTitle> <DialogContent> <DialogContentText> {translate("resources.users.action.allow_cross_signing.content", { user: record.id })} </DialogContentText> </DialogContent> <DialogActions> <MuiButton onClick={() => setOpen(false)} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton onClick={handleConfirm} disabled={loading} className="ra-confirm RaConfirm-confirmPrimary" startIcon={<ActionCheck />} > {translate("ra.action.confirm")} </MuiButton> </DialogActions> </Dialog> </> ); }; export default AllowCrossSigningButton; ================================================ FILE: src/components/users/buttons/BlockRoomButton.tsx ================================================ import ActionCheck from "@mui/icons-material/CheckCircle"; import AlertError from "@mui/icons-material/ErrorOutline"; import BlockIcon from "@mui/icons-material/Block"; import { Button as MuiButton, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, TextField, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useEffect, useId, useState } from "react"; import { Button, useDataProvider, useListContext, useNotify, useRecordContext, useRefresh, useTranslate, useUnselectAll, } from "react-admin"; import { SynapseDataProvider } from "../../../providers/types"; /** * Single room block/unblock button for the room show page. * Fetches block status on mount, shows "Block" or "Unblock" accordingly. * Block requires confirmation modal, unblock is direct. */ export const BlockRoomButton = () => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const record = useRecordContext(); const [open, setOpen] = useState(false); const [blocked, setBlocked] = useState<boolean | null>(null); const [loading, setLoading] = useState(false); const notify = useNotify(); const refresh = useRefresh(); const translate = useTranslate(); const dataProvider = useDataProvider() as SynapseDataProvider; const titleId = useId(); useEffect(() => { if (!record?.id) return; dataProvider.getRoomBlockStatus(record.id as string).then(result => { if (result.success) { setBlocked(result.block); } }); }, [record?.id, dataProvider]); if (!record || blocked === null) { return null; } const roomName = (record.name || record.canonical_alias || record.id) as string; const handleBlock = async () => { setLoading(true); try { const result = await dataProvider.blockRoom(record.id as string, true); if (result.success) { notify("resources.rooms.action.block.success", { messageArgs: { smart_count: 1 } }); setBlocked(true); setOpen(false); refresh(); } else { notify(result.error || "resources.rooms.action.block.failure", { type: "error", messageArgs: { smart_count: 1 }, }); } } catch { notify("resources.rooms.action.block.failure", { type: "error", messageArgs: { smart_count: 1 } }); } finally { setLoading(false); } }; const handleUnblock = async () => { setLoading(true); try { const result = await dataProvider.blockRoom(record.id as string, false); if (result.success) { notify("resources.rooms.action.unblock.success", { messageArgs: { smart_count: 1 } }); setBlocked(false); refresh(); } else { notify(result.error || "resources.rooms.action.unblock.failure", { type: "error", messageArgs: { smart_count: 1 }, }); } } catch { notify("resources.rooms.action.unblock.failure", { type: "error", messageArgs: { smart_count: 1 } }); } finally { setLoading(false); } }; if (blocked) { return ( <Button label="resources.rooms.action.unblock.label" onClick={handleUnblock} disabled={loading}> <BlockIcon /> </Button> ); } return ( <> <Button label="resources.rooms.action.block.label" onClick={() => setOpen(true)} disabled={loading}> <BlockIcon /> </Button> <Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth fullScreen={fullScreen} aria-labelledby={titleId} > <DialogTitle id={titleId}>{translate("resources.rooms.action.block.title", { room: roomName })}</DialogTitle> <DialogContent> <DialogContentText>{translate("resources.rooms.action.block.content")}</DialogContentText> </DialogContent> <DialogActions> <MuiButton onClick={() => setOpen(false)} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton onClick={handleBlock} disabled={loading} className="ra-confirm RaConfirm-confirmPrimary" autoFocus startIcon={<ActionCheck />} > {translate("ra.action.confirm")} </MuiButton> </DialogActions> </Dialog> </> ); }; /** * Bulk block/unblock buttons for room lists (main room list + joined_rooms). */ export const BlockRoomBulkButton = () => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const { selectedIds } = useListContext(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const notify = useNotify(); const refresh = useRefresh(); const translate = useTranslate(); const unselectAll = useUnselectAll("rooms"); const dataProvider = useDataProvider() as SynapseDataProvider; const titleId = useId(); const handleBlock = async () => { setLoading(true); try { await Promise.all(selectedIds.map(id => dataProvider.blockRoom(id as string, true))); notify("resources.rooms.action.block.success", { messageArgs: { smart_count: selectedIds.length } }); setOpen(false); unselectAll(); refresh(); } catch { notify("resources.rooms.action.block.failure", { type: "error", messageArgs: { smart_count: selectedIds.length }, }); } finally { setLoading(false); } }; return ( <> <Button label="resources.rooms.action.block.label" onClick={() => setOpen(true)} disabled={loading}> <BlockIcon /> </Button> <Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth fullScreen={fullScreen} aria-labelledby={titleId} > <DialogTitle id={titleId}> {translate("resources.rooms.action.block.title_bulk", { smart_count: selectedIds.length })} </DialogTitle> <DialogContent> <DialogContentText> {translate("resources.rooms.action.block.content_bulk", { smart_count: selectedIds.length })} </DialogContentText> </DialogContent> <DialogActions> <MuiButton onClick={() => setOpen(false)} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton onClick={handleBlock} disabled={loading} className="ra-confirm RaConfirm-confirmPrimary" autoFocus startIcon={<ActionCheck />} > {translate("ra.action.confirm")} </MuiButton> </DialogActions> </Dialog> </> ); }; export const UnblockRoomBulkButton = () => { const { selectedIds } = useListContext(); const [loading, setLoading] = useState(false); const notify = useNotify(); const refresh = useRefresh(); const unselectAll = useUnselectAll("rooms"); const dataProvider = useDataProvider() as SynapseDataProvider; const handleUnblock = async () => { setLoading(true); try { await Promise.all(selectedIds.map(id => dataProvider.blockRoom(id as string, false))); notify("resources.rooms.action.unblock.success", { messageArgs: { smart_count: selectedIds.length } }); unselectAll(); refresh(); } catch { notify("resources.rooms.action.unblock.failure", { type: "error", messageArgs: { smart_count: selectedIds.length }, }); } finally { setLoading(false); } }; return ( <Button label="resources.rooms.action.unblock.label" onClick={handleUnblock} disabled={loading}> <BlockIcon /> </Button> ); }; /** * Toolbar button above the main room list to block a room by ID. */ export const BlockRoomByIdButton = () => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const [open, setOpen] = useState(false); const [roomId, setRoomId] = useState(""); const [loading, setLoading] = useState(false); const notify = useNotify(); const translate = useTranslate(); const dataProvider = useDataProvider() as SynapseDataProvider; const titleId = useId(); const handleBlock = async () => { if (!roomId) return; setLoading(true); try { const result = await dataProvider.blockRoom(roomId, true); if (result.success) { notify("resources.rooms.action.block.success", { messageArgs: { smart_count: 1 } }); setOpen(false); setRoomId(""); } else { notify(result.error || "resources.rooms.action.block.failure", { type: "error", messageArgs: { smart_count: 1 }, }); } } catch { notify("resources.rooms.action.block.failure", { type: "error", messageArgs: { smart_count: 1 } }); } finally { setLoading(false); } }; return ( <> <Button label="resources.rooms.action.block.label" onClick={() => setOpen(true)}> <BlockIcon /> </Button> <Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth fullScreen={fullScreen} aria-labelledby={titleId} > <DialogTitle id={titleId}>{translate("resources.rooms.action.block.title_by_id")}</DialogTitle> <DialogContent> <DialogContentText sx={{ mb: 2 }}>{translate("resources.rooms.action.block.content")}</DialogContentText> <TextField autoFocus fullWidth label={translate("resources.rooms.fields.room_id")} value={roomId} onChange={e => setRoomId(e.target.value)} /> </DialogContent> <DialogActions> <MuiButton onClick={() => setOpen(false)} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton onClick={handleBlock} disabled={!roomId || loading} className="ra-confirm RaConfirm-confirmPrimary" autoFocus startIcon={<ActionCheck />} > {translate("ra.action.confirm")} </MuiButton> </DialogActions> </Dialog> </> ); }; ================================================ FILE: src/components/users/buttons/DeleteAllMediaButton.tsx ================================================ import ActionCheck from "@mui/icons-material/CheckCircle"; import DeleteForeverIcon from "@mui/icons-material/DeleteForever"; import AlertError from "@mui/icons-material/ErrorOutline"; import { Box, Button as MuiButton, CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Typography, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useId, useState } from "react"; import { Button, useDataProvider, useListContext, useNotify, useRecordContext, useRefresh, useTranslate, useUnselectAll, } from "react-admin"; import { SynapseDataProvider } from "../../../providers/types"; type DeletionStatus = "idle" | "active" | "done"; /** * Delete all media uploaded by a single user. * Shows a confirmation dialog with a spinner while running. */ export const DeleteUserMediaButton = () => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const record = useRecordContext(); const [open, setOpen] = useState(false); const [status, setStatus] = useState<DeletionStatus>("idle"); const notify = useNotify(); const translate = useTranslate(); const dataProvider = useDataProvider() as SynapseDataProvider; const titleId = useId(); if (!record) return null; const userName = (record.displayname || record.id) as string; const handleClose = () => { setOpen(false); if (status !== "active") setStatus("idle"); }; const handleDelete = async () => { setStatus("active"); try { const result = await dataProvider.deleteUserMedia(record.id); notify("resources.users.action.delete_all_media.success", { type: "success", messageArgs: { smart_count: result.total }, }); setOpen(false); setStatus("idle"); } catch (e) { setStatus("idle"); notify("resources.users.action.delete_all_media.failure", { type: "error", messageArgs: { errMsg: e instanceof Error ? e.message : String(e) }, }); } }; return ( <> <Button label="resources.users.action.delete_all_media.label" onClick={() => setOpen(true)} disabled={status === "active"} color="error" > <DeleteForeverIcon /> </Button> <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth fullScreen={fullScreen} aria-labelledby={titleId} > <DialogTitle id={titleId}> {translate("resources.users.action.delete_all_media.title", { userName })} </DialogTitle> <DialogContent> <DialogContentText>{translate("resources.users.action.delete_all_media.content")}</DialogContentText> {status === "active" && ( <> <Box sx={{ display: "flex", alignItems: "center", gap: 1, mt: 2 }} aria-live="polite"> <CircularProgress size={16} role="status" aria-label={translate("ra.message.loading")} /> <Typography variant="body2" color="text.secondary"> {translate("resources.users.action.delete_all_media.in_progress")} </Typography> </Box> <DialogContentText sx={{ mt: 1, fontStyle: "italic" }} color="text.secondary"> {translate("resources.users.action.delete_all_media.background_note")} </DialogContentText> </> )} </DialogContent> <DialogActions> <MuiButton onClick={handleClose} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton onClick={handleDelete} disabled={status !== "idle"} aria-busy={status === "active"} className="ra-confirm RaConfirm-confirmPrimary" autoFocus startIcon={status === "active" ? <CircularProgress size={16} aria-hidden="true" /> : <ActionCheck />} > {translate("ra.action.confirm")} </MuiButton> </DialogActions> </Dialog> </> ); }; /** * Bulk delete all media for selected users. */ export const DeleteUserMediaBulkButton = () => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const { selectedIds } = useListContext(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const notify = useNotify(); const refresh = useRefresh(); const translate = useTranslate(); const unselectAll = useUnselectAll("users"); const dataProvider = useDataProvider() as SynapseDataProvider; const titleId = useId(); const handleDelete = async () => { setLoading(true); const results = await Promise.allSettled(selectedIds.map(id => dataProvider.deleteUserMedia(id))); const success = results.filter(r => r.status === "fulfilled").length; const failed = results.filter(r => r.status === "rejected").length; setLoading(false); setOpen(false); if (failed === 0) { notify("resources.users.action.delete_all_media_bulk.success", { messageArgs: { success, total: selectedIds.length }, }); } else { notify("resources.users.action.delete_all_media_bulk.partial_failure", { type: "error", messageArgs: { success, failed, total: selectedIds.length }, }); } unselectAll(); refresh(); }; return ( <> <Button label="resources.users.action.delete_all_media.label" onClick={() => setOpen(true)} color="error"> <DeleteForeverIcon /> </Button> <Dialog open={open} onClose={() => !loading && setOpen(false)} disableEscapeKeyDown={loading} maxWidth="sm" fullWidth fullScreen={fullScreen} aria-labelledby={titleId} > <DialogTitle id={titleId}> {translate("resources.users.action.delete_all_media_bulk.title", { smart_count: selectedIds.length, })} </DialogTitle> <DialogContent> <DialogContentText>{translate("resources.users.action.delete_all_media_bulk.content")}</DialogContentText> {loading && ( <Box sx={{ display: "flex", alignItems: "center", gap: 1, mt: 2 }} aria-live="polite"> <CircularProgress size={16} role="status" aria-label={translate("ra.message.loading")} /> <Typography variant="body2" color="text.secondary"> {translate("resources.users.action.delete_all_media.in_progress")} </Typography> </Box> )} </DialogContent> <DialogActions> <MuiButton onClick={() => setOpen(false)} disabled={loading} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton onClick={handleDelete} disabled={loading} aria-busy={loading} className="ra-confirm RaConfirm-confirmPrimary" autoFocus startIcon={loading ? <CircularProgress size={16} aria-hidden="true" /> : <ActionCheck />} > {translate("ra.action.confirm")} </MuiButton> </DialogActions> </Dialog> </> ); }; /** * Delete all local media in a single room. * Only renders for unencrypted rooms (record.encryption is falsy). * Shows a dialog with live per-item progress counter. */ export const DeleteRoomMediaButton = () => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const record = useRecordContext(); const [open, setOpen] = useState(false); const [status, setStatus] = useState<DeletionStatus>("idle"); const [progress, setProgress] = useState<{ current: number; total: number } | null>(null); const notify = useNotify(); const translate = useTranslate(); const dataProvider = useDataProvider() as SynapseDataProvider; const titleId = useId(); if (!record || record.encryption) return null; const roomName = (record.name || record.canonical_alias || record.id) as string; const handleClose = () => { if (status === "active") return; setOpen(false); setStatus("idle"); setProgress(null); }; const handleDelete = async () => { setStatus("active"); setProgress(null); try { const result = await dataProvider.deleteRoomMedia(record.id as string, (current, total) => { setProgress({ current, total }); }); notify("resources.rooms.action.delete_all_media.success", { type: "success", messageArgs: { smart_count: result.total }, }); setOpen(false); setStatus("idle"); setProgress(null); } catch (e) { setStatus("idle"); setProgress(null); notify("resources.rooms.action.delete_all_media.failure", { type: "error", messageArgs: { errMsg: e instanceof Error ? e.message : String(e) }, }); } }; const progressLabel = progress === null || progress.total === 0 ? translate("resources.rooms.action.delete_all_media.in_progress_loading") : translate("resources.rooms.action.delete_all_media.in_progress", { current: progress.current, total: progress.total, }); return ( <> <Button label="resources.rooms.action.delete_all_media.label" onClick={() => setOpen(true)} color="error"> <DeleteForeverIcon /> </Button> <Dialog open={open} onClose={handleClose} disableEscapeKeyDown={status === "active"} maxWidth="sm" fullWidth fullScreen={fullScreen} aria-labelledby={titleId} > <DialogTitle id={titleId}> {translate("resources.rooms.action.delete_all_media.title", { roomName })} </DialogTitle> <DialogContent> <DialogContentText>{translate("resources.rooms.action.delete_all_media.content")}</DialogContentText> {status === "active" && ( <> <Box sx={{ display: "flex", alignItems: "center", gap: 1, mt: 2 }} aria-live="polite"> <CircularProgress size={16} role="status" aria-label={translate("ra.message.loading")} /> <Typography variant="body2" color="text.secondary"> {progressLabel} </Typography> </Box> <DialogContentText sx={{ mt: 1, fontStyle: "italic" }} color="warning.main"> {translate("resources.rooms.action.delete_all_media.do_not_close")} </DialogContentText> </> )} </DialogContent> <DialogActions> <MuiButton onClick={handleClose} disabled={status === "active"} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton onClick={handleDelete} disabled={status !== "idle"} aria-busy={status === "active"} className="ra-confirm RaConfirm-confirmPrimary" autoFocus startIcon={status === "active" ? <CircularProgress size={16} aria-hidden="true" /> : <ActionCheck />} > {translate("ra.action.confirm")} </MuiButton> </DialogActions> </Dialog> </> ); }; /** * Bulk delete all local media for selected rooms (skips encrypted rooms at the API level). */ export const DeleteRoomMediaBulkButton = () => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const { selectedIds } = useListContext(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const notify = useNotify(); const refresh = useRefresh(); const translate = useTranslate(); const unselectAll = useUnselectAll("rooms"); const dataProvider = useDataProvider() as SynapseDataProvider; const titleId = useId(); const handleDelete = async () => { setLoading(true); const results = await Promise.allSettled(selectedIds.map(id => dataProvider.deleteRoomMedia(id as string))); const success = results.filter(r => r.status === "fulfilled").length; const failed = results.filter(r => r.status === "rejected").length; setLoading(false); setOpen(false); if (failed === 0) { notify("resources.rooms.action.delete_all_media_bulk.success", { messageArgs: { success, total: selectedIds.length }, }); } else { notify("resources.rooms.action.delete_all_media_bulk.partial_failure", { type: "error", messageArgs: { success, failed, total: selectedIds.length }, }); } unselectAll(); refresh(); }; return ( <> <Button label="resources.rooms.action.delete_all_media.label" onClick={() => setOpen(true)} color="error"> <DeleteForeverIcon /> </Button> <Dialog open={open} onClose={() => !loading && setOpen(false)} disableEscapeKeyDown={loading} maxWidth="sm" fullWidth fullScreen={fullScreen} aria-labelledby={titleId} > <DialogTitle id={titleId}> {translate("resources.rooms.action.delete_all_media_bulk.title", { smart_count: selectedIds.length, })} </DialogTitle> <DialogContent> <DialogContentText>{translate("resources.rooms.action.delete_all_media_bulk.content")}</DialogContentText> {loading && ( <Box sx={{ display: "flex", alignItems: "center", gap: 1, mt: 2 }} aria-live="polite"> <CircularProgress size={16} role="status" aria-label={translate("ra.message.loading")} /> <Typography variant="body2" color="text.secondary"> {translate("resources.rooms.action.delete_all_media.in_progress_loading")} </Typography> </Box> )} </DialogContent> <DialogActions> <MuiButton onClick={() => setOpen(false)} disabled={loading} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton onClick={handleDelete} disabled={loading} aria-busy={loading} className="ra-confirm RaConfirm-confirmPrimary" autoFocus startIcon={loading ? <CircularProgress size={16} aria-hidden="true" /> : <ActionCheck />} > {translate("ra.action.confirm")} </MuiButton> </DialogActions> </Dialog> </> ); }; ================================================ FILE: src/components/users/buttons/DeleteRoomButton.tsx ================================================ import ActionCheck from "@mui/icons-material/CheckCircle"; import ActionDelete from "@mui/icons-material/Delete"; import AlertError from "@mui/icons-material/ErrorOutline"; import { Box, Button as MuiButton, CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { Fragment, useCallback, useEffect, useId, useRef, useState } from "react"; import { Button, SimpleForm, BooleanInput, useTranslate, useNotify, useRedirect, NotificationType, useDataProvider, Identifier, useUnselectAll, useRecordContext, useResourceContext, } from "react-admin"; import { SynapseDataProvider } from "../../../providers/types"; interface DeleteRoomButtonProps { selectedIds: Identifier[]; confirmTitle: string; confirmContent: string; } const resourceName = "rooms"; const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = props => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const translate = useTranslate(); const titleId = useId(); const [open, setOpen] = useState(false); const [block, setBlock] = useState(false); const [deleteStatus, setDeleteStatus] = useState<null | "active" | "done">(null); const notify = useNotify(); const redirect = useRedirect(); const dataProvider = useDataProvider() as SynapseDataProvider; const unselectAll = useUnselectAll(resourceName); const recordIds = props.selectedIds; const record = useRecordContext(); const resource = useResourceContext(); const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); let redirectTo = "/rooms"; if (resource === "joined_rooms" && record?.id) { redirectTo = `/users/${encodeURIComponent(record.id)}/rooms`; } const stopPolling = useCallback(() => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }, []); useEffect(() => { return stopPolling; }, [stopPolling]); const handleDialogOpen = () => setOpen(true); const handleDialogClose = () => { if (deleteStatus === "active") { // Deletion continues server-side; just close the dialog stopPolling(); setDeleteStatus(null); setOpen(false); notify("resources.rooms.action.erase.background_note", { type: "info" as NotificationType }); return; } setOpen(false); setDeleteStatus(null); }; const handleConfirm = async () => { setDeleteStatus("active"); try { const results = await Promise.all(recordIds.map(id => dataProvider.deleteRoom(id as string, block))); const deleteIds = results.filter(r => r.success && r.delete_id).map(r => r.delete_id!); const failedImmediately = results.filter(r => !r.success); if (failedImmediately.length > 0) { notify("resources.rooms.action.erase.failure", { type: "error" as NotificationType }); } if (deleteIds.length === 0) { setDeleteStatus(null); if (failedImmediately.length === 0) { // All succeeded without delete_ids (shouldn't happen, but handle gracefully) notify("resources.rooms.action.erase.success"); setOpen(false); unselectAll(); redirect(redirectTo); } return; } const pending = new Set(deleteIds); pollRef.current = setInterval(async () => { const statuses = await Promise.all( [...pending].map(async deleteId => { const status = await dataProvider.getRoomDeleteStatus(deleteId); return { deleteId, ...status }; }) ); for (const s of statuses) { if (s.status === "complete") { pending.delete(s.deleteId); } else if (s.status === "failed") { pending.delete(s.deleteId); } } if (pending.size === 0) { stopPolling(); setDeleteStatus("done"); setOpen(false); const failed = statuses.filter(s => s.status === "failed"); if (failed.length > 0) { notify("resources.rooms.action.erase.failure", { type: "error" as NotificationType }); } else { notify("resources.rooms.action.erase.success"); } unselectAll(); redirect(redirectTo); } }, 3000); } catch { stopPolling(); setDeleteStatus(null); notify("resources.rooms.action.erase.failure", { type: "error" as NotificationType }); } }; const loading = deleteStatus === "active"; return ( <Fragment> <Button label="ra.action.delete" onClick={handleDialogOpen} disabled={loading} className={"ra-delete-button"} key="button" color={"error"} > <ActionDelete /> </Button> <Dialog open={open} onClose={handleDialogClose} maxWidth="sm" fullWidth fullScreen={fullScreen} aria-labelledby={titleId} > <DialogTitle id={titleId}>{translate(props.confirmTitle)}</DialogTitle> <DialogContent> <DialogContentText>{translate(props.confirmContent)}</DialogContentText> <SimpleForm toolbar={false}> <BooleanInput source="block" value={block} onChange={(event: React.ChangeEvent<HTMLInputElement>) => setBlock(event.target.checked)} label="resources.rooms.action.erase.fields.block" defaultValue={false} disabled={loading} /> </SimpleForm> {deleteStatus === "active" && ( <> <Box sx={{ mt: 1, display: "flex", alignItems: "center", gap: 1 }}> <CircularProgress size={16} role="status" aria-label={translate("ra.message.loading")} /> {translate("resources.rooms.action.erase.in_progress")} </Box> <DialogContentText sx={{ mt: 1 }}> {translate("resources.rooms.action.erase.background_note")} </DialogContentText> </> )} </DialogContent> <DialogActions> <MuiButton onClick={handleDialogClose} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton disabled={loading} aria-busy={loading} onClick={handleConfirm} className={"ra-confirm RaConfirm-confirmPrimary"} autoFocus startIcon={loading ? <CircularProgress size={16} aria-hidden="true" /> : <ActionCheck />} > {translate("ra.action.confirm")} </MuiButton> </DialogActions> </Dialog> </Fragment> ); }; export default DeleteRoomButton; ================================================ FILE: src/components/users/buttons/DeleteUserButton.tsx ================================================ import ActionCheck from "@mui/icons-material/CheckCircle"; import ActionDelete from "@mui/icons-material/Delete"; import AlertError from "@mui/icons-material/ErrorOutline"; import { Box, Button as MuiButton, CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { Fragment, useCallback, useEffect, useId, useRef, useState } from "react"; import { Button, SimpleForm, BooleanInput, useTranslate, useNotify, useRedirect, NotificationType, useDeleteMany, useDataProvider, Identifier, useUnselectAll, } from "react-admin"; import { SynapseDataProvider } from "../../../providers/types"; import { isMAS } from "../../../providers/data/mas"; interface DeleteUserButtonProps { selectedIds: Identifier[]; confirmTitle: string; confirmContent: string; /** MXID → mas_id mapping; required for MAS bulk deactivation. Callers must resolve this from their own context. */ masIdMap?: Record<string, string>; } const resourceName = "users"; const DeleteUserButton: React.FC<DeleteUserButtonProps> = props => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const translate = useTranslate(); const titleId = useId(); const [open, setOpen] = useState(false); const [deleteMedia, setDeleteMedia] = useState(false); const [redactEvents, setRedactEvents] = useState(false); const [redactStatus, setRedactStatus] = useState<null | "active" | "done">(null); const notify = useNotify(); const redirect = useRedirect(); const dataProvider = useDataProvider() as SynapseDataProvider; const [deleteMany, { isLoading }] = useDeleteMany(); const unselectAll = useUnselectAll(resourceName); const recordIds = props.selectedIds; const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); const stopPolling = useCallback(() => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }, []); useEffect(() => { return stopPolling; }, [stopPolling]); const handleDialogOpen = () => setOpen(true); const performMASDeactivate = async () => { try { const masIds = recordIds.map(id => { const masId = props.masIdMap?.[String(id)]; return masId ?? String(id); }); await Promise.all(masIds.map(masId => dataProvider.masDeactivateUser(masId, false))); notify("ra.notification.deleted", { messageArgs: { smart_count: recordIds.length }, type: "info" as NotificationType, }); unselectAll(); redirect("/users"); } catch { notify("ra.notification.data_provider_error", { type: "error" as NotificationType }); } }; const performDelete = async () => { if (isMAS()) { await performMASDeactivate(); return; } deleteMany( resourceName, { ids: recordIds, meta: { deleteMedia, redactEvents: false } }, { onSuccess: () => { notify("ra.notification.deleted", { messageArgs: { smart_count: recordIds.length }, type: "info" as NotificationType, }); unselectAll(); redirect("/users"); }, /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ onError: (error: any) => notify(error?.message || "ra.notification.data_provider_error", { type: "error" as NotificationType }), } ); }; const handleDialogClose = async () => { if (redactStatus === "active") { // Redaction continues server-side; proceed with delete so the user is erased stopPolling(); setRedactStatus(null); setOpen(false); await performDelete(); return; } setOpen(false); setRedactStatus(null); }; const handleConfirm = async () => { if (!redactEvents) { setOpen(false); await performDelete(); return; } // Redact events first, then poll, then delete setRedactStatus("active"); try { const results = await Promise.all(recordIds.map(id => dataProvider.redactUserEvents(id))); const redactIds = results.map(r => r.redact_id); const pending = new Set(redactIds); let totalFailed = 0; pollRef.current = setInterval(async () => { const statuses = await Promise.all( [...pending].map(async redactId => { const status = await dataProvider.getRedactStatus(redactId); return { redactId, ...status }; }) ); for (const s of statuses) { if (s.status === "complete") { totalFailed += Object.keys(s.failed_redactions).length; pending.delete(s.redactId); } else if (s.status === "failed") { pending.delete(s.redactId); } } if (pending.size === 0) { stopPolling(); setRedactStatus("done"); setOpen(false); if (totalFailed > 0) { notify("resources.users.action.redact_failure", { type: "warning" as NotificationType, messageArgs: { smart_count: totalFailed }, }); } else { notify("resources.users.action.redact_success", { type: "success" as NotificationType }); } await performDelete(); } }, 3000); } catch { stopPolling(); setRedactStatus(null); notify("ra.notification.data_provider_error", { type: "error" as NotificationType }); } }; const loading = isLoading || redactStatus === "active"; return ( <Fragment> <Button label="ra.action.delete" onClick={handleDialogOpen} disabled={loading} className={"ra-delete-button"} key="button" color={"error"} > <ActionDelete /> </Button> <Dialog open={open} onClose={handleDialogClose} maxWidth="sm" fullWidth fullScreen={fullScreen} aria-labelledby={titleId} > <DialogTitle id={titleId}>{translate(props.confirmTitle)}</DialogTitle> <DialogContent> <DialogContentText>{translate(props.confirmContent)}</DialogContentText> <SimpleForm toolbar={false}> {!isMAS() && ( <> <BooleanInput source="deleteMedia" value={deleteMedia} onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDeleteMedia(event.target.checked)} label="resources.users.action.delete_media" defaultValue={false} disabled={loading} /> <BooleanInput source="redactEvents" value={redactEvents} onChange={(event: React.ChangeEvent<HTMLInputElement>) => setRedactEvents(event.target.checked)} label="resources.users.action.redact_events" defaultValue={false} disabled={loading} /> </> )} </SimpleForm> {redactStatus === "active" && ( <> <Box sx={{ mt: 1, display: "flex", alignItems: "center", gap: 1 }}> <CircularProgress size={16} role="status" aria-label={translate("ra.message.loading")} /> {translate("resources.users.action.redact_in_progress")} </Box> <DialogContentText sx={{ mt: 1 }}> {translate("resources.users.action.redact_background_note")} </DialogContentText> </> )} </DialogContent> <DialogActions> <MuiButton onClick={handleDialogClose} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton disabled={loading} aria-busy={loading} onClick={handleConfirm} className={"ra-confirm RaConfirm-confirmPrimary"} autoFocus startIcon={loading ? <CircularProgress size={16} aria-hidden="true" /> : <ActionCheck />} > {translate("ra.action.confirm")} </MuiButton> </DialogActions> </Dialog> </Fragment> ); }; export default DeleteUserButton; ================================================ FILE: src/components/users/buttons/DeviceCreateButton.tsx ================================================ import ActionCheck from "@mui/icons-material/CheckCircle"; import AddIcon from "@mui/icons-material/Add"; import AlertError from "@mui/icons-material/ErrorOutline"; import { Button as MuiButton, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; import { useNotify, useRecordContext, useRefresh, useTranslate } from "react-admin"; import { jsonClient } from "../../../providers/http"; import { invalidateManyRefCache } from "../../../resourceMap"; const DeviceCreateButton = () => { const record = useRecordContext(); const [open, setOpen] = useState(false); const [deviceId, setDeviceId] = useState(""); const [loading, setLoading] = useState(false); const notify = useNotify(); const translate = useTranslate(); const refresh = useRefresh(); const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); if (!record) return null; const handleClose = () => { setOpen(false); setDeviceId(""); }; const handleConfirm = async () => { if (!deviceId.trim()) return; setLoading(true); try { const base_url = localStorage.getItem("base_url"); await jsonClient(`${base_url}/_synapse/admin/v2/users/${encodeURIComponent(record.id as string)}/devices`, { method: "POST", body: JSON.stringify({ device_id: deviceId.trim() }), }); invalidateManyRefCache("devices"); notify("resources.devices.action.create.success", { type: "success" }); handleClose(); refresh(); } catch { notify("resources.devices.action.create.failure", { type: "error" }); } finally { setLoading(false); } }; return ( <> <MuiButton variant="outlined" size="small" startIcon={<AddIcon />} onClick={() => setOpen(true)} fullWidth={fullScreen} > {translate("resources.devices.action.create.label")} </MuiButton> <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> <DialogTitle>{translate("resources.devices.action.create.title")}</DialogTitle> <DialogContent> <TextField autoFocus fullWidth label={translate("resources.devices.fields.device_id")} value={deviceId} onChange={e => setDeviceId(e.target.value)} onKeyDown={e => { if (e.key === "Enter" && deviceId.trim()) handleConfirm(); }} sx={{ mt: 1 }} /> </DialogContent> <DialogActions> <MuiButton onClick={handleClose} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton onClick={handleConfirm} disabled={!deviceId.trim() || loading} className="ra-confirm RaConfirm-confirmPrimary" startIcon={<ActionCheck />} > {translate("ra.action.create")} </MuiButton> </DialogActions> </Dialog> </> ); }; export default DeviceCreateButton; ================================================ FILE: src/components/users/buttons/DeviceRemoveButton.tsx ================================================ import DeleteIcon from "@mui/icons-material/Delete"; import { useState } from "react"; import { Button, Confirm, DeleteWithConfirmButton, DeleteWithConfirmButtonProps, useDataProvider, useListContext, useNotify, useRecordContext, useRefresh, useTranslate, useUnselectAll, } from "react-admin"; import { SynapseDataProvider } from "../../../providers/types"; import { isSystemUser } from "../../../utils/mxid"; export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => { const record = useRecordContext(); if (!record) return null; let systemUserFlag = false; if (record.user_id) { systemUserFlag = isSystemUser(record.user_id); } return ( <DeleteWithConfirmButton {...props} label="ra.action.remove" confirmTitle="resources.devices.action.erase.title" confirmContent="resources.devices.action.erase.content" mutationMode="pessimistic" redirect={false} disabled={systemUserFlag} titleTranslateOptions={{ id: record.id, name: record.display_name ? record.display_name : record.id, }} /> ); }; export const DeviceBulkRemoveButton = () => { const { data, selectedIds } = useListContext(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const notify = useNotify(); const refresh = useRefresh(); const translate = useTranslate(); const unselectAll = useUnselectAll("devices"); const dataProvider = useDataProvider() as SynapseDataProvider; if (!data || data.length === 0) return null; const userId = data[0]?.user_id; if (!userId || isSystemUser(userId)) return null; const handleConfirm = async () => { setLoading(true); try { const result = await dataProvider.deleteDevices(userId, selectedIds as string[]); if (result.success) { notify("resources.devices.action.erase.success", { type: "success" }); unselectAll(); refresh(); } else { notify(result.error || "resources.devices.action.erase.failure", { type: "error" }); } } catch { notify("resources.devices.action.erase.failure", { type: "error" }); } finally { setLoading(false); setOpen(false); } }; return ( <> <Button label="ra.action.delete" onClick={() => setOpen(true)} disabled={loading}> <DeleteIcon /> </Button> <Confirm isOpen={open} onConfirm={handleConfirm} onClose={() => setOpen(false)} title={translate("resources.devices.action.erase.title_bulk", { smart_count: selectedIds.length })} content={translate("resources.devices.action.erase.content_bulk", { smart_count: selectedIds.length })} confirm="ra.action.confirm" cancel="ra.action.cancel" /> </> ); }; export default DeviceRemoveButton; ================================================ FILE: src/components/users/buttons/FindUserButton.tsx ================================================ import ActionCheck from "@mui/icons-material/CheckCircle"; import AlertError from "@mui/icons-material/ErrorOutline"; import PersonSearchIcon from "@mui/icons-material/PersonSearch"; import { Button as MuiButton, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, FormControlLabel, FormLabel, MenuItem, Radio, RadioGroup, TextField, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; import { Button, useDataProvider, useNotify, useRedirect, useTranslate } from "react-admin"; import { SynapseDataProvider } from "../../../providers/types"; type LookupType = "threepid" | "auth_provider"; export const FindUserButton = () => { const [open, setOpen] = useState(false); const [lookupType, setLookupType] = useState<LookupType>("threepid"); const [medium, setMedium] = useState("email"); const [address, setAddress] = useState(""); const [provider, setProvider] = useState(""); const [externalId, setExternalId] = useState(""); const [loading, setLoading] = useState(false); const notify = useNotify(); const translate = useTranslate(); const redirect = useRedirect(); const dataProvider = useDataProvider() as SynapseDataProvider; const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const handleOpen = () => { setAddress(""); setProvider(""); setExternalId(""); setOpen(true); }; const handleClose = () => setOpen(false); const canSubmit = lookupType === "threepid" ? address.trim().length > 0 : provider.trim().length > 0 && externalId.trim().length > 0; const handleConfirm = async () => { setLoading(true); try { const result = lookupType === "threepid" ? await dataProvider.findUserByThreepid(medium, address.trim()) : await dataProvider.findUserByAuthProvider(provider.trim(), externalId.trim()); if (result.success && result.user_id) { handleClose(); redirect("edit", "users", result.user_id); } else { notify("resources.users.action.find_user.not_found", { type: "warning" }); } } catch { notify("resources.users.action.find_user.failure", { type: "error" }); } finally { setLoading(false); } }; return ( <> <Button label="resources.users.action.find_user.label" onClick={handleOpen} disabled={loading}> <PersonSearchIcon /> </Button> <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> <DialogTitle>{translate("resources.users.action.find_user.title")}</DialogTitle> <DialogContent> <FormControl sx={{ mb: 2, mt: 1 }}> <FormLabel>{translate("resources.users.action.find_user.lookup_type")}</FormLabel> <RadioGroup value={lookupType} onChange={e => setLookupType(e.target.value as LookupType)}> <FormControlLabel value="threepid" control={<Radio />} label={translate("resources.users.action.find_user.by_threepid")} /> <FormControlLabel value="auth_provider" control={<Radio />} label={translate("resources.users.action.find_user.by_auth_provider")} /> </RadioGroup> </FormControl> {lookupType === "threepid" ? ( <> <TextField select fullWidth label={translate("resources.users.fields.medium")} value={medium} onChange={e => setMedium(e.target.value)} sx={{ mb: 2 }} > <MenuItem value="email">{translate("resources.users.email")}</MenuItem> <MenuItem value="msisdn">{translate("resources.users.msisdn")}</MenuItem> </TextField> <TextField autoFocus fullWidth label={translate("resources.users.fields.address")} value={address} onChange={e => setAddress(e.target.value)} onKeyDown={e => { if (e.key === "Enter" && canSubmit) handleConfirm(); }} /> </> ) : ( <> <TextField autoFocus fullWidth label={translate("resources.users.action.find_user.provider")} value={provider} onChange={e => setProvider(e.target.value)} sx={{ mb: 2 }} /> <TextField fullWidth label={translate("resources.users.action.find_user.external_id")} value={externalId} onChange={e => setExternalId(e.target.value)} onKeyDown={e => { if (e.key === "Enter" && canSubmit) handleConfirm(); }} /> </> )} </DialogContent> <DialogActions> <MuiButton onClick={handleClose} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton onClick={handleConfirm} disabled={!canSubmit || loading} className="ra-confirm RaConfirm-confirmPrimary" startIcon={<ActionCheck />} > {translate("resources.users.action.find_user.search")} </MuiButton> </DialogActions> </Dialog> </> ); }; export default FindUserButton; ================================================ FILE: src/components/users/buttons/LoginAsUserButton.tsx ================================================ import ActionCheck from "@mui/icons-material/CheckCircle"; import AlertError from "@mui/icons-material/ErrorOutline"; import LoginIcon from "@mui/icons-material/Login"; import { Button as MuiButton, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, FormControlLabel, Switch, TextField, Typography, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useId, useState } from "react"; import { Button, useDataProvider, useLocale, useNotify, useRecordContext, useTranslate } from "react-admin"; import { SynapseDataProvider } from "../../../providers/types"; export const LoginAsUserButton = () => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const record = useRecordContext(); const [open, setOpen] = useState(false); const [resultOpen, setResultOpen] = useState(false); const [useExpiry, setUseExpiry] = useState(false); const [expiryDate, setExpiryDate] = useState(""); const [accessToken, setAccessToken] = useState(""); const [validUntilMs, setValidUntilMs] = useState<number | undefined>(undefined); const [loading, setLoading] = useState(false); const locale = useLocale(); const notify = useNotify(); const translate = useTranslate(); const dataProvider = useDataProvider() as SynapseDataProvider; const confirmTitleId = useId(); const resultTitleId = useId(); if (!record) { return null; } const handleOpen = () => { setUseExpiry(false); setExpiryDate(""); setOpen(true); }; const handleClose = () => setOpen(false); const handleConfirm = async () => { setLoading(true); try { const validUntil = useExpiry && expiryDate ? new Date(expiryDate).getTime() : undefined; const result = await dataProvider.loginAsUser(record.id, validUntil); if (result.success && result.access_token) { setAccessToken(result.access_token); setValidUntilMs(validUntil); handleClose(); setResultOpen(true); } else { notify(result.error || "resources.users.action.login_as.failure", { type: "error" }); } } catch { notify("resources.users.action.login_as.failure", { type: "error" }); } finally { setLoading(false); } }; const handleResultClose = () => { setResultOpen(false); setAccessToken(""); setValidUntilMs(undefined); }; return ( <> <Button label="resources.users.action.login_as.label" onClick={handleOpen} disabled={loading}> <LoginIcon /> </Button> <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth fullScreen={fullScreen} aria-labelledby={confirmTitleId} > <DialogTitle id={confirmTitleId}>{translate("resources.users.action.login_as.title")}</DialogTitle> <DialogContent> <DialogContentText sx={{ mb: 2 }}> {translate("resources.users.action.login_as.helper", { user: record.id })} </DialogContentText> <FormControlLabel control={<Switch checked={useExpiry} onChange={e => setUseExpiry(e.target.checked)} />} label={translate("resources.users.action.login_as.valid_until")} /> {useExpiry && ( <TextField type="datetime-local" fullWidth value={expiryDate} onChange={e => setExpiryDate(e.target.value)} sx={{ mt: 1 }} slotProps={{ inputLabel: { shrink: true } }} /> )} </DialogContent> <DialogActions> <MuiButton onClick={handleClose} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton onClick={handleConfirm} disabled={loading || (useExpiry && !expiryDate)} className="ra-confirm RaConfirm-confirmPrimary" autoFocus startIcon={<ActionCheck />} > {translate("ra.action.confirm")} </MuiButton> </DialogActions> </Dialog> <Dialog open={resultOpen} onClose={handleResultClose} maxWidth="sm" fullWidth fullScreen={fullScreen} aria-labelledby={resultTitleId} > <DialogTitle id={resultTitleId}> {translate("resources.users.action.login_as.result_title", { user: record.id })} </DialogTitle> <DialogContent> <TextField fullWidth label={translate("resources.users.action.login_as.access_token")} value={accessToken} slotProps={{ input: { readOnly: true } }} sx={{ mt: 1 }} /> {validUntilMs && ( <Typography sx={{ mt: 1 }}> {translate("resources.users.action.login_as.expires_at", { date: new Date(validUntilMs).toLocaleString(locale), })} </Typography> )} </DialogContent> <DialogActions> <MuiButton onClick={handleResultClose} startIcon={<ActionCheck />}> {translate("ra.action.close")} </MuiButton> </DialogActions> </Dialog> </> ); }; export default LoginAsUserButton; ================================================ FILE: src/components/users/buttons/PurgeHistoryButton.tsx ================================================ import ActionCheck from "@mui/icons-material/CheckCircle"; import AlertError from "@mui/icons-material/ErrorOutline"; import HistoryIcon from "@mui/icons-material/History"; import { Box, Button as MuiButton, Checkbox, CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, FormControlLabel, TextField, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import { Button, useDataProvider, useNotify, useRecordContext, useTranslate } from "react-admin"; import { SynapseDataProvider } from "../../../providers/types"; export const PurgeHistoryButton = () => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const titleId = useId(); const record = useRecordContext(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [purgeDate, setPurgeDate] = useState(""); const [deleteLocalEvents, setDeleteLocalEvents] = useState(false); const [purgeStatus, setPurgeStatus] = useState<string | null>(null); const notify = useNotify(); const translate = useTranslate(); const dataProvider = useDataProvider() as SynapseDataProvider; const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); const stopPolling = useCallback(() => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }, []); useEffect(() => { return stopPolling; }, [stopPolling]); if (!record) { return null; } const roomName = (record.name || record.canonical_alias || record.id) as string; const handleClose = () => { setOpen(false); setPurgeDate(""); setDeleteLocalEvents(false); setPurgeStatus(null); stopPolling(); }; const handlePurge = async () => { if (!purgeDate) return; setLoading(true); setPurgeStatus(null); const purgeUpToTs = new Date(purgeDate).getTime(); try { const result = await dataProvider.purgeHistory(record.id as string, purgeUpToTs, deleteLocalEvents); if (result.success && result.purge_id) { setPurgeStatus("active"); pollRef.current = setInterval(async () => { const status = await dataProvider.getPurgeHistoryStatus(result.purge_id!); if (status.status === "complete") { stopPolling(); setPurgeStatus(null); setLoading(false); notify("resources.rooms.action.purge_history.success", { type: "success" }); handleClose(); } else if (status.status === "failed") { stopPolling(); setPurgeStatus(null); setLoading(false); notify("resources.rooms.action.purge_history.failure", { type: "error", messageArgs: { errMsg: status.error || "" }, }); } }, 3000); } else { setLoading(false); notify("resources.rooms.action.purge_history.failure", { type: "error", messageArgs: { errMsg: result.error || "" }, }); } } catch { setLoading(false); notify("resources.rooms.action.purge_history.failure", { type: "error", messageArgs: { errMsg: "" }, }); } }; return ( <> <Button label="resources.rooms.action.purge_history.label" onClick={() => setOpen(true)}> <HistoryIcon /> </Button> <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth fullScreen={fullScreen} aria-labelledby={titleId} > <DialogTitle id={titleId}>{translate("resources.rooms.action.purge_history.title", { roomName })}</DialogTitle> <DialogContent> <DialogContentText sx={{ mb: 2 }}> {translate("resources.rooms.action.purge_history.content")} </DialogContentText> <TextField autoFocus fullWidth type="datetime-local" label={translate("resources.rooms.action.purge_history.date_label")} value={purgeDate} onChange={e => setPurgeDate(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} sx={{ mb: 2 }} /> <FormControlLabel control={<Checkbox checked={deleteLocalEvents} onChange={e => setDeleteLocalEvents(e.target.checked)} />} label={translate("resources.rooms.action.purge_history.delete_local")} /> {purgeStatus === "active" && ( <> <Box sx={{ mt: 2, display: "flex", alignItems: "center", gap: 1 }}> <CircularProgress size={16} role="status" aria-label={translate("ra.message.loading")} /> {translate("resources.rooms.action.purge_history.in_progress")} </Box> <DialogContentText sx={{ mt: 1 }}> {translate("resources.rooms.action.purge_history.background_note")} </DialogContentText> </> )} </DialogContent> <DialogActions> <MuiButton onClick={handleClose} disabled={loading} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton onClick={handlePurge} disabled={!purgeDate || loading} aria-busy={loading} className="ra-confirm RaConfirm-confirmPrimary" startIcon={loading ? <CircularProgress size={16} aria-hidden="true" /> : <ActionCheck />} > {translate("ra.action.confirm")} </MuiButton> </DialogActions> </Dialog> </> ); }; ================================================ FILE: src/components/users/buttons/QuarantineAllMediaButton.tsx ================================================ import ActionCheck from "@mui/icons-material/CheckCircle"; import AlertError from "@mui/icons-material/ErrorOutline"; import BlockIcon from "@mui/icons-material/Block"; import { Button as MuiButton, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; import { Button, useDataProvider, useNotify, useRecordContext, useTranslate } from "react-admin"; import { SynapseDataProvider } from "../../../providers/types"; /** * Quarantine all media for a room. * Shows a confirmation dialog before proceeding. */ export const QuarantineRoomMediaButton = () => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const record = useRecordContext(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const notify = useNotify(); const translate = useTranslate(); const dataProvider = useDataProvider() as SynapseDataProvider; if (!record) return null; const roomName = (record.name || record.canonical_alias || record.id) as string; const handleQuarantine = async () => { setLoading(true); try { const result = await dataProvider.quarantineRoomMedia(record.id as string); if (result.success) { notify("resources.rooms.action.quarantine_all.success", { type: "success", messageArgs: { smart_count: result.num_quarantined }, }); setOpen(false); } else { notify("resources.rooms.action.quarantine_all.failure", { type: "error", messageArgs: { errMsg: result.error || "" }, }); } } catch { notify("resources.rooms.action.quarantine_all.failure", { type: "error", messageArgs: { errMsg: "" }, }); } setLoading(false); }; return ( <> <Button label="resources.rooms.action.quarantine_all.label" onClick={() => setOpen(true)}> <BlockIcon /> </Button> <Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth fullScreen={fullScreen}> <DialogTitle>{translate("resources.rooms.action.quarantine_all.title", { roomName })}</DialogTitle> <DialogContent> <DialogContentText>{translate("resources.rooms.action.quarantine_all.content")}</DialogContentText> </DialogContent> <DialogActions> <MuiButton onClick={() => setOpen(false)} disabled={loading} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton onClick={handleQuarantine} disabled={loading} className="ra-confirm RaConfirm-confirmPrimary" startIcon={<ActionCheck />} > {translate("ra.action.confirm")} </MuiButton> </DialogActions> </Dialog> </> ); }; /** * Quarantine all media for a user. * Shows a confirmation dialog before proceeding. */ export const QuarantineUserMediaButton = () => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const record = useRecordContext(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const notify = useNotify(); const translate = useTranslate(); const dataProvider = useDataProvider() as SynapseDataProvider; if (!record) return null; const userName = (record.displayname || record.id) as string; const handleQuarantine = async () => { setLoading(true); try { const result = await dataProvider.quarantineUserMedia(record.id as string); if (result.success) { notify("resources.users.action.quarantine_all.success", { type: "success", messageArgs: { smart_count: result.num_quarantined }, }); setOpen(false); } else { notify("resources.users.action.quarantine_all.failure", { type: "error", messageArgs: { errMsg: result.error || "" }, }); } } catch { notify("resources.users.action.quarantine_all.failure", { type: "error", messageArgs: { errMsg: "" }, }); } setLoading(false); }; return ( <> <Button label="resources.users.action.quarantine_all.label" onClick={() => setOpen(true)}> <BlockIcon /> </Button> <Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth fullScreen={fullScreen}> <DialogTitle>{translate("resources.users.action.quarantine_all.title", { userName })}</DialogTitle> <DialogContent> <DialogContentText>{translate("resources.users.action.quarantine_all.content")}</DialogContentText> </DialogContent> <DialogActions> <MuiButton onClick={() => setOpen(false)} disabled={loading} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton onClick={handleQuarantine} disabled={loading} className="ra-confirm RaConfirm-confirmPrimary" startIcon={<ActionCheck />} > {translate("ra.action.confirm")} </MuiButton> </DialogActions> </Dialog> </> ); }; ================================================ FILE: src/components/users/buttons/RenewAccountValidityButton.tsx ================================================ import ActionCheck from "@mui/icons-material/CheckCircle"; import AlertError from "@mui/icons-material/ErrorOutline"; import EventRepeatIcon from "@mui/icons-material/EventRepeat"; import { Button as MuiButton, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, FormControlLabel, Switch, TextField, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; import { Button, useDataProvider, useLocale, useNotify, useRecordContext, useTranslate } from "react-admin"; import { SynapseDataProvider } from "../../../providers/types"; import { dateParser } from "../../../utils/date"; export const RenewAccountValidityButton = () => { const record = useRecordContext(); const [open, setOpen] = useState(false); const [expirationInput, setExpirationInput] = useState(""); const [enableRenewalEmails, setEnableRenewalEmails] = useState(true); const [loading, setLoading] = useState(false); const notify = useNotify(); const translate = useTranslate(); const locale = useLocale(); const dataProvider = useDataProvider() as SynapseDataProvider; const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); if (!record) return null; const handleOpen = () => { setExpirationInput(""); setEnableRenewalEmails(true); setOpen(true); }; const handleConfirm = async () => { setLoading(true); try { const expirationTs = expirationInput ? dateParser(expirationInput) : undefined; const result = await dataProvider.renewAccountValidity(record.id as string, expirationTs, enableRenewalEmails); if (result.success && result.expiration_ts) { const expDate = new Date(result.expiration_ts).toLocaleString(locale); notify(translate("resources.users.action.renew_account.success", { date: expDate }), { type: "success" }); setOpen(false); } else { notify(result.error || "resources.users.action.renew_account.failure", { type: "error" }); } } catch { notify("resources.users.action.renew_account.failure", { type: "error" }); } finally { setLoading(false); } }; return ( <> <Button label="resources.users.action.renew_account.label" onClick={handleOpen} disabled={loading}> <EventRepeatIcon /> </Button> <Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth fullScreen={fullScreen}> <DialogTitle>{translate("resources.users.action.renew_account.title")}</DialogTitle> <DialogContent> <DialogContentText sx={{ mb: 2 }}> {translate("resources.users.action.renew_account.content", { user: record.id })} </DialogContentText> <TextField fullWidth type="datetime-local" label={translate("resources.users.action.renew_account.expiration")} value={expirationInput} onChange={e => setExpirationInput(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} helperText={translate("resources.users.action.renew_account.expiration_helper")} sx={{ mb: 2 }} /> <FormControlLabel control={<Switch checked={enableRenewalEmails} onChange={e => setEnableRenewalEmails(e.target.checked)} />} label={translate("resources.users.action.renew_account.renewal_emails")} /> </DialogContent> <DialogActions> <MuiButton onClick={() => setOpen(false)} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton onClick={handleConfirm} disabled={loading} className="ra-confirm RaConfirm-confirmPrimary" startIcon={<ActionCheck />} > {translate("ra.action.confirm")} </MuiButton> </DialogActions> </Dialog> </> ); }; export default RenewAccountValidityButton; ================================================ FILE: src/components/users/buttons/ResetPasswordButton.tsx ================================================ import ActionCheck from "@mui/icons-material/CheckCircle"; import AlertError from "@mui/icons-material/ErrorOutline"; import LockResetIcon from "@mui/icons-material/LockReset"; import { Button as MuiButton, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, TextField, FormControlLabel, Switch, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; import { Button, useDataProvider, useNotify, useRecordContext, useTranslate } from "react-admin"; import { SynapseDataProvider } from "../../../providers/types"; import { generateRandomPassword } from "../../../utils/password"; export const ResetPasswordButton = () => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const record = useRecordContext(); const [open, setOpen] = useState(false); const [password, setPassword] = useState(""); const [logoutDevices, setLogoutDevices] = useState(false); const [loading, setLoading] = useState(false); const notify = useNotify(); const translate = useTranslate(); const dataProvider = useDataProvider() as SynapseDataProvider; if (!record) { return null; } const handleOpen = () => { setPassword(""); setLogoutDevices(false); setOpen(true); }; const handleClose = () => setOpen(false); const handleGeneratePassword = () => { setPassword(generateRandomPassword()); }; const handleConfirm = async () => { if (!password) { notify("resources.users.action.reset_password.error_no_password", { type: "error" }); return; } setLoading(true); try { const result = await dataProvider.resetPassword(record.id, password, logoutDevices); if (result.success) { notify("resources.users.action.reset_password.success"); handleClose(); } else { notify(result.error || "resources.users.action.reset_password.failure", { type: "error" }); } } catch { notify("resources.users.action.reset_password.failure", { type: "error" }); } finally { setLoading(false); } }; return ( <> <Button label="resources.users.action.reset_password.label" onClick={handleOpen} disabled={loading}> <LockResetIcon /> </Button> <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> <DialogTitle>{translate("resources.users.action.reset_password.title")}</DialogTitle> <DialogContent> <DialogContentText sx={{ mb: 2 }}> {translate("resources.users.action.reset_password.helper", { user: record.id })} </DialogContentText> <TextField autoFocus fullWidth label={translate("resources.users.action.reset_password.password")} value={password} onChange={e => setPassword(e.target.value)} sx={{ mb: 1 }} /> <MuiButton variant="outlined" onClick={handleGeneratePassword} sx={{ mb: 2, display: "block" }}> {translate("resources.users.action.generate_password")} </MuiButton> <FormControlLabel control={<Switch checked={logoutDevices} onChange={e => setLogoutDevices(e.target.checked)} />} label={translate("resources.users.action.reset_password.logout_devices")} /> </DialogContent> <DialogActions> <MuiButton onClick={handleClose} startIcon={<AlertError />}> {translate("ra.action.cancel")} </MuiButton> <MuiButton onClick={handleConfirm} disabled={!password || loading} className="ra-confirm RaConfirm-confirmPrimary" autoFocus startIcon={<ActionCheck />} > {translate("ra.action.confirm")} </MuiButton> </DialogActions> </Dialog> </> ); }; export default ResetPasswordButton; ================================================ FILE: src/components/users/fields/AvatarField.test.tsx ================================================ import { render, screen, waitFor } from "@testing-library/react"; import { act } from "react"; import { RecordContextProvider } from "react-admin"; import AvatarField from "./AvatarField"; describe("AvatarField", () => { beforeEach(() => { // Mock fetch global.fetch = vi.fn(() => Promise.resolve(new Response(new Blob(["mock image data"], { type: "image/jpeg" }))) ) as unknown as typeof fetch; // Mock URL.createObjectURL global.URL.createObjectURL = vi.fn(() => "mock-object-url"); localStorage.setItem("base_url", "https://example.org"); localStorage.setItem("access_token", "secret-token"); }); afterEach(() => { vi.restoreAllMocks(); }); it("shows image", async () => { const value = { avatar: "mxc://serverName/mediaId", }; await act(async () => { render( <RecordContextProvider value={value}> <AvatarField source="avatar" /> </RecordContextProvider> ); }); await waitFor(() => { const img = screen.getByRole("img"); expect(img.getAttribute("src")).toBe("mock-object-url"); }); expect(global.fetch).toHaveBeenCalled(); }); }); ================================================ FILE: src/components/users/fields/AvatarField.tsx ================================================ import { Avatar, AvatarProps, Badge, Tooltip } from "@mui/material"; import { get } from "lodash"; import { useState, useEffect, useCallback, useRef } from "react"; import { FieldProps, useRecordContext, useTranslate } from "react-admin"; import { fetchAuthenticatedMedia } from "../../../utils/fetchMedia"; import { isMXID, isSystemUser } from "../../../utils/mxid"; const AvatarField = ({ source, ...rest }: AvatarProps & FieldProps) => { const { alt, classes, sizes, sx, variant } = rest; const record = useRecordContext(rest); const translate = useTranslate(); const mxcURL = get(record, source)?.toString(); const [src, setSrc] = useState<string>(""); const srcRef = useRef<string>(""); const fetchAvatar = useCallback(async (mxcURL: string) => { const response = await fetchAuthenticatedMedia(mxcURL, "thumbnail"); const blob = await response.blob(); const blobURL = URL.createObjectURL(blob); srcRef.current = blobURL; setSrc(blobURL); }, []); useEffect(() => { if (mxcURL) { fetchAvatar(mxcURL); } else { // Avatar was removed — revoke the old blob URL and clear the displayed image if (srcRef.current) { URL.revokeObjectURL(srcRef.current); srcRef.current = ""; } setSrc(""); } return () => { if (srcRef.current) { URL.revokeObjectURL(srcRef.current); } }; }, [mxcURL, fetchAvatar]); // a hacky way to handle both users and rooms, // where users have an ID, may have a name, and may have a displayname // and rooms have an ID and may have a name let letter = ""; if (record?.id) { letter = record.id[0].toUpperCase(); } if (record?.name) { letter = record.name[0].toUpperCase(); } if (record?.displayname) { letter = record.displayname[0].toUpperCase(); } // hacky way to determine the user type let badge = ""; let tooltip = ""; if (isMXID(record?.id)) { switch (record?.user_type) { case "bot": badge = "🤖"; tooltip = translate("resources.users.badge.bot"); break; case "support": badge = "📞"; tooltip = translate("resources.users.badge.support"); break; default: badge = "👤"; tooltip = translate("resources.users.badge.regular"); break; } if (!record?.id.endsWith(localStorage.getItem("home_server") || "")) { badge = "🌐"; tooltip = translate("resources.users.badge.federated"); } if (record?.admin) { badge = "👑"; tooltip = translate("resources.users.badge.admin"); } if (isSystemUser(record?.name) || record?.appservice_id) { badge = "🛡️"; tooltip = `${translate("resources.users.badge.system_managed")} (${tooltip})`; } if (localStorage.getItem("user_id") === record?.id) { badge = "🧙‍"; tooltip = `${translate("resources.users.badge.you")} (${tooltip})`; } } // scale badge to avatar size const sxObj = (sx && typeof sx === "object" && !Array.isArray(sx) ? sx : {}) as Record<string, unknown>; const avatarHeight = sxObj.height || sxObj.width || 40; const avatarSize = typeof avatarHeight === "string" ? parseInt(avatarHeight, 10) : (avatarHeight as number); const badgeFontSize = Math.max(10, Math.round(avatarSize * 0.18)); // if there is a badge, wrap the Avatar in a Badge and a Tooltip if (badge) { return ( <Tooltip title={tooltip} slotProps={{ tooltip: { sx: { fontSize: Math.max(11, Math.round(avatarSize * 0.08)) } } }} > <Badge badgeContent={badge} overlap="circular" sx={{ "& .MuiBadge-badge": { width: "10px", fontSize: badgeFontSize } }} anchorOrigin={{ vertical: "bottom", horizontal: "right", }} > <Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant}> {letter} </Avatar> </Badge> </Tooltip> ); } return ( <span style={{ display: "inline-block" }}> <Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant}> {letter} </Avatar> </span> ); }; export default AvatarField; ================================================ FILE: src/components/users/fields/EditableAvatarField.tsx ================================================ import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import DeleteIcon from "@mui/icons-material/Delete"; import { Avatar, Box, IconButton } from "@mui/material"; import { useRef } from "react"; import { useRecordContext } from "react-admin"; import { useFormContext } from "react-hook-form"; import AvatarField from "./AvatarField"; interface EditableAvatarFieldProps { source: string; size?: number; } const EditableAvatarField = ({ source, size = 120 }: EditableAvatarFieldProps) => { const { setValue, watch } = useFormContext(); const fileInputRef = useRef<HTMLInputElement>(null); const previewFile = watch("avatar_file"); const previewSrc = previewFile?.src; const avatarErased = watch("avatar_erase"); const record = useRecordContext(); const letter = (record?.displayname?.[0] || record?.name?.[0] || record?.id?.[0] || "").toUpperCase(); const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (!file) return; setValue("avatar_erase", false); setValue("avatar_file", { rawFile: file, src: URL.createObjectURL(file), title: file.name }, { shouldDirty: true }); }; const handleDelete = () => { setValue("avatar_file", null); setValue("avatar_erase", true, { shouldDirty: true }); if (fileInputRef.current) { fileInputRef.current.value = ""; } }; const iconSize = Math.max(24, Math.round(size * 0.3)); return ( <Box sx={{ position: "relative", width: size, height: size, cursor: "pointer", "&:hover .avatar-overlay": { opacity: 1 }, }} > {previewSrc ? ( <Box component="img" src={previewSrc} sx={{ width: size, height: size, borderRadius: "50%", objectFit: "cover", }} /> ) : avatarErased ? ( <Avatar sx={{ width: size, height: size }}>{letter}</Avatar> ) : ( <AvatarField source={source} sx={{ width: size, height: size }} /> )} <Box className="avatar-overlay" sx={{ position: "absolute", inset: 0, borderRadius: "50%", bgcolor: "rgba(0,0,0,0.5)", opacity: 0, transition: "opacity 0.2s", display: "flex", alignItems: "center", justifyContent: "center", }} > <IconButton onClick={() => fileInputRef.current?.click()} sx={{ color: "white", p: 0 }}> <CloudUploadIcon sx={{ fontSize: iconSize }} /> </IconButton> </Box> <IconButton className="avatar-overlay" onClick={handleDelete} sx={{ opacity: 0, transition: "opacity 0.2s", position: "absolute", bottom: 0, left: 0, p: 0.5, color: "white", bgcolor: "rgba(0,0,0,0.6)", "&:hover": { bgcolor: "rgba(0,0,0,0.8)" }, borderRadius: "50%", zIndex: 1, }} > <DeleteIcon sx={{ fontSize: Math.round(iconSize * 0.7) }} /> </IconButton> <input ref={fileInputRef} type="file" accept="image/png,image/jpeg" hidden onChange={handleUpload} /> </Box> ); }; export default EditableAvatarField; ================================================ FILE: src/entrypoints/auth-callback.html ================================================ <!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="theme-color" media="(prefers-color-scheme: light)" content="#F5F5F5" /> <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0C1318" /> <meta name="description" content="Ketesa" /> <base href="../" /> <!-- manifest.json provides metadata used when your web app is installed on a user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ --> <link rel="manifest" href="./manifest.json" media="(prefers-color-scheme: light)" data-scheme="light" /> <link rel="manifest" href="./manifest-dark.json" media="(prefers-color-scheme: dark)" data-scheme="dark" /> <link rel="shortcut icon" href="./favicon.ico" /> <title>Ketesa
================================================ FILE: src/entrypoints/index.html ================================================ Ketesa
================================================ FILE: src/i18n/README.md ================================================ # i18n/ Internationalization. 10 languages, each in its own directory. ## Structure - `index.ts` — i18n provider setup (do not modify for new translations) - `types.d.ts` — Type-safe translation key definitions - `en/`, `de/`, `fa/`, `fr/`, `it/`, `ja/`, `pt/`, `ru/`, `uk/`, `zh/` — one dir per language Each language directory contains: - `index.ts` — assembles all domain files into the full translation object - `common.ts` — `ra.*` built-ins and app-level strings - `users.ts` — `resources.users.*` - `rooms.ts` — `resources.rooms.*` - `mas.ts` — all `resources.mas_*` keys - `reports.ts` — `resources.reports.*` - `misc_resources.ts` — all remaining resource keys ## Adding a translation key 1. Add the type to `types.d.ts` 2. Add the English string to `en/.ts` 3. Add a proper native-quality translation to **every** other language's `.ts` All languages must stay in sync. Never use English stubs in non-English files. ================================================ FILE: src/i18n/de/base.ts ================================================ import type { TranslationMessages } from "ra-core"; const germanMessages: TranslationMessages = { ra: { action: { add_filter: "Filter hinzufügen", add: "Neu", back: "Zurück", bulk_actions: "Ein Element ausgewählt |||| %{smart_count} Elemente ausgewählt", cancel: "Abbrechen", clear_array_input: "Liste löschen", clear_input_value: "Eingabe löschen", clone: "Klonen", confirm: "Bestätigen", create: "Erstellen", create_item: "%{item} erstellen", delete: "Löschen", edit: "Bearbeiten", export: "Exportieren", list: "Liste", refresh: "Neu laden", remove_filter: "Filter entfernen", remove_all_filters: "Alle Filter entfernen", remove: "Entfernen", reset: "Zurücksetzen", save: "Speichern", search: "Suchen", search_columns: "Spalten durchsuchen", select_all: "Alles auswählen", select_all_button: "Alle auswählen", select_row: "Reihe auswählen", show: "Anzeigen", sort: "Sortieren", undo: "Rückgängig machen", unselect: "Abwählen", expand: "Erweitern", close: "Schließen", open_menu: "Menü öffnen", close_menu: "Menü schließen", update: "Aktualisieren", move_up: "Nach oben", move_down: "Nach unten", open: "Öffnen", toggle_theme: "Theme wechseln", select_columns: "Spalten", update_application: "Anwendung aktualisieren", }, boolean: { true: "Ja", false: "Nein", null: " ", }, page: { create: "%{name} erstellen", dashboard: "Dashboard", edit: "%{name} %{recordRepresentation}", error: "Etwas ist schiefgelaufen", list: "%{name}", loading: "Laden", not_found: "Nicht gefunden", show: "%{name} %{recordRepresentation}", empty: "Noch kein %{name}.", invite: "Neu erstellen?", access_denied: "Zugriff verweigert", authentication_error: "Authentifizierungsfehler", }, input: { file: { upload_several: "Dateien hier ablegen, oder zum Auswählen klicken.", upload_single: "Dateien hier ablegen, oder zum Auswählen klicken.", }, image: { upload_several: "Bilder hier ablegen, oder zum Auswählen klicken.", upload_single: "Bild hier ablegen, oder zum Auswählen klicken.", }, references: { all_missing: "Die Daten der Referenz können nicht gefunden werden.", many_missing: "Mindestens eine Referenz scheint nicht mehr verfügbar zu sein.", single_missing: "Die Referenz scheint nicht mehr verfügbar zu sein.", }, password: { toggle_visible: "Passwort verbergen", toggle_hidden: "Passwort einblenden", }, }, message: { about: "Über", access_denied: "Sie haben nicht die erforderlichen Berechtigungen um auf diese Seite zuzugreifen.", are_you_sure: "Sind Sie sicher?", authentication_error: "Der Authentifizierungsserver hat einen Fehler zurückgegeben und Ihre Anmeldedaten konnten nicht überprüft werden.", auth_error: "Bei der Validierung des Authentifizierungstokens ist ein Fehler aufgetreten.", bulk_delete_content: "Sicher, dass Sie %{name} löschen wollen? |||| Sicher, dass Sie diese %{smart_count} Elemente löschen wollen?", bulk_delete_title: "%{name} löschen |||| %{smart_count} %{name} löschen", bulk_update_content: "Sicher, dass Sie %{name} %{recordRepresentation} aktualisieren wollen? |||| Sicher, dass Sie %{smart_count} Elemente aktualisieren wollen?", bulk_update_title: "%{name} %{recordRepresentation} aktualisieren |||| %{smart_count} %{name} aktualisieren", clear_array_input: "Sicher, dass Sie die ganze Liste löschen wollen?", delete_content: "Sicher, dass Sie dieses Element löschen wollen?", delete_title: "%{name} %{recordRepresentation} löschen", details: "Details", error: "Ein Fehler trat auf, Ihre Anfrage konnte nicht verarbeitet werden.", invalid_form: "Das Formular ist ungültig. Bitte überprüfen Sie Ihre Eingaben.", loading: "Bitte warten", no: "Nein", not_found: "Sie haben eine falsche URL aufgerufen oder eingegeben.", select_all_limit_reached: "Es gibt zu viele Elemente, um sie alle auszuwählen. Es wurden nur die ersten %{max} Elemente ausgewählt.", unsaved_changes: "Einige Änderungen wurden nicht gespeichert. Sicher, dass Sie diese nicht übernehmen wollen?", yes: "Ja", placeholder_data_warning: "Netzwerkproblem: Datenaktualisierung fehlgeschlagen.", }, navigation: { clear_filters: "Alle Filter entfernen", no_filtered_results: "Keine Ergebnisse", no_results: "Keine Ergebnisse gefunden.", no_more_results: "Es gibt keine Seite %{page}. Versuchen Sie eine vorherige.", page_out_of_boundaries: "Es gibt keine Seite %{page}.", page_out_from_end: "Es gibt keine Seite mehr nach dieser.", page_out_from_begin: "Es gibt keine Seite vor Seite 1.", page_range_info: "%{offsetBegin}-%{offsetEnd} von %{total}", partial_page_range_info: "%{offsetBegin}-%{offsetEnd} von mehr als %{offsetEnd}", current_page: "Seite %{page}", page: "Gehe zu Seite %{page}", first: "Gehe zur ersten Seite", last: "Gehe zur letzten Seite", next: "Gehe zur nächsten Seite", previous: "Gehe zur vorherigen Seite", page_rows_per_page: "Reihen pro Seite:", skip_nav: "Zum Inhalt springen", }, sort: { sort_by: "Nach %{field_lower_first} %{order} sortieren", ASC: "Aufsteigend", DESC: "Absteigend", }, auth: { auth_check_error: "Bitte anmelden um fortzufahren", user_menu: "Profil", username: "Benutzername", password: "Passwort", email: "E-Mail", sign_in: "Anmelden", sign_in_error: "Anmeldung fehlgeschlagen, bitte erneut versuchen.", logout: "Abmelden", }, notification: { updated: "Element aktualisiert |||| %{smart_count} Elemente aktualisiert", created: "Element erstellt", deleted: "Element gelöscht |||| %{smart_count} Elemente gelöscht", bad_item: "Fehlerhaftes Element", item_doesnt_exist: "Element existiert nicht", http_error: "Kommunikation mit Server fehlgeschlagen", data_provider_error: "DataProvider-Fehler. Mehr Details in der Konsole.", i18n_error: "Die Übersetzungen für die ausgewählte Sprache können nicht geladen werden", canceled: "Aktion abgebrochen", logged_out: "Ihre Sitzung ist abgelaufen, bitte erneut verbinden.", not_authorized: "Sie sind nicht berechtigt, auf diese Ressource zuzugreifen.", application_update_available: "Eine neue Version ist verfügbar.", offline: "Keine Verbindung. Daten konnten nicht abgerufen werden.", }, validation: { required: "Erforderlich", minLength: "Muss mindestens %{min} Zeichen betragen", maxLength: "Darf %{max} Zeichen oder weniger betragen", minValue: "Muss mindestens %{min} betragen", maxValue: "Darf %{max} oder weniger betragen", number: "Muss eine Nummer sein", email: "Muss eine gültige E-Mail sein", oneOf: "Muss eine der folgenden Optionen sein: %{options}", regex: "Muss einem gewissen Format entsprechen (regexp): %{pattern}", unique: "Muss eindeutig sein", }, saved_queries: { label: "Gespeicherte Anfragen", query_name: "Name der Anfrage", new_label: "Speichere aktuelle Anfrage...", new_dialog_title: "Speichere aktuelle Anfrage als", remove_label: "Gespeicherte Anfrage löschen", remove_label_with_name: 'Anfrage "%{name}" löschen', remove_dialog_title: "Gespeicherte Anfrage löschen?", remove_message: "Sicher, dass Sie diese Anfrage aus der Liste der gespeicherten löschen wollen?", help: "Liste filtern und diese Anfrage für später speichern", }, guesser: { empty: { title: "Keine Daten zum Anzeigen", message: "Bitte prüfen Sie Ihren Datenanbieter", }, }, configurable: { customize: "Anpassen", configureMode: "Diese Seite anpassen", inspector: { title: "Inspektor", content: "Bewegen Sie den Mauszeiger über die UI-Elemente, um sie zu konfigurieren", reset: "Einstellungen zurücksetzen", hideAll: "Alles verbergen", showAll: "Alles anzeigen", }, Datagrid: { title: "Datagrid", unlabeled: "Unbekannte Spalte #%{column}", }, SimpleForm: { title: "Formular", unlabeled: "Unbenannter Input #%{input}", }, SimpleList: { title: "Liste", primaryText: "Primärtext", secondaryText: "Sekundärtext", tertiaryText: "Tertiärtext", }, }, }, }; export default germanMessages; ================================================ FILE: src/i18n/de/common.ts ================================================ import germanMessages from "./base"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const common: Record = { ...germanMessages, ketesa: { auth: { base_url: "Heimserver URL", welcome: "Willkommen bei %{name}", description: "Die Weiterentwicklung von Synapse Admin. Verwalten, überwachen und betreiben Sie Ihren Matrix-Server über eine einzige, übersichtliche Oberfläche. Für kleine Privatserver ebenso geeignet wie für große föderierte Communities.", server_version: "Synapse Version", supports_specs: "Unterstützt Matrix-Specs", username_error: "Bitte geben Sie den vollständigen Benutzernamen an: '@user:domain'", protocol_error: "Die URL muss mit 'http://' oder 'https://' beginnen", url_error: "Keine gültige Matrix Server URL", sso_sign_in: "Anmeldung mit SSO", credentials: "Anmeldedaten", access_token: "Zugriffstoken", logout_access_token_dialog: { title: "Sie verwenden ein bestehendes Matrix-Zugriffstoken.", content: "Möchten Sie diese Sitzung (die anderswo, z.B. in einem Matrix-Client, verwendet werden könnte) beenden oder sich nur vom Admin-Panel abmelden?", confirm: "Sitzung beenden", cancel: "Nur vom Admin-Panel abmelden", }, }, users: { invalid_user_id: "Lokaler Anteil der Matrix Benutzer-ID ohne Homeserver.", tabs: { sso: "SSO", experimental: "Experimentell", limits: "Rate Limits", account_data: "Kontodaten", sessions: "Sitzungen", }, danger_zone: "Gefahrenzone", }, rooms: { details: "Raumdetails", tabs: { basic: "Allgemein", members: "Mitglieder", detail: "Details", permission: "Berechtigungen", media: "Medien", messages: "Nachrichten", hierarchy: "Hierarchie", }, }, reports: { tabs: { basic: "Allgemein", detail: "Details" } }, admin_config: { soft_failed_events: "Soft-fehlgeschlagene Ereignisse", spam_flagged_events: "Als Spam markierte Ereignisse", success: "Admin-Konfiguration aktualisiert", failure: "Admin-Konfiguration konnte nicht aktualisiert werden", }, }, import_users: { error: { at_entry: "Bei Eintrag %{entry}: %{message}", error: "Fehler", required_field: "Pflichtfeld '%{field}' fehlt", invalid_value: "Ungültiger Wert in Zeile %{row}. Feld '%{field}' darf nur die Werte 'true' oder 'false' enthalten", unreasonably_big: "Datei ist zu groß für den Import (%{size} Megabytes)", already_in_progress: "Es läuft bereits ein Import", id_exits: "ID %{id} existiert bereits", }, title: "Benutzer aus CSV importieren", goToPdf: "Zum PDF wechseln", cards: { importstats: { header: "Geparste Benutzer für den Import", users_total: "%{smart_count} Benutzer in der CSV Datei |||| %{smart_count} Benutzer in der CSV Datei", guest_count: "%{smart_count} Gast |||| %{smart_count} Gäste", admin_count: "%{smart_count} Server Administrator |||| %{smart_count} Server Administratoren", }, conflicts: { header: "Konfliktstrategie", mode: { stop: "Bei Fehlern stoppen", skip: "Fehler anzeigen und fehlerhafte Einträge überspringen", }, }, ids: { header: "IDs", all_ids_present: "IDs in jedem Eintrag vorhanden", count_ids_present: "%{smart_count} Eintrag mit ID |||| %{smart_count} Einträge mit IDs", mode: { ignore: "IDs der CSV-Datei ignorieren und neue erstellen", update: "Existierende Benutzer aktualisieren", }, }, passwords: { header: "Passwörter", all_passwords_present: "Passwörter in jedem Eintrag vorhanden", count_passwords_present: "%{smart_count} Eintrag mit Passwort |||| %{smart_count} Einträge mit Passwörtern", use_passwords: "Passwörter aus der CSV-Datei verwenden", }, upload: { header: "CSV Datei importieren", explanation: "Hier können Sie eine Datei mit kommagetrennten Daten hochladen, die verwendet werden um Benutzer anzulegen oder zu ändern. Die Datei muss mindestens die Felder 'id' und 'displayname' enthalten. Hier können Sie eine Beispieldatei herunterladen und anpassen: ", }, startImport: { simulate_only: "Nur simulieren", run_import: "Importieren", }, results: { header: "Ergebnis", total: "%{smart_count} Eintrag insgesamt |||| %{smart_count} Einträge insgesamt", successful: "%{smart_count} Einträge erfolgreich importiert", skipped: "%{smart_count} Einträge übersprungen", download_skipped: "Übersprungene Einträge herunterladen", with_error: "%{smart_count} Eintrag mit Fehlern ||| %{smart_count} Einträge mit Fehlern", simulated_only: "Import-Vorgang war nur simuliert", }, }, }, delete_media: { name: "Medien", fields: { before_ts: "Letzter Zugriff vor", size_gt: "Größer als (in Bytes)", keep_profiles: "Behalte Profilbilder", }, action: { send: "Medien löschen", send_success: "%{smart_count} Mediendatei erfolgreich gelöscht. |||| %{smart_count} Mediendateien erfolgreich gelöscht.", send_success_none: "Keine Mediendateien entsprachen den angegebenen Kriterien. Es wurde nichts gelöscht.", send_failure: "Beim Versenden ist ein Fehler aufgetreten.", }, helper: { send: "Diese API löscht die lokalen Medien von der Festplatte des eigenen Servers. Dies umfasst alle lokalen Miniaturbilder und Kopien von Medien. Diese API wirkt sich nicht auf Medien aus, die sich in externen Medien-Repositories befinden.", }, }, purge_remote_media: { name: "Externe Medien", fields: { before_ts: "Letzter Zugriff vor", }, action: { send: "Externe Medien löschen", send_success: "%{smart_count} externe Mediendatei erfolgreich gelöscht. |||| %{smart_count} externe Mediendateien erfolgreich gelöscht.", send_success_none: "Keine externen Mediendateien entsprachen den angegebenen Kriterien. Es wurde nichts gelöscht.", send_failure: "Bei der Anfrage zum Löschen externer Medien ist ein Fehler aufgetreten.", }, helper: { send: "Diese API löscht den externen Medien-Cache von der Festplatte Ihres eigenen Servers. Dazu gehören alle lokalen Thumbnails und Kopien heruntergeladener Medien. Diese API beeinflusst nicht die Medien, die in das eigene Medienarchiv des Servers hochgeladen wurden.", }, }, etkecc: { donate: { menu_label: "Spenden", name: "Die Entwicklung von Ketesa unterstützen", title: "Die Entwicklung von Ketesa unterstützen", description_1: "Das Projekt Ketesa ist frei und Open Source, und wir entwickeln und pflegen es offen für die Matrix-Community.", description_2: "Wenn das Projekt Ketesa für Sie nützlich war, hilft eine Spende uns dabei, die Arbeit dahinter fortzusetzen: Entwicklung, Wartung, Fehlerbehebungen und kontinuierliche Verbesserungen.", description_3: "So können wir mehr Zeit darauf verwenden, das Projekt für alle zu verbessern, die sich darauf verlassen.", description_4: "Jeder Beitrag hilft, und wir schätzen Ihre Unterstützung sehr! ❤️", button: "Spenden", signature_team: "das etke.cc-Team", }, components: { name: "Komponenten", description: "Sehen Sie Ihre aktiven Komponenten ein und entdecken Sie, was Sie zu Ihrem Server hinzufügen können.", no_section: "Ihr Server", per_month: "/Mo.", included: "Inklusive", total: "Gesamt", loading: "Komponenten werden geladen...", state_add: "Hinzufügen", state_remove: "Deinstallieren", add_aria: "%{name} hinzufügen", remove_aria: "%{name} entfernen", preview_label: "Vorschau", request_changes: "Änderungen anfordern", requesting: "Wird gesendet...", request_failure: "Die Änderungsanfrage konnte nicht gesendet werden. Bitte versuchen Sie es erneut.", request_sent_title: "Anfrage eingereicht", request_sent_body: "Ihre Anfrage zur Komponentenänderung wurde an den etke.cc-Support gesendet. Wenn Sie weitere Änderungen benötigen, antworten Sie bitte auf diese Support-Anfrage, anstatt eine neue zu öffnen.", request_sent_close: "Schließen", request_sent_view: "Anfrage anzeigen", request_already_sent: "Eine Änderungsanfrage ist bereits offen. Um weitere Änderungen anzufordern, antworten Sie bitte auf Ihr bestehendes Support-Ticket.", request_already_sent_view: "Ticket anzeigen", free_label: "Kostenlos", available_label: "Verfügbar", tagline: "Erweitern Sie Ihren Server — fügen Sie Komponenten jederzeit hinzu oder entfernen Sie sie.", section: { bridges: "Brücken", extras: "Extras", matrix_apps: "Matrix-Anwendungen", matrix_bots: "Matrix-Bots", matrix_extras: "Matrix-Extras", }, }, billing: { name: "Abrechnung", title: "Zahlungshistorie", no_payments: "Keine Zahlungen gefunden.", no_payments_helper: "Wenn Sie glauben, dass das ein Fehler ist, kontaktieren Sie bitte den etke.cc-Support.", description1: "Hier können Sie Zahlungen einsehen und Rechnungen erstellen. Mehr zur Verwaltung von Abonnements erfahren Sie unter", description2: "Um Ihre Abrechnungs-E-Mail zu ändern oder Firmendaten zu Rechnungen hinzuzufügen, siehe", fields: { transaction_id: "Transaktions-ID", email: "E-Mail", type: "Typ", amount: "Betrag", paid_at: "Bezahlt am", invoice: "Rechnung", }, enums: { type: { subscription: "Abonnement", one_time: "Einmalig", }, }, helper: { download_invoice: "Rechnung herunterladen", downloading: "Wird heruntergeladen...", download_started: "Der Rechnungsdownload wurde gestartet.", invoice_not_available: "Ausstehend", loading: "Abrechnungsinformationen werden geladen...", loading_failed1: "Beim Laden der Abrechnungsinformationen ist ein Problem aufgetreten.", loading_failed2: "Bitte versuchen Sie es später erneut.", loading_failed3: "Wenn das Problem weiterhin besteht, kontaktieren Sie bitte den etke.cc-Support.", loading_failed4: "mit der folgenden Fehlermeldung:", }, components: "Aktive Komponenten", components_no_section: "Ihr Server", components_per_month: "/Mo.", components_included: "Inklusive", components_total: "Gesamt", components_help_title: "Mehr über %{name} erfahren", components_state_install: "Installieren", components_state_remove: "Deinstallieren", components_remove_aria: "%{name} installieren/deinstallieren", components_preview_label: "Vorschau", components_request_changes: "Änderungen anfordern", components_requesting: "Wird gesendet...", components_request_failure: "Die Änderungsanfrage konnte nicht gesendet werden. Bitte versuchen Sie es erneut.", components_request_sent_title: "Anfrage eingereicht", components_request_sent_body: "Ihre Anfrage zur Komponentenänderung wurde an den etke.cc-Support gesendet. Wenn Sie weitere Änderungen benötigen, antworten Sie bitte auf diese Support-Anfrage, anstatt eine neue zu öffnen.", components_request_sent_close: "Schließen", components_request_sent_view: "Anfrage anzeigen", components_request_already_sent: "Eine Änderungsanfrage ist bereits offen. Um weitere Änderungen anzufordern, antworten Sie bitte auf Ihr bestehendes Support-Ticket.", components_request_already_sent_view: "Ticket anzeigen", status: { issue: { title: "Abonnement benötigt Aufmerksamkeit", description: "Wir haben ein Problem mit Ihrem Abonnement festgestellt. Keine Sorge — es lässt sich leicht beheben.", due_overdue: "Überfällig seit", due_upcoming: "Fällig in", expected: "Erwarteter Betrag", last_paid: "Zuletzt bezahlt", fix_link: "Zahlungsrückstand beheben", fix_mismatch_link: "Abonnementspreis aktualisieren", support_link: "Support kontaktieren", }, }, }, status: { name: "Serverstatus", badge: { default: "Klicken, um den Serverstatus anzuzeigen", running: "Läuft: %{command}. %{text}", status_ok: "Server ist online", status_error: "Status: Fehler", status_maintenance: "Das System befindet sich derzeit im Wartungsmodus.", status_process_running: "Server führt einen Befehl aus", status_checking: "Serverstatus wird geprüft", }, category: { "Host Metrics": "Host-Metriken", Network: "Netzwerk", HTTP: "HTTP", Matrix: "Matrix", }, status: "Status", error: "Fehler", loading: "Echtzeit-Betriebsstatus wird abgerufen... Einen Moment!", intro1: "Dies ist ein Echtzeit-Monitoringbericht Ihres Servers. Mehr dazu finden Sie unter", intro2: 'Falls ein Status nicht "OK" anzeigen sollte, prüfen Sie bitte die empfohlenen Maßnahmen unter', help: "Hilfe", }, maintenance: { title: "Das System befindet sich derzeit im Wartungsmodus.", try_again: "Bitte versuchen Sie es später erneut.", note: "Sie müssen den Support hierzu nicht kontaktieren — wir arbeiten bereits daran!", }, actions: { name: "Serverbefehle", available_title: "Verfügbare Befehle", available_description: "Die folgenden Befehle können ausgeführt werden.", available_help_intro: "Weitere Details zu jedem Befehl finden Sie unter", scheduled_title: "Geplante Befehle", scheduled_description: "Die folgenden Befehle sind zu bestimmten Zeiten geplant. Sie können Details ansehen und sie bei Bedarf ändern.", recurring_title: "Wiederkehrende Befehle", recurring_description: "Die folgenden Befehle sind so eingerichtet, dass sie wöchentlich an einem bestimmten Wochentag und zu einer bestimmten Uhrzeit laufen. Sie können Details ansehen und sie bei Bedarf ändern.", scheduled_help_intro: "Weitere Details zu diesem Modus finden Sie unter", recurring_help_intro: "Weitere Details zu diesem Modus finden Sie unter", maintenance_title: "Das System befindet sich derzeit im Wartungsmodus.", maintenance_try_again: "Bitte versuchen Sie es später erneut.", maintenance_note: "Sie müssen den Support hierzu nicht kontaktieren — wir arbeiten bereits daran!", maintenance_commands_blocked: "Befehle können erst ausgeführt werden, wenn der Wartungsmodus deaktiviert ist.", table: { aria_label: "Serverbefehle", command: "Befehl", description: "Beschreibung", arguments: "Argumente", is_recurring: "Wiederkehrend?", run_at: "Ausführung (lokale Zeit)", next_run_at: "Nächste Ausführung (lokale Zeit)", time_utc: "Uhrzeit (UTC)", time_local: "Uhrzeit (lokale Zeit)", }, buttons: { create: "Erstellen", update: "Aktualisieren", back: "Zurück", delete: "Löschen", run: "Ausführen", }, command_scheduled: "Befehl geplant: %{command}", command_scheduled_args: "mit zusätzlichen Argumenten: %{args}", expect_prefix: "Das Ergebnis erscheint in Kürze auf der Seite", expect_suffix: ".", notifications_link: "Benachrichtigungen", command_help_title: "%{command} Hilfe", scheduled_title_create: "Geplanten Befehl erstellen", scheduled_title_edit: "Geplanten Befehl bearbeiten", recurring_title_create: "Wiederkehrenden Befehl erstellen", recurring_title_edit: "Wiederkehrenden Befehl bearbeiten", scheduled_details_title: "Details des geplanten Befehls", recurring_warning: "Geplante Befehle, die aus einem wiederkehrenden erstellt wurden, sind nicht bearbeitbar, da sie automatisch neu erstellt werden. Bitte bearbeiten Sie stattdessen den wiederkehrenden Befehl.", command_details_intro: "Weitere Details zum Befehl finden Sie unter", form: { id: "ID", command: "Befehl", scheduled_at: "Geplant für", day_of_week: "Wochentag", }, delete_scheduled_title: "Geplanten Befehl löschen", delete_recurring_title: "Wiederkehrenden Befehl löschen", delete_confirm: "Möchten Sie den Befehl wirklich löschen: %{command}?", errors: { unknown: "Ein unbekannter Fehler ist aufgetreten", delete_failed: "Fehler: %{error}", }, days: { monday: "Montag", tuesday: "Dienstag", wednesday: "Mittwoch", thursday: "Donnerstag", friday: "Freitag", saturday: "Samstag", sunday: "Sonntag", }, scheduled: { action: { create_success: "Geplanter Befehl erfolgreich erstellt.", update_success: "Geplanter Befehl erfolgreich aktualisiert.", update_failure: "Es ist ein Fehler aufgetreten.", delete_success: "Geplanter Befehl erfolgreich gelöscht.", delete_failure: "Es ist ein Fehler aufgetreten.", }, }, recurring: { action: { create_success: "Wiederkehrender Befehl erfolgreich erstellt.", update_success: "Wiederkehrender Befehl erfolgreich aktualisiert.", update_failure: "Es ist ein Fehler aufgetreten.", delete_success: "Wiederkehrender Befehl erfolgreich gelöscht.", delete_failure: "Es ist ein Fehler aufgetreten.", }, }, }, notifications: { title: "Benachrichtigungen", new_notifications: "%{smart_count} neue Benachrichtigung |||| %{smart_count} neue Benachrichtigungen", no_notifications: "Noch keine Benachrichtigungen", see_all: "Alle anzeigen", clear_all: "Alle löschen", ago: "vor", advisory_tooltip: "Möglicherweise haben Sie eine Benachrichtigung verpasst. Bitte prüfen Sie auch #news:etke.cc, etke.cc/news oder Ihr E-Mail-Postfach.", unavailable_tooltip: "Benachrichtigungen sind möglicherweise nicht verfügbar. Klicken Sie für Details.", unavailable_title: "Benachrichtigungen sind zurzeit möglicherweise nicht verfügbar", unavailable_body: "Es könnte Updates geben, die zurzeit nicht an dieses Panel übermittelt werden können — oder es gibt nichts Neues. Um nichts zu verpassen, prüfen Sie bitte regelmäßig:", unavailable_link_matrix: "Matrix-Raum #news:etke.cc", unavailable_link_news: "Ankündigungsseite auf etke.cc/news", unavailable_link_email: "Ihr E-Mail-Postfach (einschließlich Spam-Ordner)", unavailable_retry: "Erneut versuchen", }, currently_running: { command: "Derzeit läuft:", started_ago: "(vor %{time} gestartet)", }, time: { less_than_minute: "ein paar Sekunden", minutes: "%{smart_count} Minute |||| %{smart_count} Minuten", hours: "%{smart_count} Stunde |||| %{smart_count} Stunden", days: "%{smart_count} Tag |||| %{smart_count} Tage", weeks: "%{smart_count} Woche |||| %{smart_count} Wochen", months: "%{smart_count} Monat |||| %{smart_count} Monate", }, support: { name: "Support", menu_label: "Support kontaktieren", description: "Öffnen Sie eine Support-Anfrage oder verfolgen Sie eine bestehende. Unser Team wird so schnell wie möglich antworten.", create_title: "Neue Support-Anfrage", no_requests: "Noch keine Support-Anfragen.", no_messages: "Noch keine Nachrichten.", closed_message: "Diese Anfrage ist geschlossen. Wenn Sie weiterhin ein Problem haben, öffnen Sie bitte eine neue.", fields: { subject: "Betreff", message: "Nachricht", reply: "Antwort", status: "Status", created_at: "Erstellt", updated_at: "Zuletzt aktualisiert", }, status: { active: "Warte auf Betreiber", open: "Offen", closed: "Geschlossen", pending: "Wartet auf Sie", }, buttons: { new_request: "Neue Anfrage", submit: "Absenden", cancel: "Abbrechen", send: "Senden", back: "Zurück zum Support", attach_files: "Dateien anhängen", }, helper: { loading: "Support-Anfragen werden geladen...", reply_hint: "Strg+Eingabe zum Senden", reply_placeholder: "Bitte geben Sie so viele Details wie möglich an.", before_contact_title: "Bevor Sie uns kontaktieren", help_pages_prompt: "Bitte lesen Sie zuerst unsere Hilfeseiten:", services_prompt: "Wir bieten nur die auf der Serviceseite aufgeführten Leistungen an:", topics_prompt: "Wir können nur zu unterstützten Themen helfen:", scope_confirm_label: "Ich habe die Hilfeseiten gelesen und bestätige, dass diese Anfrage zu den unterstützten Themen gehört.", english_only_notice: "Support wird nur auf Englisch angeboten.", response_time_prompt: "Antwort innerhalb von 48 Stunden. Benötigen Sie schnellere Antwortzeiten? Siehe:", attachments_limit: "Bis zu 5 Dateien, je 5 MB, insgesamt 10 MB.", close_request_label: "Diese Anfrage nach dem Senden schließen", }, actions: { create_success: "Support-Anfrage erfolgreich erstellt.", create_failure: "Support-Anfrage konnte nicht erstellt werden.", send_failure: "Nachricht konnte nicht gesendet werden.", attachment_too_large: 'Datei "%{name}" überschreitet das Limit von 5 MB.', too_many_attachments: "Maximal 5 Dateien erlaubt.", total_size_exceeded: "Die Gesamtgröße der Anhänge überschreitet 10 MB.", }, }, }, }; export default common; ================================================ FILE: src/i18n/de/index.ts ================================================ import { SynapseTranslationMessages } from "../types"; import common from "./common"; import mas from "./mas"; import misc_resources from "./misc_resources"; import reports from "./reports"; import rooms from "./rooms"; import users from "./users"; const de: SynapseTranslationMessages = { ra: common.ra, ketesa: common.ketesa, import_users: common.import_users, delete_media: common.delete_media, purge_remote_media: common.purge_remote_media, etkecc: common.etkecc, resources: { users, rooms, reports, scheduled_tasks: misc_resources.scheduled_tasks, connections: misc_resources.connections, devices: misc_resources.devices, users_media: misc_resources.users_media, protect_media: misc_resources.protect_media, quarantine_media: misc_resources.quarantine_media, pushers: misc_resources.pushers, servernotices: misc_resources.servernotices, database_room_statistics: misc_resources.database_room_statistics, user_media_statistics: misc_resources.user_media_statistics, forward_extremities: misc_resources.forward_extremities, room_state: misc_resources.room_state, room_media: misc_resources.room_media, room_directory: misc_resources.room_directory, destinations: misc_resources.destinations, registration_tokens: misc_resources.registration_tokens, account_data: misc_resources.account_data, joined_rooms: misc_resources.joined_rooms, memberships: misc_resources.memberships, room_members: misc_resources.room_members, destination_rooms: misc_resources.destination_rooms, ...mas, }, }; export default de; ================================================ FILE: src/i18n/de/mas.ts ================================================ const mas = { mas_users: { name: "MAS-Benutzer |||| MAS-Benutzer", fields: { id: "MAS-ID", username: "Benutzername", admin: "Administrator", locked: "Gesperrt", deactivated: "Deaktiviert", legacy_guest: "Legacy-Gast", created_at: "Erstellt am", locked_at: "Gesperrt am", deactivated_at: "Deaktiviert am", }, filter: { status: "Status", search: "Suche", status_active: "Aktiv", status_locked: "Gesperrt", status_deactivated: "Deaktiviert", }, action: { lock: { label: "Sperren", success: "Benutzer gesperrt" }, unlock: { label: "Entsperren", success: "Benutzer entsperrt" }, deactivate: { label: "Deaktivieren", success: "Benutzer deaktiviert" }, reactivate: { label: "Reaktivieren", success: "Benutzer reaktiviert" }, set_admin: { label: "Admin erteilen", success: "Admin-Status aktualisiert" }, remove_admin: { label: "Admin entziehen", success: "Admin-Status aktualisiert" }, set_password: { label: "Passwort setzen", title: "Passwort setzen", success: "Passwort gesetzt", failure: "Passwort konnte nicht gesetzt werden", }, }, }, mas_user_emails: { name: "E-Mail |||| E-Mails", empty: "Keine E-Mails", fields: { email: "E-Mail", user_id: "Benutzer-ID", created_at: "Erstellt am", actions: "Aktionen", }, action: { remove: { label: "Entfernen", title: "E-Mail entfernen", content: "%{email} entfernen?", success: "E-Mail entfernt", }, create: { success: "E-Mail hinzugefügt" }, }, }, mas_compat_sessions: { name: "Compat-Sitzung |||| Compat-Sitzungen", empty: "Keine Compat-Sitzungen", fields: { user_id: "Benutzer-ID", device_id: "Geräte-ID", created_at: "Erstellt am", user_agent: "User Agent", last_active_at: "Zuletzt aktiv", last_active_ip: "Letzte IP", finished_at: "Beendet am", human_name: "Name", active: "Aktiv", }, action: { finish: { label: "Beenden", title: "Sitzung beenden?", content: "Diese Sitzung wird beendet.", success: "Sitzung beendet", }, }, }, mas_oauth2_sessions: { name: "OAuth2-Sitzung |||| OAuth2-Sitzungen", empty: "Keine OAuth2-Sitzungen", fields: { user_id: "Benutzer-ID", client_id: "Client-ID", scope: "Berechtigungsbereich", created_at: "Erstellt am", user_agent: "User Agent", last_active_at: "Zuletzt aktiv", last_active_ip: "Letzte IP", finished_at: "Beendet am", human_name: "Name", active: "Aktiv", }, action: { finish: { label: "Beenden", title: "Sitzung beenden?", content: "Diese Sitzung wird beendet.", success: "Sitzung beendet", }, }, }, mas_policy_data: { name: "Richtliniendaten", current_policy: "Aktuelle Richtlinie", no_policy: "Aktuell ist keine Richtlinie gesetzt.", set_policy: "Neue Richtlinie setzen", invalid_json: "Ungültiges JSON", fields: { json_placeholder: "Richtliniendaten als JSON eingeben…", created_at: "Erstellt am", }, action: { save: { label: "Richtlinie setzen", success: "Richtlinie aktualisiert", failure: "Richtlinie konnte nicht aktualisiert werden", }, }, }, mas_user_sessions: { name: "Browser-Sitzung |||| Browser-Sitzungen", fields: { user_id: "Benutzer-ID", created_at: "Erstellt am", finished_at: "Beendet am", user_agent: "User Agent", last_active_at: "Zuletzt aktiv", last_active_ip: "Letzte IP", active: "Aktiv", }, action: { finish: { label: "Beenden", title: "Sitzung beenden?", content: "Diese Browser-Sitzung wird beendet.", success: "Sitzung beendet", }, }, }, mas_upstream_oauth_links: { name: "Upstream-OAuth-Verknüpfung |||| Upstream-OAuth-Verknüpfungen", fields: { user_id: "Benutzer-ID", provider_id: "Anbieter-ID", subject: "Betreff", human_account_name: "Kontoname", created_at: "Erstellt am", }, helper: { provider_id: "Die ID des Upstream-OAuth-Anbieters. In der Liste der Upstream-OAuth-Anbieter zu finden.", }, action: { remove: { label: "Entfernen", title: "OAuth-Verknüpfung entfernen?", content: "Die Upstream-OAuth-Verknüpfung für diesen Benutzer wird entfernt.", success: "OAuth-Verknüpfung entfernt", }, }, }, mas_upstream_oauth_providers: { name: "OAuth-Anbieter |||| OAuth-Anbieter", fields: { issuer: "Aussteller", human_name: "Name", brand_name: "Marke", created_at: "Erstellt am", disabled_at: "Deaktiviert am", enabled: "Aktiv", }, }, mas_personal_sessions: { name: "Persönliche Sitzung |||| Persönliche Sitzungen", empty: "Keine persönlichen Sitzungen", fields: { owner_user_id: "Eigentümer-ID", actor_user_id: "Benutzer", human_name: "Name", scope: "Berechtigungsbereich", created_at: "Erstellt am", revoked_at: "Widerrufen am", last_active_at: "Zuletzt aktiv", last_active_ip: "Letzte IP", expires_at: "Läuft ab am", expires_in: "Läuft ab in (Sekunden)", active: "Aktiv", }, helper: { expires_in: "Optional. Anzahl der Sekunden bis zum Ablauf. Leer lassen für kein Ablaufdatum.", }, action: { revoke: { label: "Widerrufen", title: "Sitzung widerrufen?", content: "Das Zugriffstoken wird dauerhaft widerrufen.", success: "Sitzung widerrufen", }, create: { token_title: "Zugriffstoken erstellt", token_content: "Kopieren Sie dieses Token. Es wird nach dem Schließen dieses Dialogs nicht mehr angezeigt.", }, }, }, mas_sessions: { status: { active: "Aktiv", finished: "Beendet", revoked: "Widerrufen", }, }, }; export default mas; ================================================ FILE: src/i18n/de/misc_resources.ts ================================================ const misc_resources = { scheduled_tasks: { name: "Geplante Aufgabe |||| Geplante Aufgaben", fields: { id: "ID", action: "Aktion", status: "Status", timestamp: "Zeitstempel", resource_id: "Ressourcen-ID", result: "Ergebnis", error: "Fehler", max_timestamp: "Vor Datum", }, status: { scheduled: "Geplant", active: "Aktiv", complete: "Abgeschlossen", cancelled: "Abgebrochen", failed: "Fehlgeschlagen", }, }, connections: { name: "Verbindungen", fields: { last_seen: "Datum", ip: "IP-Adresse", user_agent: "User Agent", }, }, devices: { name: "Gerät |||| Geräte", fields: { device_id: "Geräte-ID", display_name: "Gerätename", last_seen_ts: "Zeitstempel", last_seen_ip: "IP-Adresse", last_seen_user_agent: "User-Agent", dehydrated: "Dehydriert", }, action: { erase: { title: "Entferne %{id}", title_bulk: "%{smart_count} Gerät entfernen |||| %{smart_count} Geräte entfernen", content: 'Möchten Sie das Gerät "%{name}" wirklich entfernen?', content_bulk: "Möchten Sie wirklich %{smart_count} Gerät entfernen? |||| Möchten Sie wirklich %{smart_count} Geräte entfernen?", success: "Gerät erfolgreich entfernt.", failure: "Beim Entfernen ist ein Fehler aufgetreten.", }, display_name: { success: "Gerätename aktualisiert", failure: "Gerätename konnte nicht aktualisiert werden", }, create: { label: "Gerät erstellen", title: "Neues Gerät erstellen", success: "Gerät erstellt", failure: "Gerät konnte nicht erstellt werden", }, }, }, users_media: { name: "Medien", fields: { media_id: "Medien-ID", media_length: "Größe", media_type: "Typ", upload_name: "Dateiname", quarantined_by: "Zur Quarantäne hinzugefügt", safe_from_quarantine: "Schutz vor Quarantäne", created_ts: "Erstellt", last_access_ts: "Letzter Zugriff", }, action: { open: "Mediendatei in neuem Fenster öffnen", }, }, protect_media: { action: { create: "Schützen", delete: "Schutz aufheben", none: "In Quarantäne", send_success: "Erfolgreich den Schutz-Status geändert.", send_failure: "Beim Versenden ist ein Fehler aufgetreten.", }, }, quarantine_media: { action: { name: "Quarantäne", create: "Quarantäne", delete: "Freigeben", none: "Geschützt", send_success: "Erfolgreich den Quarantäne-Status geändert.", send_failure: "Beim Versenden ist ein Fehler aufgetreten: %{error}", }, }, pushers: { name: "Pusher |||| Pushers", fields: { app: "App", app_display_name: "App-Anzeigename", app_id: "App ID", device_display_name: "Geräte-Anzeigename", kind: "Art", lang: "Sprache", profile_tag: "Profil-Tag", pushkey: "Pushkey", data: { url: "URL" }, }, }, servernotices: { name: "Serverbenachrichtigungen", send: "Servernachricht versenden", fields: { body: "Nachricht", }, action: { send: "Nachricht senden", send_success: "Nachricht erfolgreich versendet.", send_failure: "Beim Versenden ist ein Fehler aufgetreten.", }, helper: { send: 'Sendet eine Serverbenachrichtigung an die ausgewählten Benutzer. Hierfür muss das Feature "Server Notices" auf dem Server aktiviert sein.', }, }, database_room_statistics: { name: "Datenbank-Raumstatistiken", fields: { room_id: "Raum-ID", estimated_size: "Geschätzte Größe", }, helper: { info: "Zeigt den geschätzten Speicherplatz, der von jedem Raum in der Synapse-Datenbank verwendet wird. Die Angaben sind Näherungswerte.", }, }, user_media_statistics: { name: "Medien", fields: { media_count: "Anzahl der Dateien", media_length: "Größe der Dateien", }, }, forward_extremities: { name: "Vorderextremitäten", fields: { id: "Event-ID", received_ts: "Zeitstempel", depth: "Tiefe", state_group: "Zustandsgruppe", }, }, room_state: { name: "Zustandsereignisse", fields: { type: "Typ", content: "Inhalt", origin_server_ts: "Sendezeit", sender: "Absender", }, }, room_media: { name: "Medien", fields: { media_id: "Medien-ID", }, helper: { info: "Dies ist eine Liste der Medien, die in den Raum hochgeladen wurden. Es ist nicht möglich, Medien zu löschen, die in externen Medien-Repositories hochgeladen wurden.", }, action: { error: "%{errcode} (%{errstatus}) %{error}", }, }, room_directory: { name: "Raumverzeichnis", fields: { world_readable: "Gastbenutzer dürfen ohne Beitritt lesen", guest_can_join: "Gastbenutzer dürfen beitreten", }, action: { title: "Raum aus Verzeichnis löschen |||| %{smart_count} Räume aus Verzeichnis löschen", content: "Möchten Sie den Raum wirklich aus dem Raumverzeichnis löschen? |||| Möchten Sie die %{smart_count} Räume wirklich aus dem Raumverzeichnis löschen?", erase: "Aus Verzeichnis löschen", create: "Ins Verzeichnis eintragen", send_success: "Raum erfolgreich eingetragen.", send_failure: "Beim Entfernen ist ein Fehler aufgetreten.", }, }, destinations: { name: "Föderation", fields: { destination: "Ziel", failure_ts: "Fehlerzeitpunkt", retry_last_ts: "Letzter Wiederholungsversuch", retry_interval: "Wiederholungsintervall", last_successful_stream_ordering: "Letzter erfolgreicher Stream", stream_ordering: "Stream", }, action: { reconnect: "Neu verbinden" }, }, registration_tokens: { name: "Registrierungstoken", fields: { token: "Token", valid: "Gültige Token", uses_allowed: "Verwendungen erlaubt", pending: "Ausstehend", completed: "Abgeschlossen", expiry_time: "Ablaufzeit", length: "Länge", created_at: "Erstellt am", last_used_at: "Zuletzt verwendet am", revoked_at: "Widerrufen am", }, helper: { length: "Länge des Tokens, wenn kein Token vorgegeben wird." }, action: { revoke: { label: "Widerrufen", success: "Token widerrufen", }, unrevoke: { label: "Wiederherstellen", success: "Token wiederhergestellt", }, }, }, account_data: { name: "Kontodaten", }, joined_rooms: { name: "Beigetretene Räume", }, memberships: { name: "Mitgliedschaften", }, room_members: { name: "Mitglieder", }, destination_rooms: { name: "Räume", }, }; export default misc_resources; ================================================ FILE: src/i18n/de/reports.ts ================================================ const reports = { name: "Gemeldetes Ereignis |||| Gemeldete Ereignisse", fields: { id: "ID", received_ts: "Meldezeit", user_id: "Meldender", name: "Raumname", score: "Bewertung", reason: "Grund", event_id: "Ereignis-ID", sender: "Absender", }, action: { erase: { title: "Gemeldetes Ereignis löschen", content: "Sind Sie sicher, dass Sie das gemeldete Ereignis löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", }, event_lookup: { label: "Ereignis-Suche", title: "Ereignis nach ID abrufen", fetch: "Abrufen", }, fetch_event_error: "Fehler beim Abrufen des Ereignisses", }, }; export default reports; ================================================ FILE: src/i18n/de/rooms.ts ================================================ const rooms = { name: "Raum |||| Räume", fields: { room_id: "Raum-ID", name: "Name", canonical_alias: "Alias", joined_members: "Mitglieder", joined_local_members: "Lokale Mitglieder", joined_local_devices: "Lokale Endgeräte", state_events: "Zustandsereignisse / Komplexität", version: "Version", is_encrypted: "Verschlüsselt", encryption: "Verschlüsselungs-Algorithmus", federatable: "Fö­de­rierbar", public: "Sichtbar im Raumverzeichnis", creator: "Ersteller", join_rules: "Beitrittsregeln", guest_access: "Gastzugriff", history_visibility: "Historie-Sichtbarkeit", topic: "Thema", avatar: "Avatar", actions: "Aktionen", }, filter: { public_rooms: "Öffentliche Räume", empty_rooms: "Leere Räume", local_members_only: "Nur lokale Mitglieder", }, helper: { forward_extremities: "Vorderextremitäten sind Blatt-Ereignisse am Ende eines gerichteten azyklischen Graphen (DAG) in einem Raum, d. h. Ereignisse ohne Nachkommen. Je mehr in einem Raum existieren, umso mehr Zustandsauflösungen muss Synapse absolvieren (Hinweis: dies ist eine sehr aufwendige Operation). Obwohl Synapse Code hat um zu verhindern, dass zuviele davon gleichzeitig in einem Raum existieren, können Bugs manchmal dafür sorgen, dass sie sich ansammeln. Wenn ein Raum >10 Vorderextremitäten hat, ist es sinnvoll, sie gegebenenfalls, wie in #1760 beschrieben, mittels SQL-Queries zu entfernen.", }, enums: { join_rules: { public: "Öffentlich", knock: "Auf Anfrage", invite: "Nur auf Einladung", private: "Privat", restricted: "Eingeschränkt", }, guest_access: { can_join: "Gäste können beitreten", forbidden: "Gäste können nicht beitreten", }, history_visibility: { invited: "Ab Einladung", joined: "Ab Beitritt", shared: "Ab Setzen der Einstellung", world_readable: "Jeder", }, unencrypted: "Nicht verschlüsselt", room_type: { room: "Raum", space: "Space", }, }, action: { erase: { title: "Raum löschen", content: "Sind Sie sicher, dass Sie den Raum löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden. Alle Nachrichten und Medien, die der Raum beinhaltet werden vom Server gelöscht!", fields: { block: "Blockieren und Benutzer daran hindern, dem Raum beizutreten", }, in_progress: "Löschung läuft…", background_note: "Sie können dieses Fenster bedenkenlos schließen, die Löschung wird im Hintergrund fortgesetzt.", success: "Raum/Räume erfolgreich gelöscht.", failure: "Der/die Raum/Räume konnten nicht gelöscht werden.", }, make_admin: { assign_admin: "Raumadministrator zuweisen", title: "Raumadministrator zu %{roomName} zuweisen", confirm: "Raumadministrator zuweisen", content: "Geben Sie die vollständige MXID des Benutzers an, der als Administrator gesetzt werden soll.\nWarnung: Damit dies funktioniert, muss der Raum mindestens ein lokales Mitglied als Administrator haben.", success: "Der/die Benutzer wurde/n als Raumadministrator gesetzt.", failure: "Der/die Benutzer konnte/n nicht als Raumadministrator gesetzt werden. %{errMsg}", }, join: { label: "Benutzer hinzufügen", title: "Benutzer zu %{roomName} hinzufügen", confirm: "Hinzufügen", content: "Geben Sie die vollständige MXID des Benutzers ein, der diesem Raum beitreten soll.\nHinweis: Sie müssen im Raum sein und die Berechtigung haben, Benutzer einzuladen.", success: "Benutzer wurde erfolgreich zum Raum hinzugefügt.", failure: "Benutzer konnte dem Raum nicht hinzugefügt werden. %{errMsg}", }, block: { label: "Sperren", title: "%{room} sperren", title_bulk: "%{smart_count} Raum sperren |||| %{smart_count} Räume sperren", title_by_id: "Raum sperren", content: "Benutzer werden daran gehindert, diesem Raum beizutreten.", content_bulk: "Benutzer werden daran gehindert, %{smart_count} Raum beizutreten. |||| Benutzer werden daran gehindert, %{smart_count} Räumen beizutreten.", success: "Raum erfolgreich gesperrt. |||| Räume erfolgreich gesperrt.", failure: "Raum konnte nicht gesperrt werden. |||| Räume konnten nicht gesperrt werden.", }, unblock: { label: "Entsperren", success: "Raum erfolgreich entsperrt. |||| Räume erfolgreich entsperrt.", failure: "Raum konnte nicht entsperrt werden. |||| Räume konnten nicht entsperrt werden.", }, purge_history: { label: "Verlauf bereinigen", title: "Verlauf von %{roomName} bereinigen", content: "Alle Ereignisse vor dem ausgewählten Datum werden aus der Datenbank gelöscht. Raumstatus (Beitritte, Austritte, Thema) bleibt erhalten. Mindestens eine Nachricht bleibt immer erhalten.\nHinweis: Dieser Vorgang kann bei großen Räumen mehrere Minuten dauern.", date_label: "Ereignisse bereinigen vor", delete_local: "Auch von lokalen Benutzern gesendete Ereignisse löschen", in_progress: "Bereinigung läuft…", background_note: "Sie können dieses Fenster bedenkenlos schließen, die Bereinigung wird im Hintergrund fortgesetzt.", success: "Raumverlauf erfolgreich bereinigt.", failure: "Bereinigung des Raumverlaufs fehlgeschlagen. %{errMsg}", }, quarantine_all: { label: "Alle Medien unter Quarantäne stellen", title: "Alle Medien in %{roomName} unter Quarantäne stellen", content: "Alle lokalen und remote Medien in diesem Raum werden unter Quarantäne gestellt. Unter Quarantäne gestellte Medien sind für Benutzer nicht mehr zugänglich.", success: "%{smart_count} Medienelement erfolgreich unter Quarantäne gestellt. |||| %{smart_count} Medienelemente erfolgreich unter Quarantäne gestellt.", failure: "Quarantäne fehlgeschlagen. %{errMsg}", }, delete_all_media: { label: "Alle Medien löschen", title: "Alle Medien in %{roomName} löschen", content: "Alle lokalen Medien in diesem Raum werden dauerhaft gelöscht. Nur lokale Medien aus unverschlüsselten Räumen sind betroffen — Medien von externen Servern sind ausgeschlossen. Diese Aktion kann nicht rückgängig gemacht werden.", in_progress_loading: "Medienliste wird abgerufen…", in_progress: "Medien werden gelöscht… (%{current} / %{total})", do_not_close: "Schließen Sie diesen Dialog nicht — der Löschvorgang läuft im Vordergrund und wird unterbrochen, wenn Sie ihn schließen.", success: "Erfolgreich %{smart_count} Medienelement gelöscht. |||| Erfolgreich %{smart_count} Medienelemente gelöscht.", failure: "Medien konnten nicht gelöscht werden. %{errMsg}", }, delete_all_media_bulk: { title: "Alle Medien für %{smart_count} Raum löschen? |||| Alle Medien für %{smart_count} Räume löschen?", content: "Alle lokalen Medien in den ausgewählten Räumen werden dauerhaft gelöscht (nur unverschlüsselte Räume). Medien von externen Servern sind ausgeschlossen. Diese Aktion kann nicht rückgängig gemacht werden.", success: "Medien für %{success} von %{total} Räumen gelöscht.", partial_failure: "Medien für %{success} von %{total} Räumen gelöscht. %{failed} fehlgeschlagen.", }, event_context: { lookup_title: "Ereignis nach ID nachschlagen", jump_to_date: "Zu Datum springen", direction: "Richtung", forward: "Vorwärts", backward: "Rückwärts", target_event: "Zielereignis", events_before: "Ereignisse davor", events_after: "Ereignisse danach", not_found: "Kein Ereignis zum angegebenen Zeitpunkt gefunden", failure: "Ereigniskontext konnte nicht abgerufen werden", }, messages: { load_older: "Ältere laden", load_newer: "Neuere laden", no_messages: "Keine Nachrichten in diesem Raum", failure: "Nachrichten konnten nicht geladen werden", filter: "Filter", filter_type: "Ereignistypen", filter_sender: "Absender", advanced_filters: "Erweiterte Filter", filter_not_type: "Ereignistypen ausschließen", filter_not_sender: "Absender ausschließen", contains_url: "Enthält URL", any: "Beliebig", with_url: "Nur mit URL", without_url: "Nur ohne URL", apply_filter: "Anwenden", clear_filters: "Zurücksetzen", }, hierarchy: { load_more: "Mehr laden", max_depth: "Maximale Tiefe", unlimited: "Unbegrenzt", refresh: "Aktualisieren", members: "%{count} Mitglieder", space: "Space", room: "Raum", suggested: "Empfohlen", no_children: "Dieser Raum hat keine untergeordneten Räume", failure: "Hierarchie konnte nicht geladen werden", }, }, }; export default rooms; ================================================ FILE: src/i18n/de/users.ts ================================================ const users = { name: "Benutzer", email: "E-Mail", msisdn: "Telefon", threepid: "E-Mail / Telefon", membership: "Mitgliedschaft |||| Mitgliedschaften", fields: { avatar: "Avatar", id: "Benutzer-ID", name: "Name", is_guest: "Gast", admin: "Server Administrator", locked: "Gesperrt", suspended: "Suspendiert", shadow_banned: "Schattengebannt", deactivated: "Deaktiviert", erased: "Gelöscht", show_guests: "Gäste anzeigen", show_deactivated: "Nur deaktivierte anzeigen", show_locked: "Gesperrte Benutzer anzeigen", filter_user_all: "Alle", filter_deactivated_false: "Aktiv", filter_deactivated_true: "Deaktiviert", filter_locked_false: "Gesperrte ausschließen", filter_locked_true: "Gesperrte einschließen", filter_guests_false: "Gäste ausschließen", filter_guests_true: "Gäste einschließen", show_system_users: "Systembenutzer anzeigen", filter_system_users_false: "Systembenutzer ausblenden", filter_system_users_true: "Nur Systembenutzer", show_suspended: "Suspendierte Benutzer anzeigen", show_shadow_banned: "Schattengebannte Benutzer anzeigen", user_id: "Benutzer suchen", displayname: "Anzeigename", password: "Passwort", avatar_url: "Avatar URL", avatar_src: "Avatar", medium: "Medium", threepids: "3PIDs", address: "Adresse", creation_ts_ms: "Zeitpunkt der Erstellung", consent_version: "Zugestimmte Geschäftsbedingungen", sent_invite_count: "Gesendete Einladungen", cumulative_joined_room_count: "Kumulierte beigetretene Räume", auth_provider: "Provider", user_type: "Benutzertyp", }, helper: { password: "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.", password_required_for_reactivation: "Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.", create_password: "Generieren Sie ein starkes und sicheres Passwort mit dem Button unten.", deactivate: "Markiert den Benutzer als inaktiv. Diese Aktion kann rückgängig gemacht werden.", suspend: "Ein suspendierter Benutzer wird in den schreibgeschützten Modus versetzt und kann keine Änderungen mehr vornehmen.", shadow_ban: "Ein schattengebannter Benutzer erhält normale Antworten, aber seine Ereignisse werden nicht an andere Benutzer oder Räume übertragen. Nur als letztes Mittel verwenden.", erase: "GDPR-konformes Löschen der Benutzerdaten.", admin: "Ein Serveradministrator hat volle Kontrolle über den Server und seine Benutzer.", lock: "Verhindert, dass der Benutzer den Server nutzen kann. Dies ist eine nicht-destruktive Aktion, die rückgängig gemacht werden kann.", erase_text: "Das bedeutet, dass die von dem/den Benutzer(n) gesendeten Nachrichten für alle, die zum Zeitpunkt des Sendens im Raum waren, sichtbar bleiben, aber für Benutzer, die dem Raum später beitreten, nicht sichtbar sind.", erase_admin_error: "Das Löschen des eigenen Benutzers ist nicht erlaubt.", modify_managed_user_error: "Das Ändern eines vom System verwalteten Benutzers ist nicht zulässig.", username_available: "Benutzername verfügbar", sent_invite_count: "Gesamtzahl der von diesem Benutzer in allen Räumen gesendeten Einladungen.", cumulative_joined_room_count: "Gesamtzahl der Räume, denen dieser Benutzer jemals beigetreten ist, einschließlich Räume, die er verlassen hat oder aus denen er verbannt wurde.", }, badge: { you: "Sie", bot: "Bot", admin: "Administrator", support: "Support", regular: "Normaler Benutzer", federated: "Föderierter Benutzer", system_managed: "Systemverwalteter Benutzer", }, action: { erase: "Benutzerdaten löschen", erase_avatar: "Avatar löschen", delete_media: "Alle von dem/den Benutzer(n) hochgeladenen Medien löschen", redact_events: "Schwärzen aller vom Benutzer gesendeten Ereignisse (-s)", redact_in_progress: "Schwärzung läuft\u2026", redact_background_note: "Sie können diesen Dialog bedenkenlos schließen, die Schwärzung wird im Hintergrund fortgesetzt.", redact_success: "Alle Ereignisse wurden erfolgreich geschwärzt.", redact_failure: "Schwärzung mit %{smart_count} fehlgeschlagenem Ereignis abgeschlossen. |||| Schwärzung mit %{smart_count} fehlgeschlagenen Ereignissen abgeschlossen.", generate_password: "Passwort generieren", reset_password: { label: "Passwort zurücksetzen", title: "Passwort zurücksetzen", helper: "Passwort von %{user} ändern", password: "Passwort", logout_devices: "Von allen Geräten abmelden", success: "Passwort wurde erfolgreich zurückgesetzt", failure: "Passwort konnte nicht zurückgesetzt werden", error_no_password: "Passwort ist erforderlich", }, login_as: { label: "Als Benutzer anmelden", title: "Als Benutzer anmelden", helper: "Zugriffstoken erhalten, um sich als %{user} zu authentifizieren. Diese Aktion erstellt kein neues Gerät für den Benutzer und erscheint daher nicht in der Geräte-/Sitzungsliste. Der Zielbenutzer sollte in der Regel nicht erkennen können, dass jemand sich als er angemeldet hat.", valid_until: "Ablaufdatum festlegen", success: "Zugriffstoken erfolgreich erstellt", failure: "Zugriffstoken konnte nicht erstellt werden", result_title: "Zugriffstoken von %{user}", access_token: "Zugriffstoken", expires_at: "Dieses Zugriffstoken läuft am %{date} ab", }, overwrite_title: "Warnung!", overwrite_content: "Dieser Benutzername ist bereits vergeben. Sind Sie sicher, dass Sie den vorhandenen Benutzer überschreiben möchten?", overwrite_cancel: "Abbrechen", overwrite_confirm: "Überschreiben", quarantine_all: { label: "Alle Medien unter Quarantäne stellen", title: "Alle Medien von %{userName} unter Quarantäne stellen", content: "Alle lokalen Medien dieses Benutzers werden unter Quarantäne gestellt. Unter Quarantäne gestellte Medien sind für andere Benutzer nicht mehr zugänglich.", success: "%{smart_count} Medienelement erfolgreich unter Quarantäne gestellt. |||| %{smart_count} Medienelemente erfolgreich unter Quarantäne gestellt.", failure: "Quarantäne fehlgeschlagen. %{errMsg}", }, delete_all_media: { label: "Alle Medien löschen", title: "Alle Medien von %{userName} löschen", content: "Alle von diesem Benutzer hochgeladenen Medien werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", in_progress: "Medien werden gelöscht…", background_note: "Sie können diesen Dialog sicher schließen — der Löschvorgang wird im Hintergrund fortgesetzt.", success: "Erfolgreich %{smart_count} Medienelement gelöscht. |||| Erfolgreich %{smart_count} Medienelemente gelöscht.", failure: "Medien konnten nicht gelöscht werden. %{errMsg}", }, delete_all_media_bulk: { title: "Alle Medien für %{smart_count} Benutzer löschen? |||| Alle Medien für %{smart_count} Benutzer löschen?", content: "Alle von den ausgewählten Benutzern hochgeladenen Medien werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", success: "Medien für %{success} von %{total} Benutzern gelöscht.", partial_failure: "Medien für %{success} von %{total} Benutzern gelöscht. %{failed} fehlgeschlagen.", }, allow_cross_signing: { label: "Cross-Signing-Reset erlauben", title: "Cross-Signing-Schlüsselersatz erlauben", content: "Soll %{user} erlaubt werden, ihre Cross-Signing-Schlüssel ohne benutzerinteraktive Authentifizierung zu ersetzen? Dies öffnet ein temporäres Fenster, in dem die Schlüssel ersetzt werden können.", success: "Cross-Signing-Schlüsselersatz erlaubt bis %{deadline}", failure: "Cross-Signing-Ersatz konnte nicht erlaubt werden", no_key: "Benutzer hat keinen Master-Cross-Signing-Schlüssel", }, find_user: { label: "Benutzer suchen", title: "Benutzer suchen", lookup_type: "Suchart", by_threepid: "Per E-Mail / Telefonnummer", by_auth_provider: "Per Authentifizierungsanbieter", provider: "Authentifizierungsanbieter-ID", external_id: "Externe ID", search: "Suchen", not_found: "Benutzer nicht gefunden", failure: "Benutzersuche fehlgeschlagen", }, renew_account: { label: "Konto erneuern", title: "Kontogültigkeit erneuern", content: "Erneuert die Kontogültigkeit für %{user}. Optional kann ein benutzerdefiniertes Ablaufdatum festgelegt werden. Wenn leer gelassen, wird der standardmäßige Erneuerungszeitraum des Servers verwendet.", expiration: "Ablaufdatum", expiration_helper: "Leer lassen, um den standardmäßigen Erneuerungszeitraum des Servers zu verwenden", renewal_emails: "Erneuerungs-Benachrichtigungs-E-Mails senden", success: "Kontogültigkeit bis %{date} erneuert", failure: "Erneuerung der Kontogültigkeit fehlgeschlagen", }, system_users_scan_in_progress: "Einen Moment – es werden noch passende Benutzer gesucht, die Seite wird gleich geladen", reverse_search_scan_in_progress: "Einen Moment – alle Benutzer werden nach Übereinstimmungen durchsucht und diese ausgeschlossen, die Seite wird gleich geladen", }, limits: { messages_per_second: "Nachrichten pro Sekunde", messages_per_second_text: "Die Anzahl der Aktionen, die in einer Sekunde durchgeführt werden können.", burst_count: "Burst-Anzahl", burst_count_text: "Die Anzahl der Aktionen, die vor der Begrenzung durchgeführt werden können.", }, account_data: { title: "Kontodaten", global: "Globale", rooms: "Räume", }, }; export default users; ================================================ FILE: src/i18n/en/common.ts ================================================ import englishMessages from "ra-language-english"; // Top-level non-resource keys: ra built-ins + ketesa + import_users + delete_media + purge_remote_media + etkecc const common = { ...englishMessages, ketesa: { auth: { base_url: "Homeserver URL", welcome: "Welcome to %{name}", description: "The evolution of Synapse Admin. Manage, monitor, and maintain your Matrix homeserver from one clean interface. Built for small private servers and large federated communities alike.", server_version: "Synapse version", supports_specs: "Supports Matrix specifications", username_error: "Please enter a fully qualified user ID: '@user:domain'", protocol_error: "URL has to start with 'http://' or 'https://'", url_error: "Not a valid Matrix server URL", sso_sign_in: "Sign in with SSO", credentials: "Credentials", access_token: "Access token", logout_access_token_dialog: { title: "You are using an existing Matrix access token.", content: "Do you want to destroy this session (that could be used elsewhere, e.g. in a Matrix client) or just log out of the admin panel?", confirm: "Destroy session", cancel: "Just log out of the admin panel", }, }, users: { invalid_user_id: "Enter the localpart of a Matrix user ID only — do not include the homeserver.", tabs: { sso: "SSO", experimental: "Experimental", limits: "Rate Limits", account_data: "Account Data", sessions: "Sessions", }, danger_zone: "Danger zone", }, rooms: { details: "Room details", tabs: { basic: "Basic", members: "Members", detail: "Details", permission: "Permissions", media: "Media", messages: "Messages", hierarchy: "Hierarchy", }, }, reports: { tabs: { basic: "Basic", detail: "Details" } }, admin_config: { soft_failed_events: "Soft-failed events", spam_flagged_events: "Spam-flagged events", success: "Admin config updated", failure: "Failed to update admin config", }, }, import_users: { error: { at_entry: "At entry %{entry}: %{message}", error: "Error", required_field: "Required field '%{field}' is not present", invalid_value: "Invalid value on line %{row}. '%{field}' field may only be 'true' or 'false'", unreasonably_big: "Refused to load unreasonably big file of %{size} megabytes", already_in_progress: "An import run is already in progress", id_exits: "ID %{id} already exists", }, title: "Import users via CSV", goToPdf: "Go to PDF", cards: { importstats: { header: "Parsed users for import", users_total: "%{smart_count} user in CSV file |||| %{smart_count} users in CSV file", guest_count: "%{smart_count} guest |||| %{smart_count} guests", admin_count: "%{smart_count} admin |||| %{smart_count} admins", }, conflicts: { header: "Conflict strategy", mode: { stop: "Stop on conflict", skip: "Show error and skip on conflict", }, }, ids: { header: "IDs", all_ids_present: "All entries have an ID", count_ids_present: "%{smart_count} entry has an ID |||| %{smart_count} entries have IDs", mode: { ignore: "Ignore IDs in CSV and create new ones", update: "Update existing records", }, }, passwords: { header: "Passwords", all_passwords_present: "All entries have a password", count_passwords_present: "%{smart_count} entry has a password |||| %{smart_count} entries have passwords", use_passwords: "Use passwords from CSV", }, upload: { header: "Input CSV file", explanation: "Here you can upload a file with comma separated values that is processed to create or update users. The file must include the fields 'id' and 'displayname'. You can download and adapt an example file here: ", }, startImport: { simulate_only: "Simulate only", run_import: "Import", }, results: { header: "Import results", total: "%{smart_count} entry in total |||| %{smart_count} entries in total", successful: "%{smart_count} entries successfully imported", skipped: "%{smart_count} entries skipped", download_skipped: "Download skipped records", with_error: "%{smart_count} entry with errors |||| %{smart_count} entries with errors", simulated_only: "This was a simulation only — no changes were made", }, }, }, delete_media: { name: "Media", fields: { before_ts: "Last accessed before", size_gt: "Larger than (in bytes)", keep_profiles: "Keep profile images", }, action: { send: "Delete media", send_success: "Successfully deleted %{smart_count} media file. |||| Successfully deleted %{smart_count} media files.", send_success_none: "No media files matched the specified criteria. Nothing was deleted.", send_failure: "An error has occurred.", }, helper: { send: "This API deletes the local media from the disk of your own server. This includes any local thumbnails and copies of media downloaded. This API will not affect media that has been uploaded to external media repositories.", }, }, purge_remote_media: { name: "Remote Media", fields: { before_ts: "Last accessed before", }, action: { send: "Purge remote media", send_success: "Successfully purged %{smart_count} remote media file. |||| Successfully purged %{smart_count} remote media files.", send_success_none: "No remote media files matched the specified criteria. Nothing was purged.", send_failure: "An error has occurred with the purge remote media request.", }, helper: { send: "This API purges the remote media cache from the disk of your own server. This includes any local thumbnails and copies of media downloaded. This API will not affect media that has been uploaded to the server's own media repository.", }, }, etkecc: { donate: { menu_label: "Donate", name: "Support Ketesa Development", title: "Support Ketesa Development", description_1: "The Ketesa project is free and open source, and we build and maintain it in the open for the Matrix community.", description_2: "If the Ketesa project has been useful to you, a donation helps us continue the work behind it: development, maintenance, fixes, and steady improvements.", description_3: "It helps us spend more time improving the project for everyone who relies on it.", description_4: "Every contribution helps, and we truly appreciate your support! ❤️", button: "Donate", signature_team: "the etke.cc team", }, components: { name: "Components", description: "View and manage your active components and discover what's available to add to your server.", no_section: "Your Server", per_month: "/mo", included: "Included", total: "Total", loading: "Loading components...", state_add: "Add", state_remove: "Remove", add_aria: "Request to add %{name}", remove_aria: "Request removal of %{name}", preview_label: "preview", request_changes: "Request changes", requesting: "Sending...", request_failure: "Failed to send the change request. Please try again.", request_sent_title: "Request submitted", request_sent_body: "Your component change request has been sent to etke.cc support. If you need additional changes, please reply to this support request rather than opening a new one.", request_sent_close: "Close", request_sent_view: "View request", request_already_sent: "A change request is already open. To request more changes, reply to your existing support ticket.", request_already_sent_view: "View ticket", free_label: "Free", available_label: "Available", tagline: "Enhance your server — add or remove any component at any time.", section: { bridges: "Bridges", extras: "Extras", matrix_apps: "Matrix Apps", matrix_bots: "Matrix Bots", matrix_extras: "Matrix Extras", }, }, billing: { name: "Billing", title: "Payment History", no_payments: "No payments found.", no_payments_helper: "If you believe this is an error, please contact etke.cc support.", description1: "View payments and generate invoices here. You can learn more about subscription management at", description2: "To change your billing email or add company details to invoices, see", fields: { transaction_id: "Transaction ID", email: "Email", type: "Type", amount: "Amount", paid_at: "Paid at", invoice: "Invoice", }, enums: { type: { subscription: "Subscription", one_time: "One-time", }, }, helper: { download_invoice: "Download Invoice", downloading: "Downloading...", download_started: "Invoice download started.", invoice_not_available: "Invoice pending", loading: "Loading billing information...", loading_failed1: "There was a problem loading billing information.", loading_failed2: "Please try again later.", loading_failed3: "If the problem persists, please contact etke.cc support.", loading_failed4: "with the following error message:", }, components: "Active Components", components_no_section: "Your Server", components_per_month: "/mo", components_included: "Included", components_total: "Total", components_help_title: "Learn more about %{name}", components_state_install: "Install", components_state_remove: "Remove", components_remove_aria: "Install/remove %{name}", components_preview_label: "preview", components_request_changes: "Request changes", components_requesting: "Sending...", components_request_failure: "Failed to send the change request. Please try again.", components_request_sent_title: "Request submitted", components_request_sent_body: "Your component change request has been sent to etke.cc support. If you need additional changes, please reply to this support request rather than opening a new one.", components_request_sent_close: "Close", components_request_sent_view: "View request", components_request_already_sent: "A change request is already open. To request more changes, reply to your existing support ticket.", components_request_already_sent_view: "View ticket", status: { issue: { title: "Subscription needs attention", description: "We noticed an issue with your subscription. Don't worry — it's easy to fix.", due_overdue: "Overdue since", due_upcoming: "Due in", expected: "Expected amount", last_paid: "Last paid", fix_link: "Fix overdue payment", fix_mismatch_link: "Update subscription price", support_link: "Contact support", }, }, }, status: { name: "Server Status", badge: { default: "Click to view Server Status", running: "Running: %{command}. %{text}", status_ok: "Server is online", status_error: "Status: Error", status_maintenance: "The system is currently in maintenance mode.", status_process_running: "Server is running a command", status_checking: "Checking server status", }, category: { "Host Metrics": "Host Metrics", Network: "Network", HTTP: "HTTP", Matrix: "Matrix", }, status: "Status", error: "Error", loading: "Fetching real-time server status — just a moment…", intro1: "This is a real-time monitoring report for your server. You can learn more about it at", intro2: "If any of the checks below concern you, see the suggested actions at", help: "Help", }, maintenance: { title: "The system is currently in maintenance mode.", try_again: "Please try again later.", note: "You don't need to contact support about this — we are already working on it!", }, actions: { name: "Server Actions", available_title: "Available Commands", available_description: "The following commands are available to run on your server.", available_help_intro: "More details about each command can be found at", scheduled_title: "Scheduled commands", scheduled_description: "The following commands are scheduled to run at specific times. You can view their details and modify them as needed.", recurring_title: "Recurring commands", recurring_description: "The following commands are set to run on a specific weekday and time each week. You can view their details and modify them as needed.", scheduled_help_intro: "More details about this feature can be found at", recurring_help_intro: "More details about this feature can be found at", maintenance_title: "The system is currently in maintenance mode.", maintenance_try_again: "Please try again later.", maintenance_note: "You don't need to contact support about this — we are already working on it!", maintenance_commands_blocked: "Commands cannot be run until maintenance mode is disabled.", table: { aria_label: "Server commands", command: "Command", description: "Description", arguments: "Arguments", is_recurring: "Is recurring?", run_at: "Run at (local time)", next_run_at: "Next run at (local time)", time_utc: "Time (UTC)", time_local: "Time (local time)", }, buttons: { create: "Create", update: "Update", back: "Back", delete: "Delete", run: "Run", }, command_scheduled: "Command scheduled: %{command}", command_scheduled_args: "with additional args: %{args}", expect_prefix: "Your result will appear on the", expect_suffix: "page shortly.", notifications_link: "Notifications", command_help_title: "%{command} help", scheduled_title_create: "Create Scheduled Command", scheduled_title_edit: "Edit Scheduled Command", recurring_title_create: "Create Recurring Command", recurring_title_edit: "Edit Recurring Command", scheduled_details_title: "Scheduled Command Details", recurring_warning: "Scheduled commands generated from a recurring command cannot be edited, as they are regenerated automatically. To make changes, edit the recurring command instead.", command_details_intro: "You can find more details about the command at", form: { id: "ID", command: "Command", scheduled_at: "Scheduled at", day_of_week: "Day of Week", }, delete_scheduled_title: "Delete Scheduled Command", delete_recurring_title: "Delete Recurring Command", delete_confirm: "Are you sure you want to delete the command: %{command}?", errors: { unknown: "Unknown error occurred", delete_failed: "Error: %{error}", }, days: { monday: "Monday", tuesday: "Tuesday", wednesday: "Wednesday", thursday: "Thursday", friday: "Friday", saturday: "Saturday", sunday: "Sunday", }, scheduled: { action: { create_success: "Scheduled command created successfully", update_success: "Scheduled command updated successfully", update_failure: "An error has occurred", delete_success: "Scheduled command deleted successfully", delete_failure: "An error has occurred", }, }, recurring: { action: { create_success: "Recurring command created successfully", update_success: "Recurring command updated successfully", update_failure: "An error has occurred", delete_success: "Recurring command deleted successfully", delete_failure: "An error has occurred", }, }, }, notifications: { title: "Notifications", new_notifications: "%{smart_count} new notification |||| %{smart_count} new notifications", no_notifications: "No notifications yet", see_all: "See all notifications", clear_all: "Clear all", ago: "ago", advisory_tooltip: "You may have missed a notification. Please also check #news:etke.cc, etke.cc/news, or your email.", unavailable_tooltip: "Notifications may be unavailable. Click for details.", unavailable_title: "Notifications may be unavailable right now", unavailable_body: "There may be updates we can't deliver to this panel right now — or there may be nothing new. To avoid missing anything, please check periodically:", unavailable_link_matrix: "Matrix room #news:etke.cc", unavailable_link_news: "Announcements page at etke.cc/news", unavailable_link_email: "Your email inbox (including spam folder)", unavailable_retry: "Retry", }, currently_running: { command: "Currently running:", started_ago: "(started %{time} ago)", }, time: { less_than_minute: "just now", minutes: "%{smart_count} minute |||| %{smart_count} minutes", hours: "%{smart_count} hour |||| %{smart_count} hours", days: "%{smart_count} day |||| %{smart_count} days", weeks: "%{smart_count} week |||| %{smart_count} weeks", months: "%{smart_count} month |||| %{smart_count} months", }, support: { name: "Support", menu_label: "Contact support", description: "Open a support request or follow up on an existing one. Our team will respond as soon as possible.", create_title: "New Support Request", no_requests: "No support requests yet.", no_messages: "No messages yet.", closed_message: "This request is closed. If you still need help, please open a new request.", fields: { subject: "Subject", message: "Message", reply: "Reply", status: "Status", created_at: "Created", updated_at: "Last updated", }, status: { active: "Awaiting response from support", open: "Open", closed: "Closed", pending: "Waiting for you", }, buttons: { new_request: "New Request", submit: "Submit", cancel: "Cancel", send: "Send", back: "Back to Support", attach_files: "Attach Files", }, helper: { loading: "Loading support requests...", reply_hint: "Press Ctrl+Enter to send", reply_placeholder: "Include as much detail as possible.", before_contact_title: "Before you contact us", help_pages_prompt: "Please check our Help pages first:", services_prompt: "We only provide services listed on:", topics_prompt: "We can help only with supported topics:", scope_confirm_label: "I checked the Help pages and confirm this request matches the supported topics.", english_only_notice: "Support is provided in English only.", response_time_prompt: "We aim to respond within 48 hours. Need a faster response? See:", attachments_limit: "Up to 5 files, 5 MB each, 10 MB total.", close_request_label: "Close this request after sending", }, actions: { create_success: "Support request created successfully.", create_failure: "Failed to create support request.", send_failure: "Failed to send message.", attachment_too_large: 'File "%{name}" exceeds the 5 MB limit.', too_many_attachments: "Maximum 5 files allowed.", total_size_exceeded: "Total attachment size exceeds 10 MB.", }, }, }, }; export default common; ================================================ FILE: src/i18n/en/index.ts ================================================ import { SynapseTranslationMessages } from "../types"; import common from "./common"; import mas from "./mas"; import misc_resources from "./misc_resources"; import reports from "./reports"; import rooms from "./rooms"; import users from "./users"; const en: SynapseTranslationMessages = { ra: common.ra, ketesa: common.ketesa, import_users: common.import_users, delete_media: common.delete_media, purge_remote_media: common.purge_remote_media, etkecc: common.etkecc, resources: { users, rooms, reports, scheduled_tasks: misc_resources.scheduled_tasks, connections: misc_resources.connections, devices: misc_resources.devices, users_media: misc_resources.users_media, protect_media: misc_resources.protect_media, quarantine_media: misc_resources.quarantine_media, pushers: misc_resources.pushers, servernotices: misc_resources.servernotices, database_room_statistics: misc_resources.database_room_statistics, user_media_statistics: misc_resources.user_media_statistics, forward_extremities: misc_resources.forward_extremities, room_state: misc_resources.room_state, room_media: misc_resources.room_media, room_directory: misc_resources.room_directory, destinations: misc_resources.destinations, registration_tokens: misc_resources.registration_tokens, account_data: misc_resources.account_data, joined_rooms: misc_resources.joined_rooms, memberships: misc_resources.memberships, room_members: misc_resources.room_members, destination_rooms: misc_resources.destination_rooms, ...mas, }, }; export default en; ================================================ FILE: src/i18n/en/mas.ts ================================================ const mas = { mas_users: { name: "MAS User |||| MAS Users", fields: { id: "MAS ID", username: "Username", admin: "Admin", locked: "Locked", deactivated: "Deactivated", legacy_guest: "Legacy Guest", created_at: "Created at", locked_at: "Locked at", deactivated_at: "Deactivated at", }, filter: { status: "Status", search: "Search", status_active: "Active", status_locked: "Locked", status_deactivated: "Deactivated", }, action: { lock: { label: "Lock", success: "User locked" }, unlock: { label: "Unlock", success: "User unlocked" }, deactivate: { label: "Deactivate", success: "User deactivated" }, reactivate: { label: "Reactivate", success: "User reactivated" }, set_admin: { label: "Grant Admin", success: "Admin status updated" }, remove_admin: { label: "Remove Admin", success: "Admin status updated" }, set_password: { label: "Set Password", title: "Set Password", success: "Password set", failure: "Failed to set password", }, }, }, mas_user_emails: { name: "Email |||| Emails", empty: "No emails", fields: { email: "Email", user_id: "User ID", created_at: "Created at", actions: "Actions", }, action: { remove: { label: "Remove", title: "Remove email", content: "Remove %{email}?", success: "Email removed", }, create: { success: "Email added" }, }, }, mas_compat_sessions: { name: "Compatibility Session |||| Compatibility Sessions", empty: "No compatibility sessions", fields: { user_id: "User ID", device_id: "Device ID", created_at: "Created at", user_agent: "User Agent", last_active_at: "Last active", last_active_ip: "Last IP", finished_at: "Finished at", human_name: "Name", active: "Active", }, action: { finish: { label: "Terminate", title: "Terminate this session?", content: "This will terminate the session.", success: "Session terminated", }, }, }, mas_oauth2_sessions: { name: "OAuth2 Session |||| OAuth2 Sessions", empty: "No OAuth2 sessions", fields: { user_id: "User ID", client_id: "Client ID", scope: "Scope", created_at: "Created at", user_agent: "User Agent", last_active_at: "Last active", last_active_ip: "Last IP", finished_at: "Finished at", human_name: "Name", active: "Active", }, action: { finish: { label: "Terminate", title: "Terminate this session?", content: "This will terminate the session.", success: "Session terminated", }, }, }, mas_policy_data: { name: "Policy Data", current_policy: "Current Policy", no_policy: "No policy is currently set.", set_policy: "Set a New Policy", invalid_json: "Invalid JSON", fields: { json_placeholder: "Enter policy data as JSON…", created_at: "Created at", }, action: { save: { label: "Set Policy", success: "Policy updated", failure: "Failed to update policy", }, }, }, mas_user_sessions: { name: "Browser Session |||| Browser Sessions", fields: { user_id: "User ID", created_at: "Created at", finished_at: "Finished at", user_agent: "User Agent", last_active_at: "Last active", last_active_ip: "Last IP", active: "Active", }, action: { finish: { label: "Terminate", title: "Terminate this session?", content: "This will terminate the browser session.", success: "Session terminated", }, }, }, mas_upstream_oauth_links: { name: "Upstream OAuth Link |||| Upstream OAuth Links", fields: { user_id: "User ID", provider_id: "Provider ID", subject: "Subject", human_account_name: "Account Name", created_at: "Created at", }, helper: { provider_id: "The ID of the upstream OAuth provider. You can find it in the Upstream OAuth Providers list.", }, action: { remove: { label: "Remove", title: "Remove OAuth link?", content: "This will remove the upstream OAuth link for this user.", success: "OAuth link removed", }, }, }, mas_upstream_oauth_providers: { name: "OAuth Provider |||| OAuth Providers", fields: { issuer: "Issuer", human_name: "Name", brand_name: "Brand", created_at: "Created at", disabled_at: "Disabled at", enabled: "Enabled", }, }, mas_personal_sessions: { name: "Personal Session |||| Personal Sessions", empty: "No personal sessions", fields: { owner_user_id: "Owner User ID", actor_user_id: "User", human_name: "Name", scope: "Scope", created_at: "Created at", revoked_at: "Revoked at", last_active_at: "Last active", last_active_ip: "Last IP", expires_at: "Expires at", expires_in: "Expires in (seconds)", active: "Active", }, helper: { expires_in: "Optional. Number of seconds until the token expires. Leave blank for no expiry.", }, action: { revoke: { label: "Revoke", title: "Revoke session?", content: "This will revoke the personal access token.", success: "Session revoked", }, create: { token_title: "Personal Access Token", token_content: "Copy this token now — it will not be shown again.", }, }, }, mas_sessions: { status: { active: "Active", finished: "Finished", revoked: "Revoked", }, }, }; export default mas; ================================================ FILE: src/i18n/en/misc_resources.ts ================================================ // Miscellaneous resources: scheduled_tasks, connections, devices, users_media, // protect_media, quarantine_media, pushers, servernotices, database_room_statistics, // user_media_statistics, forward_extremities, room_state, room_media, room_directory, // destinations, registration_tokens const misc_resources = { scheduled_tasks: { name: "Scheduled task |||| Scheduled tasks", fields: { id: "ID", action: "Action", status: "Status", timestamp: "Timestamp", resource_id: "Resource ID", result: "Result", error: "Error", max_timestamp: "Before date", }, status: { scheduled: "Scheduled", active: "Active", complete: "Complete", cancelled: "Cancelled", failed: "Failed", }, }, connections: { name: "Connections", fields: { last_seen: "Date", ip: "IP address", user_agent: "User agent", }, }, devices: { name: "Device |||| Devices", fields: { device_id: "Device ID", display_name: "Device name", last_seen_ts: "Timestamp", last_seen_ip: "IP address", last_seen_user_agent: "User agent", dehydrated: "Dehydrated", }, action: { erase: { title: "Remove device %{id}?", title_bulk: "Remove %{smart_count} device? |||| Remove %{smart_count} devices?", content: 'Are you sure you want to remove the device "%{name}"?', content_bulk: "Are you sure you want to remove %{smart_count} device? |||| Are you sure you want to remove %{smart_count} devices?", success: "Device successfully removed.", failure: "An error has occurred.", }, display_name: { success: "Device name updated", failure: "Failed to update device name", }, create: { label: "Create device", title: "Create new device", success: "Device created", failure: "Failed to create device", }, }, }, users_media: { name: "Media", fields: { media_id: "Media ID", media_length: "File Size (in Bytes)", media_type: "Type", upload_name: "File name", quarantined_by: "Quarantined by", safe_from_quarantine: "Safe from quarantine", created_ts: "Created", last_access_ts: "Last access", }, action: { open: "Open media file in a new window", }, }, protect_media: { action: { create: "Protect", delete: "Unprotect", none: "In quarantine", send_success: "Successfully changed the protection status.", send_failure: "An error has occurred.", }, }, quarantine_media: { action: { name: "Quarantine", create: "Quarantine", delete: "Unquarantine", none: "Protected", send_success: "Successfully changed the quarantine status.", send_failure: "An error has occurred: %{error}", }, }, pushers: { name: "Pusher |||| Pushers", fields: { app: "App", app_display_name: "App display name", app_id: "App ID", device_display_name: "Device display name", kind: "Kind", lang: "Language", profile_tag: "Profile tag", pushkey: "Pushkey", data: { url: "URL" }, }, }, servernotices: { name: "Server Notices", send: "Send server notices", fields: { body: "Message", }, action: { send: "Send note", send_success: "Server notice successfully sent.", send_failure: "An error has occurred.", }, helper: { send: 'Sends a server notice to the selected users. The feature "Server Notices" must be enabled on the server.', }, }, database_room_statistics: { name: "Database room statistics", fields: { room_id: "Room ID", estimated_size: "Estimated size", }, helper: { info: "Shows the estimated disk space used by each room in the Synapse database. These numbers are approximate.", }, }, user_media_statistics: { name: "Media", fields: { media_count: "Media count", media_length: "Media length", }, }, forward_extremities: { name: "Forward Extremities", fields: { id: "Event ID", received_ts: "Timestamp", depth: "Depth", state_group: "State group", }, }, room_state: { name: "State events", fields: { type: "Type", content: "Content", origin_server_ts: "Sent at", sender: "Sender", }, }, room_media: { name: "Media", fields: { media_id: "Media ID", }, helper: { info: "This lists all media uploaded to this room. Media hosted on external repositories cannot be deleted from here.", }, action: { error: "%{errcode} (%{errstatus}) %{error}", }, }, room_directory: { name: "Room directory", fields: { world_readable: "Guest users may view without joining", guest_can_join: "Guest users may join", }, action: { title: "Delete room from directory |||| Delete %{smart_count} rooms from directory", content: "Are you sure you want to remove this room from the directory? |||| Are you sure you want to remove these %{smart_count} rooms from the directory?", erase: "Delete from room directory", create: "Publish in room directory", send_success: "Room successfully published.", send_failure: "An error has occurred.", }, }, destinations: { name: "Federation", fields: { destination: "Destination", failure_ts: "Failure timestamp", retry_last_ts: "Last retry timestamp", retry_interval: "Retry interval", last_successful_stream_ordering: "Last successful stream", stream_ordering: "Stream", }, action: { reconnect: "Reconnect" }, }, registration_tokens: { name: "Registration tokens", fields: { token: "Token", valid: "Valid token", uses_allowed: "Uses allowed", pending: "Pending", completed: "Completed", expiry_time: "Expiry time", length: "Length", created_at: "Created at", last_used_at: "Last used at", revoked_at: "Revoked at", }, helper: { length: "The length of the generated token, used when no specific token value is provided." }, action: { revoke: { label: "Revoke", success: "Token revoked", }, unrevoke: { label: "Restore", success: "Token restored", }, }, }, account_data: { name: "Account data", }, joined_rooms: { name: "Joined rooms", }, memberships: { name: "Memberships", }, room_members: { name: "Members", }, destination_rooms: { name: "Rooms", }, }; export default misc_resources; ================================================ FILE: src/i18n/en/reports.ts ================================================ const reports = { name: "Reported event |||| Reported events", fields: { id: "ID", received_ts: "Reported at", user_id: "Reporter", name: "Room Name", score: "Score", reason: "Reason", event_id: "Event ID", sender: "Sender", }, action: { erase: { title: "Delete reported event", content: "Are you sure you want to delete the reported event? This cannot be undone.", }, event_lookup: { label: "Look Up Event", title: "Look Up Event by ID", fetch: "Look Up", }, fetch_event_error: "Failed to fetch event", }, }; export default reports; ================================================ FILE: src/i18n/en/rooms.ts ================================================ const rooms = { name: "Room |||| Rooms", fields: { room_id: "Room ID", name: "Name", canonical_alias: "Alias", joined_members: "Members", joined_local_members: "Local members", joined_local_devices: "Local devices", state_events: "State events / Complexity", version: "Version", is_encrypted: "Encrypted", encryption: "Encryption", federatable: "Federatable", public: "Visible in room directory", creator: "Creator", join_rules: "Join rules", guest_access: "Guest access", history_visibility: "History visibility", topic: "Topic", avatar: "Avatar", actions: "Actions", }, filter: { public_rooms: "Public rooms", empty_rooms: "Empty rooms", local_members_only: "Local members only", }, helper: { forward_extremities: "Forward extremities are the leaf events at the end of a directed acyclic graph (DAG) in a room, i.e., events with no children. The more that exist in a room, the more state resolution that Synapse needs to perform (note: this is an expensive operation). While Synapse has code to prevent too many of these existing at one time in a room, bugs can sometimes make them crop up again. If a room has >10 forward extremities, it's worth investigating and potentially removing them using the SQL queries mentioned in #1760.", }, enums: { join_rules: { public: "Public", knock: "Knock", invite: "Invite", private: "Private", restricted: "Restricted", }, guest_access: { can_join: "Guests can join", forbidden: "Guests cannot join", }, history_visibility: { invited: "Since invited", joined: "Since joined", shared: "Since shared", world_readable: "Anyone", }, unencrypted: "Unencrypted", room_type: { room: "Room", space: "Space", }, }, action: { erase: { title: "Delete room", content: "Are you sure you want to delete this room? This action cannot be undone. All messages and shared media will be permanently deleted from the server.", fields: { block: "Block and prevent users from joining the room", }, in_progress: "Deletion in progress…", background_note: "You can safely close this window — the deletion will continue in the background.", success: "Room deleted successfully. |||| Rooms deleted successfully.", failure: "The room could not be deleted. |||| The rooms could not be deleted.", }, make_admin: { assign_admin: "Assign admin", title: "Assign a room admin to %{roomName}", confirm: "Make admin", content: "Enter the full MXID of the user to set as room admin.\nNote: the room must already have at least one local member with admin permissions for this to work.", success: "The user has been set as room admin.", failure: "The user could not be set as room admin. %{errMsg}", }, join: { label: "Add user", title: "Add user to %{roomName}", confirm: "Add", content: "Enter the full MXID of the user to add to this room.\nNote: you must be a member of the room with permission to invite users.", success: "User added to the room successfully.", failure: "Failed to add user to the room. %{errMsg}", }, block: { label: "Block", title: "Block %{room}", title_bulk: "Block %{smart_count} room |||| Block %{smart_count} rooms", title_by_id: "Block a room", content: "Users will be prevented from joining this room.", content_bulk: "Users will be prevented from joining %{smart_count} room. |||| Users will be prevented from joining %{smart_count} rooms.", success: "Room blocked successfully. |||| Rooms blocked successfully.", failure: "Failed to block room. |||| Failed to block rooms.", }, unblock: { label: "Unblock", success: "Room unblocked successfully. |||| Rooms unblocked successfully.", failure: "Failed to unblock room. |||| Failed to unblock rooms.", }, purge_history: { label: "Purge history", title: "Purge history of %{roomName}", content: "All events before the selected date will be deleted from the database. Room state (joins, leaves, topic) is always preserved. At least one message is always retained.\nNote: this operation may take several minutes for large rooms.", date_label: "Purge events before", delete_local: "Also delete events sent by local users", in_progress: "Purge in progress…", background_note: "You can safely close this window — the purge will continue in the background.", success: "Room history purged successfully.", failure: "Failed to purge room history. %{errMsg}", }, quarantine_all: { label: "Quarantine all media", title: "Quarantine all media in %{roomName}", content: "This will quarantine ALL local and remote media in this room. Quarantined media will no longer be accessible to users.", success: "Successfully quarantined %{smart_count} media item. |||| Successfully quarantined %{smart_count} media items.", failure: "Failed to quarantine media. %{errMsg}", }, delete_all_media: { label: "Delete all media", title: "Delete all media in %{roomName}", content: "This will permanently delete all local media in this room. Only local media from unencrypted rooms is affected — remote media from other servers is excluded. This action cannot be undone.", in_progress_loading: "Fetching media list…", in_progress: "Deleting media… (%{current} / %{total})", do_not_close: "Do not close this dialog — deletion is running in the foreground and will stop if closed.", success: "Successfully deleted %{smart_count} media item. |||| Successfully deleted %{smart_count} media items.", failure: "Failed to delete media. %{errMsg}", }, delete_all_media_bulk: { title: "Delete all media for %{smart_count} room? |||| Delete all media for %{smart_count} rooms?", content: "This will permanently delete all local media in the selected rooms (unencrypted rooms only). Remote media from other servers is excluded. This action cannot be undone.", success: "Deleted media for %{success} of %{total} rooms.", partial_failure: "Deleted media for %{success} of %{total} rooms. %{failed} failed.", }, event_context: { lookup_title: "Look Up Event by ID", jump_to_date: "Jump to date", direction: "Direction", forward: "Forward", backward: "Backward", target_event: "Target event", events_before: "Events before", events_after: "Events after", not_found: "No event found at the specified time", failure: "Failed to retrieve event context", }, messages: { load_older: "Load older", load_newer: "Load newer", no_messages: "No messages in this room", failure: "Failed to load messages", filter: "Filters", filter_type: "Event types", filter_sender: "Senders", advanced_filters: "Advanced filters", filter_not_type: "Exclude event types", filter_not_sender: "Exclude senders", contains_url: "Contains URL", any: "Any", with_url: "With URL only", without_url: "Without URL only", apply_filter: "Apply", clear_filters: "Clear", }, hierarchy: { load_more: "Load more", max_depth: "Max depth", unlimited: "Unlimited", refresh: "Refresh", members: "%{count} members", space: "Space", room: "Room", suggested: "Suggested", no_children: "This room has no child rooms", failure: "Failed to load hierarchy", }, }, }; export default rooms; ================================================ FILE: src/i18n/en/users.ts ================================================ const users = { name: "User |||| Users", email: "Email", msisdn: "Phone", threepid: "Email / Phone", membership: "Membership |||| Memberships", fields: { avatar: "Avatar", id: "User ID", name: "Name", is_guest: "Guest", admin: "Server Administrator", locked: "Locked", suspended: "Suspended", shadow_banned: "Shadow banned", deactivated: "Deactivated", erased: "Erased", show_guests: "Show guests", show_deactivated: "Show deactivated only", show_locked: "Show locked users", filter_user_all: "All", filter_deactivated_false: "Active", filter_deactivated_true: "Deactivated", filter_locked_false: "Exclude locked", filter_locked_true: "Include locked", filter_guests_false: "Exclude guests", filter_guests_true: "Include guests", show_system_users: "Show system users", filter_system_users_false: "Exclude system", filter_system_users_true: "Only system", show_suspended: "Show suspended users", show_shadow_banned: "Show shadow banned users", user_id: "Search user", displayname: "Display name", password: "Password", avatar_url: "Avatar URL", avatar_src: "Avatar", medium: "Medium", threepids: "3PIDs", address: "Address", creation_ts_ms: "Created at", consent_version: "Consent version", sent_invite_count: "Sent invites", cumulative_joined_room_count: "Cumulative joined rooms", auth_provider: "Provider", user_type: "User type", }, helper: { password: "Changing the password will log the user out of all sessions.", password_required_for_reactivation: "You must provide a password to re-activate an account.", create_password: "Generate a strong and secure password using the button below.", lock: "Prevent the user from usefully using their account. This is a non-destructive action that can be reversed.", deactivate: "A password is required to reactivate this account.", suspend: "Suspending this user places them in read-only mode.", shadow_ban: "A shadow-banned user receives normal responses, but their events are not propagated to other users or rooms. Use only as a last resort.", erase: "In addition to deactivating the user, mark the user as GDPR-erased.", admin: "A server administrator has full control over the server and its users.", erase_text: "This means messages sent by the user(s) will still be visible to anyone who was in the room at the time, but will be hidden from users who join afterward.", erase_admin_error: "Deleting your own user is not allowed.", modify_managed_user_error: "Modifying a system-managed user is not allowed.", username_available: "Username is available", sent_invite_count: "Total number of invites sent by this user across all rooms.", cumulative_joined_room_count: "Total number of rooms this user has ever joined, including rooms they have since left or been banned from.", }, action: { erase: "Erase user data", erase_avatar: "Erase avatar", delete_media: "Delete all media uploaded by this user", redact_events: "Redact all events sent by this user", redact_in_progress: "Redaction in progress\u2026", redact_background_note: "You can safely close this dialog, the redaction will continue in the background.", redact_success: "All events redacted successfully.", redact_failure: "Redaction completed with %{smart_count} failed event. |||| Redaction completed with %{smart_count} failed events.", generate_password: "Generate password", reset_password: { label: "Reset password", title: "Reset password", helper: "Change password for %{user}", password: "Password", logout_devices: "Logout all devices", success: "Password was reset successfully", failure: "Failed to reset password", error_no_password: "Password is required", }, login_as: { label: "Login as user", title: "Login as user", helper: "Get an access token that can be used to authenticate as %{user}. This action does not create a new device, so it will not appear in the user's devices/sessions list. In general, the target user will not be able to tell that someone has logged in as them.", valid_until: "Set expiry date", success: "Access token generated successfully", failure: "Failed to generate access token", result_title: "%{user} access token", access_token: "Access token", expires_at: "This access token will expire %{date}", }, overwrite_title: "Warning!", overwrite_content: "This username is already taken. Are you sure that you want to overwrite the existing user?", overwrite_cancel: "Cancel", overwrite_confirm: "Overwrite", quarantine_all: { label: "Quarantine all media", title: "Quarantine all media of %{userName}", content: "This will quarantine all local media uploaded by this user. Quarantined media will no longer be accessible to other users.", success: "Successfully quarantined %{smart_count} media item. |||| Successfully quarantined %{smart_count} media items.", failure: "Failed to quarantine media. %{errMsg}", }, delete_all_media: { label: "Delete all media", title: "Delete all media for %{userName}", content: "This will permanently delete all media uploaded by this user. This action cannot be undone.", in_progress: "Deleting media…", background_note: "You can safely close this dialog — deletion will continue in the background.", success: "Successfully deleted %{smart_count} media item. |||| Successfully deleted %{smart_count} media items.", failure: "Failed to delete media. %{errMsg}", }, delete_all_media_bulk: { title: "Delete all media for %{smart_count} user? |||| Delete all media for %{smart_count} users?", content: "This will permanently delete all media uploaded by the selected users. This action cannot be undone.", success: "Deleted media for %{success} of %{total} users.", partial_failure: "Deleted media for %{success} of %{total} users. %{failed} failed.", }, allow_cross_signing: { label: "Allow cross-signing reset", title: "Allow cross-signing key replacement", content: "Allow %{user} to replace their cross-signing keys without user-interactive authentication? This creates a temporary window during which the keys can be replaced.", success: "Cross-signing key replacement allowed until %{deadline}", failure: "Failed to allow cross-signing replacement", no_key: "User has no master cross-signing key", }, find_user: { label: "Find user", title: "Find user", lookup_type: "Lookup type", by_threepid: "By email / phone", by_auth_provider: "By auth provider", provider: "Auth provider ID", external_id: "External ID", search: "Search", not_found: "User not found", failure: "Failed to find user", }, renew_account: { label: "Renew account", title: "Renew account validity", content: "Renew the account validity for %{user}. You can optionally set a custom expiration date. If left empty, the server default renewal period will be used.", expiration: "Expiration date", expiration_helper: "Leave empty to use the server default renewal period", renewal_emails: "Send renewal notification emails", success: "Account validity renewed until %{date}", failure: "Failed to renew account validity", }, system_users_scan_in_progress: "Scanning for matching users — the page will load shortly.", reverse_search_scan_in_progress: "Scanning all users to exclude matches — the page will load shortly.", }, badge: { you: "You", bot: "Bot", admin: "Admin", support: "Support", regular: "Regular User", federated: "Federated User", system_managed: "System-managed", }, limits: { messages_per_second: "Messages per second", messages_per_second_text: "The number of actions that can be performed in a second.", burst_count: "Burst count", burst_count_text: "The number of actions that can be performed before rate limiting applies.", }, account_data: { title: "Account Data", global: "Global", rooms: "Rooms", }, }; export default users; ================================================ FILE: src/i18n/fa/base.ts ================================================ import type { TranslationMessages } from "ra-core"; const farsiMessages: TranslationMessages = { ra: { action: { add_filter: "اضافه‌کردن فیلتر", add: "اضافه", back: "بازگشت", bulk_actions: "۱ آیتم انتخاب شد |||| %{smart_count} عدد از آیتم‌ها انتخاب شدند", cancel: "انصراف", clear_array_input: "پاک‌کردن لیست", clear_input_value: "پاک‌کردن مقدار", clone: "شبیه‌سازی", confirm: "تایید", create: "ایجاد", create_item: "ایجاد %{item}", delete: "حذف", edit: "ویرایش", export: "دریافت خروجی", list: "لیست", refresh: "بروز‌رسانی", remove_filter: "حذف این فیلتر", remove_all_filters: "حذف همه‌ی فیلترها", remove: "حذف", reset: "بازنشانی", save: "ذخیره", search: "جست‌وجو", search_columns: "جستجوی ستون‌ها", select_all: "انتخاب همه", select_all_button: "انتخاب همه", select_row: "انتخاب این سطر", show: "نمایش", sort: "مرتب‌سازی", undo: "لغو", unselect: "عدم انتخاب", expand: "بگستر", close: "ببند", open_menu: "باز کردن منو", close_menu: "بستن منو", update: "بروز‌رسانی", move_up: "بالا بردن", move_down: "پایین آوردن", open: "باز کن", toggle_theme: "تغییر تم", select_columns: "ستون‌ها", update_application: "بروز‌رسانی برنامه", }, boolean: { true: "بله", false: "خیر", null: " ", }, page: { create: "ایجاد %{name}", dashboard: "داشبورد", edit: "%{name} %{recordRepresentation}", error: "مشکلی ایجاد شد", list: "%{name}", loading: "در حال بارگزاری", not_found: "پیدا نشد", show: "%{name} %{recordRepresentation}", empty: "هنوز سطری از %{name} وجود ندارد.", invite: "آیا میخواهید یک مورد اضافه کنید؟", access_denied: "دسترسی رد شد", authentication_error: "خطای احراز هویت", }, input: { file: { upload_several: "تعدادی فایل برای آپلود دراپ کنید، یا برای انتخاب آن‌ها کلیک کنید.", upload_single: "فایلی را برای آپلود دراپ کنید، یا برای انتخاب آن کلیک کنید", }, image: { upload_several: "تعدادی عکس برای آپلود دراپ کنید، یا برای انتخاب آن‌ها کلیک کنید.", upload_single: "عکسی را برای آپلود دراپ کنید، یا برای انتخاب آن کلیک کنید", }, references: { all_missing: "امکان پیدا کردن اطلاعات ارجاعی وجود ندارد.", many_missing: "حداقل یکی از مراجع در دسترس نیست.", single_missing: "مرجع مورد نظر در دسترس نیست.", }, password: { toggle_visible: "پنهان کردن رمز عبور", toggle_hidden: "نمایش رمز عبور", }, }, message: { about: "درباره", access_denied: "شما مجوزهای مناسب برای دسترسی به این صفحه را ندارید", are_you_sure: "آیا اطمینان دارید ؟", authentication_error: "سرور احراز هویت خطایی را برگرداند و اعتبار شما قابل بررسی نیست.", auth_error: "خطا در احراز هویت", bulk_delete_content: "آیا از حذف %{name} اطمینان دارید؟ |||| آیا از حذف %{smart_count} عدد از آیتم‌ها اطمینان دارید؟", bulk_delete_title: "حذف %{name} |||| حذف %{smart_count} عدد از آیتم‌های %{name}", bulk_update_content: "آیا از بروز‌رسانی %{name} %{recordRepresentation} اطمینان دارید؟ |||| آیا از بروز‌رسانی %{smart_count} عدد از آیتم‌ها اطمینان دارید؟", bulk_update_title: "بروز‌رسانی %{name} %{recordRepresentation} |||| بروز‌رسانی %{smart_count} %{name}", clear_array_input: "آیا از حذف همه‌ی مقادیر اطمینان دارید؟", delete_content: "آیا از حذف این %{name} اطمینان دارید؟", delete_title: "حذف %{name} %{recordRepresentation}", details: "جزییات", error: "خطایی در مرورگر رخ داد. درخواست شما کامل نشد", invalid_form: "فرم درست پر نشده است. لطفا خطاها را بررسی کنید", loading: "لطفاً صبر کنید", no: "خیر", not_found: "شما یک نشانی اینترنتی اشتباه تایپ کردید یا پیغام بدی را دنبال کردید.", select_all_limit_reached: "تعداد انتخاب‌ها زیاد است. فقط %{max} مورد اول انتخاب شد.", unsaved_changes: "تغییرات شما ذخیره نشده اند. آیا مطمئن هستید که می خواهید از آنها چشم پوشی کنید؟", yes: "بله", placeholder_data_warning: "مشکل شبکه: به‌روزرسانی داده‌ها ناموفق بود.", }, navigation: { clear_filters: "پاکسازی فیلترها", no_filtered_results: "هیچ %{name} با استفاده از فیلترهای فعلی یافت نشد.", no_results: "نتیجه‌ای برای %{name} پیدا نشد", no_more_results: "شماره صفحه‌ی %{page} خارج از محدوده مجاز است. صفحه قبل را امتحان کنید.", page_out_of_boundaries: "شماره صفحه %{page} خارج از محدوده است", page_out_from_end: "نمی‌توان به بعد از صفحه آخر رفت", page_out_from_begin: "نمی‌توان به قبل از صفحه اول رفت", page_range_info: "%{offsetBegin}-%{offsetEnd} (کل: %{total})", partial_page_range_info: "%{offsetBegin}-%{offsetEnd} بیشتر از %{offsetEnd}", current_page: "صفحه %{page}", page: "برو به صفحه %{page}", first: "برو به صفحه اول", last: "برو به صفحه‌ آخر", next: "صفحه بعد", previous: "صفحه قبل", page_rows_per_page: "تعداد ردیف‌ها در صفحه:", skip_nav: "رفتن به محتوا", }, sort: { sort_by: "مرتب‌سازی بر اساس %{field_lower_first} %{order}", ASC: "صعودی", DESC: "نزولی", }, auth: { auth_check_error: "لطفا برای ادامه وارد شوید", user_menu: "پروفایل", username: "نام‌کاربری", password: "رمز عبور", email: "ایمیل", sign_in: "ورود", sign_in_error: "شناسایی با شکست مواجه شد، دوباره تلاش کنید", logout: "خروج", }, notification: { updated: "المان بروز‌رسانی شد |||| %{smart_count} المان بروز‌رسانی شد", created: "المان ایجاد شد", deleted: "المان حذف شد |||| %{smart_count} المان حذف شد", bad_item: "المان اشتباه", item_doesnt_exist: "المان پیدا نشد", http_error: "خطا در برقراری ارتباط با سرور", data_provider_error: "خطا در دریافت اطلاعات", i18n_error: "بارگزاری ترجمه‌ها برای زبان مورد نظر امکان‌پذیر نیست", canceled: "لغو شد", logged_out: "نشست کاربری شما به پایان رسیده‌است، لطفا دوباره وصل شوید.", not_authorized: "شما اجازه دسترسی به این منبع را ندارید", application_update_available: "نسخه جدید برنامه در دسترس است", offline: "بدون اتصال. داده‌ها قابل دریافت نیستند.", }, validation: { required: "اجباری", minLength: "حداقل باید %{min} کاراکتر باشد", maxLength: "باید %{max} کاراکتر یا کمتر باشد", minValue: "حداقل باید %{min} باشد", maxValue: "باید %{max} یا کمتر باشد", number: "باید یک عدد باشد", email: "باید یک آدرس ایمیل صحیح باشد", oneOf: "باید انتخابی از این گزینه‌ها باشد: %{options}", regex: "باید با فرمت خاصی هماهنگ باشد (regexp): %{pattern}", unique: "باید منحصر به فرد باشد", }, saved_queries: { label: "کوئری‌های ذخیره‌شده", query_name: "نام کوئری", new_label: "ذخیره کوئری فعلی...", new_dialog_title: "ذخیره کوئری فعلی به عنوان", remove_label: "حذف کوئری ذخیره شده", remove_label_with_name: 'حذف کوئری "%{name}"', remove_dialog_title: "کوئری ذخیره شده حذف شود؟", remove_message: "آیا از حذف آیتم از لیست کوئری‌های ذخیره شده اطمینان دارید؟", help: "لیست را فیلتر کنید و کوئری را برای استفاده بعدی ذخیره کنید", }, guesser: { empty: { title: "داده‌ای برای نمایش نیست", message: "لطفاً ارائه‌دهندهٔ داده را بررسی کنید", }, }, configurable: { customize: "سفارشی‌سازی", configureMode: "این صفحه را پیکربندی کنید", inspector: { title: "بازرس", content: "عناصر رابط کاربری برنامه را نگه دارید تا آنها را پیکربندی کنید", reset: "بازنشانی تنظیمات", hideAll: "پنهان همه", showAll: "نمایش همه", }, Datagrid: { title: "شبکه داده", unlabeled: "ستون بدون برچسب #%{column}", }, SimpleForm: { title: "فرم", unlabeled: "ورودی بدون برچسب #%{input}", }, SimpleList: { title: "فهرست", primaryText: "متن اولیه", secondaryText: "متن ثانویه", tertiaryText: "متن سوم", }, }, }, }; export default farsiMessages; ================================================ FILE: src/i18n/fa/common.ts ================================================ import farsiMessages from "./base"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const common: Record = { ...farsiMessages, ketesa: { auth: { base_url: "آدرس سرور", welcome: "به پنل مدیریت Synapse خوش آمدید، %{name}", description: "تکامل Synapse Admin. مدیریت، نظارت و نگهداری سرور Matrix خود را از یک رابط ساده و تمیز انجام دهید. مناسب برای سرورهای خصوصی کوچک و جوامع بزرگ فدراسیون.", server_version: "نسخه", username_error: "لطفاً شناسه کاربر را وارد کنید: '@user:domain'", protocol_error: "URL باید با 'http://' یا 'https://' شروع شود", url_error: "آدرس وارد شده یک سرور معتبر نیست", sso_sign_in: "با SSO وارد شوید", credentials: "اعتبارنامه", access_token: "توکن دسترسی", supports_specs: "پشتیبانی از مشخصات Matrix", logout_access_token_dialog: { title: "شما در حال استفاده از یک توکن دسترسی موجود Matrix هستید.", content: "آیا می‌خواهید این نشست (که ممکن است در جای دیگر، مانند یک کلاینت Matrix نیز استفاده شود) را باطل کنید یا فقط از پنل مدیریت خارج شوید؟", confirm: "ابطال نشست", cancel: "خروج از پنل مدیریت", }, }, users: { invalid_user_id: "بخش محلی یک شناسه کاربری ماتریکس بدون سرور خانگی.", tabs: { sso: "SSO", experimental: "تجربی", limits: "محدودیت‌ها", account_data: "داده‌های کاربر", sessions: "نشست‌ها", }, danger_zone: "منطقه خطرناک", }, rooms: { details: "جزئیات اتاق", tabs: { basic: "اصلی", members: "اعضا", detail: "جزئیات", permission: "مجوزها", media: "رسانه‌ها", messages: "پیام‌ها", hierarchy: "سلسله‌مراتب", }, }, reports: { tabs: { basic: "اصلی", detail: "جزئیات" } }, admin_config: { soft_failed_events: "رویدادهای شکست نرم", spam_flagged_events: "رویدادهای علامت‌گذاری‌شده به‌عنوان هرزنامه", success: "تنظیمات مدیر به‌روزرسانی شد", failure: "به‌روزرسانی تنظیمات مدیر ناموفق بود", }, }, import_users: { error: { at_entry: "در هنگام ورود %{entry}: %{message}", error: "خطا", required_field: "فیلد الزامی '%{field}' وجود ندارد", invalid_value: "خطا در خط %{row}. '%{field}' فیلد ممکن است فقط 'درست' یا 'نادرست' باشد", unreasonably_big: "از بارگذاری فایل هایی با حجم غیر منطقی خودداری کنید %{size} مگابایت", already_in_progress: "یک بارگذاری از قبل در حال انجام است", id_exits: "شناسه %{id} موجود است", }, title: "کاربران را از طریق فایل CSV وارد کنید", goToPdf: "رفتن به PDF", cards: { importstats: { header: "کاربران پردازش شده برای وارد کردن", users_total: "%{smart_count} کاربر در فایل CSV |||| %{smart_count} کاربر در فایل CSV", guest_count: "%{smart_count} مهمان |||| %{smart_count} مهمان", admin_count: "%{smart_count} مدیر |||| %{smart_count} مدیر", }, conflicts: { header: "استراتژی متعارض", mode: { stop: "توقف", skip: "نمایش خطا و رد شدن", }, }, ids: { header: "شناسه‌ها", all_ids_present: "شناسه های موجود در هر ورودی", count_ids_present: "%{smart_count} ورود با شناسه |||| %{smart_count} ورودی با شناسه", mode: { ignore: "نادیده گرفتن شناسه‌های CSV و ایجاد شناسه‌های جدید", update: "سوابق موجود را به روز کنید", }, }, passwords: { header: "رمز عبور", all_passwords_present: "رمزهای عبور موجود در هر ورودی", count_passwords_present: "%{smart_count} ورود با رمز عبور |||| %{smart_count} ورودی با رمز عبور", use_passwords: "از رمزهای عبور CSV استفاده کنید", }, upload: { header: "بارگذاری فایل CSV", explanation: "در اینجا می توانید فایلی را با مقادیر جدا شده با کاما بارگذاری کنید که برای ایجاد یا به روز رسانی کاربران پردازش می شود. فایل باید شامل فیلدهای 'id' و 'displayname' باشد. می توانید یک فایل نمونه را از اینجا دانلود و تطبیق دهید: ", }, startImport: { simulate_only: "فقط شبیه سازی", run_import: "بارگذاری", }, results: { header: "بارگذاری نتایج", total: "%{smart_count} ورودی در کل |||| %{smart_count} ورودی ها در کل", successful: "%{smart_count} ورودی ها با موفقیت وارد شدند", skipped: "%{smart_count} ورودی ها نادیده گرفته شدند", download_skipped: "دانلود رکوردهای نادیده گرفته شده", with_error: "%{smart_count} ورود با خطا ||| %{smart_count} ورودی های دارای خطا", simulated_only: "اجرا فقط شبیه سازی شد", }, }, }, delete_media: { name: "رسانه‌ها", fields: { before_ts: "آخرین دسترسی قبل", size_gt: "بزرگتر از آن (به بایت)", keep_profiles: "تصاویر پروفایل را نگه دارید", }, action: { send: "حذف رسانه ها", send_success: "%{smart_count} فایل رسانه‌ای با موفقیت حذف شد.", send_success_none: "هیچ فایل رسانه‌ای با معیارهای مشخص شده مطابقت نداشت. چیزی حذف نشد.", send_failure: "خطایی رخ داده است.", }, helper: { send: "این API رسانه های محلی را از دیسک سرور خود حذف می کند. این شامل هر تصویر کوچک محلی و کپی از رسانه دانلود شده است. این API بر رسانه‌هایی که در مخازن رسانه خارجی آپلود شده‌اند تأثیری نخواهد گذاشت.", }, }, purge_remote_media: { name: "رسانه‌های از راه دور", fields: { before_ts: "آخرین دسترسی قبل از", }, action: { send: "پاک کردن رسانه‌های از راه دور", send_success: "%{smart_count} فایل رسانه‌ای از راه دور با موفقیت پاک شد.", send_success_none: "هیچ فایل رسانه‌ای از راه دور با معیارهای مشخص شده مطابقت نداشت. چیزی پاک نشد.", send_failure: "درخواست برای پاک کردن رسانه‌های از راه دور با خطا مواجه شد.", }, helper: { send: "این API کش رسانه‌های از راه دور را از دیسک سرور شما پاک می‌کند. این شامل هر گونه بندانگشتی محلی و نسخه‌های رسانه‌های دانلود شده می‌شود. این API بر رسانه‌های آپلود شده به مخزن رسانه سرور تأثیری نخواهد داشت.", }, }, etkecc: { donate: { menu_label: "حمایت مالی", name: "از توسعه Ketesa حمایت کنید", title: "از توسعه Ketesa حمایت کنید", description_1: "پروژه Ketesa آزاد و متن‌باز است و ما آن را به‌صورت باز برای جامعه Matrix توسعه می‌دهیم و نگه‌داری می‌کنیم.", description_2: "اگر پروژه Ketesa برای شما مفید بوده است، یک حمایت مالی به ما کمک می‌کند کار پشت آن را ادامه دهیم: توسعه، نگه‌داری، رفع اشکال و بهبودهای پیوسته.", description_3: "این به ما کمک می‌کند زمان بیشتری را صرف بهبود پروژه برای همه کسانی کنیم که به آن تکیه دارند.", description_4: "هر میزان حمایت ارزشمند است و ما صمیمانه قدردان پشتیبانی شما هستیم! ❤️", button: "حمایت مالی", signature_team: "تیم etke.cc", }, components: { name: "اجزا", description: "اجزای فعال خود را مشاهده و مدیریت کنید و آنچه را که می‌توانید به سرور خود اضافه کنید کشف نمایید.", no_section: "سرور شما", per_month: "/ماه", included: "شامل شده", total: "جمع کل", loading: "در حال بارگذاری اجزا...", state_add: "افزودن", state_remove: "حذف", add_aria: "درخواست افزودن %{name}", remove_aria: "درخواست حذف %{name}", preview_label: "پیش‌نمایش", request_changes: "درخواست تغییرات", requesting: "در حال ارسال...", request_failure: "ارسال درخواست تغییر ناموفق بود. لطفاً دوباره تلاش کنید.", request_sent_title: "درخواست ثبت شد", request_sent_body: "درخواست تغییر اجزای شما به پشتیبانی etke.cc ارسال شد. در صورت نیاز به تغییرات بیشتر، لطفاً به همین درخواست پشتیبانی پاسخ دهید و درخواست جدید باز نکنید.", request_sent_close: "بستن", request_sent_view: "مشاهده درخواست", request_already_sent: "یک درخواست تغییر در حال پردازش است. برای درخواست تغییرات بیشتر، به تیکت پشتیبانی موجود خود پاسخ دهید.", request_already_sent_view: "مشاهده تیکت", free_label: "رایگان", available_label: "در دسترس", tagline: "سرور خود را ارتقا دهید — هر زمان که بخواهید کامپوننت‌ها را اضافه یا حذف کنید.", section: { bridges: "پل‌ها", extras: "سرویس‌های اضافی", matrix_apps: "اپلیکیشن‌های Matrix", matrix_bots: "ربات‌های Matrix", matrix_extras: "سرویس‌های اضافی Matrix", }, }, billing: { name: "صورتحساب", title: "سوابق پرداخت", no_payments: "هیچ پرداختی یافت نشد.", no_payments_helper: "اگر فکر می‌کنید این یک خطاست، لطفاً با پشتیبانی etke.cc تماس بگیرید.", description1: "از اینجا می‌توانید پرداخت‌ها را مشاهده کنید و فاکتور صادر کنید. درباره مدیریت اشتراک‌ها بیشتر در اینجا بخوانید:", description2: "برای تغییر ایمیل صورتحساب یا افزودن اطلاعات شرکت به فاکتورها، به این صفحه مراجعه کنید:", fields: { transaction_id: "شناسه تراکنش", email: "ایمیل", type: "نوع", amount: "مبلغ", paid_at: "زمان پرداخت", invoice: "فاکتور", }, enums: { type: { subscription: "اشتراک", one_time: "یک‌باره", }, }, helper: { download_invoice: "دانلود فاکتور", downloading: "در حال دانلود...", download_started: "دانلود فاکتور آغاز شد.", invoice_not_available: "در انتظار", loading: "در حال بارگذاری اطلاعات صورتحساب...", loading_failed1: "در بارگذاری اطلاعات صورتحساب مشکلی پیش آمد.", loading_failed2: "لطفاً بعداً دوباره تلاش کنید.", loading_failed3: "اگر مشکل ادامه داشت، لطفاً با پشتیبانی etke.cc تماس بگیرید.", loading_failed4: "با پیام خطای زیر:", }, components: "اجزای فعال", components_no_section: "سرور شما", components_per_month: "/ماه", components_included: "شامل شده", components_total: "جمع کل", components_help_title: "اطلاعات بیشتر درباره %{name}", components_state_install: "نصب", components_state_remove: "حذف", components_remove_aria: "نصب/حذف %{name}", components_preview_label: "پیش‌نمایش", components_request_changes: "درخواست تغییرات", components_requesting: "در حال ارسال...", components_request_failure: "ارسال درخواست تغییر ناموفق بود. لطفاً دوباره تلاش کنید.", components_request_sent_title: "درخواست ثبت شد", components_request_sent_body: "درخواست تغییر اجزای شما به پشتیبانی etke.cc ارسال شد. در صورت نیاز به تغییرات بیشتر، لطفاً به همین درخواست پشتیبانی پاسخ دهید و درخواست جدید باز نکنید.", components_request_sent_close: "بستن", components_request_sent_view: "مشاهده درخواست", components_request_already_sent: "یک درخواست تغییر در حال پردازش است. برای درخواست تغییرات بیشتر، به تیکت پشتیبانی موجود خود پاسخ دهید.", components_request_already_sent_view: "مشاهده تیکت", status: { issue: { title: "اشتراک نیاز به توجه دارد", description: "مشکلی در اشتراک شما شناسایی شد. نگران نباشید — رفع آن آسان است.", due_overdue: "معوق از", due_upcoming: "تا پرداخت", expected: "مبلغ مورد انتظار", last_paid: "آخرین پرداخت", fix_link: "حل پرداخت معوق", fix_mismatch_link: "به‌روزرسانی قیمت اشتراک", support_link: "تماس با پشتیبانی", }, }, }, status: { name: "وضعیت سرور", badge: { default: "برای مشاهده وضعیت سرور کلیک کنید", running: "در حال اجرا: %{command}. %{text}", status_ok: "سرور آنلاین است", status_error: "وضعیت: خطا", status_maintenance: "سیستم در حال حاضر در حالت تعمیر و نگهداری است.", status_process_running: "سرور در حال اجرای دستور است", status_checking: "در حال بررسی وضعیت سرور", }, category: { "Host Metrics": "شاخص‌های میزبان", Network: "شبکه", HTTP: "HTTP", Matrix: "Matrix", }, status: "وضعیت", error: "خطا", loading: "در حال دریافت وضعیت سرور به‌صورت لحظه‌ای — لطفاً کمی صبر کنید…", intro1: "این گزارش پایش لحظه‌ایِ سرور شماست. می‌توانید درباره آن بیشتر در", intro2: "اگر هر یک از بررسی‌های زیر شما را نگران می‌کند، لطفاً اقدامات پیشنهادی را در", help: "راهنما", }, maintenance: { title: "سیستم در حال حاضر در حالت تعمیر و نگهداری است.", try_again: "لطفاً بعداً دوباره تلاش کنید.", note: "نیازی نیست بابت این موضوع با پشتیبانی تماس بگیرید؛ ما از قبل در حال رسیدگی هستیم!", }, actions: { name: "فرمان‌های سرور", available_title: "فرمان‌های در دسترس", available_description: "فرمان‌های زیر قابل اجرا هستند.", available_help_intro: "جزئیات بیشتر هرکدام در", scheduled_title: "فرمان‌های زمان‌بندی‌شده", scheduled_description: "فرمان‌های زیر برای اجرا در زمان‌های مشخص برنامه‌ریزی شده‌اند. می‌توانید جزئیات را ببینید و در صورت نیاز تغییر دهید.", recurring_title: "فرمان‌های تکرارشونده", recurring_description: "فرمان‌های زیر طوری تنظیم شده‌اند که هر هفته در روز و زمان مشخص اجرا شوند. می‌توانید جزئیات را ببینید و در صورت نیاز تغییر دهید.", scheduled_help_intro: "جزئیات بیشتر درباره این حالت در", recurring_help_intro: "جزئیات بیشتر درباره این حالت در", maintenance_title: "سیستم در حال حاضر در حالت تعمیر و نگهداری است.", maintenance_try_again: "لطفاً بعداً دوباره تلاش کنید.", maintenance_note: "نیازی نیست بابت این موضوع با پشتیبانی تماس بگیرید؛ ما از قبل در حال رسیدگی هستیم!", maintenance_commands_blocked: "تا زمانی که حالت تعمیر و نگهداری غیرفعال نشود، امکان اجرای فرمان‌ها نیست.", table: { aria_label: "دستورات سرور", command: "فرمان", description: "توضیحات", arguments: "آرگومان‌ها", is_recurring: "تکرارشونده؟", run_at: "اجرا (زمان محلی)", next_run_at: "اجرای بعدی (زمان محلی)", time_utc: "زمان (UTC)", time_local: "زمان (محلی)", }, buttons: { create: "ایجاد", update: "به‌روزرسانی", back: "بازگشت", delete: "حذف", run: "اجرا", }, command_scheduled: "فرمان زمان‌بندی شد: %{command}", command_scheduled_args: "با آرگومان‌های اضافی: %{args}", expect_prefix: "نتیجه را به‌زودی در صفحه", expect_suffix: "مشاهده خواهید کرد.", notifications_link: "اعلان‌ها", command_help_title: "راهنمای %{command}", scheduled_title_create: "ایجاد فرمان زمان‌بندی‌شده", scheduled_title_edit: "ویرایش فرمان زمان‌بندی‌شده", recurring_title_create: "ایجاد فرمان تکرارشونده", recurring_title_edit: "ویرایش فرمان تکرارشونده", scheduled_details_title: "جزئیات فرمان زمان‌بندی‌شده", recurring_warning: "فرمان‌های زمان‌بندی‌شده‌ای که از یک فرمان تکرارشونده ایجاد شده‌اند قابل ویرایش نیستند، چون به‌طور خودکار دوباره ساخته می‌شوند. لطفاً فرمان تکرارشونده را ویرایش کنید.", command_details_intro: "جزئیات بیشتر درباره فرمان در", form: { id: "شناسه", command: "فرمان", scheduled_at: "زمان‌بندی‌شده برای", day_of_week: "روز هفته", }, delete_scheduled_title: "حذف فرمان زمان‌بندی‌شده", delete_recurring_title: "حذف فرمان تکرارشونده", delete_confirm: "آیا از حذف فرمان %{command} مطمئن هستید؟", errors: { unknown: "خطای ناشناخته‌ای رخ داد", delete_failed: "خطا: %{error}", }, days: { monday: "دوشنبه", tuesday: "سه‌شنبه", wednesday: "چهارشنبه", thursday: "پنج‌شنبه", friday: "جمعه", saturday: "شنبه", sunday: "یکشنبه", }, scheduled: { action: { create_success: "فرمان زمان‌بندی‌شده با موفقیت ایجاد شد", update_success: "فرمان زمان‌بندی‌شده با موفقیت به‌روزرسانی شد", update_failure: "خطایی رخ داده است", delete_success: "فرمان زمان‌بندی‌شده با موفقیت حذف شد", delete_failure: "خطایی رخ داده است", }, }, recurring: { action: { create_success: "فرمان تکرارشونده با موفقیت ایجاد شد", update_success: "فرمان تکرارشونده با موفقیت به‌روزرسانی شد", update_failure: "خطایی رخ داده است", delete_success: "فرمان تکرارشونده با موفقیت حذف شد", delete_failure: "خطایی رخ داده است", }, }, }, notifications: { title: "اعلان‌ها", new_notifications: "%{smart_count} اعلان جدید", no_notifications: "هنوز اعلانی وجود ندارد", see_all: "مشاهده همه اعلان‌ها", clear_all: "حذف همه", ago: "پیش", advisory_tooltip: "ممکن است اعلانی را از دست داده باشید. لطفاً #news:etke.cc، etke.cc/news یا صندوق ورودی ایمیل خود را نیز بررسی کنید.", unavailable_tooltip: "ممکن است اعلان‌ها در دسترس نباشند. برای جزئیات کلیک کنید.", unavailable_title: "ممکن است اعلان‌ها در حال حاضر در دسترس نباشند", unavailable_body: "ممکن است به‌روزرسانی‌هایی وجود داشته باشند که در حال حاضر نتوانیم به این پنل ارسال کنیم — یا ممکن است هیچ مورد جدیدی وجود نداشته باشد. برای جلوگیری از از دست دادن اطلاعات، لطفاً به‌صورت دوره‌ای بررسی کنید:", unavailable_link_matrix: "اتاق Matrix با نشانی #news:etke.cc", unavailable_link_news: "صفحه اطلاعیه‌ها در etke.cc/news", unavailable_link_email: "صندوق ورودی ایمیل شما (از جمله پوشه هرزنامه)", unavailable_retry: "تلاش مجدد", }, currently_running: { command: "در حال اجرا:", started_ago: "(از %{time} پیش شروع شده)", }, time: { less_than_minute: "چند ثانیه", minutes: "%{smart_count} دقیقه", hours: "%{smart_count} ساعت", days: "%{smart_count} روز", weeks: "%{smart_count} هفته", months: "%{smart_count} ماه", }, support: { name: "پشتیبانی", menu_label: "تماس با پشتیبانی", description: "یک درخواست پشتیبانی باز کنید یا درخواست موجود را پیگیری کنید. تیم ما در اسرع وقت پاسخ خواهد داد.", create_title: "درخواست پشتیبانی جدید", no_requests: "هنوز درخواست پشتیبانی وجود ندارد.", no_messages: "هنوز پیامی وجود ندارد.", closed_message: "این درخواست بسته شده است. اگر هنوز مشکلی دارید، لطفاً یک درخواست جدید باز کنید.", fields: { subject: "موضوع", message: "پیام", reply: "پاسخ", status: "وضعیت", created_at: "ایجاد شده", updated_at: "آخرین به‌روزرسانی", }, status: { active: "در انتظار اپراتور", open: "باز", closed: "بسته", pending: "در انتظار شما", }, buttons: { new_request: "درخواست جدید", submit: "ارسال", cancel: "لغو", send: "ارسال", back: "بازگشت به پشتیبانی", attach_files: "پیوست فایل", }, helper: { loading: "در حال بارگذاری درخواست‌های پشتیبانی...", reply_hint: "Ctrl+Enter برای ارسال", reply_placeholder: "لطفاً تا حد امکان جزئیات بیشتری ارائه دهید.", before_contact_title: "پیش از تماس با ما", help_pages_prompt: "لطفاً ابتدا صفحات راهنما را بررسی کنید:", services_prompt: "ما فقط خدمات فهرست‌شده در صفحه خدمات را ارائه می‌دهیم:", topics_prompt: "ما فقط در موضوعات پشتیبانی‌شده می‌توانیم کمک کنیم:", scope_confirm_label: "صفحات راهنما را بررسی کرده‌ام و تأیید می‌کنم که این درخواست با موضوعات پشتیبانی‌شده مطابقت دارد.", english_only_notice: "پشتیبانی فقط به زبان انگلیسی ارائه می‌شود.", response_time_prompt: "پاسخ ظرف ۴۸ ساعت. به پاسخ سریع‌تر نیاز دارید؟ ببینید:", attachments_limit: "تا ۵ فایل، هر کدام ۵ مگابایت، در مجموع ۱۰ مگابایت.", close_request_label: "بستن این درخواست پس از ارسال", }, actions: { create_success: "درخواست پشتیبانی با موفقیت ایجاد شد.", create_failure: "ایجاد درخواست پشتیبانی ناموفق بود.", send_failure: "ارسال پیام ناموفق بود.", attachment_too_large: "فایل «%{name}» از محدودیت ۵ مگابایت فراتر رفته است.", too_many_attachments: "حداکثر ۵ فایل مجاز است.", total_size_exceeded: "اندازه کل پیوست‌ها از ۱۰ مگابایت فراتر رفته است.", }, }, }, }; export default common; ================================================ FILE: src/i18n/fa/index.ts ================================================ import { SynapseTranslationMessages } from "../types"; import common from "./common"; import mas from "./mas"; import misc_resources from "./misc_resources"; import reports from "./reports"; import rooms from "./rooms"; import users from "./users"; const fa: SynapseTranslationMessages = { ra: common.ra, ketesa: common.ketesa, import_users: common.import_users, delete_media: common.delete_media, purge_remote_media: common.purge_remote_media, etkecc: common.etkecc, resources: { users, rooms, reports, scheduled_tasks: misc_resources.scheduled_tasks, connections: misc_resources.connections, devices: misc_resources.devices, users_media: misc_resources.users_media, protect_media: misc_resources.protect_media, quarantine_media: misc_resources.quarantine_media, pushers: misc_resources.pushers, servernotices: misc_resources.servernotices, database_room_statistics: misc_resources.database_room_statistics, user_media_statistics: misc_resources.user_media_statistics, forward_extremities: misc_resources.forward_extremities, room_state: misc_resources.room_state, room_media: misc_resources.room_media, room_directory: misc_resources.room_directory, destinations: misc_resources.destinations, registration_tokens: misc_resources.registration_tokens, account_data: misc_resources.account_data, joined_rooms: misc_resources.joined_rooms, memberships: misc_resources.memberships, room_members: misc_resources.room_members, destination_rooms: misc_resources.destination_rooms, ...mas, }, }; export default fa; ================================================ FILE: src/i18n/fa/mas.ts ================================================ const mas = { mas_users: { name: "کاربر MAS |||| کاربران MAS", fields: { id: "شناسه MAS", username: "نام کاربری", admin: "مدیر", locked: "قفل‌شده", deactivated: "غیرفعال‌شده", legacy_guest: "مهمان قدیمی", created_at: "ایجاد شده در", locked_at: "قفل شده در", deactivated_at: "غیرفعال شده در", }, filter: { status: "وضعیت", search: "جستجو", status_active: "فعال", status_locked: "قفل‌شده", status_deactivated: "غیرفعال", }, action: { lock: { label: "قفل کردن", success: "کاربر قفل شد" }, unlock: { label: "باز کردن قفل", success: "قفل کاربر باز شد" }, deactivate: { label: "غیرفعال کردن", success: "کاربر غیرفعال شد" }, reactivate: { label: "فعال‌سازی مجدد", success: "کاربر دوباره فعال شد" }, set_admin: { label: "اعطای مدیریت", success: "وضعیت مدیر به‌روز شد" }, remove_admin: { label: "حذف مدیریت", success: "وضعیت مدیر به‌روز شد" }, set_password: { label: "تنظیم رمز عبور", title: "تنظیم رمز عبور", success: "رمز عبور تنظیم شد", failure: "تنظیم رمز عبور ناموفق بود", }, }, }, mas_user_emails: { name: "ایمیل |||| ایمیل‌ها", empty: "ایمیلی وجود ندارد", fields: { email: "ایمیل", user_id: "شناسه کاربر", created_at: "ایجاد شده در", actions: "عملیات", }, action: { remove: { label: "حذف", title: "حذف ایمیل", content: "حذف %{email}؟", success: "ایمیل حذف شد", }, create: { success: "ایمیل اضافه شد" }, }, }, mas_compat_sessions: { name: "نشست سازگار |||| نشست‌های سازگار", empty: "هیچ نشست سازگاری وجود ندارد", fields: { user_id: "شناسه کاربر", device_id: "شناسه دستگاه", created_at: "ایجاد شده در", user_agent: "عامل کاربر", last_active_at: "آخرین فعالیت", last_active_ip: "آخرین IP", finished_at: "پایان یافته در", human_name: "نام", active: "فعال", }, action: { finish: { label: "پایان", title: "پایان نشست؟", content: "این نشست پایان خواهد یافت.", success: "نشست پایان یافت", }, }, }, mas_oauth2_sessions: { name: "نشست OAuth2 |||| نشست‌های OAuth2", empty: "هیچ نشست OAuth2ای وجود ندارد", fields: { user_id: "شناسه کاربر", client_id: "شناسه مشتری", scope: "دامنه دسترسی", created_at: "ایجاد شده در", user_agent: "عامل کاربر", last_active_at: "آخرین فعالیت", last_active_ip: "آخرین IP", finished_at: "پایان یافته در", human_name: "نام", active: "فعال", }, action: { finish: { label: "پایان", title: "پایان نشست؟", content: "این نشست پایان خواهد یافت.", success: "نشست پایان یافت", }, }, }, mas_policy_data: { name: "داده‌های خط‌مشی", current_policy: "خط‌مشی فعلی", no_policy: "در حال حاضر هیچ خط‌مشی‌ای تنظیم نشده است.", set_policy: "تنظیم خط‌مشی جدید", invalid_json: "JSON نامعتبر", fields: { json_placeholder: "داده‌های خط‌مشی را به‌صورت JSON وارد کنید…", created_at: "ایجاد شده در", }, action: { save: { label: "تنظیم خط‌مشی", success: "خط‌مشی به‌روزرسانی شد", failure: "به‌روزرسانی خط‌مشی ناموفق بود", }, }, }, mas_user_sessions: { name: "نشست مرورگر |||| نشست‌های مرورگر", fields: { user_id: "شناسه کاربر", created_at: "ایجاد شده در", finished_at: "پایان یافته در", user_agent: "عامل کاربر", last_active_at: "آخرین فعالیت", last_active_ip: "آخرین IP", active: "فعال", }, action: { finish: { label: "پایان", title: "پایان نشست؟", content: "این نشست مرورگر پایان خواهد یافت.", success: "نشست پایان یافت", }, }, }, mas_upstream_oauth_links: { name: "پیوند OAuth بالادستی |||| پیوندهای OAuth بالادستی", fields: { user_id: "شناسه کاربر", provider_id: "شناسه ارائه‌دهنده", subject: "موضوع", human_account_name: "نام حساب", created_at: "ایجاد شده در", }, helper: { provider_id: "شناسه ارائه‌دهنده OAuth بالادستی. آن را در فهرست ارائه‌دهندگان OAuth بالادستی بیابید.", }, action: { remove: { label: "حذف", title: "حذف پیوند OAuth؟", content: "پیوند OAuth بالادستی این کاربر حذف خواهد شد.", success: "پیوند OAuth حذف شد", }, }, }, mas_upstream_oauth_providers: { name: "ارائه‌دهنده OAuth |||| ارائه‌دهندگان OAuth", fields: { issuer: "صادرکننده", human_name: "نام", brand_name: "برند", created_at: "ایجاد شده در", disabled_at: "غیرفعال‌شده در", enabled: "فعال", }, }, mas_personal_sessions: { name: "نشست شخصی |||| نشست‌های شخصی", empty: "هیچ نشست شخصی وجود ندارد", fields: { owner_user_id: "شناسه مالک", actor_user_id: "کاربر", human_name: "نام", scope: "دامنه دسترسی", created_at: "ایجاد شده در", revoked_at: "ابطال شده در", last_active_at: "آخرین فعالیت", last_active_ip: "آخرین IP", expires_at: "انقضا در", expires_in: "انقضا در (ثانیه)", active: "فعال", }, helper: { expires_in: "اختیاری. تعداد ثانیه تا انقضای توکن. برای بدون انقضا خالی بگذارید.", }, action: { revoke: { label: "ابطال", title: "ابطال نشست؟", content: "توکن دسترسی به طور دائمی ابطال می‌شود.", success: "نشست ابطال شد", }, create: { token_title: "توکن دسترسی ایجاد شد", token_content: "این توکن را کپی کنید. پس از بستن این پنجره دیگر نمایش داده نمی‌شود.", }, }, }, mas_sessions: { status: { active: "فعال", finished: "پایان‌یافته", revoked: "ابطال‌شده", }, }, }; export default mas; ================================================ FILE: src/i18n/fa/misc_resources.ts ================================================ const misc_resources = { scheduled_tasks: { name: "وظیفه زمان‌بندی‌شده |||| وظایف زمان‌بندی‌شده", fields: { id: "ID", action: "عملیات", status: "وضعیت", timestamp: "مهر زمانی", resource_id: "شناسه منبع", result: "نتیجه", error: "خطا", max_timestamp: "قبل از تاریخ", }, status: { scheduled: "زمان‌بندی‌شده", active: "فعال", complete: "تکمیل‌شده", cancelled: "لغوشده", failed: "ناموفق", }, }, connections: { name: "اتصالات", fields: { last_seen: "تاریخ", ip: "آدرس آی پی", user_agent: "عامل کاربر", }, }, devices: { name: "دستگاه |||| دستگاه‌ها", fields: { device_id: "شناسه دستگاه", display_name: "نام دستگاه", last_seen_ts: "مهر زمان", last_seen_ip: "آدرس آی پی", last_seen_user_agent: "عامل کاربر", dehydrated: "کم‌آب", }, action: { erase: { title: "حذف کردن %{id}", title_bulk: "حذف %{smart_count} دستگاه |||| حذف %{smart_count} دستگاه", content: 'آیا مطمئن هستید که می خواهید دستگاه را حذف کنید؟ "%{name}"?', content_bulk: "آیا مطمئن هستید که می‌خواهید %{smart_count} دستگاه را حذف کنید؟ |||| آیا مطمئن هستید که می‌خواهید %{smart_count} دستگاه را حذف کنید؟", success: "دستگاه با موفقیت حذف شد.", failure: "خطایی رخ داده است.", }, display_name: { success: "نام دستگاه به‌روزرسانی شد", failure: "به‌روزرسانی نام دستگاه ناموفق بود", }, create: { label: "ایجاد دستگاه", title: "ایجاد دستگاه جدید", success: "دستگاه ایجاد شد", failure: "ایجاد دستگاه ناموفق بود", }, }, }, users_media: { name: "رسانه‌ها", fields: { media_id: "شناسه رسانه", media_length: "اندازه فایل (به بایت)", media_type: "نوع", upload_name: "نام فایل", quarantined_by: "قرنطینه شده توسط", safe_from_quarantine: "امان از قرنطینه", created_ts: "ایجاد شده", last_access_ts: "آخرین دسترسی", }, action: { open: "باز کردن فایل رسانه در پنجره جدید", }, }, protect_media: { action: { create: "محافظت", delete: "لغو محافظت", none: "در قرنطینه", send_success: "وضعیت حفاظت با موفقیت تغییر کرد.", send_failure: "خطایی رخ داده است.", }, }, quarantine_media: { action: { name: "قرنطینه", create: "قرنطینه", delete: "رفع قرنطینه", none: "محافظت شده", send_success: "وضعیت قرنطینه با موفقیت تغییر کرد.", send_failure: "خطایی رخ داده است: %{error}", }, }, pushers: { name: "ارسال‌کننده اعلان |||| ارسال‌کننده‌های اعلان", fields: { app: "برنامه", app_display_name: "نام نمایش برنامه", app_id: "شناسه برنامه", device_display_name: "نام نمایشی برنامه", kind: "نوع", lang: "زبان", profile_tag: "برچسب پروفایل", pushkey: "کلید", data: { url: "URL" }, }, }, servernotices: { name: "اطلاعیه‌های سرور", send: "ارسال اعلانات سرور", fields: { body: "پیام", }, action: { send: "ارسال یادداشت", send_success: "اعلان سرور با موفقیت ارسال شد.", send_failure: "خطایی رخ داده است.", }, helper: { send: "اعلان سرور را برای کاربران انتخاب شده ارسال می کند. ویژگی 'اعلامیه های سرور' باید در سرور فعال شود.", }, }, database_room_statistics: { name: "آمار پایگاه داده اتاق‌ها", fields: { room_id: "شناسه اتاق", estimated_size: "اندازه تخمینی", }, helper: { info: "فضای دیسک تخمینی مورد استفاده هر اتاق در پایگاه داده Synapse را نشان می‌دهد. اعداد تقریبی هستند.", }, }, user_media_statistics: { name: "رسانه‌ها", fields: { media_count: "شمارش رسانه ها", media_length: "طول رسانه", }, }, forward_extremities: { name: "Forward Extremities", fields: { id: "شناسه رویداد", received_ts: "مهر زمان", depth: "عمق", state_group: "گروه وضعیت", }, }, room_state: { name: "رویدادهای وضعیت", fields: { type: "نوع", content: "محتوا", origin_server_ts: "زمان ارسال", sender: "فرستنده", }, }, room_media: { name: "رسانه‌ها", fields: { media_id: "شناسه رسانه", }, helper: { info: "این یک لیست از رسانه‌ها است که در اتاق بارگذاری شده است. نمی‌توان رسانه‌ها را حذف کرد که در اتاق‌های خارجی بارگذاری شده اند.", }, action: { error: "%{errcode} (%{errstatus}) %{error}", }, }, room_directory: { name: "راهنمای اتاق", fields: { world_readable: "کاربران مهمان می توانند بدون عضویت مشاهده کنند", guest_can_join: "کاربران مهمان ممکن است ملحق شوند", }, action: { title: "اتاق را از فهرست حذف کنید |||| حذف کنید %{smart_count} اتاق ها از دایرکتوری", content: "آیا مطمئنید که می‌خواهید این اتاق را از فهرست راهنمای حذف کنید؟ |||| آیا مطمئن هستید که می‌خواهید این موارد را %{smart_count} از راهنمای اتاق‌ها حذف کنید؟", erase: "حذف از فهرست اتاق", create: "انتشار در راهنما اتاق", send_success: "اتاق با موفقیت منتشر شد.", send_failure: "خطایی رخ داده است.", }, }, destinations: { name: "سرورهای مرتبط", fields: { destination: "آدرس", failure_ts: "زمان شکست", retry_last_ts: "آخرین زمان اتصال", retry_interval: "بازه امتحان مجدد", last_successful_stream_ordering: "آخرین جریان موفق", stream_ordering: "جریان", }, action: { reconnect: "دوباره وصل شوید" }, }, registration_tokens: { name: "توکن های ثبت نام", fields: { token: "توکن", valid: "توکن معتبر", uses_allowed: "موارد استفاده مجاز", pending: "انتظار", completed: "تکمیل شد", expiry_time: "زمان انقضا", length: "طول", created_at: "تاریخ ایجاد", last_used_at: "آخرین استفاده", revoked_at: "تاریخ ابطال", }, helper: { length: "طول توکن در صورت عدم ارائه توکن." }, action: { revoke: { label: "ابطال", success: "توکن ابطال شد", }, unrevoke: { label: "بازیابی", success: "توکن بازیابی شد", }, }, }, account_data: { name: "داده‌های کاربر", }, joined_rooms: { name: "اتاق‌های عضو شده", }, memberships: { name: "عضویت‌ها", }, room_members: { name: "اعضا", }, destination_rooms: { name: "اتاق‌ها", }, }; export default misc_resources; ================================================ FILE: src/i18n/fa/reports.ts ================================================ const reports = { name: "رویداد گزارش شده |||| رویدادهای گزارش شده", fields: { id: "شناسه", received_ts: "زمان گزارش", user_id: "کاربر گزارش‌دهنده", name: "نام اتاق", score: "نمره", reason: "دلیل", event_id: "شناسه رویداد", sender: "فرستنده", }, action: { erase: { title: "حذف رویداد گزارش‌شده", content: "آیا مطمئن هستید که می‌خواهید رویداد گزارش‌شده را حذف کنید؟ این کار قابل بازگشت نیست.", }, event_lookup: { label: "جستجوی رویداد", title: "دریافت رویداد با شناسه", fetch: "دریافت", }, fetch_event_error: "دریافت رویداد با خطا مواجه شد", }, }; export default reports; ================================================ FILE: src/i18n/fa/rooms.ts ================================================ const rooms = { name: "اتاق |||| اتاق ها", fields: { room_id: "شناسه اتاق", name: "نام", canonical_alias: "نام مستعار", joined_members: "اعضا", joined_local_members: "اعضای محلی", joined_local_devices: "دستگاه‌های محلی", state_events: "رویدادهای حالت / پیچیدگی", version: "نسخه", is_encrypted: "رمزگذاری شده است", encryption: "رمزگذاری", federatable: "قابل فدراسیون", public: "قابل مشاهده در فهرست اتاق", creator: "سازنده", join_rules: "قوانین پیوستن", guest_access: "دسترسی مهمان", history_visibility: "مشاهده تاریخچه", topic: "موضوع", avatar: "آواتار", actions: "عملیات", }, filter: { public_rooms: "اتاق‌های عمومی", empty_rooms: "اتاق‌های خالی", local_members_only: "فقط اعضای محلی", }, helper: { forward_extremities: "اندام های رو به جلو، رویدادهای برگ در انتهای نمودار غیر چرخه ای جهت دار (DAG) در یک اتاق هستند، رویدادهایی که فرزند ندارند. هر چه تعداد بیشتری در یک اتاق وجود داشته باشد، وضوح حالت بیشتری را که Synapse باید انجام دهد (نکته: این یک عملیات گران است). در حالی که Synapse کدی برای جلوگیری از وجود تعداد زیادی از این موارد در یک زمان در اتاق دارد، گاهی اوقات باگ‌ها می‌توانند دوباره ظاهر شوند. اگر اتاقی بیش از 10 انتهای رو به جلو دارد، بهتر است بررسی و احتمالاً آنها را با استفاده از جستارهای SQL ذکر شده در #1760 حذف کنید.", }, enums: { join_rules: { public: "عمومی", knock: "در زدن", invite: "دعوت کردن", private: "خصوصی", restricted: "محدود", }, guest_access: { can_join: "مهمانان می‌توانند ملحق شوند", forbidden: "مهمانان نمی‌توانند ملحق شوند", }, history_visibility: { invited: "از زمان دعوت", joined: "از زمانی که پیوست", shared: "از زمان اشتراک‌گذاری", world_readable: "هر کسی", }, unencrypted: "رمزگذاری نشده", room_type: { room: "اتاق", space: "فضا", }, }, action: { erase: { title: "حذف اتاق", content: "آیا مطمئن هستید که می‌خواهید اتاق را حذف کنید؟ این قابل بازگشت نیست. همه پیام‌ها و رسانه‌های مشترک در اتاق از سرور حذف می‌شوند!", fields: { block: "حذف", }, in_progress: "حذف در حال انجام…", background_note: "می‌توانید این پنجره را ببندید، حذف در پس‌زمینه ادامه خواهد یافت.", success: "اتاق با موفقیت حذف شد.", failure: "خطایی رخ داده است.", }, make_admin: { assign_admin: "مدیر انتخاب کنید", title: "مدیر اتاق %{roomName} را انتخاب کنید", confirm: "مدیر انتخاب کنید", content: "کامل MXID کاربری را وارد کنید که به عنوان مدیر تنظیم شود.\nهشدار: برای این کار، اتاق باید حداقل یک اعضای محلی به عنوان مدیر داشته باشد.", success: "کاربر به عنوان مدیر اتاق تنظیم شد.", failure: "کاربر به عنوان مدیر اتاق تنظیم نشد. %{errMsg}", }, join: { label: "افزودن کاربر", title: "افزودن کاربر به %{roomName}", confirm: "افزودن", content: "MXID کامل کاربری را وارد کنید که باید به این اتاق بپیوندد.\nتوجه: شما باید در اتاق باشید و مجوز دعوت کاربران را داشته باشید.", success: "کاربر با موفقیت به اتاق اضافه شد.", failure: "افزودن کاربر به اتاق انجام نشد. %{errMsg}", }, block: { label: "مسدود کردن", title: "مسدود کردن %{room}", title_bulk: "مسدود کردن %{smart_count} اتاق |||| مسدود کردن %{smart_count} اتاق", title_by_id: "مسدود کردن اتاق", content: "کاربران قادر به پیوستن به این اتاق نخواهند بود.", content_bulk: "کاربران قادر به پیوستن به %{smart_count} اتاق نخواهند بود. |||| کاربران قادر به پیوستن به %{smart_count} اتاق نخواهند بود.", success: "اتاق با موفقیت مسدود شد. |||| اتاق‌ها با موفقیت مسدود شدند.", failure: "مسدود کردن اتاق ناموفق بود. |||| مسدود کردن اتاق‌ها ناموفق بود.", }, unblock: { label: "رفع مسدودیت", success: "رفع مسدودیت اتاق با موفقیت انجام شد. |||| رفع مسدودیت اتاق‌ها با موفقیت انجام شد.", failure: "رفع مسدودیت اتاق ناموفق بود. |||| رفع مسدودیت اتاق‌ها ناموفق بود.", }, purge_history: { label: "پاکسازی تاریخچه", title: "پاکسازی تاریخچه %{roomName}", content: "تمام رویدادهای قبل از تاریخ انتخاب شده از پایگاه داده حذف خواهند شد. وضعیت اتاق (پیوستن، ترک، موضوع) همیشه حفظ می‌شود. حداقل یک پیام همیشه باقی می‌ماند.\nتوجه: این عملیات ممکن است برای اتاق‌های بزرگ چند دقیقه طول بکشد.", date_label: "پاکسازی رویدادهای قبل از", delete_local: "همچنین رویدادهای ارسال شده توسط کاربران محلی را حذف کنید", in_progress: "پاکسازی در حال انجام…", background_note: "می‌توانید این پنجره را با خیال راحت ببندید، پاکسازی در پس‌زمینه ادامه خواهد یافت.", success: "تاریخچه اتاق با موفقیت پاکسازی شد.", failure: "پاکسازی تاریخچه اتاق ناموفق بود. %{errMsg}", }, quarantine_all: { label: "قرنطینه تمام رسانه‌ها", title: "قرنطینه تمام رسانه‌های %{roomName}", content: "تمام رسانه‌های محلی و راه دور در این اتاق قرنطینه خواهند شد. رسانه‌های قرنطینه شده دیگر برای کاربران قابل دسترسی نخواهند بود.", success: "%{smart_count} مورد رسانه با موفقیت قرنطینه شد.", failure: "قرنطینه رسانه‌ها با شکست مواجه شد. %{errMsg}", }, delete_all_media: { label: "حذف همه رسانه‌ها", title: "حذف همه رسانه‌های %{roomName}", content: "تمام رسانه‌های محلی این اتاق به‌طور دائمی حذف خواهند شد. تنها رسانه‌های محلی اتاق‌های رمزنگاری‌نشده تحت‌تأثیر قرار می‌گیرند — رسانه‌های سرورهای دیگر استثنا هستند. این عمل قابل بازگشت نیست.", in_progress_loading: "در حال دریافت فهرست رسانه‌ها…", in_progress: "در حال حذف رسانه‌ها… (%{current} / %{total})", do_not_close: "این پنجره را نبندید — حذف در پیش‌زمینه در حال اجراست و در صورت بستن متوقف می‌شود.", success: "%{smart_count} رسانه با موفقیت حذف شد. |||| %{smart_count} رسانه با موفقیت حذف شدند.", failure: "حذف رسانه‌ها ناموفق بود. %{errMsg}", }, delete_all_media_bulk: { title: "حذف همه رسانه‌های %{smart_count} اتاق؟ |||| حذف همه رسانه‌های %{smart_count} اتاق؟", content: "تمام رسانه‌های محلی اتاق‌های انتخاب‌شده به‌طور دائمی حذف خواهند شد (فقط اتاق‌های رمزنگاری‌نشده). رسانه‌های سرورهای دیگر استثنا هستند. این عمل قابل بازگشت نیست.", success: "رسانه‌های %{success} از %{total} اتاق حذف شدند.", partial_failure: "رسانه‌های %{success} از %{total} اتاق حذف شدند. %{failed} ناموفق بود.", }, event_context: { lookup_title: "جستجوی رویداد بر اساس ID", jump_to_date: "پرش به تاریخ", direction: "جهت", forward: "به جلو", backward: "به عقب", target_event: "رویداد هدف", events_before: "رویدادهای قبل", events_after: "رویدادهای بعد", not_found: "هیچ رویدادی در زمان مشخص شده یافت نشد", failure: "بازیابی زمینه رویداد ناموفق بود", }, messages: { load_older: "بارگذاری قدیمی‌تر", load_newer: "بارگذاری جدیدتر", no_messages: "هیچ پیامی در این اتاق وجود ندارد", failure: "بارگذاری پیام‌ها ناموفق بود", filter: "فیلترها", filter_type: "انواع رویداد", filter_sender: "فرستندگان", advanced_filters: "فیلترهای پیشرفته", filter_not_type: "حذف انواع رویداد", filter_not_sender: "حذف فرستندگان", contains_url: "دارای URL", any: "هر", with_url: "فقط با URL", without_url: "فقط بدون URL", apply_filter: "اعمال", clear_filters: "پاک کردن", }, hierarchy: { load_more: "بارگذاری بیشتر", max_depth: "حداکثر عمق", unlimited: "نامحدود", refresh: "بازنشانی", members: "%{count} عضو", space: "فضا", room: "اتاق", suggested: "پیشنهادی", no_children: "این اتاق هیچ اتاق فرزندی ندارد", failure: "بارگذاری سلسله‌مراتب ناموفق بود", }, }, }; export default rooms; ================================================ FILE: src/i18n/fa/users.ts ================================================ const users = { name: "کاربر |||| کاربران", email: "ایمیل", msisdn: "شماره تلفن", threepid: "ایمیل / شماره تلفن", membership: "عضویت |||| عضویت ها", fields: { avatar: "آواتار", id: "شناسه کاربر", name: "نام", is_guest: "مهمان", admin: "مدیر سرور", deactivated: "غیرفعال", locked: "قفل شده", suspended: "معلق", shadow_banned: "مسدود پنهان", show_guests: "نمایش مهمانان", show_deactivated: "فقط غیرفعال‌شده‌ها", show_locked: "نمایش کاربران قفل شده", filter_user_all: "همه", filter_deactivated_false: "فعال", filter_deactivated_true: "غیرفعال", filter_locked_false: "حذف قفل‌شده‌ها", filter_locked_true: "شامل قفل‌شده‌ها", filter_guests_false: "حذف مهمانان", filter_guests_true: "شامل مهمانان", show_system_users: "نمایش کاربران سیستمی", filter_system_users_false: "حذف کاربران سیستمی", filter_system_users_true: "فقط کاربران سیستمی", show_suspended: "نمایش کاربران معلق", show_shadow_banned: "نمایش کاربران مسدود پنهان", user_id: "جستجوی کاربر", displayname: "نام نمایشی", password: "رمز عبور", avatar_url: "آواتار سرور", avatar_src: "آواتار", medium: "متوسط", threepids: "سرویس احراز هویت", address: "آدرس", creation_ts_ms: "ساخته شده در", consent_version: "Consent نسخه", sent_invite_count: "دعوت‌های ارسال شده", cumulative_joined_room_count: "تعداد کل اتاق‌های پیوسته", auth_provider: "ارائه دهنده", user_type: "نوع کاربر", erased: "پاک‌شده (GDPR)", }, helper: { password_required_for_reactivation: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.", admin: "مدیر سرور دارای کنترل کامل بر روی سرور و کاربران آن است.", lock: "ممنوعیت استفاده از سرور توسط کاربر. این یک عملیات غیرمخرب است که می‌تواند برگردانده شود.", password: "با تغییر رمز عبور کاربر از تمام دستگاه‌ها خارج می‌شود.", create_password: "رمز عبور قوی و امنی را با استفاده از دکمه زیر ایجاد کنید.", deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.", suspend: "کاربران معلق نمی‌توانند وارد شوند و پیام‌های آنها به دیگران نمایش داده نمی‌شود.", shadow_ban: "کاربر مسدود پنهان پاسخ‌های معمولی دریافت می‌کند، اما رویدادهای او به سایر کاربران یا اتاق‌ها منتشر نمی‌شوند. فقط به عنوان آخرین راه‌حل استفاده کنید.", erase: "کاربر را به عنوان GDPR پاک شده علامت‌گذاری کنید", erase_text: "این بدان معناست که پیام‌های ارسال‌شده توسط این کاربر(ها) همچنان برای افرادی که در زمان ارسال در اتاق حضور داشتند قابل مشاهده خواهد بود، اما از کاربرانی که بعداً به اتاق می‌پیوندند پنهان می‌شود.", erase_admin_error: "حذف کاربر ادمین مجاز نیست.", modify_managed_user_error: "تغییر کاربر مدیریت‌شده توسط سیستم مجاز نیست.", username_available: "نام کاربری موجود", sent_invite_count: "تعداد کل دعوت‌های ارسال شده توسط این کاربر در تمام اتاق‌ها.", cumulative_joined_room_count: "تعداد کل اتاق‌هایی که این کاربر تاکنون به آن‌ها پیوسته، شامل اتاق‌هایی که ترک کرده یا از آن‌ها محروم شده است.", }, badge: { you: "شما", bot: "ربات", admin: "مدیر", support: "پشتیبان", regular: "کاربر عادی", federated: "فدرال", system_managed: "مدیریت سیستم", }, action: { erase: "پاک کردن اطلاعات کاربر", erase_avatar: "حذف آواتار", delete_media: "حذف تمام رسانه‌های آپلود شده توسط کاربر(ها)", redact_events: "ویرایش حذفی تمام رویدادهای ارسال‌شده توسط کاربر(ها)", redact_in_progress: "حذف رویدادها در حال انجام\u2026", redact_background_note: "می‌توانید این پنجره را با خیال راحت ببندید، حذف رویدادها در پس‌زمینه ادامه خواهد یافت.", redact_success: "تمام رویدادها با موفقیت حذف شدند.", redact_failure: "حذف با %{smart_count} رویداد ناموفق به پایان رسید.", generate_password: "تولید رمز عبور", reset_password: { label: "بازنشانی رمز عبور", title: "بازنشانی رمز عبور", helper: "تغییر رمز عبور %{user}", password: "رمز عبور", logout_devices: "خروج از تمام دستگاه‌ها", success: "رمز عبور با موفقیت بازنشانی شد", failure: "بازنشانی رمز عبور ناموفق بود", error_no_password: "رمز عبور الزامی است", }, login_as: { label: "ورود به عنوان کاربر", title: "ورود به عنوان کاربر", helper: "دریافت توکن دسترسی برای احراز هویت به عنوان %{user}. این عملیات دستگاه جدیدی برای کاربر ایجاد نمی‌کند و در لیست دستگاه‌ها/نشست‌ها نمایش داده نمی‌شود. کاربر هدف معمولاً قادر به تشخیص این ورود نخواهد بود.", valid_until: "تعیین تاریخ انقضا", success: "توکن دسترسی با موفقیت ایجاد شد", failure: "ایجاد توکن دسترسی ناموفق بود", result_title: "توکن دسترسی %{user}", access_token: "توکن دسترسی", expires_at: "این توکن دسترسی در تاریخ %{date} منقضی خواهد شد", }, overwrite_title: "هشدار!", overwrite_content: "این نام کاربری قبلا استفاده شده است. آیا مطمئن هستید که می‌خواهید کاربر موجود را بازنویسی کنید؟", overwrite_cancel: "انصراف", overwrite_confirm: "بازنویسی", quarantine_all: { label: "قرنطینه تمام رسانه‌ها", title: "قرنطینه تمام رسانه‌های %{userName}", content: "تمام رسانه‌های محلی آپلود شده توسط این کاربر قرنطینه خواهند شد. رسانه‌های قرنطینه شده دیگر برای سایر کاربران قابل دسترسی نخواهند بود.", success: "%{smart_count} مورد رسانه با موفقیت قرنطینه شد.", failure: "قرنطینه رسانه‌ها با شکست مواجه شد. %{errMsg}", }, delete_all_media: { label: "حذف همه رسانه‌ها", title: "حذف همه رسانه‌های %{userName}", content: "تمام رسانه‌های بارگذاری‌شده توسط این کاربر به‌طور دائمی حذف خواهند شد. این عمل قابل بازگشت نیست.", in_progress: "در حال حذف رسانه‌ها…", background_note: "می‌توانید این پنجره را با خیال راحت ببندید — حذف در پس‌زمینه ادامه خواهد یافت.", success: "%{smart_count} رسانه با موفقیت حذف شد. |||| %{smart_count} رسانه با موفقیت حذف شدند.", failure: "حذف رسانه‌ها ناموفق بود. %{errMsg}", }, delete_all_media_bulk: { title: "حذف همه رسانه‌های %{smart_count} کاربر؟ |||| حذف همه رسانه‌های %{smart_count} کاربر؟", content: "تمام رسانه‌های بارگذاری‌شده توسط کاربران انتخاب‌شده به‌طور دائمی حذف خواهند شد. این عمل قابل بازگشت نیست.", success: "رسانه‌های %{success} از %{total} کاربر حذف شدند.", partial_failure: "رسانه‌های %{success} از %{total} کاربر حذف شدند. %{failed} ناموفق بود.", }, allow_cross_signing: { label: "اجازه بازنشانی امضای متقاطع", title: "اجازه جایگزینی کلید امضای متقاطع", content: "آیا به %{user} اجازه داده شود کلیدهای امضای متقاطع خود را بدون احراز هویت تعاملی کاربر جایگزین کند؟ این یک پنجره موقت ایجاد می‌کند که در آن کلیدها می‌توانند جایگزین شوند.", success: "جایگزینی کلید امضای متقاطع تا %{deadline} مجاز است", failure: "اجازه جایگزینی امضای متقاطع ناموفق بود", no_key: "کاربر کلید اصلی امضای متقاطع ندارد", }, find_user: { label: "جستجوی کاربر", title: "جستجوی کاربر", lookup_type: "نوع جستجو", by_threepid: "با ایمیل / تلفن", by_auth_provider: "با ارائه‌دهنده احراز هویت", provider: "شناسه ارائه‌دهنده احراز هویت", external_id: "شناسه خارجی", search: "جستجو", not_found: "کاربر یافت نشد", failure: "جستجوی کاربر ناموفق بود", }, renew_account: { label: "تمدید حساب", title: "تمدید اعتبار حساب", content: "اعتبار حساب %{user} را تمدید کنید. می‌توانید به صورت اختیاری یک تاریخ انقضای سفارشی تعیین کنید. اگر خالی بماند، دوره تمدید پیش‌فرض سرور استفاده خواهد شد.", expiration: "تاریخ انقضا", expiration_helper: "برای استفاده از دوره تمدید پیش‌فرض سرور، خالی بگذارید", renewal_emails: "ارسال ایمیل‌های اطلاع‌رسانی تمدید", success: "اعتبار حساب تا %{date} تمدید شد", failure: "تمدید اعتبار حساب ناموفق بود", }, system_users_scan_in_progress: "لطفاً صبر کنید — هنوز در حال جستجوی کاربران مطابق هستیم، صفحه به زودی بارگذاری می‌شود", reverse_search_scan_in_progress: "لطفاً صبر کنید — در حال اسکن همه کاربران برای حذف موارد مطابق هستیم، صفحه به زودی بارگذاری می‌شود", }, limits: { messages_per_second: "پیام در ثانیه", messages_per_second_text: "تعداد عملیاتی که می‌تواند در یک ثانیه انجام شود.", burst_count: "ظرفیت انفجاری", burst_count_text: "تعداد عملیاتی که می‌تواند قبل از محدودیت انجام شود.", }, account_data: { title: "داده‌های کاربر", global: "عمومی", rooms: "اتاق‌ها", }, }; export default users; ================================================ FILE: src/i18n/fr/common.ts ================================================ import frenchMessages from "ra-language-french"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const common: Record = { ...frenchMessages, ketesa: { auth: { base_url: "URL du serveur d’accueil", welcome: "Bienvenue sur %{name}", description: "L’évolution de Synapse Admin. Gérez, surveillez et maintenez votre serveur Matrix depuis une seule interface claire. Conçu aussi bien pour les petits serveurs privés que pour les grandes communautés fédérées.", server_version: "Version du serveur Synapse", username_error: "Veuillez entrer un nom d'utilisateur complet : « @utilisateur:domaine »", protocol_error: "L'URL doit commencer par « http:// » ou « https:// »", url_error: "L'URL du serveur Matrix n'est pas valide", sso_sign_in: "Se connecter avec l'authentification unique", credentials: "Identifiants", access_token: "Token d'accès", supports_specs: "prend en charge les spécifications Matrix", logout_access_token_dialog: { title: "Vous utilisez un token d'accès Matrix existant.", content: "Voulez-vous détruire cette session (qui pourrait être utilisée ailleurs, par exemple dans un client Matrix) ou simplement vous déconnecter du panneau d'administration?", confirm: "Détruire la session", cancel: "Se déconnecter simplement du panneau d'administration", }, }, users: { invalid_user_id: "Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur d’accueil.", tabs: { sso: "Authentification unique", experimental: "Expérimental", limits: "Limites", account_data: "Données du compte", sessions: "Sessions", }, danger_zone: "Zone dangereuse", }, rooms: { details: "Détails de la salle", tabs: { basic: "Informations de base", members: "Membres", detail: "Détails", permission: "Permissions", media: "Médias", messages: "Messages", hierarchy: "Hiérarchie", }, }, reports: { tabs: { basic: "Informations de base", detail: "Détails" } }, admin_config: { soft_failed_events: "Événements en échec souple", spam_flagged_events: "Événements signalés comme spam", success: "Configuration administrateur mise à jour", failure: "Échec de la mise à jour de la configuration administrateur", }, }, import_users: { error: { at_entry: "Pour l'entrée %{entry} : %{message}", error: "Erreur", required_field: "Le champ requis « %{field} » est manquant", invalid_value: "Valeur non valide à la ligne %{row}. Le champ « %{field} » ne peut être que « true » ou « false »", unreasonably_big: "Refus de charger un fichier trop volumineux de %{size} mégaoctets", already_in_progress: "Un import est déjà en cours", id_exits: "L'identifiant %{id} déjà présent", }, title: "Importer des utilisateurs à partir d'un fichier CSV", goToPdf: "Voir le PDF", cards: { importstats: { header: "Utilisateurs analysés pour l'import", users_total: "%{smart_count} utilisateur dans le fichier CSV |||| %{smart_count} utilisateurs dans le fichier CSV", guest_count: "%{smart_count} visiteur |||| %{smart_count} visiteurs", admin_count: "%{smart_count} administrateur |||| %{smart_count} administrateurs", }, conflicts: { header: "Stratégie de résolution des conflits", mode: { stop: "S'arrêter en cas de conflit", skip: "Afficher l'erreur et ignorer le conflit", }, }, ids: { header: "Identifiants", all_ids_present: "Identifiants présents pour chaque entrée", count_ids_present: "%{smart_count} entrée avec identifiant |||| %{smart_count} entrées avec identifiant", mode: { ignore: "Ignorer les identifiants dans le ficher CSV et en créer de nouveaux", update: "Mettre à jour les enregistrements existants", }, }, passwords: { header: "Mots de passe", all_passwords_present: "Mots de passe présents pour chaque entrée", count_passwords_present: "%{smart_count} entrée avec mot de passe |||| %{smart_count} entrées avec mot de passe", use_passwords: "Utiliser les mots de passe provenant du fichier CSV", }, upload: { header: "Fichier CSV en entrée", explanation: "Vous pouvez télécharger ici un fichier contenant des valeurs séparées par des virgules qui sera traité pour créer ou mettre à jour des utilisateurs. Le fichier doit inclure les champs « id » et « displayname ». Vous pouvez télécharger et adapter un fichier d'exemple ici : ", }, startImport: { simulate_only: "Simuler", run_import: "Importer", }, results: { header: "Résultats de l'import", total: "%{smart_count} entrée au total |||| %{smart_count} entrées au total", successful: "%{smart_count} entrées importées avec succès", skipped: "%{smart_count} entrées ignorées", download_skipped: "Télécharger les entrées ignorées", with_error: "%{smart_count} entrée avec des erreurs |||| %{smart_count} entrées avec des erreurs", simulated_only: "L'import était simulé", }, }, }, delete_media: { name: "Media", fields: { before_ts: "Dernier accès avant", size_gt: "Plus grand que (en octets)", keep_profiles: "Conserver les images de profil", }, action: { send: "Supprimer le média", send_success: "%{smart_count} fichier média supprimé avec succès. |||| %{smart_count} fichiers média supprimés avec succès.", send_success_none: "Aucun fichier média ne correspondait aux critères spécifiés. Rien n'a été supprimé.", send_failure: "Une erreur s'est produite", }, helper: { send: "Cette API supprime les médias locaux du disque de votre propre serveur. Cela inclut toutes les vignettes locales et les copies des médias téléchargés. Cette API n'affectera pas les médias qui ont été téléversés dans des dépôts de médias externes.", }, }, purge_remote_media: { name: "Médias distants", fields: { before_ts: "Dernier accès avant", }, action: { send: "Purger les médias distants", send_success: "%{smart_count} fichier média distant purgé avec succès. |||| %{smart_count} fichiers média distants purgés avec succès.", send_success_none: "Aucun fichier média distant ne correspondait aux critères spécifiés. Rien n'a été purgé.", send_failure: "Une erreur est survenue lors de la demande de purge des médias distants.", }, helper: { send: "Cette API purge le cache des médias distants du disque de votre propre serveur. Cela inclut toutes les vignettes locales et les copies des médias téléchargés. Cette API n'affectera pas les médias qui ont été téléchargés dans le dépôt de médias du serveur.", }, }, etkecc: { donate: { menu_label: "Faire un don", name: "Soutenir le développement de Ketesa", title: "Soutenir le développement de Ketesa", description_1: "Le projet Ketesa est libre et open source, et nous le développons et le maintenons ouvertement pour la communauté Matrix.", description_2: "Si le projet Ketesa vous a été utile, un don nous aide à poursuivre le travail qui le fait vivre : développement, maintenance, corrections et améliorations continues.", description_3: "Cela nous aide à consacrer plus de temps à améliorer le projet pour toutes les personnes qui en dépendent.", description_4: "Chaque contribution aide, et nous vous remercions sincèrement pour votre soutien ! ❤️", button: "Faire un don", signature_team: "l'équipe etke.cc", }, components: { name: "Composants", description: "Consultez et gérez vos composants actifs et découvrez ce que vous pouvez ajouter à votre serveur.", no_section: "Votre serveur", per_month: "/mois", included: "Inclus", total: "Total", loading: "Chargement des composants...", state_add: "Ajouter", state_remove: "Supprimer", add_aria: "Demander l'ajout de %{name}", remove_aria: "Demander la suppression de %{name}", preview_label: "aperçu", request_changes: "Demander des modifications", requesting: "Envoi en cours...", request_failure: "L'envoi de la demande de modification a échoué. Veuillez réessayer.", request_sent_title: "Demande soumise", request_sent_body: "Votre demande de modification de composant a été envoyée au support etke.cc. Si vous avez besoin de modifications supplémentaires, veuillez répondre à cette demande de support plutôt qu'en ouvrir une nouvelle.", request_sent_close: "Fermer", request_sent_view: "Voir la demande", request_already_sent: "Une demande de modification est déjà ouverte. Pour demander d'autres modifications, répondez à votre ticket de support existant.", request_already_sent_view: "Voir le ticket", free_label: "Gratuit", available_label: "Disponible", tagline: "Améliorez votre serveur — ajoutez ou supprimez n'importe quel composant à tout moment.", section: { bridges: "Passerelles", extras: "Compléments", matrix_apps: "Applications Matrix", matrix_bots: "Bots Matrix", matrix_extras: "Compléments Matrix", }, }, billing: { name: "Facturation", title: "Historique des paiements", no_payments: "Aucun paiement trouvé.", no_payments_helper: "Si vous pensez qu’il s’agit d’une erreur, veuillez contacter le support etke.cc.", description1: "Vous pouvez consulter les paiements et générer des factures ici. Pour en savoir plus sur la gestion des abonnements, rendez-vous sur", description2: "Pour modifier votre e-mail de facturation ou ajouter des informations d’entreprise aux factures, consultez", fields: { transaction_id: "ID de transaction", email: "E-mail", type: "Type", amount: "Montant", paid_at: "Payé le", invoice: "Facture", }, enums: { type: { subscription: "Abonnement", one_time: "Paiement unique", }, }, helper: { download_invoice: "Télécharger la facture", downloading: "Téléchargement...", download_started: "Le téléchargement de la facture a commencé.", invoice_not_available: "En attente", loading: "Chargement des informations de facturation...", loading_failed1: "Un problème est survenu lors du chargement des informations de facturation.", loading_failed2: "Veuillez réessayer plus tard.", loading_failed3: "Si le problème persiste, veuillez contacter le support etke.cc.", loading_failed4: "avec le message d’erreur suivant :", }, components: "Composants actifs", components_no_section: "Votre serveur", components_per_month: "/mois", components_included: "Inclus", components_total: "Total", components_help_title: "En savoir plus sur %{name}", components_state_install: "Installer", components_state_remove: "Supprimer", components_remove_aria: "Installer/supprimer %{name}", components_preview_label: "aperçu", components_request_changes: "Demander des modifications", components_requesting: "Envoi en cours...", components_request_failure: "L'envoi de la demande de modification a échoué. Veuillez réessayer.", components_request_sent_title: "Demande soumise", components_request_sent_body: "Votre demande de modification de composant a été envoyée au support etke.cc. Si vous avez besoin de modifications supplémentaires, veuillez répondre à cette demande de support plutôt qu'en ouvrir une nouvelle.", components_request_sent_close: "Fermer", components_request_sent_view: "Voir la demande", components_request_already_sent: "Une demande de modification est déjà ouverte. Pour demander d'autres modifications, répondez à votre ticket de support existant.", components_request_already_sent_view: "Voir le ticket", status: { issue: { title: "L'abonnement nécessite votre attention", description: "Nous avons détecté un problème avec votre abonnement. Ne vous inquiétez pas — c'est facile à résoudre.", due_overdue: "En retard depuis", due_upcoming: "Échéance dans", expected: "Montant attendu", last_paid: "Dernier paiement", fix_link: "Résoudre le retard de paiement", fix_mismatch_link: "Mettre à jour le prix de l'abonnement", support_link: "Contacter le support", }, }, }, status: { name: "État du serveur", badge: { default: "Cliquez pour voir l’état du serveur", running: "En cours d’exécution : %{command}. %{text}", status_ok: "Le serveur est en ligne", status_error: "État: Erreur", status_maintenance: "Le système est actuellement en mode maintenance.", status_process_running: "Le serveur exécute une commande", status_checking: "Vérification de l’état du serveur", }, category: { "Host Metrics": "Métriques de l’hôte", Network: "Réseau", HTTP: "HTTP", Matrix: "Matrix", }, status: "État", error: "Erreur", loading: "Récupération de l’état de fonctionnement du serveur en temps réel... Un instant !", intro1: "Ceci est un rapport de surveillance en temps réel de votre serveur. Vous pouvez en savoir plus sur", intro2: "Si l’une des vérifications ci-dessous vous inquiète, consultez les actions recommandées sur", help: "Aide", }, maintenance: { title: "Le système est actuellement en mode maintenance.", try_again: "Veuillez réessayer plus tard.", note: "Vous n’avez pas besoin de contacter le support à ce sujet : nous nous en occupons déjà !", }, actions: { name: "Commandes serveur", available_title: "Commandes disponibles", available_description: "Les commandes suivantes peuvent être exécutées.", available_help_intro: "Vous trouverez plus de détails sur chacune d’elles sur", scheduled_title: "Commandes planifiées", scheduled_description: "Les commandes suivantes sont planifiées pour s’exécuter à des moments précis. Vous pouvez voir les détails et les modifier si nécessaire.", recurring_title: "Commandes récurrentes", recurring_description: "Les commandes suivantes sont configurées pour s’exécuter chaque semaine à un jour et une heure précis. Vous pouvez voir les détails et les modifier si nécessaire.", scheduled_help_intro: "Vous trouverez plus de détails sur ce mode sur", recurring_help_intro: "Vous trouverez plus de détails sur ce mode sur", maintenance_title: "Le système est actuellement en mode maintenance.", maintenance_try_again: "Veuillez réessayer plus tard.", maintenance_note: "Vous n’avez pas besoin de contacter le support à ce sujet : nous nous en occupons déjà !", maintenance_commands_blocked: "Les commandes ne peuvent pas être exécutées tant que le mode maintenance n’est pas désactivé.", table: { aria_label: "Commandes du serveur", command: "Commande", description: "Description", arguments: "Arguments", is_recurring: "Récurrente ?", run_at: "Exécuter (heure locale)", next_run_at: "Prochaine exécution (heure locale)", time_utc: "Heure (UTC)", time_local: "Heure (locale)", }, buttons: { create: "Créer", update: "Mettre à jour", back: "Retour", delete: "Supprimer", run: "Exécuter", }, command_scheduled: "Commande planifiée : %{command}", command_scheduled_args: "avec des arguments supplémentaires : %{args}", expect_prefix: "Attendez le résultat dans la page", expect_suffix: "bientôt.", notifications_link: "Notifications", command_help_title: "Aide %{command}", scheduled_title_create: "Créer une commande planifiée", scheduled_title_edit: "Modifier la commande planifiée", recurring_title_create: "Créer une commande récurrente", recurring_title_edit: "Modifier la commande récurrente", scheduled_details_title: "Détails de la commande planifiée", recurring_warning: "Les commandes planifiées créées à partir d’une commande récurrente ne sont pas modifiables, car elles seront régénérées automatiquement. Veuillez modifier la commande récurrente à la place.", command_details_intro: "Vous trouverez plus de détails sur la commande sur", form: { id: "ID", command: "Commande", scheduled_at: "Planifiée pour", day_of_week: "Jour de la semaine", }, delete_scheduled_title: "Supprimer la commande planifiée", delete_recurring_title: "Supprimer la commande récurrente", delete_confirm: "Êtes-vous sûr de vouloir supprimer la commande : %{command} ?", errors: { unknown: "Une erreur inconnue s’est produite", delete_failed: "Erreur : %{error}", }, days: { monday: "Lundi", tuesday: "Mardi", wednesday: "Mercredi", thursday: "Jeudi", friday: "Vendredi", saturday: "Samedi", sunday: "Dimanche", }, scheduled: { action: { create_success: "Commande planifiée créée avec succès", update_success: "Commande planifiée mise à jour avec succès", update_failure: "Une erreur est survenue", delete_success: "Commande planifiée supprimée avec succès", delete_failure: "Une erreur est survenue", }, }, recurring: { action: { create_success: "Commande récurrente créée avec succès", update_success: "Commande récurrente mise à jour avec succès", update_failure: "Une erreur est survenue", delete_success: "Commande récurrente supprimée avec succès", delete_failure: "Une erreur est survenue", }, }, }, notifications: { title: "Notifications", new_notifications: "%{smart_count} nouvelle notification |||| %{smart_count} nouvelles notifications", no_notifications: "Aucune notification pour le moment", see_all: "Voir toutes les notifications", clear_all: "Tout effacer", ago: "il y a", advisory_tooltip: "Il se peut que vous ayez manqué une notification. Veuillez également consulter #news:etke.cc, etke.cc/news, ou votre messagerie électronique.", unavailable_tooltip: "Il se peut que les notifications soient indisponibles. Cliquez pour plus de détails.", unavailable_title: "Il se peut que les notifications soient indisponibles en ce moment", unavailable_body: "Il se peut que des mises à jour ne puissent pas être transmises à ce panneau en ce moment — ou qu'il n'y ait rien de nouveau. Pour ne rien manquer, veuillez vérifier régulièrement :", unavailable_link_matrix: "Salon Matrix #news:etke.cc", unavailable_link_news: "Page d'annonces sur etke.cc/news", unavailable_link_email: "Votre boîte de réception (y compris le dossier spam)", unavailable_retry: "Réessayer", }, currently_running: { command: "En cours d’exécution :", started_ago: "(démarré il y a %{time})", }, time: { less_than_minute: "quelques secondes", minutes: "%{smart_count} minute |||| %{smart_count} minutes", hours: "%{smart_count} heure |||| %{smart_count} heures", days: "%{smart_count} jour |||| %{smart_count} jours", weeks: "%{smart_count} semaine |||| %{smart_count} semaines", months: "%{smart_count} mois |||| %{smart_count} mois", }, support: { name: "Support", menu_label: "Contacter le support", description: "Ouvrez une demande de support ou faites un suivi d'une demande existante. Notre équipe répondra dans les plus brefs délais.", create_title: "Nouvelle demande de support", no_requests: "Aucune demande de support pour l'instant.", no_messages: "Aucun message pour l'instant.", closed_message: "Cette demande est clôturée. Si vous avez toujours un problème, veuillez en ouvrir une nouvelle.", fields: { subject: "Sujet", message: "Message", reply: "Réponse", status: "Statut", created_at: "Créé", updated_at: "Dernière mise à jour", }, status: { active: "En attente de l'opérateur", open: "Ouvert", closed: "Fermé", pending: "En attente de votre réponse", }, buttons: { new_request: "Nouvelle demande", submit: "Soumettre", cancel: "Annuler", send: "Envoyer", back: "Retour au support", attach_files: "Joindre des fichiers", }, helper: { loading: "Chargement des demandes de support...", reply_hint: "Ctrl+Entrée pour envoyer", reply_placeholder: "Incluez autant de détails que possible.", before_contact_title: "Avant de nous contacter", help_pages_prompt: "Veuillez d’abord consulter nos pages d’aide :", services_prompt: "Nous ne fournissons que les services listés sur la page Services :", topics_prompt: "Nous ne pouvons aider que sur les sujets pris en charge :", scope_confirm_label: "J’ai consulté les pages d’aide et je confirme que cette demande correspond aux sujets pris en charge.", english_only_notice: "Le support est fourni uniquement en anglais.", response_time_prompt: "Réponse sous 48 heures. Besoin de délais plus rapides ? Voir :", attachments_limit: "Jusqu’à 5 fichiers, 5 Mo chacun, 10 Mo au total.", close_request_label: "Fermer cette demande après l’envoi", }, actions: { create_success: "Demande de support créée avec succès.", create_failure: "Échec de la création de la demande de support.", send_failure: "Échec de l’envoi du message.", attachment_too_large: 'Le fichier "%{name}" dépasse la limite de 5 Mo.', too_many_attachments: "Maximum 5 fichiers autorisés.", total_size_exceeded: "La taille totale des pièces jointes dépasse 10 Mo.", }, }, }, }; export default common; ================================================ FILE: src/i18n/fr/index.ts ================================================ import { SynapseTranslationMessages } from "../types"; import common from "./common"; import mas from "./mas"; import misc_resources from "./misc_resources"; import reports from "./reports"; import rooms from "./rooms"; import users from "./users"; const fr: SynapseTranslationMessages = { ra: common.ra, ketesa: common.ketesa, import_users: common.import_users, delete_media: common.delete_media, purge_remote_media: common.purge_remote_media, etkecc: common.etkecc, resources: { users, rooms, reports, scheduled_tasks: misc_resources.scheduled_tasks, connections: misc_resources.connections, devices: misc_resources.devices, users_media: misc_resources.users_media, protect_media: misc_resources.protect_media, quarantine_media: misc_resources.quarantine_media, pushers: misc_resources.pushers, servernotices: misc_resources.servernotices, database_room_statistics: misc_resources.database_room_statistics, user_media_statistics: misc_resources.user_media_statistics, forward_extremities: misc_resources.forward_extremities, room_state: misc_resources.room_state, room_media: misc_resources.room_media, room_directory: misc_resources.room_directory, destinations: misc_resources.destinations, registration_tokens: misc_resources.registration_tokens, account_data: misc_resources.account_data, joined_rooms: misc_resources.joined_rooms, memberships: misc_resources.memberships, room_members: misc_resources.room_members, destination_rooms: misc_resources.destination_rooms, ...mas, }, }; export default fr; ================================================ FILE: src/i18n/fr/mas.ts ================================================ const mas = { mas_users: { name: "Utilisateur MAS |||| Utilisateurs MAS", fields: { id: "ID MAS", username: "Nom d'utilisateur", admin: "Administrateur", locked: "Verrouillé", deactivated: "Désactivé", legacy_guest: "Invité hérité", created_at: "Créé le", locked_at: "Verrouillé le", deactivated_at: "Désactivé le", }, filter: { status: "Statut", search: "Rechercher", status_active: "Actif", status_locked: "Verrouillé", status_deactivated: "Désactivé", }, action: { lock: { label: "Verrouiller", success: "Utilisateur verrouillé" }, unlock: { label: "Déverrouiller", success: "Utilisateur déverrouillé" }, deactivate: { label: "Désactiver", success: "Utilisateur désactivé" }, reactivate: { label: "Réactiver", success: "Utilisateur réactivé" }, set_admin: { label: "Accorder les droits d'administrateur", success: "Statut d'administrateur mis à jour" }, remove_admin: { label: "Révoquer les droits d'administrateur", success: "Statut d'administrateur mis à jour" }, set_password: { label: "Définir le mot de passe", title: "Définir le mot de passe", success: "Mot de passe défini", failure: "Échec de la définition du mot de passe", }, }, }, mas_user_emails: { name: "E-mail |||| E-mails", empty: "Aucun e-mail", fields: { email: "E-mail", user_id: "ID utilisateur", created_at: "Créé le", actions: "Actions", }, action: { remove: { label: "Supprimer", title: "Supprimer l'e-mail", content: "Supprimer %{email} ?", success: "E-mail supprimé", }, create: { success: "E-mail ajouté" }, }, }, mas_compat_sessions: { name: "Session compat |||| Sessions compat", empty: "Aucune session compat", fields: { user_id: "ID utilisateur", device_id: "ID appareil", created_at: "Créé le", user_agent: "Agent utilisateur", last_active_at: "Dernière activité", last_active_ip: "Dernière IP", finished_at: "Terminé le", human_name: "Nom", active: "Active", }, action: { finish: { label: "Terminer", title: "Terminer la session ?", content: "Cette session sera terminée.", success: "Session terminée", }, }, }, mas_oauth2_sessions: { name: "Session OAuth2 |||| Sessions OAuth2", empty: "Aucune session OAuth2", fields: { user_id: "ID utilisateur", client_id: "ID client", scope: "Portée", created_at: "Créé le", user_agent: "Agent utilisateur", last_active_at: "Dernière activité", last_active_ip: "Dernière IP", finished_at: "Terminé le", human_name: "Nom", active: "Active", }, action: { finish: { label: "Terminer", title: "Terminer la session ?", content: "Cette session sera terminée.", success: "Session terminée", }, }, }, mas_policy_data: { name: "Données de politique", current_policy: "Politique actuelle", no_policy: "Aucune politique n'est actuellement définie.", set_policy: "Définir une nouvelle politique", invalid_json: "JSON invalide", fields: { json_placeholder: "Saisir les données de politique en JSON…", created_at: "Créé le", }, action: { save: { label: "Définir la politique", success: "Politique mise à jour", failure: "Échec de la mise à jour de la politique", }, }, }, mas_user_sessions: { name: "Session navigateur |||| Sessions navigateur", fields: { user_id: "ID utilisateur", created_at: "Créé le", finished_at: "Terminé le", user_agent: "Agent utilisateur", last_active_at: "Dernière activité", last_active_ip: "Dernière IP", active: "Active", }, action: { finish: { label: "Terminer", title: "Terminer la session ?", content: "Cette session navigateur sera terminée.", success: "Session terminée", }, }, }, mas_upstream_oauth_links: { name: "Lien OAuth amont |||| Liens OAuth amont", fields: { user_id: "ID utilisateur", provider_id: "ID fournisseur", subject: "Sujet", human_account_name: "Nom du compte", created_at: "Créé le", }, helper: { provider_id: "L'ID du fournisseur OAuth amont. Trouvez-le dans la liste des fournisseurs OAuth amont.", }, action: { remove: { label: "Supprimer", title: "Supprimer le lien OAuth ?", content: "Le lien OAuth amont de cet utilisateur sera supprimé.", success: "Lien OAuth supprimé", }, }, }, mas_upstream_oauth_providers: { name: "Fournisseur OAuth |||| Fournisseurs OAuth", fields: { issuer: "Émetteur", human_name: "Nom", brand_name: "Marque", created_at: "Créé le", disabled_at: "Désactivé le", enabled: "Actif", }, }, mas_personal_sessions: { name: "Session personnelle |||| Sessions personnelles", empty: "Aucune session personnelle", fields: { owner_user_id: "ID propriétaire", actor_user_id: "Utilisateur", human_name: "Nom", scope: "Portée", created_at: "Créé le", revoked_at: "Révoqué le", last_active_at: "Dernière activité", last_active_ip: "Dernière IP", expires_at: "Expire le", expires_in: "Expire dans (secondes)", active: "Active", }, helper: { expires_in: "Optionnel. Nombre de secondes avant expiration. Laisser vide pour ne pas expirer.", }, action: { revoke: { label: "Révoquer", title: "Révoquer la session ?", content: "Le token d'accès sera révoqué définitivement.", success: "Session révoquée", }, create: { token_title: "Token d'accès créé", token_content: "Copiez ce token. Il ne sera plus affiché après la fermeture de cette fenêtre.", }, }, }, mas_sessions: { status: { active: "Active", finished: "Terminée", revoked: "Révoquée", }, }, }; export default mas; ================================================ FILE: src/i18n/fr/misc_resources.ts ================================================ const misc_resources = { scheduled_tasks: { name: "Tâche planifiée |||| Tâches planifiées", fields: { id: "ID", action: "Action", status: "Statut", timestamp: "Horodatage", resource_id: "ID de ressource", result: "Résultat", error: "Erreur", max_timestamp: "Avant la date", }, status: { scheduled: "Planifiée", active: "Active", complete: "Terminée", cancelled: "Annulée", failed: "Échouée", }, }, connections: { name: "Connexions", fields: { last_seen: "Date", ip: "Adresse IP", user_agent: "Agent utilisateur", }, }, devices: { name: "Appareil |||| Appareils", fields: { device_id: "Identifiant de l'appareil", display_name: "Nom de l'appareil", last_seen_ts: "Date", last_seen_ip: "Adresse IP", last_seen_user_agent: "Agent utilisateur", dehydrated: "Déshydraté", }, action: { erase: { title: "Suppression de %{id}", title_bulk: "Suppression de %{smart_count} appareil |||| Suppression de %{smart_count} appareils", content: "Voulez-vous vraiment supprimer l'appareil « %{name} » ?", content_bulk: "Voulez-vous vraiment supprimer %{smart_count} appareil ? |||| Voulez-vous vraiment supprimer %{smart_count} appareils ?", success: "Appareil supprimé avec succès", failure: "Une erreur s'est produite", }, display_name: { success: "Nom de l'appareil mis à jour", failure: "Échec de la mise à jour du nom de l'appareil", }, create: { label: "Créer un appareil", title: "Créer un nouvel appareil", success: "Appareil créé", failure: "Échec de la création de l'appareil", }, }, }, users_media: { name: "Media", fields: { media_id: "Identifiant du média", media_length: "Taille du fichier (en octets)", media_type: "Type", upload_name: "Nom du fichier", quarantined_by: "Mis en quarantaine par", safe_from_quarantine: "Protection contre la mise en quarantaine", created_ts: "Date de création", last_access_ts: "Dernier accès", }, action: { open: "Ouvrir le fichier média dans une nouvelle fenêtre", }, }, protect_media: { action: { create: "Protéger", delete: "Déprotéger", none: "En quarantaine", send_success: "Le statut de protection a été modifié avec succès", send_failure: "Une erreur s'est produite", }, }, quarantine_media: { action: { name: "Quarantaine", create: "Quarantaine", delete: "Lever la quarantaine", none: "Protégé(e)", send_success: "Le statut de la quarantaine a été modifié avec succès", send_failure: "Une erreur s'est produite: %{error}", }, }, pushers: { name: "Émetteur de notifications |||| Émetteurs de notifications", fields: { app: "Application", app_display_name: "Nom d'affichage de l'application", app_id: "Identifiant de l'application", device_display_name: "Nom d'affichage de l'appareil", kind: "Type", lang: "Langue", profile_tag: "Profil", pushkey: "Identifiant de l'émetteur", data: { url: "URL" }, }, }, servernotices: { name: "Annonces du serveur", send: "Envoyer des « Annonces du serveur »", fields: { body: "Message", }, action: { send: "Envoyer une annonce", send_success: "Annonce envoyée avec succès", send_failure: "Une erreur s'est produite", }, helper: { send: "Envoie une annonce au nom du serveur aux utilisateurs sélectionnés. La fonction « Annonces du serveur » doit être activée sur le serveur.", }, }, database_room_statistics: { name: "Statistiques de la base de données des salons", fields: { room_id: "ID du salon", estimated_size: "Taille estimée", }, helper: { info: "Affiche l'espace disque estimé utilisé par chaque salon dans la base de données Synapse. Les chiffres sont approximatifs.", }, }, user_media_statistics: { name: "Médias", fields: { media_count: "Nombre de médias", media_length: "Taille des médias", }, }, forward_extremities: { name: "Extrémités avant", fields: { id: "Identifiant de l'événement", received_ts: "Date de réception", depth: "Profondeur", state_group: "Groupe d'état", }, }, room_state: { name: "Événements d'état", fields: { type: "Type", content: "Contenu", origin_server_ts: "Date d'envoi", sender: "Expéditeur", }, }, room_media: { name: "Médias", fields: { media_id: "Identifiant du média", }, helper: { info: "Cette liste contient les médias qui ont été téléchargés dans le salon. Il n'est pas possible de supprimer les médias qui ont été téléversés dans des dépôts de médias externes.", }, action: { error: "%{errcode} (%{errstatus}) %{error}", }, }, room_directory: { name: "Répertoire des salons", fields: { world_readable: "Tout utilisateur peut avoir un aperçu du salon, sans en devenir membre", guest_can_join: "Les visiteurs peuvent rejoindre le salon", }, action: { title: "Supprimer un salon du répertoire |||| Supprimer %{smart_count} salons du répertoire", content: "Voulez-vous vraiment supprimer ce salon du répertoire ? |||| Voulez-vous vraiment supprimer ces %{smart_count} salons du répertoire ?", erase: "Supprimer du répertoire des salons", create: "Publier dans le répertoire des salons", send_success: "Salon publié avec succès", send_failure: "Une erreur s'est produite", }, }, destinations: { name: "Fédération", fields: { destination: "Destination", failure_ts: "Horodatage d’échec", retry_last_ts: "Horodatage de la dernière tentative", retry_interval: "Intervalle de nouvelle tentative", last_successful_stream_ordering: "Dernier flux réussi", stream_ordering: "Flux", }, action: { reconnect: "Reconnecter" }, }, registration_tokens: { name: "Tokens d'inscription", fields: { token: "Token", valid: "Token valide", uses_allowed: "Nombre d'inscription autorisées", pending: "Nombre d'inscription en cours", completed: "Nombre d'inscription accomplie", expiry_time: "Date d'expiration", length: "Longueur", created_at: "Date de création", last_used_at: "Dernière utilisation", revoked_at: "Date de révocation", }, helper: { length: "Longueur du token généré aléatoirement si aucun token n'est spécifié", }, action: { revoke: { label: "Révoquer", success: "Token révoqué", }, unrevoke: { label: "Restaurer", success: "Token restauré", }, }, }, account_data: { name: "Données du compte", }, joined_rooms: { name: "Salons rejoints", }, memberships: { name: "Appartenances", }, room_members: { name: "Membres", }, destination_rooms: { name: "Salons", }, }; export default misc_resources; ================================================ FILE: src/i18n/fr/reports.ts ================================================ const reports = { name: "Événement signalé |||| Événements signalés", fields: { id: "Identifiant", received_ts: "Date du signalement", user_id: "Rapporteur", name: "Nom du salon", score: "Score", reason: "Raison", event_id: "ID de l'événement", sender: "Expéditeur", }, action: { erase: { title: "Supprimer l’événement signalé", content: "Voulez-vous vraiment supprimer l’événement signalé ? Cette action est irréversible.", }, event_lookup: { label: "Recherche d'événement", title: "Récupérer un événement par ID", fetch: "Récupérer", }, fetch_event_error: "Échec de la récupération de l'événement", }, }; export default reports; ================================================ FILE: src/i18n/fr/rooms.ts ================================================ const rooms = { name: "Salon |||| Salons", fields: { room_id: "Identifiant du salon", name: "Nom", canonical_alias: "Alias", joined_members: "Membres", joined_local_members: "Membres locaux", joined_local_devices: "Appareils locaux", state_events: "Événements d'État / Complexité", version: "Version", is_encrypted: "Chiffré", encryption: "Chiffrement", federatable: "Fédérable", public: "Visible dans le répertoire des salons", creator: "Créateur", join_rules: "Règles d'adhésion", guest_access: "Accès des visiteurs", history_visibility: "Visibilité de l'historique", topic: "Sujet", avatar: "Avatar", actions: "Actions", }, filter: { public_rooms: "Salons publics", empty_rooms: "Salons vides", local_members_only: "Membres locaux uniquement", }, helper: { forward_extremities: "Les extrémités avant sont les événements feuilles à la fin d'un graphe orienté acyclique (DAG) dans un salon, c'est-à-dire les événements qui n'ont pas de descendants. Plus il y en a dans un salon, plus la résolution d'état que Synapse doit effectuer est importante (indice : c'est une opération coûteuse). Bien que Synapse dispose d'un algorithme pour éviter qu'un trop grand nombre de ces événements n'existent en même temps dans un salon, des bogues peuvent parfois les faire réapparaître. Si un salon présente plus de 10 extrémités avant, cela vaut la peine d'y prêter attention et de les supprimer si nécessaire en utilisant les requêtes SQL mentionnées dans la discussion traitant du problème https://github.com/matrix-org/synapse/issues/1760.", }, enums: { join_rules: { public: "Public", knock: "Sur demande", invite: "Sur invitation", private: "Privé", restricted: "Restreint", }, guest_access: { can_join: "Les visiteurs peuvent rejoindre le salon", forbidden: "Les visiteurs ne peuvent pas rejoindre le salon", }, history_visibility: { invited: "Depuis l'invitation", joined: "Depuis l'adhésion", shared: "Depuis le partage", world_readable: "Tout le monde", }, unencrypted: "Non chiffré", room_type: { room: "Salon", space: "Espace", }, }, action: { erase: { title: "Supprimer le salon", content: "Voulez-vous vraiment supprimer le salon ? Cette opération ne peut être annulée. Tous les messages et médias partagés du salon seront supprimés du serveur !", fields: { block: "Bloquer et empêcher les utilisateurs de rejoindre le salon", }, in_progress: "Suppression en cours…", background_note: "Vous pouvez fermer cette fenêtre, la suppression continuera en arrière-plan.", success: "Salon supprimé avec succès. |||| Salons supprimés avec succès.", failure: "Le salon n'a pas pu être supprimé. |||| Les salons n'ont pas pu être supprimés.", }, make_admin: { assign_admin: "Assigner un administrateur", title: "Assigner un administrateur au salon %{roomName}", confirm: "Assigner un administrateur", content: "Entrez la MXID complète de l'utilisateur qui sera désigné comme administrateur.\nAttention : pour que cela fonctionne, le salon doit avoir au moins un membre local en tant qu'administrateur.", success: "L'utilisateur a été désigné comme administrateur du salon.", failure: "L'utilisateur n'a pas pu être désigné comme administrateur du salon. %{errMsg}", }, join: { label: "Ajouter un utilisateur", title: "Ajouter un utilisateur à %{roomName}", confirm: "Ajouter", content: "Entrez la MXID complète de l'utilisateur à ajouter à ce salon.\nNote : vous devez être dans le salon et avoir la permission d'inviter des utilisateurs.", success: "L'utilisateur a été ajouté au salon avec succès.", failure: "L'utilisateur n'a pas pu être ajouté au salon. %{errMsg}", }, block: { label: "Bloquer", title: "Bloquer %{room}", title_bulk: "Bloquer %{smart_count} salon |||| Bloquer %{smart_count} salons", title_by_id: "Bloquer un salon", content: "Les utilisateurs ne pourront pas rejoindre ce salon.", content_bulk: "Les utilisateurs ne pourront pas rejoindre %{smart_count} salon. |||| Les utilisateurs ne pourront pas rejoindre %{smart_count} salons.", success: "Salon bloqué avec succès. |||| Salons bloqués avec succès.", failure: "Échec du blocage du salon. |||| Échec du blocage des salons.", }, unblock: { label: "Débloquer", success: "Salon débloqué avec succès. |||| Salons débloqués avec succès.", failure: "Échec du déblocage du salon. |||| Échec du déblocage des salons.", }, purge_history: { label: "Purger l'historique", title: "Purger l'historique de %{roomName}", content: "Tous les événements avant la date sélectionnée seront supprimés de la base de données. L'état du salon (adhésions, départs, sujet) est toujours préservé. Au moins un message est toujours conservé.\nNote : cette opération peut prendre plusieurs minutes pour les grands salons.", date_label: "Purger les événements avant", delete_local: "Supprimer aussi les événements envoyés par les utilisateurs locaux", in_progress: "Purge en cours…", background_note: "Vous pouvez fermer cette fenêtre en toute sécurité, la purge continuera en arrière-plan.", success: "Historique du salon purgé avec succès.", failure: "Échec de la purge de l'historique du salon. %{errMsg}", }, quarantine_all: { label: "Quarantaine de tous les médias", title: "Mettre en quarantaine tous les médias de %{roomName}", content: "Tous les médias locaux et distants de ce salon seront mis en quarantaine. Les médias en quarantaine ne seront plus accessibles aux utilisateurs.", success: "%{smart_count} élément multimédia mis en quarantaine avec succès. |||| %{smart_count} éléments multimédias mis en quarantaine avec succès.", failure: "Échec de la mise en quarantaine. %{errMsg}", }, delete_all_media: { label: "Supprimer tous les médias", title: "Supprimer tous les médias du salon %{roomName}", content: "Cette action supprimera définitivement tous les médias locaux de ce salon. Seuls les médias locaux des salons non chiffrés sont concernés — les médias provenant de serveurs distants sont exclus. Elle est irréversible.", in_progress_loading: "Récupération de la liste des médias…", in_progress: "Suppression des médias… (%{current} / %{total})", do_not_close: "Ne fermez pas cette fenêtre — la suppression s'exécute au premier plan et s'interrompra si vous la fermez.", success: "Suppression réussie de %{smart_count} élément multimédia. |||| Suppression réussie de %{smart_count} éléments multimédia.", failure: "Échec de la suppression des médias. %{errMsg}", }, delete_all_media_bulk: { title: "Supprimer tous les médias pour %{smart_count} salon ? |||| Supprimer tous les médias pour %{smart_count} salons ?", content: "Cette action supprimera définitivement tous les médias locaux des salons sélectionnés (salons non chiffrés uniquement). Les médias provenant de serveurs distants sont exclus. Elle est irréversible.", success: "Médias supprimés pour %{success} sur %{total} salons.", partial_failure: "Médias supprimés pour %{success} sur %{total} salons. %{failed} ont échoué.", }, event_context: { lookup_title: "Rechercher un événement par ID", jump_to_date: "Aller à la date", direction: "Direction", forward: "En avant", backward: "En arrière", target_event: "Événement cible", events_before: "Événements avant", events_after: "Événements après", not_found: "Aucun événement trouvé à l'heure indiquée", failure: "Impossible de récupérer le contexte de l'événement", }, messages: { load_older: "Charger les plus anciens", load_newer: "Charger les plus récents", no_messages: "Aucun message dans ce salon", failure: "Impossible de charger les messages", filter: "Filtres", filter_type: "Types d'événements", filter_sender: "Expéditeurs", advanced_filters: "Filtres avancés", filter_not_type: "Exclure les types d'événements", filter_not_sender: "Exclure les expéditeurs", contains_url: "Contient une URL", any: "Tous", with_url: "Avec URL uniquement", without_url: "Sans URL uniquement", apply_filter: "Appliquer", clear_filters: "Effacer", }, hierarchy: { load_more: "Charger plus", max_depth: "Profondeur maximale", unlimited: "Illimitée", refresh: "Actualiser", members: "%{count} membres", space: "Espace", room: "Salon", suggested: "Suggéré", no_children: "Ce salon n'a pas de sous-salons", failure: "Impossible de charger la hiérarchie", }, }, }; export default rooms; ================================================ FILE: src/i18n/fr/users.ts ================================================ const users = { name: "Utilisateur |||| Utilisateurs", email: "Adresse électronique", msisdn: "Numéro de téléphone", threepid: "Adresse électronique / Numéro de téléphone", membership: "Adhésion |||| Adhésions", fields: { avatar: "Avatar", id: "Identifiant", name: "Nom", is_guest: "Visiteur", admin: "Administrateur du serveur", locked: "Verrouillé", suspended: "Suspendu", shadow_banned: "Banni fantôme", deactivated: "Désactivé", show_guests: "Afficher les visiteurs", show_deactivated: "Afficher uniquement les désactivés", show_locked: "Afficher les utilisateurs verrouillés", filter_user_all: "Tous", filter_deactivated_false: "Actifs", filter_deactivated_true: "Désactivés", filter_locked_false: "Exclure les verrouillés", filter_locked_true: "Inclure les verrouillés", filter_guests_false: "Exclure les invités", filter_guests_true: "Inclure les invités", show_system_users: "Afficher les comptes système", filter_system_users_false: "Exclure les comptes système", filter_system_users_true: "Comptes système uniquement", show_suspended: "Afficher les utilisateurs suspendus", show_shadow_banned: "Afficher les utilisateurs bannis fantôme", user_id: "Rechercher un utilisateur", displayname: "Nom d'affichage", password: "Mot de passe", avatar_url: "URL de l'avatar", avatar_src: "Avatar", medium: "Type", threepids: "Identifiants tiers", address: "Adresse", creation_ts_ms: "Date de création", consent_version: "Version du consentement", sent_invite_count: "Invitations envoyées", cumulative_joined_room_count: "Salons rejoints cumulés", auth_provider: "Fournisseur d'identité", user_type: "Type d'utilisateur", erased: "Effacé (GDPR)", }, helper: { password: "Changer le mot de passe déconnectera l'utilisateur de toutes les sessions.", password_required_for_reactivation: "Vous devez fournir un mot de passe pour réactiver le compte.", create_password: "Veuillez générer un mot de passe fort et sécurisé en utilisant le bouton ci-dessous.", deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.", suspend: "L'utilisateur sera suspendu jusqu'à ce que vous le réactiviez.", shadow_ban: "L'utilisateur banni fantôme reçoit des réponses normales, mais ses événements ne sont pas propagés aux autres utilisateurs ou salons. À utiliser uniquement en dernier recours.", erase: "Marquer l'utilisateur comme effacé conformément au GDPR", admin: "Un administrateur de serveur a un contrôle total sur le serveur et ses utilisateurs.", lock: "Empêche l'utilisateur d'utiliser le serveur. C'est une action non destructive qui peut être annulée.", erase_text: "Cela signifie que les messages envoyés par le(s) utilisateur(s) seront toujours visibles par toute personne qui se trouvait dans le salon au moment où ces messages ont été envoyés, mais qu'ils seront cachés aux utilisateurs qui rejoindront le salon par la suite.", erase_admin_error: "La suppression de son propre utilisateur n'est pas autorisée.", modify_managed_user_error: "La modification d'un utilisateur géré par le système n'est pas autorisée.", username_available: "Nom d'utilisateur disponible", sent_invite_count: "Nombre total d'invitations envoyées par cet utilisateur dans tous les salons.", cumulative_joined_room_count: "Nombre total de salons que cet utilisateur a rejoint, y compris ceux qu'il a quittés ou dont il a été banni.", }, badge: { you: "Vous", bot: "Bot", admin: "Admin", support: "Support", regular: "Utilisateur régulier", federated: "Fédéré", system_managed: "Géré par le système", }, action: { erase: "Effacer les données de l'utilisateur", erase_avatar: "Effacer l'avatar", delete_media: "Supprimer tous les médias téléchargés par le(s) utilisateur(s)", redact_events: "Expurger tous les événements envoyés par le(s) utilisateur(s)", redact_in_progress: "Expurgation en cours\u2026", redact_background_note: "Vous pouvez fermer cette fenêtre en toute sécurité, l'expurgation continuera en arrière-plan.", redact_success: "Tous les événements ont été expurgés avec succès.", redact_failure: "Expurgation terminée avec %{smart_count} événement échoué. |||| Expurgation terminée avec %{smart_count} événements échoués.", generate_password: "Générer un mot de passe", reset_password: { label: "Réinitialiser le mot de passe", title: "Réinitialiser le mot de passe", helper: "Changer le mot de passe de %{user}", password: "Mot de passe", logout_devices: "Déconnecter tous les appareils", success: "Le mot de passe a été réinitialisé avec succès", failure: "Échec de la réinitialisation du mot de passe", error_no_password: "Le mot de passe est requis", }, login_as: { label: "Se connecter en tant qu'utilisateur", title: "Se connecter en tant qu'utilisateur", helper: "Obtenir un token d'accès pour s'authentifier en tant que %{user}. Cette action ne crée pas de nouvel appareil pour l'utilisateur et n'apparaîtra donc pas dans la liste des appareils/sessions. L'utilisateur cible ne devrait généralement pas pouvoir détecter cette connexion.", valid_until: "Définir une date d'expiration", success: "Token d'accès généré avec succès", failure: "Échec de la génération du token d'accès", result_title: "Token d'accès de %{user}", access_token: "Token d'accès", expires_at: "Ce token d'accès expirera le %{date}", }, overwrite_title: "Attention !", overwrite_content: "Ce nom d'utilisateur est déjà pris. Êtes-vous sûr de vouloir écraser l'utilisateur existant ?", overwrite_cancel: "Annuler", overwrite_confirm: "Écraser", quarantine_all: { label: "Quarantaine de tous les médias", title: "Mettre en quarantaine tous les médias de %{userName}", content: "Tous les médias locaux de cet utilisateur seront mis en quarantaine. Les médias en quarantaine ne seront plus accessibles aux autres utilisateurs.", success: "%{smart_count} élément multimédia mis en quarantaine avec succès. |||| %{smart_count} éléments multimédias mis en quarantaine avec succès.", failure: "Échec de la mise en quarantaine. %{errMsg}", }, delete_all_media: { label: "Supprimer tous les médias", title: "Supprimer tous les médias de %{userName}", content: "Cette action supprimera définitivement tous les médias téléversés par cet utilisateur. Elle est irréversible.", in_progress: "Suppression des médias en cours…", background_note: "Vous pouvez fermer cette fenêtre en toute sécurité — la suppression continuera en arrière-plan.", success: "Suppression réussie de %{smart_count} élément multimédia. |||| Suppression réussie de %{smart_count} éléments multimédia.", failure: "Échec de la suppression des médias. %{errMsg}", }, delete_all_media_bulk: { title: "Supprimer tous les médias pour %{smart_count} utilisateur ? |||| Supprimer tous les médias pour %{smart_count} utilisateurs ?", content: "Cette action supprimera définitivement tous les médias téléversés par les utilisateurs sélectionnés. Elle est irréversible.", success: "Médias supprimés pour %{success} sur %{total} utilisateurs.", partial_failure: "Médias supprimés pour %{success} sur %{total} utilisateurs. %{failed} ont échoué.", }, allow_cross_signing: { label: "Autoriser la réinitialisation du Cross-Signing", title: "Autoriser le remplacement des clés Cross-Signing", content: "Autoriser %{user} à remplacer ses clés Cross-Signing sans authentification interactive ? Cela crée une fenêtre temporaire pendant laquelle les clés peuvent être remplacées.", success: "Remplacement des clés Cross-Signing autorisé jusqu'au %{deadline}", failure: "Échec de l'autorisation du remplacement Cross-Signing", no_key: "L'utilisateur n'a pas de clé Cross-Signing maîtresse", }, find_user: { label: "Rechercher un utilisateur", title: "Rechercher un utilisateur", lookup_type: "Type de recherche", by_threepid: "Par e-mail / téléphone", by_auth_provider: "Par fournisseur d'authentification", provider: "ID du fournisseur d'authentification", external_id: "ID externe", search: "Rechercher", not_found: "Utilisateur introuvable", failure: "Impossible de trouver l'utilisateur", }, renew_account: { label: "Renouveler le compte", title: "Renouveler la validité du compte", content: "Renouveler la validité du compte de %{user}. Vous pouvez, si vous le souhaitez, définir une date d'expiration personnalisée. Si laissé vide, la période de renouvellement par défaut du serveur sera utilisée.", expiration: "Date d'expiration", expiration_helper: "Laisser vide pour utiliser la période de renouvellement par défaut du serveur", renewal_emails: "Envoyer des e-mails de notification de renouvellement", success: "Validité du compte renouvelée jusqu'au %{date}", failure: "Échec du renouvellement de la validité du compte", }, system_users_scan_in_progress: "Patientez — la recherche des utilisateurs correspondants est en cours, la page va se charger dans un instant", reverse_search_scan_in_progress: "Patientez — tous les utilisateurs sont en cours d'analyse pour exclure les correspondances, la page va se charger dans un instant", }, limits: { messages_per_second: "Messages par seconde", messages_per_second_text: "Le nombre d'actions que l'utilisateur peut effectuer par seconde.", burst_count: "Compteur de pics", burst_count_text: "Le nombre d'actions que l'utilisateur peut effectuer avant d'être limité.", }, account_data: { title: "Données du compte", global: "Globales", rooms: "Salons", }, }; export default users; ================================================ FILE: src/i18n/i18n-keys.test.ts ================================================ import de from "./de"; import en from "./en"; import fa from "./fa"; import fr from "./fr"; import itMessages from "./it"; import ja from "./ja"; import pt from "./pt"; import ru from "./ru"; import uk from "./uk"; import zh from "./zh"; const locales = { de, fa, fr, it: itMessages, ja, pt, ru, uk, zh, }; const isPlainObject = (value: unknown): value is Record => { return typeof value === "object" && value !== null && !Array.isArray(value); }; const collectKeys = (value: unknown, prefix = "", out = new Set()) => { if (!isPlainObject(value)) return out; for (const [key, child] of Object.entries(value)) { const next = prefix ? `${prefix}.${key}` : key; out.add(next); if (isPlainObject(child)) { collectKeys(child, next, out); } } return out; }; const diffKeys = (reference: Set, target: Set) => { const missing: string[] = []; const extra: string[] = []; for (const key of reference) { if (!target.has(key)) missing.push(key); } for (const key of target) { if (!reference.has(key)) extra.push(key); } missing.sort(); extra.sort(); return { missing, extra }; }; describe("i18n translation keys", () => { const referenceKeys = collectKeys(en); for (const [locale, messages] of Object.entries(locales)) { it(`${locale} matches en key set`, () => { const keys = collectKeys(messages); const { missing, extra } = diffKeys(referenceKeys, keys); if (missing.length || extra.length) { const parts: string[] = []; if (missing.length) { parts.push(`missing (${missing.length}): ${missing.join(", ")}`); } if (extra.length) { parts.push(`extra (${extra.length}): ${extra.join(", ")}`); } throw new Error(parts.join(" | ")); } }); } }); ================================================ FILE: src/i18n/index.test.ts ================================================ import { MockedFunction } from "vitest"; import { resolveBrowserLocale } from "react-admin"; import { createI18nProvider } from "."; vi.mock("react-admin", () => ({ resolveBrowserLocale: vi.fn(() => "en"), })); const mockedResolveBrowserLocale = resolveBrowserLocale as MockedFunction; describe("createI18nProvider", () => { beforeEach(() => { localStorage.clear(); document.documentElement.lang = ""; document.documentElement.dir = ""; mockedResolveBrowserLocale.mockReturnValue("en"); }); describe("initial locale resolution", () => { it("should default to en when browser locale is unsupported", async () => { mockedResolveBrowserLocale.mockReturnValue("xx"); const provider = await createI18nProvider(); expect(provider.getLocale()).toBe("en"); expect(document.documentElement.lang).toBe("en"); }); it("should use browser locale when supported", async () => { mockedResolveBrowserLocale.mockReturnValue("de"); const provider = await createI18nProvider(); expect(provider.getLocale()).toBe("de"); expect(document.documentElement.lang).toBe("de"); }); it("should prefer RaStore.locale over browser locale", async () => { localStorage.setItem("RaStore.locale", JSON.stringify("fr")); mockedResolveBrowserLocale.mockReturnValue("de"); const provider = await createI18nProvider(); expect(provider.getLocale()).toBe("fr"); expect(document.documentElement.lang).toBe("fr"); }); it("should fall back to browser locale when RaStore.locale has malformed JSON", async () => { localStorage.setItem("RaStore.locale", "not-json"); mockedResolveBrowserLocale.mockReturnValue("de"); const provider = await createI18nProvider(); expect(provider.getLocale()).toBe("de"); }); it("should fall back to browser locale when RaStore.locale has unsupported value", async () => { localStorage.setItem("RaStore.locale", JSON.stringify("xx")); mockedResolveBrowserLocale.mockReturnValue("ja"); const provider = await createI18nProvider(); expect(provider.getLocale()).toBe("ja"); }); }); describe("html lang and dir attributes", () => { it("should set lang on initial load", async () => { mockedResolveBrowserLocale.mockReturnValue("it"); await createI18nProvider(); expect(document.documentElement.lang).toBe("it"); expect(document.documentElement.dir).toBe("ltr"); }); it("should set dir=rtl for Farsi", async () => { mockedResolveBrowserLocale.mockReturnValue("fa"); await createI18nProvider(); expect(document.documentElement.lang).toBe("fa"); expect(document.documentElement.dir).toBe("rtl"); }); it("should switch back to ltr when changing from Farsi to another locale", async () => { mockedResolveBrowserLocale.mockReturnValue("fa"); const provider = await createI18nProvider(); expect(document.documentElement.dir).toBe("rtl"); await provider.changeLocale("en"); expect(document.documentElement.dir).toBe("ltr"); }); it("should update lang on changeLocale", async () => { const provider = await createI18nProvider(); expect(document.documentElement.lang).toBe("en"); await provider.changeLocale("uk"); expect(document.documentElement.lang).toBe("uk"); }); }); describe("lazy loading and caching", () => { it("should return English translations for en locale", async () => { const provider = await createI18nProvider(); const translation = provider.translate("ketesa.auth.base_url"); expect(translation).toBe("Homeserver URL"); }); it("should merge non-en locale with English as fallback", async () => { mockedResolveBrowserLocale.mockReturnValue("de"); const provider = await createI18nProvider(); // German-specific key const deTranslation = provider.translate("ketesa.auth.base_url"); expect(deTranslation).toBe("Heimserver URL"); }); it("should lazy-load a locale not loaded at init", async () => { const provider = await createI18nProvider(); await provider.changeLocale("ru"); const translation = provider.translate("ketesa.auth.base_url"); expect(translation).toBe("Адрес домашнего сервера"); }); it("should return English translations for unsupported locale", async () => { const provider = await createI18nProvider(); await provider.changeLocale("xx"); expect(document.documentElement.lang).toBe("en"); }); it("should cache loaded locales and not re-import", async () => { const provider = await createI18nProvider(); // Load zh for the first time await provider.changeLocale("zh"); const first = provider.translate("ketesa.auth.base_url"); // Switch away and back await provider.changeLocale("en"); await provider.changeLocale("zh"); const second = provider.translate("ketesa.auth.base_url"); expect(first).toBe(second); expect(first).toBe("服务器 URL"); }); }); describe("available locales", () => { it("should expose all 10 supported locales", async () => { const provider = await createI18nProvider(); const locales = provider.getLocales!(); expect(locales).toHaveLength(10); expect(locales.map(l => l.locale)).toEqual( expect.arrayContaining(["en", "de", "fa", "fr", "it", "ja", "pt", "ru", "uk", "zh"]) ); }); }); }); ================================================ FILE: src/i18n/index.ts ================================================ import { merge } from "lodash"; import polyglotI18nProvider from "ra-i18n-polyglot"; import { resolveBrowserLocale } from "react-admin"; import type { SynapseTranslationMessages } from "./types"; const supportedLocales = ["en", "de", "fa", "fr", "it", "ja", "pt", "ru", "uk", "zh"] as const; type SupportedLocale = (typeof supportedLocales)[number]; const localeLabels: { locale: SupportedLocale; name: string }[] = [ { locale: "en", name: "English" }, { locale: "de", name: "Deutsch" }, { locale: "fr", name: "Français" }, { locale: "it", name: "Italiano" }, { locale: "ja", name: "日本語" }, { locale: "fa", name: "فارسی" }, { locale: "pt", name: "Português" }, { locale: "ru", name: "Русский" }, { locale: "uk", name: "Українська" }, { locale: "zh", name: "简体中文" }, ]; const loaders: Record Promise> = { en: () => import("./en").then(m => m.default), de: () => import("./de").then(m => m.default), fa: () => import("./fa").then(m => m.default), fr: () => import("./fr").then(m => m.default), it: () => import("./it").then(m => m.default), ja: () => import("./ja").then(m => m.default), pt: () => import("./pt").then(m => m.default), ru: () => import("./ru").then(m => m.default), uk: () => import("./uk").then(m => m.default), zh: () => import("./zh").then(m => m.default), }; // Read locale directly from react-admin's localStorage store because this runs // before React mounts — useLocale() is not available at bootstrap time. const RA_STORE_LOCALE_KEY = "RaStore.locale"; function isSupportedLocale(locale: string): locale is SupportedLocale { return supportedLocales.includes(locale as SupportedLocale); } function resolveInitialLocale(): SupportedLocale { const stored = localStorage.getItem(RA_STORE_LOCALE_KEY); if (stored) { try { const parsed = JSON.parse(stored) as string; if (isSupportedLocale(parsed)) { return parsed; } } catch { // malformed JSON — fall through to browser locale } } const browser = resolveBrowserLocale(); return isSupportedLocale(browser) ? browser : "en"; } export async function createI18nProvider() { const initialLocale = resolveInitialLocale(); const setHtmlLang = (locale: string) => { document.documentElement.lang = locale; document.documentElement.dir = locale === "fa" ? "rtl" : "ltr"; }; const enMessages = await loaders.en(); const initialMessages = initialLocale !== "en" ? merge({}, enMessages, await loaders[initialLocale]()) : enMessages; setHtmlLang(initialLocale); const cache: Partial> = { en: enMessages, [initialLocale]: initialMessages, }; return polyglotI18nProvider( locale => { const cached = cache[locale as SupportedLocale]; if (cached) { setHtmlLang(locale); return cached; } if (!isSupportedLocale(locale)) { setHtmlLang("en"); return enMessages; } return loaders[locale]().then(msgs => { const merged = merge({}, enMessages, msgs); cache[locale] = merged; setHtmlLang(locale); return merged; }); }, initialLocale, localeLabels ); } ================================================ FILE: src/i18n/it/base.ts ================================================ import type { TranslationMessages } from "ra-core"; const italianMessages: TranslationMessages = { ra: { action: { add_filter: "Aggiungi un filtro", add: "Aggiungi", back: "Indietro", bulk_actions: "%{smart_count} selezionati", cancel: "Annulla", clear_array_input: "Svuota elenco", clear_input_value: "Svuota il modulo", clone: "Duplica", confirm: "Conferma", create: "Crea", create_item: "Crea elemento", delete: "Cancella", edit: "Modifica", export: "Esporta", list: "Elenco", refresh: "Aggiorna", remove_filter: "Rimuovi questo filtro", remove_all_filters: "Rimuovi tutti i filtri", remove: "Rimuovi", reset: "Reimposta", save: "Salva", search: "Ricerca", search_columns: "Cerca colonne", select_all: "Seleziona tutto", select_all_button: "Seleziona tutti", select_row: "Seleziona riga", show: "Mostra", sort: "Ordina", undo: "Annulla", unselect: "Annulla selezione", expand: "Espandi", close: "Chiudi", open_menu: "Apri il menu", close_menu: "Chiudi il menu", update: "Aggiorna", move_up: "Sposta su", move_down: "Sposta giù", open: "Apri", toggle_theme: "Cambia tema", select_columns: "Seleziona colonne", update_application: "Aggiorna applicazione", }, boolean: { true: "Si", false: "No", null: " ", }, page: { create: "Aggiungi %{name}", dashboard: "Cruscotto", edit: "%{name} %{recordRepresentation}", error: "Qualcosa non ha funzionato", list: "%{name}", loading: "Caricamento in corso", not_found: "Non trovato", show: "%{name} %{recordRepresentation}", empty: "Nessun %{name} ancora.", invite: "Vuoi aggiungerne uno?", access_denied: "Accesso negato", authentication_error: "Errore di autenticazione", }, input: { file: { upload_several: "Trascina i files da caricare, oppure clicca per selezionare.", upload_single: "Trascina il file da caricare, oppure clicca per selezionarlo.", }, image: { upload_several: "Trascina le immagini da caricare, oppure clicca per selezionarle.", upload_single: "Trascina l'immagine da caricare, oppure clicca per selezionarla.", }, references: { all_missing: "Impossibile trovare i riferimenti associati.", many_missing: "Almeno uno dei riferimenti associati non sembra più disponibile.", single_missing: "Il riferimento associato non sembra più disponibile.", }, password: { toggle_visible: "Nascondi password", toggle_hidden: "Mostra password", }, }, message: { about: "Informazioni", access_denied: "Accesso negato", are_you_sure: "È sicuro?", authentication_error: "Il server di autenticazione ha restituito un errore e non è stato possibile verificare le credenziali.", auth_error: "Errore di autenticazione", bulk_delete_content: "È sicuro di voler cancellare questo %{name}? |||| È sicuro di voler eliminare questi %{smart_count}?", bulk_delete_title: "Elimina %{name} |||| Elimina %{smart_count} %{name}", bulk_update_content: "Vuole aggiornare %{smart_count} elementi? |||| Vuole aggiornare %{smart_count} elementi?", bulk_update_title: "Aggiornare %{smart_count} elementi? |||| Aggiornare %{smart_count} elementi?", clear_array_input: "Svuota l'elenco", delete_content: "È sicuro di voler cancellare questo elemento?", delete_title: "Elimina %{name} %{recordRepresentation}", details: "Dettagli", error: "Un errore locale è occorso e la Sua richiesta non è stata completata.", invalid_form: "Il modulo non è valido. Si prega di verificare la presenza di errori.", loading: "La pagina si sta caricando, solo un momento per favore", no: "No", not_found: "Ha inserito un URL errato, oppure ha cliccato un link errato", placeholder_data_warning: "Problema di rete: aggiornamento dei dati non riuscito.", select_all_limit_reached: "Troppi elementi da selezionare. Sono stati selezionati solo i primi %{max}.", unsaved_changes: "Alcune modifiche non sono state salvate. È sicuro di volerle ignorare?", yes: "Si", }, navigation: { clear_filters: "Rimuovi filtri", current_page: "Pagina %{page}", first: "Prima", last: "Ultima", no_filtered_results: "Nessun risultato", no_more_results: "La pagina numero %{page} è fuori dell'intervallo. Prova la pagina precedente.", no_results: "Nessun risultato trovato", page_out_of_boundaries: "Il numero di pagina %{page} è fuori dei limiti", page_out_from_end: "Fine della paginazione", page_out_from_begin: "Il numero di pagina deve essere maggiore di 1", page_range_info: "%{offsetBegin}-%{offsetEnd} di %{total}", partial_page_range_info: "%{offsetBegin}-%{offsetEnd} di %{total}", page_rows_per_page: "Righe per pagina", page: "Pagina", next: "Successivo", previous: "Precedente", skip_nav: "Vai al contenuto", }, sort: { sort_by: "Ordina per %{field_lower_first} %{order}", ASC: "crescente", DESC: "decrescente", }, auth: { auth_check_error: "È necessario accedere per continuare", user_menu: "Profilo", username: "Nome utente", password: "Password", email: "Email", sign_in: "Login", sign_in_error: "Autenticazione fallita, riprovare.", logout: "Disconnessione", }, notification: { updated: "Record aggiornato |||| %{smart_count} records aggiornati", created: "Record creato", deleted: "Record eliminato |||| %{smart_count} records eliminati", bad_item: "Record errato", item_doesnt_exist: "Record inesistente", http_error: "Errore di comunicazione con il server dati", data_provider_error: "Errore del data provider. Controlla la console per i dettagli.", i18n_error: "Traduzioni non trovate per il linguaggio specificato", canceled: "Azione annullata", logged_out: "La sessione è stata terminata, si prega di ripetere l'autenticazione.", not_authorized: "Non autorizzato", application_update_available: "È disponibile un aggiornamento dell'applicazione.", offline: "Offline. Impossibile recuperare i dati.", }, validation: { required: "Campo obbligatorio", minLength: "Deve essere lungo %{min} caratteri almeno", maxLength: "Deve essere lungo %{max} caratteri al massimo", minValue: "Deve essere almeno %{min}", maxValue: "Deve essere al massimo %{max}", number: "Deve essere un numero", email: "Deve essere un valido indirizzo email", oneOf: "Deve essere uno di: %{options}", regex: "Deve rispettare il formato (espressione regolare): %{pattern}", unique: "Deve essere unico", }, saved_queries: { label: "Query salvate", query_name: "Nome della ricerca", new_label: "Salva questa ricerca", new_dialog_title: "Salvare questa ricerca?", remove_label: "Elimina", remove_label_with_name: 'Elimina "%{name}"', remove_dialog_title: "Eliminare la ricerca salvata?", remove_message: "La ricerca salvata verrà eliminata.", help: "Salva le ricerche per un uso futuro", }, guesser: { empty: { title: "Nessun dato da visualizzare", message: "Controlla il provider dei dati", }, }, configurable: { customize: "Personalizza", configureMode: "Modalità configurazione", inspector: { title: "Ispettore", content: "Personalizzi la Sua vista", reset: "Ripristina", hideAll: "Nascondi tutto", showAll: "Mostra tutto", }, Datagrid: { title: "Personalizza tabella", unlabeled: "Senza etichetta", }, SimpleForm: { title: "Personalizza modulo", unlabeled: "Senza etichetta", }, SimpleList: { title: "Personalizza elenco", primaryText: "Testo principale", secondaryText: "Testo secondario", tertiaryText: "Testo terziario", }, }, }, }; export default italianMessages; ================================================ FILE: src/i18n/it/common.ts ================================================ import italianMessages from "./base"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const common: Record = { ...italianMessages, ketesa: { auth: { base_url: "URL dell'homeserver", welcome: "Benvenuto in %{name}", description: "L'evoluzione di Synapse Admin. Gestire, monitorare e mantenere il Suo server Matrix da un'unica interfaccia pulita. Pensato per i piccoli server privati e le grandi community federate.", server_version: "Versione di Synapse", username_error: "Per favore inserisca un ID utente completo: '@utente:dominio'", protocol_error: "L'URL deve iniziare per 'http://' o 'https://'", url_error: "URL del server Matrix non valido", sso_sign_in: "Accedi con SSO", credentials: "Credenziali", access_token: "Token di accesso", supports_specs: "supporta le specifiche Matrix", logout_access_token_dialog: { title: "Sta utilizzando un token di accesso Matrix esistente.", content: "Vuole distruggere questa sessione (che potrebbe essere utilizzata altrove, ad esempio in un client Matrix) o semplicemente disconnettersi dal pannello di amministrazione?", confirm: "Distruggi sessione", cancel: "Disconnetti solo dal pannello di amministrazione", }, }, users: { invalid_user_id: "ID utente non valido su questo homeserver.", tabs: { sso: "SSO", experimental: "Sperimentale", limits: "Limiti", account_data: "Dati del profilo", sessions: "Sessioni", }, danger_zone: "Zona pericolosa", }, rooms: { details: "Dettagli della stanza", tabs: { basic: "Semplice", members: "Membro", detail: "Dettagli", permission: "Permessi", media: "Media", messages: "Messaggi", hierarchy: "Gerarchia", }, }, reports: { tabs: { basic: "Semplice", detail: "Dettagli" } }, admin_config: { soft_failed_events: "Eventi con errore non critico", spam_flagged_events: "Eventi contrassegnati come spam", success: "Configurazione amministratore aggiornata", failure: "Aggiornamento della configurazione amministratore non riuscito", }, }, import_users: { error: { at_entry: "Alla voce %{entry}: %{message}", error: "Errore", required_field: "Il campo '%{field}' non è presente", invalid_value: "Valore non valido alla riga %{row}. '%{field}' Il campo può essere solo 'true' o 'false'", unreasonably_big: "Impossibile caricare un file così grosso (%{size} megabyte)", already_in_progress: "Un import è attualmente già in caricamento", id_exits: "L'ID %{id} è già presente", }, title: "Importa utenti tramite file CSV", goToPdf: "Vai al PDF", cards: { importstats: { header: "Utenti analizzati per l'importazione", users_total: "%{smart_count} utente nel file CSV |||| %{smart_count} utenti nel file CSV", guest_count: "%{smart_count} ospite |||| %{smart_count} ospiti", admin_count: "%{smart_count} amministratore |||| %{smart_count} amministratori", }, conflicts: { header: "Strategia di conflitto", mode: { stop: "Stoppa al conflitto", skip: "Mostra l'errore e ignora il conflitto", }, }, ids: { header: "ID", all_ids_present: "ID presenti in ogni voce", count_ids_present: "%{smart_count} voce con ID |||| %{smart_count} voci con ID", mode: { ignore: "Ignora gli ID nel file CSV e creane di nuovi", update: "Aggiorna le voci esistenti", }, }, passwords: { header: "Passwords", all_passwords_present: "Password presenti in ogni voce", count_passwords_present: "%{smart_count} voce con password |||| %{smart_count} voci con password", use_passwords: "Usa le password dal file CSV", }, upload: { header: "Input file CSV", explanation: "Qui può caricare un file con valori separati da virgole che verrà poi utilizzato per creare o aggiornare gli utenti. Il file deve includere i campi 'id' and 'displayname'. Può scaricare un file di esempio per adattarlo: ", }, startImport: { simulate_only: "Solo simulazione", run_import: "Importa", }, results: { header: "Importa i risultati", total: "%{smart_count} voce in totale |||| %{smart_count} voci in totale", successful: "%{smart_count} voci importate con successo", skipped: "%{smart_count} voci ignorate", download_skipped: "Scarica le voci ignorate", with_error: "%{smart_count} voce con errori ||| %{smart_count} voci con errori", simulated_only: "Il processo era stato solamente simulato", }, }, }, delete_media: { name: "Media", fields: { before_ts: "ultimo accesso effettuato prima", size_gt: "Più grande di (in byte)", keep_profiles: "Mantieni le immagini del profilo", }, action: { send: "Cancella media", send_success: "%{smart_count} file multimediale eliminato con successo. |||| %{smart_count} file multimediali eliminati con successo.", send_success_none: "Nessun file multimediale corrispondeva ai criteri specificati. Non è stato eliminato nulla.", send_failure: "C'è stato un errore.", }, helper: { send: "Questa API cancella i media locali dal disco del Suo server. Questo include anche ogni miniatura e copia del media scaricato. Questa API non inciderà sui media che sono stati caricati nei repository esterni.", }, }, purge_remote_media: { name: "Media Remoti", fields: { before_ts: "ultimo accesso prima di", }, action: { send: "Elimina media remoti", send_success: "%{smart_count} file multimediale remoto eliminato con successo. |||| %{smart_count} file multimediali remoti eliminati con successo.", send_success_none: "Nessun file multimediale remoto corrispondeva ai criteri specificati. Non è stato eliminato nulla.", send_failure: "Si è verificato un errore con la richiesta di eliminazione dei media remoti.", }, helper: { send: "Questa API elimina la cache dei media remoti dal disco del Suo server. Questo include qualsiasi miniatura locale e copie di media scaricati. Questa API non influirà sui media che sono stati caricati nel repository multimediale del server.", }, }, etkecc: { donate: { menu_label: "Dona", name: "Sostieni lo sviluppo di Ketesa", title: "Sostieni lo sviluppo di Ketesa", description_1: "Il progetto Ketesa è libero e open source, e lo sviluppiamo e manteniamo apertamente per la comunità Matrix.", description_2: "Se il progetto Ketesa Le è stato utile, una donazione ci aiuta a continuare il lavoro che c'è dietro: sviluppo, manutenzione, correzioni e miglioramenti continui.", description_3: "Ci aiuta a dedicare più tempo a migliorare il progetto per tutte le persone che vi fanno affidamento.", description_4: "Ogni contributo aiuta, e apprezziamo sinceramente il Suo supporto! ❤️", button: "Dona", signature_team: "il team di etke.cc", }, components: { name: "Componenti", description: "Visualizzi e gestisca i Suoi componenti attivi e scopra cosa è disponibile da aggiungere al Suo server.", no_section: "Il suo server", per_month: "/mese", included: "Incluso", total: "Totale", loading: "Caricamento dei componenti in corso...", state_add: "Aggiungere", state_remove: "Rimuovere", add_aria: "Richiedere l'aggiunta di %{name}", remove_aria: "Richiedere la rimozione di %{name}", preview_label: "anteprima", request_changes: "Richiedi modifiche", requesting: "Invio in corso...", request_failure: "Impossibile inviare la richiesta di modifica. Riprovi.", request_sent_title: "Richiesta inviata", request_sent_body: "La Sua richiesta di modifica dei componenti è stata inviata al supporto di etke.cc. Se ha bisogno di ulteriori modifiche, risponda a questa richiesta di supporto anziché aprirne una nuova.", request_sent_close: "Chiudi", request_sent_view: "Visualizza richiesta", request_already_sent: "È già aperta una richiesta di modifica. Per richiedere ulteriori modifiche, risponda al ticket di supporto esistente.", request_already_sent_view: "Visualizza ticket", free_label: "Gratuito", available_label: "Disponibile", tagline: "Migliori il Suo server — aggiunga o rimuova qualsiasi componente in qualsiasi momento.", section: { bridges: "Ponti", extras: "Extra", matrix_apps: "Applicazioni Matrix", matrix_bots: "Bot Matrix", matrix_extras: "Extra Matrix", }, }, billing: { name: "Fatturazione", title: "Cronologia pagamenti", no_payments: "Nessun pagamento trovato.", no_payments_helper: "Se ritiene che si tratti di un errore, contatti l’assistenza etke.cc.", description1: "Da qui può visualizzare i pagamenti e generare le fatture. Può saperne di più sulla gestione degli abbonamenti su", description2: "Per modificare l’email di fatturazione o aggiungere i dati aziendali alle fatture, consulti", fields: { transaction_id: "ID transazione", email: "Email", type: "Tipo", amount: "Importo", paid_at: "Pagato il", invoice: "Fattura", }, enums: { type: { subscription: "Abbonamento", one_time: "Una tantum", }, }, helper: { download_invoice: "Scarica fattura", downloading: "Download in corso...", download_started: "Il download della fattura è iniziato.", invoice_not_available: "In sospeso", loading: "Caricamento delle informazioni di fatturazione...", loading_failed1: "Si è verificato un problema durante il caricamento delle informazioni di fatturazione.", loading_failed2: "Riprova più tardi.", loading_failed3: "Se il problema persiste, contatti l’assistenza etke.cc.", loading_failed4: "con il seguente messaggio di errore:", }, components: "Componenti attivi", components_no_section: "Il suo server", components_per_month: "/mese", components_included: "Incluso", components_total: "Totale", components_help_title: "Ulteriori informazioni su %{name}", components_state_install: "Installare", components_state_remove: "Rimuovere", components_remove_aria: "Installare/rimuovere %{name}", components_preview_label: "anteprima", components_request_changes: "Richiedi modifiche", components_requesting: "Invio in corso...", components_request_failure: "Impossibile inviare la richiesta di modifica. Riprovi.", components_request_sent_title: "Richiesta inviata", components_request_sent_body: "La Sua richiesta di modifica dei componenti è stata inviata al supporto di etke.cc. Se ha bisogno di ulteriori modifiche, risponda a questa richiesta di supporto anziché aprirne una nuova.", components_request_sent_close: "Chiudi", components_request_sent_view: "Visualizza richiesta", components_request_already_sent: "È già aperta una richiesta di modifica. Per richiedere ulteriori modifiche, risponda al ticket di supporto esistente.", components_request_already_sent_view: "Visualizza ticket", status: { issue: { title: "L'abbonamento richiede attenzione", description: "Abbiamo rilevato un problema con il Suo abbonamento. Non si preoccupi — è facile da risolvere.", due_overdue: "In ritardo da", due_upcoming: "Scadenza tra", expected: "Importo atteso", last_paid: "Ultimo pagamento", fix_link: "Risolva il pagamento in ritardo", fix_mismatch_link: "Aggiorni il prezzo dell'abbonamento", support_link: "Contatta il supporto", }, }, }, status: { name: "Stato del server", badge: { default: "Clicca per visualizzare lo stato del server", running: "In esecuzione: %{command}. %{text}", status_ok: "Il server è online", status_error: "Stato: Errore", status_maintenance: "Il sistema è attualmente in modalità manutenzione.", status_process_running: "Il server sta eseguendo un comando", status_checking: "Verifica dello stato del server", }, category: { "Host Metrics": "Metriche host", Network: "Rete", HTTP: "HTTP", Matrix: "Matrix", }, status: "Stato", error: "Errore", loading: "Recupero dello stato operativo del server in tempo reale... Un attimo!", intro1: "Questo è un report di monitoraggio in tempo reale del Suo server. Può saperne di più su", intro2: "Se uno dei controlli qui sotto La preoccupa, consulti le azioni suggerite su", help: "Aiuto", }, maintenance: { title: "Il sistema è attualmente in modalità manutenzione.", try_again: "Riprova più tardi.", note: "Non è necessario contattare l’assistenza per questo: ci stiamo già lavorando!", }, actions: { name: "Comandi del server", available_title: "Comandi disponibili", available_description: "I seguenti comandi possono essere eseguiti.", available_help_intro: "Ulteriori dettagli su ciascuno sono disponibili su", scheduled_title: "Comandi programmati", scheduled_description: "I seguenti comandi sono programmati per essere eseguiti in orari specifici. Può visualizzare i dettagli e modificarli se necessario.", recurring_title: "Comandi ricorrenti", recurring_description: "I seguenti comandi sono impostati per essere eseguiti settimanalmente in un giorno e un orario specifici. Può visualizzare i dettagli e modificarli se necessario.", scheduled_help_intro: "Ulteriori dettagli su questa modalità sono disponibili su", recurring_help_intro: "Ulteriori dettagli su questa modalità sono disponibili su", maintenance_title: "Il sistema è attualmente in modalità manutenzione.", maintenance_try_again: "Riprova più tardi.", maintenance_note: "Non è necessario contattare l’assistenza per questo: ci stiamo già lavorando!", maintenance_commands_blocked: "I comandi non possono essere eseguiti finché la modalità manutenzione è attiva.", table: { aria_label: "Comandi del server", command: "Comando", description: "Descrizione", arguments: "Argomenti", is_recurring: "Ricorrente?", run_at: "Esegui (ora locale)", next_run_at: "Prossima esecuzione (ora locale)", time_utc: "Ora (UTC)", time_local: "Ora (locale)", }, buttons: { create: "Crea", update: "Aggiorna", back: "Indietro", delete: "Elimina", run: "Esegui", }, command_scheduled: "Comando programmato: %{command}", command_scheduled_args: "con argomenti aggiuntivi: %{args}", expect_prefix: "Il risultato sarà disponibile nella pagina", expect_suffix: "a breve.", notifications_link: "Notifiche", command_help_title: "Guida %{command}", scheduled_title_create: "Crea comando programmato", scheduled_title_edit: "Modifica comando programmato", recurring_title_create: "Crea comando ricorrente", recurring_title_edit: "Modifica comando ricorrente", scheduled_details_title: "Dettagli comando programmato", recurring_warning: "I comandi programmati creati da uno ricorrente non sono modificabili perché verranno rigenerati automaticamente. Modifica invece il comando ricorrente.", command_details_intro: "Ulteriori dettagli sul comando sono disponibili su", form: { id: "ID", command: "Comando", scheduled_at: "Programmato per", day_of_week: "Giorno della settimana", }, delete_scheduled_title: "Elimina comando programmato", delete_recurring_title: "Elimina comando ricorrente", delete_confirm: "È sicuro di voler eliminare il comando: %{command}?", errors: { unknown: "Si è verificato un errore sconosciuto", delete_failed: "Errore: %{error}", }, days: { monday: "Lunedì", tuesday: "Martedì", wednesday: "Mercoledì", thursday: "Giovedì", friday: "Venerdì", saturday: "Sabato", sunday: "Domenica", }, scheduled: { action: { create_success: "Comando programmato creato con successo", update_success: "Comando programmato aggiornato con successo", update_failure: "Si è verificato un errore", delete_success: "Comando programmato eliminato con successo", delete_failure: "Si è verificato un errore", }, }, recurring: { action: { create_success: "Comando ricorrente creato con successo", update_success: "Comando ricorrente aggiornato con successo", update_failure: "Si è verificato un errore", delete_success: "Comando ricorrente eliminato con successo", delete_failure: "Si è verificato un errore", }, }, }, notifications: { title: "Notifiche", new_notifications: "%{smart_count} nuova notifica |||| %{smart_count} nuove notifiche", no_notifications: "Nessuna notifica per ora", see_all: "Vedi tutte le notifiche", clear_all: "Cancella tutto", ago: "fa", advisory_tooltip: "Potrebbe aver perso una notifica. Si prega di controllare anche #news:etke.cc, etke.cc/news o la Sua casella di posta elettronica.", unavailable_tooltip: "Le notifiche potrebbero non essere disponibili. Clicchi per i dettagli.", unavailable_title: "Le notifiche potrebbero non essere disponibili al momento", unavailable_body: "Potrebbero esserci aggiornamenti che non riusciamo a recapitare in questo pannello al momento — o potrebbe non esserci nulla di nuovo. Per evitare di perdere qualcosa, si prega di controllare periodicamente:", unavailable_link_matrix: "Stanza Matrix #news:etke.cc", unavailable_link_news: "Pagina degli annunci su etke.cc/news", unavailable_link_email: "La Sua casella di posta elettronica (inclusa la cartella spam)", unavailable_retry: "Riprova", }, currently_running: { command: "Attualmente in esecuzione:", started_ago: "(avviato %{time} fa)", }, time: { less_than_minute: "qualche secondo", minutes: "%{smart_count} minuto |||| %{smart_count} minuti", hours: "%{smart_count} ora |||| %{smart_count} ore", days: "%{smart_count} giorno |||| %{smart_count} giorni", weeks: "%{smart_count} settimana |||| %{smart_count} settimane", months: "%{smart_count} mese |||| %{smart_count} mesi", }, support: { name: "Supporto", menu_label: "Contatta l'assistenza", description: "Apri una richiesta di supporto o segui una esistente. Il nostro team risponderà il prima possibile.", create_title: "Nuova richiesta di supporto", no_requests: "Nessuna richiesta di supporto ancora.", no_messages: "Nessun messaggio ancora.", closed_message: "Questa richiesta è chiusa. Se ha ancora un problema, ne apra una nuova.", fields: { subject: "Oggetto", message: "Messaggio", reply: "Risposta", status: "Stato", created_at: "Creato", updated_at: "Ultimo aggiornamento", }, status: { active: "In attesa dell'operatore", open: "Aperto", closed: "Chiuso", pending: "In attesa di una Sua risposta", }, buttons: { new_request: "Nuova richiesta", submit: "Invia", cancel: "Annulla", send: "Invia", back: "Torna al supporto", attach_files: "Allega file", }, helper: { loading: "Caricamento delle richieste di supporto...", reply_hint: "Ctrl+Invio per inviare", reply_placeholder: "Includa quanti più dettagli possibili.", before_contact_title: "Prima di contattarci", help_pages_prompt: "Per favore, consulti prima le nostre pagine di aiuto:", services_prompt: "Forniamo solo i servizi elencati nella pagina dei Servizi:", topics_prompt: "Possiamo aiutarLa solo con gli argomenti supportati:", scope_confirm_label: "Ho consultato le pagine di aiuto e confermo che questa richiesta riguarda gli argomenti supportati.", english_only_notice: "Il supporto è disponibile solo in inglese.", response_time_prompt: "Risposta entro 48 ore. Ha bisogno di tempi di risposta più rapidi? Veda:", attachments_limit: "Fino a 5 file, 5 MB ciascuno, 10 MB in totale.", close_request_label: "Chiudi questa richiesta dopo l'invio", }, actions: { create_success: "Richiesta di supporto creata con successo.", create_failure: "Impossibile creare la richiesta di supporto.", send_failure: "Impossibile inviare il messaggio.", attachment_too_large: 'Il file "%{name}" supera il limite di 5 MB.', too_many_attachments: "Massimo 5 file consentiti.", total_size_exceeded: "La dimensione totale degli allegati supera i 10 MB.", }, }, }, }; export default common; ================================================ FILE: src/i18n/it/index.ts ================================================ import { SynapseTranslationMessages } from "../types"; import common from "./common"; import mas from "./mas"; import misc_resources from "./misc_resources"; import reports from "./reports"; import rooms from "./rooms"; import users from "./users"; const it: SynapseTranslationMessages = { ra: common.ra, ketesa: common.ketesa, import_users: common.import_users, delete_media: common.delete_media, purge_remote_media: common.purge_remote_media, etkecc: common.etkecc, resources: { users, rooms, reports, scheduled_tasks: misc_resources.scheduled_tasks, connections: misc_resources.connections, devices: misc_resources.devices, users_media: misc_resources.users_media, protect_media: misc_resources.protect_media, quarantine_media: misc_resources.quarantine_media, pushers: misc_resources.pushers, servernotices: misc_resources.servernotices, database_room_statistics: misc_resources.database_room_statistics, user_media_statistics: misc_resources.user_media_statistics, forward_extremities: misc_resources.forward_extremities, room_state: misc_resources.room_state, room_media: misc_resources.room_media, room_directory: misc_resources.room_directory, destinations: misc_resources.destinations, registration_tokens: misc_resources.registration_tokens, account_data: misc_resources.account_data, joined_rooms: misc_resources.joined_rooms, memberships: misc_resources.memberships, room_members: misc_resources.room_members, destination_rooms: misc_resources.destination_rooms, ...mas, }, }; export default it; ================================================ FILE: src/i18n/it/mas.ts ================================================ const mas = { mas_users: { name: "Utente MAS |||| Utenti MAS", fields: { id: "ID MAS", username: "Nome utente", admin: "Admin", locked: "Bloccato", deactivated: "Disattivato", legacy_guest: "Ospite legacy", created_at: "Creato il", locked_at: "Bloccato il", deactivated_at: "Disattivato il", }, filter: { status: "Stato", search: "Cerca", status_active: "Attivo", status_locked: "Bloccato", status_deactivated: "Disattivato", }, action: { lock: { label: "Blocca", success: "Utente bloccato" }, unlock: { label: "Sblocca", success: "Utente sbloccato" }, deactivate: { label: "Disattiva", success: "Utente disattivato" }, reactivate: { label: "Riattiva", success: "Utente riattivato" }, set_admin: { label: "Concedi Admin", success: "Stato admin aggiornato" }, remove_admin: { label: "Rimuovi Admin", success: "Stato admin aggiornato" }, set_password: { label: "Imposta password", title: "Imposta password", success: "Password impostata", failure: "Impossibile impostare la password", }, }, }, mas_user_emails: { name: "Email |||| Email", empty: "Nessuna email", fields: { email: "Email", user_id: "ID utente", created_at: "Creato il", actions: "Azioni", }, action: { remove: { label: "Rimuovi", title: "Rimuovi email", content: "Rimuovere %{email}?", success: "Email rimossa", }, create: { success: "Email aggiunta" }, }, }, mas_compat_sessions: { name: "Sessione compat |||| Sessioni compat", empty: "Nessuna sessione compat", fields: { user_id: "ID utente", device_id: "ID dispositivo", created_at: "Creato il", user_agent: "User Agent", last_active_at: "Ultima attività", last_active_ip: "Ultimo IP", finished_at: "Terminato il", human_name: "Nome", active: "Attivo", }, action: { finish: { label: "Termina", title: "Terminare la sessione?", content: "Questa azione terminerà la sessione.", success: "Sessione terminata", }, }, }, mas_oauth2_sessions: { name: "Sessione OAuth2 |||| Sessioni OAuth2", empty: "Nessuna sessione OAuth2", fields: { user_id: "ID utente", client_id: "ID client", scope: "Ambito", created_at: "Creato il", user_agent: "User Agent", last_active_at: "Ultima attività", last_active_ip: "Ultimo IP", finished_at: "Terminato il", human_name: "Nome", active: "Attivo", }, action: { finish: { label: "Termina", title: "Terminare la sessione?", content: "Questa azione terminerà la sessione.", success: "Sessione terminata", }, }, }, mas_policy_data: { name: "Dati policy", current_policy: "Policy attuale", no_policy: "Nessuna policy attualmente impostata.", set_policy: "Imposta nuova policy", invalid_json: "JSON non valido", fields: { json_placeholder: "Inserisca i dati della policy come JSON…", created_at: "Creato il", }, action: { save: { label: "Imposta policy", success: "Policy aggiornata", failure: "Impossibile aggiornare la policy", }, }, }, mas_user_sessions: { name: "Sessione browser |||| Sessioni browser", fields: { user_id: "ID utente", created_at: "Creato il", finished_at: "Terminato il", user_agent: "User Agent", last_active_at: "Ultima attività", last_active_ip: "Ultimo IP", active: "Attivo", }, action: { finish: { label: "Termina", title: "Terminare la sessione?", content: "Questa azione terminerà la sessione del browser.", success: "Sessione terminata", }, }, }, mas_upstream_oauth_links: { name: "Collegamento OAuth upstream |||| Collegamenti OAuth upstream", fields: { user_id: "ID utente", provider_id: "ID provider", subject: "Soggetto", human_account_name: "Nome account", created_at: "Creato il", }, helper: { provider_id: "L'ID del provider OAuth upstream. Può trovarlo nell'elenco dei provider OAuth upstream.", }, action: { remove: { label: "Rimuovi", title: "Rimuovere il collegamento OAuth?", content: "Questa azione rimuoverà il collegamento OAuth upstream per questo utente.", success: "Collegamento OAuth rimosso", }, }, }, mas_upstream_oauth_providers: { name: "Provider OAuth |||| Provider OAuth", fields: { issuer: "Emittente", human_name: "Nome", brand_name: "Marchio", created_at: "Creato il", disabled_at: "Disabilitato il", enabled: "Abilitato", }, }, mas_personal_sessions: { name: "Sessione personale |||| Sessioni personali", empty: "Nessuna sessione personale", fields: { owner_user_id: "ID utente proprietario", actor_user_id: "Utente", human_name: "Nome", scope: "Ambito", created_at: "Creato il", revoked_at: "Revocato il", last_active_at: "Ultima attività", last_active_ip: "Ultimo IP", expires_at: "Scade il", expires_in: "Scade tra (secondi)", active: "Attivo", }, helper: { expires_in: "Facoltativo. Numero di secondi prima della scadenza del token. Lasci vuoto per nessuna scadenza.", }, action: { revoke: { label: "Revoca", title: "Revocare la sessione?", content: "Questa azione revocherà il token di accesso personale.", success: "Sessione revocata", }, create: { token_title: "Token di accesso personale", token_content: "Copi questo token adesso — non verrà mostrato di nuovo.", }, }, }, mas_sessions: { status: { active: "Attiva", finished: "Terminata", revoked: "Revocata", }, }, }; export default mas; ================================================ FILE: src/i18n/it/misc_resources.ts ================================================ const misc_resources = { scheduled_tasks: { name: "Attività pianificata |||| Attività pianificate", fields: { id: "ID", action: "Azione", status: "Stato", timestamp: "Timestamp", resource_id: "ID risorsa", result: "Risultato", error: "Errore", max_timestamp: "Prima della data", }, status: { scheduled: "Pianificata", active: "Attiva", complete: "Completata", cancelled: "Annullata", failed: "Fallita", }, }, connections: { name: "Connessioni", fields: { last_seen: "Data", ip: "Indirizzo IP", user_agent: "User agent", }, }, devices: { name: "Dispositivo |||| Dispositivi", fields: { device_id: "ID del dispositivo", display_name: "Nome del dispositivo", last_seen_ts: "Timestamp", last_seen_ip: "Indirizzo IP", last_seen_user_agent: "User agent", dehydrated: "Disidratato", }, action: { erase: { title: "Rimozione del dispositivo %{id}", title_bulk: "Rimozione di %{smart_count} dispositivo |||| Rimozione di %{smart_count} dispositivi", content: 'È sicuro di voler rimuovere il dispositivo "%{name}"?', content_bulk: "È sicuro di voler rimuovere %{smart_count} dispositivo? |||| È sicuro di voler rimuovere %{smart_count} dispositivi?", success: "Dispositivo rimosso con successo.", failure: "C'è stato un errore.", }, display_name: { success: "Nome del dispositivo aggiornato", failure: "Aggiornamento del nome del dispositivo non riuscito", }, create: { label: "Crea dispositivo", title: "Crea nuovo dispositivo", success: "Dispositivo creato", failure: "Creazione del dispositivo non riuscita", }, }, }, users_media: { name: "Media", fields: { media_id: "ID del media", media_length: "Peso del file (in Byte)", media_type: "Tipo", upload_name: "Nome del file", quarantined_by: "In quarantena da", safe_from_quarantine: "Protetto dalla quarantena", created_ts: "Creato", last_access_ts: "Ultimo accesso", }, action: { open: "Apri il file multimediale in una nuova finestra", }, }, protect_media: { action: { create: "Proteggere", delete: "Rimuovi protezione", none: "In quarantena", send_success: "Stato della protezione cambiato con successo.", send_failure: "C'è stato un errore.", }, }, quarantine_media: { action: { name: "Quarantina", create: "Quarantena", delete: "Rimuovi quarantena", none: "Protetto", send_success: "Stato della quarantena cambiato con successo.", send_failure: "C'è stato un errore: %{error}", }, }, pushers: { name: "Pusher |||| Pusher", fields: { app: "App", app_display_name: "Nome dell'app", app_id: "ID dell'app", device_display_name: "Nome del dispositivo", kind: "Tipo", lang: "Lingua", profile_tag: "Tag del profilo", pushkey: "Pushkey", data: { url: "URL" }, }, }, servernotices: { name: "Avvisi del server", send: "Invia avvisi", fields: { body: "Messaggio", }, action: { send: "Invia nota", send_success: "Avviso inviato con successo.", send_failure: "C'è stato un errore.", }, helper: { send: 'Invia un avviso dal server agli utenti selezionati. La feature "Avvisi del server" è stata attivata sul server.', }, }, database_room_statistics: { name: "Statistiche database delle stanze", fields: { room_id: "ID stanza", estimated_size: "Dimensione stimata", }, helper: { info: "Mostra lo spazio su disco stimato utilizzato da ogni stanza nel database Synapse. I valori sono approssimativi.", }, }, user_media_statistics: { name: "Media", fields: { media_count: "Numero media", media_length: "Lunghezza media", }, }, forward_extremities: { name: "Forward Extremities", fields: { id: "Event ID", received_ts: "Timestamp", depth: "Profondità", state_group: "State group", }, }, room_state: { name: "Eventi di stato", fields: { type: "Tipo", content: "Contenuto", origin_server_ts: "Ora dell'invio", sender: "Mittente", }, }, room_media: { name: "Media", fields: { media_id: "ID Media", }, helper: { info: "Questo è un elenco dei media caricati nella stanza. Non è possibile eliminare i media caricati su repository esterni.", }, action: { error: "%{errcode} (%{errstatus}) %{error}", }, }, room_directory: { name: "Elenco delle stanze", fields: { world_readable: "Gli utenti ospite possono vedere senza entrare", guest_can_join: "Gli utenti ospite possono entrare", }, action: { title: "Cancella stanza dall'elenco |||| Cancella %{smart_count} stanze dall'elenco", content: "È sicuro di voler rimuovere questa stanza dall'elenco? |||| È sicuro di voler rimuovere %{smart_count} stanze dall'elenco?", erase: "Rimuovi dall'elenco", create: "Crea", send_success: "Stanza creata con successo.", send_failure: "C'è stato un errore.", }, }, destinations: { name: "Federazione", fields: { destination: "Destinazione", failure_ts: "Timestamp dell'errore", retry_last_ts: "Tentativo ultimo timestamp", retry_interval: "Intervallo dei tentativi", last_successful_stream_ordering: "Ultimo flusso riuscito con successo", stream_ordering: "Flusso", }, action: { reconnect: "Riconnetti" }, }, registration_tokens: { name: "Token di registrazione", fields: { token: "Token", valid: "Token valido", uses_allowed: "Usi permessi", pending: "In attesa", completed: "Completato", expiry_time: "Data della scadenza", length: "Lunghezza", created_at: "Data di creazione", last_used_at: "Ultimo utilizzo", revoked_at: "Data di revoca", }, helper: { length: "Lunghezza del token se non viene dato alcun token." }, action: { revoke: { label: "Revoca", success: "Token revocato", }, unrevoke: { label: "Ripristina", success: "Token ripristinato", }, }, }, account_data: { name: "Dati del profilo", }, joined_rooms: { name: "Stanze partecipate", }, memberships: { name: "Appartenenze", }, room_members: { name: "Membri", }, destination_rooms: { name: "Stanze", }, }; export default misc_resources; ================================================ FILE: src/i18n/it/reports.ts ================================================ const reports = { name: "Evento segnalato |||| Eventi segnalati", fields: { id: "ID", received_ts: "Orario del report", user_id: "Richiedente", name: "Nome della stanza", score: "Punteggio", reason: "Ragione", event_id: "ID dell'evento", sender: "Mittente", }, action: { erase: { title: "Elimina evento segnalato", content: "È sicuro di voler eliminare l'evento segnalato? Questa azione è irreversibile.", }, event_lookup: { label: "Ricerca evento", title: "Recupera evento per ID", fetch: "Recupera", }, fetch_event_error: "Impossibile recuperare l'evento", }, }; export default reports; ================================================ FILE: src/i18n/it/rooms.ts ================================================ const rooms = { name: "Stanza |||| Stanze", fields: { room_id: "ID della stanza", name: "Nome", canonical_alias: "Alias", joined_members: "Membri", joined_local_members: "Membri locali", joined_local_devices: "Dispositivi locali", state_events: "Eventi di stato / Complessità", version: "Versione", is_encrypted: "Criptato", encryption: "Crittografia", federatable: "Federabile", public: "Visibile nella cartella della stanza", creator: "Creatore", join_rules: "Regole per entrare", guest_access: "Entra come ospite", history_visibility: "Visibilità temporale", topic: "Topic", avatar: "Avatar", actions: "Azioni", }, filter: { public_rooms: "Stanze pubbliche", empty_rooms: "Stanze vuote", local_members_only: "Solo membri locali", }, helper: { forward_extremities: "Le Forward Extremities sono gli eventi foglia alla fine di un grafo diretto aciclico (DAG) in una stanza, ovvero eventi senza figli. Più ce ne sono, più Synapse deve eseguire la risoluzione dello stato (operazione costosa). Sebbene Synapse disponga di codice per evitare che ce ne siano troppe in una stanza, a volte dei bug le fanno ricomparire. Se una stanza ha più di 10 Forward Extremities, vale la pena investigare e potenzialmente rimuoverle utilizzando le query SQL citate in #1760.", }, enums: { join_rules: { public: "Pubblica", knock: "Bussa", invite: "Invita", private: "Privata", restricted: "Riservata", }, guest_access: { can_join: "Gli utenti ospiti possono entrare", forbidden: "Gli utenti ospiti non possono entrare", }, history_visibility: { invited: "Dall'invito", joined: "Dall'entrata", shared: "Dalla condivisione", world_readable: "Chiunque", }, unencrypted: "Non criptata", room_type: { room: "Stanza", space: "Spazio", }, }, action: { erase: { title: "Cancella stanza", content: "È sicuro di voler eliminare questa stanza? Questa azione è definitiva. Tutti i messaggi e i media condivisi in questa stanza verranno eliminati dal server!", fields: { block: "Blocca e impedisci agli utenti di entrare nella stanza", }, in_progress: "Eliminazione in corso…", background_note: "Può chiudere questa finestra, l'eliminazione continuerà in background.", success: "Stanza/e eliminata/e con successo.", failure: "Impossibile eliminare la stanza/le stanze.", }, make_admin: { assign_admin: "Assegna un amministratore", title: "Assegna un amministratore alla stanza %{roomName}", confirm: "Assegna un amministratore", content: "Inserisca la MXID completa dell'utente che sarà designato come amministratore.\nAttenzione: perché ciò funzioni, la stanza deve avere almeno un membro locale come amministratore.", success: "L'utente è stato designato come amministratore della stanza.", failure: "L'utente non può essere designato come amministratore della stanza. %{errMsg}", }, join: { label: "Aggiungi utente", title: "Aggiungi un utente a %{roomName}", confirm: "Aggiungi", content: "Inserisca la MXID completa dell'utente da unire a questa stanza.\nNota: deve essere nella stanza e avere il permesso di invitare utenti.", success: "L'utente è stato aggiunto alla stanza con successo.", failure: "Impossibile aggiungere l'utente alla stanza. %{errMsg}", }, block: { label: "Blocca", title: "Blocca %{room}", title_bulk: "Blocca %{smart_count} stanza |||| Blocca %{smart_count} stanze", title_by_id: "Blocca una stanza", content: "Gli utenti non potranno unirsi a questa stanza.", content_bulk: "Gli utenti non potranno unirsi a %{smart_count} stanza. |||| Gli utenti non potranno unirsi a %{smart_count} stanze.", success: "Stanza bloccata con successo. |||| Stanze bloccate con successo.", failure: "Impossibile bloccare la stanza. |||| Impossibile bloccare le stanze.", }, unblock: { label: "Sblocca", success: "Stanza sbloccata con successo. |||| Stanze sbloccate con successo.", failure: "Impossibile sbloccare la stanza. |||| Impossibile sbloccare le stanze.", }, purge_history: { label: "Elimina cronologia", title: "Elimina cronologia di %{roomName}", content: "Tutti gli eventi prima della data selezionata verranno eliminati dal database. Lo stato della stanza (ingressi, uscite, argomento) viene sempre preservato. Almeno un messaggio viene sempre mantenuto.\nNota: questa operazione potrebbe richiedere diversi minuti per stanze grandi.", date_label: "Elimina eventi prima di", delete_local: "Elimina anche gli eventi inviati dagli utenti locali", in_progress: "Eliminazione in corso…", background_note: "Può chiudere questa finestra in sicurezza, l'eliminazione continuerà in background.", success: "Cronologia della stanza eliminata con successo.", failure: "Impossibile eliminare la cronologia della stanza. %{errMsg}", }, quarantine_all: { label: "Metti in quarantena tutti i media", title: "Metti in quarantena tutti i media in %{roomName}", content: "Tutti i media locali e remoti in questa stanza verranno messi in quarantena. I media in quarantena non saranno più accessibili agli utenti.", success: "%{smart_count} elemento multimediale messo in quarantena con successo. |||| %{smart_count} elementi multimediali messi in quarantena con successo.", failure: "Impossibile mettere in quarantena i media. %{errMsg}", }, delete_all_media: { label: "Elimina tutti i media", title: "Elimina tutti i media in %{roomName}", content: "Questa operazione eliminerà definitivamente tutti i media locali in questa stanza. Sono interessati solo i media locali delle stanze non cifrate — i media di server remoti sono esclusi. L'operazione è irreversibile.", in_progress_loading: "Recupero dell'elenco dei media…", in_progress: "Eliminazione dei media… (%{current} / %{total})", do_not_close: "Non chiuda questa finestra — l'eliminazione è in esecuzione in primo piano e si interromperà se viene chiusa.", success: "Eliminazione riuscita di %{smart_count} elemento multimediale. |||| Eliminazione riuscita di %{smart_count} elementi multimediali.", failure: "Impossibile eliminare i media. %{errMsg}", }, delete_all_media_bulk: { title: "Eliminare tutti i media per %{smart_count} stanza? |||| Eliminare tutti i media per %{smart_count} stanze?", content: "Questa operazione eliminerà definitivamente tutti i media locali nelle stanze selezionate (solo stanze non cifrate). I media di server remoti sono esclusi. L'operazione è irreversibile.", success: "Media eliminati per %{success} su %{total} stanze.", partial_failure: "Media eliminati per %{success} su %{total} stanze. %{failed} non riusciti.", }, event_context: { lookup_title: "Cerca evento per ID", jump_to_date: "Vai alla data", direction: "Direzione", forward: "Avanti", backward: "Indietro", target_event: "Evento di destinazione", events_before: "Eventi precedenti", events_after: "Eventi successivi", not_found: "Nessun evento trovato all'ora specificata", failure: "Impossibile recuperare il contesto dell'evento", }, messages: { load_older: "Carica precedenti", load_newer: "Carica successivi", no_messages: "Nessun messaggio in questa stanza", failure: "Impossibile caricare i messaggi", filter: "Filtri", filter_type: "Tipi di evento", filter_sender: "Mittenti", advanced_filters: "Filtri avanzati", filter_not_type: "Escludi tipi di evento", filter_not_sender: "Escludi mittenti", contains_url: "Contiene URL", any: "Qualsiasi", with_url: "Solo con URL", without_url: "Solo senza URL", apply_filter: "Applica", clear_filters: "Cancella", }, hierarchy: { load_more: "Carica altro", max_depth: "Profondità massima", unlimited: "Illimitata", refresh: "Aggiorna", members: "%{count} membri", space: "Spazio", room: "Stanza", suggested: "Consigliata", no_children: "Questa stanza non ha stanze figlie", failure: "Impossibile caricare la gerarchia", }, }, }; export default rooms; ================================================ FILE: src/i18n/it/users.ts ================================================ const users = { name: "Utente |||| Utenti", email: "Email", msisdn: "Telefono", threepid: "Email / Telefono", membership: "Membro |||| Membri", fields: { avatar: "Avatar", id: "ID utente", name: "Nome", is_guest: "Ospite", admin: "Amministratore", locked: "Bloccato", suspended: "Sospeso", shadow_banned: "Shadowbannato", deactivated: "Disattivato", show_guests: "Mostra gli ospiti", show_deactivated: "Mostra solo i disattivati", show_locked: "Mostra gli utenti bloccati", filter_user_all: "Tutti", filter_deactivated_false: "Attivi", filter_deactivated_true: "Disattivati", filter_locked_false: "Escludi bloccati", filter_locked_true: "Includi bloccati", filter_guests_false: "Escludi ospiti", filter_guests_true: "Includi ospiti", show_system_users: "Mostra utenti di sistema", filter_system_users_false: "Escludi utenti di sistema", filter_system_users_true: "Solo utenti di sistema", show_suspended: "Mostra gli utenti sospesi", show_shadow_banned: "Mostra utenti shadowbannati", user_id: "Cerca utente", displayname: "Nickname", password: "Password", avatar_url: "URL dell'avatar", avatar_src: "Avatar", medium: "Medium", threepids: "3PID", address: "Indirizzo", creation_ts_ms: "Creazione del timestamp", consent_version: "Versione minima richiesta", sent_invite_count: "Inviti inviati", cumulative_joined_room_count: "Stanze totali raggiunte", auth_provider: "Provider", user_type: "Tipo d'utente", erased: "Cancellato (GDPR)", }, helper: { password: "Cambiando la password l'utente verrà disconnesso da tutte le sessioni attive.", password_required_for_reactivation: "Deve fornire una password per riattivare l'account.", create_password: "Genera una password forte e sicura utilizzando il pulsante sottostante.", deactivate: "Deve fornire una password per riattivare l'account.", suspend: "La sospensione mette l'utente in modalità di sola lettura.", shadow_ban: "L'utente bannato nell'ombra riceve risposte normali, ma i suoi eventi non vengono propagati ad altri utenti o stanze. Da usare solo come ultima risorsa.", erase: "Contrassegni l'utente come cancellato dal GDPR", admin: "Un amministratore del server ha controllo totale sul server e sui suoi utenti.", lock: "Impedisce all'utente di utilizzare il server. Questa è un'azione non distruttiva che può essere annullata.", erase_text: "Ciò significa che i messaggi inviati dall'utente (o dagli utenti) saranno ancora visibili da chiunque si trovasse nella stanza al momento dell'invio, ma saranno nascosti agli utenti che si uniranno alla stanza in seguito.", erase_admin_error: "Non è consentito eliminare il proprio utente.", modify_managed_user_error: "La modifica di un utente gestito dal sistema non è consentita.", username_available: "Nome utente disponibile", sent_invite_count: "Numero totale di inviti inviati da questo utente in tutte le stanze.", cumulative_joined_room_count: "Numero totale di stanze a cui questo utente si è unito, incluse quelle che ha lasciato o da cui è stato bannato.", }, badge: { you: "Lei", bot: "Bot", admin: "Amministratore", support: "Supporto", regular: "Utente normale", federated: "Federato", system_managed: "Gestito dal sistema", }, action: { erase: "Cancella i dati dell'utente", erase_avatar: "Cancella l'avatar dell'utente", delete_media: "Elimina tutti i media caricati dall'utente/dagli utenti", redact_events: "Oscura tutti gli eventi inviati dall'utente/dagli utenti", redact_in_progress: "Oscuramento in corso\u2026", redact_background_note: "Può chiudere questa finestra in sicurezza, l'oscuramento continuerà in background.", redact_success: "Tutti gli eventi sono stati oscurati con successo.", redact_failure: "Oscuramento completato con %{smart_count} evento fallito. |||| Oscuramento completato con %{smart_count} eventi falliti.", generate_password: "Genera password", reset_password: { label: "Reimposta password", title: "Reimposta password", helper: "Cambia la password di %{user}", password: "Password", logout_devices: "Disconnetti tutti i dispositivi", success: "Password reimpostata con successo", failure: "Impossibile reimpostare la password", error_no_password: "La password è obbligatoria", }, login_as: { label: "Accedi come utente", title: "Accedi come utente", helper: "Ottenga un token di accesso per autenticarsi come %{user}. Questa azione non genera un nuovo dispositivo per l'utente, quindi non apparirà nella lista dei dispositivi/sessioni. L'utente di destinazione generalmente non dovrebbe essere in grado di rilevare questo accesso.", valid_until: "Imposta data di scadenza", success: "Token di accesso generato con successo", failure: "Impossibile generare il token di accesso", result_title: "Token di accesso di %{user}", access_token: "Token di accesso", expires_at: "Questo token di accesso scadrà il %{date}", }, overwrite_title: "Attenzione!", overwrite_content: "Questo nome utente è già stato utilizzato. È sicuro di voler sovrascrivere l'utente esistente?", overwrite_cancel: "Annulla", overwrite_confirm: "Sovrascrivi", quarantine_all: { label: "Metti in quarantena tutti i media", title: "Metti in quarantena tutti i media di %{userName}", content: "Tutti i media locali di questo utente verranno messi in quarantena. I media in quarantena non saranno più accessibili agli altri utenti.", success: "%{smart_count} elemento multimediale messo in quarantena con successo. |||| %{smart_count} elementi multimediali messi in quarantena con successo.", failure: "Impossibile mettere in quarantena i media. %{errMsg}", }, delete_all_media: { label: "Elimina tutti i media", title: "Elimina tutti i media di %{userName}", content: "Questa operazione eliminerà definitivamente tutti i media caricati da questo utente. L'operazione è irreversibile.", in_progress: "Eliminazione dei media in corso…", background_note: "Può chiudere questa finestra in sicurezza — l'eliminazione continuerà in background.", success: "Eliminazione riuscita di %{smart_count} elemento multimediale. |||| Eliminazione riuscita di %{smart_count} elementi multimediali.", failure: "Impossibile eliminare i media. %{errMsg}", }, delete_all_media_bulk: { title: "Eliminare tutti i media per %{smart_count} utente? |||| Eliminare tutti i media per %{smart_count} utenti?", content: "Questa operazione eliminerà definitivamente tutti i media caricati dagli utenti selezionati. L'operazione è irreversibile.", success: "Media eliminati per %{success} su %{total} utenti.", partial_failure: "Media eliminati per %{success} su %{total} utenti. %{failed} non riusciti.", }, allow_cross_signing: { label: "Consenti reimpostazione Cross-Signing", title: "Consenti sostituzione chiavi Cross-Signing", content: "Consentire a %{user} di sostituire le proprie chiavi Cross-Signing senza autenticazione interattiva? Questo crea una finestra temporanea durante la quale le chiavi possono essere sostituite.", success: "Sostituzione chiavi Cross-Signing consentita fino al %{deadline}", failure: "Impossibile consentire la sostituzione Cross-Signing", no_key: "L'utente non ha una chiave Cross-Signing principale", }, find_user: { label: "Trova utente", title: "Trova utente", lookup_type: "Tipo di ricerca", by_threepid: "Per e-mail / telefono", by_auth_provider: "Per provider di autenticazione", provider: "ID provider di autenticazione", external_id: "ID esterno", search: "Cerca", not_found: "Utente non trovato", failure: "Impossibile trovare l'utente", }, renew_account: { label: "Rinnova account", title: "Rinnova la validità dell'account", content: "Rinnovi la validità dell'account di %{user}. Può facoltativamente impostare una data di scadenza personalizzata. Se lasciato vuoto, verrà utilizzato il periodo di rinnovo predefinito del server.", expiration: "Data di scadenza", expiration_helper: "Lasci vuoto per utilizzare il periodo di rinnovo predefinito del server", renewal_emails: "Invia e-mail di notifica di rinnovo", success: "Validità dell'account rinnovata fino al %{date}", failure: "Impossibile rinnovare la validità dell'account", }, system_users_scan_in_progress: "Attendere — la ricerca degli utenti corrispondenti è ancora in corso, la pagina verrà caricata a breve", reverse_search_scan_in_progress: "Attendere — tutti gli utenti vengono analizzati per escludere le corrispondenze, la pagina verrà caricata a breve", }, limits: { messages_per_second: "Messaggi al secondo", messages_per_second_text: "Il numero di azioni che l'utente può eseguire al secondo.", burst_count: "Burst-conteggio", burst_count_text: "Il numero di azioni che l'utente può eseguire prima di essere limitato.", }, account_data: { title: "Dati del profilo", global: "Globale", rooms: "Stanza", }, }; export default users; ================================================ FILE: src/i18n/ja/base.ts ================================================ import type { TranslationMessages } from "ra-core"; const japaneseMessages: TranslationMessages = { ra: { action: { add_filter: "検索条件", add: "追加", back: "戻る", bulk_actions: "%{smart_count}件選択", cancel: "キャンセル", clear_array_input: "すべての項目を削除", clear_input_value: "空にする", clone: "複製", confirm: "確認", create: "作成", create_item: "%{item}を作成", delete: "削除", edit: "編集", export: "出力", list: "一覧", refresh: "更新", remove_filter: "検索条件を削除", remove_all_filters: "すべての検索条件を削除", remove: "削除", reset: "元に戻す", save: "保存", search: "検索", search_columns: "列を検索", select_all: "すべて選択", select_all_button: "すべて選択", select_row: "この行を選択", show: "詳細", sort: "並び替え", undo: "元に戻す", unselect: "選択解除", expand: "開く", close: "閉じる", open_menu: "開く", close_menu: "閉じる", update: "更新", move_up: "上へ移動", move_down: "下へ移動", open: "開く", toggle_theme: "ダークモード切替", select_columns: "列を編集", update_application: "再読み込み", }, boolean: { true: "はい", false: "いいえ", null: "未選択", }, page: { create: "%{name}を作成", dashboard: "ダッシュボード", edit: "%{name} %{recordRepresentation}", error: "問題が発生しました", list: "%{name}", loading: "読込中", not_found: "見つかりませんでした", show: "%{name} %{recordRepresentation}", empty: "%{name}はありません", invite: "作成しますか?", access_denied: "このページは表示できません", authentication_error: "認証エラー", }, input: { file: { upload_several: "アップロードするファイルをドロップ、または選択してください", upload_single: "アップロードするファイルをドロップ、または選択してください", }, image: { upload_several: "アップロードする画像をドロップ、または選択してください", upload_single: "アップロードする画像をドロップ、または選択してください", }, references: { all_missing: "データは利用できなくなりました", many_missing: "選択したデータは利用できなくなりました", single_missing: "選択したデータは利用できなくなりました", }, password: { toggle_visible: "非表示", toggle_hidden: "表示", }, }, message: { about: "詳細", access_denied: "このページを表示する権限がありません", are_you_sure: "本当によろしいでしょうか?", authentication_error: "認証サーバーでエラーが発生し、このページを表示する権限があるか確認できませんでした", auth_error: "認証トークンの検証に失敗しました。", bulk_delete_content: "%{name}を削除しますか? |||| 選択した %{smart_count}件のアイテムを削除しますか?", bulk_delete_title: "%{name}を削除 |||| %{name} %{smart_count}件を削除", bulk_update_content: "%{name}を更新しますか? |||| 選択した %{smart_count}件のアイテムを更新しますか?", bulk_update_title: "%{name}を更新 |||| %{name} %{smart_count}件を更新", clear_array_input: "全ての項目を消去しますか?", delete_content: "削除しますか?", delete_title: "%{name} %{recordRepresentation} を削除", details: "詳細", error: "クライアントエラーが発生し、処理を完了できませんでした", invalid_form: "入力値に誤りがあります。エラーメッセージを確認してください", loading: "読み込み中です。しばらくお待ちください", no: "いいえ", not_found: "間違ったURLを入力したか、古いリンクを開いた可能性があります", placeholder_data_warning: "通信エラーのため、データの再取得に失敗しました", select_all_limit_reached: "選択できる項目が上限に達しました。最初の%{max}件が選択されています", unsaved_changes: "行った変更が保存されていません。このページから移動しますか?", yes: "はい", }, navigation: { clear_filters: "検索条件を削除", no_filtered_results: "現在の検索条件では結果が見つかりませんでした", no_results: "結果が見つかりませんでした", no_more_results: "%{page}ページは最大のページ数を超えています。前のページに戻ってください", page_out_of_boundaries: "%{page}ページは最大のページ数を超えています", page_out_from_end: "最大のページより後に移動できません", page_out_from_begin: "1ページより前に移動できません", page_range_info: "%{offsetBegin} - %{offsetEnd} / %{total}", partial_page_range_info: "%{offsetBegin} - %{offsetEnd}", current_page: "%{page}ページ目", page: "%{page}ページに移動", first: "最初のページに移動", last: "最後のページに移動", next: "次のページに移動", previous: "前のページに移動", page_rows_per_page: "表示件数:", skip_nav: "コンテンツにスキップ", }, sort: { sort_by: "%{field_lower_first}を%{order}で並び替える", ASC: "昇順", DESC: "降順", }, auth: { auth_check_error: "認証に失敗しました。再度ログインしてください", user_menu: "プロフィール", username: "ユーザー名", password: "パスワード", email: "メールアドレス", sign_in: "ログイン", sign_in_error: "認証に失敗しました。入力を確認してください", logout: "ログアウト", }, notification: { updated: "更新しました |||| %{smart_count}件更新しました", created: "作成しました", deleted: "削除しました |||| %{smart_count}件削除しました", bad_item: "データが不正です", item_doesnt_exist: "データが存在しませんでした", http_error: "通信エラーが発生しました", data_provider_error: "dataProviderエラー。詳細はコンソールを確認してください", i18n_error: "翻訳ファイルが読み込めませんでした", canceled: "元に戻しました", logged_out: "認証に失敗しました。再度ログインしてください", not_authorized: "このページにアクセスする権限がありません", application_update_available: "新しいバージョンが利用可能です", offline: "オフラインです。データを取得できませんでした", }, validation: { required: "必須", minLength: "%{min}文字以上である必要があります", maxLength: "%{max}文字以下である必要があります", minValue: "%{min}以上である必要があります", maxValue: "%{max}以下である必要があります", number: "数字である必要があります", email: "メールアドレスである必要があります", oneOf: "次のいずれかである必要があります: %{options}", regex: "次の正規表現形式にする必要があります: %{pattern}", unique: "一意である必要があります", }, saved_queries: { label: "保存した検索条件", query_name: "検索条件名", new_label: "現在の検索条件を保存...", new_dialog_title: "検索条件を保存", remove_label: "検索条件を削除", remove_label_with_name: "検索条件 %{name} を削除", remove_dialog_title: "検索条件を削除", remove_message: "選択した保存検索条件を削除しますか?", help: "検索条件を保存して、あとから同じ条件で検索できます", }, guesser: { empty: { title: "表示するデータがありません", message: "データプロバイダーを確認してください", }, }, configurable: { customize: "カスタマイズ", configureMode: "このページをカスタマイズする", inspector: { title: "カスタマイズ", content: "UI要素にマウスオーバーするとカスタマイズできます", reset: "設定をリセット", hideAll: "すべて非表示", showAll: "すべて表示", }, Datagrid: { title: "データグリッド", unlabeled: "列名未設定 #%{column}", }, SimpleForm: { title: "フォーム", unlabeled: "項目名未設定 #%{input}", }, SimpleList: { title: "リスト", primaryText: "1行目", secondaryText: "2行目", tertiaryText: "3行目", }, }, }, }; export default japaneseMessages; ================================================ FILE: src/i18n/ja/common.ts ================================================ import japaneseMessages from "./base"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const common: Record = { ...japaneseMessages, ketesa: { auth: { base_url: "ホームサーバーのURL", welcome: "%{name}にようこそ", description: "Synapse Adminの進化形。シンプルなひとつのインターフェースで、Matrixサーバーの管理・監視・運用を完結。小規模なプライベートサーバーから大規模なフェデレーションコミュニティまで、幅広く対応します。", server_version: "Synapseのバージョン", supports_specs: "次のMatrixのスペックをサポートしています", username_error: "有効なユーザーIDを入力してください。形式は「@user:domain」です。", protocol_error: "URLの先頭には「http://」または「https://」を置いてください", url_error: "正しいMatrixのサーバーのURLではありません", sso_sign_in: "シングルサインオン", credentials: "認証情報", access_token: "アクセストークン", logout_access_token_dialog: { title: "既存のMatrixアクセストークンが使われています。", content: "このセッションを破棄しますか? このセッションは、Matrixのクライアントなどで使われている可能性があります。または、管理パネルからログアウトしますか?", confirm: "破棄する", cancel: "管理パネルからログアウト", }, }, users: { invalid_user_id: "Matrix ユーザーID のローカル部分のみを入力してください — ホームサーバーは含めないでください。", tabs: { sso: "シングルサインオン", experimental: "実験的", limits: "レート制限", account_data: "アカウントのデータ", sessions: "セッション", }, danger_zone: "要注意", }, rooms: { details: "ルームの詳細", tabs: { basic: "基本情報", members: "メンバー", detail: "詳細", permission: "権限", media: "メディア", messages: "メッセージ", hierarchy: "階層", }, }, reports: { tabs: { basic: "基本情報", detail: "詳細" } }, admin_config: { soft_failed_events: "ソフト失敗イベント", spam_flagged_events: "スパムとしてフラグされたイベント", success: "管理者設定を更新しました", failure: "管理者設定の更新に失敗しました", }, }, import_users: { error: { at_entry: "エントリー %{entry}: %{message}", error: "エラー", required_field: "必須のフィールド「%{field}」がありません", invalid_value: "%{row}行目に不正な値があります。「%{field}」のフィールドには「true」または「false」を指定してください", unreasonably_big: "ファイルは%{size}メガバイトで大きすぎるため、読み込めませんでした", already_in_progress: "インポートしています", id_exits: "ID %{id} は既に存在しています", }, title: "CSVでユーザーをインポート", goToPdf: "PDFを開く", cards: { importstats: { header: "インポートするユーザー", users_total: "CSVファイルの%{smart_count}人のユーザー", guest_count: "%{smart_count}人のゲスト", admin_count: "%{smart_count}人の管理者", }, conflicts: { header: "競合を処理する方針", mode: { stop: "競合の発生時に停止", skip: "エラーを表示して競合をスキップ", }, }, ids: { header: "ID", all_ids_present: "全てのエントリーにIDsがあります", count_ids_present: "%{smart_count}個のエントリーにIDがあります", mode: { ignore: "CSVファイルのIDを無視し、新しいIDを作成", update: "既存のレコードを更新", }, }, passwords: { header: "パスワード", all_passwords_present: "全てのエントリーにパスワードがあります", count_passwords_present: "%{smart_count}個のエントリーにパスワードがあります", use_passwords: "CSVファイルのパスワードを使用", }, upload: { header: "CSVファイルを送信", explanation: "作成または更新するユーザーをコンマで区切って入力したファイルをアップロードできます。ファイルには「id」と「displayname」のフィールドを含めてください。参照用のファイルは以下からダウンロードできます。", }, startImport: { simulate_only: "シミュレーション", run_import: "インポート", }, results: { header: "インポートの結果", total: "合計%{smart_count}個のエントリー", successful: "%{smart_count}個のエントリーをインポートしました", skipped: "%{smart_count}個のエントリーをスキップしました", download_skipped: "スキップしたエントリーをダウンロード", with_error: "%{smart_count}個のエントリーでエラーが発生しました", simulated_only: "シミュレーションのみ実行", }, }, }, delete_media: { name: "メディアファイル", fields: { before_ts: "最終アクセス日時がこれより以前のもの", size_gt: "サイズがこれより大きいもの(バイト)", keep_profiles: "プロフィールの画像は削除しない", }, action: { send: "メディアファイルを削除", send_success: "%{smart_count}件のメディアファイルを削除しました。", send_success_none: "指定された条件に一致するメディアファイルはありませんでした。何も削除されていません。", send_failure: "エラーが発生しました。", }, helper: { send: "このAPIを使うとサーバーからローカルメディアファイルを削除できます。削除できるファイルは、ローカルのサムネイルファイルと、ダウンロードしたメディアファイルのコピーも含みます。外部のメディアリポジトリにアップロードされたメディアファイルは削除できません。", }, }, purge_remote_media: { name: "リモートのメディアファイル", fields: { before_ts: "最終アクセス日時がこれより以前のもの", }, action: { send: "リモートのメディアファイルを削除", send_success: "%{smart_count}件のリモートメディアファイルを削除しました。", send_success_none: "指定された条件に一致するリモートメディアファイルはありませんでした。何も削除されていません。", send_failure: "エラーが発生しました。", }, helper: { send: "このAPIを使うとサーバーからリモートメディアファイルのキャッシュを削除できます。削除できるファイルは、ローカルのサムネイルファイルと、ダウンロードしたメディアファイルのコピーも含みます。サーバーのメディアリポジトリにアップロードされたメディアファイルは削除できません。", }, }, etkecc: { donate: { menu_label: "寄付", name: "Ketesa の開発を支援", title: "Ketesa の開発を支援", description_1: "Ketesa プロジェクトは無償のオープンソースであり、私たちは Matrix コミュニティのためにオープンに開発と保守を続けています。", description_2: "Ketesa プロジェクトが役立っていると感じていただけたなら、ご寄付はその裏側にある取り組みを続ける支えになります。開発、保守、不具合修正、そして着実な改善のために役立ちます。", description_3: "それにより、このプロジェクトを頼りにしているすべての方のために、より多くの時間を改善に充てられます。", description_4: "どのようなご支援も大きな力になります。心から感謝いたします! ❤️", button: "寄付する", signature_team: "etke.cc チーム", }, components: { name: "コンポーネント", description: "アクティブなコンポーネントを確認・管理し、サーバーに追加できるものを発見してください。", no_section: "お客様のサーバー", per_month: "/月", included: "含まれています", total: "合計", loading: "コンポーネントを読み込み中...", state_add: "追加", state_remove: "削除", add_aria: "%{name} の追加をリクエスト", remove_aria: "%{name} の削除をリクエスト", preview_label: "プレビュー", request_changes: "変更をリクエスト", requesting: "送信中...", request_failure: "変更リクエストの送信に失敗しました。もう一度お試しください。", request_sent_title: "リクエストを送信しました", request_sent_body: "コンポーネント変更リクエストが etke.cc サポートに送信されました。追加の変更が必要な場合は、新しいリクエストを開くのではなく、このサポートリクエストに返信してください。", request_sent_close: "閉じる", request_sent_view: "リクエストを表示", request_already_sent: "変更リクエストはすでに開いています。さらに変更をリクエストするには、既存のサポートチケットに返信してください。", request_already_sent_view: "チケットを表示", free_label: "無料", available_label: "利用可能", tagline: "サーバーを強化しましょう — コンポーネントはいつでも追加・削除できます。", section: { bridges: "ブリッジ", extras: "エクストラ", matrix_apps: "Matrix アプリ", matrix_bots: "Matrix ボット", matrix_extras: "Matrix エクストラ", }, }, billing: { name: "請求", title: "支払履歴", no_payments: "支払が見つかりませんでした。", no_payments_helper: "誤りだと思われる場合は、etke.cc サポートにお問い合わせください。", description1: "ここから支払の確認や請求書の作成ができます。サブスクリプション管理の詳細は", description2: "請求先メールアドレスの変更や請求書への会社情報の追加については、以下をご参照ください:", fields: { transaction_id: "取引ID", email: "メール", type: "種類", amount: "金額", paid_at: "支払日", invoice: "請求書", }, enums: { type: { subscription: "サブスクリプション", one_time: "一度のみ", }, }, helper: { download_invoice: "請求書をダウンロード", downloading: "ダウンロードしています…", download_started: "請求書のダウンロードを開始しました。", invoice_not_available: "保留中", loading: "請求情報を読み込んでいます…", loading_failed1: "請求情報を読み込めませんでした。", loading_failed2: "しばらくしてからもう一度お試しください。", loading_failed3: "問題が解消しない場合は、etke.cc サポートにお問い合わせください。", loading_failed4: "エラーの詳細:", }, components: "有効なコンポーネント", components_no_section: "お客様のサーバー", components_per_month: "/月", components_included: "含まれています", components_total: "合計", components_help_title: "%{name} の詳細を見る", components_state_install: "インストール", components_state_remove: "削除", components_remove_aria: "%{name} をインストール/削除", components_preview_label: "プレビュー", components_request_changes: "変更をリクエスト", components_requesting: "送信中...", components_request_failure: "変更リクエストの送信に失敗しました。もう一度お試しください。", components_request_sent_title: "リクエストを送信しました", components_request_sent_body: "コンポーネント変更リクエストが etke.cc サポートに送信されました。追加の変更が必要な場合は、新しいリクエストを開くのではなく、このサポートリクエストに返信してください。", components_request_sent_close: "閉じる", components_request_sent_view: "リクエストを表示", components_request_already_sent: "変更リクエストはすでに開いています。さらに変更をリクエストするには、既存のサポートチケットに返信してください。", components_request_already_sent_view: "チケットを表示", status: { issue: { title: "サブスクリプションの確認が必要です", description: "サブスクリプションに問題が検出されました。ご安心ください — 簡単に解決できます。", due_overdue: "滞納期間", due_upcoming: "支払いまで", expected: "予定金額", last_paid: "最終支払い", fix_link: "滞納支払いを解決", fix_mismatch_link: "サブスクリプション価格を更新する", support_link: "サポートに連絡", }, }, }, status: { name: "サーバーの稼働状況", badge: { default: "クリックしてサーバーの稼働状況を表示", running: "実行中: %{command}。%{text}", status_ok: "サーバーはオンラインです", status_error: "ステータス: エラー", status_maintenance: "現在、システムはメンテナンスモードです。", status_process_running: "サーバーはコマンドを実行中です", status_checking: "サーバーステータスを確認中", }, category: { "Host Metrics": "ホストメトリクス", Network: "ネットワーク", HTTP: "HTTP", Matrix: "Matrix", }, status: "ステータス", error: "エラー", loading: "リアルタイムの稼働状況を取得しています — 少々お待ちください…", intro1: "これはサーバーのリアルタイム監視レポートです。詳しくは", intro2: "以下のチェック内容が気になる場合は、推奨される対処方法を", help: "ヘルプ", }, maintenance: { title: "現在、システムはメンテナンスモードです。", try_again: "しばらくしてからもう一度お試しください。", note: "この件についてサポートに連絡する必要はありません。すでに対応中です!", }, actions: { name: "サーバーのコマンド", available_title: "利用可能なコマンド", available_description: "以下のコマンドを実行できます。", available_help_intro: "各コマンドの詳細は", scheduled_title: "スケジュール済みコマンド", scheduled_description: "以下のコマンドは指定した時刻に実行されるようスケジュールされています。詳細を確認し、必要に応じて変更できます。", recurring_title: "繰り返しコマンド", recurring_description: "以下のコマンドは毎週、指定した曜日と時刻に実行されるよう設定されています。詳細を確認し、必要に応じて変更できます。", scheduled_help_intro: "このモードの詳細は", recurring_help_intro: "このモードの詳細は", maintenance_title: "現在、システムはメンテナンスモードです。", maintenance_try_again: "しばらくしてからもう一度お試しください。", maintenance_note: "この件についてサポートに連絡する必要はありません。すでに対応中です!", maintenance_commands_blocked: "メンテナンスモードが解除されるまでコマンドは実行できません。", table: { aria_label: "サーバーコマンド", command: "コマンド", description: "説明", arguments: "引数", is_recurring: "繰り返し", run_at: "実行(ローカル時間)", next_run_at: "次回実行(ローカル時間)", time_utc: "時刻(UTC)", time_local: "時刻(ローカル)", }, buttons: { create: "作成", update: "更新", back: "戻る", delete: "削除", run: "実行", }, command_scheduled: "コマンドを予約しました: %{command}", command_scheduled_args: "追加引数: %{args}", expect_prefix: "結果はまもなく", expect_suffix: "ページに表示されます。", notifications_link: "通知", command_help_title: "%{command} のヘルプ", scheduled_title_create: "スケジュール済みのコマンドを作成", scheduled_title_edit: "スケジュール済みのコマンドを編集", recurring_title_create: "繰り返し用のコマンドを作成", recurring_title_edit: "繰り返し用のコマンドを編集", scheduled_details_title: "スケジュール済みのコマンドの詳細", recurring_warning: "繰り返し用のコマンドから作成したスケジュール用のコマンドは、自動的に再生成されるため編集できません。代わりに繰り返し用のコマンドを編集してください。", command_details_intro: "コマンドの詳細", form: { id: "ID", command: "コマンド", scheduled_at: "予定時刻", day_of_week: "曜日", }, delete_scheduled_title: "スケジュール済みコマンドを削除", delete_recurring_title: "繰り返し用のコマンドを削除", delete_confirm: "コマンド %{command} を削除してもよろしいですか?", errors: { unknown: "不明なエラーが発生しました", delete_failed: "エラー: %{error}", }, days: { monday: "月曜日", tuesday: "火曜日", wednesday: "水曜日", thursday: "木曜日", friday: "金曜日", saturday: "土曜日", sunday: "日曜日", }, scheduled: { action: { create_success: "スケジュール済みコマンドを作成しました", update_success: "スケジュール済みコマンドを更新しました", update_failure: "エラーが発生しました", delete_success: "スケジュール済みコマンドを削除しました", delete_failure: "エラーが発生しました", }, }, recurring: { action: { create_success: "繰り返し用のコマンドを作成しました", update_success: "繰り返し用のコマンドを更新しました", update_failure: "エラーが発生しました", delete_success: "繰り返しコマンドを削除しました", delete_failure: "エラーが発生しました", }, }, }, notifications: { title: "通知", new_notifications: "新しい通知 %{smart_count} 件", no_notifications: "通知はまだありません", see_all: "すべての通知を見る", clear_all: "すべてクリア", ago: "前", advisory_tooltip: "通知を見逃した可能性があります。#news:etke.cc、etke.cc/news、またはメールもご確認ください。", unavailable_tooltip: "通知が利用できない場合があります。詳細はこちらをクリックしてください。", unavailable_title: "現在、通知が利用できない可能性があります", unavailable_body: "現在、このパネルにお届けできないお知らせがある可能性があります。または新しい情報がない場合もあります。見逃しがないよう、定期的にご確認ください:", unavailable_link_matrix: "Matrixルーム #news:etke.cc", unavailable_link_news: "etke.cc/news のお知らせページ", unavailable_link_email: "メールの受信箱(迷惑メールフォルダもご確認ください)", unavailable_retry: "再試行", }, currently_running: { command: "現在実行中:", started_ago: "(%{time} 前に開始)", }, time: { less_than_minute: "数秒", minutes: "%{smart_count} 分", hours: "%{smart_count} 時間", days: "%{smart_count} 日", weeks: "%{smart_count} 週間", months: "%{smart_count} か月", }, support: { name: "サポート", menu_label: "サポートに連絡", description: "サポートリクエストを開くか、既存のリクエストに情報を追加してください。チームが早急に対応します。", create_title: "新しいサポートリクエスト", no_requests: "サポートリクエストはありません。", no_messages: "メッセージはありません。", closed_message: "このリクエストは終了しました。まだ問題がある場合は、新しいリクエストを送信してください。", fields: { subject: "件名", message: "メッセージ", reply: "返信", status: "ステータス", created_at: "作成日", updated_at: "最終更新", }, status: { active: "オペレーターの対応待ち", open: "オープン", closed: "終了", pending: "あなたの返答待ち", }, buttons: { new_request: "新しいリクエスト", submit: "送信", cancel: "キャンセル", send: "送信", back: "サポートに戻る", attach_files: "ファイルを添付", }, helper: { loading: "サポートリクエストを読み込んでいます…", reply_hint: "Ctrl+Enterで送信", reply_placeholder: "できる限り詳細な情報を記入してください。", before_contact_title: "お問い合わせの前に", help_pages_prompt: "まずヘルプページをご確認ください:", services_prompt: "提供するサービスはサービスページに記載されたもののみです:", topics_prompt: "対応できるのは対応トピックのみです:", scope_confirm_label: "ヘルプページを確認し、この依頼が対応トピックに該当することを確認しました。", english_only_notice: "サポートは英語のみで提供されます。", response_time_prompt: "48時間以内に回答します。より早い対応が必要な場合は、こちらをご覧ください:", attachments_limit: "最大5ファイル、各5MB、合計10MBまで。", close_request_label: "送信後にこのリクエストを閉じる", }, actions: { create_success: "サポートリクエストを作成しました。", create_failure: "サポートリクエストを作成できませんでした。", send_failure: "メッセージを送信できませんでした。", attachment_too_large: "ファイル「%{name}」は5MBの上限を超えています。", too_many_attachments: "添付ファイルは最大5件までです。", total_size_exceeded: "添付ファイルの合計サイズが10MBを超えています。", }, }, }, }; export default common; ================================================ FILE: src/i18n/ja/index.ts ================================================ import { SynapseTranslationMessages } from "../types"; import common from "./common"; import mas from "./mas"; import misc_resources from "./misc_resources"; import reports from "./reports"; import rooms from "./rooms"; import users from "./users"; const ja: SynapseTranslationMessages = { ra: common.ra, ketesa: common.ketesa, import_users: common.import_users, delete_media: common.delete_media, purge_remote_media: common.purge_remote_media, etkecc: common.etkecc, resources: { users, rooms, reports, scheduled_tasks: misc_resources.scheduled_tasks, connections: misc_resources.connections, devices: misc_resources.devices, users_media: misc_resources.users_media, protect_media: misc_resources.protect_media, quarantine_media: misc_resources.quarantine_media, pushers: misc_resources.pushers, servernotices: misc_resources.servernotices, database_room_statistics: misc_resources.database_room_statistics, user_media_statistics: misc_resources.user_media_statistics, forward_extremities: misc_resources.forward_extremities, room_state: misc_resources.room_state, room_media: misc_resources.room_media, room_directory: misc_resources.room_directory, destinations: misc_resources.destinations, registration_tokens: misc_resources.registration_tokens, account_data: misc_resources.account_data, joined_rooms: misc_resources.joined_rooms, memberships: misc_resources.memberships, room_members: misc_resources.room_members, destination_rooms: misc_resources.destination_rooms, ...mas, }, }; export default ja; ================================================ FILE: src/i18n/ja/mas.ts ================================================ const mas = { mas_users: { name: "MASユーザー |||| MASユーザー", fields: { id: "MAS ID", username: "ユーザー名", admin: "管理者", locked: "ロック済み", deactivated: "無効化済み", legacy_guest: "レガシーゲスト", created_at: "作成日時", locked_at: "ロック日時", deactivated_at: "無効化日時", }, filter: { status: "状態", search: "検索", status_active: "アクティブ", status_locked: "ロック済み", status_deactivated: "無効化済み", }, action: { lock: { label: "ロック", success: "ユーザーをロックしました" }, unlock: { label: "ロック解除", success: "ユーザーのロックを解除しました" }, deactivate: { label: "無効化", success: "ユーザーを無効化しました" }, reactivate: { label: "再有効化", success: "ユーザーを再有効化しました" }, set_admin: { label: "管理者権限を付与", success: "管理者ステータスを更新しました" }, remove_admin: { label: "管理者権限を削除", success: "管理者ステータスを更新しました" }, set_password: { label: "パスワードを設定", title: "パスワードを設定", success: "パスワードを設定しました", failure: "パスワードの設定に失敗しました", }, }, }, mas_user_emails: { name: "メールアドレス |||| メールアドレス", empty: "メールアドレスなし", fields: { email: "メールアドレス", user_id: "ユーザーID", created_at: "作成日時", actions: "操作", }, action: { remove: { label: "削除", title: "メールアドレスを削除", content: "%{email}を削除しますか?", success: "メールアドレスを削除しました", }, create: { success: "メールアドレスを追加しました" }, }, }, mas_compat_sessions: { name: "互換セッション |||| 互換セッション", empty: "互換セッションはありません", fields: { user_id: "ユーザーID", device_id: "デバイスID", created_at: "作成日時", user_agent: "ユーザーエージェント", last_active_at: "最終アクティブ", last_active_ip: "最終IP", finished_at: "終了日時", human_name: "名前", active: "アクティブ", }, action: { finish: { label: "終了", title: "このセッションを終了してよろしいですか?", content: "このセッションを終了します。", success: "セッションを終了しました", }, }, }, mas_oauth2_sessions: { name: "OAuth2セッション |||| OAuth2セッション", empty: "OAuth2セッションはありません", fields: { user_id: "ユーザーID", client_id: "クライアントID", scope: "スコープ", created_at: "作成日時", user_agent: "ユーザーエージェント", last_active_at: "最終アクティブ", last_active_ip: "最終IP", finished_at: "終了日時", human_name: "名前", active: "アクティブ", }, action: { finish: { label: "終了", title: "このセッションを終了してよろしいですか?", content: "このセッションを終了します。", success: "セッションを終了しました", }, }, }, mas_policy_data: { name: "ポリシーデータ", current_policy: "現在のポリシー", no_policy: "現在ポリシーが設定されていません。", set_policy: "新しいポリシーを設定", invalid_json: "無効なJSON", fields: { json_placeholder: "ポリシーデータをJSONで入力…", created_at: "作成日時", }, action: { save: { label: "ポリシーを設定", success: "ポリシーを更新しました", failure: "ポリシーの更新に失敗しました", }, }, }, mas_user_sessions: { name: "ブラウザセッション |||| ブラウザセッション", fields: { user_id: "ユーザーID", created_at: "作成日時", finished_at: "終了日時", user_agent: "ユーザーエージェント", last_active_at: "最終アクティブ", last_active_ip: "最終IP", active: "アクティブ", }, action: { finish: { label: "終了", title: "このセッションを終了してよろしいですか?", content: "このブラウザセッションを終了します。", success: "セッションを終了しました", }, }, }, mas_upstream_oauth_links: { name: "上流OAuthリンク |||| 上流OAuthリンク", fields: { user_id: "ユーザーID", provider_id: "プロバイダーID", subject: "サブジェクト", human_account_name: "アカウント名", created_at: "作成日時", }, helper: { provider_id: "上流OAuthプロバイダーのID。上流OAuthプロバイダーの一覧で確認できます。", }, action: { remove: { label: "削除", title: "OAuthリンクを削除しますか?", content: "このユーザーの上流OAuthリンクが削除されます。", success: "OAuthリンクを削除しました", }, }, }, mas_upstream_oauth_providers: { name: "OAuthプロバイダー |||| OAuthプロバイダー", fields: { issuer: "発行者", human_name: "名前", brand_name: "ブランド", created_at: "作成日時", disabled_at: "無効化日時", enabled: "有効", }, }, mas_personal_sessions: { name: "個人セッション |||| 個人セッション", empty: "個人セッションはありません", fields: { owner_user_id: "所有者ID", actor_user_id: "ユーザー", human_name: "名前", scope: "スコープ", created_at: "作成日時", revoked_at: "失効日時", last_active_at: "最終アクティブ", last_active_ip: "最終IP", expires_at: "有効期限", expires_in: "有効期限(秒)", active: "アクティブ", }, helper: { expires_in: "省略可能。トークンの有効期限(秒)。空欄の場合は無期限。", }, action: { revoke: { label: "失効", title: "セッションを失効させますか?", content: "アクセストークンが永久に失効します。", success: "セッションを失効させました", }, create: { token_title: "アクセストークンが作成されました", token_content: "このトークンをコピーしてください。このダイアログを閉じると二度と表示されません。", }, }, }, mas_sessions: { status: { active: "アクティブ", finished: "終了", revoked: "失効", }, }, }; export default mas; ================================================ FILE: src/i18n/ja/misc_resources.ts ================================================ const misc_resources = { scheduled_tasks: { name: "スケジュールされたタスク |||| スケジュールされたタスク", fields: { id: "ID", action: "アクション", status: "ステータス", timestamp: "タイムスタンプ", resource_id: "リソースID", result: "結果", error: "エラー", max_timestamp: "この日付より前", }, status: { scheduled: "スケジュール済み", active: "実行中", complete: "完了", cancelled: "キャンセル済み", failed: "失敗", }, }, connections: { name: "接続", fields: { last_seen: "日時", ip: "IPアドレス", user_agent: "ユーザーエージェント", }, }, devices: { name: "端末", fields: { device_id: "端末のID", display_name: "端末の名称", last_seen_ts: "タイムスタンプ", last_seen_ip: "IPアドレス", last_seen_user_agent: "ユーザーエージェント", dehydrated: "デハイドレート", }, action: { erase: { title: "%{id}を削除", title_bulk: "%{smart_count} 件のデバイスを削除", content: "「%{name}」を削除してよろしいですか?", content_bulk: "%{smart_count} 件のデバイスを削除しますか?", success: "端末を削除しました。", failure: "エラーが発生しました。", }, display_name: { success: "端末の名称を更新しました", failure: "端末の名称の更新に失敗しました", }, create: { label: "端末を作成", title: "新しい端末を作成", success: "端末を作成しました", failure: "端末の作成に失敗しました", }, }, }, users_media: { name: "メディアファイル", fields: { media_id: "メディアのID", media_length: "ファイルの大きさ(バイト数)", media_type: "種類", upload_name: "ファイル名", quarantined_by: "検疫の実行者", safe_from_quarantine: "検疫で保護", created_ts: "作成日時", last_access_ts: "最終アクセス", }, action: { open: "メディアファイルを新しいウィンドウで開く", }, }, protect_media: { action: { create: "保護する", delete: "保護解除", none: "未保護", send_success: "保護に関する状態を変更しました。", send_failure: "エラーが発生しました。", }, }, quarantine_media: { action: { name: "検疫", create: "検疫", delete: "検疫解除", none: "検疫済", send_success: "検疫に関する状態を変更しました。", send_failure: "エラーが発生しました: %{error}", }, }, pushers: { name: "プッシュ", fields: { app: "アプリケーション", app_display_name: "アプリケーションの名称", app_id: "アプリケーションのID", device_display_name: "端末の名称", kind: "種類", lang: "言語", profile_tag: "プロフィールのタグ", pushkey: "プッシュ鍵", data: { url: "URL" }, }, }, servernotices: { name: "サーバーの告知", send: "サーバーの告知を送信", fields: { body: "メッセージ", }, action: { send: "告知を送信", send_success: "サーバーの告知を送信しました。", send_failure: "エラーが発生しました。", }, helper: { send: "サーバーの告知を指定したユーザーに送信。「サーバーの告知」機能がサーバーで有効になっている必要があります。", }, }, database_room_statistics: { name: "データベースルーム統計", fields: { room_id: "ルームID", estimated_size: "推定サイズ", }, helper: { info: "Synapse データベース内の各ルームが使用する推定ディスク容量を表示します。数値は概算です。", }, }, user_media_statistics: { name: "メディアファイル", fields: { media_count: "メディア数", media_length: "メディアの大きさ", }, }, forward_extremities: { name: "転送末端", fields: { id: "イベントのID", received_ts: "タイムスタンプ", depth: "深さ", state_group: "ステートのグループ", }, }, room_state: { name: "ステートイベント", fields: { type: "種類", content: "内容", origin_server_ts: "送信日時", sender: "送信元", }, }, room_media: { name: "メディア", fields: { media_id: "メディアのID", }, helper: { info: "ルームにアップロードされたメディアファイルの一覧です。外部のリポジトリにアップロードされたメディアファイルは削除できません。", }, action: { error: "%{errcode} (%{errstatus}) %{error}", }, }, room_directory: { name: "ルームのディレクトリー", fields: { world_readable: "ゲストユーザーは参加せず閲覧可", guest_can_join: "ゲストユーザーが参加可能", }, action: { title: "ルームをディレクトリーから削除 |||| %{smart_count}個のルームをディレクトリーから削除", content: "このルームをディレクトリーから削除してよろしいですか? |||| %{smart_count}個のルームをディレクトリーから削除してよろしいですか?", erase: "ルームをディレクトリーから削除", create: "ルームをディレクトリーで公開", send_success: "ルームを公開しました。", send_failure: "エラーが発生しました。", }, }, destinations: { name: "フェデレーション", fields: { destination: "接続先", failure_ts: "失敗した時点のタイムスタンプ", retry_last_ts: "最後に試行した時点のタイムスタンプ", retry_interval: "再試行までの間隔", last_successful_stream_ordering: "最後に成功したストリーム", stream_ordering: "ストリーム", }, action: { reconnect: "再接続" }, }, registration_tokens: { name: "登録トークン", fields: { token: "トークン", valid: "有効なトークン", uses_allowed: "使用が許可", pending: "保留中", completed: "完了", expiry_time: "期限切れとなる日時", length: "長さ", created_at: "作成日時", last_used_at: "最終使用日時", revoked_at: "失効日時", }, helper: { length: "トークンが与えられていない場合のトークンの長さ。" }, action: { revoke: { label: "失効", success: "トークンを失効しました", }, unrevoke: { label: "復元", success: "トークンが復元されました", }, }, }, account_data: { name: "アカウントのデータ", }, joined_rooms: { name: "参加中のルーム", }, memberships: { name: "メンバーシップ", }, room_members: { name: "メンバー", }, destination_rooms: { name: "ルーム", }, }; export default misc_resources; ================================================ FILE: src/i18n/ja/reports.ts ================================================ const reports = { name: "報告されたイベント", fields: { id: "ID", received_ts: "報告日時", user_id: "報告者", name: "ルーム名", score: "点数", reason: "理由", event_id: "イベントのID", sender: "送信者", }, action: { erase: { title: "報告されたイベントを削除", content: "報告されたイベントを削除してよろしいですか?これは取り消せません。", }, event_lookup: { label: "イベント検索", title: "IDでイベントを取得", fetch: "取得", }, fetch_event_error: "イベントの取得に失敗しました", }, }; export default reports; ================================================ FILE: src/i18n/ja/rooms.ts ================================================ const rooms = { name: "ルーム", fields: { room_id: "ルームのID", name: "名称", canonical_alias: "エイリアス", joined_members: "メンバー", joined_local_members: "ローカルのメンバー", joined_local_devices: "ローカルの端末", state_events: "ステートイベント / 複雑さ", version: "バージョン", is_encrypted: "暗号化", encryption: "暗号化", federatable: "フェデレーションに対応", public: "ルームディレクトリーに表示", creator: "作成者", join_rules: "参加のルール", guest_access: "ゲストによるアクセス", history_visibility: "履歴の見え方", topic: "トピック", avatar: "アバター", actions: "アクション", }, filter: { public_rooms: "公開ルーム", empty_rooms: "空のルーム", local_members_only: "ローカルメンバーのみ", }, helper: { forward_extremities: "転送末端(forward extremities)は、ルーム内の有向非巡回グラフ(DAG)の終端にあるイベント、つまり子をもたないイベントのことをいいます。この数が多いほど、Synapseが実行しなければならないステート解決(これは負荷の大きい作業です)の数も多くなります。Synapseには末端の数が増えすぎないよう制御する仕組みが備わっていますが、バグによって再び増加することがあります。もしルームに10個以上の転送末端がある場合は、原因を調査し、#1760 で参照されているSQLクエリーを使って削除することを検討してください。", }, enums: { join_rules: { public: "公開", knock: "ノック", invite: "招待", private: "非公開", restricted: "制限付き", }, guest_access: { can_join: "ゲスト参加可", forbidden: "ゲスト参加不可", }, history_visibility: { invited: "招待以後", joined: "参加以後", shared: "共有以後", world_readable: "制限なし", }, unencrypted: "非暗号化", room_type: { room: "ルーム", space: "スペース", }, }, action: { erase: { title: "ルームの削除", content: "ルームを削除してよろしいですか? これは取り消せません。ルームのメッセージとメディアファイルはサーバーから削除されます!", fields: { block: "ユーザーがルームに参加できないように設定", }, in_progress: "削除中…", background_note: "このウィンドウを閉じても、削除はバックグラウンドで続行されます。", success: "ルームを削除しました。", failure: "ルームを削除できませんでした。", }, make_admin: { assign_admin: "管理者を任命", title: "%{roomName}のルームの管理者を任命", confirm: "管理者にする", content: "管理者に任命するユーザーのMXIDを入力してください。\n注意:これが機能するには、ルームに管理者権限を持つローカルメンバーが少なくとも1人いる必要があります。", success: "ユーザーをルームの管理者に設定しました。", failure: "ユーザーをルームの管理者に設定できませんでした。%{errMsg}", }, join: { label: "ユーザーを追加", title: "%{roomName} にユーザーを追加", confirm: "追加", content: "このルームに参加させるユーザーのMXIDを入力してください。\n注意:ルームに参加しており、ユーザーを招待する権限が必要です。", success: "ユーザーをルームに追加しました。", failure: "ユーザーをルームに追加できませんでした。%{errMsg}", }, block: { label: "ブロック", title: "%{room} をブロック", title_bulk: "%{smart_count} 件のルームをブロック", title_by_id: "ルームをブロック", content: "ユーザーはこのルームに参加できなくなります。", content_bulk: "ユーザーは %{smart_count} 件のルームに参加できなくなります。", success: "ルームをブロックしました。", failure: "ルームをブロックできませんでした。", }, unblock: { label: "ブロックを解除", success: "ルームのブロックを解除しました。", failure: "ルームのブロックを解除できませんでした。", }, purge_history: { label: "履歴を削除", title: "%{roomName} の履歴を削除", content: "選択した日付より前のすべてのイベントがデータベースから削除されます。ルームの状態(参加、退出、トピック)は常に保持されます。少なくとも1つのメッセージは常に保持されます。\n注意:大きなルームではこの操作に数分かかる場合があります。", date_label: "この日付より前のイベントを削除", delete_local: "ローカルユーザーが送信したイベントも削除する", in_progress: "削除処理中…", background_note: "このウィンドウを閉じても問題ありません。削除処理はバックグラウンドで続行されます。", success: "ルームの履歴を削除しました。", failure: "ルームの履歴を削除できませんでした。%{errMsg}", }, quarantine_all: { label: "すべてのメディアを検疫", title: "%{roomName} のすべてのメディアを検疫", content: "このルームのすべてのローカルおよびリモートメディアを検疫します。検疫されたメディアはユーザーからアクセスできなくなります。", success: "%{smart_count} 件のメディアを検疫しました。", failure: "メディアの検疫を行えませんでした。%{errMsg}", }, delete_all_media: { label: "すべてのメディアを削除", title: "%{roomName} のすべてのメディアを削除", content: "このルームのすべてのローカルメディアが完全に削除されます。暗号化されていないルームのローカルメディアのみが対象です — 他のサーバーのリモートメディアは除外されます。この操作は元に戻せません。", in_progress_loading: "メディアリストを取得中…", in_progress: "メディアを削除中… (%{current} / %{total})", do_not_close: "このダイアログを閉じないでください — 削除はフォアグラウンドで実行中であり、閉じると中断されます。", success: "%{smart_count} 件のメディアを正常に削除しました。 |||| %{smart_count} 件のメディアを正常に削除しました。", failure: "メディアの削除に失敗しました。%{errMsg}", }, delete_all_media_bulk: { title: "%{smart_count} 件のルームのすべてのメディアを削除しますか? |||| %{smart_count} 件のルームのすべてのメディアを削除しますか?", content: "選択したルームのすべてのローカルメディアが完全に削除されます(暗号化されていないルームのみ)。他のサーバーのリモートメディアは除外されます。この操作は元に戻せません。", success: "%{total} 件のうち %{success} 件のルームのメディアを削除しました。", partial_failure: "%{total} 件のうち %{success} 件のルームのメディアを削除しました。%{failed} 件は失敗しました。", }, event_context: { lookup_title: "イベント ID で検索", jump_to_date: "日付にジャンプ", direction: "方向", forward: "前方", backward: "後方", target_event: "対象イベント", events_before: "以前のイベント", events_after: "以降のイベント", not_found: "指定した日時のイベントが見つかりませんでした", failure: "イベントのコンテキストを取得できませんでした", }, messages: { load_older: "古いメッセージを読み込む", load_newer: "新しいメッセージを読み込む", no_messages: "このルームにメッセージはありません", failure: "メッセージの読み込みに失敗しました", filter: "フィルター", filter_type: "イベントタイプ", filter_sender: "送信者", advanced_filters: "詳細フィルター", filter_not_type: "イベントタイプを除外", filter_not_sender: "送信者を除外", contains_url: "URLを含む", any: "すべて", with_url: "URLありのみ", without_url: "URLなしのみ", apply_filter: "適用", clear_filters: "クリア", }, hierarchy: { load_more: "さらに読み込む", max_depth: "最大深度", unlimited: "無制限", refresh: "更新", members: "%{count}人のメンバー", space: "スペース", room: "ルーム", suggested: "おすすめ", no_children: "このルームには子ルームがありません", failure: "階層の読み込みに失敗しました", }, }, }; export default rooms; ================================================ FILE: src/i18n/ja/users.ts ================================================ const users = { name: "ユーザー", email: "メールアドレス", msisdn: "電話番号", threepid: "メールアドレスまたは電話番号", membership: "メンバーシップ |||| メンバーシップ", fields: { avatar: "アバター", id: "ユーザーID", name: "名前", is_guest: "ゲスト", admin: "サーバーの管理者", locked: "ロック", suspended: "停止", shadow_banned: "シャドウバン", deactivated: "無効化", erased: "消去", show_guests: "ゲストを表示", show_deactivated: "無効化されたユーザーを表示", show_locked: "ロックされたユーザーを表示", filter_user_all: "すべて", filter_deactivated_false: "有効", filter_deactivated_true: "無効化済み", filter_locked_false: "ロックを除外", filter_locked_true: "ロックを含む", filter_guests_false: "ゲストを除外", filter_guests_true: "ゲストを含む", show_system_users: "システムユーザーを表示", filter_system_users_false: "システムユーザーを除外", filter_system_users_true: "システムユーザーのみ", show_suspended: "停止されたユーザーを表示", show_shadow_banned: "シャドウバンされたユーザーを表示", user_id: "ユーザーを検索", displayname: "表示名", password: "パスワード", avatar_url: "アバターのURL", avatar_src: "アバター", medium: "種別", threepids: "サードパーティーのID", address: "アドレス", creation_ts_ms: "作成日時", consent_version: "同意のバージョン", sent_invite_count: "送信した招待数", cumulative_joined_room_count: "累計参加ルーム数", auth_provider: "プロバイダー", user_type: "ユーザーの種類", }, helper: { password: "パスワードを変更すると、全てのセッションからログアウトします。", password_required_for_reactivation: "アカウントを再度有効にするにはパスワードを設定する必要があります", create_password: "以下のボタンで強力なパスワードを生成できます。", lock: "ユーザーにアカウントを使用できないよう設定。これは後から取り消せます。", deactivate: "アカウントを再度有効にするにはパスワードを設定する必要があります。", suspend: "ユーザーを停止すると、ユーザーは読み取り専用のモードに設定されます。", shadow_ban: "シャドウBANされたユーザーは通常の応答を受け取りますが、イベントは他のユーザーやルームに伝播しません。最終手段としてのみ使用してください。", erase: "ユーザーをGDPRに準拠した形で消去", admin: "サーバーの管理者は、サーバーとユーザーに対して完全な管理権限を持ちます。", erase_text: "ユーザーが送信したメッセージは、メッセージが送信された時点にルームに参加していたユーザーは今後もこれを閲覧できますが、その後で参加したユーザーには表示されません。", erase_admin_error: "自分自身のユーザーは削除できません。", modify_managed_user_error: "システムが管理しているユーザーは変更できません。", username_available: "ユーザー名を利用できます", sent_invite_count: "このユーザーが全ルームで送信した招待の合計数。", cumulative_joined_room_count: "このユーザーが参加したことのあるルームの累計数(退出、追放されたルームを含む)。", }, action: { erase: "ユーザーのデータを消去", erase_avatar: "アバターを消去", delete_media: "このユーザーがアップロードしたメディアファイルを削除", redact_events: "このユーザーが送信したイベントを削除", redact_in_progress: "イベントの削除処理中\u2026", redact_background_note: "このウィンドウを閉じても問題ありません。削除処理はバックグラウンドで続行されます。", redact_success: "すべてのイベントを削除しました。", redact_failure: "%{smart_count} 件のイベントの削除に失敗しました。", generate_password: "パスワードを生成", reset_password: { label: "パスワードをリセット", title: "パスワードをリセット", helper: "%{user} のパスワードを変更", password: "パスワード", logout_devices: "すべてのデバイスからログアウト", success: "パスワードをリセットしました", failure: "パスワードをリセットできませんでした", error_no_password: "パスワードを指定してください", }, login_as: { label: "ユーザーとしてログイン", title: "ユーザーとしてログイン", helper: "%{user} として認証するためのアクセストークンを取得します。この操作ではユーザーの新しいデバイスが登録されないため、デバイス/セッションの一覧には表示されず、対象のユーザーは誰かが自分として認証したことに気づきません。", valid_until: "有効期限を設定", success: "アクセストークンを作成しました", failure: "アクセストークンを作成できませんでした", result_title: "%{user} のアクセストークン", access_token: "アクセストークン", expires_at: "このアクセストークンは %{date} に期限切れになります", }, overwrite_title: "注意!", overwrite_content: "このユーザー名はすでに取得されています。既存のユーザーを上書きしてもよろしいですか?", overwrite_cancel: "キャンセル", overwrite_confirm: "上書きする", quarantine_all: { label: "すべてのメディアを検疫", title: "%{userName} のすべてのメディアを検疫", content: "このユーザーがアップロードしたすべてのローカルメディアを検疫します。検疫されたメディアは他のユーザーからアクセスできなくなります。", success: "%{smart_count} 件のメディアを検疫しました。", failure: "メディアの検疫を行えませんでした。%{errMsg}", }, delete_all_media: { label: "すべてのメディアを削除", title: "%{userName} のすべてのメディアを削除", content: "このユーザーがアップロードしたすべてのメディアが完全に削除されます。この操作は元に戻せません。", in_progress: "メディアを削除中…", background_note: "このダイアログを安全に閉じることができます — 削除はバックグラウンドで続行されます。", success: "%{smart_count} 件のメディアを正常に削除しました。 |||| %{smart_count} 件のメディアを正常に削除しました。", failure: "メディアの削除に失敗しました。%{errMsg}", }, delete_all_media_bulk: { title: "%{smart_count} 人のユーザーのすべてのメディアを削除しますか? |||| %{smart_count} 人のユーザーのすべてのメディアを削除しますか?", content: "選択したユーザーがアップロードしたすべてのメディアが完全に削除されます。この操作は元に戻せません。", success: "%{total} 人のうち %{success} 人のメディアを削除しました。", partial_failure: "%{total} 人のうち %{success} 人のメディアを削除しました。%{failed} 人は失敗しました。", }, allow_cross_signing: { label: "Cross-Signingリセットを許可", title: "Cross-Signingキーの置き換えを許可", content: "%{user} がユーザー対話型認証なしにCross-Signingキーを置き換えることを許可しますか?これにより、キーを置き換えられる一時的なウィンドウが作成されます。", success: "Cross-Signingキーの置き換えを%{deadline}まで許可しました", failure: "Cross-Signing置き換えの許可に失敗しました", no_key: "ユーザーにはマスターCross-Signingキーがありません", }, find_user: { label: "ユーザーを検索", title: "ユーザーを検索", lookup_type: "検索タイプ", by_threepid: "メール / 電話番号で検索", by_auth_provider: "認証プロバイダーで検索", provider: "認証プロバイダーID", external_id: "外部ID", search: "検索", not_found: "ユーザーが見つかりません", failure: "ユーザーの検索に失敗しました", }, renew_account: { label: "アカウントを更新", title: "アカウントの有効期限を更新", content: "%{user} のアカウント有効期限を更新します。任意でカスタムの有効期限日を設定できます。空白のままにすると、サーバーのデフォルト更新期間が使用されます。", expiration: "有効期限日", expiration_helper: "サーバーのデフォルト更新期間を使用するには空白のままにしてください", renewal_emails: "更新通知メールを送信する", success: "アカウントの有効期限を %{date} まで更新しました", failure: "アカウントの有効期限の更新に失敗しました", }, system_users_scan_in_progress: "少々お待ちください。該当するユーザーをまだ検索中です。まもなくページが読み込まれます。", reverse_search_scan_in_progress: "少々お待ちください。除外するユーザーを検索するためにすべてのユーザーをスキャン中です。まもなくページが読み込まれます。", }, badge: { you: "あなた", bot: "ボット", admin: "管理者", support: "サポート", regular: "一般ユーザー", federated: "フェデレーションユーザー", system_managed: "システム管理", }, limits: { messages_per_second: "毎秒のメッセージ数", messages_per_second_text: "毎秒ごとに実行できるアクションの数。", burst_count: "バースト数", burst_count_text: "レート制限が適用されるまでに実行できるアクションの数。", }, account_data: { title: "アカウントのデータ", global: "グローバル", rooms: "ルーム", }, }; export default users; ================================================ FILE: src/i18n/pt/base.ts ================================================ import type { TranslationMessages } from "ra-core"; const portugueseMessages: TranslationMessages = { ra: { action: { add_filter: "Adicionar filtro", add: "Adicionar", back: "Voltar", bulk_actions: "1 item selecionado |||| %{smart_count} itens selecionados", cancel: "Cancelar", clear_array_input: "Limpar lista", clear_input_value: "Limpar campo", clone: "Duplicar", confirm: "Confirmar", create: "Criar", create_item: "Criar %{item}", delete: "Eliminar", edit: "Editar", export: "Exportar", list: "Listar", refresh: "Atualizar", remove_filter: "Remover filtro", remove_all_filters: "Remover todos os filtros", remove: "Remover", reset: "Repor", save: "Guardar", search: "Pesquisar", search_columns: "Pesquisar colunas", select_all: "Selecionar tudo", select_all_button: "Selecionar todos", select_row: "Selecionar linha", show: "Ver", sort: "Ordenar", undo: "Desfazer", unselect: "Desselecionar", expand: "Expandir", close: "Fechar", open_menu: "Abrir menu", close_menu: "Fechar menu", update: "Atualizar", move_up: "Mover para cima", move_down: "Mover para baixo", open: "Abrir", toggle_theme: "Alternar tema", select_columns: "Colunas", update_application: "Atualizar aplicação", }, boolean: { true: "Sim", false: "Não", null: " ", }, page: { create: "Criar %{name}", dashboard: "Painel de controlo", edit: "%{name} %{recordRepresentation}", error: "Ocorreu um erro", list: "%{name}", loading: "A carregar", not_found: "Não encontrado", show: "%{name} %{recordRepresentation}", empty: "Ainda não há %{name}.", invite: "Criar novo?", access_denied: "Acesso negado", authentication_error: "Erro de autenticação", }, input: { file: { upload_several: "Arraste ficheiros para aqui ou clique para os selecionar.", upload_single: "Arraste um ficheiro para aqui ou clique para o selecionar.", }, image: { upload_several: "Arraste imagens para aqui ou clique para as selecionar.", upload_single: "Arraste uma imagem para aqui ou clique para a selecionar.", }, references: { all_missing: "Não foi possível encontrar os dados das referências.", many_missing: "Pelo menos uma das referências já não está disponível.", single_missing: "A referência indicada já não parece estar disponível.", }, password: { toggle_visible: "Ocultar palavra-passe", toggle_hidden: "Mostrar palavra-passe", }, }, message: { about: "Acerca de", access_denied: "Não tem as permissões necessárias para aceder a esta página.", are_you_sure: "Tem a certeza?", authentication_error: "O servidor de autenticação devolveu um erro e as suas credenciais não puderam ser verificadas.", auth_error: "Ocorreu um erro ao validar o token de autenticação.", bulk_delete_content: "Tem a certeza de que pretende eliminar %{name}? |||| Tem a certeza de que pretende eliminar estes %{smart_count} itens?", bulk_delete_title: "Eliminar %{name} |||| Eliminar %{smart_count} %{name}", bulk_update_content: "Tem a certeza de que pretende atualizar %{name} %{recordRepresentation}? |||| Tem a certeza de que pretende atualizar %{smart_count} itens?", bulk_update_title: "Atualizar %{name} %{recordRepresentation} |||| Atualizar %{smart_count} %{name}", clear_array_input: "Tem a certeza de que pretende limpar toda a lista?", delete_content: "Tem a certeza de que pretende eliminar este item?", delete_title: "Eliminar %{name} %{recordRepresentation}", details: "Detalhes", error: "Ocorreu um erro e o seu pedido não pôde ser processado.", invalid_form: "O formulário é inválido. Verifique os campos com erros.", loading: "Por favor aguarde", no: "Não", not_found: "Introduziu um URL inválido ou seguiu um link incorreto.", select_all_limit_reached: "Existem demasiados itens para selecionar todos. Foram selecionados apenas os primeiros %{max}.", unsaved_changes: "Algumas alterações não foram guardadas. Tem a certeza de que as pretende descartar?", yes: "Sim", placeholder_data_warning: "Problema de rede: falha ao atualizar os dados.", }, navigation: { clear_filters: "Remover todos os filtros", no_filtered_results: "Sem resultados", no_results: "Nenhum resultado encontrado.", no_more_results: "A página %{page} está fora dos limites. Tente a página anterior.", page_out_of_boundaries: "A página %{page} está fora dos limites.", page_out_from_end: "Não é possível avançar após a última página.", page_out_from_begin: "Não é possível recuar antes da primeira página.", page_range_info: "%{offsetBegin}-%{offsetEnd} de %{total}", partial_page_range_info: "%{offsetBegin}-%{offsetEnd} de mais de %{offsetEnd}", current_page: "Página %{page}", page: "Ir para a página %{page}", first: "Ir para a primeira página", last: "Ir para a última página", next: "Ir para a página seguinte", previous: "Ir para a página anterior", page_rows_per_page: "Linhas por página:", skip_nav: "Saltar para o conteúdo", }, sort: { sort_by: "Ordenar por %{field_lower_first} %{order}", ASC: "Ascendente", DESC: "Descendente", }, auth: { auth_check_error: "Inicie sessão para continuar", user_menu: "Perfil", username: "Utilizador", password: "Palavra-passe", email: "E-mail", sign_in: "Entrar", sign_in_error: "Falha na autenticação, tente novamente.", logout: "Sair", }, notification: { updated: "Item atualizado |||| %{smart_count} itens atualizados", created: "Item criado", deleted: "Item eliminado |||| %{smart_count} itens eliminados", bad_item: "Item incorreto", item_doesnt_exist: "Este item já não existe", http_error: "Falha na comunicação com o servidor", data_provider_error: "Erro no fornecedor de dados. Consulte a consola para mais detalhes.", i18n_error: "Não foi possível carregar as traduções para o idioma selecionado", canceled: "Ação cancelada", logged_out: "A sua sessão expirou. Por favor, volte a ligar-se.", not_authorized: "Não tem permissão para aceder a este recurso.", application_update_available: "Está disponível uma nova versão.", offline: "Sem ligação. Não foi possível obter dados.", }, validation: { required: "Obrigatório", minLength: "Deve ter pelo menos %{min} caracteres", maxLength: "Deve ter no máximo %{max} caracteres", minValue: "Deve ser %{min} ou superior", maxValue: "Deve ser %{max} ou inferior", number: "Deve ser um número", email: "Deve ser um endereço de e-mail válido", oneOf: "Deve ser uma das seguintes opções: %{options}", regex: "Deve corresponder ao formato (expressão regular): %{pattern}", unique: "Deve ser único", }, saved_queries: { label: "Pesquisas guardadas", query_name: "Nome da pesquisa", new_label: "Guardar pesquisa atual...", new_dialog_title: "Guardar pesquisa atual como", remove_label: "Eliminar pesquisa guardada", remove_label_with_name: 'Eliminar pesquisa "%{name}"', remove_dialog_title: "Eliminar pesquisa guardada?", remove_message: "Tem a certeza de que pretende eliminar esta pesquisa da lista de pesquisas guardadas?", help: "Filtre a lista e guarde esta pesquisa para mais tarde", }, guesser: { empty: { title: "Sem dados para mostrar", message: "Por favor verifique o seu fornecedor de dados", }, }, configurable: { customize: "Personalizar", configureMode: "Personalizar esta página", inspector: { title: "Inspetor", content: "Passe o cursor sobre os elementos da interface para os configurar", reset: "Repor definições", hideAll: "Ocultar tudo", showAll: "Mostrar tudo", }, Datagrid: { title: "Grelha de dados", unlabeled: "Coluna sem nome #%{column}", }, SimpleForm: { title: "Formulário", unlabeled: "Campo sem nome #%{input}", }, SimpleList: { title: "Lista", primaryText: "Texto primário", secondaryText: "Texto secundário", tertiaryText: "Texto terciário", }, }, }, }; export default portugueseMessages; ================================================ FILE: src/i18n/pt/common.ts ================================================ import portugueseMessages from "./base"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const common: Record = { ...portugueseMessages, ketesa: { auth: { base_url: "URL do homeserver", welcome: "Bem-vindo ao %{name}", description: "A evolução do Synapse Admin. Faça a gestão, monitorize e mantenha o seu servidor Matrix a partir de uma interface limpa. Concebido tanto para pequenos servidores privados como para grandes comunidades federadas.", server_version: "Versão do Synapse", supports_specs: "Suporta especificações Matrix", username_error: "Introduza o ID de utilizador completo: '@utilizador:dominio'", protocol_error: "O URL deve começar com 'http://' ou 'https://'", url_error: "URL de servidor Matrix inválido", sso_sign_in: "Entrar com SSO", credentials: "Credenciais", access_token: "Token de acesso", logout_access_token_dialog: { title: "Está a utilizar um token de acesso Matrix existente.", content: "Pretende destruir esta sessão (que pode estar a ser utilizada noutro local, por exemplo num cliente Matrix) ou apenas terminar sessão no painel de administração?", confirm: "Destruir sessão", cancel: "Apenas terminar sessão no painel de administração", }, }, users: { invalid_user_id: "Introduza apenas a parte local de um ID de utilizador Matrix — não inclua o servidor doméstico.", tabs: { sso: "SSO", experimental: "Experimental", limits: "Limites de taxa", account_data: "Dados da conta", sessions: "Sessões", }, danger_zone: "Zona de perigo", }, rooms: { details: "Detalhes da sala", tabs: { basic: "Básico", members: "Membros", detail: "Detalhes", permission: "Permissões", media: "Multimédia", messages: "Mensagens", hierarchy: "Hierarquia", }, }, reports: { tabs: { basic: "Básico", detail: "Detalhes" } }, admin_config: { soft_failed_events: "Eventos com falha suave", spam_flagged_events: "Eventos marcados como spam", success: "Configuração de administração atualizada", failure: "Falha ao atualizar a configuração de administração", }, }, import_users: { error: { at_entry: "Na entrada %{entry}: %{message}", error: "Erro", required_field: "O campo obrigatório '%{field}' está ausente", invalid_value: "Valor inválido na linha %{row}. O campo '%{field}' só pode ser 'true' ou 'false'", unreasonably_big: "Recusado o carregamento de um ficheiro excessivamente grande de %{size} megabytes", already_in_progress: "Já está em curso uma importação", id_exits: "O ID %{id} já existe", }, title: "Importar utilizadores via CSV", goToPdf: "Ir para PDF", cards: { importstats: { header: "Utilizadores analisados para importação", users_total: "%{smart_count} utilizador no ficheiro CSV |||| %{smart_count} utilizadores no ficheiro CSV", guest_count: "%{smart_count} convidado |||| %{smart_count} convidados", admin_count: "%{smart_count} administrador |||| %{smart_count} administradores", }, conflicts: { header: "Estratégia de conflito", mode: { stop: "Parar em caso de conflito", skip: "Mostrar erro e ignorar em caso de conflito", }, }, ids: { header: "IDs", all_ids_present: "Todas as entradas têm um ID", count_ids_present: "%{smart_count} entrada tem um ID |||| %{smart_count} entradas têm IDs", mode: { ignore: "Ignorar IDs do CSV e criar novos", update: "Atualizar registos existentes", }, }, passwords: { header: "Palavras-passe", all_passwords_present: "Todas as entradas têm uma palavra-passe", count_passwords_present: "%{smart_count} entrada tem uma palavra-passe |||| %{smart_count} entradas têm palavras-passe", use_passwords: "Usar palavras-passe do CSV", }, upload: { header: "Ficheiro CSV de entrada", explanation: "Aqui pode carregar um ficheiro com valores separados por vírgulas que será processado para criar ou atualizar utilizadores. O ficheiro deve incluir os campos 'id' e 'displayname'. Pode descarregar e adaptar um ficheiro de exemplo aqui: ", }, startImport: { simulate_only: "Apenas simular", run_import: "Importar", }, results: { header: "Resultados da importação", total: "%{smart_count} entrada no total |||| %{smart_count} entradas no total", successful: "%{smart_count} entradas importadas com sucesso", skipped: "%{smart_count} entradas ignoradas", download_skipped: "Descarregar registos ignorados", with_error: "%{smart_count} entrada com erros |||| %{smart_count} entradas com erros", simulated_only: "Esta foi apenas uma simulação — não foram feitas alterações", }, }, }, delete_media: { name: "Multimédia", fields: { before_ts: "Último acesso antes de", size_gt: "Maior que (em bytes)", keep_profiles: "Manter imagens de perfil", }, action: { send: "Eliminar multimédia", send_success: "Ficheiro multimédia eliminado com sucesso. |||| %{smart_count} ficheiros multimédia eliminados com sucesso.", send_success_none: "Nenhum ficheiro multimédia correspondeu aos critérios especificados. Nada foi eliminado.", send_failure: "Ocorreu um erro.", }, helper: { send: "Esta API elimina o multimédia local do disco do seu próprio servidor. Inclui miniaturas locais e cópias de multimédia descarregado. Esta API não afeta multimédia carregado em repositórios externos.", }, }, purge_remote_media: { name: "Multimédia remoto", fields: { before_ts: "Último acesso antes de", }, action: { send: "Limpar multimédia remoto", send_success: "Ficheiro multimédia remoto limpo com sucesso. |||| %{smart_count} ficheiros multimédia remotos limpos com sucesso.", send_success_none: "Nenhum ficheiro multimédia remoto correspondeu aos critérios especificados. Nada foi limpo.", send_failure: "Ocorreu um erro no pedido de limpeza de multimédia remoto.", }, helper: { send: "Esta API limpa a cache de multimédia remoto do disco do seu próprio servidor. Inclui miniaturas locais e cópias de multimédia descarregado. Esta API não afeta multimédia carregado no repositório de multimédia do próprio servidor.", }, }, etkecc: { donate: { menu_label: "Fazer donativo", name: "Apoiar o desenvolvimento do Ketesa", title: "Apoiar o desenvolvimento do Ketesa", description_1: "O projeto Ketesa é gratuito e de código aberto, e construímo-lo e mantemo-lo abertamente para a comunidade Matrix.", description_2: "Se o projeto Ketesa lhe foi útil, um donativo ajuda-nos a continuar o trabalho por detrás dele: desenvolvimento, manutenção, correções e melhorias constantes.", description_3: "Ajuda-nos a dedicar mais tempo a melhorar o projeto para todos os que dele dependem.", description_4: "Cada contribuição ajuda e agradecemos sinceramente o seu apoio! ❤️", button: "Doar", signature_team: "a equipa etke.cc", }, components: { name: "Componentes", description: "Veja os seus componentes activos e descubra o que pode adicionar ao seu servidor.", no_section: "O seu servidor", per_month: "/mês", included: "Incluído", total: "Total", loading: "A carregar componentes...", state_add: "Adicionar", state_remove: "Remover", add_aria: "Solicitar a adição de %{name}", remove_aria: "Solicitar a remoção de %{name}", preview_label: "pré-visualização", request_changes: "Solicitar alterações", requesting: "A enviar...", request_failure: "Falha ao enviar o pedido de alteração. Por favor, tente novamente.", request_sent_title: "Pedido submetido", request_sent_body: "O seu pedido de alteração de componentes foi enviado para o suporte da etke.cc. Se precisar de alterações adicionais, responda a este pedido de suporte em vez de abrir um novo.", request_sent_close: "Fechar", request_sent_view: "Ver pedido", request_already_sent: "Já existe um pedido de alteração em aberto. Para solicitar mais alterações, responda ao seu ticket de suporte existente.", request_already_sent_view: "Ver ticket", free_label: "Gratuito", available_label: "Disponível", tagline: "Melhore o seu servidor — adicione ou remova qualquer componente a qualquer momento.", section: { bridges: "Pontes", extras: "Complementos", matrix_apps: "Aplicações Matrix", matrix_bots: "Bots Matrix", matrix_extras: "Complementos Matrix", }, }, billing: { name: "Faturação", title: "Histórico de pagamentos", no_payments: "Nenhum pagamento encontrado.", no_payments_helper: "Se acredita que se trata de um erro, contacte o suporte etke.cc.", description1: "Consulte pagamentos e gere faturas aqui. Pode saber mais sobre a gestão de subscrições em", description2: "Para alterar o seu e-mail de faturação ou adicionar dados da empresa às faturas, consulte", fields: { transaction_id: "ID da transação", email: "E-mail", type: "Tipo", amount: "Montante", paid_at: "Pago em", invoice: "Fatura", }, enums: { type: { subscription: "Subscrição", one_time: "Pagamento único", }, }, helper: { download_invoice: "Descarregar fatura", downloading: "A descarregar...", download_started: "Início do descarregamento da fatura.", invoice_not_available: "Fatura pendente", loading: "A carregar informações de faturação...", loading_failed1: "Ocorreu um problema ao carregar as informações de faturação.", loading_failed2: "Por favor tente novamente mais tarde.", loading_failed3: "Se o problema persistir, contacte o suporte etke.cc.", loading_failed4: "com a seguinte mensagem de erro:", }, components: "Componentes ativos", components_no_section: "O seu servidor", components_per_month: "/mês", components_included: "Incluído", components_total: "Total", components_help_title: "Saiba mais sobre %{name}", components_state_install: "Instalar", components_state_remove: "Remover", components_remove_aria: "Instalar/remover %{name}", components_preview_label: "pré-visualização", components_request_changes: "Solicitar alterações", components_requesting: "A enviar...", components_request_failure: "Falha ao enviar o pedido de alteração. Por favor, tente novamente.", components_request_sent_title: "Pedido submetido", components_request_sent_body: "O seu pedido de alteração de componentes foi enviado para o suporte da etke.cc. Se precisar de alterações adicionais, responda a este pedido de suporte em vez de abrir um novo.", components_request_sent_close: "Fechar", components_request_sent_view: "Ver pedido", components_request_already_sent: "Já existe um pedido de alteração em aberto. Para solicitar mais alterações, responda ao seu ticket de suporte existente.", components_request_already_sent_view: "Ver ticket", status: { issue: { title: "Subscrição necessita de atenção", description: "Detetámos um problema na sua subscrição. Não se preocupe — é fácil de resolver.", due_overdue: "Em atraso há", due_upcoming: "Prazo em", expected: "Valor esperado", last_paid: "Último pagamento", fix_link: "Resolver pagamento em atraso", fix_mismatch_link: "Atualizar o preço da subscrição", support_link: "Contactar suporte", }, }, }, status: { name: "Estado do servidor", badge: { default: "Clique para ver o estado do servidor", running: "Em execução: %{command}. %{text}", status_ok: "O servidor está online", status_error: "Estado: Erro", status_maintenance: "O sistema está atualmente em modo de manutenção.", status_process_running: "O servidor está a executar um comando", status_checking: "A verificar o estado do servidor", }, category: { "Host Metrics": "Métricas do anfitrião", Network: "Rede", HTTP: "HTTP", Matrix: "Matrix", }, status: "Estado", error: "Erro", loading: "A obter o estado operacional do servidor em tempo real — um momento…", intro1: "Este é um relatório de monitorização em tempo real do seu servidor. Pode saber mais em", intro2: "Se alguma das verificações abaixo o preocupar, consulte as ações sugeridas em", help: "Ajuda", }, maintenance: { title: "O sistema está atualmente em modo de manutenção.", try_again: "Por favor tente novamente mais tarde.", note: "Não é necessário contactar o suporte sobre isto, já estamos a trabalhar nisso!", }, actions: { name: "Ações do servidor", available_title: "Comandos disponíveis", available_description: "Os seguintes comandos estão disponíveis para executar no seu servidor.", available_help_intro: "Mais detalhes sobre cada comando estão disponíveis em", scheduled_title: "Comandos agendados", scheduled_description: "Os seguintes comandos estão agendados para ser executados em momentos específicos. Pode ver os detalhes e modificá-los conforme necessário.", recurring_title: "Comandos recorrentes", recurring_description: "Os seguintes comandos estão configurados para ser executados num dia da semana e hora específicos (semanalmente). Pode ver os detalhes e modificá-los conforme necessário.", scheduled_help_intro: "Mais detalhes sobre esta funcionalidade estão disponíveis em", recurring_help_intro: "Mais detalhes sobre esta funcionalidade estão disponíveis em", maintenance_title: "O sistema está atualmente em modo de manutenção.", maintenance_try_again: "Por favor tente novamente mais tarde.", maintenance_note: "Não é necessário contactar o suporte sobre isto, já estamos a trabalhar nisso!", maintenance_commands_blocked: "Os comandos não podem ser executados enquanto o modo de manutenção estiver ativo.", table: { aria_label: "Comandos do servidor", command: "Comando", description: "Descrição", arguments: "Argumentos", is_recurring: "É recorrente?", run_at: "Executar em (hora local)", next_run_at: "Próxima execução em (hora local)", time_utc: "Hora (UTC)", time_local: "Hora (hora local)", }, buttons: { create: "Criar", update: "Atualizar", back: "Voltar", delete: "Eliminar", run: "Executar", }, command_scheduled: "Comando agendado: %{command}", command_scheduled_args: "com argumentos adicionais: %{args}", expect_prefix: "O resultado aparecerá na página", expect_suffix: "em breve.", notifications_link: "Notificações", command_help_title: "Ajuda sobre %{command}", scheduled_title_create: "Criar comando agendado", scheduled_title_edit: "Editar comando agendado", recurring_title_create: "Criar comando recorrente", recurring_title_edit: "Editar comando recorrente", scheduled_details_title: "Detalhes do comando agendado", recurring_warning: "Os comandos agendados gerados a partir de um comando recorrente não podem ser editados, pois são regenerados automaticamente. Para fazer alterações, edite o comando recorrente.", command_details_intro: "Pode encontrar mais detalhes sobre o comando em", form: { id: "ID", command: "Comando", scheduled_at: "Agendado para", day_of_week: "Dia da semana", }, delete_scheduled_title: "Eliminar comando agendado", delete_recurring_title: "Eliminar comando recorrente", delete_confirm: "Tem a certeza de que pretende eliminar o comando: %{command}?", errors: { unknown: "Ocorreu um erro desconhecido", delete_failed: "Erro: %{error}", }, days: { monday: "Segunda-feira", tuesday: "Terça-feira", wednesday: "Quarta-feira", thursday: "Quinta-feira", friday: "Sexta-feira", saturday: "Sábado", sunday: "Domingo", }, scheduled: { action: { create_success: "Comando agendado criado com sucesso", update_success: "Comando agendado atualizado com sucesso", update_failure: "Ocorreu um erro", delete_success: "Comando agendado eliminado com sucesso", delete_failure: "Ocorreu um erro", }, }, recurring: { action: { create_success: "Comando recorrente criado com sucesso", update_success: "Comando recorrente atualizado com sucesso", update_failure: "Ocorreu um erro", delete_success: "Comando recorrente eliminado com sucesso", delete_failure: "Ocorreu um erro", }, }, }, notifications: { title: "Notificações", new_notifications: "%{smart_count} nova notificação |||| %{smart_count} novas notificações", no_notifications: "Ainda não há notificações", see_all: "Ver todas as notificações", clear_all: "Limpar todas", ago: "atrás", advisory_tooltip: "É possível que tenha perdido uma notificação. Verifique também #news:etke.cc, etke.cc/news ou o seu e-mail.", unavailable_tooltip: "As notificações podem estar indisponíveis. Clique para mais detalhes.", unavailable_title: "As notificações podem estar indisponíveis neste momento", unavailable_body: "Pode haver atualizações que não conseguimos entregar a este painel neste momento — ou pode não haver nada de novo. Para não perder nada, consulte periodicamente:", unavailable_link_matrix: "Sala Matrix #news:etke.cc", unavailable_link_news: "Página de anúncios em etke.cc/news", unavailable_link_email: "A sua caixa de entrada de e-mail (incluindo a pasta de spam)", unavailable_retry: "Tentar novamente", }, currently_running: { command: "Atualmente em execução:", started_ago: "(iniciado há %{time})", }, time: { less_than_minute: "agora mesmo", minutes: "%{smart_count} minuto |||| %{smart_count} minutos", hours: "%{smart_count} hora |||| %{smart_count} horas", days: "%{smart_count} dia |||| %{smart_count} dias", weeks: "%{smart_count} semana |||| %{smart_count} semanas", months: "%{smart_count} mês |||| %{smart_count} meses", }, support: { name: "Suporte", menu_label: "Contactar suporte", description: "Abra um pedido de suporte ou acompanhe um já existente. A nossa equipa responderá o mais brevemente possível.", create_title: "Novo pedido de suporte", no_requests: "Ainda não há pedidos de suporte.", no_messages: "Ainda não há mensagens.", closed_message: "Este pedido está encerrado. Se ainda precisar de ajuda, abra um novo pedido.", fields: { subject: "Assunto", message: "Mensagem", reply: "Resposta", status: "Estado", created_at: "Criado em", updated_at: "Última atualização", }, status: { active: "A aguardar resposta do suporte", open: "Aberto", closed: "Encerrado", pending: "À sua espera", }, buttons: { new_request: "Novo pedido", submit: "Submeter", cancel: "Cancelar", send: "Enviar", back: "Voltar ao suporte", attach_files: "Anexar ficheiros", }, helper: { loading: "A carregar pedidos de suporte...", reply_hint: "Prima Ctrl+Enter para enviar", reply_placeholder: "Inclua o máximo de detalhes possível.", before_contact_title: "Antes de nos contactar", help_pages_prompt: "Consulte primeiro as nossas páginas de ajuda:", services_prompt: "Apenas prestamos os serviços listados em:", topics_prompt: "Só podemos ajudar com os tópicos suportados:", scope_confirm_label: "Consultei as páginas de ajuda e confirmo que este pedido corresponde aos tópicos suportados.", english_only_notice: "O suporte é prestado apenas em inglês.", response_time_prompt: "Procuramos responder em 48 horas. Precisa de uma resposta mais rápida? Consulte:", attachments_limit: "Até 5 ficheiros, 5 MB cada, 10 MB no total.", close_request_label: "Fechar este pedido após o envio", }, actions: { create_success: "Pedido de suporte criado com sucesso.", create_failure: "Falha ao criar o pedido de suporte.", send_failure: "Falha ao enviar mensagem.", attachment_too_large: 'O ficheiro "%{name}" excede o limite de 5 MB.', too_many_attachments: "Máximo de 5 ficheiros permitido.", total_size_exceeded: "O tamanho total dos anexos excede 10 MB.", }, }, }, }; export default common; ================================================ FILE: src/i18n/pt/index.ts ================================================ import { SynapseTranslationMessages } from "../types"; import common from "./common"; import mas from "./mas"; import misc_resources from "./misc_resources"; import reports from "./reports"; import rooms from "./rooms"; import users from "./users"; const pt: SynapseTranslationMessages = { ra: common.ra, ketesa: common.ketesa, import_users: common.import_users, delete_media: common.delete_media, purge_remote_media: common.purge_remote_media, etkecc: common.etkecc, resources: { users, rooms, reports, scheduled_tasks: misc_resources.scheduled_tasks, connections: misc_resources.connections, devices: misc_resources.devices, users_media: misc_resources.users_media, protect_media: misc_resources.protect_media, quarantine_media: misc_resources.quarantine_media, pushers: misc_resources.pushers, servernotices: misc_resources.servernotices, database_room_statistics: misc_resources.database_room_statistics, user_media_statistics: misc_resources.user_media_statistics, forward_extremities: misc_resources.forward_extremities, room_state: misc_resources.room_state, room_media: misc_resources.room_media, room_directory: misc_resources.room_directory, destinations: misc_resources.destinations, registration_tokens: misc_resources.registration_tokens, account_data: misc_resources.account_data, joined_rooms: misc_resources.joined_rooms, memberships: misc_resources.memberships, room_members: misc_resources.room_members, destination_rooms: misc_resources.destination_rooms, ...mas, }, }; export default pt; ================================================ FILE: src/i18n/pt/mas.ts ================================================ const mas = { mas_users: { name: "Utilizador MAS |||| Utilizadores MAS", fields: { id: "ID MAS", username: "Nome de utilizador", admin: "Administrador", locked: "Bloqueado", deactivated: "Desativado", legacy_guest: "Convidado legado", created_at: "Criado em", locked_at: "Bloqueado em", deactivated_at: "Desativado em", }, filter: { status: "Estado", search: "Pesquisar", status_active: "Ativo", status_locked: "Bloqueado", status_deactivated: "Desativado", }, action: { lock: { label: "Bloquear", success: "Utilizador bloqueado" }, unlock: { label: "Desbloquear", success: "Utilizador desbloqueado" }, deactivate: { label: "Desativar", success: "Utilizador desativado" }, reactivate: { label: "Reativar", success: "Utilizador reativado" }, set_admin: { label: "Conceder administração", success: "Estado de administrador atualizado" }, remove_admin: { label: "Remover administração", success: "Estado de administrador atualizado" }, set_password: { label: "Definir palavra-passe", title: "Definir palavra-passe", success: "Palavra-passe definida", failure: "Falha ao definir a palavra-passe", }, }, }, mas_user_emails: { name: "E-mail |||| E-mails", empty: "Sem e-mails", fields: { email: "E-mail", user_id: "ID de utilizador", created_at: "Criado em", actions: "Ações", }, action: { remove: { label: "Remover", title: "Remover e-mail", content: "Remover %{email}?", success: "E-mail removido", }, create: { success: "E-mail adicionado" }, }, }, mas_compat_sessions: { name: "Sessão de compatibilidade |||| Sessões de compatibilidade", empty: "Sem sessões de compatibilidade", fields: { user_id: "ID de utilizador", device_id: "ID do dispositivo", created_at: "Criado em", user_agent: "Agente de utilizador", last_active_at: "Última atividade", last_active_ip: "Último IP", finished_at: "Terminado em", human_name: "Nome", active: "Ativo", }, action: { finish: { label: "Terminar", title: "Terminar esta sessão?", content: "Isto irá terminar a sessão.", success: "Sessão terminada", }, }, }, mas_oauth2_sessions: { name: "Sessão OAuth2 |||| Sessões OAuth2", empty: "Sem sessões OAuth2", fields: { user_id: "ID de utilizador", client_id: "ID do cliente", scope: "Âmbito", created_at: "Criado em", user_agent: "Agente de utilizador", last_active_at: "Última atividade", last_active_ip: "Último IP", finished_at: "Terminado em", human_name: "Nome", active: "Ativo", }, action: { finish: { label: "Terminar", title: "Terminar esta sessão?", content: "Isto irá terminar a sessão.", success: "Sessão terminada", }, }, }, mas_policy_data: { name: "Dados de política", current_policy: "Política atual", no_policy: "Nenhuma política está atualmente definida.", set_policy: "Definir nova política", invalid_json: "JSON inválido", fields: { json_placeholder: "Introduza os dados da política em JSON…", created_at: "Criado em", }, action: { save: { label: "Definir política", success: "Política atualizada", failure: "Falha ao atualizar a política", }, }, }, mas_user_sessions: { name: "Sessão de navegador |||| Sessões de navegador", fields: { user_id: "ID de utilizador", created_at: "Criado em", finished_at: "Terminado em", user_agent: "Agente de utilizador", last_active_at: "Última atividade", last_active_ip: "Último IP", active: "Ativo", }, action: { finish: { label: "Terminar", title: "Terminar esta sessão?", content: "Isto irá terminar a sessão de navegador.", success: "Sessão terminada", }, }, }, mas_upstream_oauth_links: { name: "Ligação OAuth upstream |||| Ligações OAuth upstream", fields: { user_id: "ID de utilizador", provider_id: "ID do fornecedor", subject: "Assunto", human_account_name: "Nome da conta", created_at: "Criado em", }, helper: { provider_id: "O ID do fornecedor OAuth upstream. Pode encontrá-lo na lista de Fornecedores OAuth upstream.", }, action: { remove: { label: "Remover", title: "Remover ligação OAuth?", content: "Isto irá remover a ligação OAuth upstream deste utilizador.", success: "Ligação OAuth removida", }, }, }, mas_upstream_oauth_providers: { name: "Fornecedor OAuth |||| Fornecedores OAuth", fields: { issuer: "Emissor", human_name: "Nome", brand_name: "Marca", created_at: "Criado em", disabled_at: "Desativado em", enabled: "Ativado", }, }, mas_personal_sessions: { name: "Sessão pessoal |||| Sessões pessoais", empty: "Sem sessões pessoais", fields: { owner_user_id: "ID do utilizador proprietário", actor_user_id: "Utilizador", human_name: "Nome", scope: "Âmbito", created_at: "Criado em", revoked_at: "Revogado em", last_active_at: "Última atividade", last_active_ip: "Último IP", expires_at: "Expira em", expires_in: "Expira em (segundos)", active: "Ativo", }, helper: { expires_in: "Opcional. Número de segundos até o token expirar. Deixe em branco para sem expiração.", }, action: { revoke: { label: "Revogar", title: "Revogar sessão?", content: "Isto irá revogar o token de acesso pessoal.", success: "Sessão revogada", }, create: { token_title: "Token de acesso pessoal", token_content: "Copie este token agora — não será mostrado novamente.", }, }, }, mas_sessions: { status: { active: "Ativo", finished: "Terminado", revoked: "Revogado", }, }, }; export default mas; ================================================ FILE: src/i18n/pt/misc_resources.ts ================================================ // Miscellaneous resources: scheduled_tasks, connections, devices, users_media, // protect_media, quarantine_media, pushers, servernotices, database_room_statistics, // user_media_statistics, forward_extremities, room_state, room_media, room_directory, // destinations, registration_tokens const misc_resources = { scheduled_tasks: { name: "Tarefa agendada |||| Tarefas agendadas", fields: { id: "ID", action: "Ação", status: "Estado", timestamp: "Timestamp", resource_id: "ID do recurso", result: "Resultado", error: "Erro", max_timestamp: "Antes da data", }, status: { scheduled: "Agendada", active: "Ativa", complete: "Concluída", cancelled: "Cancelada", failed: "Falhada", }, }, connections: { name: "Ligações", fields: { last_seen: "Data", ip: "Endereço IP", user_agent: "Agente de utilizador", }, }, devices: { name: "Dispositivo |||| Dispositivos", fields: { device_id: "ID do dispositivo", display_name: "Nome do dispositivo", last_seen_ts: "Timestamp", last_seen_ip: "Endereço IP", last_seen_user_agent: "Agente de utilizador", dehydrated: "Desidratado", }, action: { erase: { title: "Remover dispositivo %{id}?", title_bulk: "Remover %{smart_count} dispositivo? |||| Remover %{smart_count} dispositivos?", content: 'Tem a certeza de que pretende remover o dispositivo "%{name}"?', content_bulk: "Tem a certeza de que pretende remover %{smart_count} dispositivo? |||| Tem a certeza de que pretende remover %{smart_count} dispositivos?", success: "Dispositivo removido com sucesso.", failure: "Ocorreu um erro.", }, display_name: { success: "Nome do dispositivo atualizado", failure: "Falha ao atualizar o nome do dispositivo", }, create: { label: "Criar dispositivo", title: "Criar novo dispositivo", success: "Dispositivo criado", failure: "Falha ao criar o dispositivo", }, }, }, users_media: { name: "Multimédia", fields: { media_id: "ID do multimédia", media_length: "Tamanho do ficheiro (em bytes)", media_type: "Tipo", upload_name: "Nome do ficheiro", quarantined_by: "Colocado em quarentena por", safe_from_quarantine: "Protegido de quarentena", created_ts: "Criado em", last_access_ts: "Último acesso", }, action: { open: "Abrir ficheiro multimédia numa nova janela", }, }, protect_media: { action: { create: "Proteger", delete: "Desproteger", none: "Em quarentena", send_success: "Estado de proteção alterado com sucesso.", send_failure: "Ocorreu um erro.", }, }, quarantine_media: { action: { name: "Quarentena", create: "Colocar em quarentena", delete: "Retirar da quarentena", none: "Protegido", send_success: "Estado de quarentena alterado com sucesso.", send_failure: "Ocorreu um erro: %{error}", }, }, pushers: { name: "Pusher |||| Pushers", fields: { app: "Aplicação", app_display_name: "Nome de apresentação da aplicação", app_id: "ID da aplicação", device_display_name: "Nome de apresentação do dispositivo", kind: "Tipo", lang: "Idioma", profile_tag: "Etiqueta de perfil", pushkey: "Chave push", data: { url: "URL" }, }, }, servernotices: { name: "Avisos do servidor", send: "Enviar avisos do servidor", fields: { body: "Mensagem", }, action: { send: "Enviar aviso", send_success: "Aviso do servidor enviado com sucesso.", send_failure: "Ocorreu um erro.", }, helper: { send: 'Envia um aviso do servidor aos utilizadores selecionados. A funcionalidade "Avisos do servidor" deve estar ativada no servidor.', }, }, database_room_statistics: { name: "Estatísticas de salas na base de dados", fields: { room_id: "ID da sala", estimated_size: "Tamanho estimado", }, helper: { info: "Mostra o espaço em disco estimado utilizado por cada sala na base de dados do Synapse. Estes valores são aproximados.", }, }, user_media_statistics: { name: "Multimédia", fields: { media_count: "Contagem de multimédia", media_length: "Tamanho do multimédia", }, }, forward_extremities: { name: "Extremidades diretas", fields: { id: "ID do evento", received_ts: "Timestamp", depth: "Profundidade", state_group: "Grupo de estado", }, }, room_state: { name: "Eventos de estado", fields: { type: "Tipo", content: "Conteúdo", origin_server_ts: "Enviado em", sender: "Remetente", }, }, room_media: { name: "Multimédia", fields: { media_id: "ID do multimédia", }, helper: { info: "Lista todo o multimédia carregado nesta sala. O multimédia alojado em repositórios externos não pode ser eliminado a partir daqui.", }, action: { error: "%{errcode} (%{errstatus}) %{error}", }, }, room_directory: { name: "Diretório de salas", fields: { world_readable: "Utilizadores convidados podem ver sem entrar", guest_can_join: "Utilizadores convidados podem entrar", }, action: { title: "Eliminar sala do diretório |||| Eliminar %{smart_count} salas do diretório", content: "Tem a certeza de que pretende remover esta sala do diretório? |||| Tem a certeza de que pretende remover estas %{smart_count} salas do diretório?", erase: "Eliminar do diretório de salas", create: "Publicar no diretório de salas", send_success: "Sala publicada com sucesso.", send_failure: "Ocorreu um erro.", }, }, destinations: { name: "Federação", fields: { destination: "Destino", failure_ts: "Timestamp de falha", retry_last_ts: "Timestamp da última tentativa", retry_interval: "Intervalo de tentativa", last_successful_stream_ordering: "Último fluxo bem-sucedido", stream_ordering: "Fluxo", }, action: { reconnect: "Reconectar" }, }, registration_tokens: { name: "Tokens de registo", fields: { token: "Token", valid: "Token válido", uses_allowed: "Utilizações permitidas", pending: "Pendente", completed: "Concluído", expiry_time: "Hora de expiração", length: "Comprimento", created_at: "Criado em", last_used_at: "Última utilização em", revoked_at: "Revogado em", }, helper: { length: "O comprimento do token gerado, utilizado quando não é fornecido um valor de token específico." }, action: { revoke: { label: "Revogar", success: "Token revogado", }, unrevoke: { label: "Restaurar", success: "Token restaurado", }, }, }, account_data: { name: "Dados da conta", }, joined_rooms: { name: "Salas em que entrou", }, memberships: { name: "Associações", }, room_members: { name: "Membros", }, destination_rooms: { name: "Salas", }, }; export default misc_resources; ================================================ FILE: src/i18n/pt/reports.ts ================================================ const reports = { name: "Evento reportado |||| Eventos reportados", fields: { id: "ID", received_ts: "Reportado em", user_id: "Denunciante", name: "Nome da sala", score: "Pontuação", reason: "Motivo", event_id: "ID do evento", sender: "Remetente", }, action: { erase: { title: "Eliminar evento reportado", content: "Tem a certeza de que pretende eliminar o evento reportado? Esta ação não pode ser desfeita.", }, event_lookup: { label: "Pesquisar evento", title: "Pesquisar evento por ID", fetch: "Pesquisar", }, fetch_event_error: "Falha ao obter o evento", }, }; export default reports; ================================================ FILE: src/i18n/pt/rooms.ts ================================================ const rooms = { name: "Sala |||| Salas", fields: { room_id: "ID da sala", name: "Nome", canonical_alias: "Alias", joined_members: "Membros", joined_local_members: "Membros locais", joined_local_devices: "Dispositivos locais", state_events: "Eventos de estado / Complexidade", version: "Versão", is_encrypted: "Encriptado", encryption: "Encriptação", federatable: "Federável", public: "Visível no diretório de salas", creator: "Criador", join_rules: "Regras de entrada", guest_access: "Acesso de convidados", history_visibility: "Visibilidade do histórico", topic: "Tópico", avatar: "Avatar", actions: "Ações", }, filter: { public_rooms: "Salas públicas", empty_rooms: "Salas vazias", local_members_only: "Apenas membros locais", }, helper: { forward_extremities: "As extremidades frontais são os eventos folha no final de um grafo acíclico dirigido (DAG) numa sala, ou seja, eventos sem filhos. Quanto mais existirem numa sala, mais resolução de estado o Synapse precisa de realizar (nota: esta é uma operação dispendiosa). Embora o Synapse tenha código para evitar demasiadas extremidades ao mesmo tempo, erros podem fazê-las reaparecer. Se uma sala tiver mais de 10 extremidades frontais, vale a pena investigar e potencialmente removê-las usando as consultas SQL mencionadas em #1760.", }, enums: { join_rules: { public: "Pública", knock: "Solicitação", invite: "Convite", private: "Privada", restricted: "Restrita", }, guest_access: { can_join: "Convidados podem entrar", forbidden: "Convidados não podem entrar", }, history_visibility: { invited: "Desde o convite", joined: "Desde a entrada", shared: "Desde a partilha", world_readable: "Qualquer pessoa", }, unencrypted: "Não encriptado", room_type: { room: "Sala", space: "Espaço", }, }, action: { erase: { title: "Eliminar sala", content: "Tem a certeza de que pretende eliminar esta sala? Esta ação não pode ser desfeita. Todas as mensagens e multimédia partilhado serão permanentemente eliminados do servidor.", fields: { block: "Bloquear e impedir utilizadores de entrar na sala", }, in_progress: "Eliminação em curso…", background_note: "Pode fechar esta janela com segurança; a eliminação continuará em segundo plano.", success: "Sala eliminada com sucesso. |||| Salas eliminadas com sucesso.", failure: "Não foi possível eliminar a sala. |||| Não foi possível eliminar as salas.", }, make_admin: { assign_admin: "Atribuir administrador", title: "Atribuir um administrador à sala %{roomName}", confirm: "Tornar administrador", content: "Introduza o MXID completo do utilizador a definir como administrador da sala.\nNota: a sala já deve ter pelo menos um membro local com permissões de administrador para que isto funcione.", success: "O utilizador foi definido como administrador da sala.", failure: "Não foi possível definir o utilizador como administrador da sala. %{errMsg}", }, join: { label: "Adicionar utilizador", title: "Adicionar utilizador a %{roomName}", confirm: "Adicionar", content: "Introduza o MXID completo do utilizador a adicionar a esta sala.\nNota: deve ser membro da sala com permissão para convidar utilizadores.", success: "Utilizador adicionado à sala com sucesso.", failure: "Falha ao adicionar utilizador à sala. %{errMsg}", }, block: { label: "Bloquear", title: "Bloquear %{room}", title_bulk: "Bloquear %{smart_count} sala |||| Bloquear %{smart_count} salas", title_by_id: "Bloquear uma sala", content: "Os utilizadores serão impedidos de entrar nesta sala.", content_bulk: "Os utilizadores serão impedidos de entrar em %{smart_count} sala. |||| Os utilizadores serão impedidos de entrar em %{smart_count} salas.", success: "Sala bloqueada com sucesso. |||| Salas bloqueadas com sucesso.", failure: "Falha ao bloquear a sala. |||| Falha ao bloquear as salas.", }, unblock: { label: "Desbloquear", success: "Sala desbloqueada com sucesso. |||| Salas desbloqueadas com sucesso.", failure: "Falha ao desbloquear a sala. |||| Falha ao desbloquear as salas.", }, purge_history: { label: "Limpar histórico", title: "Limpar histórico de %{roomName}", content: "Todos os eventos anteriores à data selecionada serão eliminados da base de dados. O estado da sala (entradas, saídas, tópico) é sempre preservado. Pelo menos uma mensagem é sempre mantida.\nNota: esta operação pode demorar vários minutos para salas grandes.", date_label: "Limpar eventos anteriores a", delete_local: "Também eliminar eventos enviados por utilizadores locais", in_progress: "Limpeza em curso…", background_note: "Pode fechar esta janela com segurança; a limpeza continuará em segundo plano.", success: "Histórico da sala limpo com sucesso.", failure: "Falha ao limpar o histórico da sala. %{errMsg}", }, quarantine_all: { label: "Colocar todo o multimédia em quarentena", title: "Colocar todo o multimédia de %{roomName} em quarentena", content: "Isto colocará em quarentena TODO o multimédia local e remoto nesta sala. O multimédia em quarentena deixará de estar acessível aos utilizadores.", success: "Item de multimédia colocado em quarentena com sucesso. |||| %{smart_count} itens de multimédia colocados em quarentena com sucesso.", failure: "Falha ao colocar multimédia em quarentena. %{errMsg}", }, delete_all_media: { label: "Eliminar todos os média", title: "Eliminar todos os média em %{roomName}", content: "Todos os ficheiros de média locais nesta sala serão eliminados permanentemente. Apenas os média locais de salas não cifradas são afetados — os média de servidores externos estão excluídos. Esta ação não pode ser revertida.", in_progress_loading: "A obter a lista de média…", in_progress: "A eliminar os média… (%{current} / %{total})", do_not_close: "Não feche esta janela — a eliminação está a decorrer em primeiro plano e será interrompida se fechar.", success: "Eliminado com sucesso %{smart_count} ficheiro de média. |||| Eliminados com sucesso %{smart_count} ficheiros de média.", failure: "Falha ao eliminar os média. %{errMsg}", }, delete_all_media_bulk: { title: "Eliminar todos os média de %{smart_count} sala? |||| Eliminar todos os média de %{smart_count} salas?", content: "Todos os ficheiros de média locais das salas selecionadas serão eliminados permanentemente (apenas salas não cifradas). Os média de servidores externos estão excluídos. Esta ação não pode ser revertida.", success: "Média eliminados para %{success} de %{total} salas.", partial_failure: "Média eliminados para %{success} de %{total} salas. %{failed} falharam.", }, event_context: { lookup_title: "Pesquisar evento por ID", jump_to_date: "Saltar para data", direction: "Direção", forward: "Para a frente", backward: "Para trás", target_event: "Evento alvo", events_before: "Eventos anteriores", events_after: "Eventos posteriores", not_found: "Nenhum evento encontrado no momento especificado", failure: "Falha ao obter o contexto do evento", }, messages: { load_older: "Carregar mais antigos", load_newer: "Carregar mais recentes", no_messages: "Sem mensagens nesta sala", failure: "Falha ao carregar mensagens", filter: "Filtros", filter_type: "Tipos de evento", filter_sender: "Remetentes", advanced_filters: "Filtros avançados", filter_not_type: "Excluir tipos de evento", filter_not_sender: "Excluir remetentes", contains_url: "Contém URL", any: "Qualquer", with_url: "Apenas com URL", without_url: "Apenas sem URL", apply_filter: "Aplicar", clear_filters: "Limpar", }, hierarchy: { load_more: "Carregar mais", max_depth: "Profundidade máxima", unlimited: "Ilimitada", refresh: "Atualizar", members: "%{count} membros", space: "Espaço", room: "Sala", suggested: "Sugerida", no_children: "Esta sala não tem salas filhas", failure: "Falha ao carregar a hierarquia", }, }, }; export default rooms; ================================================ FILE: src/i18n/pt/users.ts ================================================ const users = { name: "Utilizador |||| Utilizadores", email: "E-mail", msisdn: "Telefone", threepid: "E-mail / Telefone", membership: "Associação |||| Associações", fields: { avatar: "Avatar", id: "ID de utilizador", name: "Nome", is_guest: "Convidado", admin: "Administrador do servidor", locked: "Bloqueado", suspended: "Suspenso", shadow_banned: "Banido silenciosamente", deactivated: "Desativado", erased: "Apagado", show_guests: "Mostrar convidados", show_deactivated: "Mostrar apenas desativados", show_locked: "Mostrar utilizadores bloqueados", filter_user_all: "Todos", filter_deactivated_false: "Ativo", filter_deactivated_true: "Desativado", filter_locked_false: "Excluir bloqueados", filter_locked_true: "Incluir bloqueados", filter_guests_false: "Excluir convidados", filter_guests_true: "Incluir convidados", show_system_users: "Mostrar utilizadores do sistema", filter_system_users_false: "Excluir sistema", filter_system_users_true: "Apenas sistema", show_suspended: "Mostrar utilizadores suspensos", show_shadow_banned: "Mostrar utilizadores com shadow ban", user_id: "Pesquisar utilizador", displayname: "Nome de apresentação", password: "Palavra-passe", avatar_url: "URL do avatar", avatar_src: "Avatar", medium: "Meio", threepids: "3PIDs", address: "Endereço", creation_ts_ms: "Criado em", consent_version: "Versão do consentimento", sent_invite_count: "Convites enviados", cumulative_joined_room_count: "Total de salas em que entrou", auth_provider: "Fornecedor", user_type: "Tipo de utilizador", }, helper: { password: "Alterar a palavra-passe irá encerrar a sessão do utilizador em todos os dispositivos.", password_required_for_reactivation: "Deve fornecer uma palavra-passe para reativar uma conta.", create_password: "Gere uma palavra-passe forte e segura utilizando o botão abaixo.", lock: "Impede o utilizador de usar a sua conta de forma útil. Esta é uma ação não destrutiva que pode ser revertida.", deactivate: "É necessária uma palavra-passe para reativar esta conta.", suspend: "Suspender este utilizador coloca-o em modo só de leitura.", shadow_ban: "Um utilizador com shadow ban recebe respostas normais, mas os seus eventos não são propagados para outros utilizadores ou salas. Use apenas como último recurso.", erase: "Além de desativar o utilizador, marca-o como apagado ao abrigo do GDPR.", admin: "Um administrador do servidor tem controlo total sobre o servidor e os seus utilizadores.", erase_text: "Isto significa que as mensagens enviadas pelo(s) utilizador(es) continuarão visíveis para quem estava na sala na altura, mas ficarão ocultas para utilizadores que entrem posteriormente.", erase_admin_error: "Não é permitido eliminar o próprio utilizador.", modify_managed_user_error: "Não é permitido modificar um utilizador gerido pelo sistema.", username_available: "O nome de utilizador está disponível", sent_invite_count: "Número total de convites enviados por este utilizador em todas as salas.", cumulative_joined_room_count: "Número total de salas a que este utilizador alguma vez se juntou, incluindo salas que entretanto abandonou ou de que foi banido.", }, action: { erase: "Apagar dados do utilizador", erase_avatar: "Apagar avatar", delete_media: "Eliminar todos os multimédia carregados por este utilizador", redact_events: "Redatar todos os eventos enviados por este utilizador", redact_in_progress: "Redação em curso\u2026", redact_background_note: "Pode fechar este diálogo com segurança; a redação continuará em segundo plano.", redact_success: "Todos os eventos foram redatados com sucesso.", redact_failure: "Redação concluída com %{smart_count} evento com falha. |||| Redação concluída com %{smart_count} eventos com falha.", generate_password: "Gerar palavra-passe", reset_password: { label: "Repor palavra-passe", title: "Repor palavra-passe", helper: "Alterar a palavra-passe de %{user}", password: "Palavra-passe", logout_devices: "Terminar sessão em todos os dispositivos", success: "Palavra-passe reposta com sucesso", failure: "Falha ao repor a palavra-passe", error_no_password: "A palavra-passe é obrigatória", }, login_as: { label: "Entrar como utilizador", title: "Entrar como utilizador", helper: "Obtenha um token de acesso que pode ser usado para autenticar como %{user}. Esta ação não cria um novo dispositivo, pelo que não aparecerá na lista de dispositivos/sessões do utilizador. Em geral, o utilizador-alvo não conseguirá saber que alguém entrou como ele.", valid_until: "Definir data de expiração", success: "Token de acesso gerado com sucesso", failure: "Falha ao gerar o token de acesso", result_title: "Token de acesso de %{user}", access_token: "Token de acesso", expires_at: "Este token de acesso expirará em %{date}", }, overwrite_title: "Atenção!", overwrite_content: "Este nome de utilizador já existe. Tem a certeza de que pretende substituir o utilizador existente?", overwrite_cancel: "Cancelar", overwrite_confirm: "Substituir", quarantine_all: { label: "Colocar todo o multimédia em quarentena", title: "Colocar todo o multimédia de %{userName} em quarentena", content: "Isto colocará em quarentena todo o multimédia local carregado por este utilizador. O multimédia em quarentena deixará de estar acessível a outros utilizadores.", success: "Item de multimédia colocado em quarentena com sucesso. |||| %{smart_count} itens de multimédia colocados em quarentena com sucesso.", failure: "Falha ao colocar multimédia em quarentena. %{errMsg}", }, delete_all_media: { label: "Eliminar todos os média", title: "Eliminar todos os média de %{userName}", content: "Todos os ficheiros de média carregados por este utilizador serão eliminados permanentemente. Esta ação não pode ser revertida.", in_progress: "A eliminar os média…", background_note: "Pode fechar esta janela em segurança — a eliminação continuará em segundo plano.", success: "Eliminado com sucesso %{smart_count} ficheiro de média. |||| Eliminados com sucesso %{smart_count} ficheiros de média.", failure: "Falha ao eliminar os média. %{errMsg}", }, delete_all_media_bulk: { title: "Eliminar todos os média de %{smart_count} utilizador? |||| Eliminar todos os média de %{smart_count} utilizadores?", content: "Todos os ficheiros de média carregados pelos utilizadores selecionados serão eliminados permanentemente. Esta ação não pode ser revertida.", success: "Média eliminados para %{success} de %{total} utilizadores.", partial_failure: "Média eliminados para %{success} de %{total} utilizadores. %{failed} falharam.", }, allow_cross_signing: { label: "Permitir reposição de assinatura cruzada", title: "Permitir substituição de chaves de assinatura cruzada", content: "Permitir que %{user} substitua as suas chaves de assinatura cruzada sem autenticação interativa? Isto cria uma janela temporária durante a qual as chaves podem ser substituídas.", success: "Substituição de chave de assinatura cruzada permitida até %{deadline}", failure: "Falha ao permitir a substituição de assinatura cruzada", no_key: "O utilizador não tem chave mestra de assinatura cruzada", }, find_user: { label: "Encontrar utilizador", title: "Encontrar utilizador", lookup_type: "Tipo de pesquisa", by_threepid: "Por e-mail / telefone", by_auth_provider: "Por fornecedor de autenticação", provider: "ID do fornecedor de autenticação", external_id: "ID externo", search: "Pesquisar", not_found: "Utilizador não encontrado", failure: "Falha ao encontrar o utilizador", }, renew_account: { label: "Renovar conta", title: "Renovar validade da conta", content: "Renova a validade da conta de %{user}. Pode opcionalmente definir uma data de expiração personalizada. Se deixado em branco, será utilizado o período de renovação predefinido do servidor.", expiration: "Data de expiração", expiration_helper: "Deixe em branco para usar o período de renovação predefinido do servidor", renewal_emails: "Enviar e-mails de notificação de renovação", success: "Validade da conta renovada até %{date}", failure: "Falha ao renovar a validade da conta", }, system_users_scan_in_progress: "A pesquisar utilizadores correspondentes — a página carregará em breve.", reverse_search_scan_in_progress: "A pesquisar todos os utilizadores para excluir correspondências — a página carregará em breve.", }, badge: { you: "Você", bot: "Bot", admin: "Administrador", support: "Suporte", regular: "Utilizador regular", federated: "Utilizador federado", system_managed: "Gerido pelo sistema", }, limits: { messages_per_second: "Mensagens por segundo", messages_per_second_text: "O número de ações que podem ser realizadas por segundo.", burst_count: "Contagem de rajada", burst_count_text: "O número de ações que podem ser realizadas antes de o limite de taxa ser aplicado.", }, account_data: { title: "Dados da conta", global: "Global", rooms: "Salas", }, }; export default users; ================================================ FILE: src/i18n/ru/base.ts ================================================ import type { TranslationMessages } from "ra-core"; const russianMessages: TranslationMessages = { ra: { action: { add_filter: "Добавить фильтр", add: "Добавить", back: "Назад", bulk_actions: "1 выбран |||| %{smart_count} выбраны |||| %{smart_count} выбраны", cancel: "Отмена", clear_array_input: "Очистить список", clear_input_value: "Очистить", clone: "Дублировать", confirm: "Подтвердить", create: "Создать", create_item: "Создать %{item}", delete: "Удалить", edit: "Редактировать", export: "Экспорт", list: "Список", refresh: "Обновить", remove_filter: "Убрать фильтр", remove_all_filters: "Убрать все фильтры", remove: "Удалить", reset: "Сбросить", save: "Сохранить", search: "Поиск", search_columns: "Поиск по столбцам", select_all: "Выбрать все", select_all_button: "Выбрать все", select_row: "Выбрать эту запись", show: "Просмотр", sort: "Сортировка", undo: "Отменить", unselect: "Не выбрано", expand: "Раскрыть", close: "Закрыть", open_menu: "Открыть меню", close_menu: "Закрыть меню", update: "Обновить", move_up: "Переместить вверх", move_down: "Переместить вниз", open: "Открыть", toggle_theme: "Переключить тему", select_columns: "Столбцы", update_application: "Обновить приложение", }, boolean: { true: "Да", false: "Нет", null: " ", }, page: { create: "Создать %{name}", dashboard: "Главная", edit: "%{name} %{recordRepresentation}", error: "Что-то пошло не так", list: "%{name}", loading: "Загрузка", not_found: "Не найдено", show: "%{name} %{recordRepresentation}", empty: "Пусто", invite: "Вы хотите добавить еще одну?", access_denied: "Доступ запрещен", authentication_error: "Ошибка аутентификации", }, input: { file: { upload_several: "Перетащите файлы сюда или нажмите для выбора.", upload_single: "Перетащите файл сюда или нажмите для выбора.", }, image: { upload_several: "Перетащите изображения сюда или нажмите для выбора.", upload_single: "Перетащите изображение сюда или нажмите для выбора.", }, references: { all_missing: "Связанных данных не найдено", many_missing: "Некоторые из связанных данных недоступны", single_missing: "Связанный объект недоступен", }, password: { toggle_visible: "Скрыть пароль", toggle_hidden: "Показать пароль", }, }, message: { about: "Справка", access_denied: "У вас нет прав доступа к этой странице.", are_you_sure: "Вы уверены?", authentication_error: "Сервер аутентификации вернул ошибку и не смог проверить ваши учетные данные.", auth_error: "Произошла ошибка при валидации токена аутентификации", bulk_delete_content: "Вы уверены, что хотите удалить %{name}? |||| Вы уверены, что хотите удалить %{smart_count} объекта? |||| Вы уверены, что хотите удалить %{smart_count} объектов?", bulk_delete_title: "Удалить %{name} |||| Удалить %{smart_count} %{name} |||| Удалить %{smart_count} %{name}", bulk_update_content: "Вы уверены, что хотите обновить %{name}? |||| Вы уверены, что хотите обновить %{smart_count} объекта? |||| Вы уверены, что хотите обновить %{smart_count} объектов?", bulk_update_title: "Обновить %{name} |||| Обновить %{smart_count} %{name} |||| Обновить %{smart_count} %{name}", clear_array_input: "Вы уверены, что хотите очистить весь список?", delete_content: "Вы уверены что хотите удалить этот объект", delete_title: "Удалить %{name} %{recordRepresentation}", details: "Описание", error: "В процессе запроса возникла ошибка и он не может быть завершен", invalid_form: "Форма заполнена неверно. Проверьте, пожалуйста, ошибки.", loading: "Идет загрузка, пожалуйста, подождите...", no: "Нет", not_found: "Ошибка URL или вы перешли по неверной ссылке", placeholder_data_warning: "Проблема с сетью: не удалось обновить данные.", select_all_limit_reached: "Слишком много элементов для выбора. Были выбраны только первые %{max} элементов.", unsaved_changes: "Некоторые из ваших изменений не были сохранены. Вы уверены, что хотите их игнорировать?", yes: "Да", }, navigation: { clear_filters: "Все фильтры сбросить", no_filtered_results: "Нет результатов", no_results: "Результатов не найдено", no_more_results: "Номер страницы %{page} вне границ. Попробуйте перейти на предыдущую страницу.", page_out_of_boundaries: "Страница %{page} вне границ", page_out_from_end: "Невозможно переместиться дальше последней страницы", page_out_from_begin: "Номер страницы не может быть меньше 1", page_range_info: "%{offsetBegin}-%{offsetEnd} из %{total}", partial_page_range_info: "%{offsetBegin}-%{offsetEnd} из более %{offsetEnd}", current_page: "Страница %{page}", page: "На %{page} страницу", first: "На первую страницу", last: "На последнюю страницу", next: "Следующая", previous: "Предыдущая", page_rows_per_page: "Строк на странице:", skip_nav: "Перейти к содержанию", }, sort: { sort_by: "Сортировать по %{field_lower_first} в порядке %{order}", ASC: "возрастания", DESC: "убывания", }, auth: { auth_check_error: "Пожалуйста, авторизуйтесь для продолжения работы", user_menu: "Профиль", username: "Имя пользователя", password: "Пароль", email: "Электронная почта", sign_in: "Войти", sign_in_error: "Ошибка аутентификации, попробуйте снова", logout: "Выйти", }, notification: { updated: "Элемент обновлён |||| %{smart_count} элемента обновлено |||| %{smart_count} элементов обновлено", created: "Элемент создан", deleted: "Элемент удалён |||| %{smart_count} элемента удалено |||| %{smart_count} элементов удалено", bad_item: "Элемент не валиден", item_doesnt_exist: "Элемент не существует", http_error: "Ошибка сервера", data_provider_error: "Ошибка dataProvider, проверьте консоль", i18n_error: "Не удалось загрузить перевод для указанного языка", canceled: "Операция отменена", logged_out: "Ваша сессия завершена, попробуйте переподключиться/войти снова", not_authorized: "У вас нет доступа к этому ресурсу", application_update_available: "Имеется новая версия приложения.", offline: "Нет подключения. Не удалось загрузить данные.", }, validation: { required: "Обязательно для заполнения", minLength: "Минимальное кол-во символов %{min}", maxLength: "Максимальное кол-во символов %{max}", minValue: "Минимальное значение %{min}", maxValue: "Значение может быть %{max} или меньше", number: "Должно быть цифрой", email: "Некорректный email", oneOf: "Должно быть одним из: %{options}", regex: "Должно быть в формате (regexp): %{pattern}", unique: "Должно быть уникальным", }, saved_queries: { label: "Сохраненные запросы", query_name: "Имя запроса", new_label: "Сохранить текущий запрос...", new_dialog_title: "Сохранить текущий запрос как", remove_label: "Удалить сохраненный запрос", remove_label_with_name: 'Удалить запрос "%{name}"', remove_dialog_title: "Удалить сохраненный запрос?", remove_message: "Вы уверены, что хотите удалить этот запрос из списка сохраненных запросов?", help: "Отфильтровать список и сохранить запрос на будущее", }, guesser: { empty: { title: "Нет данных для отображения", message: "Проверьте поставщика данных", }, }, configurable: { customize: "Настроить", configureMode: "Настроить эту страницу", inspector: { title: "Инспектор", content: "Наведите на UI-элементы приложения, чтобы настроить", reset: "Сбросить настройки", hideAll: "Скрыть все", showAll: "Показать все", }, Datagrid: { title: "Таблица данных", unlabeled: "Безымянный столбец #%{column}", }, SimpleForm: { title: "Форма", unlabeled: "Безымянное поле ввода #%{input}", }, SimpleList: { title: "Список", primaryText: "Первичный текст", secondaryText: "Вторичный текст", tertiaryText: "Третичный текст", }, }, }, }; export default russianMessages; ================================================ FILE: src/i18n/ru/common.ts ================================================ import russianMessages from "./base"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const common: Record = { ...russianMessages, ketesa: { auth: { base_url: "Адрес домашнего сервера", welcome: "Добро пожаловать в %{name}", description: "Эволюция Synapse Admin. Управляйте, отслеживайте и обслуживайте свой Matrix-сервер через единый удобный интерфейс. Подходит как для небольших приватных серверов, так и для крупных федеративных сообществ.", server_version: "Версия Synapse", supports_specs: "поддерживает спецификации Matrix", username_error: "Пожалуйста, укажите полный ID пользователя: '@user:domain'", protocol_error: "Адрес должен начинаться с 'http://' или 'https://'", url_error: "Неверный адрес сервера Matrix", sso_sign_in: "Вход через SSO", credentials: "Учетные данные", access_token: "Токен доступа", logout_access_token_dialog: { title: "Вы используете существующий токен доступа Matrix.", content: "Вы хотите завершить эту сессию (которая может быть использована в другом месте, например, в клиенте Matrix) или просто выйти из панели администрирования?", confirm: "Завершить сессию", cancel: "Просто выйти из панели администрирования", }, }, users: { invalid_user_id: "Локальная часть ID пользователя Matrix без адреса домашнего сервера.", tabs: { sso: "SSO", experimental: "Экспериментальные", limits: "Ограничения", account_data: "Данные пользователя", sessions: "Сессии", }, danger_zone: "Опасная зона", }, rooms: { details: "Данные комнаты", tabs: { basic: "Основные", members: "Участники", detail: "Подробности", permission: "Права доступа", media: "Медиа", messages: "Сообщения", hierarchy: "Иерархия", }, }, reports: { tabs: { basic: "Основные", detail: "Подробности" } }, admin_config: { soft_failed_events: "События с мягким сбоем", spam_flagged_events: "События, помеченные как спам", success: "Конфигурация администратора обновлена", failure: "Не удалось обновить конфигурацию администратора", }, }, import_users: { error: { at_entry: "В записи %{entry}: %{message}", error: "Ошибка", required_field: "Отсутствует обязательное поле '%{field}'", invalid_value: "Неверное значение в строке %{row}. Поле '%{field}' может быть либо 'true', либо 'false'", unreasonably_big: "Отказано в загрузке слишком большого файла размером %{size} мегабайт", already_in_progress: "Импорт уже в процессе", id_exits: "ID %{id} уже существует", }, title: "Импорт пользователей из CSV", goToPdf: "Перейти к PDF", cards: { importstats: { header: "Сводка по импорту пользователей", users_total: "%{smart_count} пользователь в CSV файле |||| %{smart_count} пользователя в CSV файле |||| %{smart_count} пользователей в CSV файле", guest_count: "%{smart_count} гость |||| %{smart_count} гостя |||| %{smart_count} гостей", admin_count: "%{smart_count} администратор |||| %{smart_count} администратора |||| %{smart_count} администраторов", }, conflicts: { header: "Стратегия разрешения конфликтов", mode: { stop: "Остановка при конфликте", skip: "Показать ошибку и пропустить при конфликте", }, }, ids: { header: "Идентификаторы", all_ids_present: "Идентификаторы присутствуют в каждой записи", count_ids_present: "%{smart_count} запись с ID |||| %{smart_count} записи с ID |||| %{smart_count} записей с ID", mode: { ignore: "Игнорировать идентификаторы в CSV и создать новые", update: "Обновить существующие записи", }, }, passwords: { header: "Пароли", all_passwords_present: "Пароли присутствуют в каждой записи", count_passwords_present: "%{smart_count} запись с паролем |||| %{smart_count} записи с паролями |||| %{smart_count} записей с паролями", use_passwords: "Использовать пароли из CSV", }, upload: { header: "Загрузить CSV файл", explanation: "Здесь вы можете загрузить файл со значениями, разделёнными запятыми, которые будут использованы для создания или обновления данных пользователей. \ В файле должны быть поля 'id' и 'displayname'. Вы можете скачать и изменить файл-образец отсюда: ", }, startImport: { simulate_only: "Только симулировать", run_import: "Импорт", }, results: { header: "Результаты импорта", total: "%{smart_count} запись всего |||| %{smart_count} записи всего |||| %{smart_count} записей всего", successful: "%{smart_count} запись успешно импортирована |||| %{smart_count} записи успешно импортированы |||| %{smart_count} записей успешно импортированы", skipped: "%{smart_count} запись пропущена |||| %{smart_count} записи пропущены |||| %{smart_count} записей пропущено", download_skipped: "Скачать пропущенные записи", with_error: "%{smart_count} запись с ошибкой |||| %{smart_count} записи с ошибками |||| %{smart_count} записей с ошибками", simulated_only: "Импорт был симулирован", }, }, }, delete_media: { name: "Файлы", fields: { before_ts: "Последнее обращение до", size_gt: "Более чем (в байтах)", keep_profiles: "Сохранить аватары", }, action: { send: "Удалить файлы", send_success: "Успешно удалён %{smart_count} медиафайл. |||| Успешно удалено %{smart_count} медиафайла. |||| Успешно удалено %{smart_count} медиафайлов.", send_success_none: "Нет медиафайлов, соответствующих указанным критериям. Ничего не было удалено.", send_failure: "Произошла ошибка.", }, helper: { send: "Это API удаляет локальные файлы с вашего собственного сервера, включая локальные миниатюры и копии скачанных файлов. \ Данный API не затрагивает файлы, загруженные во внешние хранилища.", }, }, purge_remote_media: { name: "Внешние медиа", fields: { before_ts: "Последний доступ до", }, action: { send: "Очистить внешние медиа", send_success: "Успешно очищен %{smart_count} внешний медиафайл. |||| Успешно очищено %{smart_count} внешних медиафайла. |||| Успешно очищено %{smart_count} внешних медиафайлов.", send_success_none: "Нет внешних медиафайлов, соответствующих указанным критериям. Ничего не было очищено.", send_failure: "Произошла ошибка при запросе очистки внешних медиа.", }, helper: { send: "Этот API очищает кэш внешних медиа с диска вашего сервера. Это включает любые локальные миниатюры и копии загруженных медиа. Этот API не повлияет на медиа, которые были загружены в собственное медиа-хранилище сервера.", }, }, etkecc: { donate: { menu_label: "Пожертвовать", name: "Поддержать развитие Ketesa", title: "Поддержать развитие Ketesa", description_1: "Проект Ketesa распространяется свободно и с открытым исходным кодом, и мы открыто развиваем и поддерживаем его для сообщества Matrix.", description_2: "Если проект Ketesa оказался вам полезен, пожертвование помогает нам продолжать работу над ним: разработку, сопровождение, исправления и постоянные улучшения.", description_3: "Это помогает нам уделять больше времени тому, чтобы развивать проект для всех, кто на него полагается.", description_4: "Важен каждый вклад, и мы искренне благодарны вам за поддержку! ❤️", button: "Пожертвовать", signature_team: "команда etke.cc", }, components: { name: "Компоненты", description: "Просматривайте и управляйте активными компонентами, а также узнайте, что можно добавить на ваш сервер.", no_section: "Ваш сервер", per_month: "/мес.", included: "Включено", total: "Итого", loading: "Загрузка компонентов...", state_add: "Добавить", state_remove: "Удалить", add_aria: "Запросить добавление %{name}", remove_aria: "Запросить удаление %{name}", preview_label: "предпросмотр", request_changes: "Запросить изменения", requesting: "Отправка...", request_failure: "Не удалось отправить запрос на изменение. Пожалуйста, попробуйте снова.", request_sent_title: "Запрос отправлен", request_sent_body: "Ваш запрос на изменение компонентов был отправлен в службу поддержки etke.cc. Если вам необходимы дополнительные изменения, пожалуйста, ответьте на этот запрос поддержки, а не создавайте новый.", request_sent_close: "Закрыть", request_sent_view: "Посмотреть запрос", request_already_sent: "Запрос на изменение уже открыт. Для запроса дополнительных изменений ответьте на существующий тикет поддержки.", request_already_sent_view: "Посмотреть тикет", free_label: "Бесплатно", available_label: "Доступно", tagline: "Расширяйте возможности вашего сервера — добавляйте или удаляйте компоненты в любое время.", section: { bridges: "Мосты", extras: "Дополнения", matrix_apps: "Приложения Matrix", matrix_bots: "Боты Matrix", matrix_extras: "Дополнения Matrix", }, }, billing: { name: "Биллинг", title: "История платежей", no_payments: "Платежи не найдены.", no_payments_helper: "Если вы считаете, что это ошибка, пожалуйста, свяжитесь со службой поддержки etke.cc.", description1: "Здесь вы можете просматривать платежи и формировать счета. Подробнее об управлении подпиской — на", description2: "Чтобы изменить email для выставления счетов или добавить реквизиты компании в счета, см.", fields: { transaction_id: "ID транзакции", email: "Эл. почта", type: "Тип", amount: "Сумма", paid_at: "Дата оплаты", invoice: "Счёт", }, enums: { type: { subscription: "Подписка", one_time: "Разовый", }, }, helper: { download_invoice: "Скачать счёт", downloading: "Скачивание...", download_started: "Скачивание счёта началось.", invoice_not_available: "В ожидании", loading: "Загрузка биллинговой информации...", loading_failed1: "Возникла проблема при загрузке биллинговой информации.", loading_failed2: "Пожалуйста, попробуйте позже.", loading_failed3: "Если проблема сохраняется, пожалуйста, свяжитесь со службой поддержки etke.cc.", loading_failed4: "и сообщите следующее сообщение об ошибке:", }, components: "Активные компоненты", components_no_section: "Ваш сервер", components_per_month: "/мес.", components_included: "Включено", components_total: "Итого", components_help_title: "Подробнее о %{name}", components_state_install: "Установить", components_state_remove: "Удалить", components_remove_aria: "Установить/удалить %{name}", components_preview_label: "предпросмотр", components_request_changes: "Запросить изменения", components_requesting: "Отправка...", components_request_failure: "Не удалось отправить запрос на изменение. Пожалуйста, попробуйте снова.", components_request_sent_title: "Запрос отправлен", components_request_sent_body: "Ваш запрос на изменение компонентов был отправлен в службу поддержки etke.cc. Если вам необходимы дополнительные изменения, пожалуйста, ответьте на этот запрос поддержки, а не создавайте новый.", components_request_sent_close: "Закрыть", components_request_sent_view: "Посмотреть запрос", components_request_already_sent: "Запрос на изменение уже открыт. Для запроса дополнительных изменений ответьте на существующий тикет поддержки.", components_request_already_sent_view: "Посмотреть тикет", status: { issue: { title: "Подписка требует внимания", description: "Мы обнаружили проблему с вашей подпиской. Не беспокойтесь — её легко устранить.", due_overdue: "Просрочена с", due_upcoming: "До оплаты", expected: "Ожидаемая сумма", last_paid: "Последняя оплата", fix_link: "Исправить задолженность", fix_mismatch_link: "Обновить цену подписки", support_link: "Связаться с поддержкой", }, }, }, status: { name: "Статус сервера", badge: { default: "Нажмите, чтобы посмотреть статус сервера", running: "Запущено: %{command}. %{text}", status_ok: "Сервер в сети", status_error: "Статус: Ошибка", status_maintenance: "Система сейчас находится в режиме обслуживания.", status_process_running: "Сервер выполняет команду", status_checking: "Проверка состояния сервера", }, category: { "Host Metrics": "Метрики хоста", Network: "Сеть", HTTP: "HTTP", Matrix: "Matrix", }, status: "Статус", error: "Ошибка", loading: "Получаем данные о текущем статусе сервера в реальном времени… Подождите немного!", intro1: "Это отчёт мониторинга вашего сервера в реальном времени. Подробнее — на", intro2: "Если какая-либо из проверок ниже вас беспокоит, ознакомьтесь с рекомендуемыми действиями на", help: "Справка", }, maintenance: { title: "Система сейчас находится в режиме обслуживания.", try_again: "Пожалуйста, попробуйте позже.", note: "Не нужно обращаться в поддержку по этому поводу — мы уже работаем над этим!", }, actions: { name: "Серверные команды", available_title: "Доступные команды", available_description: "Ниже доступны команды для запуска.", available_help_intro: "Подробнее о каждой из них можно узнать на", scheduled_title: "Запланированные команды", scheduled_description: "Следующие команды запланированы на запуск в определённое время. Вы можете просмотреть их детали и изменить при необходимости.", recurring_title: "Повторяющиеся команды", recurring_description: "Следующие команды настроены на запуск в определённый день недели и время (еженедельно). Вы можете просмотреть их детали и изменить при необходимости.", scheduled_help_intro: "Подробнее о режиме можно узнать на", recurring_help_intro: "Подробнее о режиме можно узнать на", maintenance_title: "Система сейчас находится в режиме обслуживания.", maintenance_try_again: "Пожалуйста, попробуйте позже.", maintenance_note: "Не нужно обращаться в поддержку по этому поводу — мы уже работаем над этим!", maintenance_commands_blocked: "Команды нельзя запускать, пока режим обслуживания не будет отключён.", table: { aria_label: "Команды сервера", command: "Команда", description: "Описание", arguments: "Аргументы", is_recurring: "Повторяющаяся?", run_at: "Запуск (локальное время)", next_run_at: "Следующий запуск (локальное время)", time_utc: "Время (UTC)", time_local: "Время (локальное время)", }, buttons: { create: "Создать", update: "Обновить", back: "Назад", delete: "Удалить", run: "Запустить", }, command_scheduled: "Команда запланирована: %{command}", command_scheduled_args: "с дополнительными аргументами: %{args}", expect_prefix: "Результат появится на странице", expect_suffix: "в ближайшее время.", notifications_link: "Уведомления", command_help_title: "Справка по %{command}", scheduled_title_create: "Создать запланированную команду", scheduled_title_edit: "Редактировать запланированную команду", recurring_title_create: "Создать повторяющуюся команду", recurring_title_edit: "Редактировать повторяющуюся команду", scheduled_details_title: "Детали запланированной команды", recurring_warning: "Запланированные команды, созданные из повторяющейся, нельзя редактировать: они будут создаваться заново автоматически. Пожалуйста, измените повторяющуюся команду.", command_details_intro: "Подробнее о команде можно узнать на", form: { id: "ID", command: "Команда", scheduled_at: "Запланировано на", day_of_week: "День недели", }, delete_scheduled_title: "Удалить запланированную команду", delete_recurring_title: "Удалить повторяющуюся команду", delete_confirm: "Вы уверены, что хотите удалить команду: %{command}?", errors: { unknown: "Произошла неизвестная ошибка", delete_failed: "Ошибка: %{error}", }, days: { monday: "Понедельник", tuesday: "Вторник", wednesday: "Среда", thursday: "Четверг", friday: "Пятница", saturday: "Суббота", sunday: "Воскресенье", }, scheduled: { action: { create_success: "Запланированная команда создана успешно", update_success: "Запланированная команда обновлена успешно", update_failure: "Произошла ошибка", delete_success: "Запланированная команда удалена успешно", delete_failure: "Произошла ошибка", }, }, recurring: { action: { create_success: "Повторяющаяся команда создана успешно", update_success: "Повторяющаяся команда обновлена успешно", update_failure: "Произошла ошибка", delete_success: "Повторяющаяся команда удалена успешно", delete_failure: "Произошла ошибка", }, }, }, notifications: { title: "Уведомления", new_notifications: "%{smart_count} новое уведомление |||| %{smart_count} новых уведомления |||| %{smart_count} новых уведомлений", no_notifications: "Пока уведомлений нет", see_all: "Посмотреть все уведомления", clear_all: "Очистить все", ago: "назад", advisory_tooltip: "Возможно, Вы пропустили уведомление. Пожалуйста, также проверьте #news:etke.cc, etke.cc/news или Вашу электронную почту.", unavailable_tooltip: "Уведомления могут быть недоступны. Нажмите для получения подробностей.", unavailable_title: "Уведомления могут быть временно недоступны", unavailable_body: "Возможно, есть обновления, которые не удаётся доставить в эту панель прямо сейчас — или же ничего нового нет. Чтобы ничего не пропустить, пожалуйста, периодически проверяйте:", unavailable_link_matrix: "Комната Matrix #news:etke.cc", unavailable_link_news: "Страница объявлений на etke.cc/news", unavailable_link_email: "Ваш почтовый ящик (включая папку со спамом)", unavailable_retry: "Повторить", }, currently_running: { command: "Сейчас запущено:", started_ago: "(начато %{time} назад)", }, time: { less_than_minute: "несколько секунд", minutes: "%{smart_count} минуту |||| %{smart_count} минуты |||| %{smart_count} минут", hours: "%{smart_count} час |||| %{smart_count} часа |||| %{smart_count} часов", days: "%{smart_count} день |||| %{smart_count} дня |||| %{smart_count} дней", weeks: "%{smart_count} неделю |||| %{smart_count} недели |||| %{smart_count} недель", months: "%{smart_count} месяц |||| %{smart_count} месяца |||| %{smart_count} месяцев", }, support: { name: "Поддержка", menu_label: "Связаться с поддержкой", description: "Откройте запрос в поддержку или продолжите работу с существующим. Наша команда ответит как можно скорее.", create_title: "Новый запрос в поддержку", no_requests: "Запросов в поддержку пока нет.", no_messages: "Сообщений пока нет.", closed_message: "Этот запрос закрыт. Если у вас всё ещё есть проблема, пожалуйста, создайте новый.", fields: { subject: "Тема", message: "Сообщение", reply: "Ответ", status: "Статус", created_at: "Создан", updated_at: "Последнее обновление", }, status: { active: "Ожидание оператора", open: "Открыт", closed: "Закрыт", pending: "Ожидание вашего ответа", }, buttons: { new_request: "Новый запрос", submit: "Отправить", cancel: "Отмена", send: "Отправить", back: "Вернуться в поддержку", attach_files: "Прикрепить файлы", }, helper: { loading: "Загрузка запросов в поддержку...", reply_hint: "Ctrl+Enter для отправки", reply_placeholder: "Укажите как можно больше деталей.", before_contact_title: "Прежде чем связаться с нами", help_pages_prompt: "Пожалуйста, сначала ознакомьтесь с разделом помощи:", services_prompt: "Мы предоставляем только услуги, перечисленные на странице услуг:", topics_prompt: "Мы можем помочь только по поддерживаемым темам:", scope_confirm_label: "Я ознакомился с разделом помощи и подтверждаю, что запрос соответствует поддерживаемым темам.", english_only_notice: "Поддержка предоставляется только на английском языке.", response_time_prompt: "Ответ в течение 48 часов. Нужен более быстрый ответ? См.:", attachments_limit: "До 5 файлов, 5 МБ каждый, 10 МБ всего.", close_request_label: "Закрыть запрос после отправки", }, actions: { create_success: "Запрос в поддержку успешно создан.", create_failure: "Не удалось создать запрос в поддержку.", send_failure: "Не удалось отправить сообщение.", attachment_too_large: "Файл «%{name}» превышает ограничение в 5 МБ.", too_many_attachments: "Максимум 5 файлов.", total_size_exceeded: "Общий размер вложений превышает 10 МБ.", }, }, }, }; export default common; ================================================ FILE: src/i18n/ru/index.ts ================================================ import { SynapseTranslationMessages } from "../types"; import common from "./common"; import mas from "./mas"; import misc_resources from "./misc_resources"; import reports from "./reports"; import rooms from "./rooms"; import users from "./users"; const ru: SynapseTranslationMessages = { ra: common.ra, ketesa: common.ketesa, import_users: common.import_users, delete_media: common.delete_media, purge_remote_media: common.purge_remote_media, etkecc: common.etkecc, resources: { users, rooms, reports, scheduled_tasks: misc_resources.scheduled_tasks, connections: misc_resources.connections, devices: misc_resources.devices, users_media: misc_resources.users_media, protect_media: misc_resources.protect_media, quarantine_media: misc_resources.quarantine_media, pushers: misc_resources.pushers, servernotices: misc_resources.servernotices, database_room_statistics: misc_resources.database_room_statistics, user_media_statistics: misc_resources.user_media_statistics, forward_extremities: misc_resources.forward_extremities, room_state: misc_resources.room_state, room_media: misc_resources.room_media, room_directory: misc_resources.room_directory, destinations: misc_resources.destinations, registration_tokens: misc_resources.registration_tokens, account_data: misc_resources.account_data, joined_rooms: misc_resources.joined_rooms, memberships: misc_resources.memberships, room_members: misc_resources.room_members, destination_rooms: misc_resources.destination_rooms, ...mas, }, }; export default ru; ================================================ FILE: src/i18n/ru/mas.ts ================================================ const mas = { mas_users: { name: "MAS-пользователь |||| MAS-пользователи", fields: { id: "MAS-идентификатор", username: "Имя пользователя", admin: "Администратор", locked: "Заблокирован", deactivated: "Деактивирован", legacy_guest: "Устаревший гость", created_at: "Создан", locked_at: "Заблокирован", deactivated_at: "Деактивирован", }, filter: { status: "Статус", search: "Поиск", status_active: "Активен", status_locked: "Заблокирован", status_deactivated: "Деактивирован", }, action: { lock: { label: "Заблокировать", success: "Пользователь заблокирован" }, unlock: { label: "Разблокировать", success: "Пользователь разблокирован" }, deactivate: { label: "Деактивировать", success: "Пользователь деактивирован" }, reactivate: { label: "Реактивировать", success: "Пользователь реактивирован" }, set_admin: { label: "Назначить администратором", success: "Статус администратора обновлён" }, remove_admin: { label: "Снять права администратора", success: "Статус администратора обновлён" }, set_password: { label: "Установить пароль", title: "Установить пароль", success: "Пароль установлен", failure: "Не удалось установить пароль", }, }, }, mas_user_emails: { name: "Эл. почта |||| Эл. почта", empty: "Нет эл. адресов", fields: { email: "Эл. почта", user_id: "ID пользователя", created_at: "Создан", actions: "Действия", }, action: { remove: { label: "Удалить", title: "Удалить эл. адрес", content: "Удалить %{email}?", success: "Эл. адрес удалён", }, create: { success: "Эл. адрес добавлен" }, }, }, mas_compat_sessions: { name: "Совместимая сессия |||| Совместимые сессии", empty: "Нет совместимых сессий", fields: { user_id: "ID пользователя", device_id: "ID устройства", created_at: "Создана", user_agent: "Юзер-агент", last_active_at: "Последняя активность", last_active_ip: "Последний IP", finished_at: "Завершена", human_name: "Название", active: "Активна", }, action: { finish: { label: "Завершить", title: "Завершить сессию?", content: "Сессия будет завершена.", success: "Сессия завершена", }, }, }, mas_oauth2_sessions: { name: "OAuth2-сессия |||| OAuth2-сессии", empty: "Нет OAuth2-сессий", fields: { user_id: "ID пользователя", client_id: "ID клиента", scope: "Область доступа", created_at: "Создана", user_agent: "Юзер-агент", last_active_at: "Последняя активность", last_active_ip: "Последний IP", finished_at: "Завершена", human_name: "Название", active: "Активна", }, action: { finish: { label: "Завершить", title: "Завершить сессию?", content: "Сессия будет завершена.", success: "Сессия завершена", }, }, }, mas_policy_data: { name: "Данные политики", current_policy: "Текущая политика", no_policy: "Политика в настоящее время не задана.", set_policy: "Задать новую политику", invalid_json: "Некорректный JSON", fields: { json_placeholder: "Введите данные политики в формате JSON…", created_at: "Создано", }, action: { save: { label: "Задать политику", success: "Политика обновлена", failure: "Не удалось обновить политику", }, }, }, mas_user_sessions: { name: "Сессия браузера |||| Сессии браузера", fields: { user_id: "ID пользователя", created_at: "Создана", finished_at: "Завершена", user_agent: "Юзер-агент", last_active_at: "Последняя активность", last_active_ip: "Последний IP", active: "Активна", }, action: { finish: { label: "Завершить", title: "Завершить сессию?", content: "Браузерная сессия будет завершена.", success: "Сессия завершена", }, }, }, mas_upstream_oauth_links: { name: "OAuth-связь |||| OAuth-связи", fields: { user_id: "ID пользователя", provider_id: "ID провайдера", subject: "Субъект", human_account_name: "Имя аккаунта", created_at: "Создана", }, helper: { provider_id: "ID стороннего OAuth-провайдера. Найдите его в списке OAuth-провайдеров.", }, action: { remove: { label: "Удалить", title: "Удалить OAuth-связь?", content: "OAuth-связь для этого пользователя будет удалена.", success: "OAuth-связь удалена", }, }, }, mas_upstream_oauth_providers: { name: "OAuth-провайдер |||| OAuth-провайдеры", fields: { issuer: "Издатель", human_name: "Название", brand_name: "Бренд", created_at: "Создан", disabled_at: "Отключён", enabled: "Включён", }, }, mas_personal_sessions: { name: "Персональная сессия |||| Персональные сессии", empty: "Нет персональных сессий", fields: { owner_user_id: "ID владельца", actor_user_id: "Пользователь", human_name: "Название", scope: "Область доступа", created_at: "Создана", revoked_at: "Отозвана", last_active_at: "Последняя активность", last_active_ip: "Последний IP", expires_at: "Истекает", expires_in: "Истекает через (секунды)", active: "Активна", }, helper: { expires_in: "Необязательно. Количество секунд до истечения токена. Оставьте пустым для бессрочного токена.", }, action: { revoke: { label: "Отозвать", title: "Отозвать сессию?", content: "Токен доступа будет безвозвратно отозван.", success: "Сессия отозвана", }, create: { token_title: "Токен доступа создан", token_content: "Скопируйте токен. После закрытия этого окна он больше не будет доступен.", }, }, }, mas_sessions: { status: { active: "Активна", finished: "Завершена", revoked: "Отозвана", }, }, }; export default mas; ================================================ FILE: src/i18n/ru/misc_resources.ts ================================================ const misc_resources = { scheduled_tasks: { name: "Запланированная задача |||| Запланированные задачи", fields: { id: "ID", action: "Действие", status: "Статус", timestamp: "Временная метка", resource_id: "ID ресурса", result: "Результат", error: "Ошибка", max_timestamp: "До даты", }, status: { scheduled: "Запланирована", active: "Активна", complete: "Завершена", cancelled: "Отменена", failed: "Не выполнена", }, }, connections: { name: "Подключения", fields: { last_seen: "Дата", ip: "IP адрес", user_agent: "Юзер-агент", }, }, devices: { name: "Устройство |||| Устройства", fields: { device_id: "ID устройства", display_name: "Название", last_seen_ts: "Дата и время", last_seen_ip: "IP адрес", last_seen_user_agent: "Юзер-агент", dehydrated: "Дегидратировано", }, action: { erase: { title: "Удаление %{id}", title_bulk: "Удаление %{smart_count} устройства |||| Удаление %{smart_count} устройств |||| Удаление %{smart_count} устройств", content: 'Действительно удалить устройство "%{name}"?', content_bulk: "Действительно удалить %{smart_count} устройство? |||| Действительно удалить %{smart_count} устройства? |||| Действительно удалить %{smart_count} устройств?", success: "Устройство успешно удалено.", failure: "Произошла ошибка.", }, display_name: { success: "Название устройства обновлено", failure: "Не удалось обновить название устройства", }, create: { label: "Создать устройство", title: "Создание нового устройства", success: "Устройство создано", failure: "Не удалось создать устройство", }, }, }, users_media: { name: "Файлы", fields: { media_id: "ID файла", media_length: "Размер файла (в байтах)", media_type: "Тип", upload_name: "Имя файла", quarantined_by: "На карантине", safe_from_quarantine: "Защитить от карантина", created_ts: "Создано", last_access_ts: "Последний доступ", }, action: { open: "Открыть файл в новом окне", }, }, protect_media: { action: { create: "Защитить", delete: "Снять защиту", none: "На карантине", send_success: "Статус защиты успешно изменён.", send_failure: "Произошла ошибка.", }, }, quarantine_media: { action: { name: "Карантин", create: "Карантин", delete: "Снять карантин", none: "Защищено", send_success: "Статус карантина успешно изменён.", send_failure: "Произошла ошибка: %{error}", }, }, pushers: { name: "Пушер |||| Пушеры", fields: { app: "Приложение", app_display_name: "Название приложения", app_id: "ID приложения", device_display_name: "Название устройства", kind: "Вид", lang: "Язык", profile_tag: "Тег профиля", pushkey: "Ключ", data: { url: "URL" }, }, }, servernotices: { name: "Серверные уведомления", send: "Отправить серверные уведомления", fields: { body: "Сообщение", }, action: { send: "Отправить", send_success: "Серверное уведомление успешно отправлено.", send_failure: "Произошла ошибка.", }, helper: { send: 'Отправить серверное уведомление выбранным пользователям. На сервере должна быть активна функция "Server Notices".', }, }, database_room_statistics: { name: "Статистика БД по комнатам", fields: { room_id: "ID комнаты", estimated_size: "Примерный размер", }, helper: { info: "Отображает приблизительный объём дискового пространства, занятого каждой комнатой в базе данных Synapse. Цифры являются приблизительными.", }, }, user_media_statistics: { name: "Файлы", fields: { media_count: "Количество файлов", media_length: "Размер файлов", }, }, forward_extremities: { name: "Оконечности", fields: { id: "ID события", received_ts: "Дата и время", depth: "Глубина", state_group: "Группа состояния", }, }, room_state: { name: "События состояния", fields: { type: "Тип", content: "Содержимое", origin_server_ts: "Дата отправки", sender: "Отправитель", }, }, room_media: { name: "Медиа", fields: { media_id: "ID медиа", }, helper: { info: "Это список медиа, которые были загружены в комнату. Невозможно удалить медиа, которые были загружены во внешние медиа-репозитории.", }, action: { error: "%{errcode} (%{errstatus}) %{error}", }, }, room_directory: { name: "Каталог комнат", fields: { world_readable: "Гости могут просматривать без входа", guest_can_join: "Гости могут войти", }, action: { title: "Удалить комнату из каталога |||| Удалить %{smart_count} комнаты из каталога |||| Удалить %{smart_count} комнат из каталога", content: "Действительно удалить комнату из каталога? |||| Действительно удалить %{smart_count} комнаты из каталога? |||| Действительно удалить %{smart_count} комнат из каталога?", erase: "Удалить из каталога комнат", create: "Опубликовать в каталоге комнат", send_success: "Комната успешно опубликована.", send_failure: "Произошла ошибка.", }, }, destinations: { name: "Федерация", fields: { destination: "Назначение", failure_ts: "Дата и время ошибки", retry_last_ts: "Дата и время последней попытки", retry_interval: "Интервал между попытками", last_successful_stream_ordering: "Последний успешный поток", stream_ordering: "Поток", }, action: { reconnect: "Переподключиться" }, }, registration_tokens: { name: "Токены регистрации", fields: { token: "Токен", valid: "Рабочий токен", uses_allowed: "Количество использований", pending: "Ожидает", completed: "Завершено", expiry_time: "Дата окончания", length: "Длина", created_at: "Дата создания", last_used_at: "Последнее использование", revoked_at: "Дата отзыва", }, helper: { length: "Длина токена, если токен не задан." }, action: { revoke: { label: "Отозвать", success: "Токен отозван", }, unrevoke: { label: "Восстановить", success: "Токен восстановлен", }, }, }, account_data: { name: "Данные пользователя", }, joined_rooms: { name: "Участие в комнатах", }, memberships: { name: "Членства", }, room_members: { name: "Участники", }, destination_rooms: { name: "Комнаты", }, }; export default misc_resources; ================================================ FILE: src/i18n/ru/reports.ts ================================================ const reports = { name: "Жалоба |||| Жалобы", fields: { id: "ID", received_ts: "Время жалобы", user_id: "Заявитель", name: "Название комнаты", score: "Оценка", reason: "Причина", event_id: "ID события", sender: "Отправитель", }, action: { erase: { title: "Удалить жалобу", content: "Действительно удалить жалобу? Это действие будет невозможно отменить.", }, event_lookup: { label: "Поиск события", title: "Получить событие по ID", fetch: "Получить", }, fetch_event_error: "Не удалось получить событие", }, }; export default reports; ================================================ FILE: src/i18n/ru/rooms.ts ================================================ const rooms = { name: "Комната |||| Комнаты", fields: { room_id: "ID комнаты", name: "Название", canonical_alias: "Псевдоним", joined_members: "Участники", joined_local_members: "Локальные участники", joined_local_devices: "Локальные устройства", state_events: "События состояния / Сложность", version: "Версия", is_encrypted: "Зашифровано", encryption: "Шифрование", federatable: "Федерация", public: "Отображается в каталоге комнат", creator: "Создатель", join_rules: "Правила входа", guest_access: "Гостевой доступ", history_visibility: "Видимость истории", topic: "Тема", avatar: "Аватар", actions: "Действия", }, filter: { public_rooms: "Публичные комнаты", empty_rooms: "Пустые комнаты", local_members_only: "Только локальные участники", }, helper: { forward_extremities: "Оконечности — это события-листья в конце ориентированного ациклического графа (DAG) в комнате, т.е. события без дочерних элементов. \ Чем больше их в комнате, тем больше Synapse работает над разрешением состояния (это дорогостоящая операция). \ Хотя Synapse старается не допускать существования слишком большого числа таких событий в комнате, из-за ошибок они иногда снова появляются. \ Если в комнате >10 оконечностей, стоит изучить ситуацию и попробовать удалить их с помощью SQL-запросов из #1760.", }, enums: { join_rules: { public: "Для всех", knock: "Надо постучать", invite: "По приглашению", private: "Приватная", restricted: "Ограниченный доступ", }, guest_access: { can_join: "Гости могут войти", forbidden: "Гости не могут войти", }, history_visibility: { invited: "С момента приглашения", joined: "С момента входа", shared: "С момента открытия доступа", world_readable: "Для всех", }, unencrypted: "Без шифрования", room_type: { room: "Комната", space: "Пространство", }, }, action: { erase: { title: "Удалить комнату", content: "Действительно удалить эту комнату? Это действие будет невозможно отменить. Все сообщения и файлы в комнате будут удалены с сервера!", fields: { block: "Заблокировать и запретить пользователям присоединяться к комнате", }, in_progress: "Удаление выполняется…", background_note: "Вы можете закрыть это окно, удаление продолжится в фоновом режиме.", success: "Комната успешно удалена. |||| Комнаты успешно удалены. |||| Комнат успешно удалено.", failure: "Комната не может быть удалена. |||| Комнаты не могут быть удалены. |||| Комнат не удалось удалить.", }, make_admin: { assign_admin: "Назначить администратора", title: "Назначить администратора комнате %{roomName}", confirm: "Назначить администратора", content: "Введите полный MXID пользователя, которого нужно назначить администратором.\nПредупреждение: для этого должен быть назначен хотя бы один локальный участник в качестве администратора.", success: "Пользователь назначен администратором комнаты.", failure: "Пользователь не может быть назначен администратором комнаты. %{errMsg}", }, join: { label: "Добавить пользователя", title: "Добавить пользователя в %{roomName}", confirm: "Добавить", content: "Введите полный MXID пользователя, которого нужно присоединить к этой комнате.\nПримечание: вы должны быть в комнате и иметь право приглашать пользователей.", success: "Пользователь успешно добавлен в комнату.", failure: "Не удалось добавить пользователя в комнату. %{errMsg}", }, block: { label: "Заблокировать", title: "Заблокировать %{room}", title_bulk: "Заблокировать %{smart_count} комнату |||| Заблокировать %{smart_count} комнаты |||| Заблокировать %{smart_count} комнат", title_by_id: "Заблокировать комнату", content: "Пользователи не смогут присоединиться к этой комнате.", content_bulk: "Пользователи не смогут присоединиться к %{smart_count} комнате. |||| Пользователи не смогут присоединиться к %{smart_count} комнатам. |||| Пользователи не смогут присоединиться к %{smart_count} комнатам.", success: "Комната успешно заблокирована. |||| Комнаты успешно заблокированы. |||| Комнат успешно заблокировано.", failure: "Не удалось заблокировать комнату. |||| Не удалось заблокировать комнаты. |||| Не удалось заблокировать комнат.", }, unblock: { label: "Разблокировать", success: "Комната успешно разблокирована. |||| Комнаты успешно разблокированы. |||| Комнат успешно разблокировано.", failure: "Не удалось разблокировать комнату. |||| Не удалось разблокировать комнаты. |||| Не удалось разблокировать комнат.", }, purge_history: { label: "Очистить историю", title: "Очистить историю %{roomName}", content: "Все события до выбранной даты будут удалены из базы данных. Состояние комнаты (входы, выходы, тема) всегда сохраняется. Как минимум одно сообщение всегда остаётся.\nПримечание: эта операция может занять несколько минут для больших комнат.", date_label: "Очистить события до", delete_local: "Также удалить события локальных пользователей", in_progress: "Очистка выполняется…", background_note: "Вы можете закрыть это окно, очистка продолжится в фоновом режиме.", success: "История комнаты успешно очищена.", failure: "Не удалось очистить историю комнаты. %{errMsg}", }, quarantine_all: { label: "Поместить все медиа на карантин", title: "Поместить на карантин все медиа в %{roomName}", content: "Все локальные и удалённые медиа в этой комнате будут помещены на карантин. Медиа на карантине станут недоступны для пользователей.", success: "Успешно помещено на карантин %{smart_count} медиа-элемент. |||| Успешно помещено на карантин %{smart_count} медиа-элемента. |||| Успешно помещено на карантин %{smart_count} медиа-элементов.", failure: "Не удалось поместить медиа на карантин. %{errMsg}", }, delete_all_media: { label: "Удалить все файлы", title: "Удалить все файлы в %{roomName}", content: "Все локальные файлы в этой комнате будут безвозвратно удалены. Затрагиваются только локальные файлы из незашифрованных комнат — файлы с внешних серверов исключены. Это действие нельзя отменить.", in_progress_loading: "Получение списка файлов…", in_progress: "Удаление файлов… (%{current} / %{total})", do_not_close: "Не закрывайте это окно — удаление выполняется синхронно и прервётся при закрытии.", success: "Успешно удалён %{smart_count} файл. |||| Успешно удалено %{smart_count} файла. |||| Успешно удалено %{smart_count} файлов.", failure: "Не удалось удалить файлы. %{errMsg}", }, delete_all_media_bulk: { title: "Удалить все файлы для %{smart_count} комнаты? |||| Удалить все файлы для %{smart_count} комнаты? |||| Удалить все файлы для %{smart_count} комнат?", content: "Все локальные файлы в выбранных комнатах будут безвозвратно удалены (только незашифрованные комнаты). Файлы с внешних серверов исключены. Это действие нельзя отменить.", success: "Файлы удалены в %{success} из %{total} комнат.", partial_failure: "Файлы удалены в %{success} из %{total} комнат. Ошибка для %{failed}.", }, event_context: { lookup_title: "Поиск события по ID", jump_to_date: "Перейти к дате", direction: "Направление", forward: "Вперёд", backward: "Назад", target_event: "Целевое событие", events_before: "Событий до", events_after: "Событий после", not_found: "Событие на указанное время не найдено", failure: "Не удалось получить контекст события", }, messages: { load_older: "Загрузить более старые", load_newer: "Загрузить более новые", no_messages: "В этой комнате нет сообщений", failure: "Не удалось загрузить сообщения", filter: "Фильтры", filter_type: "Типы событий", filter_sender: "Отправители", advanced_filters: "Расширенные фильтры", filter_not_type: "Исключить типы событий", filter_not_sender: "Исключить отправителей", contains_url: "Содержит URL", any: "Любой", with_url: "Только с URL", without_url: "Только без URL", apply_filter: "Применить", clear_filters: "Сбросить", }, hierarchy: { load_more: "Загрузить ещё", max_depth: "Максимальная глубина", unlimited: "Без ограничений", refresh: "Обновить", members: "%{count} участников", space: "Пространство", room: "Комната", suggested: "Рекомендуемая", no_children: "У этой комнаты нет дочерних комнат", failure: "Не удалось загрузить иерархию", }, }, }; export default rooms; ================================================ FILE: src/i18n/ru/users.ts ================================================ const users = { name: "Пользователь |||| Пользователи", email: "Почта", msisdn: "Телефон", threepid: "Почта / Телефон", membership: "Участие |||| Участия", fields: { avatar: "Аватар", id: "ID пользователя", name: "Имя", is_guest: "Гость", admin: "Администратор сервера", locked: "Заблокирован", suspended: "Приостановлен", shadow_banned: "Теневой бан", deactivated: "Деактивирован", erased: "Удалён", show_guests: "Показывать гостей", show_deactivated: "Только деактивированные", show_locked: "Показывать заблокированных", filter_user_all: "Все", filter_deactivated_false: "Активные", filter_deactivated_true: "Деактивированные", filter_locked_false: "Исключить заблокированных", filter_locked_true: "Включить заблокированных", filter_guests_false: "Исключить гостей", filter_guests_true: "Включить гостей", show_system_users: "Системные пользователи", filter_system_users_false: "Скрыть системных", filter_system_users_true: "Только системные", show_suspended: "Показывать приостановленных", show_shadow_banned: "Показывать с теневым баном", user_id: "Поиск пользователя", displayname: "Отображаемое имя", password: "Пароль", avatar_url: "Адрес аватара", avatar_src: "Аватар", medium: "Тип", threepids: "3PID'ы", address: "Адрес", creation_ts_ms: "Дата создания", consent_version: "Версия соглашения", sent_invite_count: "Отправленные приглашения", cumulative_joined_room_count: "Всего комнат", auth_provider: "Провайдер", user_type: "Тип пользователя", }, helper: { password: "Смена пароля завершит все сессии пользователя.", password_required_for_reactivation: "Вы должны предоставить пароль для реактивации учётной записи.", create_password: "Сгенерировать надёжный и безопасный пароль, используя кнопку ниже.", deactivate: "Деактивация учётной записи предотвращает вход пользователя на сервер.", suspend: "Приостановка учётной записи означает, что пользователь не сможет войти в свою учётную запись, пока она не будет снова активирована.", shadow_ban: "Пользователь с теневым баном получает обычные ответы, но его события не распространяются другим пользователям или в комнаты. Используйте только в крайнем случае.", erase: "Пометить пользователя как удалённого в соответствии с GDPR", admin: "Администратор сервера имеет полный контроль над сервером и его пользователями.", lock: "Предотвращает использование пользователем сервера. Это неразрушающее действие, которое может быть отменено.", erase_text: "Это означает, что сообщения, отправленные пользователем (-ами), будут по-прежнему видны всем, кто находился в комнате в момент их отправки, но будут скрыты от пользователей, присоединившихся к комнате после этого.", erase_admin_error: "Удаление собственного пользователя запрещено.", modify_managed_user_error: "Изменение пользователя, управляемого системой, не допускается.", username_available: "Имя пользователя доступно", sent_invite_count: "Общее количество приглашений, отправленных этим пользователем во всех комнатах.", cumulative_joined_room_count: "Общее количество комнат, в которые пользователь когда-либо входил, включая те, которые он покинул или из которых был забанен.", }, badge: { you: "Вы", bot: "Бот", admin: "Админ", support: "Поддержка", regular: "Обычный пользователь", federated: "Внешний пользователь", system_managed: "Управляемый системой", }, action: { erase: "Удалить данные пользователя", erase_avatar: "Удалить аватар", delete_media: "Удаление всех файлов, загруженных пользователем (-ами)", redact_events: "Удаление всех событий, отправленных пользователем (-ами)", redact_in_progress: "Удаление событий выполняется\u2026", redact_background_note: "Вы можете закрыть это окно, удаление событий продолжится в фоновом режиме.", redact_success: "Все события успешно удалены.", redact_failure: "Удаление завершено с %{smart_count} неудачным событием. |||| Удаление завершено с %{smart_count} неудачными событиями. |||| Удаление завершено с %{smart_count} неудачными событиями.", generate_password: "Сгенерировать пароль", reset_password: { label: "Сбросить пароль", title: "Сбросить пароль", helper: "Изменить пароль %{user}", password: "Пароль", logout_devices: "Выйти со всех устройств", success: "Пароль успешно сброшен", failure: "Не удалось сбросить пароль", error_no_password: "Необходимо указать пароль", }, login_as: { label: "Войти как пользователь", title: "Войти как пользователь", helper: "Получить токен доступа для аутентификации от имени %{user}. Это действие не создаёт новое устройство для пользователя, поэтому оно не появится в списке устройств/сессий, и пользователь, как правило, не сможет узнать о входе.", valid_until: "Установить срок действия", success: "Токен доступа успешно создан", failure: "Не удалось создать токен доступа", result_title: "Токен доступа %{user}", access_token: "Токен доступа", expires_at: "Срок действия токена истекает %{date}", }, overwrite_title: "Предупреждение!", overwrite_content: "Это имя пользователя уже занято. Вы уверены, что хотите перезаписать существующего пользователя?", overwrite_cancel: "Отмена", overwrite_confirm: "Перезаписать", quarantine_all: { label: "Поместить все медиа на карантин", title: "Поместить на карантин все медиа %{userName}", content: "Все локальные медиа, загруженные этим пользователем, будут помещены на карантин. Медиа на карантине станут недоступны для других пользователей.", success: "Успешно помещено на карантин %{smart_count} медиа-элемент. |||| Успешно помещено на карантин %{smart_count} медиа-элемента. |||| Успешно помещено на карантин %{smart_count} медиа-элементов.", failure: "Не удалось поместить медиа на карантин. %{errMsg}", }, delete_all_media: { label: "Удалить все файлы", title: "Удалить все файлы %{userName}", content: "Все файлы, загруженные этим пользователем, будут безвозвратно удалены. Это действие нельзя отменить.", in_progress: "Удаление файлов…", background_note: "Вы можете закрыть это окно — удаление продолжится в фоновом режиме.", success: "Успешно удалено %{smart_count} файл. |||| Успешно удалено %{smart_count} файла. |||| Успешно удалено %{smart_count} файлов.", failure: "Не удалось удалить файлы. %{errMsg}", }, delete_all_media_bulk: { title: "Удалить все файлы для %{smart_count} пользователя? |||| Удалить все файлы для %{smart_count} пользователя? |||| Удалить все файлы для %{smart_count} пользователей?", content: "Все файлы, загруженные выбранными пользователями, будут безвозвратно удалены. Это действие нельзя отменить.", success: "Файлы удалены у %{success} из %{total} пользователей.", partial_failure: "Файлы удалены у %{success} из %{total} пользователей. Ошибка для %{failed}.", }, allow_cross_signing: { label: "Разрешить сброс перекрёстной подписи", title: "Разрешить замену ключей перекрёстной подписи", content: "Разрешить %{user} заменить ключи перекрёстной подписи без интерактивной аутентификации? Это создаст временное окно, в течение которого ключи могут быть заменены.", success: "Замена ключей перекрёстной подписи разрешена до %{deadline}", failure: "Не удалось разрешить замену перекрёстной подписи", no_key: "У пользователя нет главного ключа перекрёстной подписи", }, find_user: { label: "Найти пользователя", title: "Найти пользователя", lookup_type: "Тип поиска", by_threepid: "По email / телефону", by_auth_provider: "По поставщику аутентификации", provider: "ID поставщика аутентификации", external_id: "Внешний ID", search: "Найти", not_found: "Пользователь не найден", failure: "Не удалось найти пользователя", }, renew_account: { label: "Продлить аккаунт", title: "Продлить срок действия аккаунта", content: "Продлить срок действия аккаунта %{user}. Можно указать произвольную дату истечения срока. Если оставить пустым, будет использован стандартный период продления сервера.", expiration: "Дата истечения срока", expiration_helper: "Оставьте пустым, чтобы использовать стандартный период продления сервера", renewal_emails: "Отправлять уведомления о продлении по электронной почте", success: "Срок действия аккаунта продлён до %{date}", failure: "Не удалось продлить срок действия аккаунта", }, system_users_scan_in_progress: "Подождите — поиск подходящих пользователей ещё продолжается, страница загрузится в ближайшее время", reverse_search_scan_in_progress: "Подождите — выполняется сканирование всех пользователей для исключения совпадений, страница загрузится в ближайшее время", }, limits: { messages_per_second: "Сообщений в секунду", messages_per_second_text: "Количество действий, которые могут быть выполнены в секунду.", burst_count: "Burst-счётчик", burst_count_text: "Количество действий, которые могут быть выполнены до ограничения.", }, account_data: { title: "Данные пользователя", global: "Глобальные", rooms: "Комнаты", }, }; export default users; ================================================ FILE: src/i18n/types.d.ts ================================================ import { TranslationMessages } from "ra-core"; export interface SynapseTranslationMessages extends TranslationMessages { ketesa: { auth: { base_url: string; welcome: string; description: string; server_version: string; supports_specs: string; username_error: string; protocol_error: string; url_error: string; sso_sign_in: string; credentials: string; access_token: string; logout_access_token_dialog: { title: string; content: string; confirm: string; cancel: string; }; }; users: { invalid_user_id: string; tabs: { sso: string; experimental: string; limits: string; account_data: string; sessions: string }; danger_zone: string; }; rooms: { details: string; tabs: { basic: string; members: string; detail: string; permission: string; media: string; messages: string; hierarchy: string; }; }; reports: { tabs: { basic: string; detail: string } }; admin_config: { soft_failed_events: string; spam_flagged_events: string; success: string; failure: string; }; }; import_users: { error: { at_entry: string; error: string; required_field: string; invalid_value: string; unreasonably_big: string; already_in_progress: string; id_exits: string; }; title: string; goToPdf: string; cards: { importstats: { header: string; users_total: string; guest_count: string; admin_count: string; }; conflicts: { header: string; mode: { stop: string; skip: string; }; }; ids: { header: string; all_ids_present: string; count_ids_present: string; mode: { ignore: string; update: string; }; }; passwords: { header: string; all_passwords_present: string; count_passwords_present: string; use_passwords: string; }; upload: { header: string; explanation: string; }; startImport: { simulate_only: string; run_import: string; }; results: { header: string; total: string; successful: string; skipped: string; download_skipped: string; with_error: string; simulated_only: string; }; }; }; delete_media: { name: string; fields: { before_ts: string; size_gt: string; keep_profiles: string; }; action: { send: string; send_success: string; send_success_none: string; send_failure: string; }; helper: { send: string; }; }; purge_remote_media: { name: string; fields: { before_ts: string; }; action: { send: string; send_success: string; send_success_none: string; send_failure: string; }; helper: { send: string; }; }; resources: { users: { name: string; email: string; msisdn: string; threepid: string; membership: string; fields: { avatar: string; id: string; name: string; is_guest: string; admin: string; locked: string; suspended: string; shadow_banned: string; deactivated: string; erased: string; show_guests: string; show_deactivated: string; show_locked: string; filter_user_all: string; filter_deactivated_false: string; filter_deactivated_true: string; filter_locked_false: string; filter_locked_true: string; filter_guests_false: string; filter_guests_true: string; show_system_users: string; filter_system_users_false: string; filter_system_users_true: string; show_suspended: string; show_shadow_banned: string; user_id: string; displayname: string; password: string; avatar_url: string; avatar_src: string; medium: string; threepids: string; address: string; creation_ts_ms: string; consent_version: string; sent_invite_count: string; cumulative_joined_room_count: string; auth_provider: string; user_type: string; }; helper: { password: string; password_required_for_reactivation: string; create_password: string; lock: string; deactivate: string; suspend: string; shadow_ban: string; admin: string; erase: string; erase_text: string; erase_admin_error: string; modify_managed_user_error: string; username_available: string; sent_invite_count: string; cumulative_joined_room_count: string; }; action: { erase: string; erase_avatar: string; delete_media: string; redact_events: string; redact_in_progress: string; redact_background_note: string; redact_success: string; redact_failure: string; generate_password: string; reset_password: { label: string; title: string; helper: string; password: string; logout_devices: string; success: string; failure: string; error_no_password: string; }; login_as: { label: string; title: string; helper: string; valid_until: string; success: string; failure: string; result_title: string; access_token: string; expires_at: string; }; overwrite_title: string; overwrite_content: string; overwrite_cancel: string; overwrite_confirm: string; quarantine_all: { label: string; title: string; content: string; success: string; failure: string; }; delete_all_media: { label: string; title: string; content: string; in_progress: string; background_note: string; success: string; failure: string; }; delete_all_media_bulk: { title: string; content: string; success: string; partial_failure: string; }; allow_cross_signing: { label: string; title: string; content: string; success: string; failure: string; no_key: string; }; find_user: { label: string; title: string; lookup_type: string; by_threepid: string; by_auth_provider: string; provider: string; external_id: string; search: string; not_found: string; failure: string; }; renew_account: { label: string; title: string; content: string; expiration: string; expiration_helper: string; renewal_emails: string; success: string; failure: string; }; system_users_scan_in_progress: string; reverse_search_scan_in_progress: string; }; badge: { you: string; bot: string; admin: string; support: string; regular: string; federated: string; system_managed: string; }; limits: { messages_per_second: string; messages_per_second_text: string; burst_count: string; burst_count_text: string; }; account_data: { title: string; global: string; rooms: string; }; }; rooms: { name: string; fields: { room_id: string; name: string; canonical_alias: string; joined_members: string; joined_local_members: string; joined_local_devices: string; state_events: string; version: string; is_encrypted: string; encryption: string; federatable: string; public: string; creator: string; join_rules: string; guest_access: string; history_visibility: string; topic: string; avatar: string; actions: string; }; filter: { public_rooms: string; empty_rooms: string; }; helper: { forward_extremities: string; }; enums: { join_rules: { public: string; knock: string; invite: string; private: string; restricted: string; }; guest_access: { can_join: string; forbidden: string; }; history_visibility: { invited: string; joined: string; shared: string; world_readable: string; }; unencrypted: string; room_type: { room: string; space: string; }; }; action: { erase: { title: string; content: string; fields: { block: string; }; in_progress: string; background_note: string; success: string; failure: string; }; make_admin: { assign_admin: string; title: string; confirm: string; content: string; success: string; failure: string; }; join: { label: string; title: string; confirm: string; content: string; success: string; failure: string; }; block: { label: string; title: string; title_bulk: string; title_by_id: string; content: string; content_bulk: string; success: string; failure: string; }; unblock: { label: string; success: string; failure: string; }; purge_history: { label: string; title: string; content: string; date_label: string; delete_local: string; in_progress: string; background_note: string; success: string; failure: string; }; quarantine_all: { label: string; title: string; content: string; success: string; failure: string; }; delete_all_media: { label: string; title: string; content: string; in_progress_loading: string; in_progress: string; do_not_close: string; success: string; failure: string; }; delete_all_media_bulk: { title: string; content: string; success: string; partial_failure: string; }; event_context: { jump_to_date: string; direction: string; forward: string; backward: string; target_event: string; events_before: string; events_after: string; not_found: string; failure: string; }; messages: { load_older: string; load_newer: string; no_messages: string; failure: string; filter: string; filter_type: string; filter_sender: string; advanced_filters: string; filter_not_type: string; filter_not_sender: string; contains_url: string; any: string; with_url: string; without_url: string; apply_filter: string; clear_filters: string; }; hierarchy: { load_more: string; max_depth: string; unlimited: string; refresh: string; members: string; space: string; room: string; suggested: string; no_children: string; failure: string; }; }; }; reports: { name: string; fields: { id: string; received_ts: string; user_id: string; name: string; score: string; reason: string; event_id: string; sender: string; }; action: { erase: { title: string; content: string; }; event_lookup: { label: string; title: string; fetch: string; }; fetch_event_error: string; }; }; scheduled_tasks: { name: string; fields: { id: string; action: string; status: string; timestamp: string; resource_id: string; result: string; error: string; max_timestamp: string; }; status: { scheduled: string; active: string; complete: string; cancelled: string; failed: string; }; }; connections: { name: string; fields: { last_seen: string; ip: string; user_agent: string; }; }; devices: { name: string; fields: { device_id: string; display_name: string; last_seen_ts: string; last_seen_ip: string; last_seen_user_agent: string; dehydrated: string; }; action: { erase: { title: string; title_bulk: string; content: string; content_bulk: string; success: string; failure: string; }; display_name: { success: string; failure: string; }; create: { label: string; title: string; success: string; failure: string; }; }; }; users_media: { name: string; fields: { media_id: string; media_length: string; media_type: string; upload_name: string; quarantined_by: string; safe_from_quarantine: string; created_ts: string; last_access_ts: string; }; action: { open: string; }; }; protect_media: { action: { create: string; delete: string; none: string; send_success: string; send_failure: string; }; }; quarantine_media: { action: { name: string; create: string; delete: string; none: string; send_success: string; send_failure: string; }; }; pushers: { name: string; fields: { app: string; app_display_name: string; app_id: string; device_display_name: string; kind: string; lang: string; profile_tag: string; pushkey: string; data: { url: string; }; }; }; servernotices: { name: string; send: string; fields: { body: string; }; action: { send: string; send_success: string; send_failure: string; }; helper: { send: string; }; }; database_room_statistics: { name: string; fields: { room_id: string; estimated_size: string; }; helper: { info: string; }; }; user_media_statistics: { name: string; fields: { media_count: string; media_length: string; }; }; forward_extremities: { name: string; fields: { id: string; received_ts: string; depth: string; state_group: string; }; }; room_state: { name: string; fields: { type: string; content: string; origin_server_ts: string; sender: string; }; }; room_media: { name: string; fields: { media_id: string; }; helper: { info: string; }; action: { error: string; }; }; room_directory: { name: string; fields: { world_readable: string; guest_can_join: string; }; action: { title: string; content: string; erase: string; create: string; send_success: string; send_failure: string; }; }; destinations: { name: string; fields: { destination: string; failure_ts: string; retry_last_ts: string; retry_interval: string; last_successful_stream_ordering: string; stream_ordering: string; }; action: { reconnect: string; }; }; registration_tokens: { name: string; fields: { token: string; valid: string; uses_allowed: string; pending: string; completed: string; expiry_time: string; length: string; created_at: string; last_used_at: string; revoked_at: string; }; helper: { length: string; }; action: { revoke: { label: string; success: string; }; unrevoke: { label: string; success: string; }; }; }; account_data: { name: string; }; joined_rooms: { name: string; }; memberships: { name: string; }; room_members: { name: string; }; destination_rooms: { name: string; }; mas_users: { name: string; fields: { id: string; username: string; admin: string; locked: string; deactivated: string; legacy_guest: string; created_at: string; locked_at: string; deactivated_at: string; }; filter: { status: string; search: string; status_active: string; status_locked: string; status_deactivated: string; }; action: { lock: { label: string; success: string }; unlock: { label: string; success: string }; deactivate: { label: string; success: string }; reactivate: { label: string; success: string }; set_admin: { label: string; success: string }; remove_admin: { label: string; success: string }; set_password: { label: string; title: string; success: string; failure: string }; }; }; mas_user_emails: { name: string; empty: string; fields: { email: string; user_id: string; created_at: string; actions: string; }; action: { remove: { label: string; title: string; content: string; success: string; }; create: { success: string }; }; }; mas_compat_sessions: { name: string; empty: string; fields: { user_id: string; device_id: string; created_at: string; user_agent: string; last_active_at: string; last_active_ip: string; finished_at: string; human_name: string; active: string; }; action: { finish: { label: string; title: string; content: string; success: string; }; }; }; mas_oauth2_sessions: { name: string; empty: string; fields: { user_id: string; client_id: string; scope: string; created_at: string; user_agent: string; last_active_at: string; last_active_ip: string; finished_at: string; human_name: string; active: string; }; action: { finish: { label: string; title: string; content: string; success: string; }; }; }; mas_personal_sessions: { name: string; empty: string; fields: { owner_user_id: string; actor_user_id: string; human_name: string; scope: string; created_at: string; revoked_at: string; last_active_at: string; last_active_ip: string; expires_at: string; expires_in: string; active: string; }; helper: { expires_in: string; }; action: { revoke: { label: string; title: string; content: string; success: string; }; create: { token_title: string; token_content: string; }; }; }; mas_sessions: { status: { active: string; finished: string; revoked: string; }; }; mas_policy_data: { name: string; current_policy: string; no_policy: string; set_policy: string; invalid_json: string; fields: { json_placeholder: string; created_at: string; }; action: { save: { label: string; success: string; failure: string; }; }; }; mas_user_sessions: { name: string; fields: { user_id: string; created_at: string; finished_at: string; user_agent: string; last_active_at: string; last_active_ip: string; active: string; }; action: { finish: { label: string; title: string; content: string; success: string; }; }; }; mas_upstream_oauth_links: { name: string; fields: { user_id: string; provider_id: string; subject: string; human_account_name: string; created_at: string; }; helper: { provider_id: string; }; action: { remove: { label: string; title: string; content: string; success: string; }; }; }; mas_upstream_oauth_providers: { name: string; fields: { issuer: string; human_name: string; brand_name: string; created_at: string; disabled_at: string; enabled: string; }; }; }; etkecc: { donate: { menu_label: string; name: string; title: string; description_1: string; description_2: string; description_3: string; description_4: string; button: string; signature_team: string; }; billing: { name: string; title: string; no_payments: string; no_payments_helper: string; description1: string; description2: string; fields: { transaction_id: string; email: string; type: string; amount: string; paid_at: string; invoice: string; }; enums: { type: { subscription: string; one_time: string; }; }; helper: { download_invoice: string; downloading: string; download_started: string; invoice_not_available: string; loading: string; loading_failed1: string; loading_failed2: string; loading_failed3: string; loading_failed4: string; }; components: string; components_no_section: string; components_per_month: string; components_included: string; components_total: string; components_help_title: string; components_state_install: string; components_state_remove: string; components_remove_aria: string; components_preview_label: string; components_request_changes: string; components_requesting: string; components_request_failure: string; components_request_sent_title: string; components_request_sent_body: string; components_request_sent_close: string; components_request_sent_view: string; components_request_already_sent: string; components_request_already_sent_view: string; status: { issue: { title: string; description: string; due_overdue: string; due_upcoming: string; expected: string; last_paid: string; fix_link: string; fix_mismatch_link: string; support_link: string; }; }; }; components: { name: string; description: string; tagline: string; no_section: string; per_month: string; included: string; free_label: string; available_label: string; total: string; loading: string; state_add: string; state_remove: string; add_aria: string; remove_aria: string; preview_label: string; request_changes: string; requesting: string; request_failure: string; request_sent_title: string; request_sent_body: string; request_sent_close: string; request_sent_view: string; request_already_sent: string; request_already_sent_view: string; section: { bridges: string; extras: string; matrix_apps: string; matrix_bots: string; matrix_extras: string; }; }; status: { name: string; badge: { default: string; running: string; status_ok: string; status_error: string; status_maintenance: string; status_process_running: string; status_checking: string; }; category: { "Host Metrics": string; Network: string; HTTP: string; Matrix: string; }; status: string; error: string; loading: string; intro1: string; intro2: string; help: string; }; maintenance: { title: string; try_again: string; note: string; }; notifications: { title: string; new_notifications: string; no_notifications: string; see_all: string; clear_all: string; ago: string; }; currently_running: { command: string; started_ago: string; }; time: { less_than_minute: string; minutes: string; hours: string; days: string; weeks: string; months: string; }; support: { name: string; menu_label: string; description: string; create_title: string; no_requests: string; no_messages: string; closed_message: string; fields: { subject: string; message: string; reply: string; status: string; created_at: string; updated_at: string; }; status: { active: string; open: string; closed: string; pending: string; }; buttons: { new_request: string; submit: string; cancel: string; send: string; back: string; attach_files: string; }; helper: { loading: string; reply_hint: string; reply_placeholder: string; before_contact_title: string; help_pages_prompt: string; services_prompt: string; topics_prompt: string; scope_confirm_label: string; english_only_notice: string; response_time_prompt: string; attachments_limit: string; close_request_label: string; }; actions: { create_success: string; create_failure: string; send_failure: string; attachment_too_large: string; too_many_attachments: string; total_size_exceeded: string; }; }; actions: { name: string; available_title: string; available_description: string; available_help_intro: string; scheduled_title: string; scheduled_description: string; recurring_title: string; recurring_description: string; scheduled_help_intro: string; recurring_help_intro: string; maintenance_title: string; maintenance_try_again: string; maintenance_note: string; maintenance_commands_blocked: string; table: { aria_label: string; command: string; description: string; arguments: string; is_recurring: string; run_at: string; next_run_at: string; time_utc: string; time_local: string; }; buttons: { create: string; update: string; back: string; delete: string; run: string; }; command_scheduled: string; command_scheduled_args: string; expect_prefix: string; expect_suffix: string; notifications_link: string; command_help_title: string; scheduled_title_create: string; scheduled_title_edit: string; recurring_title_create: string; recurring_title_edit: string; scheduled_details_title: string; recurring_warning: string; command_details_intro: string; form: { id: string; command: string; scheduled_at: string; day_of_week: string; }; delete_scheduled_title: string; delete_recurring_title: string; delete_confirm: string; errors: { unknown: string; delete_failed: string; }; days: { monday: string; tuesday: string; wednesday: string; thursday: string; friday: string; saturday: string; sunday: string; }; scheduled: { action: { create_success: string; update_success: string; update_failure: string; delete_success: string; delete_failure: string; }; }; recurring: { action: { create_success: string; update_success: string; update_failure: string; delete_success: string; delete_failure: string; }; }; }; }; } ================================================ FILE: src/i18n/uk/base.ts ================================================ import type { TranslationMessages } from "ra-core"; const ukrainianMessages: TranslationMessages = { ra: { action: { add_filter: "Додати фільтр", add: "Додати", back: "Повернутися назад", bulk_actions: "%{smart_count} обрано", cancel: "Відмінити", clear_array_input: "Очистити всі елементи", clear_input_value: "Очистити", clone: "Дублювати", confirm: "Підтвердити", create: "Створити", create_item: "Створити %{item}", delete: "Видалити", edit: "Редагувати", export: "Експортувати", list: "Перелік", refresh: "Оновити", remove_filter: "Прибрати фільтр", remove_all_filters: "Прибрати всі фільтри", remove: "Видалити", reset: "Скинути", save: "Зберегти", search: "Пошук", search_columns: "Пошук по стовпцях", select_all: "Обрати всі", select_all_button: "Вибрати всі", select_row: "Обрати цей рядок", show: "Перегляд", sort: "Сортувати", undo: "Скасувати", unselect: "Зняти обрання", expand: "Розкрити", close: "Закрити", open_menu: "Меню", close_menu: "Закрити меню", update: "Оновити", move_up: "Вгору", move_down: "Вниз", open: "Відкрити", toggle_theme: "Змінити тему", select_columns: "Стовпці", update_application: "Оновити програму", }, boolean: { true: "Так", false: "Ні", null: " ", }, page: { create: "Створити %{name}", dashboard: "Дашборд", edit: "%{name} %{recordRepresentation}", error: "Щось пішло не так", list: "%{name}", loading: "Завантаження", not_found: "Не знайдено", show: "%{name} %{recordRepresentation}", empty: "Ще немає %{name}.", invite: "Бажаєте додати?", access_denied: "Доступ заборонено", authentication_error: "Помилка автентифікації", }, input: { file: { upload_several: "Перетягніть файли сюди, або натисніть для вибору.", upload_single: "Перетягніть файл сюди, або натисніть для вибору.", }, image: { upload_several: "Перетягніть зображення сюди, або натисніть для вибору.", upload_single: "Перетягніть зображення сюди, або натисніть для вибору.", }, references: { all_missing: "Неможливо знайти дані посилань.", many_missing: "Щонайменше одне з пов'язаних посилань більше не доступно.", single_missing: "Пов'язане посилання більше не доступно.", }, password: { toggle_visible: "Сховати пароль", toggle_hidden: "Показати пароль", }, }, message: { about: "Довідка", access_denied: "Ви не маєте доступу до цієї сторінки.", are_you_sure: "Ви впевнені?", authentication_error: "Сервер автентифікації повернув помилку, перевірити ваші дані не вдалося.", auth_error: "Під час перевірки автентифікації сталася помилка.", bulk_delete_content: "Ви дійсно хочете видалити це %{name}? |||| Ви впевнені що хочете видалити ці %{smart_count} %{name}?", bulk_delete_title: "Видалити %{name} |||| Видалити %{smart_count} %{name} елементів", bulk_update_content: "Ви дійсно хочете оновити це %{name}? |||| Ви впевнені що хочете оновити ці %{smart_count} %{name}?", bulk_update_title: "Оновити %{name} |||| Оновити %{smart_count} %{name}", clear_array_input: "Ви впевнені що хочете видалити всі елементи?", delete_content: "Ви впевнені що хочете видалити цей елемент?", delete_title: "Видалити %{name} %{recordRepresentation}", details: "Деталі", error: "Виникла помилка на стороні клієнта і ваш запит не був завершений.", invalid_form: "Форма недійсна. Перевірте помилки", loading: "Сторінка завантажується, хвилинку будь ласка", no: "Ні", not_found: "Ви набрали невірний URL-адресу, або перейшли за хибним посиланням.", placeholder_data_warning: "Проблема з мережею: оновлення даних не вдалося.", select_all_limit_reached: "Занадто багато елементів для вибору. Обрано лише перші %{max}.", unsaved_changes: "Деякі зміни не були збережені.Ви впевнені що хочете проігнорувати їх?", yes: "Так", }, navigation: { clear_filters: "Очистити всі фільтри", no_filtered_results: "Немає результатів", no_results: "Результатів не знайдено", no_more_results: "Номер сторінки %{page} знаходиться за межами кордонів. Спробуйте попередню сторінку.", page_out_of_boundaries: "Сторінка %{page} поза межами", page_out_from_end: "Неможливо переміститися далі останньої сторінки", page_out_from_begin: "Номер сторінки не може бути менше 1", page_range_info: "%{offsetBegin}-%{offsetEnd} із %{total}", partial_page_range_info: "%{offsetBegin}-%{offsetEnd} з більше ніж %{offsetEnd}", current_page: "Сторінка %{page}", page: "Перейти на сторінку %{page}", first: "Перейти на першу сторінку", last: "Перейти на останню сторінку", next: "Наступна", previous: "Перейти на попередню сторінку", page_rows_per_page: "Рядків на сторінці:", skip_nav: "Перейти до змісту", }, sort: { sort_by: "Сортувати за %{field_lower_first} %{order}", ASC: "верхобіжний", DESC: "низобіжний", }, auth: { auth_check_error: "Щоб продовжити, будь ласка увійдіть", user_menu: "Профіль", username: "Ім'я користувача", password: "Пароль", email: "Електронна пошта", sign_in: "Ввійти", sign_in_error: "Помилка автентифікації, спробуйте знову", logout: "Вийти", }, notification: { updated: "Елемент оновлено |||| %{smart_count} елемент оновлено", created: "Елемент створений", deleted: "Елемент видалений |||| %{smart_count} елемент видалено", bad_item: "Хибний елемент", item_doesnt_exist: "Елемент не існує", http_error: "Помилка сервера", data_provider_error: "Помилка в dataProvider. Перевірте деталі в консолі.", i18n_error: "Не вдалося завантажити переклад для вибраної мови", canceled: "Дія відмінена", logged_out: "Вашу сесію завершено, будь ласка увійдіть знову.", not_authorized: "Немає доступу до цього ресурсу.", application_update_available: "Доступна нова версія програми", offline: "Немає з'єднання. Дані не вдалося отримати.", }, validation: { required: "Обов'язково для заповнення", minLength: "Мінімальна кількість символів %{min}", maxLength: "Максимальна кількість символів %{max}", minValue: "Мінімальне значення %{min}", maxValue: "Значення може бути %{max} або менше", number: "Повинна бути цифра", email: "Хибний email", oneOf: "Повинен бути одним з: %{options}", regex: "Повинен відповідати певним форматом (регулярний вираз): %{pattern}", unique: "Має бути унікальним", }, saved_queries: { label: "Зберегти запит", query_name: "Назва запиту", new_label: "Зберегти поточний запит...", new_dialog_title: "Зберегти поточний запит як", remove_label: "Видалити збережений запит", remove_label_with_name: 'Видалити запит "%{name}"', remove_dialog_title: "Видалити збережений запит?", remove_message: "Ви впевнені, що бажаєте видалити цей елемент зі списку збережених запитів?", help: "Відфільтруйте список і збережіть цей запит на потім", }, guesser: { empty: { title: "Немає даних для відображення", message: "Перевірте постачальника даних", }, }, configurable: { customize: "Налаштувати", configureMode: "Налаштувати сторінку", inspector: { title: "Інспектор", content: "Наведіть на UI елемент щоб налаштувати його", reset: "Скинути налаштування", hideAll: "Сховати всі", showAll: "Відобразити всі", }, Datagrid: { title: "Таблиця", unlabeled: "Безіменний стовпець №%{column}", }, SimpleForm: { title: "Форма", unlabeled: "Безіменне поле вводу #%{input}", }, SimpleList: { title: "Список", primaryText: "Основний текст", secondaryText: "Вторинний текст", tertiaryText: "Третинний текст", }, }, }, }; export default ukrainianMessages; ================================================ FILE: src/i18n/uk/common.ts ================================================ import ukrainianMessages from "./base"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const common: Record = { ...ukrainianMessages, ketesa: { auth: { base_url: "URL домашнього сервера", welcome: "Ласкаво просимо до %{name}", description: "Еволюція Synapse Admin. Керуйте, відстежуйте та обслуговуйте свій Matrix-сервер через єдиний зручний інтерфейс. Підходить як для невеликих приватних серверів, так і для великих федеративних спільнот.", server_version: "Версія Synapse", supports_specs: "підтримує специфікації Matrix", username_error: "Будь ласка, введіть повний ідентифікатор користувача: '@user:domain'", protocol_error: "URL повинен починатися з 'http://' або 'https://'", url_error: "Недійсна URL-адреса сервера Matrix", sso_sign_in: "Вхід через SSO", credentials: "Облікові дані", access_token: "Токен доступу", logout_access_token_dialog: { title: "Ви використовуєте існуючий токен доступу Matrix.", content: "Ви бажаєте знищити цю сесію (яка може використовуватися деінде, наприклад, у клієнті Matrix) чи просто вийти з панелі адміністратора?", confirm: "Знищити сесію", cancel: "Просто вийти з панелі адміністратора", }, }, users: { invalid_user_id: "Локальна частина ID користувача Matrix без адреси домашнього сервера.", tabs: { sso: "SSO", experimental: "Експериментально", limits: "Обмеження", account_data: "Дані облікового запису", sessions: "Сесії", }, danger_zone: "Небезпечна зона", }, rooms: { details: "Деталі кімнати", tabs: { basic: "Основні", members: "Учасники", detail: "Детально", permission: "Дозволи", media: "Медіа", messages: "Повідомлення", hierarchy: "Ієрархія", }, }, reports: { tabs: { basic: "Основні", detail: "Детально" } }, admin_config: { soft_failed_events: "Події з м'яким збоєм", spam_flagged_events: "Події, позначені як спам", success: "Конфігурацію адміністратора оновлено", failure: "Не вдалося оновити конфігурацію адміністратора", }, }, import_users: { error: { at_entry: "Запис %{entry}: %{message}", error: "Помилка", required_field: "Обов'язкове поле '%{field}' відсутнє", invalid_value: "Неприпустиме значення у рядку %{row}. Поле '%{field}' може бути лише 'true' або 'false'", unreasonably_big: "Відмовлено у завантаженні надто великого файлу розміром %{size} МБ", already_in_progress: "Імпорт вже виконується", id_exits: "ID %{id} вже існує", }, title: "Імпорт користувачів через CSV", goToPdf: "Перейти до PDF", cards: { importstats: { header: "Імпорт користувачів", users_total: "%{smart_count} користувач у CSV-файлі |||| %{smart_count} користувачі у CSV-файлі |||| %{smart_count} користувачів у CSV-файлі", guest_count: "%{smart_count} гість |||| %{smart_count} гості |||| %{smart_count} гостей", admin_count: "%{smart_count} адміністратор |||| %{smart_count} адміністратори |||| %{smart_count} адміністраторів", }, conflicts: { header: "Стратегія конфлікту", mode: { stop: "Зупинитись при конфлікті", skip: "Показати помилку та пропустити при конфлікті", }, }, ids: { header: "IDs", all_ids_present: "ID присутній у кожному записі", count_ids_present: "%{smart_count} запис з ID |||| %{smart_count} записи з ID |||| %{smart_count} записів з ID", mode: { ignore: "Ігнорувати ID у CSV та створювати нові", update: "Оновити існуючі записи", }, }, passwords: { header: "Паролі", all_passwords_present: "Пароль присутній у кожному записі", count_passwords_present: "%{smart_count} запис з паролем |||| %{smart_count} записи з паролями |||| %{smart_count} записів з паролями", use_passwords: "Використовувати паролі з CSV", }, upload: { header: "Завантажити CSV-файл", explanation: "Тут можна завантажити файл із значеннями, розділеними комами, для створення або оновлення користувачів. Файл має містити поля 'id' та 'displayname'. Зразок файлу можна завантажити тут: ", }, startImport: { simulate_only: "Лише симуляція", run_import: "Імпортувати", }, results: { header: "Результати імпорту", total: "%{smart_count} запис загалом |||| %{smart_count} записи загалом |||| %{smart_count} записів загалом", successful: "%{smart_count} записів успішно імпортовано", skipped: "%{smart_count} записів пропущено", download_skipped: "Завантажити пропущені записи", with_error: "%{smart_count} запис з помилками |||| %{smart_count} записи з помилками |||| %{smart_count} записів з помилками", simulated_only: "Запуск був лише симульованим", }, }, }, delete_media: { name: "Медіа", fields: { before_ts: "останній доступ раніше ніж:", size_gt: "розмір більше ніж (у байтах):", keep_profiles: "Залишити зображення профілів користувачів", }, action: { send: "Видалити медіафайли", send_success: "Успішно видалено %{smart_count} медіафайл. |||| Успішно видалено %{smart_count} медіафайли. |||| Успішно видалено %{smart_count} медіафайлів.", send_success_none: "Жоден медіафайл не відповідає вказаним критеріям. Нічого не було видалено.", send_failure: "Сталася помилка.", }, helper: { send: "Цей API видаляє локальні медіа з диска вашого власного сервера. Це включає будь-які локальні мініатюри та копії завантажених медіафайлів. Цей API не впливатиме на медіафайли, які було завантажено до зовнішніх сховищ медіафайлів.", }, }, purge_remote_media: { name: "Віддалені медіа", fields: { before_ts: "останній доступ раніше ніж:", }, action: { send: "Очистити віддалені медіа", send_success: "Успішно очищено %{smart_count} віддалений медіафайл. |||| Успішно очищено %{smart_count} віддалені медіафайли. |||| Успішно очищено %{smart_count} віддалених медіафайлів.", send_success_none: "Жоден віддалений медіафайл не відповідає вказаним критеріям. Нічого не було очищено.", send_failure: "Під час запиту на очищення віддалених медіа сталася помилка.", }, helper: { send: "Цей API очищає кеш віддалених медіа файлів із вашого сервера. Це включає будь-які локальні мініатюри та копії завантажених медіафайлів. Цей API не впливатиме на медіафайли, які було завантажено у власне сховище медіафайлів сервера.", }, }, etkecc: { donate: { menu_label: "Підтримати", name: "Підтримати розвиток Ketesa", title: "Підтримати розвиток Ketesa", description_1: "Проєкт Ketesa є вільним продуктом із відкритим кодом, і ми відкрито розробляємо та підтримуємо його для спільноти Matrix.", description_2: "Якщо проєкт Ketesa став вам у пригоді, пожертва допомагає нам продовжувати роботу над ним: розробку, супровід, виправлення та постійні вдосконалення.", description_3: "Це допомагає нам приділяти більше часу тому, щоб розвивати проєкт для всіх, хто на нього покладається.", description_4: "Кожен внесок допомагає, і ми щиро вдячні вам за підтримку! ❤️", button: "Підтримати", signature_team: "команда etke.cc", }, components: { name: "Компоненти", description: "Переглядайте та керуйте активними компонентами, а також дізнайтесь, що можна додати на ваш сервер.", no_section: "Ваш сервер", per_month: "/міс.", included: "Включено", total: "Разом", loading: "Завантаження компонентів...", state_add: "Додати", state_remove: "Видалити", add_aria: "Запросити додавання %{name}", remove_aria: "Запросити видалення %{name}", preview_label: "перегляд", request_changes: "Запросити зміни", requesting: "Надсилання...", request_failure: "Не вдалося надіслати запит на зміну. Будь ласка, спробуйте ще раз.", request_sent_title: "Запит надіслано", request_sent_body: "Ваш запит на зміну компонентів був надісланий до служби підтримки etke.cc. Якщо вам потрібні додаткові зміни, будь ласка, дайте відповідь на цей запит підтримки, а не відкривайте новий.", request_sent_close: "Закрити", request_sent_view: "Переглянути запит", request_already_sent: "Запит на зміну вже відкрито. Для запиту додаткових змін дайте відповідь на існуючий тікет підтримки.", request_already_sent_view: "Переглянути тікет", free_label: "Безкоштовно", available_label: "Доступно", tagline: "Розширюйте можливості вашого сервера — додавайте або видаляйте компоненти будь-коли.", section: { bridges: "Мости", extras: "Додатки", matrix_apps: "Застосунки Matrix", matrix_bots: "Боти Matrix", matrix_extras: "Додатки Matrix", }, }, billing: { name: "Білінг", title: "Історія платежів", no_payments: "Платежів не знайдено.", no_payments_helper: "Якщо ви вважаєте, що це помилка, будь ласка, зверніться до підтримки etke.cc.", description1: "Тут ви можете переглядати платежі та формувати рахунки. Докладніше про керування підпискою — на", description2: "Щоб змінити email для виставлення рахунків або додати реквізити компанії до рахунків, дивіться", fields: { transaction_id: "ID транзакції", email: "Ел. пошта", type: "Тип", amount: "Сума", paid_at: "Дата оплати", invoice: "Рахунок", }, enums: { type: { subscription: "Підписка", one_time: "Разовий", }, }, helper: { download_invoice: "Завантажити рахунок", downloading: "Завантаження...", download_started: "Завантаження рахунку розпочато.", invoice_not_available: "В очікуванні", loading: "Завантаження інформації про білінг...", loading_failed1: "Виникла проблема під час завантаження інформації про білінг.", loading_failed2: "Будь ласка, спробуйте пізніше.", loading_failed3: "Якщо проблема не зникає, будь ласка, зверніться до підтримки etke.cc.", loading_failed4: "із таким повідомленням про помилку:", }, components: "Активні компоненти", components_no_section: "Ваш сервер", components_per_month: "/міс.", components_included: "Включено", components_total: "Разом", components_help_title: "Детальніше про %{name}", components_state_install: "Встановити", components_state_remove: "Видалити", components_remove_aria: "Встановити/видалити %{name}", components_preview_label: "перегляд", components_request_changes: "Запросити зміни", components_requesting: "Надсилання...", components_request_failure: "Не вдалося надіслати запит на зміну. Будь ласка, спробуйте ще раз.", components_request_sent_title: "Запит надіслано", components_request_sent_body: "Ваш запит на зміну компонентів був надісланий до служби підтримки etke.cc. Якщо вам потрібні додаткові зміни, будь ласка, дайте відповідь на цей запит підтримки, а не відкривайте новий.", components_request_sent_close: "Закрити", components_request_sent_view: "Переглянути запит", components_request_already_sent: "Запит на зміну вже відкрито. Для запиту додаткових змін дайте відповідь на існуючий тікет підтримки.", components_request_already_sent_view: "Переглянути тікет", status: { issue: { title: "Підписка потребує уваги", description: "Ми виявили проблему з вашою підпискою. Не хвилюйтесь — її легко вирішити.", due_overdue: "Прострочена з", due_upcoming: "До оплати", expected: "Очікувана сума", last_paid: "Остання оплата", fix_link: "Виправити прострочення", fix_mismatch_link: "Оновити ціну підписки", support_link: "Зв'язатися з підтримкою", }, }, }, status: { name: "Стан сервера", badge: { default: "Натисніть, щоб переглянути стан сервера", running: "Запущено: %{command}. %{text}", status_ok: "Сервер онлайн", status_error: "Стан: Помилка", status_maintenance: "Наразі система перебуває в режимі обслуговування.", status_process_running: "Сервер виконує команду", status_checking: "Перевірка стану сервера", }, category: { "Host Metrics": "Метрики хоста", Network: "Мережа", HTTP: "HTTP", Matrix: "Matrix", }, status: "Стан", error: "Помилка", loading: "Отримуємо статус сервера в реальному часі — зачекайте хвилинку…", intro1: "Це звіт моніторингу вашого сервера в реальному часі. Докладніше — на", intro2: "Якщо вас турбує будь-яка з перевірок нижче, перегляньте рекомендовані дії на", help: "Довідка", }, maintenance: { title: "Наразі система перебуває в режимі обслуговування.", try_again: "Будь ласка, спробуйте пізніше.", note: "Не потрібно звертатися до підтримки з цього приводу — ми вже працюємо над цим!", }, actions: { name: "Команди сервера", available_title: "Доступні команди", available_description: "Нижче наведені команди, які можна виконати.", available_help_intro: "Докладнішу інформацію про кожну з них можна знайти на", scheduled_title: "Заплановані команди", scheduled_description: "Наведені команди заплановані на виконання у визначений час. Ви можете переглянути деталі та змінити їх за потреби.", recurring_title: "Повторювані команди", recurring_description: "Наведені команди налаштовані на щотижневе виконання у визначений день і час. Ви можете переглянути деталі та змінити їх за потреби.", scheduled_help_intro: "Докладнішу інформацію про цей режим можна знайти на", recurring_help_intro: "Докладнішу інформацію про цей режим можна знайти на", maintenance_title: "Наразі система перебуває в режимі обслуговування.", maintenance_try_again: "Будь ласка, спробуйте пізніше.", maintenance_note: "Не потрібно звертатися до підтримки з цього приводу — ми вже працюємо над цим!", maintenance_commands_blocked: "Команди не можна запускати, доки режим обслуговування не буде вимкнено.", table: { aria_label: "Команди сервера", command: "Команда", description: "Опис", arguments: "Аргументи", is_recurring: "Повторювана?", run_at: "Запуск (локальний час)", next_run_at: "Наступний запуск (локальний час)", time_utc: "Час (UTC)", time_local: "Час (локальний)", }, buttons: { create: "Створити", update: "Оновити", back: "Назад", delete: "Видалити", run: "Запустити", }, command_scheduled: "Команду заплановано: %{command}", command_scheduled_args: "з додатковими аргументами: %{args}", expect_prefix: "Очікуйте результат на сторінці", expect_suffix: "найближчим часом.", notifications_link: "Сповіщення", command_help_title: "Довідка %{command}", scheduled_title_create: "Створити заплановану команду", scheduled_title_edit: "Редагувати заплановану команду", recurring_title_create: "Створити повторювану команду", recurring_title_edit: "Редагувати повторювану команду", scheduled_details_title: "Деталі запланованої команди", recurring_warning: "Заплановані команди, створені з повторюваної, не можна редагувати, оскільки вони будуть згенеровані повторно автоматично. Будь ласка, редагуйте повторювану команду.", command_details_intro: "Докладнішу інформацію про команду можна знайти на", form: { id: "ID", command: "Команда", scheduled_at: "Заплановано на", day_of_week: "День тижня", }, delete_scheduled_title: "Видалити заплановану команду", delete_recurring_title: "Видалити повторювану команду", delete_confirm: "Ви впевнені, що хочете видалити команду: %{command}?", errors: { unknown: "Сталася невідома помилка", delete_failed: "Помилка: %{error}", }, days: { monday: "Понеділок", tuesday: "Вівторок", wednesday: "Середа", thursday: "Четвер", friday: "Пʼятниця", saturday: "Субота", sunday: "Неділя", }, scheduled: { action: { create_success: "Заплановану команду успішно створено", update_success: "Заплановану команду успішно оновлено", update_failure: "Сталася помилка", delete_success: "Заплановану команду успішно видалено", delete_failure: "Сталася помилка", }, }, recurring: { action: { create_success: "Повторювану команду успішно створено", update_success: "Повторювану команду успішно оновлено", update_failure: "Сталася помилка", delete_success: "Повторювану команду успішно видалено", delete_failure: "Сталася помилка", }, }, }, notifications: { title: "Сповіщення", new_notifications: "%{smart_count} нове сповіщення |||| %{smart_count} нові сповіщення |||| %{smart_count} нових сповіщень", no_notifications: "Поки сповіщень немає", see_all: "Переглянути всі сповіщення", clear_all: "Очистити все", ago: "тому", advisory_tooltip: "Можливо, Ви пропустили сповіщення. Будь ласка, також перевірте #news:etke.cc, etke.cc/news або свою електронну пошту.", unavailable_tooltip: "Сповіщення можуть бути недоступні. Натисніть для отримання деталей.", unavailable_title: "Сповіщення можуть бути зараз недоступні", unavailable_body: "Можливо, є оновлення, які зараз не вдається доставити до цієї панелі — або ж нічого нового немає. Щоб нічого не пропустити, будь ласка, перевіряйте час від часу:", unavailable_link_matrix: "Matrix-кімната #news:etke.cc", unavailable_link_news: "Сторінка оголошень на etke.cc/news", unavailable_link_email: "Ваша поштова скринька (включно з папкою «Спам»)", unavailable_retry: "Повторити спробу", }, currently_running: { command: "Зараз виконується:", started_ago: "(запущено %{time} тому)", }, time: { less_than_minute: "кілька секунд", minutes: "%{smart_count} хвилина |||| %{smart_count} хвилини |||| %{smart_count} хвилин", hours: "%{smart_count} година |||| %{smart_count} години |||| %{smart_count} годин", days: "%{smart_count} день |||| %{smart_count} дні |||| %{smart_count} днів", weeks: "%{smart_count} тиждень |||| %{smart_count} тижні |||| %{smart_count} тижнів", months: "%{smart_count} місяць |||| %{smart_count} місяці |||| %{smart_count} місяців", }, support: { name: "Підтримка", menu_label: "Зв'язатися з підтримкою", description: "Відкрийте запит до підтримки або продовжте роботу з існуючим. Наша команда відповість якнайшвидше.", create_title: "Новий запит до підтримки", no_requests: "Запитів до підтримки ще немає.", no_messages: "Повідомлень ще немає.", closed_message: "Цей запит закрито. Якщо у вас все ще є проблема, будь ласка, відкрийте новий.", fields: { subject: "Тема", message: "Повідомлення", reply: "Відповідь", status: "Статус", created_at: "Створено", updated_at: "Останнє оновлення", }, status: { active: "Очікування оператора", open: "Відкрито", closed: "Закрито", pending: "Очікування вашої відповіді", }, buttons: { new_request: "Новий запит", submit: "Надіслати", cancel: "Скасувати", send: "Надіслати", back: "Повернутися до підтримки", attach_files: "Прикріпити файли", }, helper: { loading: "Завантаження запитів до підтримки...", reply_hint: "Ctrl+Enter для надсилання", reply_placeholder: "Вкажіть якомога більше деталей.", before_contact_title: "Перш ніж звернутися до нас", help_pages_prompt: "Будь ласка, спочатку перегляньте сторінки довідки:", services_prompt: "Ми надаємо лише послуги, перелічені на сторінці послуг:", topics_prompt: "Ми можемо допомогти лише з підтримуваними темами:", scope_confirm_label: "Я переглянув сторінки довідки та підтверджую, що цей запит відповідає підтримуваним темам.", english_only_notice: "Підтримка надається лише англійською мовою.", response_time_prompt: "Відповідь протягом 48 годин. Потрібна швидша відповідь? Див.:", attachments_limit: "До 5 файлів, 5 МБ кожен, 10 МБ загалом.", close_request_label: "Закрити запит після надсилання", }, actions: { create_success: "Запит до підтримки успішно створено.", create_failure: "Не вдалося створити запит до підтримки.", send_failure: "Не вдалося надіслати повідомлення.", attachment_too_large: "Файл «%{name}» перевищує обмеження у 5 МБ.", too_many_attachments: "Максимум 5 файлів.", total_size_exceeded: "Загальний розмір вкладень перевищує 10 МБ.", }, }, }, }; export default common; ================================================ FILE: src/i18n/uk/index.ts ================================================ import { SynapseTranslationMessages } from "../types"; import common from "./common"; import mas from "./mas"; import misc_resources from "./misc_resources"; import reports from "./reports"; import rooms from "./rooms"; import users from "./users"; const uk: SynapseTranslationMessages = { ra: common.ra, ketesa: common.ketesa, import_users: common.import_users, delete_media: common.delete_media, purge_remote_media: common.purge_remote_media, etkecc: common.etkecc, resources: { users, rooms, reports, scheduled_tasks: misc_resources.scheduled_tasks, connections: misc_resources.connections, devices: misc_resources.devices, users_media: misc_resources.users_media, protect_media: misc_resources.protect_media, quarantine_media: misc_resources.quarantine_media, pushers: misc_resources.pushers, servernotices: misc_resources.servernotices, database_room_statistics: misc_resources.database_room_statistics, user_media_statistics: misc_resources.user_media_statistics, forward_extremities: misc_resources.forward_extremities, room_state: misc_resources.room_state, room_media: misc_resources.room_media, room_directory: misc_resources.room_directory, destinations: misc_resources.destinations, registration_tokens: misc_resources.registration_tokens, account_data: misc_resources.account_data, joined_rooms: misc_resources.joined_rooms, memberships: misc_resources.memberships, room_members: misc_resources.room_members, destination_rooms: misc_resources.destination_rooms, ...mas, }, }; export default uk; ================================================ FILE: src/i18n/uk/mas.ts ================================================ const mas = { mas_users: { name: "MAS-користувач |||| MAS-користувачі", fields: { id: "MAS-ідентифікатор", username: "Ім'я користувача", admin: "Адміністратор", locked: "Заблокований", deactivated: "Деактивований", legacy_guest: "Застарілий гість", created_at: "Створений", locked_at: "Заблокований", deactivated_at: "Деактивований", }, filter: { status: "Статус", search: "Пошук", status_active: "Активний", status_locked: "Заблокований", status_deactivated: "Деактивований", }, action: { lock: { label: "Заблокувати", success: "Користувача заблоковано" }, unlock: { label: "Розблокувати", success: "Користувача розблоковано" }, deactivate: { label: "Деактивувати", success: "Користувача деактивовано" }, reactivate: { label: "Реактивувати", success: "Користувача реактивовано" }, set_admin: { label: "Призначити адміністратором", success: "Статус адміністратора оновлено" }, remove_admin: { label: "Зняти права адміністратора", success: "Статус адміністратора оновлено" }, set_password: { label: "Встановити пароль", title: "Встановити пароль", success: "Пароль встановлено", failure: "Не вдалося встановити пароль", }, }, }, mas_user_emails: { name: "Ел. пошта |||| Ел. пошта", empty: "Немає ел. адрес", fields: { email: "Ел. пошта", user_id: "ID користувача", created_at: "Створена", actions: "Дії", }, action: { remove: { label: "Видалити", title: "Видалити ел. адресу", content: "Видалити %{email}?", success: "Ел. адресу видалено", }, create: { success: "Ел. адресу додано" }, }, }, mas_compat_sessions: { name: "Сумісна сесія |||| Сумісні сесії", empty: "Немає сумісних сесій", fields: { user_id: "ID користувача", device_id: "ID пристрою", created_at: "Створена", user_agent: "User Agent", last_active_at: "Остання активність", last_active_ip: "Остання IP", finished_at: "Завершена", human_name: "Назва", active: "Активна", }, action: { finish: { label: "Завершити", title: "Завершити сесію?", content: "Сесію буде завершено.", success: "Сесію завершено", }, }, }, mas_oauth2_sessions: { name: "OAuth2-сесія |||| OAuth2-сесії", empty: "Немає OAuth2-сесій", fields: { user_id: "ID користувача", client_id: "ID клієнта", scope: "Область доступу", created_at: "Створена", user_agent: "User Agent", last_active_at: "Остання активність", last_active_ip: "Остання IP", finished_at: "Завершена", human_name: "Назва", active: "Активна", }, action: { finish: { label: "Завершити", title: "Завершити сесію?", content: "Сесію буде завершено.", success: "Сесію завершено", }, }, }, mas_policy_data: { name: "Дані політики", current_policy: "Поточна політика", no_policy: "Наразі жодної політики не встановлено.", set_policy: "Задати нову політику", invalid_json: "Некоректний JSON", fields: { json_placeholder: "Введіть дані політики у форматі JSON…", created_at: "Створено", }, action: { save: { label: "Задати політику", success: "Політику оновлено", failure: "Не вдалося оновити політику", }, }, }, mas_user_sessions: { name: "Сесія браузера |||| Сесії браузера", fields: { user_id: "ID користувача", created_at: "Створена", finished_at: "Завершена", user_agent: "User Agent", last_active_at: "Остання активність", last_active_ip: "Остання IP", active: "Активна", }, action: { finish: { label: "Завершити", title: "Завершити сесію?", content: "Браузерну сесію буде завершено.", success: "Сесію завершено", }, }, }, mas_upstream_oauth_links: { name: "OAuth-зв'язок |||| OAuth-зв'язки", fields: { user_id: "ID користувача", provider_id: "ID провайдера", subject: "Суб'єкт", human_account_name: "Назва акаунту", created_at: "Створена", }, helper: { provider_id: "ID стороннього OAuth-провайдера. Знайдіть його у списку OAuth-провайдерів.", }, action: { remove: { label: "Видалити", title: "Видалити OAuth-зв'язок?", content: "OAuth-зв'язок для цього користувача буде видалено.", success: "OAuth-зв'язок видалено", }, }, }, mas_upstream_oauth_providers: { name: "OAuth-провайдер |||| OAuth-провайдери", fields: { issuer: "Видавець", human_name: "Назва", brand_name: "Бренд", created_at: "Створено", disabled_at: "Вимкнено", enabled: "Увімкнено", }, }, mas_personal_sessions: { name: "Персональна сесія |||| Персональні сесії", empty: "Немає персональних сесій", fields: { owner_user_id: "ID власника", actor_user_id: "Користувач", human_name: "Назва", scope: "Область доступу", created_at: "Створена", revoked_at: "Скасована", last_active_at: "Остання активність", last_active_ip: "Остання IP", expires_at: "Спливає", expires_in: "Спливає через (секунди)", active: "Активна", }, helper: { expires_in: "Необов'язково. Кількість секунд до закінчення дії токена. Залиште порожнім для безстрокового токена.", }, action: { revoke: { label: "Скасувати", title: "Скасувати сесію?", content: "Токен доступу буде безповоротно скасовано.", success: "Сесію скасовано", }, create: { token_title: "Токен доступу створено", token_content: "Скопіюйте токен. Після закриття цього вікна він більше не буде доступний.", }, }, }, mas_sessions: { status: { active: "Активна", finished: "Завершена", revoked: "Відкликана", }, }, }; export default mas; ================================================ FILE: src/i18n/uk/misc_resources.ts ================================================ const misc_resources = { scheduled_tasks: { name: "Запланована задача |||| Заплановані задачі", fields: { id: "ID", action: "Дія", status: "Статус", timestamp: "Часова мітка", resource_id: "ID ресурсу", result: "Результат", error: "Помилка", max_timestamp: "До дати", }, status: { scheduled: "Заплановано", active: "Активна", complete: "Завершена", cancelled: "Скасована", failed: "Невдала", }, }, connections: { name: "Підключення", fields: { last_seen: "Дата", ip: "IP-адреса", user_agent: "Агент користувача", }, }, devices: { name: "Пристрій |||| Пристрої", fields: { device_id: "ID пристрою", display_name: "Назва пристрою", last_seen_ts: "Мітка часу", last_seen_ip: "IP адреса", last_seen_user_agent: "User agent", dehydrated: "Дегідратовано", }, action: { erase: { title: "Видалення %{id}", title_bulk: "Видалення %{smart_count} пристрою |||| Видалення %{smart_count} пристроїв |||| Видалення %{smart_count} пристроїв", content: 'Ви впевнені, що хочете видалити пристрій "%{name}"?', content_bulk: "Ви впевнені, що хочете видалити %{smart_count} пристрій? |||| Ви впевнені, що хочете видалити %{smart_count} пристрої? |||| Ви впевнені, що хочете видалити %{smart_count} пристроїв?", success: "Пристрій успішно видалено.", failure: "Сталася помилка.", }, display_name: { success: "Назву пристрою оновлено", failure: "Не вдалося оновити назву пристрою", }, create: { label: "Створити пристрій", title: "Створення нового пристрою", success: "Пристрій створено", failure: "Не вдалося створити пристрій", }, }, }, users_media: { name: "Медіа", fields: { media_id: "ID медіа", media_length: "Розмір файлу (у байтах)", media_type: "Тип", upload_name: "Ім'я файлу", quarantined_by: "У карантині", safe_from_quarantine: "Захистити від карантину", created_ts: "Створено", last_access_ts: "Останній доступ", }, action: { open: "Відкрити мультимедійний файл у новому вікні", }, }, protect_media: { action: { create: "Захистити", delete: "Зняти захист", none: "На карантині", send_success: "Статус захисту успішно змінено.", send_failure: "Сталася помилка.", }, }, quarantine_media: { action: { name: "Карантин", create: "Карантин", delete: "Зняти карантин", none: "Захищено", send_success: "Успішно змінено статус карантину.", send_failure: "Сталася помилка: %{error}", }, }, pushers: { name: "Pusher |||| Pushers", fields: { app: "Застосунок", app_display_name: "Назва застосунку", app_id: "ID застосунку", device_display_name: "Назва пристрою", kind: "Тип", lang: "Мова", profile_tag: "Тег профілю", pushkey: "Pushkey", data: { url: "URL" }, }, }, servernotices: { name: "Повідомлення сервера", send: "Надіслати сповіщення сервера", fields: { body: "Повідомлення", }, action: { send: "Надіслати повідомлення", send_success: "Повідомлення на сервер успішно надіслано.", send_failure: "Сталася помилка.", }, helper: { send: 'Надсилає повідомлення сервера вибраним користувачам. На сервері має бути активовано функцію "Повідомлення сервера".', }, }, database_room_statistics: { name: "Статистика БД по кімнатах", fields: { room_id: "ID кімнати", estimated_size: "Приблизний розмір", }, helper: { info: "Відображає приблизний обсяг дискового простору, який використовує кожна кімната в базі даних Synapse. Числа є наближеними.", }, }, user_media_statistics: { name: "Медіа", fields: { media_count: "Кількість медіафайлів", media_length: "Розмір медіафайлів", }, }, forward_extremities: { name: "Forward Extremities", fields: { id: "ID події", received_ts: "Мітка часу", depth: "Глибина", state_group: "Група стану", }, }, room_state: { name: "Події", fields: { type: "Тип", content: "Зміст", origin_server_ts: "Час відправки", sender: "Відправник", }, }, room_media: { name: "Медіа", fields: { media_id: "ID медіа", }, helper: { info: "Це список медіафайлів, які було завантажено в кімнату. Неможливо видалити медіафайли, завантажені до зовнішніх сховищ медіафайлів.", }, action: { error: "%{errcode} (%{errstatus}) %{error}", }, }, room_directory: { name: "Каталог кімнат", fields: { world_readable: "Гість може переглядати без приєднання", guest_can_join: "Гості можуть приєднатися", }, action: { title: "Видалити кімнату з каталогу кімнат |||| Видалити %{smart_count} кімнати із каталогу кімнат |||| Видалити %{smart_count} кімнат із каталогу кімнат", content: "Ви впевнені, що хочете видалити цю кімнату з каталогу? |||| Ви впевнені, що хочете видалити ці %{smart_count} кімнати із каталогу? |||| Ви впевнені, що хочете видалити ці %{smart_count} кімнат із каталогу?", erase: "Видалити з каталогу кімнат", create: "Опублікувати в каталозі кімнат", send_success: "Кімнату успішно опубліковано.", send_failure: "Сталася помилка.", }, }, destinations: { name: "Федерація", fields: { destination: "Пункт призначення", failure_ts: "Мітка часу помилки", retry_last_ts: "Мітка часу останньої повторної спроби", retry_interval: "Інтервал повторення", last_successful_stream_ordering: "Остання успішна трансляція", stream_ordering: "Трансляція", }, action: { reconnect: "Повторне підключення" }, }, registration_tokens: { name: "Реєстраційні токени", fields: { token: "Токен", valid: "Дійсний токен", uses_allowed: "Використання дозволено", pending: "В очікуванні", completed: "Завершений", expiry_time: "Термін придатності", length: "Довжина", created_at: "Дата створення", last_used_at: "Останнє використання", revoked_at: "Дата відкликання", }, helper: { length: "Довжина токена, якщо токен не вказано." }, action: { revoke: { label: "Відкликати", success: "Токен відкликано", }, unrevoke: { label: "Відновити", success: "Токен відновлено", }, }, }, account_data: { name: "Дані облікового запису", }, joined_rooms: { name: "Кімнати", }, memberships: { name: "Членства", }, room_members: { name: "Учасники", }, destination_rooms: { name: "Кімнати", }, }; export default misc_resources; ================================================ FILE: src/i18n/uk/reports.ts ================================================ const reports = { name: "Скарга на подію |||| Скарги на події |||| Скарг на події", fields: { id: "ID", received_ts: "Час скарги", user_id: "Заявник", name: "Назва кімнати", score: "Оцінка", reason: "Причина", event_id: "ID події", sender: "Відправник", }, action: { erase: { title: "Видалити повідомлення про подію", content: "Ви впевнені, що хочете видалити повідомлення про подію? Цю дію не можна скасувати.", }, event_lookup: { label: "Пошук події", title: "Отримати подію за ID", fetch: "Отримати", }, fetch_event_error: "Не вдалося отримати подію", }, }; export default reports; ================================================ FILE: src/i18n/uk/rooms.ts ================================================ const rooms = { name: "Кімната |||| Кімнати", fields: { room_id: "ID кімнати", name: "Ім'я", canonical_alias: "Псевдонім", joined_members: "Учасники", joined_local_members: "Локальні учасники", joined_local_devices: "Локальні пристрої", state_events: "Події стану / Складність", version: "Версія", is_encrypted: "Зашифровано", encryption: "Шифрування", federatable: "Федеративний", public: "Відображається у каталозі кімнат", creator: "Творець", join_rules: "Правила приєднання", guest_access: "Гостьовий доступ", history_visibility: "Видимість історії", topic: "Тема", avatar: "Аватар", actions: "Дії", }, filter: { public_rooms: "Публічні кімнати", empty_rooms: "Порожні кімнати", local_members_only: "Тільки локальні учасники", }, helper: { forward_extremities: "Прямі екстремуми — це листові події в кінці спрямованого ациклічного графа (DAG) кімнати, тобто події без дочірніх елементів. Чим більше їх у кімнаті, тим більше розв'язання стану потрібно виконувати Synapse (примітка: це дорога операція). Хоча Synapse має код для запобігання надмірній кількості таких подій у кімнаті, помилки можуть спричиняти їх повторну появу. Якщо в кімнаті більше 10 прямих екстремумів, варто дослідити ситуацію та потенційно видалити їх за допомогою SQL-запитів, згаданих у #1760.", }, enums: { join_rules: { public: "Публічна", knock: "Треба постукати", invite: "Запросити", private: "Приватна", restricted: "Обмежений доступ", }, guest_access: { can_join: "Гості можуть приєднатися", forbidden: "Гості не можуть приєднатися", }, history_visibility: { invited: "З моменту запрошення", joined: "З моменту приєднання", shared: "З моменту надання доступу", world_readable: "Будь-хто", }, unencrypted: "Незашифровано", room_type: { room: "Кімната", space: "Простір", }, }, action: { erase: { title: "Видалити кімнату", content: "Ви впевнені, що хочете видалити кімнату? Цю дію не можна скасувати. Усі повідомлення та медіафайли в кімнаті буде видалено з сервера!", fields: { block: "Заблокувати та заборонити користувачам приєднуватися до кімнати", }, in_progress: "Видалення виконується…", background_note: "Ви можете закрити це вікно, видалення продовжиться у фоновому режимі.", success: "Кімнату(и) успішно видалено.", failure: "Не вдалося видалити кімнату(и).", }, make_admin: { assign_admin: "Призначити адміністратора", title: "Призначити адміністратора кімнати %{roomName}", confirm: "Зробити адміном", content: "Введіть повний MXID користувача, якого буде встановлено як адміністратора.\nПопередження: щоб це працювало, кімната повинна мати принаймні одного локального учасника як адміністратора.", success: "Користувача призначено адміністратором кімнати.", failure: "Користувача не можна призначити адміністратором кімнати. %{errMsg}", }, join: { label: "Додати користувача", title: "Додати користувача до %{roomName}", confirm: "Додати", content: "Введіть повний MXID користувача, якого потрібно приєднати до цієї кімнати.\nПримітка: ви повинні бути в кімнаті та мати дозвіл запрошувати користувачів.", success: "Користувача успішно додано до кімнати.", failure: "Не вдалося додати користувача до кімнати. %{errMsg}", }, block: { label: "Заблокувати", title: "Заблокувати %{room}", title_bulk: "Заблокувати %{smart_count} кімнату |||| Заблокувати %{smart_count} кімнати |||| Заблокувати %{smart_count} кімнат", title_by_id: "Заблокувати кімнату", content: "Користувачі не зможуть приєднатися до цієї кімнати.", content_bulk: "Користувачі не зможуть приєднатися до %{smart_count} кімнати. |||| Користувачі не зможуть приєднатися до %{smart_count} кімнат. |||| Користувачі не зможуть приєднатися до %{smart_count} кімнат.", success: "Кімнату успішно заблоковано. |||| Кімнати успішно заблоковано. |||| Кімнат успішно заблоковано.", failure: "Не вдалося заблокувати кімнату. |||| Не вдалося заблокувати кімнати. |||| Не вдалося заблокувати кімнат.", }, unblock: { label: "Розблокувати", success: "Кімнату успішно розблоковано. |||| Кімнати успішно розблоковано. |||| Кімнат успішно розблоковано.", failure: "Не вдалося розблокувати кімнату. |||| Не вдалося розблокувати кімнати. |||| Не вдалося розблокувати кімнат.", }, purge_history: { label: "Очистити історію", title: "Очистити історію %{roomName}", content: "Усі події до обраної дати будуть видалені з бази даних. Стан кімнати (входи, виходи, тема) завжди зберігається. Принаймні одне повідомлення завжди залишається.\nПримітка: ця операція може зайняти кілька хвилин для великих кімнат.", date_label: "Очистити події до", delete_local: "Також видалити події локальних користувачів", in_progress: "Очищення виконується…", background_note: "Ви можете закрити це вікно, очищення продовжиться у фоновому режимі.", success: "Історію кімнати успішно очищено.", failure: "Не вдалося очистити історію кімнати. %{errMsg}", }, quarantine_all: { label: "Помістити всі медіа на карантин", title: "Помістити на карантин усі медіа в %{roomName}", content: "Усі локальні та віддалені медіа в цій кімнаті будуть поміщені на карантин. Медіа на карантині стануть недоступними для користувачів.", success: "Успішно поміщено на карантин %{smart_count} медіа-елемент. |||| Успішно поміщено на карантин %{smart_count} медіа-елементів.", failure: "Не вдалося помістити медіа на карантин. %{errMsg}", }, delete_all_media: { label: "Видалити всі медіафайли", title: "Видалити всі медіафайли в %{roomName}", content: "Всі локальні медіафайли в цій кімнаті будуть безповоротно видалені. Впливає лише на локальні медіафайли з незашифрованих кімнат — медіафайли зовнішніх серверів виключено. Цю дію не можна скасувати.", in_progress_loading: "Отримання списку медіафайлів…", in_progress: "Видалення медіафайлів… (%{current} / %{total})", do_not_close: "Не закривайте це вікно — видалення виконується синхронно і перерветься при закритті.", success: "Успішно видалено %{smart_count} медіафайл. |||| Успішно видалено %{smart_count} медіафайли. |||| Успішно видалено %{smart_count} медіафайлів.", failure: "Не вдалося видалити медіафайли. %{errMsg}", }, delete_all_media_bulk: { title: "Видалити всі медіафайли для %{smart_count} кімнати? |||| Видалити всі медіафайли для %{smart_count} кімнати? |||| Видалити всі медіафайли для %{smart_count} кімнат?", content: "Всі локальні медіафайли у вибраних кімнатах будуть безповоротно видалені (лише незашифровані кімнати). Медіафайли зовнішніх серверів виключено. Цю дію не можна скасувати.", success: "Медіафайли видалено в %{success} з %{total} кімнат.", partial_failure: "Медіафайли видалено в %{success} з %{total} кімнат. Помилка для %{failed}.", }, event_context: { lookup_title: "Пошук події за ID", jump_to_date: "Перейти до дати", direction: "Напрямок", forward: "Вперед", backward: "Назад", target_event: "Цільова подія", events_before: "Подій до", events_after: "Подій після", not_found: "Подію на вказаний час не знайдено", failure: "Не вдалося отримати контекст події", }, messages: { load_older: "Завантажити старіші", load_newer: "Завантажити новіші", no_messages: "У цій кімнаті немає повідомлень", failure: "Не вдалося завантажити повідомлення", filter: "Фільтри", filter_type: "Типи подій", filter_sender: "Відправники", advanced_filters: "Розширені фільтри", filter_not_type: "Виключити типи подій", filter_not_sender: "Виключити відправників", contains_url: "Містить URL", any: "Будь-який", with_url: "Тільки з URL", without_url: "Тільки без URL", apply_filter: "Застосувати", clear_filters: "Скинути", }, hierarchy: { load_more: "Завантажити ще", max_depth: "Максимальна глибина", unlimited: "Без обмежень", refresh: "Оновити", members: "%{count} учасників", space: "Простір", room: "Кімната", suggested: "Рекомендована", no_children: "У цій кімнаті немає дочірніх кімнат", failure: "Не вдалося завантажити ієрархію", }, }, }; export default rooms; ================================================ FILE: src/i18n/uk/users.ts ================================================ const users = { name: "Користувач |||| Користувачі", email: "Електронна пошта", msisdn: "Телефон", threepid: "Ел. пошта / телефон", membership: "Учасник |||| Учасники", fields: { avatar: "Аватар", id: "ID користувача", name: "Ім'я", is_guest: "Гість", admin: "Адміністратор сервера", locked: "Заблокований", suspended: "Призупинений", shadow_banned: "Тіньовий бан", deactivated: "Деактивований", erased: "Вилучений", show_guests: "Показати гостей", show_deactivated: "Показати лише деактивованих", show_locked: "Показати заблокованих", filter_user_all: "Усі", filter_deactivated_false: "Активні", filter_deactivated_true: "Деактивовані", filter_locked_false: "Виключити заблокованих", filter_locked_true: "Включити заблокованих", filter_guests_false: "Виключити гостей", filter_guests_true: "Включити гостей", show_system_users: "Системні користувачі", filter_system_users_false: "Сховати системних", filter_system_users_true: "Лише системні", show_suspended: "Показати призупинених", show_shadow_banned: "Показати з тіньовим баном", user_id: "Пошук користувача", displayname: "Ім'я, що відображається", password: "Пароль", avatar_url: "URL аватара", avatar_src: "Аватар", medium: "Тип", threepids: "3PIDs", address: "Адреса", creation_ts_ms: "Мітка часу створення", consent_version: "Версія згоди", sent_invite_count: "Надіслані запрошення", cumulative_joined_room_count: "Всього кімнат", auth_provider: "Провайдер", user_type: "Тип користувача", }, helper: { password: "Зміна пароля призведе до виходу користувача з усіх сеансів.", password_required_for_reactivation: "Для повторної активації облікового запису потрібно ввести пароль.", create_password: "Створіть надійний та унікальний пароль за допомогою кнопки нижче.", deactivate: "Ви повинні ввести пароль, щоб повторно активувати обліковий запис.", suspend: "Призупинення користувача означає перехід у режим лише читання.", shadow_ban: "Користувач з тіньовим баном отримує звичайні відповіді, але його події не поширюються іншим користувачам або в кімнати. Використовуйте лише в крайньому випадку.", erase: "Позначити користувача як вилученого GDPR", admin: "Адміністратор сервера має повний контроль над сервером і його користувачами.", lock: "Забороняє користувачу використовувати сервер. Це не руйнівна дія, яку можна скасувати.", erase_text: "Це означає, що повідомлення, надіслані користувачем (користувачами), залишатимуться видимими для тих, хто перебував у кімнаті під час їх надсилання, але будуть приховані від користувачів, які приєднаються пізніше.", erase_admin_error: "Видалення власного користувача заборонено.", modify_managed_user_error: "Редагування системного користувача заборонено.", username_available: "Ім'я користувача доступне", sent_invite_count: "Загальна кількість запрошень, надісланих цим користувачем у всіх кімнатах.", cumulative_joined_room_count: "Загальна кількість кімнат, до яких користувач коли-небудь приєднувався, включаючи ті, які він покинув або з яких був забанений.", }, action: { erase: "Видалити дані користувача", erase_avatar: "Видалити аватар", delete_media: "Видалити всі медіафайли, завантажені цим користувачем", redact_events: "Видалити всі події, надіслані цим користувачем", redact_in_progress: "Видалення подій виконується\u2026", redact_background_note: "Ви можете закрити це вікно, видалення подій продовжиться у фоновому режимі.", redact_success: "Усі події успішно видалено.", redact_failure: "Видалення завершено з %{smart_count} невдалою подією. |||| Видалення завершено з %{smart_count} невдалими подіями.", generate_password: "Згенерувати пароль", reset_password: { label: "Скинути пароль", title: "Скинути пароль", helper: "Змінити пароль %{user}", password: "Пароль", logout_devices: "Вийти з усіх пристроїв", success: "Пароль успішно скинуто", failure: "Не вдалося скинути пароль", error_no_password: "Необхідно вказати пароль", }, login_as: { label: "Увійти як користувач", title: "Увійти як користувач", helper: "Отримати токен доступу для автентифікації від імені %{user}. Ця дія не створює новий пристрій для користувача, тому не з'явиться у списку пристроїв/сесій, і користувач, як правило, не зможе дізнатися про вхід.", valid_until: "Встановити термін дії", success: "Токен доступу успішно створено", failure: "Не вдалося створити токен доступу", result_title: "Токен доступу %{user}", access_token: "Токен доступу", expires_at: "Термін дії токена закінчується %{date}", }, overwrite_title: "УВАГА!", overwrite_content: "Це ім'я користувача вже зайняте. Ви впевнені, що хочете перезаписати існуючого користувача?", overwrite_cancel: "Скасувати", overwrite_confirm: "Перезаписати", quarantine_all: { label: "Помістити всі медіа на карантин", title: "Помістити на карантин усі медіа %{userName}", content: "Усі локальні медіа, завантажені цим користувачем, будуть поміщені на карантин. Медіа на карантині стануть недоступними для інших користувачів.", success: "Успішно поміщено на карантин %{smart_count} медіа-елемент. |||| Успішно поміщено на карантин %{smart_count} медіа-елементів.", failure: "Не вдалося помістити медіа на карантин. %{errMsg}", }, delete_all_media: { label: "Видалити всі медіафайли", title: "Видалити всі медіафайли %{userName}", content: "Всі медіафайли, завантажені цим користувачем, будуть безповоротно видалені. Цю дію не можна скасувати.", in_progress: "Видалення медіафайлів…", background_note: "Ви можете закрити це вікно — видалення продовжиться у фоновому режимі.", success: "Успішно видалено %{smart_count} медіафайл. |||| Успішно видалено %{smart_count} медіафайли. |||| Успішно видалено %{smart_count} медіафайлів.", failure: "Не вдалося видалити медіафайли. %{errMsg}", }, delete_all_media_bulk: { title: "Видалити всі медіафайли для %{smart_count} користувача? |||| Видалити всі медіафайли для %{smart_count} користувача? |||| Видалити всі медіафайли для %{smart_count} користувачів?", content: "Всі медіафайли, завантажені вибраними користувачами, будуть безповоротно видалені. Цю дію не можна скасувати.", success: "Медіафайли видалено для %{success} з %{total} користувачів.", partial_failure: "Медіафайли видалено для %{success} з %{total} користувачів. Помилка для %{failed}.", }, allow_cross_signing: { label: "Дозволити скидання перехресного підпису", title: "Дозволити заміну ключів перехресного підпису", content: "Дозволити %{user} замінити ключі перехресного підпису без інтерактивної автентифікації? Це створить тимчасове вікно, протягом якого ключі можуть бути замінені.", success: "Заміну ключів перехресного підпису дозволено до %{deadline}", failure: "Не вдалося дозволити заміну перехресного підпису", no_key: "У користувача немає головного ключа перехресного підпису", }, find_user: { label: "Знайти користувача", title: "Знайти користувача", lookup_type: "Тип пошуку", by_threepid: "За email / телефоном", by_auth_provider: "За постачальником автентифікації", provider: "ID постачальника автентифікації", external_id: "Зовнішній ID", search: "Знайти", not_found: "Користувача не знайдено", failure: "Не вдалося знайти користувача", }, renew_account: { label: "Продовжити обліковий запис", title: "Продовжити термін дії облікового запису", content: "Продовжити термін дії облікового запису %{user}. Можна вказати власну дату закінчення терміну. Якщо залишити порожнім, буде використано стандартний період продовження сервера.", expiration: "Дата закінчення терміну", expiration_helper: "Залишіть порожнім, щоб використати стандартний період продовження сервера", renewal_emails: "Надсилати повідомлення про продовження електронною поштою", success: "Термін дії облікового запису продовжено до %{date}", failure: "Не вдалося продовжити термін дії облікового запису", }, system_users_scan_in_progress: "Зачекайте — пошук відповідних користувачів ще триває, сторінка завантажиться за мить", reverse_search_scan_in_progress: "Зачекайте — виконується сканування всіх користувачів для виключення збігів, сторінка завантажиться за мить", }, badge: { you: "Ви", bot: "Бот", admin: "Адмін", support: "Підтримка", regular: "Звичайний користувач", federated: "Федеративний", system_managed: "Системне керування", }, limits: { messages_per_second: "Повідомлень за секунду", messages_per_second_text: "Кількість дій, які можна виконати за секунду.", burst_count: "Кількість пакетів", burst_count_text: "Скільки дій можна виконати до обмеження.", }, account_data: { title: "Дані облікового запису", global: "Глобальні", rooms: "Кімнати", }, }; export default users; ================================================ FILE: src/i18n/zh/base.ts ================================================ import type { TranslationMessages } from "ra-core"; const chineseMessages: TranslationMessages = { ra: { action: { add: "增加", add_filter: "添加搜索条件", back: "返回", bulk_actions: "选中1项 |||| 选中%{smart_count}项", cancel: "取消", clear_array_input: "清空列表", clear_input_value: "清空输入", clone: "克隆", close: "关闭", close_menu: "关闭菜单", confirm: "确认", create: "新建", create_item: "新建 %{item}", delete: "删除", edit: "编辑", expand: "展开", export: "导出", list: "列表", move_down: "下移", move_up: "上移", open: "打开", open_menu: "打开菜单", refresh: "刷新", remove: "删除", remove_all_filters: "移除所有检索", remove_filter: "移除检索", reset: "重置", save: "保存", search: "检索", search_columns: "搜索列", select_all: "选择所有", select_all_button: "全部选择", select_columns: "列", select_row: "选择此行", show: "查看", sort: "排序", toggle_theme: "切换主题", undo: "撤销", unselect: "反选", update: "更新", update_application: "更新应用", }, boolean: { true: "是", false: "否", null: " ", }, page: { create: "新建 %{name}", dashboard: "概览", edit: "%{name} %{recordRepresentation}", empty: "无 %{name}", error: "出现错误", invite: "要增加吗?", list: "%{name} 列表", loading: "加载中", not_found: "未发现", show: "%{name} %{recordRepresentation}", access_denied: "拒绝访问", authentication_error: "认证错误", }, input: { file: { upload_several: "将文件集合拖拽到这里, 或点击这里选择文件集合.", upload_single: "将文件拖拽到这里, 或点击这里选择文件.", }, image: { upload_several: "将图片文件集合拖拽到这里, 或点击这里选择图片文件集合.", upload_single: "将图片文件拖拽到这里, 或点击这里选择图片文件.", }, password: { toggle_visible: "隐藏密码", toggle_hidden: "显示密码", }, references: { all_missing: "未找到参考数据.", many_missing: "至少有一条参考数据不再可用.", single_missing: "关联的参考数据不再可用.", }, }, message: { about: "关于", access_denied: "您没有访问此页面的权限。", are_you_sure: "您确定要执行此操作吗?", authentication_error: "身份验证服务器返回错误,无法验证您的凭据。", auth_error: "身份认证出错", bulk_delete_content: "您确定要删除 %{name}? |||| 您确定要删除 %{smart_count} 项?", bulk_delete_title: "删除 %{name} |||| 删除 %{smart_count}项 %{name}", bulk_update_content: "是否确实要更新此 %{name}? |||| 是否确实要更新这些 %{smart_count} ?", bulk_update_title: "更新 %{name} |||| 更新 %{smart_count} %{name}", clear_array_input: "您确定要清除整个列表吗?", delete_content: "您确定要删除该条目?", delete_title: "删除 %{name} %{recordRepresentation}", details: "详情", error: "客户端错误导致请求未完成.", invalid_form: "表单输入无效. 请检查错误提示", loading: "正在加载页面, 请稍候", no: "否", not_found: "您输入了错误的URL或者错误的链接.", placeholder_data_warning: "网络问题:数据刷新失败。", select_all_limit_reached: "选择的元素太多。只选择了前 %{max} 个元素。", unsaved_changes: "修改未保存. 放弃修改吗?", yes: "是", }, navigation: { clear_filters: "清除所有过滤器", no_filtered_results: "没有结果", no_results: "结果为空", no_more_results: "页码 %{page} 超出边界. 试试上一页.", page_out_of_boundaries: "页码 %{page} 超出边界", page_out_from_end: "已到最末页", page_out_from_begin: "已到最前页", page_range_info: "%{offsetBegin}-%{offsetEnd} / %{total}", partial_page_range_info: "%{offsetBegin}-%{offsetEnd} of more than %{offsetEnd}", current_page: "页码 %{page}", page: "跳到页码 %{page}", first: "第一页", last: "最后一页", next: "向后", previous: "向前", page_rows_per_page: "每页行数:", skip_nav: "跳到内容", }, sort: { sort_by: "按 %{field_lower_first} %{order}", ASC: "升序", DESC: "降序", }, auth: { auth_check_error: "请登录以继续", user_menu: "设置", username: "用户名", password: "密码", email: "邮箱", sign_in: "登录", sign_in_error: "验证失败, 请重试", logout: "登出", }, notification: { updated: "条目已更新 |||| %{smart_count} 项条目已更新", created: "条目已新建", deleted: "条目已删除 |||| %{smart_count} 项条目已删除", bad_item: "不正确的条目", item_doesnt_exist: "条目不存在", http_error: "与服务通信出错", canceled: "取消动作", data_provider_error: "dataProvider错误. 请检查console的详细信息.", i18n_error: "无法加载指定语言包", logged_out: "会话失效, 请重连.", not_authorized: "您没有权限访问此资源。", application_update_available: "新版本可用.", offline: "离线。无法获取数据。", }, validation: { required: "必填", minLength: "必须不少于 %{min} 个字符", maxLength: "必须不多于 %{max} 个字符", minValue: "必须不小于 %{min}", maxValue: "必须不大于 %{max}", number: "必须为数字", email: "必须是有效的邮箱", oneOf: "必须为: %{options}其中一项", regex: "必须符合指定的格式 (regexp): %{pattern}", unique: "必须唯一", }, saved_queries: { label: "保存查询", query_name: "查询名称", new_label: "保存当前查询...", new_dialog_title: "保存当前查询为", remove_label: "删除保存的查询", remove_label_with_name: '删除查询 "%{name}"', remove_dialog_title: "删除保存的查询?", remove_message: "确定要从保存的查询列表中删除该项吗?", help: "过滤列表,并保存此查询", }, guesser: { empty: { title: "没有可显示的数据", message: "请检查数据提供程序", }, }, configurable: { customize: "自定义", configureMode: "配置此页面", inspector: { title: "Inspector", content: "悬停应用程序UI元素来配置它们", reset: "重置设置", hideAll: "隐藏所有", showAll: "显示所有", }, Datagrid: { title: "数据网格", unlabeled: "未设置标签的列 #%{column}", }, SimpleForm: { title: "表单", unlabeled: "未设置标签的输入框 #%{input}", }, SimpleList: { title: "列表", primaryText: "主要的文本", secondaryText: "二级文本", tertiaryText: "三级文本", }, }, }, }; export default chineseMessages; ================================================ FILE: src/i18n/zh/common.ts ================================================ import chineseMessages from "./base"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const common: Record = { ...chineseMessages, ketesa: { auth: { base_url: "服务器 URL", welcome: "欢迎来到 %{name}", description: "Synapse Admin 的进化之作。通过一个简洁的界面,完成对 Matrix 服务器的管理、监控与维护。无论是小型私人服务器还是大型联邦社区,都能轻松应对。", server_version: "Synapse 版本", username_error: "请输入完整有效的用户 ID: '@user:domain'", protocol_error: "URL 需要以'http://'或'https://'作为起始", url_error: "不是一个有效的 Matrix 服务器地址", sso_sign_in: "使用 SSO 登录", credentials: "凭证", access_token: "访问令牌", supports_specs: "支持 Matrix 规范", logout_access_token_dialog: { title: "您正在使用现有的 Matrix 访问令牌。", content: "您想终止此会话(可能在其他地方使用,例如在 Matrix 客户端中)还是仅从管理面板退出?", confirm: "终止会话", cancel: "仅从管理面板退出", }, }, users: { invalid_user_id: "必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver", tabs: { sso: "SSO", experimental: "实验性", limits: "限制", account_data: "账户数据", sessions: "会话" }, danger_zone: "危险区域", }, rooms: { details: "房间详情", tabs: { basic: "基本", members: "成员", detail: "细节", permission: "权限", media: "媒体", messages: "消息", hierarchy: "层级结构", }, }, reports: { tabs: { basic: "基本", detail: "细节" } }, admin_config: { soft_failed_events: "软失败事件", spam_flagged_events: "被标记为垃圾邮件的事件", success: "管理员配置已更新", failure: "更新管理员配置失败", }, }, import_users: { error: { at_entry: "在条目 %{entry}: %{message}", error: "错误", required_field: "需要的值 '%{field}' 未被设置。", invalid_value: "第 %{row} 行出现无效值。 '%{field}' 只可以是 'true' 或 'false'。", unreasonably_big: "拒绝加载过大的文件: %{size} MB", already_in_progress: "一个导入进程已经在运行中", id_exits: "ID %{id} 已经存在", }, title: "通过 CSV 导入用户", goToPdf: "转到 PDF", cards: { importstats: { header: "分析用于导入的用户", users_total: "%{smart_count} 用户在 CSV 文件中 |||| %{smart_count} 用户在 CSV 文件中", guest_count: "%{smart_count} 访客 |||| %{smart_count} 访客", admin_count: "%{smart_count} 管理员 |||| %{smart_count} 管理员", }, conflicts: { header: "冲突处理策略", mode: { stop: "在冲突处停止", skip: "显示错误并跳过冲突", }, }, ids: { header: "IDs", all_ids_present: "每条记录的 ID", count_ids_present: "%{smart_count} 个含 ID 的记录 |||| %{smart_count} 个含 ID 的记录", mode: { ignore: "忽略 CSV 中的 ID 并创建新的", update: "更新已经存在的记录", }, }, passwords: { header: "密码", all_passwords_present: "每条记录的密码", count_passwords_present: "%{smart_count} 个含密码的记录 |||| %{smart_count} 个含密码的记录", use_passwords: "使用 CSV 中标记的密码", }, upload: { header: "导入 CSV 文件", explanation: "在这里,您可以上传一个用逗号分隔的文件,用于创建或更新用户。该文件必须包括 'id' 和 'displayname' 字段。您可以在这里下载并修改一个示例文件:", }, startImport: { simulate_only: "模拟模式", run_import: "导入", }, results: { header: "导入结果", total: "共计 %{smart_count} 条记录 |||| 共计 %{smart_count} 条记录", successful: "%{smart_count} 条记录导入成功", skipped: "跳过 %{smart_count} 条记录", download_skipped: "下载跳过的记录", with_error: "%{smart_count} 条记录出现错误 ||| %{smart_count} 条记录出现错误", simulated_only: "只是一次模拟运行", }, }, }, delete_media: { name: "媒体文件", fields: { before_ts: "最后访问时间", size_gt: "大于 (字节)", keep_profiles: "保留头像", }, action: { send: "删除媒体", send_success: "成功删除了 %{smart_count} 个媒体文件。", send_success_none: "没有符合指定条件的媒体文件。未删除任何内容。", send_failure: "出现了一个错误。", }, helper: { send: "这个API会删除您硬盘上的本地媒体。包含了任何的本地缓存和下载的媒体备份。这个API不会影响上传到外部媒体存储库上的媒体文件。", }, }, purge_remote_media: { name: "远程媒体", fields: { before_ts: "最后访问于之前", }, action: { send: "清除远程媒体", send_success: "成功清除了 %{smart_count} 个远程媒体文件。", send_success_none: "没有符合指定条件的远程媒体文件。未清除任何内容。", send_failure: "发生错误,远程媒体清除请求未成功。", }, helper: { send: "此API清除您服务器磁盘上的远程媒体缓存。这包括任何本地缩略图和下载的媒体副本。此API不会影响已经上传到服务器媒体存储库的媒体。", }, }, etkecc: { donate: { menu_label: "捐助", name: "支持 Ketesa 的开发", title: "支持 Ketesa 的开发", description_1: "Ketesa 项目是自由且开源的,我们一直以开放的方式为 Matrix 社区构建并维护它。", description_2: "如果 Ketesa 项目对您有所帮助,捐助将帮助我们继续投入这项工作,包括开发、维护、修复和持续改进。", description_3: "这能让我们投入更多时间,为所有依赖这个项目的人持续改进它。", description_4: "每一份支持都很重要,我们由衷感谢您的支持! ❤️", button: "捐助", signature_team: "etke.cc 团队", }, components: { name: "组件", description: "查看和管理您的活跃组件,并了解可以添加到您服务器的内容。", no_section: "您的服务器", per_month: "/月", included: "已包含", total: "总计", loading: "正在加载组件...", state_add: "添加", state_remove: "卸载", add_aria: "申请添加 %{name}", remove_aria: "申请移除 %{name}", preview_label: "预览", request_changes: "申请更改", requesting: "发送中...", request_failure: "发送更改申请失败,请重试。", request_sent_title: "申请已提交", request_sent_body: "您的组件更改申请已发送至 etke.cc 支持团队。如需更多更改,请回复此支持请求,而非开启新请求。", request_sent_close: "关闭", request_sent_view: "查看申请", request_already_sent: "更改申请已存在。如需申请更多更改,请回复您现有的支持工单。", request_already_sent_view: "查看工单", free_label: "免费", available_label: "可用", tagline: "增强您的服务器 — 随时添加或删除任意组件。", section: { bridges: "桥接", extras: "附加服务", matrix_apps: "Matrix 应用", matrix_bots: "Matrix 机器人", matrix_extras: "Matrix 附加服务", }, }, billing: { name: "账单", title: "付款记录", no_payments: "未找到付款记录。", no_payments_helper: "如果您认为这是错误,请联系 etke.cc 支持。", description1: "您可以在此查看付款并生成发票。有关订阅管理的更多信息,请访问", description2: "如需更改账单邮箱或向发票添加公司信息,请参阅", fields: { transaction_id: "交易ID", email: "邮箱", type: "类型", amount: "金额", paid_at: "付款时间", invoice: "发票", }, enums: { type: { subscription: "订阅", one_time: "一次性", }, }, helper: { download_invoice: "下载发票", downloading: "正在下载...", download_started: "发票下载已开始。", invoice_not_available: "待处理", loading: "正在加载账单信息...", loading_failed1: "加载账单信息时出现问题。", loading_failed2: "请稍后再试。", loading_failed3: "如果问题仍然存在,请联系 etke.cc 支持。", loading_failed4: "并提供以下错误信息:", }, components: "已启用的组件", components_no_section: "您的服务器", components_per_month: "/月", components_included: "已包含", components_total: "总计", components_help_title: "了解更多关于 %{name} 的信息", components_state_install: "安装", components_state_remove: "卸载", components_remove_aria: "安装/卸载 %{name}", components_preview_label: "预览", components_request_changes: "申请更改", components_requesting: "发送中...", components_request_failure: "发送更改申请失败,请重试。", components_request_sent_title: "申请已提交", components_request_sent_body: "您的组件更改申请已发送至 etke.cc 支持团队。如需更多更改,请回复此支持请求,而非新开一个申请。", components_request_sent_close: "关闭", components_request_sent_view: "查看申请", components_request_already_sent: "更改申请已存在。如需申请更多更改,请回复您现有的支持工单。", components_request_already_sent_view: "查看工单", status: { issue: { title: "订阅需要关注", description: "我们发现您的订阅存在问题。请放心,这很容易解决。", due_overdue: "已逾期", due_upcoming: "到期时间", expected: "预期金额", last_paid: "最近付款", fix_link: "解决逾期付款", fix_mismatch_link: "更新订阅价格", support_link: "联系支持", }, }, }, status: { name: "服务器状态", badge: { default: "点击查看服务器状态", running: "正在运行:%{command}。%{text}", status_ok: "服务器在线", status_error: "状态:错误", status_maintenance: "系统当前处于维护模式。", status_process_running: "服务器正在执行命令", status_checking: "正在检查服务器状态", }, category: { "Host Metrics": "主机指标", Network: "网络", HTTP: "HTTP", Matrix: "Matrix", }, status: "状态", error: "错误", loading: "正在获取服务器实时运行状态,请稍候…", intro1: "这是您的服务器实时监控报告。您可以在以下页面了解更多:", intro2: "如果您对下方任意检查项有疑问,请在以下页面查看建议操作:", help: "帮助", }, maintenance: { title: "系统当前处于维护模式。", try_again: "请稍后再试。", note: "无需就此联系支持团队——我们已在处理!", }, actions: { name: "服务器命令", available_title: "可用命令", available_description: "以下命令可执行。", available_help_intro: "每个命令的更多详情请访问", scheduled_title: "计划命令", scheduled_description: "以下命令已计划在指定时间运行。您可以查看详情并按需修改。", recurring_title: "周期命令", recurring_description: "以下命令设置为每周在指定的星期和时间运行。您可以查看详情并按需修改。", scheduled_help_intro: "关于此模式的更多详情请访问", recurring_help_intro: "关于此模式的更多详情请访问", maintenance_title: "系统当前处于维护模式。", maintenance_try_again: "请稍后再试。", maintenance_note: "无需就此联系支持团队——我们已在处理!", maintenance_commands_blocked: "在维护模式解除之前无法运行命令。", table: { aria_label: "服务器命令", command: "命令", description: "描述", arguments: "参数", is_recurring: "是否周期?", run_at: "运行时间(本地)", next_run_at: "下次运行(本地)", time_utc: "时间(UTC)", time_local: "时间(本地)", }, buttons: { create: "创建", update: "更新", back: "返回", delete: "删除", run: "运行", }, command_scheduled: "命令已计划:%{command}", command_scheduled_args: "附加参数:%{args}", expect_prefix: "请在", expect_suffix: "页面查看结果。", notifications_link: "通知", command_help_title: "%{command} 帮助", scheduled_title_create: "创建计划命令", scheduled_title_edit: "编辑计划命令", recurring_title_create: "创建周期命令", recurring_title_edit: "编辑周期命令", scheduled_details_title: "计划命令详情", recurring_warning: "由周期命令生成的计划命令不可编辑,因为它们会被自动重新生成。请改为编辑周期命令。", command_details_intro: "该命令的更多详情请访问", form: { id: "ID", command: "命令", scheduled_at: "计划时间", day_of_week: "星期", }, delete_scheduled_title: "删除计划命令", delete_recurring_title: "删除周期命令", delete_confirm: "确定要删除命令:%{command}?", errors: { unknown: "发生未知错误", delete_failed: "错误:%{error}", }, days: { monday: "周一", tuesday: "周二", wednesday: "周三", thursday: "周四", friday: "周五", saturday: "周六", sunday: "周日", }, scheduled: { action: { create_success: "计划命令创建成功", update_success: "计划命令更新成功", update_failure: "发生错误", delete_success: "计划命令删除成功", delete_failure: "发生错误", }, }, recurring: { action: { create_success: "周期命令创建成功", update_success: "周期命令更新成功", update_failure: "发生错误", delete_success: "周期命令删除成功", delete_failure: "发生错误", }, }, }, notifications: { title: "通知", new_notifications: "%{smart_count} 条新通知", no_notifications: "暂无通知", see_all: "查看所有通知", clear_all: "全部清除", ago: "前", advisory_tooltip: "您可能错过了通知。请同时查看 #news:etke.cc、etke.cc/news 或您的电子邮件。", unavailable_tooltip: "通知可能不可用。点击查看详情。", unavailable_title: "通知目前可能不可用", unavailable_body: "目前可能有一些更新无法推送到此面板——也可能没有新内容。为避免遗漏,请定期查看:", unavailable_link_matrix: "Matrix 房间 #news:etke.cc", unavailable_link_news: "etke.cc/news 公告页面", unavailable_link_email: "您的电子邮件收件箱(包括垃圾邮件文件夹)", unavailable_retry: "重试", }, currently_running: { command: "当前正在运行:", started_ago: "(%{time} 前开始)", }, time: { less_than_minute: "几秒钟", minutes: "%{smart_count} 分钟", hours: "%{smart_count} 小时", days: "%{smart_count} 天", weeks: "%{smart_count} 周", months: "%{smart_count} 个月", }, support: { name: "支持", menu_label: "联系支持", description: "提交支持请求或跟进现有请求。我们的团队将尽快回复。", create_title: "新建支持请求", no_requests: "暂无支持请求。", no_messages: "暂无消息。", closed_message: "此请求已关闭。如果您仍有问题,请提交新的请求。", fields: { subject: "主题", message: "消息", reply: "回复", status: "状态", created_at: "创建时间", updated_at: "最后更新", }, status: { active: "等待支持团队回复", open: "开放", closed: "已关闭", pending: "等待您的回复", }, buttons: { new_request: "新建请求", submit: "提交", cancel: "取消", send: "发送", back: "返回支持", attach_files: "附加文件", }, helper: { loading: "正在加载支持请求...", reply_hint: "Ctrl+Enter 发送", reply_placeholder: "请提供尽可能多的详细信息。", before_contact_title: "在联系之前", help_pages_prompt: "请先查看帮助页面:", services_prompt: "我们只提供服务页面中列出的服务:", topics_prompt: "我们仅能协助支持的主题:", scope_confirm_label: "我已查看帮助页面,并确认此请求符合支持的主题。", english_only_notice: "仅提供英文支持。", response_time_prompt: "我们力争在 48 小时内回复。需要更快的响应?请查看:", attachments_limit: "最多5个文件,每个5 MB,总计10 MB。", close_request_label: "发送后关闭此请求", }, actions: { create_success: "支持请求创建成功。", create_failure: "支持请求创建失败。", send_failure: "消息发送失败。", attachment_too_large: '文件"%{name}"超过5 MB限制。', too_many_attachments: "最多允许5个文件。", total_size_exceeded: "附件总大小超过10 MB。", }, }, }, }; export default common; ================================================ FILE: src/i18n/zh/index.ts ================================================ import { SynapseTranslationMessages } from "../types"; import common from "./common"; import mas from "./mas"; import misc_resources from "./misc_resources"; import reports from "./reports"; import rooms from "./rooms"; import users from "./users"; const zh: SynapseTranslationMessages = { ra: common.ra, ketesa: common.ketesa, import_users: common.import_users, delete_media: common.delete_media, purge_remote_media: common.purge_remote_media, etkecc: common.etkecc, resources: { users, rooms, reports, scheduled_tasks: misc_resources.scheduled_tasks, connections: misc_resources.connections, devices: misc_resources.devices, users_media: misc_resources.users_media, protect_media: misc_resources.protect_media, quarantine_media: misc_resources.quarantine_media, pushers: misc_resources.pushers, servernotices: misc_resources.servernotices, database_room_statistics: misc_resources.database_room_statistics, user_media_statistics: misc_resources.user_media_statistics, forward_extremities: misc_resources.forward_extremities, room_state: misc_resources.room_state, room_media: misc_resources.room_media, room_directory: misc_resources.room_directory, destinations: misc_resources.destinations, registration_tokens: misc_resources.registration_tokens, account_data: misc_resources.account_data, joined_rooms: misc_resources.joined_rooms, memberships: misc_resources.memberships, room_members: misc_resources.room_members, destination_rooms: misc_resources.destination_rooms, ...mas, }, }; export default zh; ================================================ FILE: src/i18n/zh/mas.ts ================================================ const mas = { mas_users: { name: "MAS用户 |||| MAS用户", fields: { id: "MAS ID", username: "用户名", admin: "管理员", locked: "已锁定", deactivated: "已停用", legacy_guest: "旧版访客", created_at: "创建于", locked_at: "锁定于", deactivated_at: "停用于", }, filter: { status: "状态", search: "搜索", status_active: "活跃", status_locked: "已锁定", status_deactivated: "已停用", }, action: { lock: { label: "锁定", success: "用户已锁定" }, unlock: { label: "解锁", success: "用户已解锁" }, deactivate: { label: "停用", success: "用户已停用" }, reactivate: { label: "重新激活", success: "用户已重新激活" }, set_admin: { label: "授予管理员权限", success: "管理员状态已更新" }, remove_admin: { label: "移除管理员权限", success: "管理员状态已更新" }, set_password: { label: "设置密码", title: "设置密码", success: "密码已设置", failure: "密码设置失败", }, }, }, mas_user_emails: { name: "邮箱 |||| 邮箱", empty: "无邮箱", fields: { email: "邮箱", user_id: "用户ID", created_at: "创建于", actions: "操作", }, action: { remove: { label: "删除", title: "删除邮箱", content: "删除 %{email}?", success: "邮箱已删除", }, create: { success: "邮箱已添加" }, }, }, mas_compat_sessions: { name: "兼容会话 |||| 兼容会话", empty: "没有兼容会话", fields: { user_id: "用户ID", device_id: "设备ID", created_at: "创建时间", user_agent: "用户代理", last_active_at: "最后活跃", last_active_ip: "最后IP", finished_at: "结束时间", human_name: "名称", active: "活跃", }, action: { finish: { label: "终止", title: "终止会话?", content: "此会话将被终止。", success: "会话已终止", }, }, }, mas_oauth2_sessions: { name: "OAuth2会话 |||| OAuth2会话", empty: "没有OAuth2会话", fields: { user_id: "用户ID", client_id: "客户端ID", scope: "权限范围", created_at: "创建时间", user_agent: "用户代理", last_active_at: "最后活跃", last_active_ip: "最后IP", finished_at: "结束时间", human_name: "名称", active: "活跃", }, action: { finish: { label: "终止", title: "终止会话?", content: "此会话将被终止。", success: "会话已终止", }, }, }, mas_policy_data: { name: "策略数据", current_policy: "当前策略", no_policy: "当前未设置任何策略。", set_policy: "设置新策略", invalid_json: "无效的JSON", fields: { json_placeholder: "输入JSON格式的策略数据…", created_at: "创建于", }, action: { save: { label: "设置策略", success: "策略已更新", failure: "策略更新失败", }, }, }, mas_user_sessions: { name: "浏览器会话 |||| 浏览器会话", fields: { user_id: "用户ID", created_at: "创建时间", finished_at: "结束时间", user_agent: "用户代理", last_active_at: "最后活跃", last_active_ip: "最后IP", active: "活跃", }, action: { finish: { label: "终止", title: "终止会话?", content: "此浏览器会话将被终止。", success: "会话已终止", }, }, }, mas_upstream_oauth_links: { name: "上游OAuth链接 |||| 上游OAuth链接", fields: { user_id: "用户ID", provider_id: "提供商ID", subject: "主体", human_account_name: "账户名称", created_at: "创建时间", }, helper: { provider_id: "上游OAuth提供商的ID,可在上游OAuth提供商列表中找到。", }, action: { remove: { label: "删除", title: "删除OAuth链接?", content: "此用户的上游OAuth链接将被删除。", success: "OAuth链接已删除", }, }, }, mas_upstream_oauth_providers: { name: "OAuth提供商 |||| OAuth提供商", fields: { issuer: "颁发者", human_name: "名称", brand_name: "品牌", created_at: "创建时间", disabled_at: "禁用时间", enabled: "已启用", }, }, mas_personal_sessions: { name: "个人会话 |||| 个人会话", empty: "没有个人会话", fields: { owner_user_id: "所有者ID", actor_user_id: "用户", human_name: "名称", scope: "权限范围", created_at: "创建时间", revoked_at: "撤销时间", last_active_at: "最后活跃", last_active_ip: "最后IP", expires_at: "过期时间", expires_in: "过期时间(秒)", active: "活跃", }, helper: { expires_in: "可选。令牌过期的秒数。留空表示永不过期。", }, action: { revoke: { label: "撤销", title: "撤销会话?", content: "访问令牌将被永久撤销。", success: "会话已撤销", }, create: { token_title: "访问令牌已创建", token_content: "请复制此令牌。关闭此对话框后将无法再次查看。", }, }, }, mas_sessions: { status: { active: "活跃", finished: "已结束", revoked: "已撤销", }, }, }; export default mas; ================================================ FILE: src/i18n/zh/misc_resources.ts ================================================ const misc_resources = { scheduled_tasks: { name: "计划任务 |||| 计划任务", fields: { id: "ID", action: "操作", status: "状态", timestamp: "时间戳", resource_id: "资源 ID", result: "结果", error: "错误", max_timestamp: "截止日期", }, status: { scheduled: "已计划", active: "进行中", complete: "已完成", cancelled: "已取消", failed: "已失败", }, }, connections: { name: "连接", fields: { last_seen: "日期", ip: "IP 地址", user_agent: "用户代理 (UA)", }, }, devices: { name: "设备", fields: { device_id: "设备 ID", display_name: "设备名", last_seen_ts: "时间戳", last_seen_ip: "IP 地址", last_seen_user_agent: "用户代理", dehydrated: "脱水设备", }, action: { erase: { title: "移除 %{id}", title_bulk: "移除 %{smart_count} 个设备", content: '您确定要移除设备 "%{name}"?', content_bulk: "您确定要移除 %{smart_count} 个设备?", success: "设备移除成功。", failure: "出现了一个错误。", }, display_name: { success: "设备名称已更新", failure: "更新设备名称失败", }, create: { label: "创建设备", title: "创建新设备", success: "设备已创建", failure: "创建设备失败", }, }, }, users_media: { name: "媒体文件", fields: { media_id: "媒体文件 ID", media_length: "长度", media_type: "类型", upload_name: "文件名", quarantined_by: "被隔离", safe_from_quarantine: "取消隔离", created_ts: "创建", last_access_ts: "上一次访问", }, action: { open: "在新窗口打开媒体文件", }, }, protect_media: { action: { create: "保护", delete: "取消保护", none: "处于隔离中", send_success: "保护状态修改成功。", send_failure: "发生错误。", }, }, quarantine_media: { action: { name: "隔离", create: "隔离", delete: "解除隔离", none: "已保护", send_success: "隔离状态修改成功。", send_failure: "发生错误:%{error}", }, }, pushers: { name: "推送器", fields: { app: "App", app_display_name: "App 名称", app_id: "App ID", device_display_name: "设备显示名", kind: "类型", lang: "语言", profile_tag: "数据标签", pushkey: "Pushkey", data: { url: "URL" }, }, }, servernotices: { name: "服务器提示", send: "发送服务器提示", fields: { body: "信息", }, action: { send: "发送提示", send_success: "服务器提示发送成功。", send_failure: "出现了一个错误。", }, helper: { send: '向选中的用户发送服务器提示。服务器配置中的 "服务器提示(Server Notices)" 选项需要被设置为启用。', }, }, database_room_statistics: { name: "数据库房间统计", fields: { room_id: "房间 ID", estimated_size: "估计大小", }, helper: { info: "显示 Synapse 数据库中每个房间使用的估计磁盘空间。数字为近似值。", }, }, user_media_statistics: { name: "媒体文件", fields: { media_count: "媒体文件统计", media_length: "媒体文件长度", }, }, forward_extremities: { name: "Forward Extremities", fields: { id: "事件 ID", received_ts: "时间戳", depth: "深度", state_group: "状态组", }, }, room_state: { name: "状态事件", fields: { type: "类型", content: "内容", origin_server_ts: "发送时间", sender: "发送者", }, }, room_media: { name: "媒体", fields: { media_id: "媒体 ID", }, helper: { info: "这是上传到房间的媒体列表。无法删除上传到外部媒体存储库的媒体。", }, action: { error: "%{errcode} (%{errstatus}) %{error}", }, }, room_directory: { name: "房间目录", fields: { world_readable: "访客无需加入即可查看", guest_can_join: "访客可以加入", }, action: { title: "从目录删除房间 |||| 从目录删除 %{smart_count} 个房间", content: "确定要从目录移除此房间? |||| 确定要从目录移除这 %{smart_count} 个房间?", erase: "从房间目录删除", create: "发布到房间目录", send_success: "房间发布成功。", send_failure: "发生错误。", }, }, destinations: { name: "联邦", fields: { destination: "目标", failure_ts: "失败时间戳", retry_last_ts: "上次重试时间戳", retry_interval: "重试间隔", last_successful_stream_ordering: "上次成功流", stream_ordering: "流", }, action: { reconnect: "重新连接" }, }, registration_tokens: { name: "注册令牌", fields: { token: "令牌", valid: "有效令牌", uses_allowed: "允许使用次数", pending: "待处理", completed: "已完成", expiry_time: "过期时间", length: "长度", created_at: "创建时间", last_used_at: "最后使用时间", revoked_at: "撤销时间", }, helper: { length: "如果未提供令牌,则为生成令牌的长度。" }, action: { revoke: { label: "撤销", success: "令牌已撤销", }, unrevoke: { label: "恢复", success: "令牌已恢复", }, }, }, account_data: { name: "账户数据", }, joined_rooms: { name: "已加入的房间", }, memberships: { name: "成员资格", }, room_members: { name: "成员", }, destination_rooms: { name: "房间", }, }; export default misc_resources; ================================================ FILE: src/i18n/zh/reports.ts ================================================ const reports = { name: "报告事件", fields: { id: "ID", received_ts: "报告时间", user_id: "报告者", name: "房间名", score: "分数", reason: "原因", event_id: "事件 ID", sender: "发送者", }, action: { erase: { title: "删除被举报事件", content: "确定要删除该被举报事件吗?此操作不可撤销。", }, event_lookup: { label: "事件查询", title: "按ID获取事件", fetch: "获取", }, fetch_event_error: "获取事件失败", }, }; export default reports; ================================================ FILE: src/i18n/zh/rooms.ts ================================================ const rooms = { name: "房间", fields: { room_id: "房间 ID", name: "房间名", canonical_alias: "别名", joined_members: "成员", joined_local_members: "本地成员", joined_local_devices: "本地设备", state_events: "状态事件", version: "版本", is_encrypted: "已加密", encryption: "加密", federatable: "支持联邦", public: "在房间目录中可见", creator: "创建者", join_rules: "加入规则", guest_access: "访客访问", history_visibility: "历史可见性", topic: "主题", avatar: "头像", actions: "操作", }, filter: { public_rooms: "公开房间", empty_rooms: "空房间", local_members_only: "仅本地成员", }, helper: { forward_extremities: "Forward extremities 是房间有向无环图(DAG)末端的叶子事件,也就是没有子事件的事件。数量越多,Synapse 需要进行的状态解析越多(这是一项昂贵的操作)。虽然 Synapse 有机制防止同一房间出现过多该类事件,但某些 Bug 仍可能导致其再次出现。如果房间的 forward extremities 超过 10 个,建议进行调查,并根据 #1760 中提到的 SQL 查询进行清理。", }, enums: { join_rules: { public: "公开", knock: "申请", invite: "邀请", private: "私有", restricted: "受限", }, guest_access: { can_join: "访客可以加入", forbidden: "访客不可加入", }, history_visibility: { invited: "受邀后可见", joined: "加入后可见", shared: "共享后可见", world_readable: "所有人可见", }, unencrypted: "未加密", room_type: { room: "房间", space: "空间", }, }, action: { erase: { title: "删除房间", content: "您确定要删除此房间吗?该操作不可撤销。房间内的所有消息和共享媒体都将从服务器删除!", fields: { block: "封禁并阻止用户加入房间", }, in_progress: "正在删除…", background_note: "您可以安全地关闭此窗口,删除将在后台继续进行。", success: "房间删除成功。", failure: "房间无法删除。", }, make_admin: { assign_admin: "分配管理员", title: "为 %{roomName} 分配房间管理员", confirm: "设为管理员", content: "请输入要设为管理员的用户完整 MXID。\n警告:要生效,房间中必须至少有一名本地成员为管理员。", success: "用户已设为房间管理员。", failure: "无法将用户设为房间管理员。%{errMsg}", }, join: { label: "添加用户", title: "将用户添加到 %{roomName}", confirm: "添加", content: "请输入要加入此房间的用户完整 MXID。\n注意:您必须在房间中并具有邀请用户的权限。", success: "已将用户成功添加到房间。", failure: "无法将用户添加到房间。%{errMsg}", }, block: { label: "封锁", title: "封锁 %{room}", title_bulk: "封锁 %{smart_count} 个房间", title_by_id: "封锁房间", content: "用户将无法加入此房间。", content_bulk: "用户将无法加入 %{smart_count} 个房间。", success: "房间封锁成功。", failure: "封锁房间失败。", }, unblock: { label: "解封", success: "房间解封成功。", failure: "解封房间失败。", }, purge_history: { label: "清除历史", title: "清除 %{roomName} 的历史", content: "所选日期之前的所有事件将从数据库中删除。房间状态(加入、离开、主题)始终保留。至少保留一条消息。\n注意:对于大型房间,此操作可能需要几分钟。", date_label: "清除此日期之前的事件", delete_local: "同时删除本地用户发送的事件", in_progress: "清除进行中…", background_note: "您可以安全地关闭此窗口,清除将在后台继续进行。", success: "房间历史清除成功。", failure: "清除房间历史失败。%{errMsg}", }, quarantine_all: { label: "隔离所有媒体", title: "隔离 %{roomName} 中的所有媒体", content: "这将隔离此房间中的所有本地和远程媒体。被隔离的媒体将无法被用户访问。", success: "已成功隔离 %{smart_count} 个媒体项。", failure: "隔离媒体失败。%{errMsg}", }, delete_all_media: { label: "删除所有媒体", title: "删除 %{roomName} 中的所有媒体", content: "这将永久删除该房间中的所有本地媒体文件。仅影响非加密房间中的本地媒体——来自其他服务器的远程媒体不在范围内。此操作无法撤销。", in_progress_loading: "正在获取媒体列表…", in_progress: "正在删除媒体… (%{current} / %{total})", do_not_close: "请勿关闭此对话框——删除正在前台运行,关闭后将中断。", success: "成功删除了 %{smart_count} 个媒体文件。 |||| 成功删除了 %{smart_count} 个媒体文件。", failure: "删除媒体失败。%{errMsg}", }, delete_all_media_bulk: { title: "删除 %{smart_count} 个房间的所有媒体? |||| 删除 %{smart_count} 个房间的所有媒体?", content: "这将永久删除所选房间中的所有本地媒体文件(仅限非加密房间)。来自其他服务器的远程媒体不在范围内。此操作无法撤销。", success: "已为 %{total} 个房间中的 %{success} 个删除媒体。", partial_failure: "已为 %{total} 个房间中的 %{success} 个删除媒体,%{failed} 个失败。", }, event_context: { lookup_title: "按 ID 查找事件", jump_to_date: "跳转到日期", direction: "方向", forward: "向前", backward: "向后", target_event: "目标事件", events_before: "之前的事件", events_after: "之后的事件", not_found: "在指定时间未找到任何事件", failure: "获取事件上下文失败", }, messages: { load_older: "加载更早的", load_newer: "加载更新的", no_messages: "此房间没有消息", failure: "加载消息失败", filter: "筛选", filter_type: "事件类型", filter_sender: "发送者", advanced_filters: "高级筛选", filter_not_type: "排除事件类型", filter_not_sender: "排除发送者", contains_url: "包含 URL", any: "任意", with_url: "仅含 URL", without_url: "仅不含 URL", apply_filter: "应用", clear_filters: "清除", }, hierarchy: { load_more: "加载更多", max_depth: "最大深度", unlimited: "无限制", refresh: "刷新", members: "%{count} 名成员", space: "空间", room: "房间", suggested: "推荐", no_children: "此房间没有子房间", failure: "加载层级结构失败", }, }, }; export default rooms; ================================================ FILE: src/i18n/zh/users.ts ================================================ const users = { name: "用户", email: "邮箱", msisdn: "电话", threepid: "邮箱 / 电话", membership: "成员资格 |||| 成员资格", fields: { avatar: "头像", id: "用户 ID", name: "用户名", is_guest: "访客", admin: "服务器管理员", locked: "锁定", deactivated: "已停用", suspended: "已暂停", shadow_banned: "影子封禁", show_guests: "显示访客", show_deactivated: "仅显示已禁用", show_locked: "显示被锁定的账户", filter_user_all: "全部", filter_deactivated_false: "活跃", filter_deactivated_true: "已停用", filter_locked_false: "排除锁定", filter_locked_true: "包含锁定", filter_guests_false: "排除访客", filter_guests_true: "包含访客", show_system_users: "显示系统用户", filter_system_users_false: "排除系统用户", filter_system_users_true: "仅系统用户", show_suspended: "显示已暂停的账户", show_shadow_banned: "显示被影子封禁的用户", user_id: "搜索用户", displayname: "显示名字", password: "密码", avatar_url: "头像 URL", avatar_src: "头像", medium: "Medium", threepids: "3PIDs", address: "地址", creation_ts_ms: "创建时间戳", consent_version: "协议版本", sent_invite_count: "已发送邀请数", cumulative_joined_room_count: "累计加入房间数", auth_provider: "身份提供方", user_type: "用户类型", erased: "已抹除(GDPR)", }, helper: { password: "更改密码会使用户注销所有会话。", password_required_for_reactivation: "重新激活账户需要提供密码。", create_password: "使用下面的按钮生成一个强大和安全的密码。", deactivate: "您必须提供密码才能停用此账户。", suspend: "暂停用户将使其进入只读模式。", shadow_ban: "被影子封禁的用户会收到正常响应,但其事件不会传播到其他用户或房间。仅在万不得已时使用。", erase: "将用户标记为根据 GDPR 的要求抹除了", admin: "服务器管理员对服务器和其用户有完全的控制权。", lock: "阻止用户使用服务器。这是一个非破坏性的操作,可以被撤销。", erase_text: "这意味着用户发送的信息对于发送信息时在房间内的任何人来说都是可见的,但对于之后加入房间的用户来说则是隐藏的。", erase_admin_error: "不允许删除自己的用户", modify_managed_user_error: "不允许修改系统管理的用户。", username_available: "用户名可用", sent_invite_count: "该用户在所有房间中发送的邀请总数。", cumulative_joined_room_count: "该用户曾经加入过的房间总数,包括已离开或被封禁的房间。", }, badge: { you: "您", bot: "机器人", admin: "管理员", support: "支持", regular: "普通用户", federated: "联邦用户", system_managed: "系统管理", }, action: { erase: "抹除用户信息", erase_avatar: "抹掉头像", delete_media: "删除用户上传的所有媒体", redact_events: "撤回用户发送的所有事件", redact_in_progress: "事件删除进行中\u2026", redact_background_note: "您可以安全地关闭此窗口,删除操作将在后台继续进行。", redact_success: "所有事件已成功删除。", redact_failure: "删除完成,但有 %{smart_count} 个事件删除失败。", generate_password: "生成密码", reset_password: { label: "重置密码", title: "重置密码", helper: "更改 %{user} 的密码", password: "密码", logout_devices: "登出所有设备", success: "密码已成功重置", failure: "重置密码失败", error_no_password: "密码为必填项", }, login_as: { label: "以用户身份登录", title: "以用户身份登录", helper: "获取可用于以 %{user} 身份进行认证的访问令牌。此操作不会为用户生成新设备,因此不会出现在其设备/会话列表中,目标用户通常无法察觉此登录。", valid_until: "设置过期时间", success: "访问令牌已成功生成", failure: "生成访问令牌失败", result_title: "%{user} 的访问令牌", access_token: "访问令牌", expires_at: "此访问令牌将于 %{date} 过期", }, overwrite_title: "警告!", overwrite_content: "这个用户名已经被占用。您确定要覆盖现有的用户吗?", overwrite_cancel: "取消", overwrite_confirm: "覆盖", quarantine_all: { label: "隔离所有媒体", title: "隔离 %{userName} 的所有媒体", content: "这将隔离该用户上传的所有本地媒体。被隔离的媒体将无法被其他用户访问。", success: "已成功隔离 %{smart_count} 个媒体项。", failure: "隔离媒体失败。%{errMsg}", }, delete_all_media: { label: "删除所有媒体", title: "删除 %{userName} 的所有媒体", content: "这将永久删除该用户上传的所有媒体文件。此操作无法撤销。", in_progress: "正在删除媒体…", background_note: "您可以安全地关闭此对话框——删除将在后台继续进行。", success: "成功删除了 %{smart_count} 个媒体文件。 |||| 成功删除了 %{smart_count} 个媒体文件。", failure: "删除媒体失败。%{errMsg}", }, delete_all_media_bulk: { title: "删除 %{smart_count} 名用户的所有媒体? |||| 删除 %{smart_count} 名用户的所有媒体?", content: "这将永久删除所选用户上传的所有媒体文件。此操作无法撤销。", success: "已为 %{total} 名用户中的 %{success} 名删除媒体。", partial_failure: "已为 %{total} 名用户中的 %{success} 名删除媒体,%{failed} 名失败。", }, allow_cross_signing: { label: "允许重置 Cross-Signing", title: "允许替换 Cross-Signing 密钥", content: "允许 %{user} 在无需用户交互式认证的情况下替换其 Cross-Signing 密钥?这将创建一个临时窗口,在此期间密钥可以被替换。", success: "Cross-Signing 密钥替换已允许至 %{deadline}", failure: "允许 Cross-Signing 替换失败", no_key: "用户没有主 Cross-Signing 密钥", }, find_user: { label: "查找用户", title: "查找用户", lookup_type: "查找类型", by_threepid: "通过邮箱 / 电话", by_auth_provider: "通过认证提供商", provider: "认证提供商 ID", external_id: "外部 ID", search: "搜索", not_found: "未找到用户", failure: "查找用户失败", }, renew_account: { label: "续期账户", title: "续期账户有效期", content: "续期 %{user} 的账户有效期。可选择设置自定义到期日期。若留空,将使用服务器默认续期周期。", expiration: "到期日期", expiration_helper: "留空以使用服务器默认续期周期", renewal_emails: "发送续期通知邮件", success: "账户有效期已续期至 %{date}", failure: "续期账户有效期失败", }, system_users_scan_in_progress: "请稍候 — 仍在搜索匹配的用户,页面即将加载", reverse_search_scan_in_progress: "请稍候 — 正在扫描所有用户以排除匹配项,页面即将加载", }, limits: { messages_per_second: "每秒消息数", messages_per_second_text: "每秒可以执行的操作数。", burst_count: "突发计数", burst_count_text: "在限制之前可以执行的操作数。", }, account_data: { title: "账户数据", global: "全局", rooms: "房间", }, }; export default users; ================================================ FILE: src/index.tsx ================================================ import React from "react"; import { createRoot } from "react-dom/client"; import "./assets/fonts.css"; import { App } from "./App"; import { ConfigProvider } from "./Context"; import { FetchInstanceConfig, GetInstanceConfig } from "./components/etke.cc/InstanceConfig"; import { createI18nProvider } from "./i18n"; import { FetchConfig, GetConfig } from "./utils/config"; await FetchConfig(); await FetchInstanceConfig(GetConfig().etkeccAdmin, ""); const i18nProvider = await createI18nProvider(); // we set base title here to be used in useDocTitle hook // as a tricky workaround since hooks can't be used outside components, // and react-admin doesn't provide a way to set document title directly const icfg = GetInstanceConfig(); document.head.dataset.baseTitle = icfg.name || "Ketesa"; // set based on instance name, only if it's not already set if (icfg.name && !document.title.includes(icfg.name)) { document.title = icfg.name; } createRoot(document.getElementById("root")).render( <React.StrictMode> <ConfigProvider> <App i18nProvider={i18nProvider} /> </ConfigProvider> </React.StrictMode> ); // Fade out and remove the static loader overlay const loader = document.getElementById("loader"); if (loader) { loader.classList.add("fade-out"); loader.addEventListener("transitionend", () => loader.remove(), { once: true }); } ================================================ FILE: src/pages/DonatePage.test.tsx ================================================ import { render, screen } from "@testing-library/react"; import polyglotI18nProvider from "ra-i18n-polyglot"; import { AdminContext } from "react-admin"; import DonatePage, { DONATE_URL } from "./DonatePage"; import englishMessages from "../i18n/en"; const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]); describe("DonatePage", () => { it("renders the approved english donation copy and CTA", () => { render( <AdminContext i18nProvider={i18nProvider}> <DonatePage /> </AdminContext> ); screen.getByRole("heading", { name: `${englishMessages.etkecc.donate.name} ✨` }); screen.getByLabelText("Matrix"); screen.getByText(/The Ketesa project is free and open source/i); screen.getByText(/community\./i); screen.getByText(englishMessages.etkecc.donate.description_2); screen.getByText(englishMessages.etkecc.donate.description_3); screen.getByText(englishMessages.etkecc.donate.description_4); screen.getByText(englishMessages.etkecc.donate.signature_team); const donateLink = screen.getByRole("link", { name: englishMessages.etkecc.donate.button }); expect(donateLink).toHaveAttribute("href", DONATE_URL); expect(donateLink).toHaveAttribute("target", "_blank"); expect(donateLink).toHaveAttribute("rel", "noreferrer"); }); }); ================================================ FILE: src/pages/DonatePage.tsx ================================================ import FavoriteIcon from "@mui/icons-material/Favorite"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import { Box, Button, Paper, Stack, Typography, keyframes } from "@mui/material"; import { Title, useTranslate } from "react-admin"; import MatrixWordmark from "../components/MatrixWordmark"; import { EtkeAttribution } from "../components/etke.cc/EtkeAttribution"; import { useDocTitle } from "../components/hooks/useDocTitle"; export const DONATE_URL = "https://github.com/sponsors/etkecc"; const fadeIn = keyframes` from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } `; const float = keyframes` 0% { transform: translateY(0) rotate(0deg) scale(1); } 50% { transform: translateY(-10px) rotate(2deg) scale(1.03); } 100% { transform: translateY(0) rotate(0deg) scale(1); } `; const glow = keyframes` 0%, 100% { opacity: 0.45; transform: scale(1); } 50% { opacity: 0.8; transform: scale(1.12); } `; const renderTextWithMatrixWordmark = (text: string) => { const [before, after] = text.split("Matrix"); if (typeof after === "undefined") { return text; } return ( <> {before} <MatrixWordmark sx={{ width: "2.45em", mx: "0.14em", verticalAlign: "-0.04em", }} /> {after} </> ); }; const DonatePage = () => { const translate = useTranslate(); useDocTitle(translate("etkecc.donate.title")); return ( <Stack spacing={4} sx={{ width: "100%", minHeight: "100%", alignItems: "center", justifyContent: "center", px: { xs: 2, sm: 3, md: 4 }, py: { xs: 4, md: 6 }, animation: `${fadeIn} 500ms ease-out`, }} > <Title title={translate("etkecc.donate.title")} /> <Box sx={{ position: "relative", width: "100%", display: "flex", justifyContent: "center", pt: { xs: 2, md: 3 }, }} > <Box sx={theme => ({ position: "absolute", top: { xs: "2%", md: "0%" }, width: { xs: "92%", md: "78%" }, height: { xs: "92%", md: "84%" }, borderRadius: "50%", background: theme.palette.mode === "dark" ? "radial-gradient(circle, rgba(244,147,0,0.10) 0%, rgba(244,147,0,0.05) 36%, transparent 68%)" : "radial-gradient(circle, rgba(24,88,213,0.08) 0%, rgba(220,38,38,0.05) 34%, transparent 66%)", filter: "blur(18px)", opacity: 0.9, WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.8) 18%, black 32%, black 100%)", maskImage: "linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.8) 18%, black 32%, black 100%)", pointerEvents: "none", zIndex: 0, })} /> <Stack spacing={3} sx={{ position: "relative", width: { xs: "100%", md: "88%", lg: "78%" }, alignItems: "center", zIndex: 1, }} > <Box sx={{ position: "relative", display: "flex", justifyContent: "center" }}> <Box sx={theme => ({ position: "absolute", inset: -12, borderRadius: "50%", background: theme.palette.mode === "dark" ? "radial-gradient(circle, rgba(244,147,0,0.18) 0%, rgba(244,147,0,0.08) 48%, transparent 72%)" : "radial-gradient(circle, rgba(220,38,38,0.16) 0%, rgba(220,38,38,0.08) 46%, transparent 72%)", animation: `${glow} 4s ease-in-out infinite`, pointerEvents: "none", filter: "blur(8px)", })} /> <Box sx={theme => ({ width: { xs: 112, sm: 136 }, height: { xs: 112, sm: 136 }, borderRadius: "50%", display: "flex", alignItems: "center", justifyContent: "center", position: "relative", background: theme.palette.mode === "dark" ? "radial-gradient(circle at 35% 30%, rgba(244,147,0,0.22) 0%, rgba(244,147,0,0.07) 60%, transparent 100%)" : "radial-gradient(circle at 35% 30%, rgba(220,38,38,0.18) 0%, rgba(24,88,213,0.08) 58%, transparent 100%)", border: theme.palette.mode === "dark" ? "1px solid rgba(244,147,0,0.18)" : "1px solid rgba(220,38,38,0.14)", animation: `${float} 5s ease-in-out infinite`, })} > <FavoriteIcon sx={theme => ({ fontSize: { xs: 52, sm: 60 }, color: theme.palette.mode === "dark" ? "rgba(244,147,0,0.7)" : "rgba(220,38,38,0.72)", })} /> </Box> </Box> <Stack spacing={1.5} sx={{ alignItems: "center", textAlign: "center", width: "100%" }}> <Typography variant="h3" sx={{ fontSize: { xs: "2rem", md: "2.6rem" }, lineHeight: 1.1 }}> {translate("etkecc.donate.name")} <Box component="span">✨</Box> </Typography> <Typography variant="body1" color="text.secondary" sx={{ width: { xs: "100%", md: "84%", lg: "72%" }, fontSize: { xs: "1rem", md: "1.05rem" }, }} > {renderTextWithMatrixWordmark(translate("etkecc.donate.description_1"))} </Typography> </Stack> </Stack> </Box> <Paper elevation={0} sx={theme => ({ p: { xs: 2.5, sm: 3.5, md: 4 }, width: { xs: "100%", md: "88%", lg: "78%" }, borderRadius: 6, border: theme.palette.mode === "dark" ? "1px solid rgba(244,147,0,0.14)" : "1px solid rgba(24,88,213,0.08)", background: theme.palette.mode === "dark" ? "linear-gradient(180deg, rgba(21,24,32,0.94) 0%, rgba(13,16,22,0.98) 100%)" : "linear-gradient(180deg, rgba(255,255,255,0.94) 0%, rgba(247,249,253,0.98) 100%)", boxShadow: theme.palette.mode === "dark" ? "0 24px 80px rgba(0,0,0,0.28)" : "0 24px 70px rgba(24,88,213,0.08)", })} > <Stack spacing={3} sx={{ alignItems: "center" }}> <Stack spacing={2} sx={{ width: "100%" }}> <Typography variant="body1" sx={{ textAlign: "center", width: "100%" }}> {translate("etkecc.donate.description_2")} </Typography> <Typography variant="body1" sx={{ textAlign: "center", width: "100%" }}> {translate("etkecc.donate.description_3")} </Typography> <EtkeAttribution> <Typography variant="body1" sx={{ textAlign: "center", width: "100%", fontWeight: 500 }}> {translate("etkecc.donate.description_4")} </Typography> </EtkeAttribution> </Stack> <Box sx={{ display: "flex", justifyContent: "center", width: "100%" }}> <Button variant="contained" color="primary" href={DONATE_URL} target="_blank" rel="noreferrer" endIcon={<OpenInNewIcon />} sx={{ px: 3, py: 1.2, width: { xs: "100%", sm: "auto" } }} > {translate("etkecc.donate.button")} </Button> </Box> <Box sx={{ pt: 1, textAlign: "center" }}> <Typography variant="body1" sx={{ fontWeight: 600 }}> {translate("etkecc.donate.signature_team")} </Typography> </Box> </Stack> </Paper> </Stack> ); }; export default DonatePage; ================================================ FILE: src/pages/LoginPage.test.tsx ================================================ import { act, render, screen } from "@testing-library/react"; import polyglotI18nProvider from "ra-i18n-polyglot"; import { AdminContext } from "react-admin"; import LoginPage, { getDefaultProtocolForHomeserverInput, isValidIssuer } from "./LoginPage"; import { AppContext } from "../Context"; import englishMessages from "../i18n/en"; const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]); const welcomeText = englishMessages.ketesa.auth.welcome.replace("%{name}", "Ketesa"); describe("isValidIssuer", () => { it.each([ // valid — https any host ["https://auth.example.com/", true], ["https://auth.example.com", true], ["https://localhost:8007/", true], ["https://127.0.0.1:8007", true], // valid — http for intranet/local deployments ["http://localhost:8007", true], ["http://127.0.0.1:8007", true], ["http://mas.intranet.corp", true], ["http://auth.internal/", true], // invalid — query string or fragment ["https://auth.example.com/?foo=bar", false], ["https://auth.example.com/#section", false], // invalid — non-http scheme ["ftp://auth.example.com/", false], ["javascript:alert(1)", false], // invalid — not a URL ["not-a-url", false], ["", false], ])("isValidIssuer(%s) === %s", (issuer, expected) => { expect(isValidIssuer(issuer)).toBe(expected); }); }); describe("LoginForm", () => { it.each([ ["localhost", "http"], ["localhost:8008", "http"], ["127.0.0.1", "http"], ["127.0.0.1:8008", "http"], ["::1", "http"], ["[::1]:8008", "http"], ["matrix.example.com", "https"], ["matrix.example.com:8448", "https"], ])("selects %s for %s homeserver inputs", (input, expectedProtocol) => { expect(getDefaultProtocolForHomeserverInput(input)).toBe(expectedProtocol); }); it("renders with no restriction to homeserver", async () => { await act(async () => { render( <AdminContext i18nProvider={i18nProvider}> <LoginPage /> </AdminContext> ); }); screen.getByText(welcomeText); screen.getByRole("combobox", { name: "" }); // Language selector // Base URL input should be visible and editable const baseUrlInput = screen.getByRole("textbox", { name: englishMessages.ketesa.auth.base_url, }); expect(baseUrlInput.className.split(" ")).not.toContain("Mui-readOnly"); // Username and password fields are not visible until server info is checked // and supportPassAuth is determined }); it("renders with single restricted homeserver", () => { render( <AppContext.Provider value={{ restrictBaseUrl: "https://matrix.example.com", asManagedUsers: [], menu: [], corsCredentials: "include", externalAuthProvider: false, }} > <AdminContext i18nProvider={i18nProvider}> <LoginPage /> </AdminContext> </AppContext.Provider> ); screen.getByText(welcomeText); screen.getByRole("combobox", { name: "" }); // Language selector // Base URL field should not be visible when single restricted homeserver is set expect(() => screen.getByRole("textbox", { name: englishMessages.ketesa.auth.base_url, }) ).toThrow(); // Username and password fields are not visible until server info is checked // and supportPassAuth is determined }); it("renders with multiple restricted homeservers", async () => { render( <AppContext.Provider value={{ restrictBaseUrl: ["https://matrix.example.com", "https://matrix.example.org"], asManagedUsers: [], menu: [], corsCredentials: "include", externalAuthProvider: false, }} > <AdminContext i18nProvider={i18nProvider}> <LoginPage /> </AdminContext> </AppContext.Provider> ); screen.getByText(welcomeText); screen.getByRole("combobox", { name: "" }); // Language selector // Base URL field should be visible as a combobox when multiple restricted homeservers are set screen.getByRole("combobox", { name: englishMessages.ketesa.auth.base_url, }); // Username and password fields are not visible until server info is checked // and supportPassAuth is determined }); }); ================================================ FILE: src/pages/LoginPage.tsx ================================================ import TranslateIcon from "@mui/icons-material/Translate"; import { Avatar, Box, Button, Card, CardActions, CircularProgress, MenuItem, Select, Tab, Tabs, Typography, } from "@mui/material"; import { useState, useEffect, useRef } from "react"; import { Form, FormDataConsumer, Notification, required, useLogin, useNotify, useLocaleState, useTranslate, PasswordInput, TextInput, SelectInput, useLocales, } from "react-admin"; import { useFormContext } from "react-hook-form"; import { useAppContext } from "../Context"; import { EtkeAttribution } from "../components/etke.cc/EtkeAttribution"; import { useInstanceConfig } from "../components/etke.cc/InstanceConfig"; import { getServerVersion } from "../providers/data/synapse"; import { getSupportedFeatures, getWellKnownUrl, isValidBaseUrl, splitMxid, getSupportedLoginFlows, getAuthMetadata, resolveBaseUrlWithWellKnown, } from "../providers/matrix"; import { GetConfig, SetExternalAuthProvider } from "../utils/config"; import createLogger from "../utils/logger"; import { Footer, LoginFormBox } from "../components/layout"; const log = createLogger("login"); export type LoginMethod = "credentials" | "accessToken"; /** * Get restricted base URL(s) from app context * @returns tuple of (single URL or null, array of URLs or null) */ function useRestrictedBaseUrl(): [string | null, string[] | null] { const { restrictBaseUrl } = useAppContext(); // no var set, allow any if (!restrictBaseUrl) { return [null, null]; } if (typeof restrictBaseUrl === "string") { // empty string means allow any if (restrictBaseUrl === "") { return [null, null]; } // any other string means single url return [restrictBaseUrl, null]; } if (Array.isArray(restrictBaseUrl)) { // empty array means allow any if (restrictBaseUrl.length === 0) { return [null, null]; } let items = restrictBaseUrl.filter(item => item && item.trim() !== ""); items = Array.from(new Set(items)); // deduplicate // after filtering, empty array means allow any if (items.length === 0) { return [null, null]; } // array with one element means single url if (items.length === 1) { return [items[0], null]; } // array with multiple elements means multiple urls return [null, items]; } // fallback to any return [null, null]; } export const getDefaultProtocolForHomeserverInput = (value: string): "http" | "https" => { const normalizedValue = value.trim().replace(/\/+$/g, ""); if ( /^(localhost|127\.0\.0\.1)(:\d{1,5})?$/i.test(normalizedValue) || /^::1$/i.test(normalizedValue) || /^\[::1\](:\d{1,5})?$/i.test(normalizedValue) ) { return "http"; } return "https"; }; const prependDefaultProtocol = (value: string): string => { if (value.match(/^https?:\/\//)) { return value; } return `${getDefaultProtocolForHomeserverInput(value)}://${value}`; }; /** * Returns true when the issuer string is a well-formed HTTP(S) URL * with no query string or fragment (per RFC 8414 §2). * Does not enforce https — that is a deployment policy, not a format rule. */ export const isValidIssuer = (issuer: string): boolean => { try { const { protocol, search, hash } = new URL(issuer); return (protocol === "https:" || protocol === "http:") && search === "" && hash === ""; } catch { return false; } }; const LoginPage = () => { const login = useLogin(); const notify = useNotify(); const [restrictBaseUrlSingle, restrictBaseUrlMultiple] = useRestrictedBaseUrl(); const wellKnownDiscovery = GetConfig().wellKnownDiscovery ?? true; const baseUrlChoices = restrictBaseUrlMultiple ? restrictBaseUrlMultiple : []; const localStorageBaseUrl = localStorage.getItem("base_url"); let base_url = restrictBaseUrlSingle ? restrictBaseUrlSingle : restrictBaseUrlMultiple ? restrictBaseUrlMultiple[0] : null; if (!base_url) { if (localStorageBaseUrl && restrictBaseUrlMultiple?.includes(localStorageBaseUrl)) { // set base_url if it is in the restrictBaseUrl array base_url = localStorageBaseUrl; } } const [loading, setLoading] = useState(false); const [supportPassAuth, setSupportPassAuth] = useState(false); const [locale, setLocale] = useLocaleState(); const locales = useLocales(); const translate = useTranslate(); const hasInitializedUrlParams = useRef(false); const [authMetadata, setAuthMetadata] = useState({}); const [oidcVisible, setOIDCVisible] = useState(true); const [oidcUrl, setOIDCUrl] = useState(""); const [ssoBaseUrl, setSSOBaseUrl] = useState(""); const [baseUrl, setBaseUrl] = useState(base_url || ""); const [resolvedBaseUrl, setResolvedBaseUrl] = useState(base_url || ""); const loginToken = new URLSearchParams(window.location.search).get("loginToken"); const [loginMethod, setLoginMethod] = useState<LoginMethod>("credentials"); const [serverVersion, setServerVersion] = useState(""); const [matrixVersions, setMatrixVersions] = useState(""); const initialBaseUrl = useRef(base_url); useEffect(() => { if (initialBaseUrl.current) { resolveAndCheckServerInfo(initialBaseUrl.current as string); } }, []); // eslint-disable-line react-hooks/exhaustive-deps -- resolveAndCheckServerInfo is stable within the mount useEffect(() => { if (!loginToken) { return; } // Prevent further requests const previousUrl = new URL(window.location.toString()); previousUrl.searchParams.delete("loginToken"); window.history.replaceState({}, "", previousUrl.toString()); const sso_base_url = localStorage.getItem("sso_base_url"); localStorage.removeItem("sso_base_url"); if (sso_base_url) { const auth = { base_url: sso_base_url, username: null, password: null, loginToken, }; login(auth).catch(error => { alert( typeof error === "string" ? error : typeof error === "undefined" || !error.message ? "ra.auth.sign_in_error" : error.message ); log.error("login with token failed", error); }); } }, [loginToken, login]); const validateBaseUrl = (value: string) => { if (!value.match(/^(https?):\/\//)) { return translate("ketesa.auth.protocol_error"); } else if (!isValidBaseUrl(value)) { return translate("ketesa.auth.url_error"); } else { return undefined; } }; const handleSubmit = auth => { setLoading(true); const cleanUrl = window.location.href.replace(window.location.search, ""); window.history.replaceState({}, "", cleanUrl); const authWithResolved = { ...auth, base_url: resolvedBaseUrl || auth.base_url, }; login(authWithResolved).catch(error => { setLoading(false); notify( typeof error === "string" ? error : typeof error === "undefined" || !error.message ? "ra.auth.sign_in_error" : error.message, { type: "warning" } ); }); }; const handleSSO = () => { localStorage.setItem("sso_base_url", ssoBaseUrl); const ssoFullUrl = `${ssoBaseUrl}/_matrix/client/v3/login/sso/redirect?redirectUrl=${encodeURIComponent( window.location.href )}`; window.location.href = ssoFullUrl; }; const handleOIDC = () => { log.debug("OIDC login initiated", { baseUrl }); login({ base_url: baseUrl, clientUrl: window.location.origin + window.location.pathname, authMetadata: authMetadata, }); }; const checkServerInfo = async (url: string) => { if (!isValidBaseUrl(url)) { setServerVersion(""); setMatrixVersions(""); setOIDCUrl(""); setBaseUrl(""); setResolvedBaseUrl(""); setSupportPassAuth(false); return; } try { const serverVersion = await getServerVersion(url); setServerVersion(`${translate("ketesa.auth.server_version")} ${serverVersion}`); } catch { setServerVersion(""); } try { const features = await getSupportedFeatures(url); setMatrixVersions(`${translate("ketesa.auth.supports_specs")} ${features.versions.join(", ")}`); } catch { setMatrixVersions(""); } // Probe login flows and auth_metadata in parallel. // auth_metadata (/_matrix/client/v1/auth_metadata) works even when /v3/login is disabled by MAS. const [loginFlowsResult, authMetadataResult] = await Promise.allSettled([ getSupportedLoginFlows(url), getAuthMetadata(url), ]); const loginFlows = loginFlowsResult.status === "fulfilled" ? loginFlowsResult.value : []; const authMetadata = authMetadataResult.status === "fulfilled" ? authMetadataResult.value : null; const supportPass = loginFlows.find(f => f.type === "m.login.password") !== undefined; const supportSSO = loginFlows.find(f => f.type === "m.login.sso") !== undefined; const hasDelegatedOIDC = supportSSO && !!loginFlows.find( f => f.type === "m.login.sso" && (f["org.matrix.msc3824.delegated_oidc_compatibility"] || f["delegated_oidc_compatibility"]) ); // Either the MSC3824 delegated_oidc flag OR a valid auth_metadata issuer triggers the OIDC path. // MAS servers typically disable /v3/login (returning 404), so auth_metadata is the only signal. if (hasDelegatedOIDC || authMetadata?.issuer) { if (!authMetadata || !isValidIssuer(authMetadata.issuer)) { // auth_metadata missing or issuer has an unsupported scheme — server misconfigured setSupportPassAuth(false); setSSOBaseUrl(""); setOIDCUrl(""); setOIDCVisible(false); setBaseUrl(""); setResolvedBaseUrl(""); return; } setBaseUrl(url); setResolvedBaseUrl(url); SetExternalAuthProvider(true); setSSOBaseUrl(""); setAuthMetadata(authMetadata); setOIDCUrl(authMetadata.issuer); setSupportPassAuth(false); } else if (loginFlows.length > 0) { // Standard login flows — no delegated OIDC setBaseUrl(url); setResolvedBaseUrl(url); setSupportPassAuth(supportPass); setSSOBaseUrl(supportSSO ? url : ""); setOIDCVisible(false); setOIDCUrl(""); setAuthMetadata({}); } else { // Both probes failed — unknown or unreachable server setSupportPassAuth(false); setSSOBaseUrl(""); setOIDCUrl(""); setOIDCVisible(false); setBaseUrl(""); setResolvedBaseUrl(""); } }; const resolveAndCheckServerInfo = async (url: string, updateFormValue?: (nextUrl: string) => void) => { if (!url) { return; } if (!isValidBaseUrl(url)) { checkServerInfo(url); return; } const resolvedUrl = wellKnownDiscovery ? await resolveBaseUrlWithWellKnown(url) : url; if (resolvedUrl !== url && updateFormValue) { updateFormValue(resolvedUrl); } checkServerInfo(resolvedUrl); }; const icfg = useInstanceConfig(); let welcomeTo = "Ketesa"; let logoUrl = "./images/logo.webp"; let backgroundUrl = ""; if (icfg.name) { welcomeTo = icfg.name; } if (icfg.logo_url) { logoUrl = icfg.logo_url; } if (icfg.background_url) { backgroundUrl = icfg.background_url; } const UserData = ({ formData }) => { const form = useFormContext(); const handleUsernameChange = async () => { if (formData.base_url || restrictBaseUrlSingle) { return; } // check if username is a full qualified userId then set base_url accordingly const domain = splitMxid(formData.username)?.domain; if (domain) { const url = wellKnownDiscovery ? await getWellKnownUrl(domain) : `https://${domain}`; if (!restrictBaseUrlMultiple || restrictBaseUrlMultiple.includes(url)) { form.setValue("base_url", url, { shouldValidate: true, shouldDirty: true, }); setResolvedBaseUrl(url); checkServerInfo(url); } } }; const handleBaseUrlBlurOrChange = event => { // Get the value either from the event (onChange) or from formData (onBlur) let value = event?.target?.value || formData.base_url; if (!value) { return; } if (!value.match(/^https?:\/\//)) { value = prependDefaultProtocol(value); if (!restrictBaseUrlMultiple && !restrictBaseUrlSingle) { form.setValue("base_url", value, { shouldValidate: true, shouldDirty: true, }); } } // Trigger validation only when user finishes typing/selecting form.trigger("base_url"); const updateFormValue = restrictBaseUrlMultiple || restrictBaseUrlSingle ? undefined : (nextUrl: string) => form.setValue("base_url", nextUrl, { shouldValidate: true, shouldDirty: true, }); resolveAndCheckServerInfo(value, updateFormValue); }; useEffect(() => { if (hasInitializedUrlParams.current) return; hasInitializedUrlParams.current = true; // Defer to ensure form is initialized const timer = setTimeout(() => { const params = new URLSearchParams(window.location.search); const hostname = window.location.hostname; const username = params.get("username"); const password = params.get("password"); const accessToken = params.get("accessToken"); let serverURL = params.get("server"); if (username) { form.setValue("username", username); } if (hostname === "localhost" || hostname === "127.0.0.1") { if (password) { form.setValue("password", password); } if (accessToken) { setLoginMethod("accessToken"); form.setValue("accessToken", accessToken); } } if (serverURL) { const isFullUrl = serverURL.match(/^(http|https):\/\//); if (!isFullUrl) { serverURL = prependDefaultProtocol(serverURL); } form.setValue("base_url", serverURL, { shouldValidate: true, shouldDirty: true, }); const updateFormValue = restrictBaseUrlMultiple || restrictBaseUrlSingle ? undefined : (nextUrl: string) => form.setValue("base_url", nextUrl, { shouldValidate: true, shouldDirty: true, }); resolveAndCheckServerInfo(serverURL, updateFormValue); } }, 0); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( <> <Tabs value={loginMethod} onChange={(_, newValue) => setLoginMethod(newValue as LoginMethod)} indicatorColor="primary" textColor="primary" variant="fullWidth" > <Tab label={translate("ketesa.auth.credentials")} value="credentials" /> <Tab label={translate("ketesa.auth.access_token")} value="accessToken" /> </Tabs> <Box> {restrictBaseUrlMultiple && ( <SelectInput source="base_url" label="ketesa.auth.base_url" select={true} autoComplete="url" {...(loading ? { disabled: true } : {})} onChange={handleBaseUrlBlurOrChange} validate={[required(), validateBaseUrl]} choices={baseUrlChoices} /> )} {!restrictBaseUrlSingle && !restrictBaseUrlMultiple && ( <TextInput source="base_url" label="ketesa.auth.base_url" autoComplete="url" {...(loading ? { disabled: true } : {})} resettable={true} validate={[required(), validateBaseUrl]} onBlur={handleBaseUrlBlurOrChange} /> )} </Box> {loginMethod === "credentials" && supportPassAuth && ( <> <Box> <TextInput source="username" label="ra.auth.username" autoComplete="username" onBlur={handleUsernameChange} resettable validate={required()} {...(loading || !supportPassAuth ? { disabled: true } : {})} /> </Box> <Box> <PasswordInput source="password" label="ra.auth.password" type="password" autoComplete="current-password" {...(loading || !supportPassAuth ? { disabled: true } : {})} resettable validate={required()} /> </Box> </> )} {loginMethod === "accessToken" && ( <Box> <TextInput source="accessToken" label="ketesa.auth.access_token" {...(loading ? { disabled: true } : {})} resettable validate={required()} /> </Box> )} <Typography className="serverVersion" sx={{ wordBreak: "break-word" }}> {serverVersion} </Typography> <Typography className="matrixVersions" sx={{ wordBreak: "break-word" }}> {matrixVersions} </Typography> </> ); }; return ( <Form defaultValues={{ base_url: base_url }} onSubmit={handleSubmit} mode="onBlur"> <LoginFormBox backgroundUrl={backgroundUrl}> {!backgroundUrl && ( <> <div className="login-orb login-orb-1" /> <div className="login-orb login-orb-2" /> <div className="login-orb login-orb-3" /> </> )} <Card className="card"> <Box className="avatar"> {loading ? ( <CircularProgress size={25} thickness={2} /> ) : ( <Avatar sx={{ width: { xs: "80px", sm: "120px" }, height: { xs: "80px", sm: "120px" } }} src={logoUrl} /> )} </Box> <Box className="hint">{translate("ketesa.auth.welcome", { name: welcomeTo })}</Box> <Box sx={{ display: "flex", justifyContent: "center", color: "text.secondary", fontSize: "0.85rem", mt: -1, mb: 1.5, px: 2, textAlign: "center", }} > {translate("ketesa.auth.description")} </Box> <Box className="form"> <FormDataConsumer>{formDataProps => <UserData {...formDataProps} />}</FormDataConsumer> {loginMethod === "credentials" && ( <CardActions className="actions" sx={{ flexDirection: "column", gap: 1, "& > :not(:first-of-type)": { ml: 0 } }} > {supportPassAuth && ( <Button variant="contained" type="submit" color="primary" disabled={loading} fullWidth> {translate("ra.auth.sign_in")} </Button> )} {ssoBaseUrl !== "" && ( <Button variant="contained" color="secondary" onClick={handleSSO} disabled={loading} fullWidth> {translate("ketesa.auth.sso_sign_in")} </Button> )} {(oidcVisible || oidcUrl !== "") && ( <Button variant="contained" color="secondary" onClick={handleOIDC} disabled={loading || oidcUrl === ""} fullWidth > {translate("ra.auth.sign_in")} </Button> )} </CardActions> )} {loginMethod === "accessToken" && ( <CardActions className="actions"> <Button variant="contained" type="submit" color="primary" disabled={loading} fullWidth> {translate("ra.auth.sign_in")} </Button> </CardActions> )} </Box> <Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", gap: 0.5, pb: 2, opacity: 0.6, "&:hover": { opacity: 0.85 }, transition: "opacity 150ms ease", }} > <TranslateIcon sx={{ fontSize: "0.95rem", color: "text.secondary" }} /> <Select variant="standard" value={locale} onChange={e => setLocale(e.target.value)} disabled={loading} disableUnderline sx={{ fontSize: "0.8rem", color: "text.secondary", "& .MuiSelect-select": { py: 0 }, "& .MuiSvgIcon-root": { color: "text.secondary", fontSize: "1rem" }, }} > {locales.map(l => ( <MenuItem key={l.locale} value={l.locale} dense> {l.name} </MenuItem> ))} </Select> </Box> </Card> </LoginFormBox> <Notification /> <EtkeAttribution> <Footer /> </EtkeAttribution> </Form> ); }; export default LoginPage; ================================================ FILE: src/pages/MASPolicyDataPage.test.tsx ================================================ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import polyglotI18nProvider from "ra-i18n-polyglot"; import { AdminContext } from "react-admin"; import englishMessages from "../i18n/en"; import MASPolicyDataPage from "./MASPolicyDataPage"; vi.mock("../components/hooks/useDocTitle", () => ({ useDocTitle: vi.fn(), })); const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]); const makeMockDataProvider = (overrides: Record<string, unknown> = {}) => ({ getList: vi.fn(), getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(), getMASPolicyData: vi.fn(), setMASPolicyData: vi.fn(), ...overrides, }); const renderPage = (dataProvider: ReturnType<typeof makeMockDataProvider>) => render( <AdminContext i18nProvider={i18nProvider} dataProvider={dataProvider}> <MASPolicyDataPage /> </AdminContext> ); /** Set a textarea value using fireEvent to avoid userEvent's { } key-sequence parsing. */ const setTextarea = (el: Element, value: string) => { fireEvent.change(el, { target: { value } }); }; describe("MASPolicyDataPage", () => { it("does not show policy content while still loading", () => { // Render without awaiting effects — policy stays undefined → Loading state // eslint-disable-next-line @typescript-eslint/no-empty-function const dp = makeMockDataProvider({ getMASPolicyData: vi.fn(() => new Promise(() => {})) }); render( <AdminContext i18nProvider={i18nProvider} dataProvider={dp}> <MASPolicyDataPage /> </AdminContext> ); // While loading (policy === undefined), the content cards must not be visible expect(screen.queryByText("Current Policy")).toBeNull(); expect(screen.queryByText("Set a New Policy")).toBeNull(); }); it("shows no-policy message when getMASPolicyData returns null", async () => { const dp = makeMockDataProvider({ getMASPolicyData: vi.fn().mockResolvedValue(null) }); await act(async () => { renderPage(dp); }); await waitFor(() => { expect(screen.getByText("No policy is currently set.")).toBeTruthy(); }); }); it("displays existing policy JSON when data is returned", async () => { const policyData = { id: "pol-1", data: { allowed: true }, created_at: "2024-01-01T00:00:00Z" }; const dp = makeMockDataProvider({ getMASPolicyData: vi.fn().mockResolvedValue(policyData) }); await act(async () => { renderPage(dp); }); await waitFor(() => { expect(screen.getByText(/"allowed": true/)).toBeTruthy(); }); }); it("shows JSON validation error for invalid JSON input", async () => { const dp = makeMockDataProvider({ getMASPolicyData: vi.fn().mockResolvedValue(null) }); await act(async () => { renderPage(dp); }); await waitFor(() => screen.getByRole("textbox")); act(() => { setTextarea(screen.getByRole("textbox"), "not valid json{"); }); await waitFor(() => { expect(screen.getByText("Invalid JSON")).toBeTruthy(); }); }); it("Set Policy button is disabled initially and enabled after valid JSON is entered", async () => { const dp = makeMockDataProvider({ getMASPolicyData: vi.fn().mockResolvedValue(null) }); await act(async () => { renderPage(dp); }); await waitFor(() => screen.getByRole("textbox")); expect(screen.getByRole("button", { name: /set policy/i })).toBeDisabled(); act(() => { setTextarea(screen.getByRole("textbox"), '{"key":"value"}'); }); await waitFor(() => { expect(screen.getByRole("button", { name: /set policy/i })).not.toBeDisabled(); }); }); it("calls setMASPolicyData with parsed JSON on save", async () => { const user = userEvent.setup(); const setMASPolicyData = vi.fn().mockResolvedValue({ success: true }); const dp = makeMockDataProvider({ getMASPolicyData: vi.fn().mockResolvedValue(null), setMASPolicyData, }); await act(async () => { renderPage(dp); }); await waitFor(() => screen.getByRole("textbox")); act(() => { setTextarea(screen.getByRole("textbox"), '{"key":"value"}'); }); await waitFor(() => expect(screen.getByRole("button", { name: /set policy/i })).not.toBeDisabled()); await user.click(screen.getByRole("button", { name: /set policy/i })); await waitFor(() => { expect(setMASPolicyData).toHaveBeenCalledWith({ key: "value" }); }); }); it("clears the input after a successful save", async () => { const user = userEvent.setup(); const dp = makeMockDataProvider({ getMASPolicyData: vi.fn().mockResolvedValue(null), setMASPolicyData: vi.fn().mockResolvedValue({ success: true }), }); await act(async () => { renderPage(dp); }); await waitFor(() => screen.getByRole("textbox")); act(() => { setTextarea(screen.getByRole("textbox"), '{"key":"value"}'); }); await waitFor(() => expect(screen.getByRole("button", { name: /set policy/i })).not.toBeDisabled()); await user.click(screen.getByRole("button", { name: /set policy/i })); await waitFor(() => { expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe(""); }); }); }); ================================================ FILE: src/pages/MASPolicyDataPage.tsx ================================================ import SaveIcon from "@mui/icons-material/Save"; import { Box, Button as MuiButton, Card, CardContent, Stack, Typography } from "@mui/material"; import MuiTextField from "@mui/material/TextField"; import { useCallback, useEffect, useState } from "react"; import { Loading, Title, useDataProvider, useLocale, useNotify, useTranslate } from "react-admin"; import { useDocTitle } from "../components/hooks/useDocTitle"; import { MASPolicyData, SynapseDataProvider } from "../providers/types"; import { DATE_FORMAT } from "../utils/date"; const MASPolicyDataPage = () => { const translate = useTranslate(); const notify = useNotify(); const locale = useLocale(); const dataProvider = useDataProvider() as SynapseDataProvider; const [policy, setPolicy] = useState<MASPolicyData | null | undefined>(undefined); const [newJson, setNewJson] = useState(""); const [jsonError, setJsonError] = useState<string | null>(null); const [saving, setSaving] = useState(false); useDocTitle(translate("resources.mas_policy_data.name")); const fetchPolicy = useCallback(async () => { const data = await dataProvider.getMASPolicyData(); setPolicy(data); }, [dataProvider]); useEffect(() => { fetchPolicy(); }, [fetchPolicy]); const handleJsonChange = (value: string) => { setNewJson(value); if (!value) { setJsonError(null); return; } try { JSON.parse(value); setJsonError(null); } catch { setJsonError(translate("resources.mas_policy_data.invalid_json")); } }; const isValidJson = newJson !== "" && jsonError === null; const handleSave = async () => { if (!isValidJson) return; setSaving(true); try { const parsed = JSON.parse(newJson); const result = await dataProvider.setMASPolicyData(parsed); if (result.success) { notify("resources.mas_policy_data.action.save.success"); setNewJson(""); setJsonError(null); await fetchPolicy(); } else { notify(result.error || "resources.mas_policy_data.action.save.failure", { type: "error" }); } } catch { notify("resources.mas_policy_data.action.save.failure", { type: "error" }); } finally { setSaving(false); } }; if (policy === undefined) return <Loading />; return ( <Box sx={{ p: 2 }}> <Title title={translate("resources.mas_policy_data.name")} /> <Card sx={{ mb: 3 }}> <CardContent> <Typography variant="h6" sx={{ mb: 1 }}> {translate("resources.mas_policy_data.current_policy")} </Typography> {policy ? ( <Stack spacing={1}> <Box component="pre" sx={{ m: 0, p: 2, bgcolor: "action.hover", borderRadius: 1, overflow: "auto", fontSize: "0.8rem", fontFamily: "monospace", wordBreak: "break-all", whiteSpace: "pre-wrap", }} > {JSON.stringify(policy.data, null, 2)} </Box> <Typography variant="body2" color="text.secondary"> {translate("resources.mas_policy_data.fields.created_at")}:{" "} {new Date(policy.created_at).toLocaleString(locale, DATE_FORMAT)} </Typography> </Stack> ) : ( <Typography variant="body2" color="text.secondary"> {translate("resources.mas_policy_data.no_policy")} </Typography> )} </CardContent> </Card> <Card> <CardContent> <Typography variant="h6" sx={{ mb: 2 }}> {translate("resources.mas_policy_data.set_policy")} </Typography> <Stack direction="column" spacing={2} alignItems="flex-start"> <MuiTextField label={translate("resources.mas_policy_data.fields.json_placeholder")} value={newJson} onChange={e => handleJsonChange(e.target.value)} fullWidth size="small" multiline minRows={4} error={!!jsonError} helperText={jsonError ?? " "} slotProps={{ input: { style: { fontFamily: "monospace" } } }} /> <MuiButton onClick={handleSave} disabled={saving || !isValidJson} variant="contained" startIcon={<SaveIcon />} sx={{ alignSelf: { xs: "stretch", sm: "flex-start" } }} > {translate("resources.mas_policy_data.action.save.label")} </MuiButton> </Stack> </CardContent> </Card> </Box> ); }; export default MASPolicyDataPage; ================================================ FILE: src/pages/auth-callback-error.test.tsx ================================================ import { act } from "react"; import { createRoot } from "react-dom/client"; import { waitFor } from "@testing-library/react"; vi.mock("../i18n", async () => { const polyglotI18nProvider = (await import("ra-i18n-polyglot")).default; const englishMessages = (await import("../i18n/en")).default; return { createI18nProvider: async () => polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]), }; }); vi.mock("../components/etke.cc/InstanceConfig", () => ({ useInstanceConfig: vi.fn(() => ({ name: "", logo_url: "", background_url: "", disabled: { attributions: false }, })), })); import { useInstanceConfig } from "../components/etke.cc/InstanceConfig"; import { renderAuthCallbackError } from "./auth-callback-error"; const mockUseInstanceConfig = vi.mocked(useInstanceConfig); describe("renderAuthCallbackError", () => { let container: HTMLDivElement; let root: ReturnType<typeof createRoot>; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); mockUseInstanceConfig.mockReturnValue({ name: "", logo_url: "", background_url: "", disabled: { attributions: false }, } as ReturnType<typeof useInstanceConfig>); }); afterEach(() => { act(() => { root.unmount(); }); container.remove(); }); it("renders the error message", async () => { const onBack = vi.fn(); await act(async () => { await renderAuthCallbackError(root, { message: "Something went wrong", onBack }); }); await waitFor(() => { expect(container.textContent).toContain("Something went wrong"); }); }); it("renders the Go Back button", async () => { const onBack = vi.fn(); await act(async () => { await renderAuthCallbackError(root, { message: "Oops", onBack }); }); await waitFor(() => { const button = container.querySelector("button"); expect(button).toBeTruthy(); }); }); it("calls onBack when the button is clicked", async () => { const onBack = vi.fn(); await act(async () => { await renderAuthCallbackError(root, { message: "Oops", onBack }); }); await waitFor(() => expect(container.querySelector("button")).toBeTruthy()); act(() => { container.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(onBack).toHaveBeenCalledTimes(1); }); it("shows the welcome text with default Ketesa branding", async () => { const onBack = vi.fn(); await act(async () => { await renderAuthCallbackError(root, { message: "Oops", onBack }); }); await waitFor(() => { expect(container.textContent).toContain("Ketesa"); }); }); it("shows custom instance name when configured", async () => { mockUseInstanceConfig.mockReturnValue({ name: "My Matrix Admin", logo_url: "", background_url: "", disabled: { attributions: false }, } as ReturnType<typeof useInstanceConfig>); const onBack = vi.fn(); await act(async () => { await renderAuthCallbackError(root, { message: "Oops", onBack }); }); await waitFor(() => { expect(container.textContent).toContain("My Matrix Admin"); }); }); }); ================================================ FILE: src/pages/auth-callback-error.tsx ================================================ import { Avatar, Box, Button, Card, CardActions, CssBaseline, Typography } from "@mui/material"; import { ThemeProvider, createTheme } from "@mui/material/styles"; import { I18nContextProvider } from "ra-core"; import { useTranslate } from "react-admin"; import { Root } from "react-dom/client"; import React from "react"; import { EtkeAttribution } from "../components/etke.cc/EtkeAttribution"; import { useInstanceConfig } from "../components/etke.cc/InstanceConfig"; import { createI18nProvider } from "../i18n"; import { Footer, LoginFormBox } from "../components/layout"; const AuthCallbackErrorView = ({ message, onBack }: { message: string; onBack: () => void }): React.ReactElement => { const icfg = useInstanceConfig(); const translate = useTranslate(); let welcomeTo = "Ketesa"; let logoUrl = "./images/logo.webp"; let footerLogoUrl = "./images/logo.webp"; let backgroundUrl = ""; if (icfg.name) { welcomeTo = icfg.name; } if (icfg.logo_url) { logoUrl = icfg.logo_url; footerLogoUrl = icfg.logo_url; } if (icfg.background_url) { backgroundUrl = icfg.background_url; } return ( <LoginFormBox backgroundUrl={backgroundUrl}> {!backgroundUrl && ( <> <div className="login-orb login-orb-1" /> <div className="login-orb login-orb-2" /> <div className="login-orb login-orb-3" /> </> )} <Card className="card"> <Box className="avatar"> <Avatar sx={{ width: { xs: "80px", sm: "120px" }, height: { xs: "80px", sm: "120px" } }} src={logoUrl} /> </Box> <Box className="hint">{translate("ketesa.auth.welcome", { name: welcomeTo })}</Box> <Box className="form"> <Typography variant="h6" sx={{ marginBottom: "0.5rem" }}> {translate("ra.page.authentication_error")} </Typography> <Typography variant="body2" sx={{ color: "text.secondary" }}> {message} </Typography> </Box> <CardActions className="actions"> <Button variant="contained" type="button" color="primary" onClick={onBack} fullWidth> {translate("ra.action.back")} </Button> </CardActions> </Card> <EtkeAttribution> <Footer logoSrc={footerLogoUrl} /> </EtkeAttribution> </LoginFormBox> ); }; export const renderAuthCallbackError = async ( root: Root, { message, onBack }: { message: string; onBack: () => void } ) => { const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)")?.matches ?? false; const theme = createTheme({ palette: { mode: prefersDark ? "dark" : "light", }, }); const i18nProvider = await createI18nProvider(); root.render( <ThemeProvider theme={theme}> <CssBaseline /> <I18nContextProvider value={i18nProvider}> <AuthCallbackErrorView message={message} onBack={onBack} /> </I18nContextProvider> </ThemeProvider> ); }; ================================================ FILE: src/pages/auth-callback.test.tsx ================================================ import { act } from "react"; import { waitFor } from "@testing-library/react"; describe("auth-callback entrypoint", () => { beforeEach(() => { vi.resetModules(); vi.doUnmock("../utils/config"); vi.doUnmock("../components/etke.cc/InstanceConfig"); vi.doUnmock("../providers/auth"); }); it("redirects to provided path on success", async () => { const { runAuthCallback } = await import("./auth-callback"); const handleCallback = vi.fn().mockResolvedValue({ redirectTo: "/server_status" }); const result = await runAuthCallback({ handleCallback }); expect(handleCallback).toHaveBeenCalledTimes(1); expect(result).toEqual({ redirectTo: "/server_status" }); }, 15000); it("shows error and does not redirect on failure", async () => { vi.doMock("../utils/config", async () => ({ __esModule: true, ...(await vi.importActual("../utils/config")), FetchConfig: vi.fn().mockResolvedValue(undefined), GetConfig: () => ({ etkeccAdmin: "" }), })); vi.doMock("../components/etke.cc/InstanceConfig", async () => ({ __esModule: true, ...(await vi.importActual("../components/etke.cc/InstanceConfig")), FetchInstanceConfig: vi.fn().mockResolvedValue(undefined), GetInstanceConfig: () => ({ name: "" }), useInstanceConfig: () => ({ name: "", disabled: { attributions: false } }), })); const { bootstrapAuthCallback } = await import("./auth-callback"); document.body.innerHTML = '<div id="root"></div>'; const rootElement = document.getElementById("root"); const location = { origin: "http://localhost", href: "http://localhost/auth-callback?code=abc" }; const handleCallback = vi.fn().mockRejectedValue(new Error("nope")); const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); await act(async () => { bootstrapAuthCallback(rootElement, location, { handleCallback }); await Promise.resolve(); await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0)); }); expect(location.href).toBe("http://localhost/auth-callback?code=abc"); await waitFor(() => expect(rootElement?.textContent).toContain("Authentication error")); expect(rootElement?.textContent).toContain("nope"); expect(rootElement?.textContent).toContain("Welcome to Ketesa"); expect(rootElement?.textContent).toContain("Go Back"); expect(consoleSpy).toHaveBeenCalled(); const button = rootElement?.querySelector("button"); expect(button).toBeTruthy(); button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(location.href).toBe("http://localhost/#/login"); }, 15000); it("preserves subpath when redirecting", async () => { const { bootstrapAuthCallback } = await import("./auth-callback"); const handleCallback = vi.fn().mockResolvedValue({ redirectTo: "/server_status" }); const location = { origin: "http://localhost", href: "http://localhost/admin/auth-callback?code=abc" }; document.body.innerHTML = '<div id="root"></div>'; const rootElement = document.getElementById("root"); await act(async () => { bootstrapAuthCallback(rootElement, location, { handleCallback }); await Promise.resolve(); await new Promise(resolve => setTimeout(resolve, 0)); }); expect(handleCallback).toHaveBeenCalledTimes(1); expect(location.href).toBe("http://localhost/admin/#/server_status"); }); it("loads config before handling callback", async () => { const fetchConfig = vi.fn().mockResolvedValue(undefined); const fetchInstanceConfig = vi.fn().mockResolvedValue(undefined); const getConfig = vi.fn().mockReturnValue({ etkeccAdmin: "https://admin.example" }); const handleCallback = vi.fn().mockResolvedValue({ redirectTo: "/" }); const callOrder: string[] = []; fetchConfig.mockImplementation(async () => { callOrder.push("fetchConfig"); }); fetchInstanceConfig.mockImplementation(async () => { callOrder.push("fetchInstanceConfig"); }); handleCallback.mockImplementation(async () => { callOrder.push("handleCallback"); return { redirectTo: "/" }; }); vi.doMock("../utils/config", async () => ({ __esModule: true, ...(await vi.importActual("../utils/config")), FetchConfig: fetchConfig, GetConfig: getConfig, })); vi.doMock("../components/etke.cc/InstanceConfig", async () => ({ __esModule: true, ...(await vi.importActual("../components/etke.cc/InstanceConfig")), FetchInstanceConfig: fetchInstanceConfig, GetInstanceConfig: () => ({ name: "" }), useInstanceConfig: () => ({ name: "", disabled: { attributions: false } }), })); vi.doMock("../providers/auth", () => ({ __esModule: true, default: { handleCallback }, })); const { bootstrapAuthCallback } = await import("./auth-callback"); document.body.innerHTML = '<div id="root"></div>'; const rootElement = document.getElementById("root"); const location = { origin: "http://localhost", href: "http://localhost/auth-callback?code=abc" }; await act(async () => { bootstrapAuthCallback(rootElement, location); await Promise.resolve(); await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0)); }); expect(fetchConfig).toHaveBeenCalledTimes(1); expect(fetchInstanceConfig).toHaveBeenCalledWith("https://admin.example", ""); expect(handleCallback).toHaveBeenCalledTimes(1); expect(callOrder.indexOf("fetchConfig")).toBeLessThan(callOrder.indexOf("handleCallback")); }); it.each([ ["http://localhost/auth-callback?code=abc", "http://localhost/#/server_status", "root path"], ["http://localhost/auth-callback/?code=abc", "http://localhost/#/server_status", "root path trailing slash"], ["http://localhost/admin/auth-callback?code=abc", "http://localhost/admin/#/server_status", "subpath"], [ "http://localhost/admin/ui/auth-callback/?code=abc", "http://localhost/admin/ui/#/server_status", "nested subpath trailing slash", ], [ "http://localhost/admin/auth-callback?code=abc#fragment", "http://localhost/admin/#/server_status", "hash fragment", ], [ "http://localhost/admin/auth-callback/index.html?code=abc", "http://localhost/admin/#/server_status", "explicit index.html", ], [ "http://localhost/admin/auth-callback/index.html/?code=abc", "http://localhost/admin/#/server_status", "explicit index.html trailing slash", ], [ "http://localhost:8080/admin/auth-callback?code=abc", "http://localhost:8080/admin/#/server_status", "custom port", ], ])("normalizes callback base (%s)", async (href, expectedHref) => { const { bootstrapAuthCallback } = await import("./auth-callback"); const handleCallback = vi.fn().mockResolvedValue({ redirectTo: "/server_status" }); const location = { origin: new URL(href).origin, href: href, }; document.body.innerHTML = '<div id="root"></div>'; const rootElement = document.getElementById("root"); await act(async () => { bootstrapAuthCallback(rootElement, location, { handleCallback }); await Promise.resolve(); await new Promise(resolve => setTimeout(resolve, 0)); }); expect(location.href).toBe(expectedHref); }); }); ================================================ FILE: src/pages/auth-callback.tsx ================================================ import { Loading } from "react-admin"; import { createRoot } from "react-dom/client"; import React from "react"; import authProvider from "../providers/auth"; import { FetchConfig, GetConfig } from "../utils/config"; import { FetchInstanceConfig, GetInstanceConfig } from "../components/etke.cc/InstanceConfig"; import createLogger from "../utils/logger"; const log = createLogger("auth-callback"); interface AuthProviderLike { handleCallback?: () => Promise<{ redirectTo?: string } | void>; } interface LocationLike { origin: string; href: string; } const resolveBasePath = (href: string) => { try { const url = new URL(href); let basePath = url.pathname.replace(/\/auth-callback(?:\/index\.html)?\/?$/, ""); if (basePath.endsWith("/")) { basePath = basePath.slice(0, -1); } return `${url.origin}${basePath}`; } catch { return ""; } }; const redirectToApp = (location: LocationLike, redirectTo: string) => { const base = resolveBasePath(location.href) || location.origin; const target = redirectTo.startsWith("/") ? redirectTo : `/${redirectTo}`; location.href = `${base}/#${target}`; }; const getErrorMessage = (error: unknown) => { if (error instanceof Error && error.message) { return error.message; } if (typeof error === "string") { return error; } return "Unexpected error during authentication."; }; export const runAuthCallback = async (provider: AuthProviderLike): Promise<{ redirectTo?: string } | void> => provider.handleCallback?.(); const ensureBaseTitle = () => { if (!document.head.dataset.baseTitle) { document.head.dataset.baseTitle = "Ketesa"; } if (!document.title) { document.title = "Ketesa"; } }; export const bootstrapAuthCallback = ( rootElement: HTMLElement | null = document.getElementById("root"), location: LocationLike = window.location, provider: AuthProviderLike = authProvider ): void => { ensureBaseTitle(); const root = rootElement ? createRoot(rootElement) : null; root?.render(<Loading loadingPrimary="" loadingSecondary="" />); // Fade out and remove the static loader overlay const loader = document.getElementById("loader"); if (loader) { loader.classList.add("fade-out"); loader.addEventListener("transitionend", () => loader.remove(), { once: true }); } void (async () => { await FetchConfig(); await FetchInstanceConfig(GetConfig().etkeccAdmin, ""); const icfg = GetInstanceConfig(); if (icfg.name) { document.head.dataset.baseTitle = icfg.name; if (!document.title.includes(icfg.name)) { document.title = icfg.name; } } return runAuthCallback(provider); })() .then(result => { redirectToApp(location, result?.redirectTo || "/"); }) .catch(async error => { const message = getErrorMessage(error); log.error("OAuth callback error", { href: location.href, message }); if (!root) { return; } const { renderAuthCallbackError } = await import("./auth-callback-error"); await renderAuthCallbackError(root, { message, onBack: () => redirectToApp(location, "/login") }); }); }; declare global { interface Window { __KETESA_AUTH_CALLBACK_ENTRY__?: boolean; } } if (typeof window !== "undefined" && window.__KETESA_AUTH_CALLBACK_ENTRY__) { bootstrapAuthCallback(); } ================================================ FILE: src/providers/README.md ================================================ # providers/ API integration layer. Provides data and authentication to the React-Admin app. ## Structure - `data/` — DataProvider: `index.ts` (routing/dispatch), `synapse.ts` (Synapse API), `mas.ts` (MAS API), `etke.ts` (ETKE.CC API) - `auth/` — AuthProvider: `index.ts` - `types/` — TypeScript types split by domain: `users.ts`, `rooms.ts`, `mas.ts`, `reports.ts`, `destinations.ts`, `etke.ts`, `common.ts`, `index.ts` - `http.ts` — HTTP client factory - `matrix.ts` — Matrix protocol utilities - `serverVersion.ts` — Server version detection ## Conventions - `types/index.ts` re-exports all types — import from `providers/types`, never from domain files directly - `data/index.ts` is the DataProvider export — import from `providers/data` - `auth/index.ts` is the AuthProvider export — import from `providers/auth` ================================================ FILE: src/providers/auth/index.test.ts ================================================ vi.mock("oidc-client-ts", () => { return { UserManager: vi.fn(function UserManager() { return { signinRedirectCallback: vi.fn().mockResolvedValue({ access_token: "oidc_access_token", refresh_token: "oidc_refresh_token", id_token: "oidc_id_token", expires_in: 3600, }), }; }), }; }); vi.mock("../data", () => ({ initResources: vi.fn(), })); vi.mock("../data/mas", async () => ({ ...(await vi.importActual("../data/mas")), detectAndSetMAS: vi.fn().mockResolvedValue(undefined), })); import { HttpError } from "ra-core"; import authProvider from "./index"; import { initResources } from "../data"; import { UserManager } from "oidc-client-ts"; describe("authProvider", () => { beforeEach(() => { vi.stubGlobal("fetch", vi.fn()); vi.clearAllMocks(); localStorage.clear(); }); describe("login", () => { it("should successfully login with username and password", async () => { vi.mocked(fetch).mockResolvedValueOnce( new Response( JSON.stringify({ home_server: "example.com", user_id: "@user:example.com", access_token: "foobar", device_id: "some_device", }) ) ); vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({}))); const ret = await authProvider.login({ base_url: "http://example.com", username: "@user:example.com", password: "secret", }); expect(ret).toEqual({ redirectTo: "/" }); expect(fetch).toHaveBeenCalledWith("http://example.com/_matrix/client/v3/login", { body: '{"device_id":null,"initial_device_display_name":"Ketesa","type":"m.login.password","identifier":{"type":"m.id.user","user":"@user:example.com"},"password":"secret"}', headers: new Headers({ Accept: "application/json", "Content-Type": "application/json", }), credentials: "same-origin", method: "POST", }); expect(localStorage.getItem("base_url")).toEqual("http://example.com"); expect(localStorage.getItem("user_id")).toEqual("@user:example.com"); expect(localStorage.getItem("access_token")).toEqual("foobar"); expect(localStorage.getItem("device_id")).toEqual("some_device"); expect(localStorage.getItem("home_server")).toEqual("example.com"); }); it("extracts home_server from user_id, ignoring the deprecated home_server field", async () => { vi.mocked(fetch).mockResolvedValueOnce( new Response( JSON.stringify({ home_server: "deprecated.should-be-ignored.example.com", user_id: "@admin:actual.example.com", access_token: "tok", device_id: "dev", }) ) ); vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({}))); await authProvider.login({ base_url: "http://actual.example.com", username: "@admin:actual.example.com", password: "pass", }); expect(localStorage.getItem("home_server")).toEqual("actual.example.com"); }); it("throws when user_id is missing from the login response", async () => { vi.mocked(fetch).mockResolvedValueOnce( new Response( JSON.stringify({ access_token: "tok", device_id: "dev", }) ) ); await expect( authProvider.login({ base_url: "http://example.com", username: "@admin:example.com", password: "pass", }) ).rejects.toBeDefined(); }); it("extracts home_server with port from user_id", async () => { vi.mocked(fetch).mockResolvedValueOnce( new Response( JSON.stringify({ user_id: "@admin:example.com:8008", access_token: "tok", device_id: "dev", }) ) ); vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({}))); await authProvider.login({ base_url: "http://example.com:8008", username: "@admin:example.com:8008", password: "pass", }); expect(localStorage.getItem("home_server")).toEqual("example.com:8008"); }); }); it("should successfully login with token", async () => { vi.mocked(fetch).mockResolvedValueOnce( new Response( JSON.stringify({ home_server: "example.com", user_id: "@user:example.com", access_token: "foobar", device_id: "some_device", }) ) ); vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({}))); const ret = await authProvider.login({ base_url: "https://example.com/", loginToken: "login_token", }); expect(ret).toEqual({ redirectTo: "/" }); expect(fetch).toHaveBeenCalledWith("https://example.com/_matrix/client/v3/login", { body: '{"device_id":null,"initial_device_display_name":"Ketesa","type":"m.login.token","token":"login_token"}', headers: new Headers({ Accept: "application/json", "Content-Type": "application/json", }), credentials: "same-origin", method: "POST", }); expect(localStorage.getItem("base_url")).toEqual("https://example.com"); expect(localStorage.getItem("user_id")).toEqual("@user:example.com"); expect(localStorage.getItem("access_token")).toEqual("foobar"); expect(localStorage.getItem("device_id")).toEqual("some_device"); }); it("handles OIDC callback via oidc-client-ts", async () => { localStorage.setItem("clientId", "client_id"); localStorage.setItem("oidc_issuer", "https://issuer.example"); localStorage.setItem("oidc_scope", "openid profile"); localStorage.setItem("oidc_redirect_uri", "http://localhost:5173/auth-callback/"); localStorage.setItem("decoded_base_url", "http://example.com"); vi.mocked(fetch).mockResolvedValueOnce( new Response( JSON.stringify({ user_id: "@user:example.com", device_id: "DEVICE", }) ) ); vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({}))); const result = await authProvider.handleCallback?.(); expect(UserManager).toHaveBeenCalledWith({ authority: "https://issuer.example", client_id: "client_id", redirect_uri: "http://localhost:5173/auth-callback/", response_type: "code", scope: "openid profile", }); const userManagerInstance = vi.mocked(UserManager).mock.results[0].value; expect(userManagerInstance.signinRedirectCallback).toHaveBeenCalledTimes(1); expect(localStorage.getItem("access_token")).toBe("oidc_access_token"); expect(localStorage.getItem("refresh_token")).toBe("oidc_refresh_token"); expect(localStorage.getItem("id_token")).toBe("oidc_id_token"); expect(localStorage.getItem("login_type")).toBe("credentials"); expect(initResources).toHaveBeenCalledTimes(1); expect(result).toEqual({ redirectTo: "/" }); }); describe("logout", () => { it("should remove the access_token from storage", async () => { localStorage.setItem("base_url", "example.com"); localStorage.setItem("access_token", "foo"); vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({}))); await authProvider.logout(null); expect(fetch).toHaveBeenCalledWith("example.com/_matrix/client/v3/logout", { headers: new Headers({ Accept: "application/json", "Content-Type": "application/json", Authorization: "Bearer foo", }), method: "POST", credentials: "same-origin", user: { authenticated: true, token: "Bearer foo" }, }); expect(localStorage.getItem("access_token")).toBeNull(); }); }); describe("checkError", () => { it("should resolve if error.status is not 401", async () => { await expect(authProvider.checkError({ status: 200 })).resolves.toBeUndefined(); }); it("should reject if error.status is 401", async () => { await expect( authProvider.checkError(new HttpError("test-error", 401, { errcode: "test-errcode", error: "test-error" })) ).rejects.toBeDefined(); }); }); describe("checkAuth", () => { it("should reject when not logged in", async () => { await expect(authProvider.checkAuth({})).rejects.toBeUndefined(); }); it("should resolve when logged in", async () => { localStorage.setItem("access_token", "foobar"); await expect(authProvider.checkAuth({})).resolves.toBeUndefined(); }); }); describe("getPermissions", () => { it("should do nothing", async () => { if (authProvider.getPermissions) { await expect(authProvider.getPermissions(null)).resolves.toBeUndefined(); } }); }); }); ================================================ FILE: src/providers/auth/index.ts ================================================ import { UserManager } from "oidc-client-ts"; import { AuthProvider, HttpError, Options, fetchUtils } from "react-admin"; import createLogger from "../../utils/logger"; const log = createLogger("auth"); import { AuthMetadata, handleOIDCAuth, refreshAccessToken } from "../matrix"; import { detectAndSetMAS } from "../data/mas"; import { initResources } from "../data"; import { fetchServerVersions, clearServerVersions } from "../serverVersion"; import { FetchInstanceConfig, GetInstanceConfig } from "../../components/etke.cc/InstanceConfig"; import { ClearConfig, FetchWellKnownConfig, GetConfig, SetExternalAuthProvider } from "../../utils/config"; import { decodeURLComponent } from "../../utils/safety"; import { MatrixError, displayError } from "../../utils/error"; import { fetchAuthenticatedMedia } from "../../utils/fetchMedia"; const authProvider: AuthProvider = { // called when the user attempts to log in login: async ({ base_url, username, password, loginToken, accessToken, clientUrl, authMetadata, }: { base_url: string; username: string; password: string; loginToken: string; accessToken: string; clientUrl: string; authMetadata: AuthMetadata; }) => { // use the base_url from login instead of the well_known entry from the // server, since the admin might want to access the admin API via some // private address if (!base_url) { // there is some kind of bug with base_url being present in the form, but not submitted // ref: https://github.com/etkecc/ketesa/issues/14 localStorage.removeItem("base_url"); throw new Error("Homeserver URL is required."); } base_url = base_url.replace(/\/+$/g, ""); localStorage.setItem("base_url", base_url); log.info("login", { base_url, method: clientUrl && authMetadata ? "oidc" : loginToken ? "token" : accessToken ? "access_token" : "password", }); const decoded_base_url = decodeURLComponent(base_url); localStorage.setItem("decoded_base_url", decoded_base_url); if (clientUrl && authMetadata) { // this is a OIDC login const authParams = await handleOIDCAuth(authMetadata, clientUrl); const userManager = new UserManager({ authority: authParams.issuer, client_id: authParams.clientId, redirect_uri: authParams.redirectUri, response_type: authParams.responseType, scope: authParams.scope, }); await userManager.signinRedirect(); return; } const config = GetConfig(); const icfg = GetInstanceConfig(); let deviceName = "Ketesa"; if (icfg.name) { deviceName = icfg.name; } let options: Options = { method: "POST", credentials: config.corsCredentials as RequestCredentials, headers: new Headers({ Accept: "application/json", "Content-Type": "application/json", }), body: JSON.stringify( Object.assign( { device_id: localStorage.getItem("device_id"), initial_device_display_name: deviceName, }, loginToken ? { type: "m.login.token", token: loginToken, } : { type: "m.login.password", identifier: { type: "m.id.user", user: username, }, password: password, } ) ), }; const login_api_url = decoded_base_url + (accessToken ? "/_matrix/client/v3/account/whoami" : "/_matrix/client/v3/login"); let response; try { if (accessToken) { // this a login with an already obtained access token, let's just validate it options = { headers: new Headers({ Accept: "application/json", Authorization: `Bearer ${accessToken}`, }), }; } response = await fetchUtils.fetchJson(login_api_url, options); const json = response.json; // just split(":")[1] is not enough, because there are homeservers with ports or IPv6 addresses, // like "@user:example.com:8008" or "@user:[2001:db8::1]" // home_server is deprecated in the login response (Matrix spec), so always extract from user_id const mxidParts = json.user_id?.split(":"); mxidParts?.shift(); const homeServer = mxidParts?.join(":"); if (!homeServer) { throw new Error(`Cannot determine home_server from user_id: ${json.user_id}`); } localStorage.setItem("home_server", homeServer); localStorage.setItem("user_id", json.user_id); localStorage.setItem("access_token", accessToken ? accessToken : json.access_token); localStorage.setItem("device_id", json.device_id); localStorage.setItem("login_type", accessToken ? "accessToken" : "credentials"); let pageToRedirectTo = "/"; await FetchWellKnownConfig(); const cfg = GetConfig(); if (cfg.etkeccAdmin) { await FetchInstanceConfig(cfg.etkeccAdmin, ""); } const updatedIcfg = GetInstanceConfig(); if (cfg.etkeccAdmin && updatedIcfg && !updatedIcfg.disabled.monitoring) { pageToRedirectTo = "/server_status"; } await detectAndSetMAS(); initResources(); fetchServerVersions(); return Promise.resolve({ redirectTo: pageToRedirectTo }); } catch (err) { const error = err as HttpError; const errorStatus = error.status; const errorBody = error.body as MatrixError; const errMsg = errorBody?.errcode ? displayError(errorBody.errcode, errorStatus, errorBody.error) : displayError("M_INVALID", errorStatus, error.message); return Promise.reject(new HttpError(errMsg, errorStatus)); } }, getIdentity: async () => { const access_token = localStorage.getItem("access_token"); const user_id = localStorage.getItem("user_id"); const base_url = localStorage.getItem("base_url"); if (typeof access_token !== "string" || typeof user_id !== "string" || typeof base_url !== "string") { return Promise.reject(); } const options: Options = { headers: new Headers({ Accept: "application/json", Authorization: `Bearer ${access_token}`, }), }; const whoami_api_url = base_url + `/_matrix/client/v3/profile/${user_id}`; try { let avatar_url = ""; const response = await fetchUtils.fetchJson(whoami_api_url, options); if (response.json.avatar_url) { const mediaresp = await fetchAuthenticatedMedia(response.json.avatar_url, "thumbnail"); const blob = await mediaresp.blob(); avatar_url = URL.createObjectURL(blob); } return Promise.resolve({ id: user_id, fullName: response.json.displayname, avatar: avatar_url, }); } catch (err) { log.error("getIdentity failed", err); return Promise.reject(); } }, handleCallback: async () => { log.debug("handleCallback start"); const clientId = localStorage.getItem("clientId"); const issuer = localStorage.getItem("oidc_issuer"); const scope = localStorage.getItem("oidc_scope") || "openid"; const redirectUri = localStorage.getItem("oidc_redirect_uri") || `${window.location.origin}/auth-callback/`; if (!clientId || !issuer) { log.error("handleCallback: missing OIDC config in storage", { hasClientId: !!clientId, hasIssuer: !!issuer }); return Promise.reject(new Error("Missing OAuth configuration")); } const userManager = new UserManager({ authority: issuer, client_id: clientId, redirect_uri: redirectUri, response_type: "code", scope, }); const user = await userManager.signinRedirectCallback(window.location.href); // Save tokens to localStorage const { access_token, refresh_token, id_token, expires_in } = user; if (!access_token) { throw new Error("Missing access token in callback response"); } localStorage.setItem("access_token", access_token); if (refresh_token) { SetExternalAuthProvider(true); // refresh token is only present for external auth providers localStorage.setItem("refresh_token", refresh_token); } if (id_token) { localStorage.setItem("id_token", id_token); } // Save token expiration time if (expires_in) { const expiresAt = Date.now() + expires_in * 1000; localStorage.setItem("access_token_expires_at", expiresAt.toString()); } const decoded_base_url = localStorage.getItem("decoded_base_url") || ""; if (!decoded_base_url) { log.error("handleCallback: no base_url in storage"); throw new Error("Base URL not found"); } // Get user_id from whoami endpoint const whoamiUrl = `${decoded_base_url}/_matrix/client/v3/account/whoami`; try { const whoamiResponse = await fetchUtils.fetchJson(whoamiUrl, { headers: new Headers({ Accept: "application/json", Authorization: `Bearer ${access_token}`, }), }); const json = whoamiResponse.json; const userId = json.user_id; const deviceId = json.device_id; if (!userId) { throw new Error("Missing user_id in whoami response"); } localStorage.setItem("user_id", userId); if (deviceId) { localStorage.setItem("device_id", deviceId); } // just split(":")[1] is not enough, because there are homeservers with ports or IPv6 addresses, // like "@user:example.com:8008" or "@user:[2001:db8::1]" const mxidParts = userId.split(":"); mxidParts.shift(); localStorage.setItem("home_server", mxidParts.join(":")); localStorage.setItem("access_token", access_token); localStorage.setItem("login_type", "credentials"); // OIDC login is basically credentials login, just via external provider await FetchWellKnownConfig(); const cfg = GetConfig(); if (cfg.etkeccAdmin) { await FetchInstanceConfig(cfg.etkeccAdmin, ""); } const icfg = GetInstanceConfig(); let pageToRedirectTo = "/"; if (cfg.etkeccAdmin && icfg && !icfg.disabled.monitoring) { pageToRedirectTo = "/server_status"; } log.info("authenticated via OIDC", { userId }); await detectAndSetMAS(); initResources(); fetchServerVersions(); return Promise.resolve({ redirectTo: pageToRedirectTo }); } catch (err) { log.error("handleCallback: failed to get user info", err); ClearConfig(); throw err; } }, // called when the user clicks on the logout button logout: async () => { log.info("logout"); const logout_api_url = localStorage.getItem("base_url") + "/_matrix/client/v3/logout"; const access_token = localStorage.getItem("access_token"); const options: Options = { method: "POST", credentials: GetConfig().corsCredentials as RequestCredentials, headers: new Headers({ Accept: "application/json", "Content-Type": "application/json", }), user: { authenticated: true, token: `Bearer ${access_token}`, }, }; if (typeof access_token === "string") { try { await fetchUtils.fetchJson(logout_api_url, options); } catch (err) { log.warn("logout: server call failed (session cleared anyway)", err); } finally { clearServerVersions(); ClearConfig(); } } }, // called when the API returns an error checkError: (err: HttpError) => { const errorBody = err.body as MatrixError; const status = err.status; if (status === 401) { return Promise.reject({ message: displayError(errorBody.errcode, status, errorBody.error) }); } return Promise.resolve(); }, // called when the user navigates to a new location, to check for authentication checkAuth: async () => { const access_token = localStorage.getItem("access_token"); if (typeof access_token !== "string") { return Promise.reject(); } // Ensure server versions are fetched (handles page reload) fetchServerVersions(); // Check if token has expired const expiresAt = localStorage.getItem("access_token_expires_at"); if (expiresAt) { SetExternalAuthProvider(true); // presence of expiration time indicates external auth provider const expirationTime = parseInt(expiresAt, 10); const now = Date.now(); if (now >= expirationTime) { log.debug("checkAuth: token expired, refreshing"); // Attempt to refresh the token const refreshSuccess = await refreshAccessToken(); if (refreshSuccess) { log.debug("checkAuth: token refreshed"); return Promise.resolve(); } else { log.warn("checkAuth: token refresh failed, redirecting to login"); return Promise.reject(); } } } return Promise.resolve(); }, }; export default authProvider; ================================================ FILE: src/providers/data/etke.test.ts ================================================ import { etkeProviderMethods } from "./etke"; beforeEach(() => { vi.stubGlobal("fetch", vi.fn()); vi.clearAllMocks(); localStorage.clear(); localStorage.setItem("access_token", "test_token"); }); describe("getServerNotifications", () => { const url = "https://admin.example/etke"; it("maps X-Notifications-Advisory: none to status ok", async () => { vi.mocked(fetch).mockResolvedValueOnce( new Response(JSON.stringify([{ event_id: "$a", output: "body", sent_at: "2026-04-22" }]), { status: 200, headers: { "X-Notifications-Advisory": "none" }, }) ); const result = await etkeProviderMethods.getServerNotifications(url, "en"); expect(result.status).toBe("ok"); expect(result.success).toBe(true); expect(result.notifications).toHaveLength(1); }); it("maps X-Notifications-Advisory: possibly_missed to status advisory", async () => { vi.mocked(fetch).mockResolvedValueOnce( new Response(JSON.stringify([]), { status: 200, headers: { "X-Notifications-Advisory": "possibly_missed" }, }) ); const result = await etkeProviderMethods.getServerNotifications(url, "en"); expect(result.status).toBe("advisory"); expect(result.success).toBe(true); }); it("defaults to status ok when advisory header is absent", async () => { vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })); const result = await etkeProviderMethods.getServerNotifications(url, "en"); expect(result.status).toBe("ok"); expect(result.success).toBe(true); }); it("returns ok + empty list on 204 No Content", async () => { vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 204 })); const result = await etkeProviderMethods.getServerNotifications(url, "en"); expect(result.status).toBe("ok"); expect(result.success).toBe(true); expect(result.notifications).toEqual([]); }); it("returns unavailable on 503", async () => { vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 503 })); const result = await etkeProviderMethods.getServerNotifications(url, "en"); expect(result.status).toBe("unavailable"); expect(result.success).toBe(false); expect(result.notifications).toEqual([]); }); it("returns unavailable on non-ok HTTP (e.g. 500)", async () => { vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 500, statusText: "Server Error" })); const result = await etkeProviderMethods.getServerNotifications(url, "en"); expect(result.status).toBe("unavailable"); expect(result.success).toBe(false); }); it("returns unavailable when fetch rejects (network error)", async () => { vi.mocked(fetch).mockRejectedValueOnce(new Error("network down")); const result = await etkeProviderMethods.getServerNotifications(url, "en"); expect(result.status).toBe("unavailable"); expect(result.success).toBe(false); }); }); ================================================ FILE: src/providers/data/etke.ts ================================================ import { etkeClient } from "../http"; import createLogger from "../../utils/logger"; const log = createLogger("data"); import type { ComponentsResponse, NotificationsStatus, PaymentsResponse, RecurringCommand, ScheduledCommand, ServerNotification, ServerNotificationsResponse, ServerProcessResponse, ServerStatusResponse, SupportAttachment, SupportMessage, SupportRequest, SupportRequestDetail, } from "../types"; export const etkeProviderMethods = { getServerRunningProcess: async ( etkeAdminUrl: string, locale: string, burstCache = false ): Promise<ServerProcessResponse> => { const locked_at = ""; const command = ""; let serverURL = `${etkeAdminUrl}/lock`; if (burstCache) { serverURL += `?time=${new Date().getTime()}`; } try { const response = await etkeClient(serverURL, locale); if (response.status === 503) { return { locked_at, command, maintenance: true }; } if (!response.ok) { log.error(`getServerRunningProcess: HTTP ${response.status} ${response.statusText}`, { url: serverURL }); return { locked_at, command, maintenance: false }; } const status = response.status; if (status === 200) { const json = await response.json(); return json as { locked_at: string; command: string }; } if (status === 204) { return { locked_at, command, maintenance: false }; } } catch (error) { log.error("getServerRunningProcess failed", error); } return { locked_at, command, maintenance: false }; }, getServerStatus: async (etkeAdminUrl: string, locale: string, burstCache = false): Promise<ServerStatusResponse> => { let serverURL = `${etkeAdminUrl}/status`; if (burstCache) { serverURL += `?time=${new Date().getTime()}`; } try { const response = await etkeClient(serverURL, locale); if (response.status === 503) { return { success: false, ok: false, host: "", results: [], maintenance: true }; } if (!response.ok) { log.error(`getServerStatus: HTTP ${response.status} ${response.statusText}`, { url: serverURL }); return { success: false, ok: false, host: "", results: [] }; } const status = response.status; if (status === 200) { const json = await response.json(); return { success: true, ...json } as ServerStatusResponse; } } catch (error) { log.error("getServerStatus failed", error); } return { success: false, ok: false, host: "", results: [] }; }, getServerNotifications: async ( etkeAdminUrl: string, locale: string, burstCache = false ): Promise<ServerNotificationsResponse> => { let serverURL = `${etkeAdminUrl}/notifications`; if (burstCache) { serverURL += `?time=${new Date().getTime()}`; } try { const response = await etkeClient(serverURL, locale); if (response.status === 503) { return { success: false, status: "unavailable", notifications: [] }; } if (!response.ok) { log.error(`getServerNotifications: HTTP ${response.status} ${response.statusText}`, { url: serverURL }); return { success: false, status: "unavailable", notifications: [] }; } const advisory = response.headers.get("X-Notifications-Advisory"); const status: NotificationsStatus = advisory === "possibly_missed" ? "advisory" : "ok"; if (response.status === 204) { return { success: true, status, notifications: [] }; } if (response.status === 200) { const json = await response.json(); return { success: true, status, notifications: json as ServerNotification[] }; } return { success: true, status, notifications: [] }; } catch (error) { log.error("getServerNotifications failed", error); } return { success: false, status: "unavailable", notifications: [] }; }, deleteServerNotifications: async (etkeAdminUrl: string, locale: string) => { try { const response = await etkeClient(`${etkeAdminUrl}/notifications`, locale, { method: "DELETE", }); if (!response.ok) { log.error(`deleteServerNotifications: HTTP ${response.status} ${response.statusText}`); return { success: false }; } const status = response.status; if (status === 204) { return { success: true }; } } catch (error) { log.error("deleteServerNotifications failed", error); } return { success: false }; }, getUnits: async (etkeAdminUrl: string, locale: string): Promise<string[]> => { try { const response = await etkeClient(`${etkeAdminUrl}/units`, locale); if (!response.ok) { log.error(`getUnits: HTTP ${response.status} ${response.statusText}`); return []; } if (response.status === 204) { return []; } if (response.status === 200) { const json = await response.json(); return json as string[]; } } catch (error) { log.error("getUnits failed", error); } return []; }, getServerCommands: async (etkeAdminUrl: string, locale: string) => { try { const response = await etkeClient(`${etkeAdminUrl}/commands`, locale); if (response.status === 503) { return { maintenance: true, commands: [] }; } if (!response.ok) { log.error(`getServerCommands: HTTP ${response.status} ${response.statusText}`); return { maintenance: false, commands: [] }; } const status = response.status; if (status === 200) { const json = await response.json(); return { maintenance: false, commands: json }; } return { maintenance: false, commands: [] }; } catch (error) { log.error("getServerCommands failed", error); } return { maintenance: false, commands: [] }; }, /* eslint-disable @typescript-eslint/no-explicit-any */ runServerCommand: async (serverCommandsUrl: string, command: string, additionalArgs: Record<string, any> = {}) => { const endpoint_url = `${serverCommandsUrl}/commands`; const body = { command: command, ...additionalArgs, }; const response = await fetch(endpoint_url, { method: "POST", body: JSON.stringify(body), headers: { "Content-Type": "application/json", Authorization: `Bearer ${localStorage.getItem("access_token")}`, }, }); if (response.status === 503) { return { success: false, maintenance: true }; } if (!response.ok) { log.error(`runServerCommand: HTTP ${response.status} ${response.statusText}`, { command }); return { success: false, maintenance: false }; } if (response.status === 204) { log.info("server command executed", { command }); return { success: true, maintenance: false }; } return { success: false, maintenance: false }; }, getScheduledCommands: async (etkeAdminUrl: string, locale: string) => { try { const response = await etkeClient(`${etkeAdminUrl}/schedules`, locale); if (response.status === 503) { return []; } if (!response.ok) { log.error(`getScheduledCommands: HTTP ${response.status} ${response.statusText}`); return []; } const status = response.status; if (status === 200) { const json = await response.json(); return json as ScheduledCommand[]; } return []; } catch (error) { log.error("getScheduledCommands failed", error); } return []; }, getRecurringCommands: async (etkeAdminUrl: string, locale: string) => { try { const response = await etkeClient(`${etkeAdminUrl}/recurrings`, locale); if (response.status === 503) { return []; } if (!response.ok) { log.error(`getRecurringCommands: HTTP ${response.status} ${response.statusText}`); return []; } const status = response.status; if (status === 200) { const json = await response.json(); return json as RecurringCommand[]; } return []; } catch (error) { log.error("getRecurringCommands failed", error); } return []; }, createScheduledCommand: async (etkeAdminUrl: string, locale: string, command: Partial<ScheduledCommand>) => { try { const response = await etkeClient(`${etkeAdminUrl}/schedules`, locale, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(command), }); if (!response.ok) { log.error(`createScheduledCommand: HTTP ${response.status} ${response.statusText}`, { command }); throw new Error("Failed to create scheduled command"); } if (response.status === 204) { return command as ScheduledCommand; } const json = await response.json(); return json as ScheduledCommand; } catch (error) { log.error("createScheduledCommand failed", error); throw error; } }, updateScheduledCommand: async (etkeAdminUrl: string, locale: string, command: ScheduledCommand) => { try { // Use the base endpoint without ID and use PUT for upsert const response = await etkeClient(`${etkeAdminUrl}/schedules`, locale, { method: "PUT", // Using PUT on the base endpoint headers: { "Content-Type": "application/json", }, body: JSON.stringify(command), }); if (!response.ok) { const jsonErr = JSON.parse(await response.text()); log.error(`updateScheduledCommand: HTTP ${response.status} ${response.statusText}`, { command }); throw new Error(jsonErr.error); } // According to docs, successful response is 204 No Content if (response.status === 204) { // Return the command object we sent since the server doesn't return data return command; } // If server does return data (though docs suggest it returns 204) const json = await response.json(); return json as ScheduledCommand; } catch (error) { log.error("updateScheduledCommand failed", error); throw error; } }, deleteScheduledCommand: async (etkeAdminUrl: string, locale: string, id: string) => { try { const response = await etkeClient(`${etkeAdminUrl}/schedules/${id}`, locale, { method: "DELETE", }); if (!response.ok) { log.error(`deleteScheduledCommand: HTTP ${response.status} ${response.statusText}`, { id }); return { success: false }; } return { success: true }; } catch (error) { log.error("deleteScheduledCommand failed", { id, error }); return { success: false }; } }, createRecurringCommand: async (etkeAdminUrl: string, locale: string, command: Partial<RecurringCommand>) => { try { const response = await etkeClient(`${etkeAdminUrl}/recurrings`, locale, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(command), }); if (!response.ok) { log.error(`createRecurringCommand: HTTP ${response.status} ${response.statusText}`, { command }); throw new Error("Failed to create recurring command"); } if (response.status === 204) { // Return the command object we sent since the server doesn't return data return command as RecurringCommand; } const json = await response.json(); return json as RecurringCommand; } catch (error) { log.error("createRecurringCommand failed", error); throw error; } }, updateRecurringCommand: async (etkeAdminUrl: string, locale: string, command: RecurringCommand) => { try { const response = await etkeClient(`${etkeAdminUrl}/recurrings`, locale, { method: "PUT", headers: { "Content-Type": "application/json", }, body: JSON.stringify(command), }); if (!response.ok) { log.error(`updateRecurringCommand: HTTP ${response.status} ${response.statusText}`, { command }); throw new Error("Failed to update recurring command"); } if (response.status === 204) { // Return the command object we sent since the server doesn't return data return command as RecurringCommand; } const json = await response.json(); return json as RecurringCommand; } catch (error) { log.error("updateRecurringCommand failed", error); throw error; } }, deleteRecurringCommand: async (etkeAdminUrl: string, locale: string, id: string) => { try { const response = await etkeClient(`${etkeAdminUrl}/recurrings/${id}`, locale, { method: "DELETE", }); if (!response.ok) { log.error(`deleteRecurringCommand: HTTP ${response.status} ${response.statusText}`, { id }); return { success: false }; } return { success: true }; } catch (error) { log.error("deleteRecurringCommand failed", { id, error }); return { success: false }; } }, getComponents: async (etkeAdminUrl: string, locale: string) => { try { const response = await etkeClient(`${etkeAdminUrl}/components`, locale); if (response.status === 503) { return { components: [], sections: [], currency: "EUR", total_price: 0 } as ComponentsResponse; } if (!response.ok) { log.error(`getComponents: HTTP ${response.status} ${response.statusText}`); return { components: [], sections: [], currency: "EUR", total_price: 0 } as ComponentsResponse; } if (response.status === 200) { const json = await response.json(); return json as ComponentsResponse; } } catch (error) { log.error("getComponents failed", error); } return { components: [], sections: [], currency: "EUR", total_price: 0 } as ComponentsResponse; }, getPayments: async (etkeAdminUrl: string, locale: string) => { const response = await etkeClient(`${etkeAdminUrl}/payments`, locale); if (response.status === 503) { return { payments: [], total: 0, maintenance: true }; } if (!response.ok) { throw new Error(`Failed to fetch payments: ${response.status} ${response.statusText}`); } const status = response.status; if (status === 200) { const json = await response.json(); return json as PaymentsResponse; } if (status === 204) { return { payments: [], total: 0, maintenance: false }; } throw new Error(`${response.status} ${response.statusText}`); // Handle unexpected status codes }, getInvoice: async (etkeAdminUrl: string, locale: string, transactionId: string) => { try { const response = await etkeClient(`${etkeAdminUrl}/payments/${transactionId}/invoice`, locale); if (!response.ok) { let errorMessage = `Error fetching invoice: ${response.status} ${response.statusText}`; // Handle specific error codes switch (response.status) { case 404: errorMessage = "Invoice not found for this transaction"; break; case 500: errorMessage = "Server error while generating invoice. Please try again later"; break; case 401: errorMessage = "Unauthorized access. Please check your permissions"; break; case 403: errorMessage = "Access forbidden. You don't have permission to download this invoice"; break; default: errorMessage = `Failed to fetch invoice (${response.status}): ${response.statusText}`; } log.error("getInvoice failed", { transactionId, status: response.status, message: errorMessage }); throw new Error(errorMessage); } // Get the file as a blob const blob = await response.blob(); // Create a download link const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; // Try to get filename from response headers const contentDisposition = response.headers.get("Content-Disposition"); let filename = `invoice_${transactionId}.pdf`; if (contentDisposition) { const filenameMatch = contentDisposition.match(/filename="(.+)"/); if (filenameMatch) { filename = filenameMatch[1]; } } link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); } catch (error) { log.error("getInvoice download failed", { transactionId, error }); throw error; // Re-throw to let the UI handle the error } }, getSupportRequests: async (etkeAdminUrl: string, locale: string) => { const response = await etkeClient(`${etkeAdminUrl}/support`, locale); if (response.status === 204) { return []; } if (!response.ok) { throw new Error(`Failed to fetch support requests: ${response.status} ${response.statusText}`); } const json = await response.json(); return (json.requests ?? json) as SupportRequest[]; }, getSupportRequest: async (etkeAdminUrl: string, locale: string, id: string, burstCache = false) => { let url = `${etkeAdminUrl}/support/${id}`; if (burstCache) url += `?time=${new Date().getTime()}`; const response = await etkeClient(url, locale); if (!response.ok) { throw new Error(`Failed to fetch support request: ${response.status} ${response.statusText}`); } const json = await response.json(); return json as SupportRequestDetail; }, createSupportRequest: async ( etkeAdminUrl: string, locale: string, subject: string, message: string, attachments?: SupportAttachment[] ) => { const response = await etkeClient(`${etkeAdminUrl}/support`, locale, { method: "POST", body: JSON.stringify({ subject, message, ...(attachments?.length ? { attachments } : {}) }), }); if (!response.ok) { let errMsg = "etkecc.support.actions.create_failure"; try { const body = await response.json(); if (body?.error) errMsg = body.error; } catch { /* ignore */ } throw new Error(errMsg); } const json = await response.json(); return json as SupportRequest; }, postSupportMessage: async ( etkeAdminUrl: string, locale: string, id: string, message: string, attachments?: SupportAttachment[], close?: boolean ) => { const response = await etkeClient(`${etkeAdminUrl}/support/${id}`, locale, { method: "POST", body: JSON.stringify({ message, ...(close ? { close } : {}), ...(attachments?.length ? { attachments } : {}) }), }); if (!response.ok) { let errMsg = "etkecc.support.actions.send_failure"; try { const body = await response.json(); if (body?.error) errMsg = body.error; } catch { /* ignore */ } throw new Error(errMsg); } if (response.status === 204 || response.headers.get("content-length") === "0") { return {} as SupportMessage; } const json = await response.json(); return json as SupportMessage; }, }; ================================================ FILE: src/providers/data/index.test.ts ================================================ vi.mock("../matrix", async () => ({ ...(await vi.importActual("../matrix")), refreshAccessToken: vi.fn().mockResolvedValue(undefined), })); import dataProvider from "./index"; import { clearSystemUsersScanCache, clearReverseSearchScanCache } from "./index"; import { LoadConfig } from "../../utils/config"; beforeEach(() => { vi.stubGlobal("fetch", vi.fn()); vi.clearAllMocks(); localStorage.clear(); localStorage.setItem("base_url", "http://localhost"); localStorage.setItem("access_token", "access_token"); clearSystemUsersScanCache(); clearReverseSearchScanCache(); LoadConfig({ restrictBaseUrl: "", corsCredentials: "same-origin", asManagedUsers: [], menu: [], etkeccAdmin: "", }); }); describe("dataProvider", () => { it("fetches all users", async () => { vi.mocked(fetch).mockResolvedValueOnce( new Response( JSON.stringify({ users: [ { name: "@user_id1:provider", password_hash: "password_hash1", is_guest: 0, admin: 0, user_type: null, deactivated: 0, displayname: "User One", }, { name: "@user_id2:provider", password_hash: "password_hash2", is_guest: 0, admin: 1, user_type: null, deactivated: 0, displayname: "User Two", }, ], next_token: "100", total: 200, }) ) ); const users = await dataProvider.getList("users", { pagination: { page: 1, perPage: 5 }, sort: { field: "title", order: "ASC" }, filter: { author_id: 12 }, }); expect(users.data[0].id).toEqual("@user_id1:provider"); expect(users.total).toEqual(200); expect(fetch).toHaveBeenCalledTimes(1); }); it("fetches one user", async () => { vi.mocked(fetch).mockResolvedValueOnce( new Response( JSON.stringify({ name: "@user_id1:provider", password: "user_password", displayname: "User", threepids: [ { medium: "email", address: "user@mail_1.com", }, { medium: "email", address: "user@mail_2.com", }, ], avatar_url: "mxc://localhost/user1", admin: false, deactivated: false, creation_ts: 1560432506, }) ) ); const user = await dataProvider.getOne("users", { id: "@user_id1:provider" }); expect(user.data.id).toEqual("@user_id1:provider"); expect(user.data.displayname).toEqual("User"); expect(user.data.creation_ts_ms).toEqual(1560432506000); expect(fetch).toHaveBeenCalledTimes(1); }); it("keeps Synapse list creation_ts values in milliseconds", async () => { vi.mocked(fetch).mockResolvedValueOnce( new Response( JSON.stringify({ users: [ { name: "@user_id1:provider", is_guest: 0, admin: 0, user_type: null, deactivated: 0, displayname: "User One", creation_ts: 1560432668000, }, ], total: 1, }) ) ); const users = await dataProvider.getList("users", { pagination: { page: 1, perPage: 5 }, sort: { field: "name", order: "ASC" }, filter: {}, }); expect(users.data[0].creation_ts_ms).toEqual(1560432668000); }); it("skips MAS availability check when no access token", async () => { localStorage.setItem("token_endpoint", "http://mas.example/oauth2/token"); localStorage.removeItem("access_token"); // isMAS() reads RaStore.isMAS, which is not set, so registration_tokens stays Synapse const { initResources } = await import("./index"); initResources(); expect(fetch).not.toHaveBeenCalled(); }); it("uses MAS pagination cursor on page 2", async () => { vi.resetModules(); // Re-import after reset so MAS registration tokens init isn't cached from prior tests. const { default: freshDataProvider } = await import("./index"); localStorage.setItem("token_endpoint", "http://mas.example/oauth2/token"); // Set the cached MAS flag so the resource is detected as MAS localStorage.setItem("RaStore.isMAS", "true"); // Re-init registration tokens with the MAS flag set const { initResources } = await import("./index"); initResources(); const masListPage1 = { links: { self: "/api/admin/v1/user-registration-tokens?page%5Bafter%5D=01JB4PAPAMESEFX6CNP1JA5M6V&page%5Bfirst%5D=10", first: "/api/admin/v1/user-registration-tokens?page%5Bfirst%5D=10", last: "/api/admin/v1/user-registration-tokens?page%5Bafter%5D=01JB4PAPG3N9FS0YVQTMYV0NG&page%5Bfirst%5D=10", next: "/api/admin/v1/user-registration-tokens?page%5Bafter%5D=01JB4PAPAMESEFX6CNP1JA5M6V&page%5Bfirst%5D=10", prev: null, }, data: [ { type: "user-registration-token", id: "01JB4PAPAMESEFX6CNP1JA5M6V", attributes: { token: "5lQl96lyEJwMRx1c1Vx0Q2O93", valid: true, usage_limit: null, times_used: 0, created_at: "2024-06-10T10:12:21.184Z", last_used_at: null, expires_at: null, revoked_at: null, }, }, ], meta: { count: 1, }, }; const masListPage2 = { links: { self: "/api/admin/v1/user-registration-tokens?page%5Bafter%5D=01JB4PAPG3N9FS0YVQTMYV0NG&page%5Bfirst%5D=10", first: "/api/admin/v1/user-registration-tokens?page%5Bfirst%5D=10", last: "/api/admin/v1/user-registration-tokens?page%5Bafter%5D=01JB4PAPG3N9FS0YVQTMYV0NG&page%5Bfirst%5D=10", next: null, prev: "/api/admin/v1/user-registration-tokens?page%5Bbefore%5D=01JB4PAPG3N9FS0YVQTMYV0NG&page%5Bfirst%5D=10", }, data: [], meta: { count: 1, }, }; vi.mocked(fetch) .mockResolvedValueOnce(new Response(JSON.stringify(masListPage1))) .mockResolvedValueOnce(new Response(JSON.stringify(masListPage2))); await freshDataProvider.getList("registration_tokens", { pagination: { page: 1, perPage: 10 }, sort: { field: "token", order: "ASC" }, filter: { valid: true }, }); await freshDataProvider.getList("registration_tokens", { pagination: { page: 2, perPage: 10 }, sort: { field: "token", order: "ASC" }, filter: { valid: true }, }); const [page2Url] = vi.mocked(fetch).mock.calls[1]; expect(page2Url).toContain("http://mas.example/api/admin/v1/user-registration-tokens?"); expect(page2Url).toContain("page%5Bfirst%5D=10"); expect(page2Url).toContain("page%5Bafter%5D=01JB4PAPAMESEFX6CNP1JA5M6V"); expect(page2Url).toContain("filter%5Bvalid%5D=true"); }); it("keeps fetching backend pages until a filtered users page is filled", async () => { LoadConfig({ restrictBaseUrl: "", corsCredentials: "same-origin", asManagedUsers: ["^@sys"], menu: [], etkeccAdmin: "", }); vi.mocked(fetch).mockResolvedValueOnce( new Response( JSON.stringify({ users: [ { name: "@sys1:provider", is_guest: 0, admin: 0, deactivated: 0, displayname: "System 1" }, { name: "@sys2:provider", is_guest: 0, admin: 0, deactivated: 0, displayname: "System 2" }, { name: "@user1:provider", is_guest: 0, admin: 0, deactivated: 0, displayname: "User 1" }, { name: "@sys3:provider", is_guest: 0, admin: 0, deactivated: 0, displayname: "System 3" }, { name: "@user2:provider", is_guest: 0, admin: 0, deactivated: 0, displayname: "User 2" }, ], total: 5, }) ) ); const users = await dataProvider.getList("users", { pagination: { page: 1, perPage: 2 }, sort: { field: "name", order: "ASC" }, filter: { system_users: false, deactivated: false }, }); expect(users.data.map(user => user.id)).toEqual(["@user1:provider", "@user2:provider"]); expect(users.total).toEqual(2); expect(fetch).toHaveBeenCalledTimes(1); expect(vi.mocked(fetch).mock.calls[0]?.[0]).toContain("from=0"); expect(vi.mocked(fetch).mock.calls[0]?.[0]).toContain("limit=250"); expect(vi.mocked(fetch).mock.calls[0]?.[0]).toContain("deactivated=false"); }); it("paginates users by the filtered system_users dataset", async () => { LoadConfig({ restrictBaseUrl: "", corsCredentials: "same-origin", asManagedUsers: ["^@sys"], menu: [], etkeccAdmin: "", }); vi.mocked(fetch).mockResolvedValueOnce( new Response( JSON.stringify({ users: [ { name: "@sys1:provider", is_guest: 0, admin: 0, deactivated: 0, displayname: "System 1" }, { name: "@user1:provider", is_guest: 0, admin: 0, deactivated: 0, displayname: "User 1" }, { name: "@sys2:provider", is_guest: 0, admin: 0, deactivated: 0, displayname: "System 2" }, { name: "@user2:provider", is_guest: 0, admin: 0, deactivated: 0, displayname: "User 2" }, { name: "@user3:provider", is_guest: 0, admin: 0, deactivated: 0, displayname: "User 3" }, { name: "@sys3:provider", is_guest: 0, admin: 0, deactivated: 0, displayname: "System 3" }, ], total: 6, }) ) ); const users = await dataProvider.getList("users", { pagination: { page: 2, perPage: 2 }, sort: { field: "name", order: "ASC" }, filter: { system_users: false, guests: false }, }); expect(users.data.map(user => user.id)).toEqual(["@user3:provider"]); expect(users.total).toEqual(3); expect(fetch).toHaveBeenCalledTimes(1); expect(vi.mocked(fetch).mock.calls[0]?.[0]).toContain("from=0"); expect(vi.mocked(fetch).mock.calls[0]?.[0]).toContain("limit=250"); expect(vi.mocked(fetch).mock.calls[0]?.[0]).toContain("guests=false"); }); it("stops once the filtered page is filled and reports partial pagination info", async () => { LoadConfig({ restrictBaseUrl: "", corsCredentials: "same-origin", asManagedUsers: ["^@sys"], menu: [], etkeccAdmin: "", }); const firstChunkUsers = [ { name: "@sys1:provider", is_guest: 0, admin: 0, deactivated: 0, displayname: "System 1" }, { name: "@user1:provider", is_guest: 0, admin: 0, deactivated: 0, displayname: "User 1" }, { name: "@sys2:provider", is_guest: 0, admin: 0, deactivated: 0, displayname: "System 2" }, { name: "@user2:provider", is_guest: 0, admin: 0, deactivated: 0, displayname: "User 2" }, { name: "@user3:provider", is_guest: 0, admin: 0, deactivated: 0, displayname: "User 3" }, ...Array.from({ length: 245 }, (_, index) => ({ name: `@sys_more${index}:provider`, is_guest: 0, admin: 0, deactivated: 0, displayname: `System More ${index}`, })), ]; vi.mocked(fetch).mockResolvedValueOnce( new Response( JSON.stringify({ users: firstChunkUsers, total: 300, }) ) ); const users = await dataProvider.getList("users", { pagination: { page: 1, perPage: 2 }, sort: { field: "name", order: "ASC" }, filter: { system_users: false }, }); expect(users.data.map(user => user.id)).toEqual(["@user1:provider", "@user2:provider"]); expect(users.total).toBeUndefined(); expect(users.pageInfo).toEqual({ hasPreviousPage: false, hasNextPage: true, }); expect(fetch).toHaveBeenCalledTimes(1); }); it("reuses cached filtered scan state across later pages", async () => { LoadConfig({ restrictBaseUrl: "", corsCredentials: "same-origin", asManagedUsers: ["^@sys"], menu: [], etkeccAdmin: "", }); const firstChunkUsers = Array.from({ length: 250 }, (_, index) => ({ name: index < 80 ? `@user${index}:provider` : `@sys${index}:provider`, is_guest: 0, admin: 0, deactivated: 0, displayname: `User ${index}`, })); const secondChunkUsers = Array.from({ length: 50 }, (_, index) => ({ name: `@user_more${index}:provider`, is_guest: 0, admin: 0, deactivated: 0, displayname: `More User ${index}`, })); vi.mocked(fetch) .mockResolvedValueOnce( new Response( JSON.stringify({ users: firstChunkUsers, total: 300, }) ) ) .mockResolvedValueOnce( new Response( JSON.stringify({ users: secondChunkUsers, total: 300, }) ) ); const page1 = await dataProvider.getList("users", { pagination: { page: 1, perPage: 50 }, sort: { field: "name", order: "ASC" }, filter: { system_users: false }, }); const page2 = await dataProvider.getList("users", { pagination: { page: 2, perPage: 50 }, sort: { field: "name", order: "ASC" }, filter: { system_users: false }, }); expect(page1.data).toHaveLength(50); expect(page2.data).toHaveLength(50); expect(fetch).toHaveBeenCalledTimes(2); expect(vi.mocked(fetch).mock.calls[0]?.[0]).toContain("from=0"); expect(vi.mocked(fetch).mock.calls[1]?.[0]).toContain("from=250"); }); }); ================================================ FILE: src/providers/data/index.ts ================================================ import { HttpError, Identifier, PaginationPayload, RaRecord, SortPayload } from "react-admin"; import { jsonClient } from "../http"; import { getMASBaseUrl, getMASNextPageCursor, buildMASCursorKey, getMASCursor, setMASCursor, filterUndefined, revokeRegistrationToken, isMAS, } from "./mas-utils"; import { getMASRegistrationTokensResource, getMASUsersResource, getMASUsersAsMainResource, getMASUserEmailsResource, getMASCompatSessionsResource, getMASAuth2SessionsResource, getMASPersonalSessionsResource, getMASUserSessionsResource, getMASUpstreamOAuthLinksResource, getMASUpstreamOAuthProvidersResource, } from "./mas"; import { masLockUser, masDeactivateUser, masSetAdmin, masSetPassword, masFinishSession, masRevokePersonalSession, masRegeneratePersonalSession, masFinishUserSession, getMASPolicyData, setMASPolicyData, } from "./mas-actions"; import { synapseResourceMap, synapseRegistrationTokensResource } from "./synapse"; import { deleteMedia, purgeRemoteMedia, getFeatures, updateFeatures, getRateLimits, setRateLimits, getSentInviteCount, getCumulativeJoinedRoomCount, getAccountData, checkUsernameAvailability, blockRoom, deleteDevices, getRoomBlockStatus, joinUserToRoom, makeRoomAdmin, deleteUserMedia, deleteRoomMedia, quarantineRoomMedia, quarantineUserMedia, purgeHistory, getPurgeHistoryStatus, deleteRoom, getRoomDeleteStatus, suspendUser, shadowBanUser, resetPassword, loginAsUser, eraseUser, renewAccountValidity, allowCrossSigningReplacement, findUserByThreepid, findUserByAuthProvider, getEventByTimestamp, getEventContext, getRoomMessages, getRoomHierarchy, getAdminClientConfig, setAdminClientConfig, redactUserEvents, getRedactStatus, fetchEvent, } from "./synapse-actions"; import { uploadMedia } from "../matrix"; import { CACHED_MANY_REF, resourceMap } from "../../resourceMap"; import { etkeProviderMethods } from "./etke"; import { SynapseDataProvider } from "../types"; import { isSystemUser, getLocalpart } from "../../utils/mxid"; import { systemUsersScanCache, reverseSearchScanCache, buildScanCacheKey, setScanNotifier, runVirtualScan, } from "./scan"; import { wrapWithLifecycle } from "./lifecycle"; import createLogger from "../../utils/logger"; export { clearSystemUsersScanCache, clearReverseSearchScanCache } from "./scan"; /** * Initialize all flag-dependent resources and patch them into resourceMap. * Reads the cached MAS flag synchronously — no HTTP calls needed. * Add new MAS-dependent resources here as they are introduced. */ export const initResources = () => { if (isMAS()) { resourceMap.registration_tokens = getMASRegistrationTokensResource(); // Swap users to MAS-backed resource; Synapse tabs still work because id = @user:homeserver // eslint-disable-next-line @typescript-eslint/no-explicit-any (resourceMap as any).users = getMASUsersAsMainResource(); // mas_users registered with ULID ids for ReferenceInput in user-email create // eslint-disable-next-line @typescript-eslint/no-explicit-any (resourceMap as any).mas_users = getMASUsersResource(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (resourceMap as any).mas_user_emails = getMASUserEmailsResource(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (resourceMap as any).mas_compat_sessions = getMASCompatSessionsResource(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (resourceMap as any).mas_oauth2_sessions = getMASAuth2SessionsResource(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (resourceMap as any).mas_personal_sessions = getMASPersonalSessionsResource(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (resourceMap as any).mas_user_sessions = getMASUserSessionsResource(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (resourceMap as any).mas_upstream_oauth_links = getMASUpstreamOAuthLinksResource(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (resourceMap as any).mas_upstream_oauth_providers = getMASUpstreamOAuthProvidersResource(); } else { resourceMap.registration_tokens = synapseRegistrationTokensResource; // Restore Synapse users resource // eslint-disable-next-line @typescript-eslint/no-explicit-any (resourceMap as any).users = synapseResourceMap.users; // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (resourceMap as any).mas_users; // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (resourceMap as any).mas_user_emails; // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (resourceMap as any).mas_compat_sessions; // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (resourceMap as any).mas_oauth2_sessions; // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (resourceMap as any).mas_personal_sessions; // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (resourceMap as any).mas_user_sessions; // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (resourceMap as any).mas_upstream_oauth_links; // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (resourceMap as any).mas_upstream_oauth_providers; } }; // Initialize on module load to handle page refresh when already logged in if (localStorage.getItem("access_token")) { initResources(); } const resolveResource = (resource: string) => { const homeserver = localStorage.getItem("base_url"); if (!homeserver) throw Error("Homeserver not set"); if (!(resource in resourceMap)) throw Error(`Resource ${resource} not found`); // eslint-disable-next-line @typescript-eslint/no-explicit-any const res = resourceMap[resource as keyof typeof resourceMap] as any; const baseUrl = res.isMAS ? getMASBaseUrl() : homeserver; return { res, baseUrl, homeserver }; }; export const setDataProviderNotifier = (fn: (key: string) => void) => { setScanNotifier(fn); }; /* eslint-disable @typescript-eslint/no-explicit-any */ function filterNullValues(key: string, value: any) { // Filtering out null properties // to reset user_type from user, it must be null if (value === null && key !== "user_type") { return undefined; } return value; } function getSearchOrder(order: "ASC" | "DESC") { if (order === "DESC") { return "b"; } else { return "f"; } } const buildSynapseListQuery = ( params: { user_id: unknown; search_term: unknown; name: unknown; destination: unknown; guests: unknown; deactivated: unknown; locked: unknown; suspended: unknown; shadow_banned: unknown; valid: unknown; public_rooms: unknown; empty_rooms: unknown; action_name: unknown; resource_id: unknown; status: unknown; max_timestamp: unknown; }, from: number, limit: number, field: string, order: "ASC" | "DESC" ) => ({ from, limit, user_id: params.user_id, search_term: params.search_term, name: params.name, destination: params.destination, guests: isMAS() ? false : params.guests, deactivated: params.deactivated, locked: params.locked, suspended: params.suspended, shadow_banned: params.shadow_banned, valid: params.valid, order_by: field === "creation_ts_ms" ? "creation_ts" : field, dir: getSearchOrder(order), public_rooms: params.public_rooms, empty_rooms: params.empty_rooms, action_name: params.action_name, resource_id: params.resource_id, status: params.status, max_timestamp: params.max_timestamp, }); const log = createLogger("data"); const baseDataProvider: SynapseDataProvider = { getList: async (resource, params) => { const { res, baseUrl } = resolveResource(resource); const { user_id, name, guests, deactivated, locked, suspended, shadow_banned, search_term, destination, valid, public_rooms, empty_rooms, action_name, resource_id, status, max_timestamp, system_users, } = params.filter; const { page, perPage } = params.pagination as PaginationPayload; log.debug("getList", resource, { page, perPage, filter: params.filter }); const { field, order } = params.sort as SortPayload; const from = (page - 1) * perPage; // Shared filter param object — avoids repeating 16 keys across multiple buildSynapseListQuery calls. const synapseFilterParams = { user_id, search_term, name, destination, guests, deactivated, locked, suspended, shadow_banned, valid, public_rooms, empty_rooms, action_name, resource_id, status, max_timestamp, }; // Determine reverse search flag before res.getList delegation const isReverseSearch = resource === "users" && typeof name === "string" && name.startsWith("!"); // Allow resource to override getList entirely (e.g. MAS users Synapse-first sort) // Skip when reverse search or system_users scan is active — handled below for both modes. if (!isReverseSearch && !system_users && res.getList) { const result = await res.getList({ pagination: params.pagination as PaginationPayload, sort: params.sort as SortPayload, filter: params.filter, }); if (result !== null) return result; } // Build query based on API type let query: Record<string, any>; if (res.isMAS) { const cursorKey = buildMASCursorKey(resource, perPage, params.filter); const pageAfter = page > 1 ? getMASCursor(cursorKey, page) : undefined; query = res.buildListQuery(perPage, pageAfter, params.filter); } else { // Synapse API query = buildSynapseListQuery(synapseFilterParams, from, perPage, field, order); } // Client-side post-filter for system (appservice-managed) users const shouldFilterSystemUsers = resource === "users" && system_users !== undefined && system_users !== null; if (shouldFilterSystemUsers) { const wantSystem = system_users === true || system_users === "true"; // MAS mode: scan Synapse v3 directly (same endpoint as reverse search) const synapseBaseUrl = res.isMAS ? localStorage.getItem("base_url") || "" : String(baseUrl); const endpoint_url = res.isMAS ? `${synapseBaseUrl}/_synapse/admin/v3/users` : baseUrl + (res.listPath || res.path); const scanMap = res.isMAS ? synapseResourceMap.users.map : res.map; const scanDataKey = res.isMAS ? synapseResourceMap.users.data : res.data; const scanTotal = res.isMAS ? synapseResourceMap.users.total : res.total; const pageStart = from; const pageEnd = pageStart + perPage; const scanFilterParams = res.isMAS ? { ...synapseFilterParams, guests: false } : synapseFilterParams; const scanQuery = buildSynapseListQuery(scanFilterParams, 0, 0, field, order); const cacheKey = buildScanCacheKey({ resource, baseUrl: synapseBaseUrl, query: filterUndefined(scanQuery), field, order, wantSystem, }); return runVirtualScan({ cache: systemUsersScanCache, cacheKey, pageStart, pageEnd, perPage, fetchPage: async (offset, limit) => { const pagedQuery = buildSynapseListQuery(scanFilterParams, offset, limit, field, order); const pagedUrl = `${endpoint_url}?${new URLSearchParams(filterUndefined(pagedQuery)).toString()}`; const { json } = await jsonClient(pagedUrl); const rawData = json[scanDataKey] || []; const records = await Promise.all(rawData.map(scanMap)); return { rawCount: rawData.length, records, serverTotal: scanTotal(json) }; }, filterFn: record => isSystemUser(record.id) === wantSystem, notifyKey: "resources.users.action.system_users_scan_in_progress", enrichList: res.enrichList, }); } if (isReverseSearch) { const excludeTerm = name.slice(1).toLowerCase(); // MAS mode: scan Synapse v3 directly (getMASUsersAsMainResource is Synapse-first) const synapseBaseUrl = res.isMAS ? localStorage.getItem("base_url") || "" : String(baseUrl); const scanEndpoint = res.isMAS ? `${synapseBaseUrl}/_synapse/admin/v3/users` : synapseBaseUrl + (res.listPath || res.path); // Use Synapse user map/data/total — both modes scan the same Synapse v3 API const scanMap = synapseResourceMap.users.map; const scanDataKey = synapseResourceMap.users.data; const scanTotal = synapseResourceMap.users.total; const pageStart = from; const pageEnd = pageStart + perPage; // Reverse search excludes the name filter and overrides guests for MAS mode. const reverseSearchFilterParams = { ...synapseFilterParams, name: undefined, guests: res.isMAS ? false : guests }; const scanQuery = buildSynapseListQuery(reverseSearchFilterParams, 0, 0, field, order); const cacheKey = buildScanCacheKey({ resource, baseUrl: synapseBaseUrl, query: filterUndefined(scanQuery), field, order, excludeTerm, }); return runVirtualScan({ cache: reverseSearchScanCache, cacheKey, pageStart, pageEnd, perPage, fetchPage: async (offset, limit) => { const pagedQuery = buildSynapseListQuery(reverseSearchFilterParams, offset, limit, field, order); const pagedUrl = `${scanEndpoint}?${new URLSearchParams(filterUndefined(pagedQuery)).toString()}`; const { json } = await jsonClient(pagedUrl); const rawData = json[scanDataKey] || []; const records = await Promise.all(rawData.map(scanMap)); return { rawCount: rawData.length, records, serverTotal: scanTotal(json) }; }, filterFn: record => { const localpartLower = String(getLocalpart(record.id || "")).toLowerCase(); const displayLower = String(record.displayname || "").toLowerCase(); return !localpartLower.includes(excludeTerm) && !displayLower.includes(excludeTerm); }, notifyKey: "resources.users.action.reverse_search_scan_in_progress", maxRequests: 100, enrichList: res.enrichList, }); } const endpoint_url = baseUrl + (res.listPath || res.path); const url = res.noQueryParams ? endpoint_url : `${endpoint_url}?${new URLSearchParams(filterUndefined(query)).toString()}`; let json: Record<string, any>; try { ({ json } = await jsonClient(url)); } catch (error) { // Some resources map known server errors to an empty result rather than // propagating the error to React-Admin (which would prevent the empty // state from rendering). E.g. Synapse returns 500 for // database_room_statistics when the stats table hasn't been populated yet. // See: https://github.com/element-hq/synapse/issues/19561 if (res.ignoredErrors?.includes((error as any)?.status)) { return { data: [], total: 0 }; } throw error; } let formattedData = json[res.data].map(res.map); if (res.isMAS) { const cursorKey = buildMASCursorKey(resource, perPage, params.filter); const nextCursor = getMASNextPageCursor(json); if (nextCursor) { setMASCursor(cursorKey, page + 1, nextCursor); } } if (res.enrichList) { formattedData = await res.enrichList(formattedData); } return { data: formattedData, total: res.total(json), }; }, getOne: async (resource, params) => { log.debug("getOne", resource, params.id); const { res, baseUrl } = resolveResource(resource); // Allow resource configs to provide a custom async getOne (e.g. MAS users by Matrix ID) if (res.getOne) { const data = await res.getOne(params); return { data }; } const endpoint_url = baseUrl + res.path; const { json } = await jsonClient(`${endpoint_url}/${encodeURIComponent(params.id)}`); return { data: res.map(json) }; }, getMany: async (resource, params) => { log.debug("getMany", resource, `${params.ids.length} ids`); const { res, baseUrl } = resolveResource(resource); const homeserver = localStorage.getItem("home_server"); // If the resource provides a custom getOne (e.g. MAS users, which use ULIDs and can't be // fetched by Matrix ID via the standard path), delegate each lookup to it. if (res.getOne) { const data = await Promise.all( params.ids.map(async id => { // external/federated users are not on this homeserver — return stub as in Synapse path if (homeserver && resource === "users" && !(id as string).endsWith(homeserver)) { return { id, name: id } as RaRecord; } try { return (await res.getOne({ id })) as RaRecord; } catch { return { id } as RaRecord; } }) ); return { data, total: data.length }; } const endpoint_url = baseUrl + res.path; const data = await Promise.all( params.ids.map(async id => { // Federated/external users can't be queried via the Synapse admin API. // Return a minimal stub without going through res.map — this prevents res.map // from setting boolean fields like is_guest: false on records that have no real data. if (homeserver && resource === "users" && !(id as string).endsWith(homeserver)) { return { id, name: id } as RaRecord; } try { const { json } = await jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`); return (await Promise.resolve(res.map(json))) as RaRecord; } catch (error) { // Handle deleted/non-existent resources gracefully by returning minimal data // This can happen when a room is deleted but still referenced in joined_rooms if (error instanceof HttpError && error.status === 404) { const json = resource === "rooms" ? { room_id: id, name: id } : { id }; return (await Promise.resolve(res.map(json))) as RaRecord; } throw error; } }) ); return { data: data as any[], total: data.length }; }, getManyReference: async (resource, params) => { log.debug("getManyReference", resource, `ref=${params.id}`); const { res, homeserver } = resolveResource(resource); const { page, perPage } = params.pagination; const { field, order } = params.sort; const from = (page - 1) * perPage; const query = { from: from, limit: perPage, order_by: field, dir: getSearchOrder(order), }; const ref = res.reference(params.id); const endpoint_url = `${homeserver}${ref.endpoint}?${new URLSearchParams(filterUndefined(query)).toString()}`; const CACHE_KEY = ref.endpoint; let jsonData: any[]; let total: number; if (CACHED_MANY_REF[CACHE_KEY]) { let allData: any[] = CACHED_MANY_REF[CACHE_KEY]["data"]; // Apply localOnly filter for room_members: exclude federated users if (resource === "room_members" && params.filter?.localOnly) { const hs = localStorage.getItem("home_server") || ""; allData = allData.filter((m: any) => String(m).endsWith(`:${hs}`)); } total = allData.length; const safeFrom = from < total ? from : 0; jsonData = allData.slice(safeFrom, safeFrom + perPage); } else { const { json } = await jsonClient(endpoint_url); jsonData = json[res.data]; // memberships endpoint needs special handling if (resource === "memberships") { jsonData = Object.entries(jsonData).map(([room_id, membership]) => ({ id: room_id, membership: membership, })); } total = res.total(json); // only cache if the endpoint returned all data (no server-side pagination) if (jsonData.length >= total) { CACHED_MANY_REF[CACHE_KEY] = { data: jsonData, total: total }; // Apply localOnly filter for room_members: exclude federated users if (resource === "room_members" && params.filter?.localOnly) { const hs = localStorage.getItem("home_server") || ""; jsonData = jsonData.filter((m: any) => String(m).endsWith(`:${hs}`)); } total = jsonData.length; const safeFrom = from < total ? from : 0; jsonData = jsonData.slice(safeFrom, safeFrom + perPage); } } return { data: jsonData.map(res.map), total: total, }; }, update: async (resource, params) => { log.debug("update", resource, params.id); const { res, baseUrl } = resolveResource(resource); const endpoint_url = baseUrl + res.path; if (res.update) { const upd = res.update(params); const options: { method: string; body?: string } = { method: upd.method }; if (upd.method !== "GET" && "body" in upd) { options.body = JSON.stringify(upd.body, filterNullValues); } const { json } = await jsonClient(baseUrl + upd.endpoint, options); return { data: res.map(json) }; } const { json } = await jsonClient(`${endpoint_url}/${encodeURIComponent(params.id)}`, { method: "PUT", body: JSON.stringify(params.data, filterNullValues), }); return { data: res.map(json) }; }, updateMany: async (resource, params) => { log.debug("updateMany", resource, `${params.ids.length} ids`); const { res, homeserver } = resolveResource(resource); const endpoint_url = homeserver + res.path; const responses = await Promise.all( params.ids.map(id => jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(params.data, filterNullValues), }) ) ); return { data: responses.map(({ json }) => json) }; }, create: async (resource, params) => { log.debug("create", resource); const { res, baseUrl } = resolveResource(resource); if (!("create" in res)) return Promise.reject(new Error(`Create not supported for ${resource}`)); const create = res.create(params.data); const endpoint_url = baseUrl + create.endpoint; const { json } = await jsonClient(endpoint_url, { method: create.method, body: JSON.stringify(create.body, filterNullValues), }); // for some resources, the response is empty, so we return the adjusted input data as response if (create?.response) { return { data: create.response(params.data) }; } // Use custom response handler if provided (e.g., for MAS) if (res.handleCreateResponse) { const converted = res.handleCreateResponse(json); return { data: converted }; } return { data: res.map(json) }; }, createMany: async (resource: string, params: { ids: Identifier[]; data: RaRecord }) => { log.debug("createMany", resource, `${params.ids.length} ids`); const { res, homeserver } = resolveResource(resource); if (!("create" in res)) throw Error(`Create ${resource} is not allowed`); const responses = await Promise.all( params.ids.map(id => { params.data.id = id; const cre = res.create(params.data); const endpoint_url = homeserver + cre.endpoint; return jsonClient(endpoint_url, { method: cre.method, body: JSON.stringify(cre.body, filterNullValues), }); }) ); return { data: responses.map(({ json }) => json) }; }, delete: async (resource, params) => { log.debug("delete", resource, params.id); const { res, baseUrl } = resolveResource(resource); if ("delete" in res) { const del = res.delete(params); const endpoint_url = baseUrl + del.endpoint; const { json } = await jsonClient(endpoint_url, { method: "method" in del ? del.method : "DELETE", body: "body" in del ? JSON.stringify(del.body) : null, }); if (del?.response) { return { data: del.response(params.previousData) }; } return { data: json }; } else { const endpoint_url = baseUrl + res.path; const { json } = await jsonClient(`${endpoint_url}/${params.id}`, { method: "DELETE", body: JSON.stringify(params.previousData, filterNullValues), }); return { data: json }; } }, deleteMany: async (resource, params) => { log.debug("deleteMany", resource, `${params.ids.length} ids`); const { res, baseUrl } = resolveResource(resource); if ("delete" in res) { const responses = await Promise.all( params.ids.map(id => { const del = res.delete({ ...params, id: id }); const endpoint_url = baseUrl + del.endpoint; return jsonClient(endpoint_url, { method: "method" in del ? del.method : "DELETE", body: "body" in del ? JSON.stringify(del.body) : null, }); }) ); return { data: responses.map(({ json }) => json), }; } else { const endpoint_url = baseUrl + res.path; const responses = await Promise.all( params.ids.map(id => jsonClient(`${endpoint_url}/${id}`, { method: "DELETE", // body: JSON.stringify(params.data, filterNullValues), @FIXME }) ) ); return { data: responses.map(({ json }) => json) }; } }, // Custom methods (https://marmelab.com/react-admin/DataProviders.html#adding-custom-methods) /** * Delete media by date or size * * @link https://matrix-org.github.io/synapse/latest/admin_api/media_admin_api.html#delete-local-media-by-date-or-size */ deleteMedia, purgeRemoteMedia, uploadMedia, fetchEvent, getFeatures, updateFeatures, getRateLimits, setRateLimits, getSentInviteCount, getCumulativeJoinedRoomCount, getAccountData, checkUsernameAvailability, blockRoom, deleteDevices, getRoomBlockStatus, joinUserToRoom, makeRoomAdmin, deleteUserMedia, deleteRoomMedia, quarantineRoomMedia, quarantineUserMedia, purgeHistory, getPurgeHistoryStatus, deleteRoom, getRoomDeleteStatus, redactUserEvents, getRedactStatus, suspendUser, shadowBanUser, resetPassword, loginAsUser, eraseUser, renewAccountValidity, allowCrossSigningReplacement, findUserByThreepid, findUserByAuthProvider, getEventByTimestamp, getEventContext, getRoomMessages, getRoomHierarchy, getAdminClientConfig, setAdminClientConfig, revokeRegistrationToken, masLockUser, masDeactivateUser, masSetAdmin, masSetPassword, masFinishSession, masRevokePersonalSession, masRegeneratePersonalSession, masFinishUserSession, getMASPolicyData, setMASPolicyData, ...etkeProviderMethods, }; export default wrapWithLifecycle(baseDataProvider); ================================================ FILE: src/providers/data/lifecycle.ts ================================================ /** * React-admin lifecycle callbacks for the Synapse data provider. * * Wraps the base data provider with beforeUpdate / beforeDelete / afterDelete hooks * that dispatch side-effects (MAS auth changes, Synapse profile updates, media cleanup). */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { DataProvider, DeleteManyParams, DeleteParams, UpdateParams, withLifecycleCallbacks } from "react-admin"; import { jsonClient } from "../http"; import { SynapseDataProvider } from "../types"; import { isMAS } from "./mas-utils"; import { deleteUserMedia } from "./synapse-actions"; import { invalidateManyRefCache } from "./synapse"; /** * Wrap a base SynapseDataProvider with lifecycle callbacks for user and room resources. * Returns the wrapped provider, which is the dataProvider used by the app. */ export const wrapWithLifecycle = (base: SynapseDataProvider): SynapseDataProvider => withLifecycleCallbacks(base, [ { resource: "rooms", afterDelete: async result => { // Invalidate the joined_rooms cache after room deletion invalidateManyRefCache("joined_rooms"); return result; }, afterDeleteMany: async result => { // Invalidate the joined_rooms cache after room deletion invalidateManyRefCache("joined_rooms"); return result; }, }, { resource: "users", beforeUpdate: async (params: UpdateParams<any>, dataProvider: DataProvider) => { // In MAS mode: dispatch MAS auth-field changes and skip Synapse-only logic if (isMAS()) { const masId = params.previousData.mas_id as string; const prev = params.previousData; const next = params.data; // MAS-managed fields — only fire when the field was actually submitted (not disabled/omitted). // Disabled BooleanInputs (e.g. admin editing their own account) are excluded from react-hook-form // submission, leaving the value as undefined. Guarding with !== undefined prevents spurious API // calls that would e.g. strip admin from the current user or unlock an already-unlocked account. // Also normalise to strict booleans to guard against undefined/null from incomplete cache data. const prevAdmin = prev.admin === true; const nextAdmin = next.admin === true; if (next.admin !== undefined && prevAdmin !== nextAdmin) await (dataProvider as SynapseDataProvider).masSetAdmin(masId, nextAdmin); const prevLocked = prev.locked === true; const nextLocked = next.locked === true; if (next.locked !== undefined && prevLocked !== nextLocked) await (dataProvider as SynapseDataProvider).masLockUser(masId, nextLocked); const prevDeactivated = prev.deactivated === true; const nextDeactivated = next.deactivated === true; if (next.deactivated !== undefined && prevDeactivated !== nextDeactivated) // masDeactivateUser(id, active): true = reactivate, false = deactivate await (dataProvider as SynapseDataProvider).masDeactivateUser(masId, !nextDeactivated); // Synapse-managed profile fields (not disabled by MSC3861) if (prev.suspended !== next.suspended && next.suspended !== undefined) await (dataProvider as SynapseDataProvider).suspendUser(params.id, next.suspended); if (prev.shadow_banned !== next.shadow_banned && next.shadow_banned !== undefined) await (dataProvider as SynapseDataProvider).shadowBanUser(params.id, next.shadow_banned); // Handle avatar upload / erase before the Synapse profile PUT const avatarFile = next.avatar_file?.rawFile; const avatarErase = next.avatar_erase; if (avatarErase) { next.avatar_src = null; } else if (avatarFile instanceof File) { const uploaded = await (dataProvider as SynapseDataProvider).uploadMedia({ file: avatarFile, filename: next.avatar_file.title, content_type: avatarFile.type, }); next.avatar_src = uploaded.content_uri; } // Synapse-managed profile fields sent via main PUT /_synapse/admin/v2/users/... const synapseProfileChanged = prev.displayname !== next.displayname || prev.avatar_src !== next.avatar_src; if (synapseProfileChanged) { const baseUrl = localStorage.getItem("base_url") || ""; const matrixId = encodeURIComponent(String(params.id)); const body: Record<string, any> = {}; if (prev.displayname !== next.displayname) body.displayname = next.displayname ?? ""; if (prev.avatar_src !== next.avatar_src) body.avatar_url = next.avatar_src ?? ""; await jsonClient(`${baseUrl}/_synapse/admin/v2/users/${matrixId}`, { method: "PUT", body: JSON.stringify(body), }); } return params; } const avatarFile = params.data.avatar_file?.rawFile; const avatarErase = params.data.avatar_erase; const rates = params.data.rates; const suspended = params.data.suspended; const previousSuspended = params.previousData?.suspended; const shadowBanned = params.data.shadow_banned; const previousShadowBanned = params.previousData?.shadow_banned; const deactivated = params.data.deactivated; const erased = params.data.erased; if (rates) { await dataProvider.setRateLimits(params.id, rates); delete params.data.rates; } if (suspended !== undefined && suspended !== previousSuspended) { await (dataProvider as SynapseDataProvider).suspendUser(params.id, suspended); delete params.data.suspended; } if (shadowBanned !== undefined && shadowBanned !== previousShadowBanned) { await (dataProvider as SynapseDataProvider).shadowBanUser(params.id, shadowBanned); delete params.data.shadow_banned; } if (deactivated !== undefined && erased !== undefined) { await (dataProvider as SynapseDataProvider).eraseUser(params.id); delete params.data.deactivated; delete params.data.erased; } if (avatarErase) { params.data.avatar_url = ""; return params; } if (avatarFile instanceof File) { const response = await dataProvider.uploadMedia({ file: avatarFile, filename: params.data.avatar_file.title, content_type: params.data.avatar_file.rawFile.type, }); params.data.avatar_url = response.content_uri; } return params; }, beforeDelete: async (params: DeleteParams<any>, _dataProvider: DataProvider) => { if (params.meta?.deleteMedia) await deleteUserMedia(params.id); return params; }, beforeDeleteMany: async (params: DeleteManyParams<any>, _dataProvider: DataProvider) => { await Promise.all( params.ids.map(async id => { if (params.meta?.deleteMedia) await deleteUserMedia(id); }) ); return params; }, }, ]) as SynapseDataProvider; ================================================ FILE: src/providers/data/mas-actions.ts ================================================ /** * MAS (Matrix Authentication Service) admin API action helpers. * Direct async wrappers around MAS admin endpoints for user management actions. */ import { HttpError } from "react-admin"; import { jsonClient } from "../http"; import { MASPolicyData, MASPolicyDataResource } from "../types"; import { getMASBaseUrl } from "./mas-utils"; export const masLockUser = async (id: string, lock: boolean): Promise<{ success: boolean; error?: string }> => { const masBaseUrl = getMASBaseUrl(); if (!masBaseUrl) return { success: false, error: "MAS base URL not found" }; const action = lock ? "lock" : "unlock"; try { await jsonClient(`${masBaseUrl}/api/admin/v1/users/${encodeURIComponent(id)}/${action}`, { method: "POST" }); return { success: true }; } catch (error) { if (error instanceof HttpError) return { success: false, error: error.body?.errors?.[0]?.title || error.message }; throw error; } }; export const masDeactivateUser = async (id: string, active: boolean): Promise<{ success: boolean; error?: string }> => { const masBaseUrl = getMASBaseUrl(); if (!masBaseUrl) return { success: false, error: "MAS base URL not found" }; const action = active ? "reactivate" : "deactivate"; try { await jsonClient(`${masBaseUrl}/api/admin/v1/users/${encodeURIComponent(id)}/${action}`, { method: "POST" }); return { success: true }; } catch (error) { if (error instanceof HttpError) return { success: false, error: error.body?.errors?.[0]?.title || error.message }; throw error; } }; export const masSetAdmin = async (id: string, admin: boolean): Promise<{ success: boolean; error?: string }> => { const masBaseUrl = getMASBaseUrl(); if (!masBaseUrl) return { success: false, error: "MAS base URL not found" }; try { await jsonClient(`${masBaseUrl}/api/admin/v1/users/${encodeURIComponent(id)}/set-admin`, { method: "POST", body: JSON.stringify({ admin }), }); return { success: true }; } catch (error) { if (error instanceof HttpError) return { success: false, error: error.body?.errors?.[0]?.title || error.message }; throw error; } }; export const masSetPassword = async (id: string, password: string): Promise<{ success: boolean; error?: string }> => { const masBaseUrl = getMASBaseUrl(); if (!masBaseUrl) return { success: false, error: "MAS base URL not found" }; try { await jsonClient(`${masBaseUrl}/api/admin/v1/users/${encodeURIComponent(id)}/set-password`, { method: "POST", body: JSON.stringify({ password }), }); return { success: true }; } catch (error) { if (error instanceof HttpError) return { success: false, error: error.body?.errors?.[0]?.title || error.message }; throw error; } }; export const masFinishSession = async ( resource: "mas_compat_sessions" | "mas_oauth2_sessions", id: string ): Promise<{ success: boolean; error?: string }> => { const masBaseUrl = getMASBaseUrl(); if (!masBaseUrl) return { success: false, error: "MAS base URL not found" }; const apiPath = resource === "mas_compat_sessions" ? "compat-sessions" : "oauth2-sessions"; try { await jsonClient(`${masBaseUrl}/api/admin/v1/${apiPath}/${encodeURIComponent(id)}/finish`, { method: "POST" }); return { success: true }; } catch (error) { if (error instanceof HttpError) return { success: false, error: error.body?.errors?.[0]?.title || error.message }; throw error; } }; export const masRevokePersonalSession = async (id: string): Promise<{ success: boolean; error?: string }> => { const masBaseUrl = getMASBaseUrl(); if (!masBaseUrl) return { success: false, error: "MAS base URL not found" }; try { await jsonClient(`${masBaseUrl}/api/admin/v1/personal-sessions/${encodeURIComponent(id)}/revoke`, { method: "POST", }); return { success: true }; } catch (error) { if (error instanceof HttpError) return { success: false, error: error.body?.errors?.[0]?.title || error.message }; throw error; } }; export const masFinishUserSession = async (id: string): Promise<{ success: boolean; error?: string }> => { const masBaseUrl = getMASBaseUrl(); if (!masBaseUrl) return { success: false, error: "MAS base URL not found" }; try { await jsonClient(`${masBaseUrl}/api/admin/v1/user-sessions/${encodeURIComponent(id)}/finish`, { method: "POST" }); return { success: true }; } catch (error) { if (error instanceof HttpError) return { success: false, error: error.body?.errors?.[0]?.title || error.message }; throw error; } }; export const getMASPolicyData = async (): Promise<MASPolicyData | null> => { const masBaseUrl = getMASBaseUrl(); if (!masBaseUrl) return null; try { const { json } = await jsonClient(`${masBaseUrl}/api/admin/v1/policy-data/latest`); const d = json.data as MASPolicyDataResource; return { id: d.id, data: d.attributes.data, created_at: d.attributes.created_at }; } catch { return null; } }; export const setMASPolicyData = async (data: unknown): Promise<{ success: boolean; error?: string }> => { const masBaseUrl = getMASBaseUrl(); if (!masBaseUrl) return { success: false, error: "MAS base URL not found" }; try { await jsonClient(`${masBaseUrl}/api/admin/v1/policy-data`, { method: "POST", body: JSON.stringify({ data }), }); return { success: true }; } catch (error) { if (error instanceof HttpError) return { success: false, error: error.body?.errors?.[0]?.title || error.message }; throw error; } }; export const masRegeneratePersonalSession = async ( id: string ): Promise<{ success: boolean; token?: string; error?: string }> => { const masBaseUrl = getMASBaseUrl(); if (!masBaseUrl) return { success: false, error: "MAS base URL not found" }; try { const { json } = await jsonClient( `${masBaseUrl}/api/admin/v1/personal-sessions/${encodeURIComponent(id)}/regenerate`, { method: "POST" } ); return { success: true, token: json?.data?.attributes?.token }; } catch (error) { if (error instanceof HttpError) return { success: false, error: error.body?.errors?.[0]?.title || error.message }; throw error; } }; ================================================ FILE: src/providers/data/mas-utils.test.ts ================================================ import type { MASRegistrationToken, MASRegistrationTokenResource } from "../types"; vi.mock("react-admin", () => ({ HttpError: class HttpError extends Error { constructor( message: string, public status: number, public body?: Record<string, unknown> ) { super(message); } }, useStore: vi.fn(() => [false, vi.fn()]), })); vi.mock("../http", () => ({ jsonClient: vi.fn(), })); import { buildMASCursorKey, convertMASTokenToSynapse, filterUndefined, getMASBaseUrl, getMASCursor, getMASNextPageCursor, getMASTokenResource, isMAS, setIsMAS, setMASCursor, toRfc3339, } from "./mas-utils"; beforeEach(() => { localStorage.clear(); }); // --- isMAS / setIsMAS --- describe("isMAS / setIsMAS", () => { it("returns false when localStorage has no entry", () => { expect(isMAS()).toBe(false); }); it("returns true after setIsMAS(true)", () => { setIsMAS(true); expect(isMAS()).toBe(true); }); it("returns false after setIsMAS(false)", () => { setIsMAS(true); setIsMAS(false); expect(isMAS()).toBe(false); }); }); // --- getMASBaseUrl --- describe("getMASBaseUrl", () => { it("returns null when token_endpoint is absent", () => { expect(getMASBaseUrl()).toBeNull(); }); it("strips /oauth2/token to return the base URL", () => { localStorage.setItem("token_endpoint", "http://localhost:8007/oauth2/token"); expect(getMASBaseUrl()).toBe("http://localhost:8007"); }); it("returns the full value when it does not end with /oauth2/token", () => { localStorage.setItem("token_endpoint", "http://localhost:8007/custom"); expect(getMASBaseUrl()).toBe("http://localhost:8007/custom"); }); }); // --- toRfc3339 --- describe("toRfc3339", () => { it("returns undefined for undefined", () => { expect(toRfc3339(undefined)).toBeUndefined(); }); it("returns undefined for null", () => { expect(toRfc3339(null)).toBeUndefined(); }); it("returns undefined for 0", () => { expect(toRfc3339(0)).toBeUndefined(); }); it("converts a millisecond timestamp to an ISO string", () => { const ts = new Date("2024-03-15T10:00:00.000Z").getTime(); expect(toRfc3339(ts)).toBe("2024-03-15T10:00:00.000Z"); }); }); // --- filterUndefined --- describe("filterUndefined", () => { it("removes undefined values", () => { expect(filterUndefined({ a: 1, b: undefined })).toEqual({ a: 1 }); }); it("removes null values", () => { expect(filterUndefined({ a: 1, b: null })).toEqual({ a: 1 }); }); it("keeps zero and false", () => { expect(filterUndefined({ a: 0, b: false, c: "" })).toEqual({ a: 0, b: false, c: "" }); }); it("returns empty object when all values are null/undefined", () => { expect(filterUndefined({ a: null, b: undefined })).toEqual({}); }); }); // --- getMASTokenResource --- const makeTokenResource = (): MASRegistrationTokenResource => ({ type: "registration-token", id: "token-1", attributes: { token: "tok123", valid: true, times_used: 5, created_at: "2024-01-01T00:00:00Z", }, links: { self: "/tokens/token-1" }, }); describe("getMASTokenResource", () => { it("unwraps a wrapped MASRegistrationToken (has 'data' key)", () => { const wrapped: MASRegistrationToken = { data: makeTokenResource(), links: { self: "/tokens/token-1" }, }; expect(getMASTokenResource(wrapped)).toBe(wrapped.data); }); it("returns a bare MASRegistrationTokenResource unchanged", () => { const resource = makeTokenResource(); expect(getMASTokenResource(resource)).toBe(resource); }); }); // --- convertMASTokenToSynapse --- describe("convertMASTokenToSynapse", () => { it("maps all fields correctly from a resource", () => { const resource = makeTokenResource(); resource.attributes.usage_limit = 10; resource.attributes.expires_at = "2025-01-01T00:00:00Z"; resource.attributes.last_used_at = "2024-06-01T00:00:00Z"; resource.attributes.revoked_at = undefined; const result = convertMASTokenToSynapse(resource); expect(result.token).toBe("tok123"); expect(result.valid).toBe(true); expect(result.uses_allowed).toBe(10); expect(result.pending).toBe(0); expect(result.completed).toBe(5); expect(result.expiry_time).toBe("2025-01-01T00:00:00Z"); expect(result.created_at).toBe("2024-01-01T00:00:00Z"); expect(result.last_used_at).toBe("2024-06-01T00:00:00Z"); expect(result.revoked_at).toBeUndefined(); }); it("falls back to null uses_allowed when usage_limit is absent", () => { const resource = makeTokenResource(); const result = convertMASTokenToSynapse(resource); expect(result.uses_allowed).toBeNull(); }); it("falls back to null expiry_time when expires_at is absent", () => { const resource = makeTokenResource(); const result = convertMASTokenToSynapse(resource); expect(result.expiry_time).toBeNull(); }); it("defaults valid to true when undefined", () => { const resource = makeTokenResource(); // @ts-expect-error testing undefined case resource.attributes.valid = undefined; expect(convertMASTokenToSynapse(resource).valid).toBe(true); }); it("accepts a wrapped MASRegistrationToken", () => { const wrapped: MASRegistrationToken = { data: makeTokenResource(), links: { self: "/" }, }; const result = convertMASTokenToSynapse(wrapped); expect(result.token).toBe("tok123"); }); }); // --- buildMASCursorKey / getMASCursor / setMASCursor --- describe("cursor cache", () => { it("buildMASCursorKey returns a stable JSON string", () => { const key = buildMASCursorKey("users", 20, { valid: true }); expect(key).toBe(JSON.stringify({ resource: "users", perPage: 20, filter: { valid: true } })); }); it("getMASCursor returns undefined for an unknown cache key", () => { expect(getMASCursor("no-such-key", 1)).toBeUndefined(); }); it("setMASCursor + getMASCursor round-trips correctly", () => { const key = buildMASCursorKey("tokens", 10, {}); setMASCursor(key, 2, "cursor-abc"); expect(getMASCursor(key, 2)).toBe("cursor-abc"); }); it("stores independent cursors for different pages", () => { const key = buildMASCursorKey("tokens", 10, {}); setMASCursor(key, 1, "cursor-page1"); setMASCursor(key, 2, "cursor-page2"); expect(getMASCursor(key, 1)).toBe("cursor-page1"); expect(getMASCursor(key, 2)).toBe("cursor-page2"); }); }); // --- getMASNextPageCursor --- describe("getMASNextPageCursor", () => { it("returns undefined for empty data array", () => { expect(getMASNextPageCursor({ data: [] })).toBeUndefined(); }); it("returns undefined when data is absent", () => { expect(getMASNextPageCursor({})).toBeUndefined(); }); it("extracts cursor from links.next URL", () => { const result = getMASNextPageCursor({ links: { next: "https://example.invalid/api?page[after]=cursor-xyz" }, data: [], }); expect(result).toBe("cursor-xyz"); }); it("falls back to last item meta.page.cursor when no links.next", () => { const result = getMASNextPageCursor({ data: [ { id: "1", meta: { page: { cursor: "cursor-a" } } }, { id: "2", meta: { page: { cursor: "cursor-b" } } }, ], }); expect(result).toBe("cursor-b"); }); it("falls back to last item id when meta cursor is absent", () => { const result = getMASNextPageCursor({ data: [{ id: "item-1" }, { id: "item-2" }], }); expect(result).toBe("item-2"); }); it("ignores malformed links.next and falls back to item cursor", () => { const result = getMASNextPageCursor({ links: { next: "not a valid url" }, data: [{ id: "item-1", meta: { page: { cursor: "cursor-x" } } }], }); // URL is malformed but "not a valid url" doesn't have page[after] param — falls through to item cursor expect(result).toBe("cursor-x"); }); }); ================================================ FILE: src/providers/data/mas-utils.ts ================================================ /** * MAS (Matrix Authentication Service) utility functions and helpers. * Shared by mas.ts (resource factories), mas-actions.ts, and index.ts. */ import { HttpError, useStore } from "react-admin"; import { jsonClient } from "../http"; import { MASRegistrationToken, MASRegistrationTokenResource } from "../types"; /** * Read the cached MAS flag from localStorage. * react-admin's useStore persists under the "RaStore." prefix, * so useStore<boolean>('mas', false) reads/writes "RaStore.isMAS". * This function is for non-React code (dataProvider, serverVersion). */ export const isMAS = (): boolean => { // react-admin's useStore serialises values as JSON under "RaStore.<key>" return localStorage.getItem("RaStore.isMAS") === "true"; }; /** * React hook for components — reactive, backed by react-admin store. */ export const useIsMAS = (): boolean => { const [value] = useStore<boolean>("isMAS", false); return value; }; /** * Set the MAS flag in react-admin store's localStorage slot. * The flag means "is MAS AND admin API is available". */ export const setIsMAS = (value: boolean): void => { localStorage.setItem("RaStore.isMAS", JSON.stringify(value)); }; /** * Extract the MAS base URL from the token endpoint * e.g., "http://localhost:8007/oauth2/token" -> "http://localhost:8007" */ export const getMASBaseUrl = (): string | null => { const tokenEndpoint = localStorage.getItem("token_endpoint"); if (!tokenEndpoint) return null; // Remove trailing /oauth2/token to get the base URL return tokenEndpoint.replace(/\/oauth2\/token$/, ""); }; /** * Convert Unix timestamp (milliseconds) to RFC 3339 formatted string * Used for MAS API which expects RFC 3339 format for expiry dates */ export const toRfc3339 = (timestamp: number | undefined | null): string | undefined => { if (!timestamp) return undefined; return new Date(timestamp).toISOString(); }; /** * Check if MAS admin API is available by attempting a health check. * Only called once at login time, never on page refresh. */ export const checkMASAdminApiAvailable = async (): Promise<boolean> => { const masBaseUrl = getMASBaseUrl(); if (!masBaseUrl) return false; const token = localStorage.getItem("access_token"); if (!token) return false; try { await jsonClient(`${masBaseUrl}/api/admin/v1/site-config`, { method: "GET" }); return true; } catch { return false; } }; /** * Detect MAS, check admin API availability, set the cached flag, * and initialize the registration tokens resource. * Called once at login / OIDC callback. */ export const detectAndSetMAS = async (): Promise<void> => { const tokenEndpoint = localStorage.getItem("token_endpoint"); const isMASEndpoint = !!tokenEndpoint && tokenEndpoint.endsWith("/oauth2/token"); if (isMASEndpoint && (await checkMASAdminApiAvailable())) { setIsMAS(true); } else { setIsMAS(false); } }; /** * Get the MAS server version */ export const getMASVersion = async (): Promise<string> => { const masBaseUrl = getMASBaseUrl(); if (!masBaseUrl) return ""; try { const { json } = await jsonClient(`${masBaseUrl}/api/admin/v1/version`); return json.version as string; } catch { return ""; } }; /** * Revoke or unrevoke a MAS registration token */ export const revokeRegistrationToken = async ( id: string, revoke: boolean ): Promise<{ success: boolean; error?: string }> => { const masBaseUrl = getMASBaseUrl(); if (!masBaseUrl) return { success: false, error: "MAS base URL not found" }; const action = revoke ? "revoke" : "unrevoke"; try { await jsonClient(`${masBaseUrl}/api/admin/v1/user-registration-tokens/${encodeURIComponent(id)}/${action}`, { method: "POST", }); return { success: true }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body?.errors?.[0]?.title || error.message }; } throw error; } }; /** * Convert MAS registration token format to Synapse format */ export const getMASTokenResource = ( token: MASRegistrationToken | MASRegistrationTokenResource ): MASRegistrationTokenResource => { return "data" in token ? token.data : token; }; export const convertMASTokenToSynapse = (masToken: MASRegistrationToken | MASRegistrationTokenResource) => { const resource = getMASTokenResource(masToken); return { token: resource.attributes.token, valid: resource.attributes.valid ?? true, uses_allowed: resource.attributes.usage_limit ?? null, pending: 0, // MAS doesn't provide pending count, use 0 completed: resource.attributes.times_used ?? 0, expiry_time: resource.attributes.expires_at || null, created_at: resource.attributes.created_at, last_used_at: resource.attributes.last_used_at, revoked_at: resource.attributes.revoked_at, }; }; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ export const filterUndefined = (obj: Record<string, any>) => { return Object.fromEntries(Object.entries(obj).filter(([_key, value]) => value !== undefined && value !== null)); }; // Generic MAS cursor cache keyed by resource+perPage+filter const masCursorCache = new Map<string, Map<number, string>>(); export const buildMASCursorKey = (resource: string, perPage: number, filter: Record<string, unknown>): string => { return JSON.stringify({ resource, perPage, filter }); }; export const getMASCursor = (cacheKey: string, page: number): string | undefined => { return masCursorCache.get(cacheKey)?.get(page); }; export const setMASCursor = (cacheKey: string, page: number, cursor: string): void => { const cache = masCursorCache.get(cacheKey) ?? new Map<number, string>(); cache.set(page, cursor); masCursorCache.set(cacheKey, cache); }; // Legacy registration-token helpers — delegate to generic functions export const getMASRegistrationTokensCursorKey = (params: { perPage: number }, valid?: boolean) => buildMASCursorKey("registration_tokens", params.perPage, { valid }); export const getMASRegistrationTokensPageCursor = (cacheKey: string, page: number) => getMASCursor(cacheKey, page); export const setMASRegistrationTokensPageCursor = (cacheKey: string, page: number, cursor: string) => setMASCursor(cacheKey, page, cursor); export const getMASNextPageCursor = (json: { links?: { next?: string }; data?: { meta?: { page?: { cursor?: string } }; id?: string }[]; }) => { if (json.links?.next) { try { const url = new URL(json.links.next, "https://example.invalid"); const cursor = url.searchParams.get("page[after]"); if (cursor) { return cursor; } } catch { // Ignore malformed pagination links. } } const data = json.data; if (!Array.isArray(data) || data.length === 0) { return undefined; } const last = data[data.length - 1]; return last?.meta?.page?.cursor ?? last?.id; }; ================================================ FILE: src/providers/data/mas.ts ================================================ /** * MAS (Matrix Authentication Service) resource factory functions. * Each function returns a resource descriptor consumed by the MAS data provider in index.ts. * * Utility functions and helpers are in ./mas-utils.ts. * Action API calls (lock/unlock, deactivate, set-admin, etc.) are in ./mas-actions.ts. */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { DeleteParams, PaginationPayload, RaRecord, SortPayload, UpdateParams } from "react-admin"; import { MASCompatSessionListResponse, MASCompatSessionResource, MASOAuth2SessionListResponse, MASOAuth2SessionResource, MASPersonalSessionListResponse, MASPersonalSessionResource, MASRegistrationToken, MASRegistrationTokenListResponse, MASRegistrationTokenResource, MASRegistrationTokensResourceType, MASUpstreamOAuthLinkListResponse, MASUpstreamOAuthLinkResource, MASUpstreamOAuthProviderListResponse, MASUpstreamOAuthProviderResource, MASUserEmailListResponse, MASUserEmailResource, MASUserListResponse, MASUserResource, MASUserSessionListResponse, MASUserSessionResource, } from "../types"; import { jsonClient } from "../http"; import { normalizeTS } from "../../utils/date"; import { convertMASTokenToSynapse, detectAndSetMAS, filterUndefined, getMASBaseUrl, getMASNextPageCursor, getMASVersion, getMASTokenResource, isMAS, setIsMAS, toRfc3339, useIsMAS, } from "./mas-utils"; // Re-export utilities consumed by components and auth providers that import from this module. export { detectAndSetMAS, getMASBaseUrl, getMASNextPageCursor, getMASVersion, isMAS, setIsMAS, useIsMAS }; export const getMASRegistrationTokensResource = (): MASRegistrationTokensResourceType => ({ path: "/api/admin/v1/user-registration-tokens", isMAS: true, map: (token: MASRegistrationToken | MASRegistrationTokenResource) => { const resource = getMASTokenResource(token); const converted = convertMASTokenToSynapse(resource); return { ...converted, id: resource.id || converted.token }; }, data: "data", total: (json: MASRegistrationTokenListResponse) => json.meta?.count || 0, buildListQuery: (perPage: number, cursor: string | undefined, filter: Record<string, any>) => filterUndefined({ "page[first]": perPage, "page[after]": cursor, "filter[valid]": filter.valid, count: "true", }), create: (params: RaRecord) => ({ endpoint: "/api/admin/v1/user-registration-tokens", body: { token: params.token || undefined, usage_limit: params.uses_allowed ?? undefined, expires_at: toRfc3339(params.expiry_time), }, method: "POST", }), handleCreateResponse: (token: MASRegistrationToken) => { const resource = getMASTokenResource(token); const converted = convertMASTokenToSynapse(resource); return { ...converted, id: resource.id || converted.token }; }, delete: (params: DeleteParams) => ({ endpoint: `/api/admin/v1/user-registration-tokens/${params.id}/revoke`, method: "POST", }), update: (params: UpdateParams) => ({ endpoint: `/api/admin/v1/user-registration-tokens/${params.id}`, body: { usage_limit: params.data.uses_allowed ?? undefined, expires_at: toRfc3339(params.data.expiry_time), }, method: "PUT", }), }); // Helper shared between getMASUsersResource and getMASUsersAsMainResource const mapMASUserItem = (item: MASUserResource, homeserverId?: string) => { const homeserver = homeserverId || localStorage.getItem("home_server") || ""; return { id: item.id, // ULID — for mas_users data resource (ReferenceInput) mas_id: item.id, username: item.attributes.username, admin: item.attributes.admin, locked: !!item.attributes.locked_at, deactivated: !!item.attributes.deactivated_at, created_at: item.attributes.created_at, locked_at: item.attributes.locked_at, deactivated_at: item.attributes.deactivated_at, legacy_guest: item.attributes.legacy_guest, // Synapse-compatible fields name: `@${item.attributes.username}:${homeserver}`, }; }; export const getMASUsersResource = () => ({ path: "/api/admin/v1/users", isMAS: true, map: (item: MASUserResource | { data: MASUserResource }) => { const u = "data" in item ? item.data : item; return mapMASUserItem(u); }, data: "data", total: (json: MASUserListResponse) => json.meta?.count || 0, buildListQuery: (perPage: number, cursor: string | undefined, filter: Record<string, any>) => filterUndefined({ "page[first]": perPage, "page[after]": cursor, "filter[search]": filter.search, "filter[status]": filter.status, "filter[admin]": filter.admin, count: "true", }), create: (params: RaRecord) => ({ endpoint: "/api/admin/v1/users", body: { username: params.username }, method: "POST", }), update: (params: UpdateParams) => ({ endpoint: `/api/admin/v1/users/${params.id}`, method: "GET", }), handleCreateResponse: (item: { data: MASUserResource }) => mapMASUserItem(item.data), }); /** * MAS users resource for use as the main "users" resource in MAS mode. * Maps user IDs to Synapse-compatible format (@username:homeserver) so * all existing Synapse tabs (devices, rooms, connections, etc.) continue to work. * The MAS ULID is stored as mas_id for use by MAS action APIs. */ export const getMASUsersAsMainResource = () => ({ path: "/api/admin/v1/users", isMAS: true, map: (item: MASUserResource | { data: MASUserResource }) => { const u = "data" in item ? item.data : item; const homeserver = localStorage.getItem("home_server") || ""; return { ...mapMASUserItem(u, homeserver), id: `@${u.attributes.username}:${homeserver}`, }; }, // enrichList fetches Synapse user data for each MAS record in cursor-mode (default name ASC sort). // It must include ALL fields that getList (Synapse-first mode) populates from Synapse, // so both modes expose a unified record shape to the UI. enrichList: async (records: RaRecord[]) => { const synapseBaseUrl = localStorage.getItem("base_url") || ""; return Promise.all( records.map(async record => { try { const matrixId = encodeURIComponent(record.id); const { json } = await jsonClient(`${synapseBaseUrl}/_synapse/admin/v2/users/${matrixId}`); return { ...record, avatar_src: json.avatar_url ?? null, displayname: json.displayname ?? null, // Normalize across Synapse user endpoints before the value reaches the UI. creation_ts_ms: normalizeTS(json.creation_ts), is_guest: !!json.is_guest, shadow_banned: !!json.shadow_banned, erased: !!json.erased, deactivated: !!json.deactivated, suspended: !!json.suspended, }; } catch { return record; } }) ); }, getList: async (params: { pagination: PaginationPayload; sort: SortPayload; filter: Record<string, any> }) => { // Always use Synapse-first path so appservice/bot users (Synapse-only, no MAS account) // are included in the list alongside regular MAS-managed users. // Synapse-first path: fetch from Synapse v3 users API, then enrich with MAS data const synapseBaseUrl = localStorage.getItem("base_url") || ""; const masBaseUrl = getMASBaseUrl(); const homeserver = localStorage.getItem("home_server") || ""; const { page, perPage } = params.pagination; const { field, order } = params.sort; // Map sort field to Synapse order_by const orderByMap: Record<string, string> = { creation_ts_ms: "creation_ts", }; const orderBy = orderByMap[field] ?? field; const synapseQuery = filterUndefined({ from: (page - 1) * perPage, limit: perPage, order_by: orderBy, dir: order === "DESC" ? "b" : "f", name: params.filter.name || params.filter.search || undefined, guests: false, deactivated: params.filter.deactivated, locked: params.filter.locked, }); const synapseUrl = `${synapseBaseUrl}/_synapse/admin/v3/users?${new URLSearchParams( synapseQuery as Record<string, string> ).toString()}`; const { json } = await jsonClient(synapseUrl); const synapseUsers: any[] = json.users || []; const mergedRecords = await Promise.all( synapseUsers.map(async u => { // Extract local username from MXID (@user:homeserver) const mxid: string = u.name || ""; const username = mxid.startsWith("@") ? mxid.slice(1).split(":")[0] : mxid; // Synapse-only base record const synapseRecord = { id: mxid, name: mxid, mas_id: undefined as string | undefined, username, admin: !!u.admin, deactivated: !!u.deactivated, locked: false, created_at: undefined as string | undefined, locked_at: null as string | null, deactivated_at: null as string | null, legacy_guest: undefined as boolean | undefined, is_guest: !!u.is_guest, erased: !!u.erased, shadow_banned: !!u.shadow_banned, suspended: !!u.suspended, avatar_src: u.avatar_url ?? null, displayname: u.displayname ?? null, user_type: u.user_type ?? null, appservice_id: u.appservice_id ?? null, // Normalize across Synapse user endpoints before the value reaches the UI. creation_ts_ms: normalizeTS(u.creation_ts), }; if (!masBaseUrl) return synapseRecord; try { const masQuery = new URLSearchParams( filterUndefined({ "filter[search]": username, "page[first]": "50", count: "true" }) as Record< string, string > ).toString(); const { json: masJson } = await jsonClient(`${masBaseUrl}/api/admin/v1/users?${masQuery}`); const masUsers: MASUserResource[] = (masJson?.data as MASUserResource[]) || []; const masUser = masUsers.find(mu => mu.attributes.username === username); if (!masUser) return synapseRecord; const masBase = mapMASUserItem(masUser, homeserver); return { ...masBase, id: mxid, name: mxid, mas_id: masUser.id, username, admin: masBase.admin, deactivated: masBase.deactivated, locked: !!masUser.attributes.locked_at, created_at: masBase.created_at, locked_at: masUser.attributes.locked_at ?? null, deactivated_at: masUser.attributes.deactivated_at ?? null, legacy_guest: masBase.legacy_guest, is_guest: !!u.is_guest, erased: !!u.erased, shadow_banned: !!u.shadow_banned, suspended: !!u.suspended, avatar_src: u.avatar_url ?? null, displayname: u.displayname ?? null, user_type: u.user_type ?? null, appservice_id: u.appservice_id ?? null, // Normalize across Synapse user endpoints before the value reaches the UI. creation_ts_ms: normalizeTS(u.creation_ts), }; } catch { // MAS lookup failed — return Synapse-only record return synapseRecord; } }) ); return { data: mergedRecords, total: json.total || 0 }; }, data: "data", total: (json: MASUserListResponse) => json.meta?.count || 0, buildListQuery: (perPage: number, cursor: string | undefined, filter: Record<string, any>) => filterUndefined({ "page[first]": perPage, "page[after]": cursor, // support Synapse-style "name" search filter as well as MAS "search" "filter[search]": filter.name || filter.search, "filter[status]": filter.status, "filter[admin]": filter.admin !== undefined ? filter.admin : undefined, count: "true", }), getOne: async (params: { id: string | number }) => { const id = String(params.id); const username = id.startsWith("@") ? id.slice(1).split(":")[0] : id; const homeserver = localStorage.getItem("home_server") || ""; const masBaseUrl = getMASBaseUrl(); if (!masBaseUrl) throw new Error("MAS base URL not found"); // Fetch MAS user data const query = filterUndefined({ "page[first]": 10, "filter[search]": username, count: "true" }); const masUrl = `${masBaseUrl}/api/admin/v1/users?${new URLSearchParams(query as Record<string, string>).toString()}`; const { json } = await jsonClient(masUrl); const items: MASUserResource[] = (json?.data as MASUserResource[]) || []; const item = items.find(u => u.attributes.username === username); const synapseBaseUrl = localStorage.getItem("base_url") || ""; const mxid = `@${username}:${homeserver}`; const matrixId = encodeURIComponent(mxid); if (!item) { // User exists in Synapse but not in MAS (e.g., appservice-managed user). // Return a Synapse-only record so the edit page can still render. const { json: synapseJson } = await jsonClient(`${synapseBaseUrl}/_synapse/admin/v2/users/${matrixId}`); return { id: mxid, name: mxid, mas_id: undefined, username, admin: !!synapseJson.admin, deactivated: !!synapseJson.deactivated, locked: false, created_at: undefined, locked_at: null, deactivated_at: null, legacy_guest: undefined, is_guest: !!synapseJson.is_guest, erased: !!synapseJson.erased, shadow_banned: !!synapseJson.shadow_banned, suspended: !!synapseJson.suspended, avatar_src: synapseJson.avatar_url ?? null, displayname: synapseJson.displayname ?? null, user_type: synapseJson.user_type ?? null, appservice_id: synapseJson.appservice_id ?? null, creation_ts_ms: normalizeTS(synapseJson.creation_ts), }; } const masRecord = { ...mapMASUserItem(item, homeserver), id: mxid }; // Merge Synapse profile data (avatar, displayname, creation_ts_ms, suspended, shadow_banned) try { const { json: synapseJson } = await jsonClient(`${synapseBaseUrl}/_synapse/admin/v2/users/${matrixId}`); return { ...masRecord, avatar_src: synapseJson.avatar_url ?? null, displayname: synapseJson.displayname ?? null, user_type: synapseJson.user_type ?? null, appservice_id: synapseJson.appservice_id ?? null, // Normalize across Synapse user endpoints before the value reaches the UI. creation_ts_ms: normalizeTS(synapseJson.creation_ts), suspended: !!synapseJson.suspended, shadow_banned: !!synapseJson.shadow_banned, }; } catch { // Synapse data unavailable — return MAS-only record; UI gracefully shows what it has return masRecord; } }, create: (params: RaRecord) => ({ endpoint: "/api/admin/v1/users", body: { username: params.username || (String(params.id || "").startsWith("@") ? String(params.id).slice(1).split(":")[0] : params.id), }, method: "POST", }), handleCreateResponse: (item: { data: MASUserResource }) => { const homeserver = localStorage.getItem("home_server") || ""; return { ...mapMASUserItem(item.data, homeserver), id: `@${item.data.attributes.username}:${homeserver}` }; }, // Re-fetch after beforeUpdate has dispatched MAS action calls update: (params: UpdateParams) => ({ endpoint: `/api/admin/v1/users/${params.previousData.mas_id}`, method: "GET", }), }); export const getMASUserEmailsResource = () => ({ path: "/api/admin/v1/user-emails", isMAS: true, map: (item: MASUserEmailResource | { data: MASUserEmailResource }) => { const e = "data" in item ? item.data : item; return { id: e.id, email: e.attributes.email, user_id: e.attributes.user_id, created_at: e.attributes.created_at, }; }, data: "data", total: (json: MASUserEmailListResponse) => json.meta?.count || 0, buildListQuery: (perPage: number, cursor: string | undefined, filter: Record<string, any>) => filterUndefined({ "page[first]": perPage, "page[after]": cursor, "filter[user]": filter.user_id, "filter[email]": filter.email, count: "true", }), create: (params: RaRecord) => ({ endpoint: "/api/admin/v1/user-emails", body: { user_id: params.user_id, email: params.email }, method: "POST", }), handleCreateResponse: (item: { data: MASUserEmailResource }) => { const e = item.data; return { id: e.id, email: e.attributes.email, user_id: e.attributes.user_id, created_at: e.attributes.created_at }; }, delete: (params: DeleteParams) => ({ endpoint: `/api/admin/v1/user-emails/${params.id}`, method: "DELETE", }), }); export const getMASCompatSessionsResource = () => ({ path: "/api/admin/v1/compat-sessions", isMAS: true, map: (item: MASCompatSessionResource | { data: MASCompatSessionResource }) => { const s = "data" in item ? item.data : item; return { id: s.id, user_id: s.attributes.user_id, device_id: s.attributes.device_id, created_at: s.attributes.created_at, user_agent: s.attributes.user_agent, last_active_at: s.attributes.last_active_at, last_active_ip: s.attributes.last_active_ip, finished_at: s.attributes.finished_at, human_name: s.attributes.human_name, active: !s.attributes.finished_at, }; }, data: "data", total: (json: MASCompatSessionListResponse) => json.meta?.count || 0, buildListQuery: (perPage: number, cursor: string | undefined, filter: Record<string, any>) => filterUndefined({ "page[first]": perPage, "page[after]": cursor, "filter[user]": filter.user_id, "filter[status]": filter.status, count: "true", }), }); export const getMASAuth2SessionsResource = () => ({ path: "/api/admin/v1/oauth2-sessions", isMAS: true, map: (item: MASOAuth2SessionResource | { data: MASOAuth2SessionResource }) => { const s = "data" in item ? item.data : item; return { id: s.id, user_id: s.attributes.user_id, client_id: s.attributes.client_id, scope: s.attributes.scope, created_at: s.attributes.created_at, finished_at: s.attributes.finished_at, user_agent: s.attributes.user_agent, last_active_at: s.attributes.last_active_at, last_active_ip: s.attributes.last_active_ip, human_name: s.attributes.human_name, active: !s.attributes.finished_at, }; }, data: "data", total: (json: MASOAuth2SessionListResponse) => json.meta?.count || 0, buildListQuery: (perPage: number, cursor: string | undefined, filter: Record<string, any>) => filterUndefined({ "page[first]": perPage, "page[after]": cursor, "filter[user]": filter.user_id, "filter[status]": filter.status, count: "true", }), }); export const getMASPersonalSessionsResource = () => ({ path: "/api/admin/v1/personal-sessions", isMAS: true, map: (item: MASPersonalSessionResource | { data: MASPersonalSessionResource }) => { const s = "data" in item ? item.data : item; return { id: s.id, owner_user_id: s.attributes.owner_user_id, human_name: s.attributes.human_name, scope: s.attributes.scope, created_at: s.attributes.created_at, revoked_at: s.attributes.revoked_at, last_active_at: s.attributes.last_active_at, last_active_ip: s.attributes.last_active_ip, expires_at: s.attributes.expires_at, active: !s.attributes.revoked_at, }; }, data: "data", total: (json: MASPersonalSessionListResponse) => json.meta?.count || 0, buildListQuery: (perPage: number, cursor: string | undefined, filter: Record<string, any>) => filterUndefined({ "page[first]": perPage, "page[after]": cursor, "filter[user]": filter.user_id, "filter[status]": filter.status, count: "true", }), create: (params: RaRecord) => ({ endpoint: "/api/admin/v1/personal-sessions", body: filterUndefined({ actor_user_id: params.actor_user_id, scope: params.scope, human_name: params.human_name, expires_in: params.expires_in ? Number(params.expires_in) : undefined, }), method: "POST", }), handleCreateResponse: (item: { data: MASPersonalSessionResource }) => { const s = item.data; return { id: s.id, owner_user_id: s.attributes.owner_user_id, human_name: s.attributes.human_name, scope: s.attributes.scope, created_at: s.attributes.created_at, revoked_at: s.attributes.revoked_at, last_active_at: s.attributes.last_active_at, last_active_ip: s.attributes.last_active_ip, expires_at: s.attributes.expires_at, active: !s.attributes.revoked_at, access_token: s.attributes.access_token, }; }, delete: (params: DeleteParams) => ({ endpoint: `/api/admin/v1/personal-sessions/${params.id}/revoke`, method: "POST", }), }); export const getMASUserSessionsResource = () => ({ path: "/api/admin/v1/user-sessions", isMAS: true, map: (item: MASUserSessionResource | { data: MASUserSessionResource }) => { const s = "data" in item ? item.data : item; return { id: s.id, user_id: s.attributes.user_id, created_at: s.attributes.created_at, finished_at: s.attributes.finished_at, user_agent: s.attributes.user_agent, last_active_at: s.attributes.last_active_at, last_active_ip: s.attributes.last_active_ip, active: !s.attributes.finished_at, }; }, data: "data", total: (json: MASUserSessionListResponse) => json.meta?.count || 0, buildListQuery: (perPage: number, cursor: string | undefined, filter: Record<string, any>) => filterUndefined({ "page[first]": perPage, "page[after]": cursor, "filter[user]": filter.user_id, "filter[status]": filter.status, count: "true", }), }); export const getMASUpstreamOAuthLinksResource = () => ({ path: "/api/admin/v1/upstream-oauth-links", isMAS: true, map: (item: MASUpstreamOAuthLinkResource | { data: MASUpstreamOAuthLinkResource }) => { const l = "data" in item ? item.data : item; return { id: l.id, user_id: l.attributes.user_id, provider_id: l.attributes.provider_id, subject: l.attributes.subject, human_account_name: l.attributes.human_account_name, created_at: l.attributes.created_at, }; }, data: "data", total: (json: MASUpstreamOAuthLinkListResponse) => json.meta?.count || 0, buildListQuery: (perPage: number, cursor: string | undefined, filter: Record<string, any>) => filterUndefined({ "page[first]": perPage, "page[after]": cursor, "filter[user]": filter.user_id, "filter[provider]": filter.provider_id, count: "true", }), create: (params: RaRecord) => ({ endpoint: "/api/admin/v1/upstream-oauth-links", body: filterUndefined({ user_id: params.user_id, provider_id: params.provider_id, subject: params.subject, human_account_name: params.human_account_name || undefined, }), method: "POST", }), handleCreateResponse: (item: { data: MASUpstreamOAuthLinkResource }) => { const l = item.data; return { id: l.id, user_id: l.attributes.user_id, provider_id: l.attributes.provider_id, subject: l.attributes.subject, human_account_name: l.attributes.human_account_name, created_at: l.attributes.created_at, }; }, delete: (params: DeleteParams) => ({ endpoint: `/api/admin/v1/upstream-oauth-links/${params.id}`, method: "DELETE", }), }); export const getMASUpstreamOAuthProvidersResource = () => ({ path: "/api/admin/v1/upstream-oauth-providers", isMAS: true, map: (item: MASUpstreamOAuthProviderResource | { data: MASUpstreamOAuthProviderResource }) => { const p = "data" in item ? item.data : item; return { id: p.id, issuer: p.attributes.issuer, human_name: p.attributes.human_name, brand_name: p.attributes.brand_name, created_at: p.attributes.created_at, disabled_at: p.attributes.disabled_at, enabled: !p.attributes.disabled_at, }; }, data: "data", total: (json: MASUpstreamOAuthProviderListResponse) => json.meta?.count || 0, buildListQuery: (perPage: number, cursor: string | undefined, filter: Record<string, any>) => filterUndefined({ "page[first]": perPage, "page[after]": cursor, "filter[enabled]": filter.enabled, count: "true", }), }); ================================================ FILE: src/providers/data/scan.ts ================================================ /** * Virtual scan engine for client-side post-filter pagination. * * Used by system-users and reverse-search list modes, which need to scan the full * backend dataset, apply a local filter, and paginate the filtered virtual dataset. */ import { GetListResult, RaRecord } from "react-admin"; export const SYSTEM_USERS_SCAN_CHUNK_SIZE = 250; export interface SystemUsersScanCacheEntry { // Whether the backend list has been fully scanned for this filter/sort combination. backendExhausted: boolean; // Raw backend offset already consumed while building the filtered virtual dataset. backendOffset: number; // Filtered users accumulated so later pages can reuse prior scan work. filteredRecords: RaRecord[]; } export const systemUsersScanCache = new Map<string, SystemUsersScanCacheEntry>(); export const clearSystemUsersScanCache = () => systemUsersScanCache.clear(); export const reverseSearchScanCache = new Map<string, SystemUsersScanCacheEntry>(); export const clearReverseSearchScanCache = () => reverseSearchScanCache.clear(); /** * Build a stable cache key from any set of filter/sort parameters. * Callers are responsible for pre-filtering undefined values out of query params * before passing them in (e.g., using filterUndefined(query)). */ export const buildScanCacheKey = (params: Record<string, unknown>): string => JSON.stringify(params); let _scanNotifier: ((key: string) => void) | null = null; /** * Set the notification function called when a scan takes multiple requests. * Should be called once at app startup, e.g. from setDataProviderNotifier. */ export const setScanNotifier = (fn: ((key: string) => void) | null) => { _scanNotifier = fn; }; export interface RunVirtualScanOpts { cache: Map<string, SystemUsersScanCacheEntry>; cacheKey: string; /** First record index (inclusive) of the desired page. */ pageStart: number; /** Last record index (exclusive) of the desired page. */ pageEnd: number; perPage: number; /** Fetch a page of raw records from the backend at the given offset/limit. */ fetchPage: ( offset: number, limit: number ) => Promise<{ rawCount: number; records: RaRecord[]; serverTotal: number; }>; /** Return true to include a record in the filtered virtual dataset. */ filterFn: (record: RaRecord) => boolean; /** i18n key passed to the notifier after 3 consecutive loop requests. */ notifyKey?: string; /** Maximum number of backend fetches per call (default: unlimited). */ maxRequests?: number; /** Optional post-processor applied to the final page slice (e.g. MAS enrichment). */ enrichList?: (records: RaRecord[]) => Promise<RaRecord[]>; } /* eslint-disable @typescript-eslint/no-explicit-any */ export async function runVirtualScan(opts: RunVirtualScanOpts): Promise<GetListResult> { const { cache, cacheKey, pageStart, pageEnd, perPage, fetchPage, filterFn, notifyKey, maxRequests, enrichList } = opts; const nextPageThreshold = pageEnd + 1; const effectiveMaxRequests = maxRequests ?? Infinity; const cachedScan = cache.get(cacheKey); const scanState: SystemUsersScanCacheEntry = cachedScan || { backendExhausted: false, backendOffset: 0, filteredRecords: [], }; let loopRequestCount = 0; while ( !scanState.backendExhausted && scanState.filteredRecords.length < nextPageThreshold && loopRequestCount < effectiveMaxRequests ) { loopRequestCount++; if (loopRequestCount === 3 && notifyKey) { _scanNotifier?.(notifyKey); } const backendLimit = Math.max(perPage, SYSTEM_USERS_SCAN_CHUNK_SIZE); const { rawCount, records, serverTotal } = await fetchPage(scanState.backendOffset, backendLimit); const filteredData = records.filter(filterFn); scanState.filteredRecords.push(...filteredData); scanState.backendOffset += rawCount; scanState.backendExhausted = rawCount === 0 || scanState.backendOffset >= serverTotal || rawCount < backendLimit; } cache.set(cacheKey, scanState); let pagedData: RaRecord[] = scanState.filteredRecords.slice(pageStart, pageEnd); if (enrichList) { pagedData = await enrichList(pagedData); } const hasNextPage = scanState.backendExhausted ? scanState.filteredRecords.length > pageEnd : scanState.filteredRecords.length >= nextPageThreshold; return { data: pagedData as any[], total: scanState.backendExhausted ? scanState.filteredRecords.length : undefined, pageInfo: { hasPreviousPage: pageStart > 0, hasNextPage, }, }; } ================================================ FILE: src/providers/data/synapse-actions.ts ================================================ /** * Standalone Synapse admin API action helpers. * These are direct async wrappers around Synapse admin endpoints, used as * custom dataProvider methods and from lifecycle callbacks. */ import { HttpError, Identifier } from "react-admin"; import { jsonClient } from "../http"; import { AdminClientConfig, AccountDataModel, DeleteMediaParams, DeleteMediaResult, ExperimentalFeaturesModel, RateLimitsModel, UsernameAvailabilityResult, } from "../types"; import { returnMXID } from "../../utils/mxid"; export const deleteMedia = async ({ before_ts, size_gt = 0, keep_profiles = true, }: DeleteMediaParams): Promise<DeleteMediaResult> => { const base_url = localStorage.getItem("base_url"); const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/media/delete?before_ts=${before_ts}&size_gt=${size_gt}&keep_profiles=${keep_profiles}`, { method: "POST" } ); return json as DeleteMediaResult; }; export const purgeRemoteMedia = async ({ before_ts, }: Pick<DeleteMediaParams, "before_ts">): Promise<DeleteMediaResult> => { const base_url = localStorage.getItem("base_url"); const { json } = await jsonClient(`${base_url}/_synapse/admin/v1/purge_media_cache?before_ts=${before_ts}`, { method: "POST", }); return json as DeleteMediaResult; }; export const getFeatures = async (id: Identifier): Promise<ExperimentalFeaturesModel> => { const base_url = localStorage.getItem("base_url"); const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/experimental_features/${encodeURIComponent(returnMXID(id))}` ); return json.features as ExperimentalFeaturesModel; }; export const updateFeatures = async (id: Identifier, features: ExperimentalFeaturesModel): Promise<void> => { const base_url = localStorage.getItem("base_url"); await jsonClient(`${base_url}/_synapse/admin/v1/experimental_features/${encodeURIComponent(returnMXID(id))}`, { method: "PUT", body: JSON.stringify({ features }), }); }; export const getRateLimits = async (id: Identifier): Promise<RateLimitsModel> => { const base_url = localStorage.getItem("base_url"); const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/override_ratelimit` ); return json as RateLimitsModel; }; export const setRateLimits = async (id: Identifier, rateLimits: RateLimitsModel): Promise<void> => { const filtered = Object.fromEntries( Object.entries(rateLimits).filter(([_key, value]) => value !== null && value !== undefined) ); const base_url = localStorage.getItem("base_url"); const endpoint_url = `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/override_ratelimit`; if (Object.keys(filtered).length === 0) { await jsonClient(endpoint_url, { method: "DELETE" }); return; } await jsonClient(endpoint_url, { method: "POST", body: JSON.stringify(filtered) }); }; export const getSentInviteCount = async (id: Identifier, fromTs = 0): Promise<number> => { const base_url = localStorage.getItem("base_url"); const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/sent_invite_count?from_ts=${fromTs}` ); return json.invite_count as number; }; export const getCumulativeJoinedRoomCount = async (id: Identifier, fromTs = 0): Promise<number> => { const base_url = localStorage.getItem("base_url"); const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/cumulative_joined_room_count?from_ts=${fromTs}` ); return json.cumulative_joined_room_count as number; }; export const getAccountData = async (id: Identifier): Promise<AccountDataModel> => { const base_url = localStorage.getItem("base_url"); const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/accountdata` ); return json as AccountDataModel; }; export const checkUsernameAvailability = async (username: string): Promise<UsernameAvailabilityResult> => { const base_url = localStorage.getItem("base_url"); try { const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/username_available?username=${encodeURIComponent(username)}` ); return json as UsernameAvailabilityResult; } catch (error) { if (error instanceof HttpError) { return { available: false, error: error.body.error, errcode: error.body.errcode } as UsernameAvailabilityResult; } throw error; } }; export const blockRoom = async (roomId: string, block: boolean) => { const base_url = localStorage.getItem("base_url"); try { await jsonClient(`${base_url}/_synapse/admin/v1/rooms/${encodeURIComponent(roomId)}/block`, { method: "PUT", body: JSON.stringify({ block }), }); return { success: true }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const getRoomBlockStatus = async (roomId: string) => { const base_url = localStorage.getItem("base_url"); try { const { json } = await jsonClient(`${base_url}/_synapse/admin/v1/rooms/${encodeURIComponent(roomId)}/block`); return { success: true, block: json.block as boolean }; } catch (error) { if (error instanceof HttpError) { return { success: false, block: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const deleteDevices = async (user_id: string, devices: string[]) => { const base_url = localStorage.getItem("base_url"); try { await jsonClient(`${base_url}/_synapse/admin/v2/users/${encodeURIComponent(user_id)}/delete_devices`, { method: "POST", body: JSON.stringify({ devices }), }); return { success: true }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const joinUserToRoom = async (room_id: string, user_id: string) => { const base_url = localStorage.getItem("base_url"); try { await jsonClient(`${base_url}/_synapse/admin/v1/join/${encodeURIComponent(room_id)}`, { method: "POST", body: JSON.stringify({ user_id }), }); return { success: true }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const makeRoomAdmin = async (room_id: string, user_id: string) => { const base_url = localStorage.getItem("base_url"); try { await jsonClient(`${base_url}/_synapse/admin/v1/rooms/${encodeURIComponent(room_id)}/make_room_admin`, { method: "POST", body: JSON.stringify({ user_id }), }); return { success: true }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const suspendUser = async (id: Identifier, suspendValue: boolean) => { const base_url = localStorage.getItem("base_url"); try { await jsonClient(`${base_url}/_synapse/admin/v1/suspend/${encodeURIComponent(returnMXID(id))}`, { method: "PUT", body: JSON.stringify({ suspend: suspendValue }), }); return { success: true }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const shadowBanUser = async (id: Identifier, shadowBan: boolean) => { const base_url = localStorage.getItem("base_url"); try { await jsonClient(`${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/shadow_ban`, { method: shadowBan ? "POST" : "DELETE", }); return { success: true }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const resetPassword = async (id: Identifier, newPassword: string, logoutDevices = true) => { const base_url = localStorage.getItem("base_url"); try { await jsonClient(`${base_url}/_synapse/admin/v1/reset_password/${encodeURIComponent(returnMXID(id))}`, { method: "POST", body: JSON.stringify({ new_password: newPassword, logout_devices: logoutDevices }), }); return { success: true }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const loginAsUser = async (id: Identifier, validUntilMs?: number) => { const base_url = localStorage.getItem("base_url"); try { const body: Record<string, unknown> = {}; if (validUntilMs !== undefined) { body.valid_until_ms = validUntilMs; } const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/login`, { method: "POST", body: JSON.stringify(body), } ); return { success: true, access_token: json.access_token }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const eraseUser = async (id: Identifier) => { const base_url = localStorage.getItem("base_url"); try { await jsonClient(`${base_url}/_synapse/admin/v1/deactivate/${encodeURIComponent(returnMXID(id))}`, { method: "POST", body: JSON.stringify({ erase: true }), }); return { success: true }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const findUserByThreepid = async (medium: string, address: string) => { const base_url = localStorage.getItem("base_url"); try { const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/threepid/${encodeURIComponent(medium)}/users/${encodeURIComponent(address)}` ); return { success: true, user_id: json.user_id as string }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const renewAccountValidity = async (userId: string, expirationTs?: number, enableRenewalEmails = true) => { const base_url = localStorage.getItem("base_url"); try { const body: Record<string, unknown> = { user_id: returnMXID(userId), enable_renewal_emails: enableRenewalEmails, }; if (expirationTs !== undefined) { body.expiration_ts = expirationTs; } const { json } = await jsonClient(`${base_url}/_synapse/admin/v1/account_validity/validity`, { method: "POST", body: JSON.stringify(body), }); return { success: true, expiration_ts: json.expiration_ts as number }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const allowCrossSigningReplacement = async (userId: string) => { const base_url = localStorage.getItem("base_url"); try { const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(userId))}/_allow_cross_signing_replacement_without_uia`, { method: "POST", body: JSON.stringify({}) } ); return { success: true, updatable_without_uia_before_ms: json.updatable_without_uia_before_ms as number }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const findUserByAuthProvider = async (provider: string, externalId: string) => { const base_url = localStorage.getItem("base_url"); try { const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/auth_providers/${encodeURIComponent(provider)}/users/${encodeURIComponent(externalId)}` ); return { success: true, user_id: json.user_id as string }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const getEventByTimestamp = async (roomId: string, ts: number, dir: "f" | "b" = "f") => { const base_url = localStorage.getItem("base_url"); try { const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/rooms/${encodeURIComponent(roomId)}/timestamp_to_event?ts=${ts}&dir=${dir}` ); return { success: true, event_id: json.event_id as string, origin_server_ts: json.origin_server_ts as number }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const getEventContext = async (roomId: string, eventId: string, limit = 5) => { const base_url = localStorage.getItem("base_url"); try { const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/rooms/${encodeURIComponent(roomId)}/context/${encodeURIComponent(eventId)}?limit=${limit}` ); return { success: true, data: json }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const getRoomMessages = async ( roomId: string, params: { from: string; to?: string; limit?: number; dir?: "f" | "b"; filter?: string } ) => { const base_url = localStorage.getItem("base_url"); try { const query = new URLSearchParams({ from: params.from }); if (params.to) query.set("to", params.to); if (params.limit) query.set("limit", String(params.limit)); if (params.dir) query.set("dir", params.dir); if (params.filter) query.set("filter", params.filter); const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/rooms/${encodeURIComponent(roomId)}/messages?${query}` ); return { success: true, data: json }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const getRoomHierarchy = async ( roomId: string, params?: { from?: string; limit?: number; max_depth?: number } ) => { const base_url = localStorage.getItem("base_url"); try { const query = new URLSearchParams(); if (params?.from) query.set("from", params.from); if (params?.limit) query.set("limit", String(params.limit)); if (params?.max_depth !== undefined) query.set("max_depth", String(params.max_depth)); const qs = query.toString(); const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/rooms/${encodeURIComponent(roomId)}/hierarchy${qs ? `?${qs}` : ""}` ); return { success: true, data: json }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const getAdminClientConfig = async () => { const base_url = localStorage.getItem("base_url"); const userId = localStorage.getItem("user_id"); if (!userId) return { return_soft_failed_events: false, return_policy_server_spammy_events: false }; try { const { json } = await jsonClient( `${base_url}/_matrix/client/v3/user/${encodeURIComponent(userId)}/account_data/io.element.synapse.admin_client_config` ); return json as AdminClientConfig; } catch { return { return_soft_failed_events: false, return_policy_server_spammy_events: false }; } }; export const setAdminClientConfig = async (config: AdminClientConfig) => { const base_url = localStorage.getItem("base_url"); const userId = localStorage.getItem("user_id"); if (!userId) throw new Error("No user_id"); await jsonClient( `${base_url}/_matrix/client/v3/user/${encodeURIComponent(userId)}/account_data/io.element.synapse.admin_client_config`, { method: "PUT", body: JSON.stringify(config) } ); }; export const deleteRoomMedia = async ( roomId: string, onProgress?: (current: number, total: number) => void ): Promise<{ total: number }> => { const base_url = localStorage.getItem("base_url"); const { json: listJson } = await jsonClient(`${base_url}/_synapse/admin/v1/room/${encodeURIComponent(roomId)}/media`); const localMedia: string[] = listJson.local ?? []; // Filter before counting so the progress counter matches actual deletions. const validMedia = localMedia.filter(mxc => { if (!mxc.startsWith("mxc://")) return false; const parts = mxc.split("/"); return Boolean(parts[2] && parts[3]); }); const total = validMedia.length; onProgress?.(0, total); let current = 0; for (const mxc of validMedia) { const parts = mxc.split("/"); const serverName = parts[2]; const mediaId = parts[3]; await jsonClient( `${base_url}/_synapse/admin/v1/media/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`, { method: "DELETE" } ); current++; onProgress?.(current, total); } return { total }; }; export const quarantineRoomMedia = async (roomId: string) => { const base_url = localStorage.getItem("base_url"); try { const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/room/${encodeURIComponent(roomId)}/media/quarantine`, { method: "POST", body: "{}" } ); return { success: true, num_quarantined: json.num_quarantined as number }; } catch (error) { if (error instanceof HttpError) { return { success: false, num_quarantined: 0, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const quarantineUserMedia = async (userId: string) => { const base_url = localStorage.getItem("base_url"); try { const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/user/${encodeURIComponent(userId)}/media/quarantine`, { method: "POST", body: "{}" } ); return { success: true, num_quarantined: json.num_quarantined as number }; } catch (error) { if (error instanceof HttpError) { return { success: false, num_quarantined: 0, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const purgeHistory = async (roomId: string, purge_up_to_ts: number, delete_local_events: boolean) => { const base_url = localStorage.getItem("base_url"); try { const { json } = await jsonClient(`${base_url}/_synapse/admin/v1/purge_history/${encodeURIComponent(roomId)}`, { method: "POST", body: JSON.stringify({ purge_up_to_ts, delete_local_events }), }); return { success: true, purge_id: json.purge_id as string }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const getPurgeHistoryStatus = async (purgeId: string) => { const base_url = localStorage.getItem("base_url"); try { const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/purge_history_status/${encodeURIComponent(purgeId)}` ); return { success: true, status: json.status as string, error: json.error as string | undefined }; } catch (error) { if (error instanceof HttpError) { return { success: false, status: "failed", error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const deleteUserMedia = async (id: Identifier): Promise<DeleteMediaResult> => { const base_url = localStorage.getItem("base_url"); const { json } = await jsonClient(`${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/media`, { method: "DELETE", }); return json as DeleteMediaResult; }; export const fetchEvent = async (eventId: string) => { const base_url = localStorage.getItem("base_url"); const { json } = await jsonClient(`${base_url}/_synapse/admin/v1/fetch_event/${encodeURIComponent(eventId)}`); return json.event; }; export const redactUserEvents = async (id: Identifier) => { const base_url = localStorage.getItem("base_url"); const { json } = await jsonClient(`${base_url}/_synapse/admin/v1/user/${encodeURIComponent(returnMXID(id))}/redact`, { method: "POST", body: JSON.stringify({ rooms: [] }), }); return { redact_id: json.redact_id as string }; }; export const deleteRoom = async (roomId: string, block: boolean) => { const base_url = localStorage.getItem("base_url"); try { const { json } = await jsonClient(`${base_url}/_synapse/admin/v2/rooms/${encodeURIComponent(roomId)}`, { method: "DELETE", body: JSON.stringify({ block }), }); return { success: true, delete_id: json.delete_id as string }; } catch (error) { if (error instanceof HttpError) { return { success: false, error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const getRoomDeleteStatus = async (deleteId: string) => { const base_url = localStorage.getItem("base_url"); try { const { json } = await jsonClient( `${base_url}/_synapse/admin/v2/rooms/delete_status/${encodeURIComponent(deleteId)}` ); return { success: true, status: json.status as string, error: json.error as string | undefined }; } catch (error) { if (error instanceof HttpError) { return { success: false, status: "failed", error: error.body.error, errcode: error.body.errcode }; } throw error; } }; export const getRedactStatus = async (redactId: string) => { const base_url = localStorage.getItem("base_url"); try { const { json } = await jsonClient( `${base_url}/_synapse/admin/v1/user/redact_status/${encodeURIComponent(redactId)}` ); return { success: true, status: json.status as string, failed_redactions: json.failed_redactions as Record<string, string>, }; } catch (error) { if (error instanceof HttpError) { return { success: false, status: "failed", failed_redactions: {}, error: error.body.error, errcode: error.body.errcode, }; } throw error; } }; ================================================ FILE: src/providers/data/synapse.ts ================================================ import { DeleteParams, Identifier, RaRecord, fetchUtils } from "react-admin"; import { DatabaseRoomStatistic, Destination, DestinationRoom, ScheduledTask, Device, EventReport, ForwardExtremity, Membership, Pusher, RaServerNotice, RegistrationToken, Room, RoomState, SynapseRegistrationTokensResourceType, User, UserMedia, UserMediaStatistic, Whois, } from "../types"; import { returnMXID } from "../../utils/mxid"; import { normalizeTS } from "../../utils/date"; /** * Get Synapse server version via /_synapse/admin/v1/server_version */ export const getServerVersion = async (baseUrl: string): Promise<string> => { const response = await fetchUtils.fetchJson(`${baseUrl}/_synapse/admin/v1/server_version`, { method: "GET" }); return response.json.server_version; }; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ export const CACHED_MANY_REF: Record<string, any> = {}; export const invalidateManyRefCache = (pattern: string) => { for (const key of Object.keys(CACHED_MANY_REF)) { if (key.includes(pattern)) { delete CACHED_MANY_REF[key]; } } }; export const synapseRegistrationTokensResource: SynapseRegistrationTokensResourceType = { path: "/_synapse/admin/v1/registration_tokens", isMAS: false, map: (rt: RegistrationToken) => ({ ...rt, id: rt.token }), data: "registration_tokens", total: json => json.registration_tokens.length, create: (params: RaRecord) => ({ endpoint: "/_synapse/admin/v1/registration_tokens/new", body: params, method: "POST", }), // Synapse accepts Unix timestamps as-is delete: (params: DeleteParams) => ({ endpoint: `/_synapse/admin/v1/registration_tokens/${params.id}`, }), }; export const synapseResourceMap = { users: { path: "/_synapse/admin/v2/users", listPath: "/_synapse/admin/v3/users", map: (u: User) => ({ ...u, id: returnMXID(u.name), avatar_src: u.avatar_url ? u.avatar_url : undefined, is_guest: !!u.is_guest, admin: !!u.admin, deactivated: !!u.deactivated, shadow_banned: !!u.shadow_banned, // Normalize across Synapse user endpoints before the value reaches the UI. creation_ts_ms: normalizeTS(u.creation_ts), }), data: "users", total: (json: { total: number }) => json.total, create: (data: RaRecord) => ({ endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(returnMXID(data.id))}`, body: data, method: "PUT", }), delete: (params: DeleteParams) => ({ endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(returnMXID(params.id))}`, body: { erase: true }, method: "POST", }), }, rooms: { path: "/_synapse/admin/v1/rooms", map: (r: Room) => ({ ...r, id: r.room_id, alias: r.canonical_alias, members: r.joined_members, is_encrypted: !!r.encryption, federatable: !!r.federatable, public: !!r.public, }), data: "rooms", total: (json: { total_rooms: number }) => json.total_rooms, delete: (params: DeleteParams) => ({ endpoint: `/_synapse/admin/v2/rooms/${params.id}`, body: { block: params.meta?.block ?? false }, }), }, reports: { path: "/_synapse/admin/v1/event_reports", map: (er: EventReport) => ({ ...er }), data: "event_reports", total: (json: { total: number }) => json.total, }, devices: { map: (d: Device) => ({ ...d, id: d.device_id }), data: "devices", total: (json: { total: number }) => json.total, reference: (id: Identifier) => ({ endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(id)}/devices`, }), delete: (params: DeleteParams) => ({ endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(params.previousData.user_id)}/devices/${params.id}`, }), }, connections: { path: "/_synapse/admin/v1/whois", map: (c: Whois) => ({ ...c, id: c.user_id }), data: "connections", }, room_members: { map: (m: string) => ({ id: m }), reference: (id: Identifier) => ({ endpoint: `/_synapse/admin/v1/rooms/${id}/members` }), data: "members", total: (json: { total: number }) => json.total, }, room_media: { map: (mediaId: string) => ({ id: mediaId.replace("mxc://" + localStorage.getItem("home_server") + "/", ""), media_id: mediaId.replace("mxc://" + localStorage.getItem("home_server") + "/", ""), }), reference: (id: Identifier) => ({ endpoint: `/_synapse/admin/v1/room/${id}/media` }), total: (json: { total: number }) => json.total, data: "local", delete: (params: DeleteParams) => ({ endpoint: `/_synapse/admin/v1/media/${localStorage.getItem("home_server")}/${params.id}`, }), }, room_state: { map: (rs: RoomState) => ({ ...rs, id: rs.event_id }), reference: (id: Identifier) => ({ endpoint: `/_synapse/admin/v1/rooms/${id}/state` }), data: "state", total: (json: { state: unknown[] }) => json.state.length, }, pushers: { map: (p: Pusher) => ({ ...p, id: p.pushkey }), reference: (id: Identifier) => ({ endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/pushers`, }), data: "pushers", total: (json: { total: number }) => json.total, }, joined_rooms: { map: (jr: string) => ({ id: jr }), reference: (id: Identifier) => ({ endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/joined_rooms`, }), data: "joined_rooms", total: (json: { total: number }) => json.total, }, memberships: { map: (m: Membership) => ({ ...m }), reference: (id: Identifier) => ({ endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/memberships`, }), data: "memberships", total: (json: { total: number }) => json.total, }, users_media: { map: (um: UserMedia) => ({ ...um, id: um.media_id }), reference: (id: Identifier) => ({ endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/media`, }), data: "media", total: (json: { total: number }) => json.total, delete: (params: DeleteParams) => ({ endpoint: `/_synapse/admin/v1/media/${localStorage.getItem("home_server")}/${params.id}`, }), }, protect_media: { map: (pm: UserMedia) => ({ id: pm.media_id }), create: (params: UserMedia) => ({ endpoint: `/_synapse/admin/v1/media/protect/${params.media_id}`, method: "POST", response: (data: RaRecord) => ({ ...data, safe_from_quarantine: true }), }), delete: (params: DeleteParams) => ({ endpoint: `/_synapse/admin/v1/media/unprotect/${params.id}`, method: "POST", response: (data: RaRecord) => ({ ...data, safe_from_quarantine: false }), }), }, quarantine_media: { map: (qm: UserMedia) => ({ id: qm.media_id }), create: (params: UserMedia) => ({ endpoint: `/_synapse/admin/v1/media/quarantine/${localStorage.getItem("home_server")}/${params.media_id}`, method: "POST", response: (data: RaRecord) => ({ ...data, quarantined_by: localStorage.getItem("user_id") || "admin" }), }), delete: (params: DeleteParams) => ({ endpoint: `/_synapse/admin/v1/media/unquarantine/${localStorage.getItem("home_server")}/${params.id}`, method: "POST", response: (data: RaRecord) => ({ ...data, quarantined_by: "" }), }), }, servernotices: { map: (n: { event_id: string }) => ({ id: n.event_id }), create: (data: RaServerNotice) => ({ endpoint: "/_synapse/admin/v1/send_server_notice", body: { user_id: returnMXID(data.id), content: { msgtype: "m.text", body: data.body }, }, method: "POST", }), }, database_room_statistics: { path: "/_synapse/admin/v1/statistics/database/rooms", map: (drs: DatabaseRoomStatistic) => ({ ...drs, id: drs.room_id }), data: "rooms", total: (json: { rooms: DatabaseRoomStatistic[] }) => json.rooms.length, noQueryParams: true, // Synapse returns 500 when the stats table hasn't been populated yet. // Treat it as an empty result so the empty state renders instead of an error. // See: https://github.com/element-hq/synapse/issues/19561 ignoredErrors: [500], }, user_media_statistics: { path: "/_synapse/admin/v1/statistics/users/media", map: (usms: UserMediaStatistic) => ({ ...usms, id: returnMXID(usms.user_id) }), data: "users", total: (json: { total: number }) => json.total, }, forward_extremities: { map: (fe: ForwardExtremity) => ({ ...fe, id: fe.event_id }), reference: (id: Identifier) => ({ endpoint: `/_synapse/admin/v1/rooms/${id}/forward_extremities`, }), data: "results", total: (json: { count: number }) => json.count, delete: (params: DeleteParams) => ({ endpoint: `/_synapse/admin/v1/rooms/${params.id}/forward_extremities`, }), }, destinations: { path: "/_synapse/admin/v1/federation/destinations", map: (dst: Destination) => ({ ...dst, id: dst.destination }), data: "destinations", total: (json: { total: number }) => json.total, delete: (params: DeleteParams) => ({ endpoint: `/_synapse/admin/v1/federation/destinations/${params.id}/reset_connection`, method: "POST", }), }, destination_rooms: { map: (dstroom: DestinationRoom) => ({ ...dstroom, id: dstroom.room_id }), reference: (id: Identifier) => ({ endpoint: `/_synapse/admin/v1/federation/destinations/${id}/rooms`, }), data: "rooms", total: (json: { total: number }) => json.total, }, scheduled_tasks: { path: "/_synapse/admin/v1/scheduled_tasks", map: (st: ScheduledTask) => ({ ...st }), data: "scheduled_tasks", total: (json: { scheduled_tasks: unknown[] }) => json.scheduled_tasks.length, }, }; ================================================ FILE: src/providers/http.ts ================================================ import { HttpError, Options, fetchUtils } from "react-admin"; import { refreshAccessToken } from "./matrix"; import { GetConfig } from "../utils/config"; import { MatrixError, displayError } from "../utils/error"; import createLogger from "../utils/logger"; const log = createLogger("http"); // Singleton refresh promise — prevents multiple concurrent refresh requests let ongoingRefresh: Promise<boolean> | null = null; // Adds the access token to all requests export const jsonClient = async (url: string, options: Options = {}) => { // Check if token needs refresh before making the request const access_token_expires_at = localStorage.getItem("access_token_expires_at"); const refreshToken = localStorage.getItem("refresh_token"); if (access_token_expires_at && refreshToken) { const expiresAt = parseInt(access_token_expires_at, 10); const now = Date.now(); const timeUntilExpiry = expiresAt - now; // Refresh if token has expired or will expire in less than 2 minutes if (timeUntilExpiry < 120000) { log.debug("proactive token refresh", { status: timeUntilExpiry <= 0 ? "expired" : "expiring soon", timeUntilExpiry, }); if (!ongoingRefresh) { ongoingRefresh = refreshAccessToken().finally(() => { ongoingRefresh = null; }); } await ongoingRefresh; } } const token = localStorage.getItem("access_token"); log.debug(url); options.credentials = GetConfig().corsCredentials as RequestCredentials; if (token !== null) { options.user = { authenticated: true, token: `Bearer ${token}`, }; } try { const response = await fetchUtils.fetchJson(url, options); return response; } catch (err) { const error = err as HttpError; const errorStatus = error.status; const errorBody = error.body as MatrixError; const errMsg = errorBody?.errcode ? displayError(errorBody.errcode, errorStatus, errorBody.error) : displayError("M_INVALID", errorStatus, error.message); return Promise.reject(new HttpError(errMsg, errorStatus, errorBody)); } }; export const etkeClient = async (url: string, locale: string, options: RequestInit = {}) => { const token = localStorage.getItem("access_token"); if (!token) { return Promise.reject(new Error("Missing access token")); } const headers = new Headers(options.headers || {}); headers.set("Authorization", `Bearer ${token}`); if (!headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } if (locale) { headers.set("Accept-Language", locale); } return fetch(url, { ...options, headers }); }; ================================================ FILE: src/providers/matrix.test.ts ================================================ import { Mock } from "vitest"; import { fetchUtils } from "react-admin"; import { isValidBaseUrl, splitMxid, resolveBaseUrlWithWellKnown, getAuthMetadata, uploadMedia } from "./matrix"; import { jsonClient } from "./http"; vi.mock("react-admin", () => ({ fetchUtils: { fetchJson: vi.fn(), }, })); vi.mock("./http", () => ({ jsonClient: vi.fn(), })); describe("splitMxid", () => { test.each([ // valid — hostname ["@name:domain.tld", { name: "name", domain: "domain.tld" }], ["@name:domain.tld:8448", { name: "name", domain: "domain.tld:8448" }], // valid — single-label / localhost ["@name:localhost", { name: "name", domain: "localhost" }], ["@name:localhost:8448", { name: "name", domain: "localhost:8448" }], // valid — IPv4 ["@name:192.168.1.1", { name: "name", domain: "192.168.1.1" }], ["@name:192.168.1.1:8448", { name: "name", domain: "192.168.1.1:8448" }], // valid — IPv6 ["@name:[::1]", { name: "name", domain: "[::1]" }], ["@name:[::1]:8448", { name: "name", domain: "[::1]:8448" }], ["@name:[2001:db8::1]", { name: "name", domain: "[2001:db8::1]" }], ["@name:[2001:db8::1]:8448", { name: "name", domain: "[2001:db8::1]:8448" }], // invalid ["foo", undefined], ["@noserver", undefined], ["notanmxid:domain.tld", undefined], ])("splitMxid(%s)", (mxid, expected) => { expect(splitMxid(mxid)).toEqual(expected); }); }); describe("isValidBaseUrl", () => { test.each([ // valid — hostname ["http://foo.bar", true], ["https://foo.bar", true], ["https://foo.bar:1234", true], ["https://foo.bar/", true], ["https://foo.bar:1234/", true], // valid — IPv4 ["http://192.168.1.1", true], ["https://192.168.1.1:8448", true], // valid — IPv6 ["http://[::1]", true], ["https://[::1]", true], ["http://[::1]:8448", true], ["https://[::1]:8448/", true], ["https://[2001:db8::1]", true], ["https://[2001:db8::1]:443", true], ["https://[2001:db8::1]:443/", true], ["http://[2001:db8:cafe::1]:7000", true], // invalid — missing / wrong protocol [undefined, false], [null, false], ["", false], [{}, false], ["foo.bar", false], ["ftp://foo.bar", false], ["http:/foo.bar", false], // invalid — has path ["http://foo.bar/path", false], ["https://[::1]/path", false], // invalid — bare IPv6 without brackets ["http://::1", false], ])("isValidBaseUrl(%s) === %s", (url, expected) => { expect(isValidBaseUrl(url)).toBe(expected); }); }); describe("resolveBaseUrlWithWellKnown", () => { const fetchJsonMock = fetchUtils.fetchJson as Mock; afterEach(() => { fetchJsonMock.mockReset(); }); it("returns well-known base_url when present", async () => { fetchJsonMock.mockResolvedValueOnce({ json: { "m.homeserver": { base_url: "https://api.example.com" }, }, }); await expect(resolveBaseUrlWithWellKnown("https://example.com")).resolves.toBe("https://api.example.com"); expect(fetchJsonMock).toHaveBeenCalledWith("https://example.com/.well-known/matrix/client", { method: "GET" }); }); it("falls back to provided URL when well-known fails", async () => { fetchJsonMock.mockRejectedValueOnce(new Error("nope")); await expect(resolveBaseUrlWithWellKnown("https://example.com/")).resolves.toBe("https://example.com"); }); }); describe("getAuthMetadata", () => { const fetchJsonMock = fetchUtils.fetchJson as Mock; const baseUrl = "https://matrix.example.com"; const v1Url = `${baseUrl}/_matrix/client/v1/auth_metadata`; const unstableUrl = `${baseUrl}/_matrix/client/unstable/org.matrix.msc2965/auth_metadata`; const metadata = { issuer: "https://auth.example.com/", authorization_endpoint: "https://auth.example.com/oauth2/auth", token_endpoint: "https://auth.example.com/oauth2/token", registration_endpoint: "https://auth.example.com/oauth2/clients/register", revocation_endpoint: "https://auth.example.com/oauth2/revoke", response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], response_modes_supported: ["query", "fragment"], code_challenge_methods_supported: ["S256"], }; afterEach(() => { fetchJsonMock.mockReset(); }); it("returns metadata from v1 endpoint when it succeeds (stable endpoint tried first)", async () => { fetchJsonMock.mockResolvedValueOnce({ status: 200, json: metadata }); const result = await getAuthMetadata(baseUrl); expect(result).toEqual(metadata); expect(fetchJsonMock).toHaveBeenCalledTimes(1); expect(fetchJsonMock).toHaveBeenCalledWith(v1Url, { method: "GET" }); }); it("falls back to unstable endpoint when v1 returns 404", async () => { fetchJsonMock.mockRejectedValueOnce(new Error("404")); fetchJsonMock.mockResolvedValueOnce({ status: 200, json: metadata }); const result = await getAuthMetadata(baseUrl); expect(result).toEqual(metadata); expect(fetchJsonMock).toHaveBeenCalledTimes(2); expect(fetchJsonMock).toHaveBeenNthCalledWith(1, v1Url, { method: "GET" }); expect(fetchJsonMock).toHaveBeenNthCalledWith(2, unstableUrl, { method: "GET" }); }); it("returns null when both endpoints fail with network errors", async () => { fetchJsonMock.mockRejectedValueOnce(new Error("network error")); fetchJsonMock.mockRejectedValueOnce(new Error("network error")); await expect(getAuthMetadata(baseUrl)).resolves.toBeNull(); }); it("returns null when both endpoints return 404 (react-admin fetchJson throws on non-2xx)", async () => { fetchJsonMock.mockRejectedValueOnce(new Error("404 Not Found")); fetchJsonMock.mockRejectedValueOnce(new Error("404 Not Found")); await expect(getAuthMetadata(baseUrl)).resolves.toBeNull(); }); it("returns null when v1 returns 200 but response is missing issuer field", async () => { fetchJsonMock.mockResolvedValueOnce({ status: 200, json: { authorization_endpoint: "https://auth.example.com/oauth2/auth" }, }); fetchJsonMock.mockResolvedValueOnce({ status: 200, json: { authorization_endpoint: "https://auth.example.com/oauth2/auth" }, }); await expect(getAuthMetadata(baseUrl)).resolves.toBeNull(); }); }); describe("uploadMedia", () => { const jsonClientMock = jsonClient as Mock; beforeEach(() => { localStorage.clear(); localStorage.setItem("base_url", "https://hs.example"); jsonClientMock.mockResolvedValue({ json: { content_uri: "mxc://hs.example/abc123" } }); }); afterEach(() => { jsonClientMock.mockReset(); }); it("encodes spaces in filename", async () => { await uploadMedia({ file: new File(["data"], "test"), filename: "my file.png", content_type: "image/png" }); expect(jsonClientMock).toHaveBeenCalledWith(expect.stringContaining("filename=my%20file.png"), expect.any(Object)); }); it("encodes non-ASCII characters in filename", async () => { await uploadMedia({ file: new File(["data"], "test"), filename: "résumé.pdf", content_type: "application/pdf" }); expect(jsonClientMock).toHaveBeenCalledWith( expect.stringContaining("filename=r%C3%A9sum%C3%A9.pdf"), expect.any(Object) ); }); it("leaves plain filenames unmodified", async () => { await uploadMedia({ file: new File(["data"], "test"), filename: "plain.jpg", content_type: "image/jpeg" }); expect(jsonClientMock).toHaveBeenCalledWith(expect.stringContaining("filename=plain.jpg"), expect.any(Object)); }); it("uses base_url from localStorage for the upload URL", async () => { await uploadMedia({ file: new File(["data"], "test"), filename: "file.txt", content_type: "text/plain" }); expect(jsonClientMock).toHaveBeenCalledWith( expect.stringContaining("https://hs.example/_matrix/media/v3/upload"), expect.any(Object) ); }); it("returns the content_uri from the server response", async () => { const result = await uploadMedia({ file: new File(["data"], "test"), filename: "file.txt", content_type: "text/plain", }); expect(result).toEqual({ content_uri: "mxc://hs.example/abc123" }); }); }); ================================================ FILE: src/providers/matrix.ts ================================================ import { DeleteParams, RaRecord, fetchUtils } from "react-admin"; import { jsonClient } from "./http"; import createLogger from "../utils/logger"; const log = createLogger("matrix"); import { Room, UploadMediaParams, UploadMediaResult } from "./types"; import { GetInstanceConfig } from "../components/etke.cc/InstanceConfig"; import { generateDeviceId } from "../utils/password"; import { encodeURLComponent } from "../utils/safety"; export const splitMxid = (mxid: string) => { const re = /^@(?<name>[a-zA-Z0-9._=\-/]+):(?<domain>(?:\[[\da-fA-F:]+\]|[a-zA-Z0-9\-.]+)(?::\d{1,5})?)$/; return re.exec(mxid)?.groups; }; export const isValidBaseUrl = (baseUrl: unknown): boolean => typeof baseUrl === "string" && /^(https?):\/\/(\[[\da-fA-F:]+\]|[a-zA-Z0-9\-.]+)(:\d{1,5})?\/?$/.test(baseUrl); /** * Resolve a base URL using /.well-known/matrix/client if present. * Falls back to the provided URL if lookup fails or is invalid. */ export const resolveBaseUrlWithWellKnown = async (baseUrl: string): Promise<string> => { if (!baseUrl) return baseUrl; const cleaned = baseUrl.replace(/\/+$/g, ""); let origin: string; try { origin = new URL(cleaned).origin; } catch { return cleaned; } const wellKnownUrl = `${origin}/.well-known/matrix/client`; try { const response = await fetchUtils.fetchJson(wellKnownUrl, { method: "GET" }); const wkBaseUrl = response.json?.["m.homeserver"]?.base_url; if (typeof wkBaseUrl === "string" && wkBaseUrl.trim() !== "") { const resolved = wkBaseUrl.replace(/\/+$/g, ""); log.debug("resolved base URL via well-known", { original: baseUrl, resolved }); return resolved; } } catch { // ignore and fall back to the provided URL } return cleaned; }; /** * Resolve the homeserver URL using the well-known lookup * @param domain the domain part of an MXID * @returns homeserver base URL */ export const getWellKnownUrl = async (domain: string) => { const wellKnownUrl = `https://${domain}/.well-known/matrix/client`; try { const response = await fetchUtils.fetchJson(wellKnownUrl, { method: "GET" }); return response.json["m.homeserver"].base_url; } catch { // if there is no .well-known entry, return the domain itself return `https://${domain}`; } }; /** Get supported Matrix features */ export const getSupportedFeatures = async (baseUrl: string) => { const versionUrl = `${baseUrl}/_matrix/client/versions`; const response = await fetchUtils.fetchJson(versionUrl, { method: "GET" }); return response.json; }; /** * Get supported login flows * @param baseUrl the base URL of the homeserver * @returns array of supported login flows */ export const getSupportedLoginFlows = async (baseUrl: string) => { const loginFlowsUrl = `${baseUrl}/_matrix/client/v3/login`; const response = await fetchUtils.fetchJson(loginFlowsUrl, { method: "GET" }); return response.json.flows; }; export const getAuthMetadata = async (baseUrl: string): Promise<AuthMetadata | null> => { const endpoints = [ `${baseUrl}/_matrix/client/v1/auth_metadata`, // stable (Matrix spec v1.14+) `${baseUrl}/_matrix/client/unstable/org.matrix.msc2965/auth_metadata`, // legacy unstable fallback ]; for (const url of endpoints) { try { const response = await fetchUtils.fetchJson(url, { method: "GET" }); if (response.status === 200 && response.json?.issuer) { return response.json; } } catch { // try next endpoint } } return null; }; /** * Refresh the access token using the refresh token * Based on: https://github.com/authts/oidc-client-ts/blob/main/docs/protocols/refresh-token-grant.md */ export const refreshAccessToken = async (): Promise<boolean> => { const refreshToken = localStorage.getItem("refresh_token"); const tokenEndpoint = localStorage.getItem("token_endpoint"); const clientId = localStorage.getItem("clientId"); if (!refreshToken || !tokenEndpoint || !clientId) { log.error("refreshAccessToken: missing credentials", { hasRefreshToken: !!refreshToken, hasTokenEndpoint: !!tokenEndpoint, hasClientId: !!clientId, }); return false; } try { log.debug("refreshing access token", { tokenEndpoint }); const tokenParams = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: clientId, }); const response = await fetchUtils.fetchJson(tokenEndpoint, { method: "POST", headers: new Headers({ Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded", }), body: tokenParams.toString(), }); const { access_token, refresh_token: new_refresh_token, id_token, expires_in } = response.json; // Update tokens in localStorage if (access_token) { localStorage.setItem("access_token", access_token); log.debug("access token refreshed", { expiresIn: expires_in }); } // Some providers return a new refresh token if (new_refresh_token) { localStorage.setItem("refresh_token", new_refresh_token); } if (id_token) { localStorage.setItem("id_token", id_token); } // Update token expiration time if (expires_in) { const expiresAt = Date.now() + expires_in * 1000; localStorage.setItem("access_token_expires_at", expiresAt.toString()); } return true; } catch (error) { log.error("access token refresh failed", error); return false; } }; interface ClientRegistration { client_id: string; client_name: string; client_uri: string; redirect_uris: string[]; grant_types: string[]; response_types: string[]; token_endpoint_auth_method: string; id_token_signed_response_alg: string; application_type: string; logo_uri: string; } export const registerClient = async (registrationEndpoint: string, clientUrl: string): Promise<ClientRegistration> => { if (clientUrl.endsWith("/")) { clientUrl = clientUrl.slice(0, -1); } const icfg = GetInstanceConfig(); let clientName = "Ketesa"; let logoUri = `${clientUrl}/images/logo.webp`; if (icfg.name) { clientName = icfg.name; } if (icfg.logo_url) { logoUri = icfg.logo_url; } const registerOpts = { method: "POST", body: JSON.stringify({ client_name: clientName, client_uri: clientUrl, response_types: ["code"], grant_types: ["authorization_code", "refresh_token"], redirect_uris: [`${clientUrl}/auth-callback/`], id_token_signed_response_alg: "RS256", token_endpoint_auth_method: "none", application_type: "web", logo_uri: logoUri, }), }; const registerResponse = await fetchUtils.fetchJson(`${registrationEndpoint}`, registerOpts); const json = registerResponse.json; log.debug("OIDC client registered", { clientId: json.client_id, clientName }); return json; }; export interface AuthMetadata { issuer: string; authorization_endpoint: string; token_endpoint: string; registration_endpoint?: string; revocation_endpoint?: string; response_types_supported?: string[]; grant_types_supported?: string[]; response_modes_supported?: string[]; code_challenge_methods_supported?: string[]; prompt_values_supported?: string[]; device_authorization_endpoint?: string; account_management_uri?: string; account_management_actions_supported?: string[]; } export interface OIDCAuthParams { clientId: string; redirectUri: string; issuer: string; scope: string; responseType: string; } export const handleOIDCAuth = async (authMetadata: AuthMetadata, clientUrl: string): Promise<OIDCAuthParams> => { if (!authMetadata.registration_endpoint) { throw new Error("Server does not support dynamic client registration"); } const registrationJson = await registerClient(authMetadata.registration_endpoint, clientUrl); const clientId = registrationJson.client_id; localStorage.setItem("clientId", clientId); localStorage.setItem("token_endpoint", authMetadata.token_endpoint); let deviceId = localStorage.getItem("device_id"); if (!deviceId) { deviceId = generateDeviceId(); localStorage.setItem("device_id", deviceId); } const scopes = [ "openid", "urn:matrix:org.matrix.msc2967.client:api:*", `urn:matrix:org.matrix.msc2967.client:device:${deviceId}`, "urn:synapse:admin:*", "urn:mas:admin", // Required for MAS registration tokens ]; const scope = scopes.join(" "); localStorage.setItem("oidc_issuer", authMetadata.issuer); localStorage.setItem("oidc_scope", scope); localStorage.setItem("oidc_redirect_uri", registrationJson.redirect_uris[0]); return { clientId, redirectUri: registrationJson.redirect_uris[0], issuer: authMetadata.issuer, scope, responseType: "code", }; }; export const uploadMedia = async ({ file, filename, content_type }: UploadMediaParams): Promise<UploadMediaResult> => { const base_url = localStorage.getItem("base_url"); const { json } = await jsonClient(`${base_url}/_matrix/media/v3/upload?filename=${encodeURLComponent(filename)}`, { method: "POST", body: file, headers: new Headers({ Accept: "application/json", "Content-Type": content_type, }) as Headers, }); return json as UploadMediaResult; }; export const roomDirectoryResource = { path: "/_matrix/client/v3/publicRooms", map: (rd: Room) => ({ ...rd, id: rd.room_id, public: !!rd.public, guest_access: !!rd.guest_access, avatar_src: rd.avatar_url ? rd.avatar_url : undefined, }), data: "chunk", total: (json: { total_room_count_estimate: number }) => json.total_room_count_estimate, create: (params: RaRecord) => ({ endpoint: `/_matrix/client/v3/directory/list/room/${params.id}`, body: { visibility: "public" }, method: "PUT", response: (data: RaRecord) => data, }), delete: (params: DeleteParams) => ({ endpoint: `/_matrix/client/v3/directory/list/room/${params.id}`, body: { visibility: "private" }, method: "PUT", }), }; ================================================ FILE: src/providers/serverVersion.ts ================================================ import { useEffect, useState } from "react"; import { getServerVersion } from "./data/synapse"; import { getMASVersion, isMAS } from "./data/mas"; interface ServerVersions { synapse: string; mas: string; } let cached: ServerVersions | null = null; let fetchPromise: Promise<ServerVersions> | null = null; const listeners = new Set<(v: ServerVersions) => void>(); const notify = (v: ServerVersions) => { for (const fn of listeners) fn(v); }; /** * Fetch Synapse (and optionally MAS) versions, cache the result. * Safe to call multiple times — deduplicates concurrent calls. */ export const fetchServerVersions = async (): Promise<ServerVersions> => { if (cached) return cached; if (fetchPromise) return fetchPromise; fetchPromise = (async () => { const baseUrl = localStorage.getItem("base_url"); let synapse = ""; let mas = ""; if (baseUrl) { try { synapse = await getServerVersion(baseUrl); } catch { /* ignore */ } } if (isMAS()) { try { mas = await getMASVersion(); } catch { /* ignore */ } } cached = { synapse, mas }; fetchPromise = null; notify(cached); return cached; })(); return fetchPromise; }; /** * Clear cached versions (call on logout so next login re-fetches). */ export const clearServerVersions = () => { cached = null; fetchPromise = null; notify({ synapse: "", mas: "" }); }; /** * React hook that returns { synapse, mas } version strings. * Triggers a fetch on mount if not yet cached. */ export const useServerVersions = (): ServerVersions => { const [versions, setVersions] = useState<ServerVersions>(cached || { synapse: "", mas: "" }); useEffect(() => { // Subscribe to updates listeners.add(setVersions); // If already cached, sync immediately if (cached) { setVersions(cached); } else if (localStorage.getItem("access_token")) { // Only fetch if logged in fetchServerVersions(); } return () => { listeners.delete(setVersions); }; }, []); return versions; }; ================================================ FILE: src/providers/types/common.ts ================================================ import type { DataProvider, Identifier } from "react-admin"; import type { AccountDataModel, ExperimentalFeaturesModel, RateLimitsModel, UsernameAvailabilityResult } from "./users"; import type { EventContextResult, RoomHierarchyResult, RoomMessagesResult } from "./rooms"; import type { MASPolicyData } from "./mas"; import type { ComponentsResponse, PaymentsResponse, RecurringCommand, ScheduledCommand, ServerCommandsResponse, ServerNotificationsResponse, ServerProcessResponse, ServerStatusResponse, SupportMessage, SupportAttachment, SupportRequest, SupportRequestDetail, } from "./etke"; export interface RaServerNotice { id: string; body: string; } export interface ScheduledTask { id: string; action: string; status: string; timestamp_ms: number; resource_id?: string; result?: unknown; error?: string; } export interface DeleteMediaParams { before_ts: string; size_gt: number; keep_profiles: boolean; } export interface DeleteMediaResult { deleted_media: Identifier[]; total: number; } export interface UploadMediaParams { file: File; filename: string; content_type: string; } export interface UploadMediaResult { content_uri: string; } export interface DatabaseRoomStatistic { room_id: string; estimated_size: number; } export interface UserMediaStatistic { displayname: string; media_count: number; media_length: number; user_id: string; } export interface AdminClientConfig { return_soft_failed_events: boolean; return_policy_server_spammy_events: boolean; } export interface SynapseDataProvider extends DataProvider { deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>; purgeRemoteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>; uploadMedia: (params: UploadMediaParams) => Promise<UploadMediaResult>; updateFeatures: (id: Identifier, features: ExperimentalFeaturesModel) => Promise<void>; getRateLimits: (id: Identifier) => Promise<RateLimitsModel>; setRateLimits: (id: Identifier, rateLimits: RateLimitsModel) => Promise<void>; getSentInviteCount: (id: Identifier, fromTs?: number) => Promise<number>; getCumulativeJoinedRoomCount: (id: Identifier, fromTs?: number) => Promise<number>; getAccountData: (id: Identifier) => Promise<AccountDataModel>; checkUsernameAvailability: (username: string) => Promise<UsernameAvailabilityResult>; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ fetchEvent: (eventId: string) => Promise<Record<string, any>>; revokeRegistrationToken: (id: string, revoke: boolean) => Promise<{ success: boolean; error?: string }>; blockRoom: (roomId: string, block: boolean) => Promise<{ success: boolean; error?: string; errcode?: string }>; getRoomBlockStatus: ( roomId: string ) => Promise<{ success: boolean; block: boolean; error?: string; errcode?: string }>; deleteDevices: ( user_id: string, devices: string[] ) => Promise<{ success: boolean; error?: string; errcode?: string }>; joinUserToRoom: (room_id: string, user_id: string) => Promise<{ success: boolean; error?: string; errcode?: string }>; makeRoomAdmin: (room_id: string, user_id: string) => Promise<{ success: boolean; error?: string; errcode?: string }>; suspendUser: ( id: Identifier, suspendValue: boolean ) => Promise<{ success: boolean; error?: string; errcode?: string }>; shadowBanUser: ( id: Identifier, shadowBan: boolean ) => Promise<{ success: boolean; error?: string; errcode?: string }>; resetPassword: ( id: Identifier, newPassword: string, logoutDevices?: boolean ) => Promise<{ success: boolean; error?: string; errcode?: string }>; loginAsUser: ( id: Identifier, validUntilMs?: number ) => Promise<{ success: boolean; access_token?: string; error?: string; errcode?: string }>; eraseUser: (id: Identifier) => Promise<{ success: boolean; error?: string; errcode?: string }>; renewAccountValidity: ( userId: string, expirationTs?: number, enableRenewalEmails?: boolean ) => Promise<{ success: boolean; expiration_ts?: number; error?: string; errcode?: string }>; allowCrossSigningReplacement: ( userId: string ) => Promise<{ success: boolean; updatable_without_uia_before_ms?: number; error?: string; errcode?: string }>; findUserByThreepid: ( medium: string, address: string ) => Promise<{ success: boolean; user_id?: string; error?: string; errcode?: string }>; findUserByAuthProvider: ( provider: string, externalId: string ) => Promise<{ success: boolean; user_id?: string; error?: string; errcode?: string }>; getEventByTimestamp: ( roomId: string, ts: number, dir?: "f" | "b" ) => Promise<{ success: boolean; event_id?: string; origin_server_ts?: number; error?: string; errcode?: string }>; getEventContext: ( roomId: string, eventId: string, limit?: number ) => Promise<{ success: boolean; data?: EventContextResult; error?: string; errcode?: string }>; getRoomMessages: ( roomId: string, params: { from: string; to?: string; limit?: number; dir?: "f" | "b"; filter?: string } ) => Promise<{ success: boolean; data?: RoomMessagesResult; error?: string; errcode?: string }>; getRoomHierarchy: ( roomId: string, params?: { from?: string; limit?: number; max_depth?: number } ) => Promise<{ success: boolean; data?: RoomHierarchyResult; error?: string; errcode?: string }>; deleteUserMedia: (id: Identifier) => Promise<DeleteMediaResult>; deleteRoomMedia: ( roomId: string, onProgress?: (current: number, total: number) => void ) => Promise<{ total: number }>; quarantineRoomMedia: ( roomId: string ) => Promise<{ success: boolean; num_quarantined: number; error?: string; errcode?: string }>; quarantineUserMedia: ( userId: string ) => Promise<{ success: boolean; num_quarantined: number; error?: string; errcode?: string }>; purgeHistory: ( roomId: string, purge_up_to_ts: number, delete_local_events: boolean ) => Promise<{ success: boolean; purge_id?: string; error?: string; errcode?: string }>; getPurgeHistoryStatus: ( purgeId: string ) => Promise<{ success: boolean; status?: string; error?: string; errcode?: string }>; deleteRoom: ( roomId: string, block: boolean ) => Promise<{ success: boolean; delete_id?: string; error?: string; errcode?: string }>; getRoomDeleteStatus: ( deleteId: string ) => Promise<{ success: boolean; status?: string; error?: string; errcode?: string }>; redactUserEvents: (id: Identifier) => Promise<{ redact_id: string }>; getRedactStatus: (redactId: string) => Promise<{ success: boolean; status: string; failed_redactions: Record<string, string>; error?: string; errcode?: string; }>; getServerRunningProcess: (etkeAdminUrl: string, locale: string) => Promise<ServerProcessResponse>; getServerStatus: (etkeAdminUrl: string, locale: string) => Promise<ServerStatusResponse>; getServerNotifications: (etkeAdminUrl: string, locale: string) => Promise<ServerNotificationsResponse>; deleteServerNotifications: (etkeAdminUrl: string, locale: string) => Promise<{ success: boolean }>; getUnits: (etkeAdminUrl: string, locale: string) => Promise<string[]>; getServerCommands: ( etkeAdminUrl: string, locale: string ) => Promise<{ maintenance: boolean; commands: ServerCommandsResponse[] }>; getScheduledCommands: (etkeAdminUrl: string, locale: string) => Promise<ScheduledCommand[]>; getRecurringCommands: (etkeAdminUrl: string, locale: string) => Promise<RecurringCommand[]>; createScheduledCommand: ( etkeAdminUrl: string, locale: string, command: Partial<ScheduledCommand> ) => Promise<ScheduledCommand>; updateScheduledCommand: ( etkeAdminUrl: string, locale: string, command: ScheduledCommand ) => Promise<ScheduledCommand>; deleteScheduledCommand: (etkeAdminUrl: string, locale: string, id: string) => Promise<{ success: boolean }>; createRecurringCommand: ( etkeAdminUrl: string, locale: string, command: Partial<RecurringCommand> ) => Promise<RecurringCommand>; updateRecurringCommand: ( etkeAdminUrl: string, locale: string, command: RecurringCommand ) => Promise<RecurringCommand>; deleteRecurringCommand: (etkeAdminUrl: string, locale: string, id: string) => Promise<{ success: boolean }>; getComponents: (etkeAdminUrl: string, locale: string) => Promise<ComponentsResponse>; getPayments: (etkeAdminUrl: string, locale: string) => Promise<PaymentsResponse>; getInvoice: (etkeAdminUrl: string, locale: string, transactionId: string) => Promise<void>; getSupportRequests: (etkeAdminUrl: string, locale: string) => Promise<SupportRequest[]>; getSupportRequest: ( etkeAdminUrl: string, locale: string, id: string, burstCache?: boolean ) => Promise<SupportRequestDetail>; createSupportRequest: ( etkeAdminUrl: string, locale: string, subject: string, message: string, attachments?: SupportAttachment[] ) => Promise<SupportRequest>; postSupportMessage: ( etkeAdminUrl: string, locale: string, id: string, message: string, attachments?: SupportAttachment[], close?: boolean ) => Promise<SupportMessage>; getAdminClientConfig: () => Promise<AdminClientConfig>; setAdminClientConfig: (config: AdminClientConfig) => Promise<void>; masLockUser: (id: string, lock: boolean) => Promise<{ success: boolean; error?: string }>; masDeactivateUser: (id: string, active: boolean) => Promise<{ success: boolean; error?: string }>; masSetAdmin: (id: string, admin: boolean) => Promise<{ success: boolean; error?: string }>; masSetPassword: (id: string, password: string) => Promise<{ success: boolean; error?: string }>; masFinishSession: ( resource: "mas_compat_sessions" | "mas_oauth2_sessions", id: string ) => Promise<{ success: boolean; error?: string }>; masRevokePersonalSession: (id: string) => Promise<{ success: boolean; error?: string }>; masRegeneratePersonalSession: (id: string) => Promise<{ success: boolean; token?: string; error?: string }>; masFinishUserSession: (id: string) => Promise<{ success: boolean; error?: string }>; getMASPolicyData: () => Promise<MASPolicyData | null>; setMASPolicyData: (data: unknown) => Promise<{ success: boolean; error?: string }>; } ================================================ FILE: src/providers/types/destinations.ts ================================================ export interface Destination { destination: string; retry_last_ts: number; retry_interval: number; failure_ts: number; last_successful_stream_ordering?: number; } export interface DestinationRoom { room_id: string; stream_ordering: number; } ================================================ FILE: src/providers/types/etke.ts ================================================ export interface ServerStatusComponent { ok: boolean; category: string; reason: string; url: string; help: string; label: { url: string; icon: string; text: string; }; } export interface ServerStatusResponse { success: boolean; maintenance?: boolean; ok: boolean; host: string; results: ServerStatusComponent[]; } export interface ServerProcessResponse { locked_at: string; command: string; maintenance?: boolean; } export interface ServerNotification { event_id: string; output: string; sent_at: string; } export type NotificationsStatus = "ok" | "advisory" | "unavailable"; export interface ServerNotificationsResponse { success: boolean; status: NotificationsStatus; notifications: ServerNotification[]; } export interface ServerCommand { icon: string; name: string; description: string; args: boolean; with_lock: boolean; additionalArgs?: string; } export type ServerCommandsResponse = Record<string, ServerCommand>; export interface ScheduledCommand { args: string; command: string; id: string; is_recurring: boolean; scheduled_at: string; } export interface RecurringCommand { args: string; command: string; id: string; scheduled_at: string; time: string; } export interface Payment { amount: number; currency: string; email: string; is_subscription: boolean; paid_at: string; transaction_id: string; invoice_id: string; } export interface PaymentStatus { due_at: string; expected_price: number; mismatch: boolean; overdue: boolean; } export interface PaymentsResponse { payments: Payment[]; maintenance: boolean; total: number; status?: PaymentStatus; } export interface Component { id: string; name: string; enabled: boolean; archived?: boolean; price: number; help: string; } export interface ComponentSection { id: string; name: string; enabled: boolean; archived?: boolean; price: number; help: string; components: Component[]; } export interface ComponentsResponse { components: Component[]; sections: ComponentSection[]; currency: string; total_price: number; } export interface SupportRequest { id: number; subject: string; status?: string; created_at?: string; updated_at?: string; } export interface SupportMessage { id?: number; type: string; text: string; created_by?: { firstName: string; avatarUrl?: string; email?: string; }; created_at?: string; } export interface SupportRequestDetail extends SupportRequest { messages: SupportMessage[]; } export interface SupportAttachment { fileName: string; data: string; // base64-encoded file content } ================================================ FILE: src/providers/types/index.ts ================================================ export * from "./users"; export * from "./rooms"; export * from "./mas"; export * from "./reports"; export * from "./destinations"; export * from "./etke"; export * from "./common"; ================================================ FILE: src/providers/types/mas.ts ================================================ import type { DeleteParams, RaRecord, UpdateParams } from "react-admin"; export interface MasPaginationLinks { self?: string; first?: string; last?: string; next?: string; prev?: string; } export interface MasPageMeta { page?: { cursor?: string; }; } export interface MASRegistrationTokenAttributes { token: string; valid: boolean; usage_limit?: number; times_used: number; created_at: string; last_used_at?: string; expires_at?: string; revoked_at?: string; } export interface MASRegistrationTokenResource { type: string; id: string; attributes: MASRegistrationTokenAttributes; meta?: MasPageMeta; links: { self: string; }; } export interface MASRegistrationToken { data: MASRegistrationTokenResource; links: { self: string; }; } export interface MASRegistrationTokenListResponse { data: MASRegistrationTokenResource[]; meta?: { count?: number; }; links?: MasPaginationLinks; } export interface BaseRegistrationTokensResource { path: string; data: string; create: (params: RaRecord) => { endpoint: string; body: object; method: string }; delete: (params: DeleteParams) => { endpoint: string; method?: string; body?: object }; } export interface RegistrationToken { token: string; uses_allowed: number; pending: number; completed: number; expiry_time?: number; // MAS-only fields created_at?: string; last_used_at?: string; revoked_at?: string; } export interface SynapseRegistrationTokensResourceType extends BaseRegistrationTokensResource { isMAS: false; map: (token: RegistrationToken) => object; total: (json: { registration_tokens: unknown[] }) => number; } export interface MASRegistrationTokensResourceType extends BaseRegistrationTokensResource { isMAS: true; map: (token: MASRegistrationToken | MASRegistrationTokenResource) => object; total: (json: MASRegistrationTokenListResponse) => number; handleCreateResponse: (token: MASRegistrationToken) => object; update: (params: UpdateParams) => { endpoint: string; body: object; method: string }; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ buildListQuery: (perPage: number, cursor: string | undefined, filter: Record<string, any>) => Record<string, any>; } export type RegistrationTokensResource = SynapseRegistrationTokensResourceType | MASRegistrationTokensResourceType; export interface MASUserAttributes { username: string; created_at: string; locked_at: string | null; deactivated_at: string | null; admin: boolean; legacy_guest: boolean; } export interface MASUserResource { type: "user"; id: string; attributes: MASUserAttributes; links: { self: string }; meta?: MasPageMeta; } export interface MASUserResponse { data: MASUserResource; links: { self: string }; } export interface MASUserListResponse { data: MASUserResource[]; meta?: { count?: number }; links?: MasPaginationLinks; } export interface MASUserEmailAttributes { created_at: string; user_id: string; email: string; } export interface MASUserEmailResource { type: "user-email"; id: string; attributes: MASUserEmailAttributes; links: { self: string }; meta?: MasPageMeta; } export interface MASUserEmailListResponse { data: MASUserEmailResource[]; meta?: { count?: number }; links?: MasPaginationLinks; } export interface MASCompatSessionAttributes { user_id: string; device_id: string | null; user_session_id: string | null; redirect_uri: string | null; created_at: string; user_agent: string | null; last_active_at: string | null; last_active_ip: string | null; finished_at: string | null; human_name: string | null; } export interface MASCompatSessionResource { type: "compat-session"; id: string; attributes: MASCompatSessionAttributes; links: { self: string }; meta?: MasPageMeta; } export interface MASCompatSessionListResponse { data: MASCompatSessionResource[]; meta?: { count?: number }; links?: MasPaginationLinks; } export interface MASOAuth2SessionAttributes { created_at: string; finished_at: string | null; user_id: string | null; user_session_id: string | null; client_id: string; scope: string; user_agent: string | null; last_active_at: string | null; last_active_ip: string | null; human_name: string | null; } export interface MASOAuth2SessionResource { type: "oauth2-session"; id: string; attributes: MASOAuth2SessionAttributes; links: { self: string }; meta?: MasPageMeta; } export interface MASOAuth2SessionListResponse { data: MASOAuth2SessionResource[]; meta?: { count?: number }; links?: MasPaginationLinks; } export interface MASPersonalSessionAttributes { created_at: string; revoked_at: string | null; owner_user_id: string | null; actor_user_id: string | null; human_name: string | null; scope: string; last_active_at: string | null; last_active_ip: string | null; expires_at: string | null; access_token?: string | null; } export interface MASPersonalSessionResource { type: "personal-session"; id: string; attributes: MASPersonalSessionAttributes; links: { self: string }; meta?: MasPageMeta; } export interface MASPersonalSessionListResponse { data: MASPersonalSessionResource[]; meta?: { count?: number }; links?: MasPaginationLinks; } export interface MASUserSessionAttributes { user_id: string; created_at: string; finished_at: string | null; user_agent: string | null; last_active_at: string | null; last_active_ip: string | null; } export interface MASUserSessionResource { type: "browser-session"; id: string; attributes: MASUserSessionAttributes; links: { self: string }; meta?: MasPageMeta; } export interface MASUserSessionListResponse { data: MASUserSessionResource[]; meta?: { count?: number }; links?: MasPaginationLinks; } export interface MASPolicyDataAttributes { created_at: string; data: unknown; // free-form JSON — operator-defined OPA data document } export interface MASPolicyDataResource { type: "policy-data"; id: string; attributes: MASPolicyDataAttributes; links: { self: string }; } export interface MASPolicyData { id: string; data: unknown; created_at: string; } export interface MASUpstreamOAuthLinkAttributes { created_at: string; provider_id: string; subject: string; user_id: string; human_account_name: string | null; } export interface MASUpstreamOAuthLinkResource { type: "upstream-oauth-link"; id: string; attributes: MASUpstreamOAuthLinkAttributes; links: { self: string }; meta?: MasPageMeta; } export interface MASUpstreamOAuthLinkListResponse { data: MASUpstreamOAuthLinkResource[]; meta?: { count?: number }; links?: MasPaginationLinks; } export interface MASUpstreamOAuthProviderAttributes { issuer: string | null; human_name: string | null; brand_name: string | null; created_at: string; disabled_at: string | null; } export interface MASUpstreamOAuthProviderResource { type: "upstream-oauth-provider"; id: string; attributes: MASUpstreamOAuthProviderAttributes; links: { self: string }; meta?: MasPageMeta; } export interface MASUpstreamOAuthProviderListResponse { data: MASUpstreamOAuthProviderResource[]; meta?: { count?: number }; links?: MasPaginationLinks; } ================================================ FILE: src/providers/types/reports.ts ================================================ export interface EventReport { id: number; received_ts: number; room_id: string; name: string; event_id: string; user_id: string; reason?: string; score?: number; sender: string; canonical_alias?: string; } ================================================ FILE: src/providers/types/rooms.ts ================================================ export interface Room { room_id: string; name?: string; canonical_alias?: string; avatar_url?: string; joined_members: number; joined_local_members: number; version: number; creator: string; encryption?: string; federatable: boolean; public: boolean; join_rules: "public" | "knock" | "invite" | "private"; guest_access?: "can_join" | "forbidden"; history_visibility: "invited" | "joined" | "shared" | "world_readable"; state_events: number; room_type?: string; } export interface RoomState { age: number; content: { alias?: string; }; event_id: string; origin_server_ts: number; room_id: string; sender: string; state_key: string; type: string; user_id: string; unsigned: { age?: number; }; } export interface ForwardExtremity { event_id: string; state_group: number; depth: number; received_ts: number; } export interface TimestampToEventResult { event_id: string; origin_server_ts: number; } export interface RoomEvent { event_id: string; type: string; room_id: string; sender: string; origin_server_ts: number; content: Record<string, unknown>; state_key?: string; unsigned?: Record<string, unknown>; } export interface EventContextResult { event: RoomEvent; events_before: RoomEvent[]; events_after: RoomEvent[]; start: string; end: string; state: RoomEvent[]; } export interface RoomMessagesResult { chunk: RoomEvent[]; start: string; end?: string; state?: RoomEvent[]; } export interface HierarchyRoom { room_id: string; name?: string; topic?: string; canonical_alias?: string; avatar_url?: string; room_type?: string; num_joined_members: number; join_rule?: string; guest_can_join: boolean; world_readable: boolean; children_state: { type: string; state_key: string; content: { via?: string[]; suggested?: boolean; order?: string; [key: string]: unknown; }; sender: string; origin_server_ts: number; }[]; } export interface RoomHierarchyResult { rooms: HierarchyRoom[]; next_batch?: string; } ================================================ FILE: src/providers/types/users.ts ================================================ export interface Threepid { medium: string; address: string; added_at: number; validated_at: number; } export interface ExternalId { auth_provider: string; external_id: string; } export interface User { id?: string; name: string; displayname?: string; threepids: Threepid[]; avatar_url?: string; is_guest: 0 | 1; admin: 0 | 1; deactivated: 0 | 1; erased: boolean; shadow_banned: 0 | 1; creation_ts: number; appservice_id?: string; consent_server_notice_sent?: string; consent_version?: string; consent_ts?: number; external_ids: ExternalId[]; user_type?: string; locked: boolean; suspended?: boolean; } export interface Device { device_id: string; display_name?: string; last_seen_ip?: string; last_seen_user_agent?: string; last_seen_ts?: number; user_id: string; dehydrated?: boolean; } export interface Connection { ip: string; last_seen: number; user_agent: string; } export interface Membership { id: string; membership: string; } export interface Whois { user_id: string; devices: Record< string, { sessions: { connections: Connection[]; }[]; } >; } export interface Pusher { app_display_name: string; app_id: string; data: { url?: string; format: string; }; url: string; format: string; device_display_name: string; profile_tag: string; kind: string; lang: string; pushkey: string; } export interface UserMedia { created_ts: number; last_access_ts?: number; media_id: string; media_length: number; media_type: string; quarantined_by?: string; safe_from_quarantine: boolean; upload_name?: string; } export interface ExperimentalFeaturesModel { features: Record<string, boolean>; } export interface RateLimitsModel { messages_per_second?: number; burst_count?: number; } export interface AccountDataModel { account_data: { global: Record<string, object>; rooms: Record<string, object>; }; } export interface UsernameAvailabilityResult { available?: boolean; error?: string; errcode?: string; } ================================================ FILE: src/resourceMap.ts ================================================ import { RegistrationTokensResource } from "./providers/types"; import { synapseResourceMap, synapseRegistrationTokensResource, CACHED_MANY_REF, invalidateManyRefCache, } from "./providers/data/synapse"; import { roomDirectoryResource } from "./providers/matrix"; export { CACHED_MANY_REF, invalidateManyRefCache }; export const resourceMap = { ...synapseResourceMap, room_directory: roomDirectoryResource, // Default to Synapse API; patched to MAS API at login/page-load via initResources() registration_tokens: synapseRegistrationTokensResource as RegistrationTokensResource, }; ================================================ FILE: src/resources/README.md ================================================ # resources/ React-Admin resource definitions. Each resource is its own directory. ## Structure Each resource directory contains: - `index.ts` — barrel export (the only file consumers should import from) - `List.tsx`, `Show.tsx`, `Edit.tsx`, `Create.tsx` — one file per CRUD view ## Conventions - Directory names: kebab-case (`registration-tokens/`, `room-directory/`) - File names: PascalCase for components (`List.tsx`, `Show.tsx`) - Always re-export from `index.ts` when adding a new export - Import from the directory, never from individual files: `import { UserList } from "../resources/users"` ================================================ FILE: src/resources/destinations/List.tsx ================================================ import AutorenewIcon from "@mui/icons-material/Autorenew"; import ErrorIcon from "@mui/icons-material/Error"; import FolderSharedIcon from "@mui/icons-material/FolderShared"; import ViewListIcon from "@mui/icons-material/ViewList"; import { Box, useMediaQuery } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import { get } from "lodash"; import { MouseEvent } from "react"; import { Button, DateField, DateFieldProps, FunctionField, ListProps, Pagination, RaRecord, ReferenceField, ReferenceManyField, SearchInput, Show, ShowProps, SimpleList, Tab, TabbedShowLayout, TextField, TopToolbar, useDelete, useLocale, useNotify, useRecordContext, useRefresh, useTranslate, } from "react-admin"; import { useDocTitle } from "../../components/hooks/useDocTitle"; import { DATE_FORMAT } from "../../utils/date"; import { Datagrid, EmptyState, List } from "../../components/layout"; const DestinationPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />; const destinationFilters = [<SearchInput source="destination" alwaysOn />]; export const DestinationReconnectButton = () => { const record = useRecordContext(); const refresh = useRefresh(); const notify = useNotify(); const [handleReconnect, { isLoading }] = useDelete(); // Reconnect is not required if no error has occurred. (`failure_ts`) if (!record || !record.failure_ts) return null; const handleClick = (e: MouseEvent<HTMLButtonElement>) => { // Prevents redirection to the detail page when clicking in the list e.stopPropagation(); handleReconnect( "destinations", { id: record.id }, { onSuccess: () => { notify("ra.notification.updated", { messageArgs: { smart_count: 1 }, }); refresh(); }, /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ onError: (error: any) => { notify(error?.message || "ra.message.error", { type: "error" }); }, } ); }; return ( <Button label="resources.destinations.action.reconnect" onClick={handleClick} disabled={isLoading}> <AutorenewIcon /> </Button> ); }; const DestinationShowActions = () => ( <TopToolbar> <DestinationReconnectButton /> </TopToolbar> ); const DestinationTitle = () => { const record = useRecordContext(); const translate = useTranslate(); const text = `${translate("resources.destinations.name", 1)} ${record?.destination}`; useDocTitle(text); return <span>{text}</span>; }; const RetryDateField = (props: DateFieldProps) => { const record = useRecordContext(props); if (props.source && get(record, props.source) === 0) { return <DateField {...props} record={{ ...record, [props.source]: null }} />; } return <DateField {...props} />; }; const destinationFieldRender = (record: RaRecord) => { if (record.retry_last_ts > 0) { return ( <> <ErrorIcon fontSize="inherit" color="error" sx={{ verticalAlign: "middle" }} /> {record.destination} </> ); } return <> {record.destination} </>; }; export const DestinationList = (props: ListProps) => { const locale = useLocale(); const translate = useTranslate(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); useDocTitle(translate("resources.destinations.name", 2)); return ( <List {...props} filters={destinationFilters} pagination={<DestinationPagination />} sort={{ field: "destination", order: "ASC" }} perPage={50} empty={<EmptyState />} > {isSmall ? ( <SimpleList primaryText={record => ( <Box component="span" sx={{ wordBreak: "break-all" }}> {record.destination} </Box> )} secondaryText={record => record.failure_ts ? ( <Box component="span" sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}> <ErrorIcon fontSize="inherit" color="error" /> {translate("resources.destinations.fields.failure_ts")}:{" "} {new Date(record.failure_ts).toLocaleString(locale)} </Box> ) : null } tertiaryText={record => (record.failure_ts ? <DestinationReconnectButton /> : null)} rowClick={id => `${id}/show/rooms`} /> ) : ( <Datagrid rowClick={id => `${id}/show/rooms`} bulkActionButtons={false}> <FunctionField source="destination" render={destinationFieldRender} label="resources.destinations.fields.destination" /> <DateField source="failure_ts" showTime options={DATE_FORMAT} label="resources.destinations.fields.failure_ts" locales={locale} /> <RetryDateField source="retry_last_ts" showTime options={DATE_FORMAT} label="resources.destinations.fields.retry_last_ts" locales={locale} /> <TextField source="retry_interval" label="resources.destinations.fields.retry_interval" /> <TextField source="last_successful_stream_ordering" label="resources.destinations.fields.last_successful_stream_ordering" /> <DestinationReconnectButton /> </Datagrid> )} </List> ); }; export const DestinationShow = (props: ShowProps) => { const translate = useTranslate(); const locale = useLocale(); return ( <Show actions={<DestinationShowActions />} title={<DestinationTitle />} {...props} sx={{ "& .RaShow-card": { maxWidth: { xs: "100vw", sm: "calc(100vw - 32px)" }, overflowX: "auto" } }} > <TabbedShowLayout sx={{ "& .MuiTabs-scroller": { overflowX: "auto !important" } }}> <Tab label="status" icon={<ViewListIcon />}> <TextField source="destination" /> <DateField source="failure_ts" showTime options={DATE_FORMAT} locales={locale} /> <DateField source="retry_last_ts" showTime options={DATE_FORMAT} locales={locale} /> <TextField source="retry_interval" /> <TextField source="last_successful_stream_ordering" /> </Tab> <Tab label={translate("resources.rooms.name", { smart_count: 2 })} icon={<FolderSharedIcon />} path="rooms"> <ReferenceManyField reference="destination_rooms" target="destination" label={false} pagination={<DestinationPagination />} perPage={50} > <Datagrid rowClick={id => `/rooms/${id}/show`} empty={<EmptyState resource="destination_rooms" />}> <TextField source="room_id" label="resources.rooms.fields.room_id" sx={{ wordBreak: "break-all" }} /> <TextField source="stream_ordering" sortable={false} /> <ReferenceField label="resources.rooms.fields.name" source="id" reference="rooms" sortable={false} link="" > <TextField source="name" sortable={false} /> </ReferenceField> </Datagrid> </ReferenceManyField> </Tab> </TabbedShowLayout> </Show> ); }; ================================================ FILE: src/resources/destinations/index.ts ================================================ import DestinationsIcon from "@mui/icons-material/CloudQueue"; import { ResourceProps } from "react-admin"; import { DestinationList, DestinationShow } from "./List"; export { DestinationList, DestinationReconnectButton, DestinationShow } from "./List"; const resource: ResourceProps = { name: "destinations", icon: DestinationsIcon, list: DestinationList, show: DestinationShow, }; export default resource; ================================================ FILE: src/resources/mas/CompatSessions.tsx ================================================ import DeleteIcon from "@mui/icons-material/Delete"; import DevicesIcon from "@mui/icons-material/Devices"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; import { BooleanField, Button, Confirm, DateField, ListProps, ResourceProps, SelectInput, SimpleList, TextField, useDataProvider, useNotify, useRecordContext, useRefresh, useTranslate, } from "react-admin"; import { SynapseDataProvider } from "../../providers/types"; import { sessionStatusChoices } from "./shared"; import { Datagrid, EmptyState, List } from "../../components/layout"; export const FinishCompatSessionButton = () => { const record = useRecordContext(); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); const notify = useNotify(); const refresh = useRefresh(); const dataProvider = useDataProvider() as SynapseDataProvider; const translate = useTranslate(); if (!record || !record.active) return null; const handleConfirm = async () => { setOpen(false); setLoading(true); try { const result = await dataProvider.masFinishSession("mas_compat_sessions", record.id as string); if (result.success) { notify("resources.mas_compat_sessions.action.finish.success"); refresh(); } else { notify(result.error || "ra.notification.http_error", { type: "error" }); } } catch { notify("ra.notification.http_error", { type: "error" }); } finally { setLoading(false); } }; return ( <> <Button label="resources.mas_compat_sessions.action.finish.label" onClick={() => setOpen(true)} disabled={loading} color="error" > <DeleteIcon /> </Button> <Confirm isOpen={open} title={translate("resources.mas_compat_sessions.action.finish.title")} content={translate("resources.mas_compat_sessions.action.finish.content")} onConfirm={handleConfirm} onClose={() => setOpen(false)} /> </> ); }; const compatSessionFilters = [ <SelectInput key="status" source="status" choices={sessionStatusChoices} label="resources.mas_compat_sessions.fields.active" />, ]; export function MASCompatSessionsList(props: ListProps) { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); return ( <List {...props} filters={compatSessionFilters} pagination={false} perPage={50} empty={<EmptyState />}> {isSmall ? ( <SimpleList primaryText={record => record.human_name || record.device_id || String(record.id)} secondaryText={record => String(record.user_id || "")} tertiaryText={() => <FinishCompatSessionButton />} rowClick={false} /> ) : ( <Datagrid bulkActionButtons={false} rowClick={false}> <TextField source="user_id" sortable={false} /> <TextField source="device_id" sortable={false} emptyText="-" /> <TextField source="human_name" sortable={false} emptyText="-" /> <BooleanField source="active" sortable={false} /> <DateField source="created_at" showTime sortable={false} /> <DateField source="last_active_at" showTime sortable={false} emptyText="-" /> <TextField source="last_active_ip" sortable={false} emptyText="-" /> <DateField source="finished_at" showTime sortable={false} emptyText="-" /> <FinishCompatSessionButton /> </Datagrid> )} </List> ); } export const masCompatSessions: ResourceProps = { name: "mas_compat_sessions", icon: DevicesIcon, list: MASCompatSessionsList, }; ================================================ FILE: src/resources/mas/OAuth2Sessions.tsx ================================================ import DeleteIcon from "@mui/icons-material/Delete"; import HttpsIcon from "@mui/icons-material/Https"; import { Box, Chip } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; import { BooleanField, Button, Confirm, DateField, FunctionField, ListProps, ResourceProps, SelectInput, SimpleList, TextField, useDataProvider, useNotify, useRecordContext, useRefresh, useTranslate, } from "react-admin"; import { SynapseDataProvider } from "../../providers/types"; import { sessionStatusChoices } from "./shared"; import { Datagrid, EmptyState, List } from "../../components/layout"; export const FinishOAuth2SessionButton = () => { const record = useRecordContext(); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); const notify = useNotify(); const refresh = useRefresh(); const dataProvider = useDataProvider() as SynapseDataProvider; const translate = useTranslate(); if (!record || !record.active) return null; const handleConfirm = async () => { setOpen(false); setLoading(true); try { const result = await dataProvider.masFinishSession("mas_oauth2_sessions", record.id as string); if (result.success) { notify("resources.mas_oauth2_sessions.action.finish.success"); refresh(); } else { notify(result.error || "ra.notification.http_error", { type: "error" }); } } catch { notify("ra.notification.http_error", { type: "error" }); } finally { setLoading(false); } }; return ( <> <Button label="resources.mas_oauth2_sessions.action.finish.label" onClick={() => setOpen(true)} disabled={loading} color="error" > <DeleteIcon /> </Button> <Confirm isOpen={open} title={translate("resources.mas_oauth2_sessions.action.finish.title")} content={translate("resources.mas_oauth2_sessions.action.finish.content")} onConfirm={handleConfirm} onClose={() => setOpen(false)} /> </> ); }; const oauth2SessionFilters = [ <SelectInput key="status" source="status" choices={sessionStatusChoices} label="resources.mas_oauth2_sessions.fields.active" />, ]; export function MASOAuth2SessionsList(props: ListProps) { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); return ( <List {...props} filters={oauth2SessionFilters} pagination={false} perPage={50} empty={<EmptyState />}> {isSmall ? ( <SimpleList primaryText={record => record.human_name || record.client_id || String(record.id)} secondaryText={record => String(record.user_id || "")} tertiaryText={() => <FinishOAuth2SessionButton />} rowClick={false} sx={{ "& .MuiListItemText-secondary": { wordBreak: "break-all" } }} /> ) : ( <Datagrid bulkActionButtons={false} rowClick={false}> <TextField source="user_id" sortable={false} emptyText="-" /> <TextField source="client_id" sortable={false} /> <FunctionField source="scope" label="resources.mas_oauth2_sessions.fields.scope" sortable={false} render={(record: { scope?: string }) => record?.scope ? ( <Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}> {record.scope .split(" ") .filter(Boolean) .map(s => ( <Chip key={s} label={s} size="small" variant="outlined" /> ))} </Box> ) : null } /> <TextField source="human_name" sortable={false} emptyText="-" /> <BooleanField source="active" sortable={false} /> <DateField source="created_at" showTime sortable={false} /> <DateField source="last_active_at" showTime sortable={false} emptyText="-" /> <TextField source="last_active_ip" sortable={false} emptyText="-" /> <DateField source="finished_at" showTime sortable={false} emptyText="-" /> <FinishOAuth2SessionButton /> </Datagrid> )} </List> ); } export const masOAuth2Sessions: ResourceProps = { name: "mas_oauth2_sessions", icon: HttpsIcon, list: MASOAuth2SessionsList, }; ================================================ FILE: src/resources/mas/PersonalSessions.tsx ================================================ import DeleteIcon from "@mui/icons-material/Delete"; import KeyIcon from "@mui/icons-material/Key"; import { Button as MuiButton, Dialog, DialogActions, DialogContent, DialogTitle, TextField as MuiTextField, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; import { useSearchParams } from "react-router-dom"; import { AutocompleteInput, BooleanField, Button, Confirm, Create, CreateProps, DateField, ListProps, ReferenceInput, ResourceProps, SaveButton, SelectInput, SimpleList, SimpleForm, TextField, TextInput, Toolbar, useDataProvider, useNotify, useRecordContext, useRedirect, useRefresh, useTranslate, } from "react-admin"; import { SynapseDataProvider } from "../../providers/types"; import { personalSessionStatusChoices } from "./shared"; import { Datagrid, EmptyState, List } from "../../components/layout"; export const RevokePersonalSessionButton = () => { const record = useRecordContext(); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); const notify = useNotify(); const refresh = useRefresh(); const dataProvider = useDataProvider() as SynapseDataProvider; const translate = useTranslate(); if (!record || !record.active) return <span>—</span>; const handleConfirm = async () => { setOpen(false); setLoading(true); try { const result = await dataProvider.masRevokePersonalSession(record.id as string); if (result.success) { notify("resources.mas_personal_sessions.action.revoke.success"); refresh(); } else { notify(result.error || "ra.notification.http_error", { type: "error" }); } } catch { notify("ra.notification.http_error", { type: "error" }); } finally { setLoading(false); } }; return ( <> <Button label="resources.mas_personal_sessions.action.revoke.label" onClick={() => setOpen(true)} disabled={loading} color="error" > <DeleteIcon /> </Button> <Confirm isOpen={open} title={translate("resources.mas_personal_sessions.action.revoke.title")} content={translate("resources.mas_personal_sessions.action.revoke.content")} onConfirm={handleConfirm} onClose={() => setOpen(false)} /> </> ); }; const personalSessionFilters = [ <SelectInput key="status" source="status" choices={personalSessionStatusChoices} label="resources.mas_personal_sessions.fields.active" />, ]; export function MASPersonalSessionsList(props: ListProps) { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); return ( <List {...props} filters={personalSessionFilters} pagination={false} perPage={50} empty={<EmptyState />}> {isSmall ? ( <SimpleList primaryText={record => record.human_name || String(record.id)} secondaryText={record => String(record.scope || "")} tertiaryText={() => <RevokePersonalSessionButton />} rowClick={false} sx={{ "& .MuiListItemText-secondary": { wordBreak: "break-all" } }} /> ) : ( <Datagrid bulkActionButtons={false} rowClick={false}> <TextField source="owner_user_id" sortable={false} emptyText="-" /> <TextField source="human_name" sortable={false} emptyText="-" /> <TextField source="scope" sortable={false} /> <BooleanField source="active" sortable={false} /> <DateField source="created_at" showTime sortable={false} /> <DateField source="last_active_at" showTime sortable={false} emptyText="-" /> <TextField source="last_active_ip" sortable={false} emptyText="-" /> <DateField source="expires_at" showTime sortable={false} emptyText="-" /> <DateField source="revoked_at" showTime sortable={false} emptyText="-" /> <RevokePersonalSessionButton /> </Datagrid> )} </List> ); } export const MASPersonalSessionCreate = (props: CreateProps) => { const [token, setToken] = useState<string | null>(null); const redirect = useRedirect(); const translate = useTranslate(); const [searchParams] = useSearchParams(); const presetUserId = searchParams.get("actor_user_id"); const handleSuccess = (record: Record<string, unknown>) => { const tok = record.access_token as string | null | undefined; if (tok) { setToken(tok); } else { redirect("list", "mas_personal_sessions"); } }; return ( <> <Create {...props} mutationOptions={{ onSuccess: handleSuccess }}> <SimpleForm toolbar={ <Toolbar> <SaveButton /> </Toolbar> } defaultValues={presetUserId ? { actor_user_id: presetUserId } : undefined} > {presetUserId ? ( <TextInput source="actor_user_id" disabled label="resources.mas_personal_sessions.fields.actor_user_id" fullWidth /> ) : ( <ReferenceInput source="actor_user_id" reference="mas_users"> <AutocompleteInput optionText="username" optionValue="id" label="resources.mas_personal_sessions.fields.actor_user_id" filterToQuery={search => ({ search })} isRequired /> </ReferenceInput> )} <TextInput source="scope" required label="resources.mas_personal_sessions.fields.scope" fullWidth /> <TextInput source="human_name" required label="resources.mas_personal_sessions.fields.human_name" fullWidth /> <TextInput source="expires_in" label="resources.mas_personal_sessions.fields.expires_in" fullWidth helperText="resources.mas_personal_sessions.helper.expires_in" /> </SimpleForm> </Create> <Dialog open={!!token} maxWidth="sm" fullWidth> <DialogTitle>{translate("resources.mas_personal_sessions.action.create.token_title")}</DialogTitle> <DialogContent> <p>{translate("resources.mas_personal_sessions.action.create.token_content")}</p> <MuiTextField value={token || ""} fullWidth multiline rows={3} InputProps={{ readOnly: true }} onClick={e => (e.target as HTMLInputElement).select()} /> </DialogContent> <DialogActions> <MuiButton onClick={() => redirect("list", "mas_personal_sessions")}>OK</MuiButton> </DialogActions> </Dialog> </> ); }; export const masPersonalSessions: ResourceProps = { name: "mas_personal_sessions", icon: KeyIcon, list: MASPersonalSessionsList, }; ================================================ FILE: src/resources/mas/UpstreamOAuthLinks.tsx ================================================ import DeleteIcon from "@mui/icons-material/Delete"; import HttpsIcon from "@mui/icons-material/Https"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; import { Button, Confirm, DateField, ListProps, ResourceProps, SearchInput, SimpleList, TextField, useDataProvider, useNotify, useRecordContext, useRefresh, useTranslate, } from "react-admin"; import { SynapseDataProvider } from "../../providers/types"; import { Datagrid, EmptyState, List } from "../../components/layout"; export const DeleteOAuthLinkButton = () => { const record = useRecordContext(); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); const notify = useNotify(); const refresh = useRefresh(); const dataProvider = useDataProvider() as SynapseDataProvider; const translate = useTranslate(); if (!record) return null; const handleConfirm = async () => { setOpen(false); setLoading(true); try { await dataProvider.delete("mas_upstream_oauth_links", { id: record.id, previousData: record }); notify("resources.mas_upstream_oauth_links.action.remove.success"); refresh(); } catch { notify("ra.notification.http_error", { type: "error" }); } finally { setLoading(false); } }; return ( <> <Button label="resources.mas_upstream_oauth_links.action.remove.label" onClick={() => setOpen(true)} disabled={loading} color="error" > <DeleteIcon /> </Button> <Confirm isOpen={open} title={translate("resources.mas_upstream_oauth_links.action.remove.title")} content={translate("resources.mas_upstream_oauth_links.action.remove.content")} onConfirm={handleConfirm} onClose={() => setOpen(false)} /> </> ); }; const oauthLinkFilters = [<SearchInput key="user_id" source="user_id" alwaysOn />]; export function MASUpstreamOAuthLinksList(props: ListProps) { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); return ( <List {...props} filters={oauthLinkFilters} pagination={false} perPage={50} empty={<EmptyState />}> {isSmall ? ( <SimpleList primaryText={record => String(record.subject || "")} secondaryText={record => String(record.user_id || "")} tertiaryText={() => <DeleteOAuthLinkButton />} rowClick={false} /> ) : ( <Datagrid bulkActionButtons={false} rowClick={false}> <TextField source="user_id" sortable={false} /> <TextField source="provider_id" sortable={false} /> <TextField source="subject" sortable={false} /> <TextField source="human_account_name" sortable={false} emptyText="-" /> <DateField source="created_at" showTime sortable={false} /> <DeleteOAuthLinkButton /> </Datagrid> )} </List> ); } export const masUpstreamOAuthLinks: ResourceProps = { name: "mas_upstream_oauth_links", icon: HttpsIcon, list: MASUpstreamOAuthLinksList, }; ================================================ FILE: src/resources/mas/UpstreamOAuthProviders.tsx ================================================ import BlockIcon from "@mui/icons-material/Block"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import HttpsIcon from "@mui/icons-material/Https"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { Datagrid, EmptyState, List } from "../../components/layout"; import { BooleanField, DateField, ListProps, ResourceProps, Show, SimpleList, SimpleShowLayout, TextField, } from "react-admin"; export function MASUpstreamOAuthProvidersList(props: ListProps) { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); return ( <List {...props} pagination={false} perPage={50} empty={<EmptyState />}> {isSmall ? ( <SimpleList primaryText={record => record.human_name || String(record.id)} secondaryText={record => String(record.issuer || "")} tertiaryText={record => record.enabled ? ( <CheckCircleIcon fontSize="small" sx={{ color: "success.main" }} /> ) : ( <BlockIcon fontSize="small" sx={{ color: "text.disabled" }} /> ) } rowClick="show" /> ) : ( <Datagrid rowLabel={record => String(record.human_name || record.id)} bulkActionButtons={false} rowClick="show"> <TextField source="human_name" sortable={false} emptyText="-" /> <TextField source="brand_name" sortable={false} emptyText="-" /> <TextField source="issuer" sortable={false} emptyText="-" /> <BooleanField source="enabled" sortable={false} /> <DateField source="created_at" showTime sortable={false} /> </Datagrid> )} </List> ); } export const MASUpstreamOAuthProvidersShow = () => ( <Show> <SimpleShowLayout> <TextField source="id" /> <TextField source="human_name" emptyText="-" /> <TextField source="brand_name" emptyText="-" /> <TextField source="issuer" emptyText="-" /> <BooleanField source="enabled" /> <DateField source="created_at" showTime /> <DateField source="disabled_at" showTime emptyText="-" /> </SimpleShowLayout> </Show> ); export const masUpstreamOAuthProviders: ResourceProps = { name: "mas_upstream_oauth_providers", icon: HttpsIcon, list: MASUpstreamOAuthProvidersList, show: MASUpstreamOAuthProvidersShow, }; ================================================ FILE: src/resources/mas/UserEmails.tsx ================================================ import DeleteIcon from "@mui/icons-material/Delete"; import ContactMailIcon from "@mui/icons-material/ContactMail"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; import { AutocompleteInput, Button, Confirm, Create, CreateProps, DateField, ListProps, ReferenceInput, ResourceProps, SaveButton, SearchInput, SimpleList, SimpleForm, TextField, TextInput, Toolbar, useDataProvider, useNotify, useRecordContext, useRefresh, useTranslate, } from "react-admin"; import { SynapseDataProvider } from "../../providers/types"; import { Datagrid, EmptyState, List } from "../../components/layout"; const DeleteEmailButton = () => { const record = useRecordContext(); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); const notify = useNotify(); const refresh = useRefresh(); const dataProvider = useDataProvider() as SynapseDataProvider; const translate = useTranslate(); if (!record) return null; const handleConfirm = async () => { setOpen(false); setLoading(true); try { await dataProvider.delete("mas_user_emails", { id: record.id, previousData: record }); notify("resources.mas_user_emails.action.remove.success"); refresh(); } catch { notify("ra.notification.http_error", { type: "error" }); } finally { setLoading(false); } }; return ( <> <Button label="resources.mas_user_emails.action.remove.label" onClick={() => setOpen(true)} disabled={loading} color="error" > <DeleteIcon /> </Button> <Confirm isOpen={open} title={translate("resources.mas_user_emails.action.remove.title")} content={translate("resources.mas_user_emails.action.remove.content", { email: record.email })} onConfirm={handleConfirm} onClose={() => setOpen(false)} /> </> ); }; const userEmailFilters = [<SearchInput key="email" source="email" alwaysOn />]; export function MASUserEmailsList(props: ListProps) { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); return ( <List {...props} filters={userEmailFilters} pagination={false} perPage={50} empty={<EmptyState />}> {isSmall ? ( <SimpleList primaryText={record => String(record.email || "")} secondaryText={record => String(record.user_id || "")} tertiaryText={() => <DeleteEmailButton />} rowClick={false} /> ) : ( <Datagrid bulkActionButtons={false} rowClick={false}> <TextField source="email" sortable={false} /> <TextField source="user_id" sortable={false} /> <DateField source="created_at" showTime sortable={false} /> <DeleteEmailButton /> </Datagrid> )} </List> ); } export const MASUserEmailCreate = (props: CreateProps) => ( <Create {...props} redirect="list"> <SimpleForm toolbar={ <Toolbar> <SaveButton /> </Toolbar> } > <ReferenceInput source="user_id" reference="mas_users"> <AutocompleteInput optionText="username" optionValue="id" label="resources.mas_user_emails.fields.user_id" filterToQuery={search => ({ search })} isRequired /> </ReferenceInput> <TextInput source="email" required label="resources.mas_user_emails.fields.email" /> </SimpleForm> </Create> ); export const masUserEmails: ResourceProps = { name: "mas_user_emails", icon: ContactMailIcon, list: MASUserEmailsList, create: MASUserEmailCreate, }; ================================================ FILE: src/resources/mas/UserSessions.tsx ================================================ import DeleteIcon from "@mui/icons-material/Delete"; import HttpsIcon from "@mui/icons-material/Https"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; import { BooleanField, Button, Confirm, DateField, ListProps, ResourceProps, SelectInput, SimpleList, TextField, useDataProvider, useNotify, useRecordContext, useRefresh, useTranslate, } from "react-admin"; import { SynapseDataProvider } from "../../providers/types"; import { sessionStatusChoices } from "./shared"; import { Datagrid, EmptyState, List } from "../../components/layout"; export const FinishUserSessionButton = () => { const record = useRecordContext(); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); const notify = useNotify(); const refresh = useRefresh(); const dataProvider = useDataProvider() as SynapseDataProvider; const translate = useTranslate(); if (!record || !record.active) return null; const handleConfirm = async () => { setOpen(false); setLoading(true); try { const result = await dataProvider.masFinishUserSession(record.id as string); if (result.success) { notify("resources.mas_user_sessions.action.finish.success"); refresh(); } else { notify(result.error || "ra.notification.http_error", { type: "error" }); } } catch { notify("ra.notification.http_error", { type: "error" }); } finally { setLoading(false); } }; return ( <> <Button label="resources.mas_user_sessions.action.finish.label" onClick={() => setOpen(true)} disabled={loading} color="error" > <DeleteIcon /> </Button> <Confirm isOpen={open} title={translate("resources.mas_user_sessions.action.finish.title")} content={translate("resources.mas_user_sessions.action.finish.content")} onConfirm={handleConfirm} onClose={() => setOpen(false)} /> </> ); }; const userSessionFilters = [ <SelectInput key="status" source="status" choices={sessionStatusChoices} label="resources.mas_user_sessions.fields.active" />, ]; export function MASUserSessionsList(props: ListProps) { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); return ( <List {...props} filters={userSessionFilters} pagination={false} perPage={50} empty={<EmptyState />}> {isSmall ? ( <SimpleList primaryText={record => String(record.user_id || "")} secondaryText={record => String(record.user_agent || record.last_active_ip || "")} tertiaryText={() => <FinishUserSessionButton />} rowClick={false} /> ) : ( <Datagrid bulkActionButtons={false} rowClick={false}> <TextField source="user_id" sortable={false} /> <BooleanField source="active" sortable={false} /> <DateField source="created_at" showTime sortable={false} /> <DateField source="last_active_at" showTime sortable={false} emptyText="-" /> <TextField source="last_active_ip" sortable={false} emptyText="-" /> <TextField source="user_agent" sortable={false} emptyText="-" /> <DateField source="finished_at" showTime sortable={false} emptyText="-" /> <FinishUserSessionButton /> </Datagrid> )} </List> ); } export const masUserSessions: ResourceProps = { name: "mas_user_sessions", icon: HttpsIcon, list: MASUserSessionsList, }; ================================================ FILE: src/resources/mas/index.ts ================================================ export { masCompatSessions, FinishCompatSessionButton } from "./CompatSessions"; export { masOAuth2Sessions, FinishOAuth2SessionButton } from "./OAuth2Sessions"; export { masPersonalSessions, RevokePersonalSessionButton } from "./PersonalSessions"; export { masUserEmails } from "./UserEmails"; export { masUserSessions, FinishUserSessionButton } from "./UserSessions"; export { masUpstreamOAuthLinks, DeleteOAuthLinkButton } from "./UpstreamOAuthLinks"; export { masUpstreamOAuthProviders } from "./UpstreamOAuthProviders"; ================================================ FILE: src/resources/mas/shared.tsx ================================================ // Shared constants used across multiple MAS session resource files. export const sessionStatusChoices = [ { id: "active", name: "resources.mas_sessions.status.active" }, { id: "finished", name: "resources.mas_sessions.status.finished" }, ]; export const personalSessionStatusChoices = [ { id: "active", name: "resources.mas_sessions.status.active" }, { id: "revoked", name: "resources.mas_sessions.status.revoked" }, ]; ================================================ FILE: src/resources/registration-tokens/Create.tsx ================================================ import { Create, CreateProps, DateTimeInput, NumberInput, SaveButton, SimpleForm, TextInput, Toolbar, maxValue, number, regex, useTranslate, } from "react-admin"; import { useDocTitle } from "../../components/hooks/useDocTitle"; import { dateParser } from "../../utils/date"; const validateToken = [regex(/^[A-Za-z0-9._~-]{0,64}$/)]; const validateUsesAllowed = [number()]; const validateLength = [number(), maxValue(64)]; export const RegistrationTokenCreate = (props: CreateProps) => { const translate = useTranslate(); useDocTitle(translate("ra.action.create_item", { item: translate("resources.registration_tokens.name") })); return ( <Create {...props} redirect="list"> <SimpleForm toolbar={ <Toolbar> {/* It is possible to create tokens per default without input. */} <SaveButton alwaysEnable /> </Toolbar> } > <TextInput source="token" autoComplete="off" validate={validateToken} resettable /> <NumberInput source="length" validate={validateLength} helperText="resources.registration_tokens.helper.length" step={1} /> <NumberInput source="uses_allowed" validate={validateUsesAllowed} step={1} /> <DateTimeInput source="expiry_time" parse={dateParser} /> </SimpleForm> </Create> ); }; ================================================ FILE: src/resources/registration-tokens/Edit.tsx ================================================ import BlockIcon from "@mui/icons-material/Block"; import RestoreIcon from "@mui/icons-material/RestoreFromTrash"; import { useState } from "react"; import { Button, DateTimeInput, DeleteButton, Edit, EditProps, NumberInput, SaveButton, SimpleForm, TextInput, Toolbar, number, useDataProvider, useNotify, useRecordContext, useRefresh, useTranslate, } from "react-admin"; import { useDocTitle } from "../../components/hooks/useDocTitle"; import { useIsMAS } from "../../providers/data/mas"; import { SynapseDataProvider } from "../../providers/types"; import { dateFormatter, dateParser } from "../../utils/date"; const validateUsesAllowed = [number()]; const RevokeTokenButton = () => { const record = useRecordContext(); const [loading, setLoading] = useState(false); const notify = useNotify(); const refresh = useRefresh(); const dataProvider = useDataProvider() as SynapseDataProvider; const isMAS = useIsMAS(); if (!record || !isMAS) return null; const isRevoked = !!record.revoked_at; const handleClick = async () => { setLoading(true); try { const result = await dataProvider.revokeRegistrationToken(record.id as string, !isRevoked); if (result.success) { notify( isRevoked ? "resources.registration_tokens.action.unrevoke.success" : "resources.registration_tokens.action.revoke.success" ); refresh(); } else { notify(result.error || "ra.notification.http_error", { type: "error" }); } } catch { notify("ra.notification.http_error", { type: "error" }); } finally { setLoading(false); } }; return ( <Button label={ isRevoked ? "resources.registration_tokens.action.unrevoke.label" : "resources.registration_tokens.action.revoke.label" } onClick={handleClick} disabled={loading} > {isRevoked ? <RestoreIcon /> : <BlockIcon />} </Button> ); }; const RegistrationTokenEditToolbar = () => ( <Toolbar sx={{ justifyContent: "space-between" }}> <SaveButton /> <RevokeTokenButton /> <DeleteButton redirect="list" /> </Toolbar> ); export const RegistrationTokenEdit = (props: EditProps) => { const translate = useTranslate(); const isMAS = useIsMAS(); useDocTitle(`${translate("ra.action.edit")} ${translate("resources.registration_tokens.name")}`); return ( <Edit {...props} sx={{ "& .RaEdit-card": { maxWidth: { xs: "100vw", sm: "calc(100vw - 32px)" }, overflowX: "auto" } }} > <SimpleForm toolbar={<RegistrationTokenEditToolbar />}> <TextInput source="token" disabled /> <NumberInput source="pending" disabled /> <NumberInput source="completed" disabled /> <NumberInput source="uses_allowed" validate={validateUsesAllowed} step={1} /> <DateTimeInput source="expiry_time" parse={dateParser} format={dateFormatter} /> {isMAS && <DateTimeInput source="created_at" disabled />} {isMAS && <DateTimeInput source="last_used_at" disabled />} {isMAS && <DateTimeInput source="revoked_at" disabled />} </SimpleForm> </Edit> ); }; ================================================ FILE: src/resources/registration-tokens/List.tsx ================================================ import { Box, useMediaQuery } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import { BooleanInput, DateField, DeleteButton, ListProps, NumberField, SimpleList, TextField, useLocale, useTranslate, } from "react-admin"; import { useDocTitle } from "../../components/hooks/useDocTitle"; import { useIsMAS } from "../../providers/data/mas"; import { DATE_FORMAT } from "../../utils/date"; import { Datagrid, EmptyState, List } from "../../components/layout"; const registrationTokenFilters = [<BooleanInput key="valid" source="valid" />]; export const RegistrationTokenList = (props: ListProps) => { const locale = useLocale(); const translate = useTranslate(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const isMAS = useIsMAS(); useDocTitle(translate("resources.registration_tokens.name", { smart_count: 2 })); return ( <List {...props} filters={registrationTokenFilters} filterDefaultValues={{ valid: true }} pagination={false} perPage={50} empty={<EmptyState />} > {isSmall ? ( <SimpleList primaryText={record => ( <Box component="span" sx={{ wordBreak: "break-all" }}> {record.token} </Box> )} secondaryText={record => ( <> {translate("resources.registration_tokens.fields.uses_allowed")}: {record.uses_allowed ?? "∞"} {" · "} {translate("resources.registration_tokens.fields.completed")}: {record.completed ?? 0} {record.expiry_time && ( <> <br /> {translate("resources.registration_tokens.fields.expiry_time")}:{" "} {new Date(record.expiry_time).toLocaleString(locale)} </> )} </> )} tertiaryText={() => <DeleteButton redirect={false} />} rowClick="edit" /> ) : ( <Datagrid rowLabel={record => String(record.token)} rowClick="edit"> <TextField source="token" sortable={false} label="resources.registration_tokens.fields.token" /> <NumberField source="uses_allowed" sortable={false} label="resources.registration_tokens.fields.uses_allowed" /> <NumberField source="pending" sortable={false} label="resources.registration_tokens.fields.pending" /> <NumberField source="completed" sortable={false} label="resources.registration_tokens.fields.completed" /> <DateField source="expiry_time" showTime options={DATE_FORMAT} sortable={false} label="resources.registration_tokens.fields.expiry_time" locales={locale} /> {isMAS && ( <DateField source="created_at" showTime options={DATE_FORMAT} sortable={false} label="resources.registration_tokens.fields.created_at" locales={locale} /> )} {isMAS && ( <DateField source="last_used_at" showTime options={DATE_FORMAT} sortable={false} label="resources.registration_tokens.fields.last_used_at" locales={locale} /> )} {isMAS && ( <DateField source="revoked_at" showTime options={DATE_FORMAT} sortable={false} label="resources.registration_tokens.fields.revoked_at" locales={locale} /> )} </Datagrid> )} </List> ); }; ================================================ FILE: src/resources/registration-tokens/index.ts ================================================ import RegistrationTokenIcon from "@mui/icons-material/ConfirmationNumber"; import { ResourceProps } from "react-admin"; import { RegistrationTokenCreate } from "./Create"; import { RegistrationTokenEdit } from "./Edit"; import { RegistrationTokenList } from "./List"; export { RegistrationTokenCreate } from "./Create"; export { RegistrationTokenEdit } from "./Edit"; export { RegistrationTokenList } from "./List"; const resource: ResourceProps = { name: "registration_tokens", icon: RegistrationTokenIcon, list: RegistrationTokenList, edit: RegistrationTokenEdit, create: RegistrationTokenCreate, }; export default resource; ================================================ FILE: src/resources/reports/List.tsx ================================================ import SearchIcon from "@mui/icons-material/Search"; import { Box, useMediaQuery } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import { useState } from "react"; import { Button, DateField, ListProps, Pagination, SimpleList, TextField, TopToolbar, useLocale, useTranslate, } from "react-admin"; import { EventLookupDialog } from "../../components/rooms/EventLookupDialog"; import { useDocTitle } from "../../components/hooks/useDocTitle"; import { DATE_FORMAT } from "../../utils/date"; import { Datagrid, EmptyState, List } from "../../components/layout"; const ReportPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />; const EventLookupButton = () => { const [open, setOpen] = useState(false); return ( <> <Button label="resources.reports.action.event_lookup.label" onClick={() => setOpen(true)}> <SearchIcon /> </Button> <EventLookupDialog open={open} onClose={() => setOpen(false)} /> </> ); }; const ReportListActions = () => ( <TopToolbar> <EventLookupButton /> </TopToolbar> ); const ellipsisSx = { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } as const; export const ReportList = (props: ListProps) => { const locale = useLocale(); const translate = useTranslate(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); useDocTitle(translate("resources.reports.name", { smart_count: 2 })); return ( <List {...props} pagination={<ReportPagination />} perPage={50} sort={{ field: "received_ts", order: "DESC" }} actions={<ReportListActions />} empty={<EmptyState />} > {isSmall ? ( <SimpleList primaryText={record => ( <Box component="span" sx={{ wordBreak: "break-all" }}> #{record.id} {record.name || record.room_id} </Box> )} secondaryText={record => { const date = new Date(record.received_ts).toLocaleDateString(locale, DATE_FORMAT); const score = record.score !== undefined ? ` · ${translate("resources.reports.fields.score")}: ${record.score}` : ""; return `${date}${score}`; }} tertiaryText={record => { if (!record.user_id) return ""; return ( <Box component="span" sx={{ wordBreak: "break-all" }}> {record.user_id} </Box> ); }} rowClick="show" /> ) : ( <Datagrid rowLabel={record => `#${record.id} ${record.name || record.room_id || ""}`.trim()} rowClick="show" bulkActionButtons={false} > <TextField source="id" sortable={false} label="resources.reports.fields.id" /> <DateField source="received_ts" showTime options={DATE_FORMAT} sortable={true} label="resources.reports.fields.received_ts" locales={locale} /> <TextField sortable={false} source="user_id" label="resources.reports.fields.user_id" sx={{ ...ellipsisSx, maxWidth: "200px", display: "inline-block" }} /> <TextField sortable={false} source="name" label="resources.reports.fields.name" sx={{ ...ellipsisSx, maxWidth: "200px", display: "inline-block" }} /> <TextField sortable={false} source="score" label="resources.reports.fields.score" /> </Datagrid> )} </List> ); }; ================================================ FILE: src/resources/reports/Show.tsx ================================================ import PageviewIcon from "@mui/icons-material/Pageview"; import ViewListIcon from "@mui/icons-material/ViewList"; import { Box, Card, CardContent, Grid, Typography } from "@mui/material"; import { DateField, DeleteButton, NumberField, ReferenceField, Show, ShowProps, Tab, TabbedShowLayout, TextField, TopToolbar, useLocale, useRecordContext, useTranslate, } from "react-admin"; import AvatarField from "../../components/users/fields/AvatarField"; import { useDocTitle } from "../../components/hooks/useDocTitle"; import { DATE_FORMAT } from "../../utils/date"; const LabeledField = ({ label, children }: { label: string; children: React.ReactNode }) => ( <Box sx={{ mb: 2 }}> <Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 0.5 }}> {label} </Typography> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>{children}</Box> </Box> ); const ReportTitle = () => { const record = useRecordContext(); const translate = useTranslate(); const baseTitle = translate("resources.reports.name", { smart_count: 1 }); const pageTitle = record ? `${baseTitle} #${record.id}` : baseTitle; useDocTitle(pageTitle); if (!record) return null; return <span>{pageTitle}</span>; }; const RoomInfoField = () => { const record = useRecordContext(); if (!record) return null; const parts = [record.id as string]; if (record.canonical_alias) parts.push(record.canonical_alias as string); if (record.name) parts.push(record.name as string); return <span style={{ wordBreak: "break-all" }}>{parts.join(" ")}</span>; }; const ReportBasicTab = () => { const translate = useTranslate(); const locale = useLocale(); return ( <Grid container spacing={2} sx={{ alignItems: "stretch" }}> <Grid size={{ xs: 12, md: 6 }}> <Card variant="outlined" sx={{ height: "100%" }}> <CardContent> <LabeledField label={translate("resources.reports.fields.id")}> <NumberField source="id" label={false} /> </LabeledField> <LabeledField label={translate("resources.reports.fields.received_ts")}> <DateField source="received_ts" showTime options={DATE_FORMAT} locales={locale} label={false} /> </LabeledField> <LabeledField label={translate("resources.reports.fields.score")}> <NumberField source="score" label={false} /> </LabeledField> <LabeledField label={translate("resources.reports.fields.reason")}> <TextField source="reason" label={false} /> </LabeledField> </CardContent> </Card> </Grid> <Grid size={{ xs: 12, md: 6 }}> <Card variant="outlined" sx={{ height: "100%" }}> <CardContent> <LabeledField label={translate("resources.reports.fields.user_id")}> <ReferenceField source="user_id" reference="users" link="show" label={false}> <AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} /> <TextField source="id" sx={{ wordBreak: "break-all" }} /> </ReferenceField> </LabeledField> <LabeledField label={translate("resources.reports.fields.sender")}> <ReferenceField source="sender" reference="users" link="show" label={false}> <AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} /> <TextField source="id" sx={{ wordBreak: "break-all" }} /> </ReferenceField> </LabeledField> <LabeledField label={translate("resources.rooms.fields.room_id")}> <ReferenceField source="room_id" reference="rooms" link="show" label={false}> <AvatarField source="avatar" sx={{ height: "40px", width: "40px" }} /> <RoomInfoField /> </ReferenceField> </LabeledField> <LabeledField label={translate("resources.reports.fields.event_id")}> <TextField source="event_id" label={false} sx={{ wordBreak: "break-all" }} /> </LabeledField> </CardContent> </Card> </Grid> </Grid> ); }; const EventJsonField = () => { const record = useRecordContext(); if (!record?.event_json) return null; return ( <Box component="pre" sx={{ whiteSpace: "pre-wrap", wordBreak: "break-all", m: 0, p: 2, fontSize: { xs: "0.75rem", sm: "0.85rem" }, bgcolor: "action.hover", borderRadius: 1, overflow: "auto", maxWidth: "100%", }} > {JSON.stringify(record.event_json, null, 4)} </Box> ); }; const ReportShowActions = () => { const record = useRecordContext(); return ( <TopToolbar> <DeleteButton record={record} mutationMode="pessimistic" confirmTitle="resources.reports.action.erase.title" confirmContent="resources.reports.action.erase.content" /> </TopToolbar> ); }; export const ReportShow = (props: ShowProps) => { return ( <Show {...props} actions={<ReportShowActions />} title={<ReportTitle />} sx={{ "& .RaShow-card": { maxWidth: { xs: "100vw", sm: "calc(100vw - 32px)" }, overflowX: "auto" } }} > <TabbedShowLayout sx={{ "& .MuiTabs-scroller": { overflowX: "auto !important" } }}> <Tab label="ketesa.reports.tabs.basic" icon={<ViewListIcon />}> <ReportBasicTab /> </Tab> <Tab label="ketesa.reports.tabs.detail" icon={<PageviewIcon />} path="detail"> <EventJsonField /> </Tab> </TabbedShowLayout> </Show> ); }; ================================================ FILE: src/resources/reports/index.ts ================================================ import ReportIcon from "@mui/icons-material/Warning"; import { ResourceProps } from "react-admin"; import { ReportList } from "./List"; import { ReportShow } from "./Show"; export { ReportList } from "./List"; export { ReportShow } from "./Show"; const resource: ResourceProps = { name: "reports", icon: ReportIcon, list: ReportList, show: ReportShow, }; export default resource; ================================================ FILE: src/resources/room-directory/index.tsx ================================================ import RoomDirectoryIcon from "@mui/icons-material/FolderShared"; import { Box, useMediaQuery } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import { useMutation } from "@tanstack/react-query"; import { BooleanField, BulkDeleteButton, BulkDeleteButtonProps, Button, ButtonProps, DeleteButton, DeleteButtonProps, ExportButton, NumberField, Pagination, ResourceProps, SelectColumnsButton, SimpleList, TextField, TopToolbar, useCreate, useDataProvider, useListContext, useNotify, useRecordContext, useRefresh, useTranslate, useUnselectAll, } from "react-admin"; import { MakeAdminBtn } from "../rooms"; import AvatarField from "../../components/users/fields/AvatarField"; import { useDocTitle } from "../../components/hooks/useDocTitle"; import { Datagrid, EmptyState, List } from "../../components/layout"; const RoomDirectoryPagination = () => <Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />; export const RoomDirectoryUnpublishButton = (props: DeleteButtonProps) => { const translate = useTranslate(); return ( <DeleteButton {...props} label="resources.room_directory.action.erase" redirect={false} mutationMode="pessimistic" confirmTitle={translate("resources.room_directory.action.title", { smart_count: 1, })} confirmContent={translate("resources.room_directory.action.content", { smart_count: 1, })} resource="room_directory" icon={<RoomDirectoryIcon />} /> ); }; export const RoomDirectoryBulkUnpublishButton = (props: BulkDeleteButtonProps) => ( <BulkDeleteButton {...props} label="resources.room_directory.action.erase" mutationMode="pessimistic" confirmTitle="resources.room_directory.action.title" confirmContent="resources.room_directory.action.content" resource="room_directory" icon={<RoomDirectoryIcon />} /> ); export const RoomDirectoryBulkPublishButton = (props: ButtonProps) => { const { selectedIds } = useListContext(); const notify = useNotify(); const refresh = useRefresh(); const unselectAllRooms = useUnselectAll("rooms"); const dataProvider = useDataProvider(); const { mutate, isPending } = useMutation({ mutationFn: () => dataProvider.createMany("room_directory", { ids: selectedIds, data: {}, }), onSuccess: () => { notify("resources.room_directory.action.send_success"); unselectAllRooms(); refresh(); }, /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ onError: (error: any) => notify(error?.message || "resources.room_directory.action.send_failure", { type: "error", }), }); return ( <Button {...props} label="resources.room_directory.action.create" onClick={mutate} disabled={isPending}> <RoomDirectoryIcon /> </Button> ); }; export const RoomDirectoryPublishButton = (props: ButtonProps) => { const record = useRecordContext(); const notify = useNotify(); const refresh = useRefresh(); const [create, { isLoading }] = useCreate(); if (!record) { return null; } const handleSend = () => { create( "room_directory", { data: { id: record.id } }, { onSuccess: () => { notify("resources.room_directory.action.send_success"); refresh(); }, /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ onError: (error: any) => notify(error?.message || "resources.room_directory.action.send_failure", { type: "error", }), } ); }; return ( <Button {...props} label="resources.room_directory.action.create" onClick={handleSend} disabled={isLoading}> <RoomDirectoryIcon /> </Button> ); }; const RoomDirectoryListActions = () => { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const { total } = useListContext(); return ( <TopToolbar> {!isSmall && !!total && <SelectColumnsButton />} {!!total && <ExportButton />} </TopToolbar> ); }; export const RoomDirectoryList = () => { const translate = useTranslate(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); useDocTitle(translate("resources.room_directory.name", { smart_count: 2 })); return ( <List pagination={<RoomDirectoryPagination />} perPage={50} actions={<RoomDirectoryListActions />} empty={<EmptyState />} > {isSmall ? ( <SimpleList primaryText={record => ( <Box component="span" sx={{ wordBreak: "break-all" }}> {record.name || record.canonical_alias || record.room_id} </Box> )} secondaryText={record => ( <> {record.canonical_alias && ( <> <Box component="span" sx={{ wordBreak: "break-all" }}> {record.canonical_alias} </Box> <br /> </> )} {translate("resources.rooms.fields.joined_members")}: {record.num_joined_members ?? 0} </> )} rowClick="show" leftIcon={record => ( <AvatarField record={record} source="avatar_src" sx={{ height: "40px", width: "40px" }} /> )} /> ) : ( <Datagrid rowLabel={record => String(record.name || record.canonical_alias || record.id)} rowClick={id => "/rooms/" + id + "/show"} bulkActionButtons={<RoomDirectoryBulkUnpublishButton />} omit={["room_id", "canonical_alias", "topic"]} > <AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} label="resources.rooms.fields.avatar" /> <TextField source="name" sortable={false} label="resources.rooms.fields.name" /> <TextField source="room_id" sortable={false} label="resources.rooms.fields.room_id" sx={{ wordBreak: "break-all" }} /> <TextField source="canonical_alias" sortable={false} label="resources.rooms.fields.canonical_alias" sx={{ wordBreak: "break-all" }} /> <TextField source="topic" sortable={false} label="resources.rooms.fields.topic" /> <NumberField source="num_joined_members" sortable={false} label="resources.rooms.fields.joined_members" /> <BooleanField source="world_readable" sortable={false} label="resources.room_directory.fields.world_readable" /> <BooleanField source="guest_can_join" sortable={false} label="resources.room_directory.fields.guest_can_join" /> <MakeAdminBtn /> </Datagrid> )} </List> ); }; const resource: ResourceProps = { name: "room_directory", icon: RoomDirectoryIcon, list: RoomDirectoryList, }; export default resource; ================================================ FILE: src/resources/rooms/List.tsx ================================================ import HttpsIcon from "@mui/icons-material/Https"; import NoEncryptionIcon from "@mui/icons-material/NoEncryption"; import StorageIcon from "@mui/icons-material/Storage"; import PersonAddIcon from "@mui/icons-material/PersonAdd"; import PersonIcon from "@mui/icons-material/Person"; import Box from "@mui/material/Box"; import MuiList from "@mui/material/List"; import ListItemButton from "@mui/material/ListItemButton"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import { BooleanField, WrapperField, ExportButton, FilterButton, FunctionField, ListProps, NullableBooleanInput, Pagination, ReferenceField, SearchInput, SelectColumnsButton, TextField as RaTextField, TopToolbar, useGetMany, useRecordContext, useTranslate, useListContext, useNotify, Button as RaButton, Confirm, Link, useDataProvider, } from "react-admin"; import { RoomDirectoryBulkUnpublishButton, RoomDirectoryBulkPublishButton } from "../room-directory"; import AvatarField from "../../components/users/fields/AvatarField"; import { BlockRoomBulkButton, UnblockRoomBulkButton, BlockRoomByIdButton, } from "../../components/users/buttons/BlockRoomButton"; import DeleteRoomButton from "../../components/users/buttons/DeleteRoomButton"; import { DeleteRoomMediaBulkButton } from "../../components/users/buttons/DeleteAllMediaButton"; import { useDocTitle } from "../../components/hooks/useDocTitle"; import { Room } from "../../providers/types"; import { Datagrid, EmptyState, List } from "../../components/layout"; export const RoomPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />; export const MakeAdminBtn = () => { const record = useRecordContext() as Room; if (!record) { return null; } if (record.joined_local_members < 1) { return null; } const ownMXID = localStorage.getItem("user_id") || ""; const [open, setOpen] = useState(false); const [userIdValue, setUserIdValue] = useState(ownMXID); const dataProvider = useDataProvider(); const notify = useNotify(); const translate = useTranslate(); const { mutate, isPending } = useMutation({ mutationFn: async () => { const result = await dataProvider.makeRoomAdmin(record.room_id, userIdValue); if (!result.success) { throw new Error(result.error); } }, onSuccess: () => { notify("resources.rooms.action.make_admin.success", { type: "success" }); setOpen(false); setUserIdValue(""); }, onError: err => { const errorMessage = err instanceof Error ? err.message : "Unknown error"; notify("resources.rooms.action.make_admin.failure", { type: "error", messageArgs: { errMsg: errorMessage } }); setOpen(false); setUserIdValue(""); }, }); const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { setUserIdValue(event.target.value); }; const handleConfirm = async () => { mutate(); setOpen(false); }; const handleDialogClose = () => { setOpen(false); }; const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { if (event.key === "Enter") { handleConfirm(); } }; return ( <> <RaButton label="resources.rooms.action.make_admin.assign_admin" onClick={e => { e.stopPropagation(); setOpen(true); }} disabled={isPending} > <PersonIcon /> </RaButton> <Confirm isOpen={open} onConfirm={handleConfirm} onClose={handleDialogClose} confirm="resources.rooms.action.make_admin.confirm" cancel="ra.action.cancel" title={translate("resources.rooms.action.make_admin.title", { roomName: record.name ? record.name : record.room_id, })} content={ <> <Typography sx={{ marginBottom: 2, whiteSpace: "pre-line" }}> {translate("resources.rooms.action.make_admin.content")} </Typography> <TextField type="text" variant="filled" value={userIdValue} onChange={handleChange} onKeyDown={handleKeyDown} label={"Matrix ID"} /> </> } /> </> ); }; export const JoinUserBtn = () => { const record = useRecordContext() as Room; if (!record) { return null; } const [open, setOpen] = useState(false); const [userIdValue, setUserIdValue] = useState(""); const dataProvider = useDataProvider(); const notify = useNotify(); const translate = useTranslate(); const { mutate, isPending } = useMutation({ mutationFn: async () => { const result = await dataProvider.joinUserToRoom(record.room_id, userIdValue); if (!result.success) { throw new Error(result.error); } }, onSuccess: () => { notify("resources.rooms.action.join.success", { type: "success" }); setOpen(false); setUserIdValue(""); }, onError: err => { const errorMessage = err instanceof Error ? err.message : "Unknown error"; notify("resources.rooms.action.join.failure", { type: "error", messageArgs: { errMsg: errorMessage } }); setOpen(false); setUserIdValue(""); }, }); const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { setUserIdValue(event.target.value); }; const handleConfirm = async () => { mutate(); setOpen(false); }; const handleDialogClose = () => { setOpen(false); }; const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { if (event.key === "Enter") { handleConfirm(); } }; return ( <> <RaButton label="resources.rooms.action.join.label" onClick={e => { e.stopPropagation(); setOpen(true); }} disabled={isPending} > <PersonAddIcon /> </RaButton> <Confirm isOpen={open} onConfirm={handleConfirm} onClose={handleDialogClose} confirm="resources.rooms.action.join.confirm" cancel="ra.action.cancel" title={translate("resources.rooms.action.join.title", { roomName: record.name ? record.name : record.room_id, })} content={ <> <Typography sx={{ marginBottom: 2, whiteSpace: "pre-line" }}> {translate("resources.rooms.action.join.content")} </Typography> <TextField type="text" variant="filled" value={userIdValue} onChange={handleChange} onKeyDown={handleKeyDown} label={"Matrix ID"} /> </> } /> </> ); }; export const RoomBulkActionButtons = () => { const record = useListContext(); return ( <> <BlockRoomBulkButton /> <UnblockRoomBulkButton /> <RoomDirectoryBulkPublishButton /> <RoomDirectoryBulkUnpublishButton /> <DeleteRoomMediaBulkButton /> <DeleteRoomButton selectedIds={record.selectedIds} confirmTitle="resources.rooms.action.erase.title" confirmContent="resources.rooms.action.erase.content" /> </> ); }; const RoomSearchInput = (_props: { alwaysOn?: boolean }) => { const translate = useTranslate(); return ( <SearchInput source="search_term" slotProps={{ htmlInput: { "aria-label": translate("ra.action.search") } }} /> ); }; const roomFilters = [ <RoomSearchInput key="search_term" alwaysOn />, <NullableBooleanInput key="public_rooms" source="public_rooms" label="resources.rooms.filter.public_rooms" />, <NullableBooleanInput key="empty_rooms" source="empty_rooms" label="resources.rooms.filter.empty_rooms" />, ]; const RoomListActions = () => { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const { total } = useListContext(); return ( <TopToolbar> <RaButton component={Link} to="/database_room_statistics" label="resources.database_room_statistics.name"> <StorageIcon /> </RaButton> <FilterButton /> <BlockRoomByIdButton /> {!isSmall && !!total && <SelectColumnsButton />} {!!total && <ExportButton />} </TopToolbar> ); }; const RoomsMobileList = () => { const { data: rooms } = useListContext(); const theme = useTheme(); const translate = useTranslate(); const ids = (rooms || []).map(r => r.id); const { data: roomDetails } = useGetMany("rooms", { ids }, { enabled: ids.length > 0 }); const roomMap = new Map((roomDetails || []).map(r => [r.id, r])); if (!rooms?.length) return null; return ( <MuiList disablePadding> {rooms.map(record => { const room = roomMap.get(record.id) || record; return ( <ListItemButton key={record.id as string} component={Link} to={"/rooms/" + record.id + "/show"} sx={{ gap: 1, alignItems: "center" }} > <AvatarField record={room} source="avatar" sx={{ height: "40px", width: "40px" }} /> <Box sx={{ flex: 1, minWidth: 0 }}> <Typography variant="body1" sx={{ wordBreak: "break-all" }}> {record.name || record.canonical_alias || record.id} </Typography> <Typography variant="body2" color="text.secondary"> {translate("resources.rooms.fields.joined_members")}: {record.joined_members ?? 0} {record.creator && ( <> <br /> <Box component="span" sx={{ wordBreak: "break-all" }}> {translate("resources.rooms.fields.creator")}: {record.creator} </Box> </> )} </Typography> </Box> <Box sx={{ display: "flex", gap: 0.5, ml: 1 }}> {record.is_encrypted ? ( <Tooltip title={translate("resources.rooms.fields.encryption")}> <HttpsIcon fontSize="small" sx={{ color: theme.palette.success.main }} /> </Tooltip> ) : ( <Tooltip title={translate("resources.rooms.fields.encryption")}> <NoEncryptionIcon fontSize="small" sx={{ color: theme.palette.error.main }} /> </Tooltip> )} </Box> </ListItemButton> ); })} </MuiList> ); }; export const RoomList = (props: ListProps) => { const theme = useTheme(); const translate = useTranslate(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); useDocTitle(translate("resources.rooms.name", { smart_count: 2 })); return ( <List {...props} pagination={<RoomPagination />} sort={{ field: "name", order: "ASC" }} filters={roomFilters} actions={<RoomListActions />} perPage={50} empty={<EmptyState />} > {isSmall ? ( <RoomsMobileList /> ) : ( <Datagrid rowLabel={record => String(record.name || record.canonical_alias || record.id)} rowClick="show" bulkActionButtons={<RoomBulkActionButtons />} omit={["joined_local_members", "state_events", "version", "federatable", "join_rules"]} > <ReferenceField reference="rooms" source="id" label="resources.users.fields.avatar" link={false} sortable={false} > <AvatarField source="avatar" sx={{ height: "40px", width: "40px" }} /> </ReferenceField> <RaTextField source="id" label="resources.rooms.fields.room_id" sortable={false} /> <WrapperField source="encryption" sortBy="encryption" label="resources.rooms.fields.encryption"> <BooleanField source="is_encrypted" sortBy="encryption" TrueIcon={HttpsIcon} FalseIcon={NoEncryptionIcon} label={<HttpsIcon />} sx={{ [`& [data-testid="true"]`]: { color: theme.palette.success.main }, [`& [data-testid="false"]`]: { color: theme.palette.error.main }, }} /> </WrapperField> <FunctionField source="name" sx={{ wordBreak: "break-all", }} render={record => record["name"] || record["canonical_alias"] || record["id"]} label="resources.rooms.fields.name" /> <RaTextField source="joined_members" label="resources.rooms.fields.joined_members" /> <RaTextField source="joined_local_members" label="resources.rooms.fields.joined_local_members" /> <RaTextField source="state_events" label="resources.rooms.fields.state_events" /> <RaTextField source="version" label="resources.rooms.fields.version" /> <RaTextField source="join_rules" label="resources.rooms.fields.join_rules" /> <ReferenceField source="creator" reference="users"> <RaTextField source="id" label="resources.rooms.fields.creator" sx={{ wordBreak: "break-all" }} /> </ReferenceField> <BooleanField source="federatable" label="resources.rooms.fields.federatable" /> <BooleanField source="public" label="resources.rooms.fields.public" /> <WrapperField label="resources.rooms.fields.actions"> <MakeAdminBtn /> </WrapperField> </Datagrid> )} </List> ); }; ================================================ FILE: src/resources/rooms/Show.tsx ================================================ import EventIcon from "@mui/icons-material/Event"; import AccountTreeIcon from "@mui/icons-material/AccountTree"; import FastForwardIcon from "@mui/icons-material/FastForward"; import MessageIcon from "@mui/icons-material/Message"; import UserIcon from "@mui/icons-material/Group"; import GroupWorkIcon from "@mui/icons-material/GroupWork"; import HttpsIcon from "@mui/icons-material/Https"; import MeetingRoomIcon from "@mui/icons-material/MeetingRoom"; import NoEncryptionIcon from "@mui/icons-material/NoEncryption"; import PermMediaIcon from "@mui/icons-material/PermMedia"; import ViewListIcon from "@mui/icons-material/ViewList"; import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import FormControlLabel from "@mui/material/FormControlLabel"; import Switch from "@mui/material/Switch"; import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; import Chip from "@mui/material/Chip"; import Divider from "@mui/material/Divider"; import MuiList from "@mui/material/List"; import ListItemButton from "@mui/material/ListItemButton"; import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; import { BooleanField, DateField, DeleteButton, FunctionField, Link, NumberField, Pagination, ReferenceField, ReferenceManyField, Show, ShowProps, SimpleList, Tab, TabbedShowLayout, TextField as RaTextField, TopToolbar, useGetMany, useListContext, useLocale, useRecordContext, useTranslate, } from "react-admin"; import { RoomHierarchy } from "../../components/rooms/RoomHierarchy"; import { EventLookupDialog } from "../../components/rooms/EventLookupDialog"; import { RoomMessages } from "../../components/rooms/RoomMessages"; import AvatarField from "../../components/users/fields/AvatarField"; import { BlockRoomButton } from "../../components/users/buttons/BlockRoomButton"; import DeleteRoomButton from "../../components/users/buttons/DeleteRoomButton"; import { PurgeHistoryButton } from "../../components/users/buttons/PurgeHistoryButton"; import { QuarantineRoomMediaButton } from "../../components/users/buttons/QuarantineAllMediaButton"; import { DeleteRoomMediaButton } from "../../components/users/buttons/DeleteAllMediaButton"; import { useDocTitle } from "../../components/hooks/useDocTitle"; import { MediaIDField } from "../../components/media"; import { DATE_FORMAT } from "../../utils/date"; import { tt } from "../../utils/safety"; import { RoomDirectoryUnpublishButton, RoomDirectoryPublishButton } from "../room-directory"; import { MakeAdminBtn, JoinUserBtn, RoomPagination } from "./List"; import { Datagrid, EmptyState } from "../../components/layout"; const RoomTitle = () => { const record = useRecordContext(); const translate = useTranslate(); const baseTitle = translate("resources.rooms.name", 1); let name = ""; if (record) { let recordIdentifier = record.id as string; if (record.canonical_alias) { recordIdentifier = record.canonical_alias; } name = record.name ? `${record.name} (${recordIdentifier})` : recordIdentifier; } const pageTitle = record ? `${baseTitle}: ${name}` : baseTitle; useDocTitle(pageTitle); if (!record) { return null; } return ( <span> {baseTitle} <AvatarField source="avatar" sx={{ height: "25px", width: "25px" }} /> {name} </span> ); }; const RoomShowActions = () => { const record = useRecordContext(); if (!record) { return null; } const publishButton = record?.public ? <RoomDirectoryUnpublishButton /> : <RoomDirectoryPublishButton />; // FIXME: refresh after (un)publish return ( <TopToolbar sx={{ flexWrap: "wrap", gap: 0.5, whiteSpace: "normal" }}> {publishButton} <BlockRoomButton /> <PurgeHistoryButton /> <JoinUserBtn /> <MakeAdminBtn /> <DeleteRoomButton selectedIds={[record.id]} confirmTitle="resources.rooms.action.erase.title" confirmContent="resources.rooms.action.erase.content" /> </TopToolbar> ); }; const RoomOverviewTab = () => { const translate = useTranslate(); const record = useRecordContext(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); if (!record) return null; const isEncrypted = !!record.encryption; const rawRoomType = record.room_type ?? ""; const roomTypeKey = rawRoomType === "m.space" ? "space" : rawRoomType === "" ? "room" : rawRoomType; return ( <Box sx={{ display: "flex", flexDirection: "column", gap: 2, py: 1 }}> <Box sx={{ display: "flex", flexDirection: isSmall ? "column" : "row", gap: 2, alignItems: isSmall ? "center" : "flex-start", }} > <AvatarField source="avatar" sx={{ height: "96px", width: "96px" }} label="resources.rooms.fields.avatar" /> <Box sx={{ flex: 1, minWidth: 0 }}> <Typography variant="h6" sx={{ wordBreak: "break-word" }}> {record.name || record.canonical_alias || record.room_id} </Typography> <Typography variant="body2" color="text.secondary" sx={{ wordBreak: "break-all" }}> {record.room_id} </Typography> {record.canonical_alias && ( <Typography variant="body2" color="text.secondary" sx={{ mb: 0.5, wordBreak: "break-all" }}> {record.canonical_alias} </Typography> )} {record.topic && ( <Typography variant="body2" color="text.secondary" sx={{ mb: 1, whiteSpace: "pre-wrap" }}> {record.topic} </Typography> )} <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}> <Chip size="small" icon={isEncrypted ? <HttpsIcon fontSize="small" /> : <NoEncryptionIcon fontSize="small" />} label={isEncrypted ? record.encryption : translate("resources.rooms.enums.unencrypted")} color={isEncrypted ? "success" : "default"} variant="outlined" /> <Chip size="small" label={`v${record.version}`} variant="outlined" /> {record.public && ( <Chip size="small" label={translate("resources.rooms.fields.public")} color="info" variant="outlined" /> )} {record.federatable && ( <Chip size="small" label={translate("resources.rooms.fields.federatable")} variant="outlined" /> )} <Chip size="small" icon={ rawRoomType === "m.space" ? <GroupWorkIcon fontSize="small" /> : <MeetingRoomIcon fontSize="small" /> } label={tt(translate, `resources.rooms.enums.room_type.${roomTypeKey}`, roomTypeKey)} variant="outlined" /> </Box> </Box> </Box> <Divider /> <Box sx={{ display: "grid", gridTemplateColumns: isSmall ? "1fr" : "1fr 1fr", gap: 2, }} > <Card variant="outlined"> <CardContent> <Typography variant="subtitle2" color="text.secondary" gutterBottom> {translate("ketesa.rooms.tabs.detail")} </Typography> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, gap: 1 }}> <Box> <Typography variant="caption" color="text.secondary"> {translate("resources.rooms.fields.joined_members")} </Typography> <Typography variant="body2">{record.joined_members ?? "—"}</Typography> </Box> <Box> <Typography variant="caption" color="text.secondary"> {translate("resources.rooms.fields.joined_local_members")} </Typography> <Typography variant="body2">{record.joined_local_members ?? "—"}</Typography> </Box> <Box> <Typography variant="caption" color="text.secondary"> {translate("resources.rooms.fields.joined_local_devices")} </Typography> <Typography variant="body2">{record.joined_local_devices ?? "—"}</Typography> </Box> <Box> <Typography variant="caption" color="text.secondary"> {translate("resources.rooms.fields.state_events")} </Typography> <Typography variant="body2">{record.state_events ?? "—"}</Typography> </Box> </Box> </CardContent> </Card> <Card variant="outlined"> <CardContent> <Typography variant="subtitle2" color="text.secondary" gutterBottom> {translate("ketesa.rooms.tabs.permission")} </Typography> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, gap: 1 }}> <Box> <Typography variant="caption" color="text.secondary"> {translate("resources.rooms.fields.join_rules")} </Typography> <Typography variant="body2"> {record.join_rules ? tt(translate, `resources.rooms.enums.join_rules.${record.join_rules}`, record.join_rules) : "—"} </Typography> </Box> <Box> <Typography variant="caption" color="text.secondary"> {translate("resources.rooms.fields.guest_access")} </Typography> <Typography variant="body2"> {record.guest_access ? tt(translate, `resources.rooms.enums.guest_access.${record.guest_access}`, record.guest_access) : "—"} </Typography> </Box> <Box> <Typography variant="caption" color="text.secondary"> {translate("resources.rooms.fields.history_visibility")} </Typography> <Typography variant="body2"> {record.history_visibility ? tt( translate, `resources.rooms.enums.history_visibility.${record.history_visibility}`, record.history_visibility ) : "—"} </Typography> </Box> <Box> <Typography variant="caption" color="text.secondary"> {translate("resources.rooms.fields.creator")} </Typography> <Typography variant="body2" sx={{ wordBreak: "break-all" }}> <ReferenceField source="creator" reference="users" link="show"> <RaTextField source="id" /> </ReferenceField> </Typography> </Box> </Box> </CardContent> </Card> </Box> </Box> ); }; const ClickableEventId = ({ eventId, onClick }: { eventId: string; onClick: (eventId: string) => void }) => ( <Box component="button" type="button" onClick={() => onClick(eventId)} sx={{ all: "unset", cursor: "pointer", color: "primary.main", textDecoration: "underline", wordBreak: "break-all", }} > {eventId} </Box> ); const ForwardExtremitiesTab = () => { const translate = useTranslate(); const locale = useLocale(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const [lookupEventId, setLookupEventId] = useState<string | null>(null); return ( <> <Box sx={{ fontFamily: "Roboto, Helvetica, Arial, sans-serif", margin: "0.5em", }} > {translate("resources.rooms.helper.forward_extremities")} </Box> <ReferenceManyField reference="forward_extremities" target="room_id" label={false} pagination={<Pagination />} perPage={10} > {isSmall ? ( <SimpleList empty={<EmptyState resource="forward_extremities" />} primaryText={record => <ClickableEventId eventId={record.id} onClick={setLookupEventId} />} secondaryText={record => ( <> {record.received_ts && new Date(record.received_ts).toLocaleString(locale)} {record.state_group && ( <> {" "} · {translate("resources.forward_extremities.fields.state_group")}: {record.state_group} </> )} </> )} rowClick={false} /> ) : ( <Datagrid sx={{ width: "100%" }} bulkActionButtons={false} omit={["depth", "received_ts"]} empty={<EmptyState resource="forward_extremities" />} > <FunctionField source="id" sortable={false} render={record => <ClickableEventId eventId={record.id} onClick={setLookupEventId} />} /> <DateField source="received_ts" showTime options={DATE_FORMAT} sortable={false} locales={locale} /> <NumberField source="depth" sortable={false} /> <RaTextField source="state_group" sortable={false} /> </Datagrid> )} </ReferenceManyField> <EventLookupDialog open={!!lookupEventId} onClose={() => setLookupEventId(null)} initialEventId={lookupEventId ?? undefined} /> </> ); }; const RoomStateTab = () => { const locale = useLocale(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const [lookupEventId, setLookupEventId] = useState<string | null>(null); return ( <> <ReferenceManyField reference="room_state" target="room_id" label={false} pagination={<Pagination />} perPage={10} > {isSmall ? ( <SimpleList empty={<EmptyState resource="room_state" />} primaryText={record => record.type} secondaryText={record => ( <> {record.origin_server_ts && new Date(record.origin_server_ts).toLocaleString(locale)} {record.sender && ( <> <br /> <Box component="span" sx={{ wordBreak: "break-all" }}> {record.sender} </Box> </> )} <Box component="pre" sx={{ whiteSpace: "pre-wrap", wordBreak: "break-all", m: 0, mt: 0.5, p: 1, fontSize: "0.75rem", bgcolor: "action.hover", borderRadius: 1, overflow: "auto", maxWidth: "100%", }} > {JSON.stringify(record.content, null, 2)} </Box> </> )} rowClick={id => { setLookupEventId(String(id)); return false; }} /> ) : ( <Datagrid sx={{ width: "100%" }} bulkActionButtons={false} empty={<EmptyState resource="room_state" />} rowClick={id => { setLookupEventId(String(id)); return false; }} > <RaTextField source="type" sortable={false} /> <DateField source="origin_server_ts" showTime options={DATE_FORMAT} sortable={false} locales={locale} /> <FunctionField source="content" sortable={false} render={record => `${JSON.stringify(record.content, null, 2)}`} /> <ReferenceField source="sender" reference="users" sortable={false}> <RaTextField source="id" sx={{ wordBreak: "break-all" }} /> </ReferenceField> </Datagrid> )} </ReferenceManyField> <EventLookupDialog open={!!lookupEventId} onClose={() => setLookupEventId(null)} initialEventId={lookupEventId ?? undefined} /> </> ); }; const RoomMembersMobileList = () => { const { data: members } = useListContext(); const ids = (members || []).map(r => r.id); const { data: users } = useGetMany("users", { ids }, { enabled: ids.length > 0 }); const userMap = new Map((users || []).map(u => [u.id, u])); if (!members?.length) return null; return ( <MuiList disablePadding> {members.map(record => { const user = userMap.get(record.id); return ( <ListItemButton key={record.id as string} component={Link} to={"/users/" + record.id} sx={{ gap: 1, alignItems: "center" }} > <AvatarField record={user || record} source="avatar_src" sx={{ height: "40px", width: "40px" }} /> <Box sx={{ flex: 1, minWidth: 0 }}> <Typography variant="body1" sx={{ wordBreak: "break-all" }}> {user?.displayname || record.id} </Typography> {user?.displayname && ( <Typography variant="body2" color="text.secondary" sx={{ wordBreak: "break-all" }}> {record.id} </Typography> )} </Box> </ListItemButton> ); })} </MuiList> ); }; const RoomShowLayout = () => { const record = useRecordContext(); const translate = useTranslate(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const isSpace = record?.room_type === "m.space"; const [localMembersOnly, setLocalMembersOnly] = useState(false); return ( <TabbedShowLayout sx={{ "& .MuiTabs-scroller": { overflowX: "auto !important" } }}> <Tab label="ketesa.rooms.tabs.basic" icon={<ViewListIcon />}> <RoomOverviewTab /> </Tab> <Tab label="ketesa.rooms.tabs.members" icon={<UserIcon />} path="members"> <MakeAdminBtn /> <Box sx={{ px: 2, pt: 1 }}> <FormControlLabel control={ <Switch checked={localMembersOnly} onChange={e => setLocalMembersOnly(e.target.checked)} size="small" /> } label={translate("resources.rooms.filter.local_members_only")} /> </Box> <ReferenceManyField reference="room_members" target="room_id" label={false} perPage={10} pagination={<RoomPagination />} filter={{ localOnly: localMembersOnly }} > {isSmall ? ( <RoomMembersMobileList /> ) : ( <Datagrid sx={{ width: "100%" }} rowClick={id => "/users/" + id} bulkActionButtons={false} empty={<EmptyState resource="room_members" />} > <ReferenceField label="resources.users.fields.avatar" source="id" reference="users" sortable={false} link="" > <AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} /> </ReferenceField> <RaTextField source="id" sortable={false} label="resources.users.fields.id" sx={{ wordBreak: "break-all" }} /> <ReferenceField label="resources.users.fields.displayname" source="id" reference="users" sortable={false} link="" > <RaTextField source="displayname" sortable={false} /> </ReferenceField> <ReferenceField label="resources.users.fields.is_guest" source="id" reference="users" sortable={false} link="" > <BooleanField source="is_guest" label="resources.users.fields.is_guest" /> </ReferenceField> <ReferenceField label="resources.users.fields.deactivated" source="id" reference="users" sortable={false} link="" > <BooleanField source="deactivated" label="resources.users.fields.deactivated" /> </ReferenceField> <ReferenceField label="resources.users.fields.locked" source="id" reference="users" sortable={false} link="" > <BooleanField source="locked" label="resources.users.fields.locked" /> </ReferenceField> <ReferenceField label="resources.users.fields.erased" source="id" reference="users" sortable={false} link="" > <BooleanField source="erased" sortable={false} label="resources.users.fields.erased" /> </ReferenceField> </Datagrid> )} </ReferenceManyField> </Tab> <Tab label="ketesa.rooms.tabs.media" icon={<PermMediaIcon />} path="media"> <Alert severity="warning">{translate("resources.room_media.helper.info")}</Alert> <QuarantineRoomMediaButton /> <DeleteRoomMediaButton /> <ReferenceManyField reference="room_media" target="room_id" label={false} pagination={<Pagination />} perPage={10} > {isSmall ? ( <SimpleList empty={<EmptyState resource="room_media" />} primaryText={() => ( <Box sx={{ wordBreak: "break-all" }}> <MediaIDField source="media_id" /> </Box> )} tertiaryText={() => <DeleteButton mutationMode="pessimistic" redirect={false} />} rowClick={false} /> ) : ( <Datagrid sx={{ width: "100%" }} bulkActionButtons={false} empty={<EmptyState resource="room_media" />}> <MediaIDField source="media_id" /> <DeleteButton mutationMode="pessimistic" redirect={false} /> </Datagrid> )} </ReferenceManyField> </Tab> <Tab label={translate("resources.room_state.name", { smart_count: 2 })} icon={<EventIcon />} path="state"> <RoomStateTab /> </Tab> <Tab label="ketesa.rooms.tabs.messages" icon={<MessageIcon />} path="messages"> <RoomMessages /> </Tab> {isSpace && ( <Tab label="ketesa.rooms.tabs.hierarchy" icon={<AccountTreeIcon />} path="hierarchy"> <RoomHierarchy /> </Tab> )} <Tab label="resources.forward_extremities.name" icon={<FastForwardIcon />} path="forward_extremities"> <ForwardExtremitiesTab /> </Tab> </TabbedShowLayout> ); }; export const RoomShow = (props: ShowProps) => { return ( <Show {...props} actions={<RoomShowActions />} title={<RoomTitle />} sx={{ "& .RaShow-card": { maxWidth: { xs: "100vw", sm: "calc(100vw - 32px)" }, overflowX: "auto" } }} > <RoomShowLayout /> </Show> ); }; ================================================ FILE: src/resources/rooms/index.ts ================================================ import ViewListIcon from "@mui/icons-material/ViewList"; import { ResourceProps } from "react-admin"; import { RoomList } from "./List"; import { RoomShow } from "./Show"; export { MakeAdminBtn, JoinUserBtn, RoomBulkActionButtons, RoomList } from "./List"; export { RoomShow } from "./Show"; const resource: ResourceProps = { name: "rooms", icon: ViewListIcon, list: RoomList, show: RoomShow, }; export default resource; ================================================ FILE: src/resources/scheduled-tasks/List.tsx ================================================ import { Box, Chip } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { DateField, DateTimeInput, FunctionField, ListProps, Pagination, SelectInput, SimpleList, TextField, TextInput, useLocale, useTranslate, } from "react-admin"; import { useDocTitle } from "../../components/hooks/useDocTitle"; import { DATE_FORMAT, dateParser } from "../../utils/date"; import { JSONStringify } from "../../utils/safety"; import { Datagrid, EmptyState, List } from "../../components/layout"; const ScheduledTaskPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />; const statusColors: Record<string, "default" | "info" | "success" | "warning" | "error"> = { scheduled: "default", active: "info", complete: "success", cancelled: "warning", failed: "error", }; const StatusChip = ({ status }: { status: string }) => { const translate = useTranslate(); return ( <Chip label={translate(`resources.scheduled_tasks.status.${status}`, { _: status })} color={statusColors[status] || "default"} size="small" /> ); }; const scheduledTaskFilters = (translate: ReturnType<typeof useTranslate>) => [ <SelectInput key="status" source="status" choices={["scheduled", "active", "complete", "cancelled", "failed"].map(s => ({ id: s, name: translate(`resources.scheduled_tasks.status.${s}`), }))} label="resources.scheduled_tasks.fields.status" />, <TextInput key="action_name" source="action_name" label="resources.scheduled_tasks.fields.action" />, <TextInput key="resource_id" source="resource_id" label="resources.scheduled_tasks.fields.resource_id" />, <DateTimeInput key="max_timestamp" source="max_timestamp" label="resources.scheduled_tasks.fields.max_timestamp" parse={dateParser} />, ]; export const ScheduledTaskList = (props: ListProps) => { const locale = useLocale(); const translate = useTranslate(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); useDocTitle(translate("resources.scheduled_tasks.name", { smart_count: 2 })); return ( <List {...props} pagination={<ScheduledTaskPagination />} perPage={50} sort={{ field: "id", order: "DESC" }} filters={scheduledTaskFilters(translate)} empty={<EmptyState />} > {isSmall ? ( <SimpleList primaryText={record => ( <> {record.action} <StatusChip status={record.status} /> </> )} secondaryText={record => new Date(record.timestamp_ms).toLocaleDateString(locale, DATE_FORMAT)} tertiaryText={record => record.resource_id ? ( <Box component="span" sx={{ wordBreak: "break-all" }}> {record.resource_id} </Box> ) : ( "" ) } rowClick={false} /> ) : ( <Datagrid bulkActionButtons={false}> <TextField source="id" sortable={false} label="resources.scheduled_tasks.fields.id" /> <TextField source="action" sortable={false} label="resources.scheduled_tasks.fields.action" /> <FunctionField source="status" sortable={false} label="resources.scheduled_tasks.fields.status" render={record => <StatusChip status={record.status} />} /> <DateField source="timestamp_ms" showTime options={DATE_FORMAT} sortable={false} label="resources.scheduled_tasks.fields.timestamp" locales={locale} /> <TextField source="resource_id" sortable={false} label="resources.scheduled_tasks.fields.resource_id" sx={{ wordBreak: "break-all" }} /> <FunctionField source="result" sortable={false} label="resources.scheduled_tasks.fields.result" render={record => JSONStringify(record.result)} /> <TextField source="error" sortable={false} label="resources.scheduled_tasks.fields.error" /> </Datagrid> )} </List> ); }; ================================================ FILE: src/resources/scheduled-tasks/index.ts ================================================ import ScheduleIcon from "@mui/icons-material/Schedule"; import { ResourceProps } from "react-admin"; import { ScheduledTaskList } from "./List"; export { ScheduledTaskList } from "./List"; const resource: ResourceProps = { name: "scheduled_tasks", icon: ScheduleIcon, list: ScheduledTaskList, }; export default resource; ================================================ FILE: src/resources/statistics/DatabaseRooms.tsx ================================================ import StorageIcon from "@mui/icons-material/Storage"; import Box from "@mui/material/Box"; import MuiList from "@mui/material/List"; import ListItemButton from "@mui/material/ListItemButton"; import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { ExportButton, FunctionField, Link, ListProps, ReferenceField, ResourceProps, TextField, TopToolbar, useGetMany, useListContext, useTranslate, } from "react-admin"; import AvatarField from "../../components/users/fields/AvatarField"; import { useDocTitle } from "../../components/hooks/useDocTitle"; import { formatBytes } from "../../utils/formatBytes"; import { Datagrid, EmptyState, List } from "../../components/layout"; const ListActions = () => { const { total } = useListContext(); return <TopToolbar>{!!total && <ExportButton />}</TopToolbar>; }; const DatabaseRoomStatsMobileList = () => { const { data: stats } = useListContext(); const translate = useTranslate(); const ids = (stats || []).map(r => r.id); const { data: rooms } = useGetMany("rooms", { ids }, { enabled: ids.length > 0 }); const roomMap = new Map((rooms || []).map(r => [r.id, r])); if (!stats?.length) return null; return ( <MuiList disablePadding> {stats.map(record => { const room = roomMap.get(record.id); return ( <ListItemButton key={record.id as string} component={Link} to={`/rooms/${encodeURIComponent(record.id as string)}/show`} sx={{ gap: 1, alignItems: "center" }} > <AvatarField record={room || record} source="avatar" sx={{ height: "40px", width: "40px" }} /> <Box sx={{ flex: 1, minWidth: 0 }}> <Typography variant="body1" sx={{ wordBreak: "break-all" }}> {room?.name || room?.canonical_alias || record.id} </Typography> <Typography variant="body2" color="text.secondary"> {translate("resources.rooms.fields.joined_members")}: {room?.joined_members ?? 0} </Typography> </Box> <Typography variant="body2" color="text.secondary" sx={{ whiteSpace: "nowrap" }}> {formatBytes(record.estimated_size)} </Typography> </ListItemButton> ); })} </MuiList> ); }; export const DatabaseRoomStatsList = (props: ListProps) => { const translate = useTranslate(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); useDocTitle(translate("resources.database_room_statistics.name", { smart_count: 2 })); return ( <List {...props} resource="database_room_statistics" actions={<ListActions />} pagination={false} perPage={100} empty={<EmptyState />} > <Box sx={{ fontFamily: "Roboto, Helvetica, Arial, sans-serif", margin: "0.5em" }}> {translate("resources.database_room_statistics.helper.info")} </Box> {isSmall ? ( <DatabaseRoomStatsMobileList /> ) : ( <Datagrid rowClick={id => `/rooms/${encodeURIComponent(id as string)}/show`} bulkActionButtons={false}> <ReferenceField label="resources.rooms.fields.avatar" source="id" reference="rooms" sortable={false} link=""> <AvatarField source="avatar" sx={{ height: "40px", width: "40px" }} /> </ReferenceField> <TextField source="room_id" sortable={false} /> <ReferenceField label="resources.rooms.fields.canonical_alias" source="id" reference="rooms" sortable={false} link="" > <TextField source="canonical_alias" /> </ReferenceField> <ReferenceField label="resources.rooms.fields.name" source="id" reference="rooms" sortable={false} link=""> <TextField source="name" /> </ReferenceField> <ReferenceField label="resources.rooms.fields.joined_members" source="id" reference="rooms" sortable={false} link="" > <TextField source="joined_members" /> </ReferenceField> <FunctionField source="estimated_size" sortable={false} render={(record: { estimated_size: number }) => formatBytes(record.estimated_size)} /> </Datagrid> )} </List> ); }; const resource: ResourceProps = { name: "database_room_statistics", icon: StorageIcon, list: DatabaseRoomStatsList, }; export default resource; ================================================ FILE: src/resources/statistics/UserMedia.tsx ================================================ import PermMediaIcon from "@mui/icons-material/PermMedia"; import { Box, useMediaQuery } from "@mui/material"; import MuiList from "@mui/material/List"; import ListItemButton from "@mui/material/ListItemButton"; import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import { BooleanField, ExportButton, FunctionField, Link, ListProps, NumberField, Pagination, ReferenceField, ResourceProps, SearchInput, TextField, TopToolbar, useGetMany, useListContext, useTranslate, } from "react-admin"; import AvatarField from "../../components/users/fields/AvatarField"; import { useDocTitle } from "../../components/hooks/useDocTitle"; import { formatBytes } from "../../utils/formatBytes"; import { DeleteMediaButton, PurgeRemoteMediaButton } from "../../components/media"; import { Datagrid, EmptyState, List } from "../../components/layout"; const ListActions = () => { const { total } = useListContext(); return ( <TopToolbar> <DeleteMediaButton /> <PurgeRemoteMediaButton /> {!!total && <ExportButton />} </TopToolbar> ); }; const UserMediaStatsPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />; const userMediaStatsFilters = [<SearchInput source="search_term" alwaysOn />]; const UserMediaMobileList = () => { const { data: stats } = useListContext(); const translate = useTranslate(); const ids = (stats || []).map(r => r.id); const { data: users } = useGetMany("users", { ids }, { enabled: ids.length > 0 }); const userMap = new Map((users || []).map(u => [u.id, u])); if (!stats?.length) return null; return ( <MuiList disablePadding> {stats.map(record => { const user = userMap.get(record.id); return ( <ListItemButton key={record.id as string} component={Link} to={"/users/" + record.id + "/media"} sx={{ gap: 1, alignItems: "center" }} > <AvatarField record={user || record} source="avatar_src" sx={{ height: "40px", width: "40px" }} /> <Box sx={{ flex: 1, minWidth: 0 }}> <Typography variant="body1" sx={{ wordBreak: "break-all" }}> {record.displayname || record.user_id} </Typography> <Typography variant="body2" color="text.secondary"> {record.displayname && ( <Box component="span" sx={{ wordBreak: "break-all", display: "block" }}> {record.user_id} </Box> )} {translate("resources.user_media_statistics.fields.media_count")}: {record.media_count} {" · "} {translate("resources.user_media_statistics.fields.media_length")}: {formatBytes(record.media_length)} </Typography> </Box> </ListItemButton> ); })} </MuiList> ); }; export const UserMediaStatsList = (props: ListProps) => { const translate = useTranslate(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); useDocTitle(translate("resources.user_media_statistics.name", { smart_count: 2 })); return ( <List {...props} actions={<ListActions />} filters={userMediaStatsFilters} pagination={<UserMediaStatsPagination />} sort={{ field: "media_length", order: "DESC" }} perPage={50} empty={<EmptyState />} > {isSmall ? ( <UserMediaMobileList /> ) : ( <Datagrid rowClick={id => "/users/" + id + "/media"} bulkActionButtons={false}> <ReferenceField label="resources.users.fields.avatar" source="id" reference="users" sortable={false} link=""> <AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} /> </ReferenceField> <TextField source="user_id" label="resources.users.fields.id" sx={{ wordBreak: "break-all" }} /> <TextField source="displayname" label="resources.users.fields.displayname" /> <NumberField source="media_count" /> <FunctionField source="media_length" render={record => formatBytes(record.media_length)} /> <ReferenceField label="resources.users.fields.is_guest" source="id" reference="users" sortable={false} link="" > <BooleanField source="is_guest" label="resources.users.fields.is_guest" /> </ReferenceField> <ReferenceField label="resources.users.fields.deactivated" source="id" reference="users" sortable={false} link="" > <BooleanField source="deactivated" label="resources.users.fields.deactivated" /> </ReferenceField> <ReferenceField label="resources.users.fields.locked" source="id" reference="users" sortable={false} link=""> <BooleanField source="locked" label="resources.users.fields.locked" /> </ReferenceField> <ReferenceField label="resources.users.fields.erased" source="id" reference="users" sortable={false} link=""> <BooleanField source="erased" sortable={false} label="resources.users.fields.erased" /> </ReferenceField> </Datagrid> )} </List> ); }; const resource: ResourceProps = { name: "user_media_statistics", icon: PermMediaIcon, list: UserMediaStatsList, }; export default resource; ================================================ FILE: src/resources/statistics/index.ts ================================================ export { DatabaseRoomStatsList } from "./DatabaseRooms"; export { UserMediaStatsList } from "./UserMedia"; ================================================ FILE: src/resources/users/Create.tsx ================================================ import { Typography } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import { useState } from "react"; import { ArrayInput, BooleanInput, Confirm, Create, CreateProps, SelectInput, SimpleForm, SimpleFormIterator, TextInput, maxLength, required, useCreate, useDataProvider, useNotify, useRedirect, useTranslate, } from "react-admin"; import { useDocTitle } from "../../components/hooks/useDocTitle"; import { User, UsernameAvailabilityResult } from "../../providers/types"; import type { SynapseDataProvider } from "../../providers/types"; import { isMAS } from "../../providers/data/mas"; import { choices_medium, choices_type, validateUser, validateAddress, UserPasswordInput } from "./Edit"; export const UserCreate = (props: CreateProps) => { if (isMAS()) { return <MASUserCreate {...props} />; } return <SynapseUserCreate {...props} />; }; const MASUserCreate = (props: CreateProps) => { const translate = useTranslate(); const dataProvider = useDataProvider() as SynapseDataProvider; const redirect = useRedirect(); const notify = useNotify(); const [create] = useCreate(); useDocTitle(translate("ra.action.create_item", { item: translate("resources.users.name") })); /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const handleSubmit = async (data: Record<string, any>) => { let record: User; try { record = await create("users", { data: { username: data.username } }, { returnPromise: true }); } catch { return; // RA shows error notification automatically } const masId = (record as unknown as Record<string, unknown>)?.mas_id as string | undefined; if (masId && data.admin) { try { await dataProvider.masSetAdmin(masId, true); } catch (e) { console.error("masSetAdmin failed:", e); } } if (masId && data.password) { const result = await dataProvider.masSetPassword(masId, data.password); if (!result.success) { notify(result.error || "resources.users.action.password.failure", { type: "warning" }); } } notify("ra.notification.created", { messageArgs: { smart_count: 1 } }); redirect(() => `users/${encodeURIComponent(record.id as string)}`); }; return ( <Create {...props}> <SimpleForm onSubmit={handleSubmit}> <TextInput source="username" required autoComplete="off" /> <UserPasswordInput source="password" autoComplete="new-password" /> <BooleanInput source="admin" /> </SimpleForm> </Create> ); }; const SynapseUserCreate = (props: CreateProps) => { const dataProvider = useDataProvider(); const translate = useTranslate(); const redirect = useRedirect(); const notify = useNotify(); const theme = useTheme(); useDocTitle(translate("ra.action.create_item", { item: translate("resources.users.name") })); const [open, setOpen] = useState(false); const [userIsAvailable, setUserIsAvailable] = useState<boolean | undefined>(); const [userAvailabilityEl, setUserAvailabilityEl] = useState<React.ReactElement | false>( <Typography component="span"></Typography> ); /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const [formData, setFormData] = useState<Record<string, any>>({}); const [create] = useCreate(); const checkAvailability = async (event: React.FocusEvent<HTMLInputElement>) => { const username = event.target.value; const result: UsernameAvailabilityResult = await dataProvider.checkUsernameAvailability(username); setUserIsAvailable(!!result?.available); if (result?.available) { setUserAvailabilityEl( <Typography component="span" variant="body2" sx={{ color: theme.palette.success.main }}> ✔️ {translate("resources.users.helper.username_available")} </Typography> ); } else { setUserAvailabilityEl( <Typography component="span" variant="body2" sx={{ color: theme.palette.warning.main }}> ⚠️ {result?.error || "unknown error"} </Typography> ); } }; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const postSave = (data: Record<string, any>) => { setFormData(data); if (!userIsAvailable) { setOpen(true); return; } create( "users", { data: data }, { onSuccess: (resource: User) => { notify("ra.notification.created", { messageArgs: { smart_count: 1 } }); redirect(() => { return `users/${encodeURIComponent(resource.id as string)}`; }); }, } ); }; const handleConfirm = () => { setOpen(false); updateUser(); }; const handleDialogClose = () => { setOpen(false); }; const updateUser = () => { create( "users", { data: formData }, { onSuccess: (resource: User) => { notify("ra.notification.updated", { messageArgs: { smart_count: 1 } }); redirect(() => { return `users/${encodeURIComponent(resource.id as string)}`; }); }, } ); }; return ( <Create {...props}> <SimpleForm onSubmit={postSave}> <TextInput source="id" autoComplete="off" validate={validateUser} onBlur={checkAvailability} helperText={userAvailabilityEl} /> <TextInput source="displayname" validate={maxLength(256)} /> <UserPasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" /> <SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable /> <BooleanInput source="admin" /> <ArrayInput source="threepids"> <SimpleFormIterator disableReordering> <SelectInput source="medium" choices={choices_medium} validate={required()} /> <TextInput source="address" validate={validateAddress} /> </SimpleFormIterator> </ArrayInput> <ArrayInput source="external_ids" label="ketesa.users.tabs.sso"> <SimpleFormIterator disableReordering> <TextInput source="auth_provider" validate={required()} /> <TextInput source="external_id" label="resources.users.fields.id" validate={required()} /> </SimpleFormIterator> </ArrayInput> </SimpleForm> <Confirm isOpen={open} title="resources.users.action.overwrite_title" content="resources.users.action.overwrite_content" onConfirm={handleConfirm} onClose={handleDialogClose} confirm="resources.users.action.overwrite_confirm" cancel="resources.users.action.overwrite_cancel" /> </Create> ); }; // end SynapseUserCreate ================================================ FILE: src/resources/users/Edit.tsx ================================================ import AssignmentIndIcon from "@mui/icons-material/AssignmentInd"; import HttpsIcon from "@mui/icons-material/Https"; import ContactMailIcon from "@mui/icons-material/ContactMail"; import DevicesIcon from "@mui/icons-material/Devices"; import DocumentScannerIcon from "@mui/icons-material/DocumentScanner"; import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted"; import LockClockIcon from "@mui/icons-material/LockClock"; import ManageAccountsIcon from "@mui/icons-material/ManageAccounts"; import NotificationsIcon from "@mui/icons-material/Notifications"; import PermMediaIcon from "@mui/icons-material/PermMedia"; import PersonPinIcon from "@mui/icons-material/PersonPin"; import ScienceIcon from "@mui/icons-material/Science"; import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent"; import ViewListIcon from "@mui/icons-material/ViewList"; import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings"; import BlockIcon from "@mui/icons-material/Block"; import DeleteForeverIcon from "@mui/icons-material/DeleteForever"; import DeleteIcon from "@mui/icons-material/Delete"; import LockIcon from "@mui/icons-material/Lock"; import NoAccountsIcon from "@mui/icons-material/NoAccounts"; import VisibilityIcon from "@mui/icons-material/Visibility"; import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; import { Box, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Button as MuiButton, Divider, FormControl, InputLabel, List as MuiList, ListItem, ListItemButton, ListItemText, MenuItem, Paper, Select, Tab, Tabs, TextField as MuiTextField, Typography, } from "@mui/material"; import IconButton from "@mui/material/IconButton"; import InputAdornment from "@mui/material/InputAdornment"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useEffect, useState } from "react"; import { ArrayInput, ArrayField, Button, BulkDeleteButton, DateField, DeleteButton, Edit, EditProps, Loading, TabbedForm, TabbedFormTabs, FormTab, BooleanField, BooleanInput, FunctionField, ListContextProvider, maxLength, Pagination, PasswordInput, ReferenceManyField, ReferenceField, regex, required, ResourceContextProvider, SaveButton, SelectInput, SimpleFormIterator, SimpleList, TextField, TextInput, Toolbar, ToolbarClasses, TopToolbar, WrapperField, useDataProvider, useGetList, useGetMany, useList, useLocale, useNotify, useRecordContext, useRefresh, useTranslate, Link, Confirm, useListContext, } from "react-admin"; import { useFormContext } from "react-hook-form"; import { MakeAdminBtn, RoomBulkActionButtons } from "../rooms"; import AvatarField from "../../components/users/fields/AvatarField"; import EditableAvatarField from "../../components/users/fields/EditableAvatarField"; import DeleteUserButton from "../../components/users/buttons/DeleteUserButton"; import { AllowCrossSigningButton } from "../../components/users/buttons/AllowCrossSigningButton"; import DeviceCreateButton from "../../components/users/buttons/DeviceCreateButton"; import { RenewAccountValidityButton } from "../../components/users/buttons/RenewAccountValidityButton"; import { useIsMAS } from "../../providers/data/mas"; import DeviceDisplayNameInput from "../../components/users/DeviceDisplayNameInput"; import DeviceRemoveButton, { DeviceBulkRemoveButton } from "../../components/users/buttons/DeviceRemoveButton"; import ExperimentalFeaturesList from "../../components/users/ExperimentalFeatures"; import { LoginAsUserButton } from "../../components/users/buttons/LoginAsUserButton"; import { ResetPasswordButton } from "../../components/users/buttons/ResetPasswordButton"; import { ServerNoticeButton } from "../../components/users/ServerNotices"; import UserAccountData from "../../components/users/UserAccountData"; import UserInfoChips from "../../components/users/UserCounts"; import UserRateLimits from "../../components/users/UserRateLimits"; import { useDocTitle } from "../../components/hooks/useDocTitle"; import { MediaIDField, ProtectMediaButton, QuarantineMediaButton } from "../../components/media"; import { QuarantineUserMediaButton } from "../../components/users/buttons/QuarantineAllMediaButton"; import { DeleteUserMediaButton } from "../../components/users/buttons/DeleteAllMediaButton"; import { SynapseDataProvider } from "../../providers/types"; import { FinishCompatSessionButton, FinishOAuth2SessionButton, RevokePersonalSessionButton, FinishUserSessionButton, DeleteOAuthLinkButton, } from "../mas"; import { isMAS } from "../../providers/data/mas"; import { GetConfig } from "../../utils/config"; import { DATE_FORMAT } from "../../utils/date"; import { decodeURLComponent } from "../../utils/safety"; import { isSystemUser } from "../../utils/mxid"; import { formatBytes } from "../../utils/formatBytes"; import { generateRandomPassword } from "../../utils/password"; import { UserPreventSelfDelete } from "./List"; import { Datagrid, EmptyState } from "../../components/layout"; // Shared constants — also used by Create.tsx export const choices_medium = [ { id: "email", name: "resources.users.email" }, { id: "msisdn", name: "resources.users.msisdn" }, ]; export const choices_type = [ { id: "bot", name: "bot" }, { id: "support", name: "support" }, ]; // https://matrix.org/docs/spec/appendices#user-identifiers // here only local part of user_id // maxLength = 255 - "@" - ":" - storage.getItem("home_server").length // storage.getItem("home_server").length is not valid here export const validateUser = [required(), maxLength(253), regex(/^[a-z0-9._=\-+/]+$/, "ketesa.users.invalid_user_id")]; export const validateAddress = [required(), maxLength(255)]; // Set MAS password — used in the toolbar when in MAS mode const MASSetPasswordButton = () => { const record = useRecordContext(); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const notify = useNotify(); const dataProvider = useDataProvider() as SynapseDataProvider; const translate = useTranslate(); if (!record?.mas_id) return null; const handleConfirm = async () => { if (!password) return; setOpen(false); setLoading(true); try { const result = await dataProvider.masSetPassword(record.mas_id as string, password); if (result.success) { notify("resources.mas_users.action.set_password.success"); setPassword(""); } else { notify(result.error || "resources.mas_users.action.set_password.failure", { type: "error" }); } } catch { notify("resources.mas_users.action.set_password.failure", { type: "error" }); } finally { setLoading(false); } }; return ( <> <Button label="resources.mas_users.action.set_password.label" onClick={() => setOpen(true)} disabled={loading}> <ManageAccountsIcon /> </Button> <Confirm isOpen={open} title={translate("resources.mas_users.action.set_password.title")} content={ <MuiTextField type={showPassword ? "text" : "password"} label={translate("resources.mas_users.action.set_password.label")} value={password} onChange={e => setPassword(e.target.value)} autoComplete="new-password" fullWidth autoFocus slotProps={{ input: { endAdornment: ( <InputAdornment position="end"> <IconButton onClick={() => setShowPassword(v => !v)} edge="end"> {showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />} </IconButton> </InputAdornment> ), }, }} /> } onConfirm={handleConfirm} onClose={() => { setOpen(false); setPassword(""); setShowPassword(false); }} /> </> ); }; // MAS sessions panel — sub-tabbed, shown in the Sessions tab of the user profile in MAS mode const MASSessionsPanel = () => { const record = useRecordContext(); const translate = useTranslate(); const dataProvider = useDataProvider() as SynapseDataProvider; const notify = useNotify(); const refresh = useRefresh(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const masId = record?.mas_id as string | undefined; const [tab, setTab] = useState(0); const [creating, setCreating] = useState(false); const [newToken, setNewToken] = useState<string | null>(null); const [form, setForm] = useState({ scope: "", human_name: "", expires_in: "" }); const { data: personalSessions = [], isLoading: loadingPersonal } = useGetList( "mas_personal_sessions", { filter: { user_id: masId }, pagination: { page: 1, perPage: 50 }, sort: { field: "created_at", order: "DESC" } }, { enabled: !!masId } ); const { data: userSessions = [], isLoading: loadingUser } = useGetList( "mas_user_sessions", { filter: { user_id: masId }, pagination: { page: 1, perPage: 50 }, sort: { field: "created_at", order: "DESC" } }, { enabled: !!masId } ); const { data: oauth2Sessions = [], isLoading: loadingOAuth2 } = useGetList( "mas_oauth2_sessions", { filter: { user_id: masId }, pagination: { page: 1, perPage: 50 }, sort: { field: "created_at", order: "DESC" } }, { enabled: !!masId } ); const { data: compatSessions = [], isLoading: loadingCompat } = useGetList( "mas_compat_sessions", { filter: { user_id: masId }, pagination: { page: 1, perPage: 50 }, sort: { field: "created_at", order: "DESC" } }, { enabled: !!masId } ); // Build list contexts at top level (hooks must not be called conditionally) const personalListCtx = useList({ data: personalSessions, isLoading: loadingPersonal }); const userListCtx = useList({ data: userSessions, isLoading: loadingUser }); const oauth2ListCtx = useList({ data: oauth2Sessions, isLoading: loadingOAuth2 }); const compatListCtx = useList({ data: compatSessions, isLoading: loadingCompat }); const handleCreate = async () => { if (!masId || !form.scope || !form.human_name) return; setCreating(true); try { const result = await dataProvider.create("mas_personal_sessions", { data: { actor_user_id: masId, scope: form.scope, human_name: form.human_name, expires_in: form.expires_in ? Number(form.expires_in) : undefined, }, }); const token = result?.data?.access_token as string | undefined; if (token) setNewToken(token); setForm({ scope: "", human_name: "", expires_in: "" }); refresh(); } catch { notify("ra.notification.http_error", { type: "error" }); } finally { setCreating(false); } }; return ( <Box sx={{ width: "100%" }}> <Tabs value={tab} onChange={(_, v) => setTab(v)} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile > <Tab label={translate("resources.mas_personal_sessions.name", { smart_count: 2 })} /> <Tab label={translate("resources.mas_user_sessions.name", { smart_count: 2 })} /> <Tab label={translate("resources.mas_oauth2_sessions.name", { smart_count: 2 })} /> <Tab label={translate("resources.mas_compat_sessions.name", { smart_count: 2 })} /> </Tabs> {tab === 0 && ( <> <Box sx={{ display: "flex", gap: 2, mt: 2, alignItems: "center", flexWrap: "wrap" }}> <MuiTextField size="small" label={translate("resources.mas_personal_sessions.fields.human_name")} value={form.human_name} onChange={e => setForm(f => ({ ...f, human_name: e.target.value }))} /> <MuiTextField size="small" label={translate("resources.mas_personal_sessions.fields.scope")} value={form.scope} onChange={e => setForm(f => ({ ...f, scope: e.target.value }))} /> <MuiTextField size="small" label={translate("resources.mas_personal_sessions.fields.expires_in")} value={form.expires_in} onChange={e => setForm(f => ({ ...f, expires_in: e.target.value }))} placeholder={translate("resources.mas_personal_sessions.helper.expires_in")} sx={{ minWidth: 220 }} /> <MuiButton variant="contained" disabled={creating || !form.scope || !form.human_name} onClick={handleCreate} sx={{ height: "40px" }} > {translate("ra.action.create")} </MuiButton> </Box> <ResourceContextProvider value="mas_personal_sessions"> <ListContextProvider value={personalListCtx}> {isSmall ? ( <SimpleList empty={<EmptyState resource="mas_personal_sessions" />} primaryText={record => record.human_name || String(record.id)} secondaryText={record => String(record.scope || "")} tertiaryText={() => <RevokePersonalSessionButton />} rowClick={false} sx={{ "& .MuiListItemText-secondary": { wordBreak: "break-all" } }} /> ) : ( <Box sx={{ overflowX: "auto", mt: 2 }}> <Datagrid bulkActionButtons={false} rowClick={false} empty={<EmptyState resource="mas_personal_sessions" />} > <TextField source="human_name" sortable={false} emptyText="-" /> <TextField source="scope" sortable={false} /> <BooleanField source="active" sortable={false} /> <DateField source="created_at" showTime sortable={false} /> <DateField source="expires_at" showTime sortable={false} emptyText="-" /> <RevokePersonalSessionButton /> </Datagrid> </Box> )} </ListContextProvider> </ResourceContextProvider> </> )} {tab === 1 && ( <ResourceContextProvider value="mas_user_sessions"> <ListContextProvider value={userListCtx}> {isSmall ? ( <SimpleList empty={<EmptyState resource="mas_user_sessions" />} primaryText={record => String(record.user_agent || record.last_active_ip || record.id)} secondaryText={record => String(record.last_active_ip || "")} tertiaryText={() => <FinishUserSessionButton />} rowClick={false} /> ) : ( <Box sx={{ overflowX: "auto", mt: 1 }}> <Datagrid bulkActionButtons={false} rowClick={false} empty={<EmptyState resource="mas_user_sessions" />} > <BooleanField source="active" sortable={false} /> <DateField source="created_at" showTime sortable={false} /> <DateField source="last_active_at" showTime sortable={false} emptyText="-" /> <TextField source="last_active_ip" sortable={false} emptyText="-" /> <TextField source="user_agent" sortable={false} emptyText="-" /> <FinishUserSessionButton /> </Datagrid> </Box> )} </ListContextProvider> </ResourceContextProvider> )} {tab === 2 && ( <ResourceContextProvider value="mas_oauth2_sessions"> <ListContextProvider value={oauth2ListCtx}> {isSmall ? ( <SimpleList empty={<EmptyState resource="mas_oauth2_sessions" />} primaryText={record => record.human_name || record.client_id || String(record.id)} secondaryText={record => String(record.scope || "")} tertiaryText={() => <FinishOAuth2SessionButton />} rowClick={false} sx={{ "& .MuiListItemText-secondary": { wordBreak: "break-all" } }} /> ) : ( <Box sx={{ overflowX: "auto", mt: 1 }}> <Datagrid bulkActionButtons={false} rowClick={false} empty={<EmptyState resource="mas_oauth2_sessions" />} > <TextField source="client_id" sortable={false} /> <FunctionField source="scope" label="resources.mas_oauth2_sessions.fields.scope" sortable={false} render={(record: { scope?: string }) => record?.scope ? ( <Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}> {record.scope .split(" ") .filter(Boolean) .map(s => ( <Chip key={s} label={s} size="small" variant="outlined" sx={{ wordBreak: "break-all" }} /> ))} </Box> ) : null } /> <TextField source="human_name" sortable={false} emptyText="-" /> <BooleanField source="active" sortable={false} /> <DateField source="created_at" showTime sortable={false} /> <DateField source="last_active_at" showTime sortable={false} emptyText="-" /> <FinishOAuth2SessionButton /> </Datagrid> </Box> )} </ListContextProvider> </ResourceContextProvider> )} {tab === 3 && ( <ResourceContextProvider value="mas_compat_sessions"> <ListContextProvider value={compatListCtx}> {isSmall ? ( <SimpleList empty={<EmptyState resource="mas_compat_sessions" />} primaryText={record => record.human_name || record.device_id || String(record.id)} secondaryText={record => String(record.last_active_ip || "")} tertiaryText={() => <FinishCompatSessionButton />} rowClick={false} /> ) : ( <Box sx={{ overflowX: "auto", mt: 1 }}> <Datagrid bulkActionButtons={false} rowClick={false} empty={<EmptyState resource="mas_compat_sessions" />} > <TextField source="device_id" sortable={false} emptyText="-" /> <TextField source="human_name" sortable={false} emptyText="-" /> <BooleanField source="active" sortable={false} /> <DateField source="created_at" showTime sortable={false} /> <DateField source="last_active_at" showTime sortable={false} emptyText="-" /> <TextField source="last_active_ip" sortable={false} emptyText="-" /> <FinishCompatSessionButton /> </Datagrid> </Box> )} </ListContextProvider> </ResourceContextProvider> )} <Dialog open={!!newToken} maxWidth="sm" fullWidth> <DialogTitle>{translate("resources.mas_personal_sessions.action.create.token_title")}</DialogTitle> <DialogContent> <Typography sx={{ mb: 2 }}> {translate("resources.mas_personal_sessions.action.create.token_content")} </Typography> <MuiTextField value={newToken || ""} fullWidth multiline rows={3} slotProps={{ input: { readOnly: true } }} onClick={e => (e.target as HTMLInputElement).select()} /> </DialogContent> <DialogActions> <MuiButton onClick={() => setNewToken(null)}>OK</MuiButton> </DialogActions> </Dialog> </Box> ); }; // MAS upstream OAuth links panel — replaces the Synapse SSO tab in MAS mode const MASUpstreamOAuthLinksPanel = () => { const record = useRecordContext(); const translate = useTranslate(); const dataProvider = useDataProvider() as SynapseDataProvider; const notify = useNotify(); const refresh = useRefresh(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const masId = record?.mas_id as string | undefined; const [creating, setCreating] = useState(false); const [form, setForm] = useState({ provider_id: "", subject: "", human_account_name: "" }); const { data: links = [], isLoading } = useGetList( "mas_upstream_oauth_links", { filter: { user_id: masId }, pagination: { page: 1, perPage: 50 }, sort: { field: "created_at", order: "DESC" } }, { enabled: !!masId } ); const { data: providers = [] } = useGetList("mas_upstream_oauth_providers", { pagination: { page: 1, perPage: 100 }, sort: { field: "human_name", order: "ASC" }, }); const linksListCtx = useList({ data: links, isLoading }); const handleCreate = async () => { if (!masId || !form.provider_id || !form.subject) return; setCreating(true); try { await dataProvider.create("mas_upstream_oauth_links", { data: { user_id: masId, provider_id: form.provider_id, subject: form.subject, human_account_name: form.human_account_name || undefined, }, }); setForm({ provider_id: "", subject: "", human_account_name: "" }); refresh(); } catch { notify("ra.notification.http_error", { type: "error" }); } finally { setCreating(false); } }; return ( <Box sx={{ width: "100%" }}> <Box sx={{ display: "flex", gap: 2, mt: 2, mb: 1, alignItems: "center", flexWrap: "wrap" }}> <FormControl size="small" sx={{ minWidth: 200 }}> <InputLabel>{translate("resources.mas_upstream_oauth_links.fields.provider_id")}</InputLabel> <Select value={form.provider_id} label={translate("resources.mas_upstream_oauth_links.fields.provider_id")} onChange={e => setForm(f => ({ ...f, provider_id: e.target.value }))} > {providers.map(p => ( <MenuItem key={p.id} value={p.id}> {p.human_name || p.id} </MenuItem> ))} </Select> </FormControl> <MuiTextField size="small" label={translate("resources.mas_upstream_oauth_links.fields.subject")} value={form.subject} onChange={e => setForm(f => ({ ...f, subject: e.target.value }))} /> <MuiTextField size="small" label={translate("resources.mas_upstream_oauth_links.fields.human_account_name")} value={form.human_account_name} onChange={e => setForm(f => ({ ...f, human_account_name: e.target.value }))} /> <MuiButton variant="contained" disabled={creating || !form.provider_id || !form.subject} onClick={handleCreate} sx={{ height: "40px" }} > {translate("ra.action.create")} </MuiButton> </Box> <ResourceContextProvider value="mas_upstream_oauth_links"> <ListContextProvider value={linksListCtx}> {isSmall ? ( <SimpleList empty={<EmptyState resource="mas_upstream_oauth_links" />} primaryText={record => String(record.subject || "")} secondaryText={record => String(record.provider_id || "")} tertiaryText={() => <DeleteOAuthLinkButton />} rowClick={false} /> ) : ( <Datagrid bulkActionButtons={false} rowClick={false} empty={<EmptyState resource="mas_upstream_oauth_links" />} sx={{ width: "100%" }} > <TextField source="provider_id" sortable={false} /> <TextField source="subject" sortable={false} /> <TextField source="human_account_name" sortable={false} emptyText="-" /> <DateField source="created_at" showTime sortable={false} /> <DeleteOAuthLinkButton /> </Datagrid> )} </ListContextProvider> </ResourceContextProvider> </Box> ); }; // MAS email management panel — replaces the Synapse 3PIDs tab in MAS mode const MASEmailsPanel = () => { const record = useRecordContext(); const dataProvider = useDataProvider(); const notify = useNotify(); const translate = useTranslate(); const locale = useLocale(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const [newEmail, setNewEmail] = useState(""); const [adding, setAdding] = useState(false); const masId = record?.mas_id as string | undefined; const { data: emails, isLoading, refetch, } = useGetList( "mas_user_emails", { filter: { user_id: masId }, pagination: { page: 1, perPage: 50 }, sort: { field: "created_at", order: "DESC" } }, { enabled: !!masId } ); const handleDelete = async (emailId: string) => { try { await dataProvider.delete("mas_user_emails", { id: emailId, previousData: { id: emailId } }); notify("resources.mas_user_emails.action.remove.success"); refetch(); } catch { notify("ra.notification.http_error", { type: "error" }); } }; const handleAdd = async () => { if (!newEmail || !masId) return; setAdding(true); try { await dataProvider.create("mas_user_emails", { data: { user_id: masId, email: newEmail } }); notify("resources.mas_user_emails.action.create.success"); setNewEmail(""); refetch(); } catch { notify("ra.notification.http_error", { type: "error" }); } finally { setAdding(false); } }; if (isLoading) return <Loading />; return ( <Box sx={{ width: "100%" }}> {isSmall ? ( <MuiList disablePadding> {(emails || []).map(email => ( <ListItem key={String(email.id)} secondaryAction={ <Button label="resources.mas_user_emails.action.remove.label" onClick={() => handleDelete(String(email.id))} size="small" color="error" > <DeleteIcon /> </Button> } > <ListItemText primary={String(email.email)} secondary={new Date(String(email.created_at)).toLocaleString(locale, DATE_FORMAT)} /> </ListItem> ))} </MuiList> ) : ( <Datagrid data={emails || []} total={emails?.length || 0} isLoading={isLoading} bulkActionButtons={false} rowClick={false} empty={<EmptyState resource="mas_user_emails" />} sx={{ width: "100%", mb: 2 }} > <TextField source="email" sortable={false} /> <DateField source="created_at" showTime sortable={false} /> <WrapperField label="resources.mas_user_emails.fields.actions"> <FunctionField render={(emailRecord: { id: string }) => ( <Button label="resources.mas_user_emails.action.remove.label" onClick={() => handleDelete(emailRecord.id)} color="error" > <DeleteIcon /> </Button> )} /> </WrapperField> </Datagrid> )} <Box sx={{ display: "flex", gap: 1, alignItems: "center" }}> <MuiTextField label={translate("resources.mas_user_emails.fields.email")} value={newEmail} onChange={e => setNewEmail(e.target.value)} size="small" /> <Button label="ra.action.add" onClick={handleAdd} disabled={adding || !newEmail} variant="contained" sx={{ height: "40px" }} /> </Box> </Box> ); }; const UserEditActions = () => { const record = useRecordContext(); const isMAS = useIsMAS(); const ownUserId = localStorage.getItem("user_id"); let ownUserIsSelected = false; let systemUserIsSelected = false; if (record && record.id) { ownUserIsSelected = record.id === ownUserId; systemUserIsSelected = isSystemUser(record.id); } return ( <TopToolbar sx={{ flexWrap: "wrap", gap: 0.5, whiteSpace: "normal" }}> {!record?.deactivated && !isMAS && <LoginAsUserButton />} {!record?.deactivated && !isMAS && <ResetPasswordButton />} {!record?.deactivated && isMAS && <MASSetPasswordButton />} {!record?.deactivated && !isMAS && <AllowCrossSigningButton />} {!record?.deactivated && !isMAS && <RenewAccountValidityButton />} {!record?.deactivated && <ServerNoticeButton />} {record && record.id && ( <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} systemUserIsSelected={systemUserIsSelected}> <DeleteUserButton selectedIds={[record?.id]} confirmTitle="resources.users.helper.erase" confirmContent="resources.users.helper.erase_text" masIdMap={record?.mas_id ? { [String(record.id)]: String(record.mas_id) } : undefined} /> </UserPreventSelfDelete> )} </TopToolbar> ); }; const UserTitle = () => { const record = useRecordContext(); const translate = useTranslate(); const baseTitle = translate("resources.users.name", { smart_count: 1 }); const username = record ? (record.displayname ? `${record.displayname} (${record.name})` : `${record.name}`) : ""; const pageTitle = record ? `${baseTitle} ${username}` : baseTitle; useDocTitle(pageTitle); if (!record) { return null; } return ( <span> {baseTitle} <AvatarField source="avatar_src" sx={{ height: "25px", width: "25px" }} /> {username} </span> ); }; const UserEditToolbar = () => { const record = useRecordContext(); const ownUserId = localStorage.getItem("user_id"); let ownUserIsSelected = false; let systemUserIsSelected = false; if (record && record.id) { ownUserIsSelected = record.id === ownUserId; systemUserIsSelected = isSystemUser(record.id); } return ( <> <div className={ToolbarClasses.defaultToolbar}> <Toolbar sx={{ justifyContent: "space-between" }}> <SaveButton /> <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} systemUserIsSelected={systemUserIsSelected}> <DeleteButton /> </UserPreventSelfDelete> </Toolbar> </div> </> ); }; export const UserBooleanInput = (props: React.ComponentProps<typeof BooleanInput> & { icon?: React.ReactNode }) => { const translate = useTranslate(); const record = useRecordContext(); const ownUserId = localStorage.getItem("user_id"); let ownUserIsSelected = false; let systemUserIsSelected = false; if (record) { ownUserIsSelected = record.id === ownUserId; systemUserIsSelected = isSystemUser(record.id); if (["locked", "deactivated", "erased"].includes(props.source) && record[props.source]) { // we want to allow re-activating locked/deactivated/erased users even if they are AS managed systemUserIsSelected = false; } } const { icon, ...rest } = props; const label = icon ? ( <Box sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}> {icon} {translate((typeof rest.label === "string" && rest.label) || `resources.users.fields.${rest.source}`)} </Box> ) : undefined; return ( <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} systemUserIsSelected={systemUserIsSelected}> <BooleanInput disabled={ownUserIsSelected || systemUserIsSelected} {...rest} {...(label ? { label } : {})} /> </UserPreventSelfDelete> ); }; export const UserPasswordInput = (props: React.ComponentProps<typeof PasswordInput>) => { const record = useRecordContext(); let systemUserIsSelected = false; const translate = useTranslate(); // Get form context to update field value const form = useFormContext(); if (record) { systemUserIsSelected = isSystemUser(record.id); } const generatePassword = () => { const password = generateRandomPassword(); form.setValue("password", password, { shouldDirty: true }); }; // Get the current deactivated state and the original value const deactivated = form.watch("deactivated"); const deactivatedFromRecord = record?.deactivated; // Custom validation for reactivation case const validatePasswordOnReactivation = (value: unknown) => { if (deactivatedFromRecord === true && deactivated === false && !GetConfig().externalAuthProvider && !value) { return translate("resources.users.helper.password_required_for_reactivation"); } return undefined; }; let passwordHelperText = "resources.users.helper.create_password"; if (systemUserIsSelected) { passwordHelperText = "resources.users.helper.modify_managed_user_error"; } else if (deactivatedFromRecord === true && deactivated === false && !GetConfig().externalAuthProvider) { passwordHelperText = "resources.users.helper.password_required_for_reactivation"; } else if (record) { passwordHelperText = "resources.users.helper.password"; } return ( <> <PasswordInput {...props} validate={validatePasswordOnReactivation} helperText={passwordHelperText} disabled={systemUserIsSelected} /> <Button variant="outlined" label="resources.users.action.generate_password" onClick={generatePassword} sx={{ marginBottom: "10px" }} disabled={systemUserIsSelected} /> </> ); }; const ErasedBooleanInput = (props: React.ComponentProps<typeof BooleanInput>) => { const record = useRecordContext(); const form = useFormContext(); const deactivated = form.watch("deactivated"); const erased = form.watch("erased"); const erasedFromRecord = record?.erased; const deactivatedFromRecord = record?.deactivated; useEffect(() => { // If the user was erased and deactivated, by unchecking Erased, we want to also uncheck Deactivated if (erasedFromRecord === true && erased === false) { form.setValue("deactivated", false); } }, [deactivatedFromRecord, erased, erasedFromRecord, form]); return <UserBooleanInput disabled={!deactivated} {...props} />; }; const JoinedRoomsMobileList = () => { const { data: joinedRooms } = useListContext(); const translate = useTranslate(); const ids = (joinedRooms || []).map(r => r.id); const { data: rooms } = useGetMany("rooms", { ids }, { enabled: ids.length > 0 }); const roomMap = new Map((rooms || []).map(r => [r.id, r])); if (!joinedRooms) return null; if (!joinedRooms.length) return <EmptyState resource="joined_rooms" />; return ( <MuiList disablePadding> {joinedRooms.map(record => { const room = roomMap.get(record.id); return ( <ListItemButton key={record.id as string} component={Link} to={"/rooms/" + record.id + "/show"} sx={{ gap: 1, alignItems: "center" }} > <AvatarField record={room || record} source="avatar" sx={{ height: "40px", width: "40px" }} /> <Box sx={{ flex: 1, minWidth: 0 }}> <Typography variant="body1" sx={{ wordBreak: "break-all" }}> {room?.name || room?.canonical_alias || record.id} </Typography> <Typography variant="body2" color="text.secondary"> {translate("resources.rooms.fields.joined_members")}: {room?.joined_members ?? 0} {room?.creator && ( <> <br /> <Box component="span" sx={{ wordBreak: "break-all" }}> {translate("resources.rooms.fields.creator")}: {room.creator} </Box> </> )} </Typography> </Box> </ListItemButton> ); })} </MuiList> ); }; const MembershipsMobileList = () => { const { data: memberships } = useListContext(); const translate = useTranslate(); const ids = (memberships || []).map(r => r.id); const { data: rooms } = useGetMany("rooms", { ids }, { enabled: ids.length > 0 }); const roomMap = new Map((rooms || []).map(r => [r.id, r])); if (!memberships) return null; if (!memberships.length) return <EmptyState resource="memberships" />; return ( <MuiList disablePadding> {memberships.map(record => { const room = roomMap.get(record.id); return ( <ListItemButton key={record.id as string} component={Link} to={"/rooms/" + record.id + "/show"} sx={{ gap: 1, alignItems: "center" }} > <AvatarField record={room || record} source="avatar" sx={{ height: "40px", width: "40px" }} /> <Box sx={{ flex: 1, minWidth: 0 }}> <Typography variant="body1" sx={{ wordBreak: "break-all" }}> {room?.name || record.id} </Typography> <Typography variant="body2" color="text.secondary"> {translate("resources.users.membership", { smart_count: 1 })}: {record.membership} </Typography> </Box> </ListItemButton> ); })} </MuiList> ); }; const UserPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />; export const UserEdit = (props: EditProps) => { const translate = useTranslate(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const locale = useLocale(); return ( <Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic" queryOptions={{ meta: { include: ["features"], // Tell your dataProvider to include features }, }} sx={{ "& .RaEdit-card": { maxWidth: { xs: "100vw", sm: "calc(100vw - 32px)" }, minWidth: 0, overflowX: "auto" } }} > <TabbedForm toolbar={<UserEditToolbar />} tabs={<TabbedFormTabs variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile />} > <FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}> {!isMAS() && ( <Box sx={{ display: "flex", flexDirection: { xs: "column", sm: "row" }, gap: 4, width: "100%", mb: 2, mt: 1 }} > <Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", minWidth: 140, gap: 2, }} > <EditableAvatarField source="avatar_src" /> <UserInfoChips /> </Box> <Box sx={{ flex: 1 }}> <TextInput source="id" readOnly fullWidth /> <TextInput source="displayname" fullWidth /> <SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable fullWidth /> <UserPasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" /> </Box> </Box> )} {isMAS() && ( <Box sx={{ display: "flex", flexDirection: { xs: "column", sm: "row" }, gap: 4, width: "100%", mb: 2, mt: 1 }} > <Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", minWidth: 140, gap: 2, }} > <EditableAvatarField source="avatar_src" /> <UserInfoChips /> </Box> <Box sx={{ flex: 1 }}> <TextInput source="id" readOnly fullWidth label="resources.users.fields.id" /> <TextInput source="mas_id" readOnly fullWidth label="resources.mas_users.fields.id" /> <TextInput source="displayname" fullWidth /> <SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable fullWidth /> </Box> </Box> )} <Divider sx={{ width: "100%", my: 2 }} /> <Box sx={{ display: "flex", flexDirection: { xs: "column", sm: "row" }, gap: 4, width: "100%" }}> {!isMAS() && ( <Box sx={{ flex: 1 }}> <UserBooleanInput source="suspended" helperText="resources.users.helper.suspend" icon={<BlockIcon fontSize="small" />} /> <UserBooleanInput source="shadow_banned" helperText="resources.users.helper.shadow_ban" icon={<VisibilityOffIcon fontSize="small" />} /> <UserBooleanInput sx={{ color: theme.palette.warning.main }} source="locked" helperText="resources.users.helper.lock" icon={<LockIcon fontSize="small" />} /> </Box> )} {isMAS() && ( <Box sx={{ flex: 1 }}> <UserBooleanInput sx={{ color: theme.palette.warning.main }} source="locked" helperText="resources.users.helper.lock" icon={<LockIcon fontSize="small" />} /> <UserBooleanInput source="suspended" helperText="resources.users.helper.suspend" icon={<BlockIcon fontSize="small" />} /> <UserBooleanInput source="shadow_banned" helperText="resources.users.helper.shadow_ban" icon={<VisibilityOffIcon fontSize="small" />} /> </Box> )} <Paper variant="outlined" sx={{ flex: 1, p: 2, borderColor: theme.palette.error.main, borderStyle: "dashed", }} > <Typography variant="subtitle2" color="error" sx={{ mb: 1 }}> {translate("ketesa.users.danger_zone")} </Typography> <UserBooleanInput source="admin" helperText="resources.users.helper.admin" icon={<AdminPanelSettingsIcon fontSize="small" />} /> <UserBooleanInput sx={{ color: theme.palette.error.main }} source="deactivated" helperText="resources.users.helper.deactivate" icon={<NoAccountsIcon fontSize="small" />} /> {!isMAS() && ( <ErasedBooleanInput sx={{ color: theme.palette.error.main, marginLeft: "25px" }} source="erased" helperText="resources.users.helper.erase" icon={<DeleteForeverIcon fontSize="small" />} /> )} </Paper> </Box> {isMAS() && ( <Box sx={{ display: "flex", gap: 1, mt: 2, flexWrap: "wrap" }}> <FunctionField render={(r: { locked_at?: string }) => r?.locked_at ? ( <Chip size="small" label={`${translate("resources.mas_users.fields.locked_at")}: ${new Date(r.locked_at).toLocaleString(locale, DATE_FORMAT)}`} /> ) : null } /> <FunctionField render={(r: { deactivated_at?: string }) => r?.deactivated_at ? ( <Chip size="small" label={`${translate("resources.mas_users.fields.deactivated_at")}: ${new Date(r.deactivated_at).toLocaleString(locale, DATE_FORMAT)}`} /> ) : null } /> </Box> )} </FormTab> <FormTab label="resources.users.threepid" icon={<ContactMailIcon />} path="threepid"> {isMAS() ? ( <MASEmailsPanel /> ) : ( <ArrayInput source="threepids"> <SimpleFormIterator disableReordering> <SelectInput source="medium" choices={choices_medium} /> <TextInput source="address" /> </SimpleFormIterator> </ArrayInput> )} </FormTab> <FormTab label="ketesa.users.tabs.sso" icon={<AssignmentIndIcon />} path="sso"> {isMAS() ? ( <MASUpstreamOAuthLinksPanel /> ) : ( <ArrayInput source="external_ids" label=""> <SimpleFormIterator disableReordering> <TextInput source="auth_provider" validate={required()} /> <TextInput source="external_id" label="resources.users.fields.id" validate={required()} /> </SimpleFormIterator> </ArrayInput> )} </FormTab> {isMAS() && ( <FormTab label="ketesa.users.tabs.sessions" icon={<HttpsIcon />} path="sessions"> <MASSessionsPanel /> </FormTab> )} <FormTab label={translate("resources.devices.name", { smart_count: 2 })} icon={<DevicesIcon />} path="devices"> <DeviceCreateButton /> <ReferenceManyField reference="devices" target="user_id" label="" pagination={<UserPagination />} perPage={10} > <Box sx={{ width: "100%" }}> {isSmall ? ( <SimpleList empty={<EmptyState resource="devices" />} primaryText={record => ( <Box component="span" sx={{ wordBreak: "break-all" }}> {record.device_id} </Box> )} secondaryText={record => ( <> {record.display_name && ( <> {record.display_name} <br /> </> )} {record.last_seen_ip && ( <> {record.last_seen_ip} <br /> </> )} {record.last_seen_ts && new Date(record.last_seen_ts).toLocaleString(locale)} </> )} tertiaryText={() => <DeviceRemoveButton />} rowClick={false} /> ) : ( <Datagrid bulkActionButtons={<DeviceBulkRemoveButton />} omit={["last_seen_user_agent", "dehydrated"]} empty={<EmptyState resource="devices" />} > <TextField source="device_id" sortable={false} /> <DeviceDisplayNameInput /> <TextField source="last_seen_ip" sortable={false} /> <TextField source="last_seen_user_agent" sortable={false} /> <DateField source="last_seen_ts" showTime options={DATE_FORMAT} sortable={false} locales={locale} /> <BooleanField source="dehydrated" sortable={false} /> <WrapperField label="resources.rooms.fields.actions"> <DeviceRemoveButton /> </WrapperField> </Datagrid> )} </Box> </ReferenceManyField> </FormTab> <FormTab label="resources.connections.name" icon={<SettingsInputComponentIcon />} path="connections"> <ReferenceField reference="connections" source="id" label="" link={false}> <WrapperField label="resources.connections.name"> <ArrayField source="devices[].sessions[0].connections"> {isSmall ? ( <SimpleList empty={<EmptyState resource="connections" />} primaryText={record => record.ip} secondaryText={record => ( <> {record.last_seen && new Date(record.last_seen).toLocaleString(locale)} {record.user_agent && ( <> <br /> <Box component="span" sx={{ wordBreak: "break-all" }}> {record.user_agent} </Box> </> )} </> )} rowClick={false} /> ) : ( <Datagrid sx={{ width: "100%" }} bulkActionButtons={false} empty={<EmptyState resource="connections" />} > <TextField source="ip" sortable={false} /> <DateField source="last_seen" showTime options={DATE_FORMAT} sortable={false} locales={locale} /> <TextField source="user_agent" sortable={false} style={{ width: "100%" }} /> </Datagrid> )} </ArrayField> </WrapperField> </ReferenceField> </FormTab> <FormTab label={translate("resources.users_media.name", { smart_count: 2 })} icon={<PermMediaIcon />} path="media" > <QuarantineUserMediaButton /> <DeleteUserMediaButton /> <ReferenceManyField reference="users_media" target="user_id" label="" pagination={<UserPagination />} perPage={10} sort={{ field: "created_ts", order: "DESC" }} > {isSmall ? ( <SimpleList empty={<EmptyState resource="users_media" />} primaryText={record => ( <Box component="span" sx={{ wordBreak: "break-all" }}> {record.upload_name ? decodeURLComponent(record.upload_name) : record.media_id} </Box> )} secondaryText={record => ( <> {formatBytes(record.media_length)} {record.media_type && <> · {record.media_type}</>} <br /> {new Date(record.created_ts).toLocaleString(locale)} </> )} tertiaryText={() => ( <Box component="span" sx={{ display: "flex", gap: 0.5 }}> <QuarantineMediaButton /> <ProtectMediaButton /> <DeleteButton mutationMode="pessimistic" redirect={false} /> </Box> )} rowClick={false} /> ) : ( <Datagrid sx={{ width: "100%" }} bulkActionButtons={<BulkDeleteButton />} empty={<EmptyState resource="users_media" />} > <MediaIDField source="media_id" /> <DateField source="created_ts" showTime options={DATE_FORMAT} locales={locale} /> <DateField source="last_access_ts" showTime options={DATE_FORMAT} locales={locale} /> <FunctionField source="media_length" render={record => formatBytes(record.media_length)} /> <TextField source="media_type" sx={{ display: "block", width: 200, wordBreak: "break-word" }} /> <FunctionField source="upload_name" render={record => (record.upload_name ? decodeURLComponent(record.upload_name) : "")} /> <TextField source="quarantined_by" /> <QuarantineMediaButton /> <ProtectMediaButton /> <DeleteButton mutationMode="pessimistic" redirect={false} /> </Datagrid> )} </ReferenceManyField> </FormTab> <FormTab label={translate("resources.rooms.name", { smart_count: 2 })} icon={<ViewListIcon />} path="rooms"> <ReferenceManyField reference="joined_rooms" target="user_id" label="" perPage={10} pagination={<Pagination />} > {isSmall ? ( <JoinedRoomsMobileList /> ) : ( <Datagrid sx={{ width: "100%" }} rowClick={id => "/rooms/" + id + "/show"} bulkActionButtons={<RoomBulkActionButtons />} empty={<EmptyState resource="joined_rooms" />} > <ReferenceField reference="rooms" source="id" label="" link={false} sortable={false}> <AvatarField source="avatar" sx={{ height: "40px", width: "40px" }} /> </ReferenceField> <TextField source="id" label="resources.rooms.fields.room_id" sortable={false} sx={{ wordBreak: "break-all" }} /> <ReferenceField reference="rooms" source="id" label="resources.rooms.fields.name" link={false} sortable={false} > <TextField source="name" sx={{ wordBreak: "break-word", overflowWrap: "break-word", }} /> </ReferenceField> <ReferenceField reference="rooms" source="id" label="resources.rooms.fields.joined_members" link={false} sortable={false} > <TextField source="joined_members" sortable={false} /> </ReferenceField> <ReferenceField reference="rooms" source="id" label="" link={false} sortable={false}> <MakeAdminBtn /> </ReferenceField> </Datagrid> )} </ReferenceManyField> </FormTab> <FormTab label={translate("resources.users.membership", { smart_count: 2 })} icon={<FormatListBulletedIcon />} path="memberships" > <ReferenceManyField reference="memberships" target="user_id" label="" perPage={10} pagination={<Pagination />} > {isSmall ? ( <MembershipsMobileList /> ) : ( <Datagrid sx={{ width: "100%" }} rowClick={id => "/rooms/" + id + "/show"} bulkActionButtons={false} empty={<EmptyState resource="memberships" />} > <ReferenceField reference="rooms" source="id" label="" link={false} sortable={false}> <AvatarField source="avatar" sx={{ height: "40px", width: "40px" }} /> </ReferenceField> <TextField source="id" label="resources.rooms.fields.room_id" sortable={false} sx={{ wordBreak: "break-all" }} /> <ReferenceField reference="rooms" source="id" label="resources.rooms.fields.name" link={false} sortable={false} > <TextField source="name" /> </ReferenceField> <TextField source="membership" label={translate("resources.users.membership", { smart_count: 1 })} sortable={false} /> </Datagrid> )} </ReferenceManyField> </FormTab> <FormTab label={translate("resources.pushers.name", { smart_count: 2 })} icon={<NotificationsIcon />} path="pushers" > <ReferenceManyField reference="pushers" target="user_id" label="" pagination={<Pagination />} perPage={10}> {isSmall ? ( <SimpleList empty={<EmptyState resource="pushers" />} primaryText={record => ( <Box component="span" sx={{ wordBreak: "break-all" }}> {record.app_display_name || record.app_id} </Box> )} secondaryText={record => ( <> {record.kind} {record.device_display_name && <> · {record.device_display_name}</>} {record.pushkey && ( <> <br /> <Box component="span" sx={{ wordBreak: "break-all" }}> {record.pushkey} </Box> </> )} </> )} rowClick={false} /> ) : ( <Datagrid sx={{ width: "100%" }} bulkActionButtons={false} omit={["app_id", "data.url", "profile_tag", "pushkey"]} empty={<EmptyState resource="pushers" />} > <TextField source="kind" sortable={false} /> <TextField source="app_display_name" sortable={false} /> <TextField source="app_id" sortable={false} /> <TextField source="data.url" sortable={false} /> <TextField source="device_display_name" sortable={false} /> <TextField source="lang" sortable={false} /> <TextField source="profile_tag" sortable={false} /> <TextField source="pushkey" sortable={false} /> </Datagrid> )} </ReferenceManyField> </FormTab> <FormTab label="ketesa.users.tabs.experimental" icon={<ScienceIcon />} path="experimental"> <ExperimentalFeaturesList /> </FormTab> <FormTab label="ketesa.users.tabs.limits" icon={<LockClockIcon />} path="limits"> <UserRateLimits /> </FormTab> <FormTab label="ketesa.users.tabs.account_data" icon={<DocumentScannerIcon />} path="accountdata"> <UserAccountData /> </FormTab> </TabbedForm> </Edit> ); }; ================================================ FILE: src/resources/users/List.tsx ================================================ import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings"; import BlockIcon from "@mui/icons-material/Block"; import DeleteForeverIcon from "@mui/icons-material/DeleteForever"; import GetAppIcon from "@mui/icons-material/GetApp"; import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty"; import LockIcon from "@mui/icons-material/Lock"; import NoAccountsIcon from "@mui/icons-material/NoAccounts"; import SearchIcon from "@mui/icons-material/Search"; import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; import { Alert, Box, InputAdornment, Tooltip } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useEffect, useState } from "react"; import { useWatch } from "react-hook-form"; import { BooleanField, BooleanInput, Button, CreateButton, DateField, ExportButton, ListProps, NullableBooleanInput, Pagination, SelectInput, SimpleList, TextInput, TextField, TopToolbar, Identifier, useListContext, useLocale, useNotify, useTranslate, Link, } from "react-admin"; import AvatarField from "../../components/users/fields/AvatarField"; import DeleteUserButton from "../../components/users/buttons/DeleteUserButton"; import { DeleteUserMediaBulkButton } from "../../components/users/buttons/DeleteAllMediaButton"; import { ServerNoticeBulkButton } from "../../components/users/ServerNotices"; import { FindUserButton } from "../../components/users/buttons/FindUserButton"; import { useDocTitle } from "../../components/hooks/useDocTitle"; import { GetConfig } from "../../utils/config"; import { DATE_FORMAT } from "../../utils/date"; import { isSystemUser, getLocalpart } from "../../utils/mxid"; import { isMAS } from "../../providers/data/mas"; import { Datagrid, EmptyState, List } from "../../components/layout"; const UserListActions = () => { const { total } = useListContext(); return ( <TopToolbar> <FindUserButton /> <CreateButton /> {!!total && <ExportButton maxResults={10000} />} <Button component={Link} to="/import_users" label="CSV Import"> <GetAppIcon sx={{ transform: "rotate(180deg)", fontSize: "20px" }} /> </Button> </TopToolbar> ); }; const UserPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />; const SystemUsersFilter = (props: Record<string, unknown>) => { const translate = useTranslate(); const label = ( <Box component="span" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}> <HourglassEmptyIcon sx={{ fontSize: "1em", opacity: 0.6 }} /> {translate("resources.users.fields.show_system_users")} </Box> ); return ( <NullableBooleanInput {...props} label={label} source="system_users" nullLabel="resources.users.fields.filter_user_all" falseLabel="resources.users.fields.filter_system_users_false" trueLabel="resources.users.fields.filter_system_users_true" /> ); }; const MASStatusFilter = (props: Record<string, unknown>) => { return ( <SelectInput {...props} source="status" choices={[ { id: "active", name: "resources.mas_users.filter.status_active" }, { id: "locked", name: "resources.mas_users.filter.status_locked" }, { id: "deactivated", name: "resources.mas_users.filter.status_deactivated" }, ]} /> ); }; const ReverseSearchInput = (props: { source: string } & Record<string, unknown>) => { const translate = useTranslate(); const nameValue = useWatch({ name: "name" }) as string | undefined; const isReverse = typeof nameValue === "string" && nameValue.startsWith("!"); return ( <TextInput {...props} resettable slotProps={{ htmlInput: { "aria-label": translate("ra.action.search") }, input: { startAdornment: ( <InputAdornment position="start"> {isReverse ? ( <HourglassEmptyIcon sx={{ fontSize: "1em", opacity: 0.6 }} /> ) : ( <SearchIcon sx={{ fontSize: "1em", opacity: 0.6 }} /> )} </InputAdornment> ), }, }} /> ); }; const userFilters = () => { const mas = isMAS(); const filters = [ <ReverseSearchInput key="name" source="name" alwaysOn />, ...(mas ? [<MASStatusFilter key="status" source="status" />, <BooleanInput key="admin" source="admin" />] : []), <NullableBooleanInput key="deactivated" label="resources.users.fields.show_deactivated" source="deactivated" nullLabel="resources.users.fields.filter_user_all" falseLabel="resources.users.fields.filter_deactivated_false" trueLabel="resources.users.fields.filter_deactivated_true" alwaysOn />, <NullableBooleanInput key="locked" label="resources.users.fields.show_locked" source="locked" nullLabel="resources.users.fields.filter_user_all" falseLabel="resources.users.fields.filter_locked_false" trueLabel="resources.users.fields.filter_locked_true" alwaysOn />, // waiting for https://github.com/element-hq/synapse/issues/18016 // <BooleanInput label="resources.users.fields.show_suspended" source="suspended" alwaysOn />, // as of Synapse v1.149.1, filter doesn't work yet, showing all users instead of only shadow banned ones // <BooleanInput label="resources.users.fields.show_shadow_banned" source="shadow_banned" alwaysOn />, ]; // guests filter: hidden in MAS mode (externalAuthProvider is set) and when using an external auth provider if (!GetConfig().externalAuthProvider) { filters.push( <NullableBooleanInput key="guests" label="resources.users.fields.show_guests" source="guests" nullLabel="resources.users.fields.filter_user_all" falseLabel="resources.users.fields.filter_guests_false" trueLabel="resources.users.fields.filter_guests_true" alwaysOn /> ); } if (GetConfig().asManagedUsers?.length > 0) { filters.push(<SystemUsersFilter key="system_users" source="system_users" alwaysOn />); } return filters; }; export const UserPreventSelfDelete: React.FC<{ children: React.ReactNode; ownUserIsSelected: boolean; systemUserIsSelected: boolean; }> = props => { const ownUserIsSelected = props.ownUserIsSelected; const systemUserIsSelected = props.systemUserIsSelected; const notify = useNotify(); const translate = useTranslate(); const handleDeleteClick = (ev: React.MouseEvent<HTMLDivElement>) => { if (ownUserIsSelected) { notify(<Alert severity="error">{translate("resources.users.helper.erase_admin_error")}</Alert>); ev.stopPropagation(); } else if (systemUserIsSelected) { notify(<Alert severity="error">{translate("resources.users.helper.modify_managed_user_error")}</Alert>); ev.stopPropagation(); } }; return <div onClickCapture={handleDeleteClick}>{props.children}</div>; }; const UserBulkActionButtons = () => { const record = useListContext(); const [ownUserIsSelected, setOwnUserIsSelected] = useState(false); const [systemUserIsSelected, setSystemUserIsSelected] = useState(false); const selectedIds = record.selectedIds; const masIdMap = Object.fromEntries((record.data || []).map(r => [String(r.id), String(r.mas_id || r.id)])); const ownUserId = localStorage.getItem("user_id"); useEffect(() => { setOwnUserIsSelected(selectedIds.includes(ownUserId)); setSystemUserIsSelected(selectedIds.some(id => isSystemUser(id))); }, [selectedIds, ownUserId]); return ( <> <ServerNoticeBulkButton /> <DeleteUserMediaBulkButton /> <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} systemUserIsSelected={systemUserIsSelected}> <DeleteUserButton selectedIds={selectedIds} confirmTitle="resources.users.helper.erase" confirmContent="resources.users.helper.erase_text" masIdMap={masIdMap} /> </UserPreventSelfDelete> </> ); }; export const UserList = (props: ListProps) => { const locale = useLocale(); const translate = useTranslate(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("sm")); useDocTitle(translate("resources.users.name", { smart_count: 2 })); return ( <List {...props} filters={userFilters()} filterDefaultValues={isMAS() ? {} : { guests: false, locked: false, suspended: false }} // shadow_banned: no API yet sort={{ field: "name", order: "ASC" }} actions={<UserListActions />} pagination={<UserPagination />} perPage={50} empty={<EmptyState />} sx={theme => ({ [theme.breakpoints.up("sm")]: { "& .RaList-actions": { flexWrap: "nowrap" }, "& .RaList-actions form": { flexWrap: "nowrap", overflowX: "auto", minWidth: 0 }, }, })} > {isSmall ? ( <SimpleList primaryText={record => ( <Box component="span" sx={{ wordBreak: "break-all" }}> {record.displayname || getLocalpart(record.id)} </Box> )} secondaryText={record => ( <Box component="span" sx={{ wordBreak: "break-all" }}> {record.id} </Box> )} tertiaryText={record => ( <Box component="span" sx={{ display: "flex", gap: 0.5 }}> {record.admin && ( <Tooltip title={translate("resources.users.fields.admin")}> <AdminPanelSettingsIcon fontSize="small" color="primary" /> </Tooltip> )} {record.locked && ( <Tooltip title={translate("resources.users.fields.locked")}> <LockIcon fontSize="small" color="warning" /> </Tooltip> )} {record.suspended && ( <Tooltip title={translate("resources.users.fields.suspended")}> <BlockIcon fontSize="small" color="warning" /> </Tooltip> )} {record.shadow_banned && ( <Tooltip title={translate("resources.users.fields.shadow_banned")}> <VisibilityOffIcon fontSize="small" color="warning" /> </Tooltip> )} {record.deactivated && ( <Tooltip title={translate("resources.users.fields.deactivated")}> <NoAccountsIcon fontSize="small" color="error" /> </Tooltip> )} {record.erased && ( <Tooltip title={translate("resources.users.fields.erased")}> <DeleteForeverIcon fontSize="small" color="error" /> </Tooltip> )} </Box> )} rowClick="edit" leftIcon={record => ( <AvatarField record={record} source="avatar_src" sx={{ height: "40px", width: "40px" }} /> )} /> ) : ( <Datagrid rowLabel={record => String(record.displayname || record.id)} rowClick={(id: Identifier, resource: string) => `/${resource}/${encodeURIComponent(id)}`} bulkActionButtons={<UserBulkActionButtons />} > <AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} sortBy="avatar_url" label="resources.users.fields.avatar" /> <TextField source="id" sx={{ wordBreak: "break-all", }} sortBy="name" label="resources.users.fields.id" /> <TextField source="displayname" sx={{ wordBreak: "break-all", }} label="resources.users.fields.displayname" /> <BooleanField source="is_guest" label="resources.users.fields.is_guest" sortable={isMAS() ? false : undefined} /> <BooleanField source="admin" label="resources.users.fields.admin" /> <BooleanField source="deactivated" label="resources.users.fields.deactivated" /> <BooleanField source="locked" label="resources.users.fields.locked" sortable={isMAS() ? false : undefined} /> <BooleanField source="shadow_banned" label="resources.users.fields.shadow_banned" /> <BooleanField source="erased" sortable={false} label="resources.users.fields.erased" /> <DateField source="creation_ts_ms" label="resources.users.fields.creation_ts_ms" showTime options={DATE_FORMAT} locales={locale} /> </Datagrid> )} </List> ); }; ================================================ FILE: src/resources/users/index.ts ================================================ import UserIcon from "@mui/icons-material/Group"; import { ResourceProps } from "react-admin"; export { UserList } from "./List"; export { UserEdit } from "./Edit"; export { UserCreate } from "./Create"; import { UserList } from "./List"; import { UserEdit } from "./Edit"; import { UserCreate } from "./Create"; const resource: ResourceProps = { name: "users", icon: UserIcon, list: UserList, edit: UserEdit, create: UserCreate, }; export default resource; ================================================ FILE: src/utils/config.test.ts ================================================ import { ClearConfig, FetchWellKnownConfig, GetConfig, LoadConfig, SetExternalAuthProvider, SubscribeConfig, WellKnownKey, } from "./config"; describe("config utils", () => { beforeEach(() => { localStorage.clear(); vi.stubGlobal("fetch", vi.fn()); ClearConfig(); LoadConfig({ restrictBaseUrl: "https://example.org", corsCredentials: "same-origin", asManagedUsers: [], menu: [], etkeccAdmin: "", }); }); it("converts managed-user patterns to regular expressions", () => { LoadConfig({ restrictBaseUrl: "https://example.org", corsCredentials: "include", asManagedUsers: ["^@bot:example\\.org$"], menu: [], etkeccAdmin: "admin", }); const config = GetConfig(); expect(config.corsCredentials).toBe("include"); expect(config.etkeccAdmin).toBe("admin"); expect(config.asManagedUsers[0]).toBeInstanceOf(RegExp); expect((config.asManagedUsers[0] as RegExp).test("@bot:example.org")).toBe(true); }); it("loads external auth provider from localStorage when omitted from context", () => { localStorage.setItem("external_auth_provider", "true"); LoadConfig({ restrictBaseUrl: "https://example.org", corsCredentials: "same-origin", asManagedUsers: [], menu: [], etkeccAdmin: "", }); expect(GetConfig().externalAuthProvider).toBe(true); }); it("preserves static config on clear while dropping runtime auth state", () => { SetExternalAuthProvider(true); ClearConfig(); const config = GetConfig(); expect(config.restrictBaseUrl).toBe("https://example.org"); expect(config.corsCredentials).toBe("same-origin"); expect(config.externalAuthProvider).toBeUndefined(); expect(localStorage.length).toBe(0); }); it("notifies subscribers when config changes", () => { const listener = vi.fn(); const unsubscribe = SubscribeConfig(listener); LoadConfig({ restrictBaseUrl: "https://updated.example.org", corsCredentials: "same-origin", asManagedUsers: [], menu: [], etkeccAdmin: "", }); expect(listener).toHaveBeenCalledTimes(1); unsubscribe(); }); it("sets wellKnownDiscovery when provided in context", () => { LoadConfig({ restrictBaseUrl: "https://example.org", corsCredentials: "same-origin", asManagedUsers: [], menu: [], etkeccAdmin: "", wellKnownDiscovery: false, }); expect(GetConfig().wellKnownDiscovery).toBe(false); }); it("sets wellKnownDiscovery to true when explicitly provided", () => { LoadConfig({ restrictBaseUrl: "https://example.org", corsCredentials: "same-origin", asManagedUsers: [], menu: [], etkeccAdmin: "", wellKnownDiscovery: true, }); expect(GetConfig().wellKnownDiscovery).toBe(true); }); it("does not overwrite wellKnownDiscovery when omitted from context", () => { LoadConfig({ restrictBaseUrl: "https://example.org", corsCredentials: "same-origin", asManagedUsers: [], menu: [], etkeccAdmin: "", wellKnownDiscovery: false, }); // omitting wellKnownDiscovery in a subsequent LoadConfig should not reset it LoadConfig({ restrictBaseUrl: "https://example.org", corsCredentials: "same-origin", asManagedUsers: [], menu: [], etkeccAdmin: "", }); expect(GetConfig().wellKnownDiscovery).toBe(false); }); it("loads well-known config using the host from restrictBaseUrl when home_server is unset", async () => { vi.mocked(fetch).mockResolvedValue( new Response( JSON.stringify({ [WellKnownKey]: { asManagedUsers: ["^@wk:example\\.org$"], menu: [], }, }) ) ); const loaded = await FetchWellKnownConfig(); expect(loaded).toBe(true); expect(global.fetch).toHaveBeenCalledWith("https://example.org/.well-known/matrix/client"); expect((GetConfig().asManagedUsers[0] as RegExp).test("@wk:example.org")).toBe(true); }); }); ================================================ FILE: src/utils/config.ts ================================================ import createLogger from "./logger"; const log = createLogger("config"); export interface Config { restrictBaseUrl: string | string[]; corsCredentials: string; asManagedUsers: RegExp[] | string[]; menu: MenuItem[]; externalAuthProvider?: boolean; etkeccAdmin?: string; wellKnownDiscovery?: boolean; } export interface MenuItem { label: string; i18n?: Record<string, string>; icon: string; url: string; } export const WellKnownKey = "cc.etke.ketesa"; export const WellKnownKeyLegacy = "cc.etke.synapse-admin"; type ConfigListener = () => void; const configListeners = new Set<ConfigListener>(); const notifyConfigListeners = () => { configListeners.forEach(listener => listener()); }; // current configuration let config: Config = { restrictBaseUrl: "", corsCredentials: "same-origin", asManagedUsers: [], menu: [], etkeccAdmin: "", }; export const FetchConfig = async () => { // load config.json and honor vite base url (import.meta.env.BASE_URL) // if that url doesn't have a trailing slash - add it let configJSONUrl = "config.json"; if (import.meta.env.BASE_URL) { configJSONUrl = `${import.meta.env.BASE_URL.replace(/\/?$/, "/")}config.json`; } try { const resp = await fetch(configJSONUrl); const configJSON = await resp.json(); log.debug("config.json loaded", { url: configJSONUrl }); LoadConfig(configJSON); } catch (e) { log.warn("config.json not found, using defaults", e); } await FetchWellKnownConfig(); if (config.externalAuthProvider !== undefined) { SetExternalAuthProvider(config.externalAuthProvider); } }; export const FetchWellKnownConfig = async () => { let protocol = "https"; const baseURL = localStorage.getItem("base_url"); if (baseURL && baseURL.startsWith("http://")) { protocol = "http"; } // if home_server is set, try to load https://home_server/.well-known/matrix/client let homeserver = localStorage.getItem("home_server"); // if it is not set, attempt to identify homeserver from the restrictBaseUrl config if (!homeserver) { const restrictBaseUrl = config.restrictBaseUrl; if (typeof restrictBaseUrl === "string" && restrictBaseUrl !== "") { try { const url = new URL(restrictBaseUrl); const host = url.host; if (host) { homeserver = host; } } catch (e) { // invalid URL, ignore log.warn("invalid restrictBaseUrl, skipping", { restrictBaseUrl, error: e }); } } else if (Array.isArray(restrictBaseUrl) && restrictBaseUrl.length > 0 && restrictBaseUrl[0] !== "") { try { const url = new URL(restrictBaseUrl[0]); const host = url.host; if (host) { homeserver = host; } } catch (e) { log.warn("invalid restrictBaseUrl, skipping", { restrictBaseUrl: restrictBaseUrl[0], error: e }); } } } if (!homeserver) { return false; } try { const resp = await fetch(`${protocol}://${homeserver}/.well-known/matrix/client`); const configWK = await resp.json(); const wkConfig = configWK[WellKnownKey] || configWK[WellKnownKeyLegacy]; if (!wkConfig) { log.debug("well-known loaded but no Ketesa config key found", { homeserver, expectedKey: WellKnownKey, legacyKey: WellKnownKeyLegacy, response: configWK, }); return false; } log.info("well-known config loaded", { homeserver }); LoadConfig(wkConfig); return true; } catch (e) { log.debug("well-known not found, skipping", { homeserver, error: e }); return false; } }; // load config from context // we deliberately processing each key separately to avoid overwriting the whole config, losing some keys, and messing // with typescript types export const LoadConfig = (context: Config) => { const nextConfig: Config = { ...config }; let changed = false; if (context?.restrictBaseUrl) { nextConfig.restrictBaseUrl = context.restrictBaseUrl as string | string[]; changed = true; } if (context?.corsCredentials) { nextConfig.corsCredentials = context.corsCredentials; changed = true; } if (context?.asManagedUsers) { nextConfig.asManagedUsers = context.asManagedUsers.map((regex: string | RegExp) => typeof regex === "string" ? new RegExp(regex) : regex ); changed = true; } let menu: MenuItem[] = []; if (context?.menu) { menu = context.menu as MenuItem[]; } if (menu.length > 0) { nextConfig.menu = menu; changed = true; } if (context?.externalAuthProvider !== undefined) { nextConfig.externalAuthProvider = context.externalAuthProvider; changed = true; } // if not set in context, try to load from localStorage if (nextConfig.externalAuthProvider === undefined) { const storedExternalAuthProvider = localStorage.getItem("external_auth_provider"); if (storedExternalAuthProvider !== null) { nextConfig.externalAuthProvider = storedExternalAuthProvider === "true"; changed = true; } } if (context?.wellKnownDiscovery !== undefined) { nextConfig.wellKnownDiscovery = context.wellKnownDiscovery; changed = true; } if (context?.etkeccAdmin) { nextConfig.etkeccAdmin = context.etkeccAdmin; changed = true; } if (changed) { config = nextConfig; log.debug("config updated", { config }); notifyConfigListeners(); } }; // get config export const GetConfig = (): Config => { return config; }; // Clear session-specific runtime state from config and localStorage. // Static deployment config (restrictBaseUrl, corsCredentials, asManagedUsers, menu, etkeccAdmin) // is preserved so the login page behaves correctly after logout. export const ClearConfig = () => { config = { ...config, externalAuthProvider: undefined }; localStorage.clear(); notifyConfigListeners(); }; // workaround for external auth providers (like OIDC, LDAP, etc.) to signal that some functionality should be disabled export const SetExternalAuthProvider = (value: boolean) => { config = { ...config, externalAuthProvider: value }; localStorage.setItem("external_auth_provider", value ? "true" : "false"); notifyConfigListeners(); }; export const SubscribeConfig = (listener: ConfigListener) => { configListeners.add(listener); return () => { configListeners.delete(listener); }; }; ================================================ FILE: src/utils/date.test.ts ================================================ import { dateFormatter, dateParser, getTimeSince, getTimeUntil, normalizeTS } from "./date"; describe("normalizeTS", () => { it("converts second-based unix timestamps to milliseconds", () => { expect(normalizeTS(1560432506)).toBe(1560432506000); }); it("keeps millisecond-based unix timestamps unchanged", () => { expect(normalizeTS(1560432668000)).toBe(1560432668000); }); it("returns null and undefined unchanged", () => { expect(normalizeTS(null)).toBeNull(); expect(normalizeTS(undefined)).toBeUndefined(); }); }); describe("dateParser", () => { it("parses a date string to a numeric timestamp", () => { const ts = dateParser("2020-01-01T00:00:00.000Z"); expect(typeof ts).toBe("number"); expect(ts).toBe(new Date("2020-01-01T00:00:00.000Z").getTime()); }); it("accepts a numeric timestamp and returns it unchanged", () => { const now = Date.now(); expect(dateParser(now)).toBe(now); }); it("accepts a Date object", () => { const d = new Date(0); expect(dateParser(d)).toBe(0); }); }); describe("dateFormatter", () => { it("returns empty string for null", () => { expect(dateFormatter(null)).toBe(""); }); it("returns empty string for undefined", () => { expect(dateFormatter(undefined)).toBe(""); }); it("formats a Date object to yyyy-MM-ddThh:mm", () => { // Use a fixed UTC date and derive expected local parts to avoid TZ drift const d = new Date(2024, 2, 15, 10, 30); // local time 2024-03-15 10:30 const pad = (n: number) => n.toString().padStart(2, "0"); const expected = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; expect(dateFormatter(d)).toBe(expected); }); it("formats a timestamp number", () => { const d = new Date(2024, 0, 1, 8, 5); // 2024-01-01 08:05 const result = dateFormatter(d.getTime()); expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/); }); it("formats a date string", () => { const result = dateFormatter("2024-06-15T12:00:00.000Z"); expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/); }); }); describe("getTimeSince", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); const setNow = (date: Date) => vi.setSystemTime(date); const past = (minutesAgo: number): string => { const d = new Date(Date.now() - minutesAgo * 60 * 1000); return d .toISOString() .replace("T", " ") .replace(/\.\d{3}Z$/, ""); }; it("returns less_than_minute when diff < 1 min", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeSince(past(0)); expect(result.timeI18Nkey).toBe("etkecc.time.less_than_minute"); expect(result.timeI18Nparams).toEqual({}); }); it("returns minutes with smart_count 1 when diff === 1 min", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeSince(past(1)); expect(result.timeI18Nkey).toBe("etkecc.time.minutes"); expect(result.timeI18Nparams).toEqual({ smart_count: 1 }); }); it("returns minutes with correct count when diff < 60 min", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeSince(past(30)); expect(result.timeI18Nkey).toBe("etkecc.time.minutes"); expect(result.timeI18Nparams).toEqual({ smart_count: 30 }); }); it("returns hours with smart_count 1 when diff is 60–119 min", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeSince(past(90)); expect(result.timeI18Nkey).toBe("etkecc.time.hours"); expect(result.timeI18Nparams).toEqual({ smart_count: 1 }); }); it("returns hours with correct count when diff < 24h", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeSince(past(6 * 60)); expect(result.timeI18Nkey).toBe("etkecc.time.hours"); expect(result.timeI18Nparams).toEqual({ smart_count: 6 }); }); it("returns days with smart_count 1 when diff is 24–47h", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeSince(past(36 * 60)); expect(result.timeI18Nkey).toBe("etkecc.time.days"); expect(result.timeI18Nparams).toEqual({ smart_count: 1 }); }); it("returns days with correct count when diff < 7 days", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeSince(past(5 * 24 * 60)); expect(result.timeI18Nkey).toBe("etkecc.time.days"); expect(result.timeI18Nparams).toEqual({ smart_count: 5 }); }); it("returns weeks with smart_count 1 when diff is 7–13 days", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeSince(past(10 * 24 * 60)); expect(result.timeI18Nkey).toBe("etkecc.time.weeks"); expect(result.timeI18Nparams).toEqual({ smart_count: 1 }); }); it("returns weeks with correct count when diff < 30 days", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeSince(past(21 * 24 * 60)); expect(result.timeI18Nkey).toBe("etkecc.time.weeks"); expect(result.timeI18Nparams).toEqual({ smart_count: 3 }); }); it("returns months with smart_count 1 when diff is 30–59 days", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeSince(past(45 * 24 * 60)); expect(result.timeI18Nkey).toBe("etkecc.time.months"); expect(result.timeI18Nparams).toEqual({ smart_count: 1 }); }); it("returns months with correct count when diff >= 60 days", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeSince(past(90 * 24 * 60)); expect(result.timeI18Nkey).toBe("etkecc.time.months"); expect(result.timeI18Nparams).toEqual({ smart_count: 3 }); }); it("appends Z to date strings without timezone suffix", () => { setNow(new Date("2024-01-01T12:01:00Z")); // "2024-01-01 12:00:00" has no Z — should be treated as UTC const result = getTimeSince("2024-01-01 12:00:00"); expect(result.timeI18Nkey).toBe("etkecc.time.minutes"); expect(result.timeI18Nparams).toEqual({ smart_count: 1 }); }); it("handles overdue dates (past, > 1 month)", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeSince(past(45 * 24 * 60)); expect(result.timeI18Nkey).toBe("etkecc.time.months"); expect(result.timeI18Nparams).toEqual({ smart_count: 1 }); }); it("handles overdue dates (past, > 2 months)", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeSince(past(75 * 24 * 60)); expect(result.timeI18Nkey).toBe("etkecc.time.months"); expect(result.timeI18Nparams).toEqual({ smart_count: 2 }); }); }); describe("getTimeUntil", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); const setNow = (date: Date) => vi.setSystemTime(date); const future = (minutesFromNow: number): string => { const d = new Date(Date.now() + minutesFromNow * 60 * 1000); return d.toISOString(); }; it("returns less_than_minute when diff < 1 min", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeUntil(future(0)); expect(result.timeI18Nkey).toBe("etkecc.time.less_than_minute"); expect(result.timeI18Nparams).toEqual({}); }); it("returns minutes with smart_count 1 when diff === 1 min", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeUntil(future(1)); expect(result.timeI18Nkey).toBe("etkecc.time.minutes"); expect(result.timeI18Nparams).toEqual({ smart_count: 1 }); }); it("returns minutes with correct count when diff < 60 min", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeUntil(future(30)); expect(result.timeI18Nkey).toBe("etkecc.time.minutes"); expect(result.timeI18Nparams).toEqual({ smart_count: 30 }); }); it("returns hours with smart_count 1 when diff is 60–119 min", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeUntil(future(90)); expect(result.timeI18Nkey).toBe("etkecc.time.hours"); expect(result.timeI18Nparams).toEqual({ smart_count: 1 }); }); it("returns days with smart_count 1 when diff is 24–47h", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeUntil(future(36 * 60)); expect(result.timeI18Nkey).toBe("etkecc.time.days"); expect(result.timeI18Nparams).toEqual({ smart_count: 1 }); }); it("returns days with correct count when diff < 7 days", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeUntil(future(5 * 24 * 60)); expect(result.timeI18Nkey).toBe("etkecc.time.days"); expect(result.timeI18Nparams).toEqual({ smart_count: 5 }); }); it("returns weeks with smart_count 1 when diff is 7–13 days", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeUntil(future(10 * 24 * 60)); expect(result.timeI18Nkey).toBe("etkecc.time.weeks"); expect(result.timeI18Nparams).toEqual({ smart_count: 1 }); }); it("returns months with smart_count 1 when diff is 30–59 days", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeUntil(future(45 * 24 * 60)); expect(result.timeI18Nkey).toBe("etkecc.time.months"); expect(result.timeI18Nparams).toEqual({ smart_count: 1 }); }); it("returns months with correct count when diff >= 60 days", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeUntil(future(90 * 24 * 60)); expect(result.timeI18Nkey).toBe("etkecc.time.months"); expect(result.timeI18Nparams).toEqual({ smart_count: 3 }); }); it("returns less_than_minute for a past date (due_at already passed)", () => { setNow(new Date("2024-01-01T12:00:00Z")); // date 1 minute in the past → diff is negative, resolves to first bucket const pastDate = new Date(Date.now() - 60 * 1000).toISOString(); const result = getTimeUntil(pastDate); expect(result.timeI18Nkey).toBe("etkecc.time.less_than_minute"); }); it("appends Z to date strings without timezone suffix", () => { setNow(new Date("2024-01-01T12:00:00Z")); const result = getTimeUntil("2024-01-01 12:01:00"); expect(result.timeI18Nkey).toBe("etkecc.time.minutes"); expect(result.timeI18Nparams).toEqual({ smart_count: 1 }); }); }); ================================================ FILE: src/utils/date.ts ================================================ export const DATE_FORMAT: Intl.DateTimeFormatOptions = { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }; export const dateParser = (v: string | number | Date): number => { const d = new Date(v); return d.getTime(); }; export const dateFormatter = (v: string | number | Date | undefined | null): string => { if (v === undefined || v === null) return ""; const d = new Date(v); const pad = "00"; const year = d.getFullYear().toString(); const month = (pad + (d.getMonth() + 1).toString()).slice(-2); const day = (pad + d.getDate().toString()).slice(-2); const hour = (pad + d.getHours().toString()).slice(-2); const minute = (pad + d.getMinutes().toString()).slice(-2); // target format yyyy-MM-ddThh:mm return `${year}-${month}-${day}T${hour}:${minute}`; }; /** * Normalize timestamps to milliseconds. * * This exists because the upstream APIs used by the users resource are inconsistent: * * - `GET /_synapse/admin/v2/users/<user_id>` returns `creation_ts` in seconds. * - `GET /_synapse/admin/v2/users` and `GET /_synapse/admin/v3/users` return `creation_ts` in milliseconds. * * The UI expects a single normalized field (`creation_ts_ms`) regardless of which endpoint * produced the record. Without normalization, records loaded from the single-user v2 endpoint * are interpreted as milliseconds by the browser and render dates around January 1970. * * We detect second-based Unix timestamps using a simple threshold: * current millisecond epoch values are 13 digits, while second-based epoch values are 10 digits. * Anything below 1_000_000_000_000 is therefore treated as seconds and multiplied by 1000. * * This helper is intentionally conservative: * - `null` and `undefined` are returned as-is so callers can preserve missing values. * - already-normalized millisecond timestamps are returned unchanged. */ export const normalizeTS = (value?: number | null): number | null | undefined => { if (value == null) { return value; } return value < 1_000_000_000_000 ? value * 1000 : value; }; interface TimeSinceResult { timeI18Nkey: string; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ timeI18Nparams: Record<string, any>; } /** * Returns a relative time result for a future date (e.g. "in 5 days"). * Uses the same bucket thresholds and TimeSinceResult shape as getTimeSince. * If the date is in the past, diffInMs will be negative and all thresholds resolve * to the first bucket (less_than_minute), which callers should treat as "due now". * Assumes ISO 8601 format; appends "Z" if no timezone suffix is present. */ export const getTimeUntil = (dateToCompare: string): TimeSinceResult => { const nowUTC = new Date().getTime(); if (!dateToCompare.includes("Z")) { dateToCompare = dateToCompare + "Z"; } const future = new Date(dateToCompare); const futureUTC = future.getTime(); const diffInMs = futureUTC - nowUTC; const diffInMinutes = Math.floor(diffInMs / (1000 * 60)); if (diffInMinutes < 1) return { timeI18Nkey: "etkecc.time.less_than_minute", timeI18Nparams: {} }; if (diffInMinutes === 1) return { timeI18Nkey: "etkecc.time.minutes", timeI18Nparams: { smart_count: 1 } }; if (diffInMinutes < 60) return { timeI18Nkey: "etkecc.time.minutes", timeI18Nparams: { smart_count: diffInMinutes } }; if (diffInMinutes < 120) return { timeI18Nkey: "etkecc.time.hours", timeI18Nparams: { smart_count: 1 } }; if (diffInMinutes < 24 * 60) return { timeI18Nkey: "etkecc.time.hours", timeI18Nparams: { smart_count: Math.floor(diffInMinutes / 60) } }; if (diffInMinutes < 48 * 60) return { timeI18Nkey: "etkecc.time.days", timeI18Nparams: { smart_count: 1 } }; if (diffInMinutes < 7 * 24 * 60) return { timeI18Nkey: "etkecc.time.days", timeI18Nparams: { smart_count: Math.floor(diffInMinutes / (24 * 60)) } }; if (diffInMinutes < 14 * 24 * 60) return { timeI18Nkey: "etkecc.time.weeks", timeI18Nparams: { smart_count: 1 } }; if (diffInMinutes < 30 * 24 * 60) return { timeI18Nkey: "etkecc.time.weeks", timeI18Nparams: { smart_count: Math.floor(diffInMinutes / (7 * 24 * 60)) }, }; if (diffInMinutes < 60 * 24 * 60) return { timeI18Nkey: "etkecc.time.months", timeI18Nparams: { smart_count: 1 } }; return { timeI18Nkey: "etkecc.time.months", timeI18Nparams: { smart_count: Math.floor(diffInMinutes / (30 * 24 * 60)) }, }; }; // assuming date is in format "2025-02-26 20:52:00" where no timezone is specified export const getTimeSince = (dateToCompare: string): TimeSinceResult => { const nowUTC = new Date().getTime(); if (!dateToCompare.includes("Z")) { dateToCompare = dateToCompare + "Z"; } const past = new Date(dateToCompare); const pastUTC = past.getTime(); const diffInMs = nowUTC - pastUTC; const diffInMinutes = Math.floor(diffInMs / (1000 * 60)); if (diffInMinutes < 1) return { timeI18Nkey: "etkecc.time.less_than_minute", timeI18Nparams: {} }; if (diffInMinutes === 1) return { timeI18Nkey: "etkecc.time.minutes", timeI18Nparams: { smart_count: 1 } }; if (diffInMinutes < 60) return { timeI18Nkey: "etkecc.time.minutes", timeI18Nparams: { smart_count: diffInMinutes } }; if (diffInMinutes < 120) return { timeI18Nkey: "etkecc.time.hours", timeI18Nparams: { smart_count: 1 } }; if (diffInMinutes < 24 * 60) return { timeI18Nkey: "etkecc.time.hours", timeI18Nparams: { smart_count: Math.floor(diffInMinutes / 60) } }; if (diffInMinutes < 48 * 60) return { timeI18Nkey: "etkecc.time.days", timeI18Nparams: { smart_count: 1 } }; if (diffInMinutes < 7 * 24 * 60) return { timeI18Nkey: "etkecc.time.days", timeI18Nparams: { smart_count: Math.floor(diffInMinutes / (24 * 60)) } }; if (diffInMinutes < 14 * 24 * 60) return { timeI18Nkey: "etkecc.time.weeks", timeI18Nparams: { smart_count: 1 } }; if (diffInMinutes < 30 * 24 * 60) return { timeI18Nkey: "etkecc.time.weeks", timeI18Nparams: { smart_count: Math.floor(diffInMinutes / (7 * 24 * 60)) }, }; if (diffInMinutes < 60 * 24 * 60) return { timeI18Nkey: "etkecc.time.months", timeI18Nparams: { smart_count: 1 } }; return { timeI18Nkey: "etkecc.time.months", timeI18Nparams: { smart_count: Math.floor(diffInMinutes / (30 * 24 * 60)) }, }; }; ================================================ FILE: src/utils/error.test.ts ================================================ import { displayError } from "./error"; describe("displayError", () => { it("formats the error string correctly", () => { expect(displayError("M_UNKNOWN", 500, "Internal server error")).toBe("M_UNKNOWN (500): Internal server error"); }); it("handles empty strings", () => { expect(displayError("", 0, "")).toBe(" (0): "); }); it("includes all three parts", () => { const result = displayError("M_FORBIDDEN", 403, "You are not allowed"); expect(result).toContain("M_FORBIDDEN"); expect(result).toContain("403"); expect(result).toContain("You are not allowed"); }); }); ================================================ FILE: src/utils/error.ts ================================================ export interface MatrixError { errcode: string; error: string; } export const displayError = (errcode: string, status: number, message: string) => `${errcode} (${status}): ${message}`; ================================================ FILE: src/utils/fetchMedia.test.ts ================================================ import { fetchAuthenticatedMedia, getServerAndMediaIdFromMxcUrl } from "./fetchMedia"; describe("getServerAndMediaIdFromMxcUrl", () => { it("extracts the server name and media id from a valid MXC URL", () => { expect(getServerAndMediaIdFromMxcUrl("mxc://matrix.example/media-123")).toEqual({ serverName: "matrix.example", mediaId: "media-123", }); }); it("returns empty values for invalid MXC URLs", () => { expect(getServerAndMediaIdFromMxcUrl("https://example.org/not-mxc")).toEqual({ serverName: "", mediaId: "", }); }); }); describe("fetchAuthenticatedMedia", () => { beforeEach(() => { localStorage.clear(); localStorage.setItem("base_url", "https://hs.example"); localStorage.setItem("access_token", "secret-token"); vi.stubGlobal("fetch", vi.fn()); }); it("returns a 400 response for invalid MXC URLs without fetching", async () => { const response = await fetchAuthenticatedMedia("invalid", "thumbnail"); expect(response.status).toBe(400); expect(global.fetch).not.toHaveBeenCalled(); }); it("fetches thumbnails with the authenticated media URL", async () => { vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 200 })); await fetchAuthenticatedMedia("mxc://matrix.example/media123", "thumbnail"); expect(global.fetch).toHaveBeenCalledWith( "https://hs.example/_matrix/client/v1/media/thumbnail/matrix.example/media123?width=320&height=240&method=scale", { headers: { authorization: "Bearer secret-token", }, } ); }); it("fetches originals with quarantine bypass enabled", async () => { vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 200 })); await fetchAuthenticatedMedia("mxc://matrix.example/media123", "original"); expect(global.fetch).toHaveBeenCalledWith( "https://hs.example/_matrix/client/v1/media/download/matrix.example/media123/?admin_unsafely_bypass_quarantine=true", { headers: { authorization: "Bearer secret-token", }, } ); }); }); ================================================ FILE: src/utils/fetchMedia.ts ================================================ import createLogger from "./logger"; const log = createLogger("media"); export const getServerAndMediaIdFromMxcUrl = (mxcUrl: string): { serverName: string; mediaId: string } => { const re = /^mxc:\/\/([^/]+)\/([\w-]+)$/; const ret = re.exec(mxcUrl); if (ret == null) { return { serverName: "", mediaId: "" }; } const serverName = ret[1]; const mediaId = ret[2]; return { serverName, mediaId }; }; export type MediaType = "thumbnail" | "original"; export const fetchAuthenticatedMedia = async (mxcUrl: string, type: MediaType): Promise<Response> => { const homeserver = localStorage.getItem("base_url"); const accessToken = localStorage.getItem("access_token"); const { serverName, mediaId } = getServerAndMediaIdFromMxcUrl(mxcUrl); if (!serverName || !mediaId) { log.error("invalid mxc URL", { mxcUrl, serverName, mediaId }); return new Response(null, { status: 400, statusText: "Invalid mxcUrl" }); } let url: string; if (type === "thumbnail") { // ref: https://spec.matrix.org/latest/client-server-api/#thumbnails url = `${homeserver}/_matrix/client/v1/media/thumbnail/${serverName}/${mediaId}?width=320&height=240&method=scale`; } else if (type === "original") { url = `${homeserver}/_matrix/client/v1/media/download/${serverName}/${mediaId}/?admin_unsafely_bypass_quarantine=true`; } else { throw new Error("Invalid authenticated media type"); } const response = await fetch(`${url}`, { headers: { authorization: `Bearer ${accessToken}`, }, }); return response; }; ================================================ FILE: src/utils/formatBytes.test.ts ================================================ import { formatBytes } from "./formatBytes"; describe("formatBytes", () => { it("formats zero bytes", () => { expect(formatBytes(0)).toBe("0 B"); }); it("formats bytes without decimals", () => { expect(formatBytes(1023)).toBe("1023 B"); }); it("formats kilobytes with one decimal place", () => { expect(formatBytes(1024)).toBe("1.0 KB"); }); it("caps units at terabytes", () => { expect(formatBytes(1024 ** 5)).toBe("1024.0 TB"); }); }); ================================================ FILE: src/utils/formatBytes.ts ================================================ const units = ["B", "KB", "MB", "GB", "TB"]; export const formatBytes = (bytes: number): string => { if (bytes === 0) return "0 B"; const i = Math.floor(Math.log(bytes) / Math.log(1024)); const index = Math.min(i, units.length - 1); return `${(bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`; }; ================================================ FILE: src/utils/icons.ts ================================================ import AnnouncementIcon from "@mui/icons-material/Announcement"; import EngineeringIcon from "@mui/icons-material/Engineering"; import HelpCenterIcon from "@mui/icons-material/HelpCenter"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import PieChartIcon from "@mui/icons-material/PieChart"; import PriceCheckIcon from "@mui/icons-material/PriceCheck"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; import RouterIcon from "@mui/icons-material/Router"; import SupportAgentIcon from "@mui/icons-material/SupportAgent"; import UpgradeIcon from "@mui/icons-material/Upgrade"; export const Icons = { Announcement: AnnouncementIcon, Engineering: EngineeringIcon, HelpCenter: HelpCenterIcon, SupportAgent: SupportAgentIcon, Default: OpenInNewIcon, PieChart: PieChartIcon, Upgrade: UpgradeIcon, Router: RouterIcon, PriceCheck: PriceCheckIcon, RestartAlt: RestartAltIcon, // Add more icons as needed }; export const DefaultIcon = Icons.Default; ================================================ FILE: src/utils/logger.test.ts ================================================ import createLogger from "./logger"; describe("createLogger", () => { let debugSpy: ReturnType<typeof vi.spyOn>; let infoSpy: ReturnType<typeof vi.spyOn>; let warnSpy: ReturnType<typeof vi.spyOn>; let errorSpy: ReturnType<typeof vi.spyOn>; beforeEach(() => { debugSpy = vi.spyOn(console, "debug").mockImplementation(() => undefined); infoSpy = vi.spyOn(console, "info").mockImplementation(() => undefined); warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); }); afterEach(() => { vi.restoreAllMocks(); }); it("returns an object with debug, info, warn, and error methods", () => { const logger = createLogger("test"); expect(typeof logger.debug).toBe("function"); expect(typeof logger.info).toBe("function"); expect(typeof logger.warn).toBe("function"); expect(typeof logger.error).toBe("function"); }); it("debug() calls console.debug with the prefix", () => { createLogger("myprefix").debug("hello", 42); expect(debugSpy).toHaveBeenCalledWith("[myprefix]", "hello", 42); }); it("info() calls console.info with the prefix", () => { createLogger("myprefix").info("world"); expect(infoSpy).toHaveBeenCalledWith("[myprefix]", "world"); }); it("warn() calls console.warn with the prefix", () => { createLogger("myprefix").warn("careful"); expect(warnSpy).toHaveBeenCalledWith("[myprefix]", "careful"); }); it("error() calls console.error with the prefix", () => { createLogger("myprefix").error("boom", { code: 1 }); expect(errorSpy).toHaveBeenCalledWith("[myprefix]", "boom", { code: 1 }); }); it("uses the correct prefix per logger instance", () => { createLogger("alpha").info("a"); createLogger("beta").info("b"); expect(infoSpy).toHaveBeenNthCalledWith(1, "[alpha]", "a"); expect(infoSpy).toHaveBeenNthCalledWith(2, "[beta]", "b"); }); }); ================================================ FILE: src/utils/logger.ts ================================================ const createLogger = (prefix: string) => ({ debug: (...args: unknown[]) => console.debug(`[${prefix}]`, ...args), info: (...args: unknown[]) => console.info(`[${prefix}]`, ...args), warn: (...args: unknown[]) => console.warn(`[${prefix}]`, ...args), error: (...args: unknown[]) => console.error(`[${prefix}]`, ...args), }); export default createLogger; ================================================ FILE: src/utils/mxid.test.ts ================================================ import { LoadConfig } from "./config"; import { generateRandomMXID, getLocalpart, isMXID, isSystemUser, returnMXID } from "./mxid"; describe("mxid utils", () => { beforeEach(() => { localStorage.clear(); LoadConfig({ restrictBaseUrl: "https://example.org", corsCredentials: "same-origin", asManagedUsers: [], menu: [], etkeccAdmin: "", }); }); // --------------------------------------------------------------------------- // isMXID // --------------------------------------------------------------------------- describe("isMXID", () => { describe("valid MXIDs", () => { it("accepts a simple domain server name", () => { expect(isMXID("@alice:example.org")).toBe(true); }); it("accepts a subdomain server name", () => { expect(isMXID("@alice:matrix.example.org")).toBe(true); }); it("accepts a domain with port", () => { expect(isMXID("@alice:example.org:8448")).toBe(true); }); it("accepts a host:port server name (no TLD, e.g. dev/test envs)", () => { expect(isMXID("@managed-bot:synapse:8008")).toBe(true); }); it("accepts an IPv4 server name", () => { expect(isMXID("@alice:192.168.1.1")).toBe(true); }); it("accepts an IPv4 server name with port", () => { expect(isMXID("@alice:192.168.1.1:8448")).toBe(true); }); it("accepts an IPv6 server name (loopback)", () => { expect(isMXID("@alice:[::1]")).toBe(true); }); it("accepts an IPv6 server name with port", () => { expect(isMXID("@alice:[::1]:8448")).toBe(true); }); it("accepts a full IPv6 server name with port", () => { expect(isMXID("@alice:[2001:db8::1]:8448")).toBe(true); }); it("accepts a localpart with underscores (appservice/bridge users)", () => { expect(isMXID("@_bridge_bot:example.org")).toBe(true); }); it("accepts a localpart with hyphens", () => { expect(isMXID("@managed-mas:example.org")).toBe(true); }); it("accepts a localpart with dots", () => { expect(isMXID("@alice.smith:example.org")).toBe(true); }); }); describe("invalid MXIDs", () => { it("rejects a plain username with no sigil or server", () => { expect(isMXID("alice")).toBe(false); }); it("rejects a username missing the server part", () => { expect(isMXID("@alice")).toBe(false); }); it("rejects a username with an empty server part", () => { expect(isMXID("@alice:")).toBe(false); }); it("rejects an empty string", () => { expect(isMXID("")).toBe(false); }); it("rejects a string with an empty localpart", () => { expect(isMXID("@:example.org")).toBe(false); }); it("rejects a double-@ prefix", () => { expect(isMXID("@@alice:example.org")).toBe(false); }); it("rejects a localpart that contains @", () => { expect(isMXID("@al@ice:example.org")).toBe(false); }); it("rejects a server-only string with no @-prefix", () => { expect(isMXID(":example.org")).toBe(false); }); it("rejects undefined coerced to string", () => { expect(isMXID(undefined as unknown as string)).toBe(false); }); }); }); // --------------------------------------------------------------------------- // getLocalpart // --------------------------------------------------------------------------- describe("getLocalpart", () => { it("extracts localpart from a simple MXID", () => { expect(getLocalpart("@alice:example.org")).toBe("alice"); }); it("extracts localpart from a MXID with port", () => { expect(getLocalpart("@alice:example.org:8448")).toBe("alice"); }); it("extracts localpart from a MXID with IPv4 server", () => { expect(getLocalpart("@alice:192.168.1.1:8448")).toBe("alice"); }); it("extracts localpart from a MXID with IPv6 server", () => { expect(getLocalpart("@alice:[::1]:8448")).toBe("alice"); }); it("extracts localpart with hyphens and underscores", () => { expect(getLocalpart("@_bridge-bot:example.org")).toBe("_bridge-bot"); }); it("returns the input unchanged when there is no @ prefix", () => { expect(getLocalpart("alice")).toBe("alice"); }); it("returns the input unchanged when there is no colon", () => { expect(getLocalpart("@alice")).toBe("@alice"); }); it("returns a non-MXID string unchanged", () => { expect(getLocalpart("just-a-string")).toBe("just-a-string"); }); }); // --------------------------------------------------------------------------- // returnMXID // --------------------------------------------------------------------------- describe("returnMXID", () => { it("returns an already-valid MXID unchanged (simple domain)", () => { localStorage.setItem("home_server", "example.org"); expect(returnMXID("@alice:example.org")).toBe("@alice:example.org"); }); it("returns an already-valid MXID unchanged (domain with port)", () => { localStorage.setItem("home_server", "example.org:8448"); expect(returnMXID("@alice:example.org:8448")).toBe("@alice:example.org:8448"); }); it("returns an already-valid MXID unchanged (IPv4 with port)", () => { localStorage.setItem("home_server", "192.168.1.1:8448"); expect(returnMXID("@alice:192.168.1.1:8448")).toBe("@alice:192.168.1.1:8448"); }); it("returns an already-valid MXID unchanged (IPv6 with port)", () => { localStorage.setItem("home_server", "[::1]:8448"); expect(returnMXID("@alice:[::1]:8448")).toBe("@alice:[::1]:8448"); }); it("builds a MXID from a bare localpart", () => { localStorage.setItem("home_server", "example.org"); expect(returnMXID("alice")).toBe("@alice:example.org"); }); it("builds a MXID from a bare localpart when homeserver has a port", () => { localStorage.setItem("home_server", "example.org:8448"); expect(returnMXID("alice")).toBe("@alice:example.org:8448"); }); it("builds a MXID from a bare localpart when homeserver is IPv4 with port", () => { localStorage.setItem("home_server", "192.168.1.1:8448"); expect(returnMXID("alice")).toBe("@alice:192.168.1.1:8448"); }); it("builds a MXID from a bare localpart when homeserver is IPv6", () => { localStorage.setItem("home_server", "[::1]:8448"); expect(returnMXID("alice")).toBe("@alice:[::1]:8448"); }); it("strips the leading @ when building from @-prefixed localpart", () => { localStorage.setItem("home_server", "example.org"); expect(returnMXID("@alice")).toBe("@alice:example.org"); }); }); // --------------------------------------------------------------------------- // isSystemUser // --------------------------------------------------------------------------- describe("isSystemUser", () => { it("returns false when asManagedUsers is empty", () => { expect(isSystemUser("@alice:example.org")).toBe(false); }); it("returns false when the id does not match any pattern", () => { LoadConfig({ restrictBaseUrl: "https://example.org", corsCredentials: "same-origin", asManagedUsers: ["^@bot:example\\.org$"], menu: [], etkeccAdmin: "", }); expect(isSystemUser("@alice:example.org")).toBe(false); }); it("returns true when the id matches a string pattern", () => { LoadConfig({ restrictBaseUrl: "https://example.org", corsCredentials: "same-origin", asManagedUsers: ["^@bot:example\\.org$"], menu: [], etkeccAdmin: "", }); expect(isSystemUser("@bot:example.org")).toBe(true); }); it("returns true when the id matches a RegExp pattern", () => { LoadConfig({ restrictBaseUrl: "https://example.org", corsCredentials: "same-origin", asManagedUsers: [/^@bot:/], menu: [], etkeccAdmin: "", }); expect(isSystemUser("@bot:example.org")).toBe(true); }); it("matches appservice users with port in server name", () => { LoadConfig({ restrictBaseUrl: "https://example.org", corsCredentials: "same-origin", asManagedUsers: ["^@managed-[a-zA-Z0-9\\-]+:synapse:8008$"], menu: [], etkeccAdmin: "", }); expect(isSystemUser("@managed-mas:synapse:8008")).toBe(true); expect(isSystemUser("@alice:synapse:8008")).toBe(false); }); it("clears the cache when config changes", () => { LoadConfig({ restrictBaseUrl: "https://example.org", corsCredentials: "same-origin", asManagedUsers: ["^@bot:example\\.org$"], menu: [], etkeccAdmin: "", }); expect(isSystemUser("@bot:example.org")).toBe(true); LoadConfig({ restrictBaseUrl: "https://example.org", corsCredentials: "same-origin", asManagedUsers: ["^@admin:example\\.org$"], menu: [], etkeccAdmin: "", }); expect(isSystemUser("@bot:example.org")).toBe(false); expect(isSystemUser("@admin:example.org")).toBe(true); }); }); // --------------------------------------------------------------------------- // generateRandomMXID // --------------------------------------------------------------------------- describe("generateRandomMXID", () => { it("generates a MXID for the current homeserver (simple domain)", () => { localStorage.setItem("home_server", "example.org"); const randomValues = new Uint32Array([0, 1, 2, 3, 4, 5, 6, 7]); const cryptoSpy = vi.spyOn(global.crypto, "getRandomValues").mockReturnValue(randomValues); expect(generateRandomMXID()).toBe("@01234567:example.org"); cryptoSpy.mockRestore(); }); it("generates a valid MXID for a homeserver with a port", () => { localStorage.setItem("home_server", "example.org:8448"); const randomValues = new Uint32Array([0, 1, 2, 3, 4, 5, 6, 7]); const cryptoSpy = vi.spyOn(global.crypto, "getRandomValues").mockReturnValue(randomValues); const result = generateRandomMXID(); expect(result).toBe("@01234567:example.org:8448"); expect(isMXID(result)).toBe(true); cryptoSpy.mockRestore(); }); it("generates a valid MXID for an IPv6 homeserver", () => { localStorage.setItem("home_server", "[::1]:8448"); const randomValues = new Uint32Array([0, 1, 2, 3, 4, 5, 6, 7]); const cryptoSpy = vi.spyOn(global.crypto, "getRandomValues").mockReturnValue(randomValues); const result = generateRandomMXID(); expect(result).toBe("@01234567:[::1]:8448"); expect(isMXID(result)).toBe(true); cryptoSpy.mockRestore(); }); }); }); ================================================ FILE: src/utils/mxid.ts ================================================ import { Identifier } from "ra-core"; import { GetConfig, SubscribeConfig } from "../utils/config"; const mxidPattern = /^@[^@:]+:[^@]+$/; /* * Check if id is a valid Matrix ID (user) * @param id The ID to check * @returns Whether the ID is a valid Matrix ID */ export const isMXID = (id: string | Identifier): boolean => mxidPattern.test(id as string); // Cache for isSystemUser results — cleared when config changes const asManagedCache = new Map<string, boolean>(); SubscribeConfig(() => asManagedCache.clear()); /** * Check if a user is managed by an application service * @param id The user ID to check * @returns Whether the user is managed by an application service */ export const isSystemUser = (id: string | Identifier): boolean => { const key = id as string; const cached = asManagedCache.get(key); if (cached !== undefined) { return cached; } const managedUsers = GetConfig().asManagedUsers; if (!managedUsers || managedUsers.length === 0) { return false; } const result = managedUsers.some((regex: string | RegExp) => (regex instanceof RegExp ? regex : new RegExp(regex)).test(key) ); asManagedCache.set(key, result); return result; }; /** * Generate a random MXID for current homeserver * @returns full MXID as string */ export function generateRandomMXID(): string { const homeserver = localStorage.getItem("home_server"); const characters = "0123456789abcdefghijklmnopqrstuvwxyz"; const localpart = Array.from(crypto.getRandomValues(new Uint32Array(8))) .map(x => characters[x % characters.length]) .join(""); return `@${localpart}:${homeserver}`; } /** * Extract localpart from a MXID * @param id The MXID (e.g. @localpart:homeserver) * @returns localpart without @ prefix and without :homeserver suffix */ export function getLocalpart(id: string | Identifier): string { const str = id as string; if (!str.startsWith("@") || !str.includes(":")) { return str; } return str.slice(1, str.indexOf(":")); } /** * Return the full MXID from an arbitrary input * @param input the input string * @returns full MXID as string */ export function returnMXID(input: string | Identifier): string { const inputStr = input as string; const homeserver = localStorage.getItem("home_server") || ""; // when homeserver is not (just) a domain name, but a domain:port or even an IPv6 address if (homeserver != "" && inputStr.endsWith(homeserver) && inputStr.startsWith("@")) { return inputStr; // Already a valid MXID } // Check if the input already looks like a valid MXID (i.e., starts with "@" and contains ":") if (isMXID(input)) { return inputStr; // Already a valid MXID } // If input is not a valid MXID, assume it's a localpart and construct the MXID const localpart = typeof input === "string" && inputStr.startsWith("@") ? inputStr.slice(1) : inputStr; return `@${localpart}:${homeserver}`; } ================================================ FILE: src/utils/password.test.ts ================================================ import { generateDeviceId, generateRandomPassword } from "./password"; const ALLOWED_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~`!@#$%^&*()_-+={[}]|:;'.?/<>,"; const ALPHANUMERIC = /^[A-Za-z0-9]+$/; describe("generateRandomPassword", () => { it("returns 64 characters by default", () => { expect(generateRandomPassword()).toHaveLength(64); }); it("returns the requested length", () => { expect(generateRandomPassword(10)).toHaveLength(10); expect(generateRandomPassword(128)).toHaveLength(128); }); it("only contains characters from the allowed set", () => { const pw = generateRandomPassword(200); for (const char of pw) { expect(ALLOWED_CHARS).toContain(char); } }); it("produces different values on successive calls", () => { const a = generateRandomPassword(); const b = generateRandomPassword(); expect(a).not.toBe(b); }); }); describe("generateDeviceId", () => { it("returns exactly 16 characters", () => { expect(generateDeviceId()).toHaveLength(16); }); it("contains only alphanumeric characters", () => { const id = generateDeviceId(); expect(ALPHANUMERIC.test(id)).toBe(true); }); it("produces different values on successive calls", () => { const a = generateDeviceId(); const b = generateDeviceId(); expect(a).not.toBe(b); }); }); ================================================ FILE: src/utils/password.ts ================================================ /** * Generate a random user password * @returns a new random password as string */ export function generateRandomPassword(length = 64): string { const characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~`!@#$%^&*()_-+={[}]|:;'.?/<>,"; return Array.from(crypto.getRandomValues(new Uint32Array(length))) .map(x => characters[x % characters.length]) .join(""); } export const generateDeviceId = (): string => { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; const array = new Uint8Array(16); crypto.getRandomValues(array); return Array.from(array, byte => chars[byte % chars.length]).join(""); }; ================================================ FILE: src/utils/safety.test.ts ================================================ import { JSONStringify, decodeURLComponent, encodeURLComponent, tt } from "./safety"; describe("encodeURLComponent", () => { it("returns a plain string unchanged", () => { expect(encodeURLComponent("plain.jpg")).toBe("plain.jpg"); }); it("encodes spaces as %20", () => { expect(encodeURLComponent("my file.png")).toBe("my%20file.png"); }); it("encodes & as %26", () => { expect(encodeURLComponent("a&b")).toBe("a%26b"); }); it("encodes # as %23", () => { expect(encodeURLComponent("file#1.txt")).toBe("file%231.txt"); }); it("encodes + as %2B", () => { expect(encodeURLComponent("a+b")).toBe("a%2Bb"); }); it("encodes non-ASCII characters", () => { expect(encodeURLComponent("résumé.pdf")).toBe("r%C3%A9sum%C3%A9.pdf"); }); it("returns an empty string unchanged", () => { expect(encodeURLComponent("")).toBe(""); }); }); describe("decodeURLComponent", () => { it("decodes valid url-encoded strings", () => { expect(decodeURLComponent("hello%20world")).toBe("hello world"); }); it("returns the original string when decoding fails", () => { expect(decodeURLComponent("%E0%A4%A")).toBe("%E0%A4%A"); }); }); describe("JSONStringify", () => { it("returns strings unchanged", () => { expect(JSONStringify("plain text")).toBe("plain text"); }); it("returns the fallback for nullish values", () => { expect(JSONStringify(null, "fallback")).toBe("fallback"); expect(JSONStringify(undefined, "fallback")).toBe("fallback"); }); it("stringifies plain objects", () => { expect(JSONStringify({ ok: true })).toBe('{"ok":true}'); }); it("returns the fallback when JSON.stringify throws", () => { const circular: Record<string, unknown> = {}; circular.self = circular; expect(JSONStringify(circular, "fallback")).toBe("fallback"); }); }); describe("tt", () => { it("returns the translation when the key has a translation (translate returns a different value)", () => { const translate = (_key: string) => "Translated value"; expect(tt(translate, "some.key", "fallback")).toBe("Translated value"); }); it("returns the fallback when translate returns the key unchanged (no translation found)", () => { const translate = (key: string) => key; expect(tt(translate, "some.key", "fallback")).toBe("fallback"); }); it("uses the fallback for unknown keys even with a non-trivial key path", () => { const translate = (key: string) => key; expect(tt(translate, "resources.rooms.enums.join_rules.unknown_variant", "unknown_variant")).toBe( "unknown_variant" ); }); it("returns the translation when it differs from the key", () => { const translate = (_key: string) => "Public"; expect(tt(translate, "resources.rooms.enums.join_rules.public", "public")).toBe("Public"); }); }); ================================================ FILE: src/utils/safety.ts ================================================ /** * Decode a URI component, and if it fails, return the original string. */ export const decodeURLComponent = (str: string): string => { try { return decodeURIComponent(str); } catch { return str; } }; /** * Encode a URI component, and if it fails, return the original string. */ export const encodeURLComponent = (str: string): string => { try { return encodeURIComponent(str); } catch { return str; } }; /** * Try-translate: attempt to translate a key, falling back to a default value if no translation exists. * * react-admin's translate() (from useTranslate) returns the key itself when no translation is found. * This helper detects that case and returns `fallback` instead, making it safe to call with any * dynamic key — enum values, server-provided strings, future unknown variants — without littering * the UI with raw i18n key paths like "resources.rooms.enums.join_rules.restricted". * * Usage: * tt(translate, `resources.rooms.enums.join_rules.${record.join_rules}`, record.join_rules) * tt(translate, `resources.users.enums.status.${user.status}`, user.status) */ export const tt = (translate: (key: string) => string, key: string, fallback: string): string => { const t = translate(key); return t !== key ? t : fallback; }; /** * Safely convert a value to a JSON string representation. * If JSON.stringify fails, returns the fallback string. */ export const JSONStringify = (value: unknown, fallback = ""): string => { if (value == null) return fallback; if (typeof value === "string") return value; try { return JSON.stringify(value); } catch { return fallback; } }; ================================================ FILE: src/utils/version.test.ts ================================================ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { resolveVersion, injectVersion } from "./version"; const entrypoints = ["index.html", "auth-callback.html"]; describe.each(entrypoints)("entrypoint %s version injection", file => { const html = readFileSync(resolve(__dirname, "../entrypoints", file), "utf8"); it("contains js-version element", () => { expect(html).toContain('id="js-version"'); }); it("version is injected and placeholder is removed", () => { const version = resolveVersion(); const transformed = injectVersion(html, version); expect(transformed).not.toContain("__KETESA_VERSION__"); expect(transformed).toContain(version); }); }); ================================================ FILE: src/utils/version.ts ================================================ import { execSync } from "node:child_process"; export function resolveVersion(): string { try { return execSync( 'git describe --tags || git rev-parse --short HEAD || echo "${KETESA_VERSION:-${SYNAPSE_ADMIN_VERSION:-unknown}}"', { encoding: "utf8", shell: "/bin/sh" } ).trim(); } catch (e) { const stdout = e instanceof Error && "stdout" in e ? String(e.stdout || "").trim() : ""; if (stdout) { return stdout; } console.error("[version] failed to resolve version", e); return process.env.KETESA_VERSION || process.env.SYNAPSE_ADMIN_VERSION || "unknown"; } } export function injectVersion(html: string, version: string): string { return html.replace(/__KETESA_VERSION__/g, JSON.stringify(version)); } ================================================ FILE: src/vitest.setup.ts ================================================ import "@testing-library/jest-dom/vitest"; import { expect, vi } from "vitest"; import * as axeMatchers from "vitest-axe/matchers"; expect.extend(axeMatchers); const createStorage = () => { const store = new Map<string, string>(); return { getItem: (key: string) => store.get(key) ?? null, setItem: (key: string, value: string) => { store.set(key, String(value)); }, removeItem: (key: string) => { store.delete(key); }, clear: () => { store.clear(); }, key: (index: number) => Array.from(store.keys())[index] ?? null, get length() { return store.size; }, } as unknown as Storage; }; const installStorageGlobals = () => { vi.stubGlobal("localStorage", createStorage()); vi.stubGlobal("sessionStorage", createStorage()); }; installStorageGlobals(); // React's scheduler uses setImmediate for its work loop. Those callbacks can fire after // jsdom teardown removes window, causing "window is not defined". Guard every callback // so work is silently skipped once the environment is gone — this is safe because // any remaining work at that point is already irrelevant to the completed tests. const _setImmediate = globalThis.setImmediate.bind(globalThis); vi.stubGlobal("setImmediate", ((fn: (...args: unknown[]) => void, ...args: unknown[]) => _setImmediate(() => { if (typeof window !== "undefined") fn(...args); })) as typeof setImmediate); let logSpy: ReturnType<typeof vi.spyOn> | null = null; beforeEach(() => { installStorageGlobals(); }); beforeAll(() => { if (!vi.isMockFunction(console.log)) { logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); } else { vi.mocked(console.log).mockImplementation(() => undefined); } }); afterAll(() => { logSpy?.mockRestore(); }); ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { /* Basic Options */ "target": "ESNext" /* Specify ECMAScript target version */, "module": "ESNext" /* Specify module code generation */, "lib": ["DOM", "DOM.Iterable", "ESNext"] /* Specify library files to be included in the compilation. */, "allowJs": false /* Allow javascript files to be compiled. */, // "checkJs": true, /* Report errors in .js files. */ "jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, "declaration": true /* Generates corresponding '.d.ts' file. */, "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, "sourceMap": true /* Generates corresponding '.map' file. */, // "outFile": "./", /* Concatenate and emit output to single file. */ // "outDir": "./lib", /* Redirect output structure to the directory. */ "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ // "composite": true, /* Enable project compilation */ // "removeComments": true, /* Do not emit comments to output. */ "noEmit": true, /* Do not emit outputs. */ // "importHelpers": true, /* Import emit helpers from 'tslib'. */ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ /* Strict Type-Checking Options */ "strict": true /* Enable all strict type-checking options. */, "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */, "strictNullChecks": true, /* Enable strict null checks. */ // "strictFunctionTypes": true, /* Enable strict checking of function types. */ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ /* Additional Checks */ // "noUnusedLocals": true, /* Report errors on unused locals. */ // "noUnusedParameters": true, /* Report errors on unused parameters. */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, /* Module Resolution Options */ "moduleResolution": "Bundler" /* Specify module resolution strategy */, // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "typeRoots": [], /* List of folders to include type definitions from. */ "types": ["vite/client", "vitest/globals"], /* Type declaration files to be included in compilation. */ "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */, // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ "resolveJsonModule": true, /* Source Map Options */ // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ /* Experimental Options */ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ "skipLibCheck": true }, "include": ["src"] } ================================================ FILE: vite.config.ts ================================================ import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import { resolve, join, dirname } from "node:path"; import { promises as fs } from "node:fs"; import { resolveVersion, injectVersion } from "./src/utils/version"; const version = resolveVersion(); let resolvedOutDir = "dist"; let resolvedBase = "./"; export default defineConfig(({ mode }) => ({ appType: "mpa", base: "./", test: { globals: true, environment: "jsdom", setupFiles: ["./src/vitest.setup.ts"], css: true, clearMocks: true, restoreMocks: true, coverage: { provider: "v8", reporter: ["text", "html"], exclude: ["dist/**", "node_modules/**"], }, }, build: { target: "esnext", chunkSizeWarningLimit: 1500, // react-admin itself is 500kb, @mui 350kb, and other vendor libs are 730kb+ at the moment of writing sourcemap: mode === "development", rolldownOptions: { input: { main: resolve(__dirname, "src/entrypoints/index.html"), "auth-callback/index": resolve(__dirname, "src/entrypoints/auth-callback.html"), }, output: { codeSplitting: { groups: [ { name: "ra", test: /node_modules[\\/].*(react-admin|ra-)/, priority: 20 }, { name: "mui", test: /node_modules[\\/]@mui/, priority: 15 }, { name: "react", test: /node_modules[\\/](react|react-dom|react-is|scheduler)[\\/]/, priority: 10 }, { name: "vendor", test: /node_modules/, priority: 5 }, ], }, }, }, }, plugins: [ { name: "entrypoint-output-paths", apply: "build", configResolved(config) { resolvedOutDir = config.build.outDir; resolvedBase = config.base || "./"; }, async closeBundle() { const outDir = resolvedOutDir; const sourceIndex = join(outDir, "src/entrypoints/index.html"); const sourceAuth = join(outDir, "src/entrypoints/auth-callback.html"); const targetIndex = join(outDir, "index.html"); const targetAuth = join(outDir, "auth-callback/index.html"); const normalizedBase = resolvedBase === "" || resolvedBase === "./" ? "./" : resolvedBase.endsWith("/") ? resolvedBase : `${resolvedBase}/`; const expectedAssetsPrefix = normalizedBase === "./" ? "./assets/" : `${normalizedBase}assets/`; const moveIfExists = async (from: string, to: string) => { try { await fs.access(from); } catch { return; } await fs.mkdir(dirname(to), { recursive: true }); await fs.rm(to, { force: true }); await fs.rename(from, to); }; const rewriteAssets = async (filePath: string, assetsPrefix: string) => { try { const content = await fs.readFile(filePath, "utf8"); const updated = content.replace(/(["'])(?:\.\.\/)+assets\//g, `$1${assetsPrefix}`); if (updated !== content) { await fs.writeFile(filePath, updated); } } catch { return; } }; const assertAssetPrefix = async (filePath: string, assetsPrefix: string) => { const content = await fs.readFile(filePath, "utf8"); const hasAssets = content.includes("assets/"); if (!hasAssets) { return; } // Strip inline <style> blocks — CSS url() tokens use different quoting and are not HTML attribute paths const htmlOnly = content.replace(/<style>[\s\S]*?<\/style>/g, ""); const invalidAssets = new RegExp(`["'](?!${assetsPrefix.replace(/\//g, "\\/")})[^"']*assets\\/`); if (invalidAssets.test(htmlOnly)) { throw new Error(`Unexpected assets path in ${filePath}`); } }; await moveIfExists(sourceIndex, targetIndex); await moveIfExists(sourceAuth, targetAuth); if (normalizedBase === "./") { await rewriteAssets(targetIndex, "./assets/"); await rewriteAssets(targetAuth, "./assets/"); } await assertAssetPrefix(targetIndex, expectedAssetsPrefix); await assertAssetPrefix(targetAuth, expectedAssetsPrefix); await fs.rm(join(outDir, "src/entrypoints"), { recursive: true, force: true }); }, }, { name: "auth-callback-dev-rewrite", configureServer(server) { server.middlewares.use((req, _res, next) => { if (!req.url) return next(); const [path] = req.url.split("?"); if (path === "/auth-callback" || path === "/auth-callback/" || path === "/auth-callback/index.html") { req.url = req.url.replace(path, "/src/entrypoints/auth-callback.html"); } else if (path === "/" || path === "/index.html") { req.url = req.url.replace(path, "/src/entrypoints/index.html"); } next(); }); }, }, react(), (() => { let fontsCss = ""; return { name: "inline-fonts-css", apply: "build", generateBundle(_options, bundle) { for (const [fileName, chunk] of Object.entries(bundle)) { if (fileName.endsWith(".css") && chunk.type === "asset") { fontsCss = (chunk.source as string).replace(/url\(\.\//g, "url(./assets/"); delete bundle[fileName]; } } }, transformIndexHtml: { order: "post", handler(html) { if (!fontsCss) return html; return html .replace(/<link[^>]+rel="stylesheet"[^>]*>\n?/g, "") .replace("</head>", `<style>${fontsCss}</style>\n</head>`); }, }, }; })(), { name: "version-inject", transformIndexHtml(html) { return injectVersion(html, version); }, }, { name: "manifests", apply: "build", generateBundle() { const base = { name: "Ketesa", short_name: "Ketesa", version, description: "Ketesa is an admin UI for Matrix servers, formerly Synapse Admin.", lang: "en", dir: "auto", categories: ["productivity", "utilities"], orientation: "landscape", icons: [ { src: "favicon.ico", sizes: "32x32", type: "image/x-icon" }, { src: "images/logo.webp", sizes: "512x512", type: "image/webp", purpose: "any maskable" }, ], start_url: ".", scope: ".", id: ".", display: "standalone", }; this.emitFile({ type: "asset", fileName: "manifest.json", source: JSON.stringify({ ...base, theme_color: "#F5F5F5", background_color: "#F5F5F5" }), }); this.emitFile({ type: "asset", fileName: "manifest-dark.json", source: JSON.stringify({ ...base, theme_color: "#0C1318", background_color: "#0C1318" }), }); }, }, ], ssr: { noExternal: ["react-dropzone", "react-admin", "ra-ui-materialui"], }, }));