├── .dockerignore
├── .editorconfig
├── .gitattributes
├── .github
├── CONTRIBUTING.md
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── SECURITY.md
├── dependabot.yml
└── workflows
│ └── workflow.yml
├── .gitignore
├── .prettierignore
├── .vscode
├── extensions.json
└── settings.json
├── .watchmanconfig
├── .yarn
├── releases
│ └── yarn-4.4.1.cjs
└── sdks
│ ├── eslint
│ ├── bin
│ │ └── eslint.js
│ ├── lib
│ │ ├── api.js
│ │ └── unsupported-api.js
│ └── package.json
│ ├── integrations.yml
│ ├── prettier
│ ├── bin
│ │ └── prettier.cjs
│ ├── index.cjs
│ └── package.json
│ └── typescript
│ ├── bin
│ ├── tsc
│ └── tsserver
│ ├── lib
│ ├── tsc.js
│ ├── tsserver.js
│ ├── tsserverlibrary.js
│ └── typescript.js
│ └── package.json
├── Dockerfile
├── Dockerfile.build
├── LICENSE
├── README.md
├── docker-compose-dev.yml
├── docker-compose.yml
├── docs
├── README.md
├── config.md
├── cors-credentials.md
├── custom-menu.md
├── prefill-login-form.md
├── restrict-hs.md
├── reverse-proxy.md
├── system-users.md
└── user-badges.md
├── index.html
├── jest.config.ts
├── justfile
├── package.json
├── public
├── config.json
├── data
│ └── example.csv
├── favicon.ico
├── images
│ ├── floating-cogs.svg
│ └── logo.webp
└── robots.txt
├── screenshots
├── auth.webp
├── etke.cc
│ ├── server-actions
│ │ └── page.webp
│ ├── server-commands
│ │ └── panel.webp
│ ├── server-notifications
│ │ ├── badge.webp
│ │ └── page.webp
│ └── server-status
│ │ ├── indicator-sidebar.webp
│ │ ├── indicator.webp
│ │ └── page.webp
└── screenshots.jpg
├── src
├── App.test.tsx
├── App.tsx
├── Context.tsx
├── components
│ ├── AdminLayout.tsx
│ ├── AvatarField.test.tsx
│ ├── AvatarField.tsx
│ ├── DeleteRoomButton.tsx
│ ├── DeleteUserButton.tsx
│ ├── DeviceRemoveButton.tsx
│ ├── ExperimentalFeatures.tsx
│ ├── Footer.tsx
│ ├── LoginFormBox.tsx
│ ├── ServerNotices.tsx
│ ├── UserAccountData.tsx
│ ├── UserRateLimits.tsx
│ ├── etke.cc
│ │ ├── CurrentlyRunningCommand.tsx
│ │ ├── README.md
│ │ ├── ServerActionsPage.tsx
│ │ ├── ServerCommandsPanel.tsx
│ │ ├── ServerNotificationsBadge.tsx
│ │ ├── ServerNotificationsPage.tsx
│ │ ├── ServerStatusBadge.tsx
│ │ ├── ServerStatusPage.tsx
│ │ ├── hooks
│ │ │ └── useServerCommands.ts
│ │ └── schedules
│ │ │ ├── components
│ │ │ ├── ScheduledCommandCreate.tsx
│ │ │ ├── ScheduledCommandEdit.tsx
│ │ │ ├── recurring
│ │ │ │ ├── RecurringCommandEdit.tsx
│ │ │ │ ├── RecurringCommandsList.tsx
│ │ │ │ └── RecurringDeleteButton.tsx
│ │ │ └── scheduled
│ │ │ │ ├── ScheduledCommandEdit.tsx
│ │ │ │ ├── ScheduledCommandShow.tsx
│ │ │ │ ├── ScheduledCommandsList.tsx
│ │ │ │ └── ScheduledDeleteButton.tsx
│ │ │ └── hooks
│ │ │ ├── useRecurringCommands.tsx
│ │ │ └── useScheduledCommands.tsx
│ ├── media.tsx
│ └── user-import
│ │ ├── ConflictModeCard.tsx
│ │ ├── ErrorsCard.tsx
│ │ ├── ResultsCard.tsx
│ │ ├── StartImportCard.tsx
│ │ ├── StatsCard.tsx
│ │ ├── UploadCard.tsx
│ │ ├── UserImport.tsx
│ │ ├── types.ts
│ │ └── useImportFile.tsx
├── i18n
│ ├── de.ts
│ ├── en.ts
│ ├── fa.ts
│ ├── fr.ts
│ ├── index.d.ts
│ ├── it.ts
│ ├── ru.ts
│ └── zh.ts
├── index.tsx
├── jest.setup.ts
├── pages
│ ├── LoginPage.test.tsx
│ └── LoginPage.tsx
├── resources
│ ├── destinations.tsx
│ ├── registration_tokens.tsx
│ ├── reports.tsx
│ ├── room_directory.tsx
│ ├── rooms.tsx
│ ├── user_media_statistics.tsx
│ └── users.tsx
├── synapse
│ ├── authProvider.test.ts
│ ├── authProvider.ts
│ ├── dataProvider.test.ts
│ ├── dataProvider.ts
│ ├── matrix.test.ts
│ └── matrix.ts
└── utils
│ ├── config.ts
│ ├── date.ts
│ ├── decodeURLComponent.ts
│ ├── error.ts
│ ├── fetchMedia.ts
│ ├── icons.ts
│ ├── mxid.ts
│ └── password.ts
├── testdata
├── element
│ ├── config.json
│ └── nginx.conf
└── synapse
│ ├── homeserver.yaml
│ ├── synapse.log.config
│ └── synapse.signing.key
├── tsconfig.eslint.json
├── tsconfig.json
├── tsconfig.vite.json
├── vite.config.ts
└── yarn.lock
/.dockerignore:
--------------------------------------------------------------------------------
1 | /testdata
2 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig https://EditorConfig.org
2 |
3 | # top-most EditorConfig file
4 | root = true
5 |
6 | [*]
7 | charset = utf-8
8 | end_of_line = lf
9 | indent_size = 2
10 | indent_style = space
11 | insert_final_newline = true
12 | max_line_length = 120
13 | trim_trailing_whitespace = true
14 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | yarn*.cjs binary
2 |
--------------------------------------------------------------------------------
/.github/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contribution Guidelines
2 |
3 | Table of Contents:
4 |
5 |
6 |
7 | * [Did you find a bug?](#did-you-find-a-bug)
8 | * [Is it a Security Vulnerability?](#is-it-a-security-vulnerability)
9 | * [Is it already a known issue?](#is-it-already-a-known-issue)
10 | * [Reporting a Bug](#reporting-a-bug)
11 | * [Is there a patch for the bug?](#is-there-a-patch-for-the-bug)
12 | * [Do you want to add a new feature?](#do-you-want-to-add-a-new-feature)
13 | * [Is it just an idea?](#is-it-just-an-idea)
14 | * [Is there a patch for the feature?](#is-there-a-patch-for-the-feature)
15 | * [Do you have questions about the Synapse Admin project or need guidance?](#do-you-have-questions-about-the-synapse-admin-project-or-need-guidance)
16 |
17 |
18 |
19 | ## Did you find a bug?
20 |
21 | ### Is it a Security Vulnerability?
22 |
23 | Please follow the [Security Policy](https://github.com/etkecc/synapse-admin/blob/main/.github/SECURITY.md) for reporting
24 | security vulnerabilities.
25 |
26 | ### Is it already a known issue?
27 |
28 | Please ensure the bug was not already reported by searching [the Issues section](https://github.com/etkecc/synapse-admin/issues).
29 |
30 | ### Reporting a Bug
31 |
32 | If you think you have found a bug in Synapse Admin, it is not a security vulnerability, and it is not already reported,
33 | please open [a new issue](https://github.com/etkecc/synapse-admin/issues/new) with:
34 | * A proper title and clear description of the problem.
35 | * As much relevant information as possible:
36 | * The version of Synapse Admin you are using.
37 | * The version of Synapse you are using.
38 | * Any relevant browser console logs, failed requests details, and error messages.
39 |
40 | ### Is there a patch for the bug?
41 |
42 | If you already have a patch for the bug, please open a pull request with the patch,
43 | and mention the issue number in the pull request description.
44 |
45 | ## Do you want to add a new feature?
46 |
47 | ### Is it just an idea?
48 |
49 | Please open [a new issue](https://github.com/etkecc/synapse-admin/issues/new) with:
50 | * A proper title and clear description of the requested feature.
51 | * Any relevant information about the feature:
52 | * Why do you think this feature is needed?
53 | * How do you think it should work? (provide Synapse Admin API endpoint)
54 | * Any relevant screenshots or mockups.
55 |
56 | ### Is there a patch for the feature?
57 |
58 | If you already have a patch for the feature, please open a pull request with the patch,
59 | and mention the issue number in the pull request description.
60 |
61 | ## Do you have questions about the Synapse Admin project or need guidance?
62 |
63 | Please use the official community Matrix room: [#synapse-admin:etke.cc](https://matrix.to/#/#synapse-admin:etke.cc)
64 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | liberapay: etkecc
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Report a Synapse Admin bug
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Browser console logs**
27 | If applicable, add the browser console's log
28 |
29 | **Instance configuration:**
30 | - Synapse Admin version: [e.g. v0.10.3-etke39]
31 | - Synapse version [v1.127.1]
32 |
33 | **Additional context**
34 | Add any other context about the problem here.
35 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for Synapse Admin
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Provide related Synapse Admin API endpoints**
20 | If applicable, provide links to the Synapse Admin API's endpoints that could be used to implement that feature
21 |
22 | **Additional context**
23 | Add any other context or screenshots about the feature request here.
24 |
--------------------------------------------------------------------------------
/.github/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | Only [the last published version](https://github.com/etkecc/synapse-admin/releases/latest) of the project is supported.
6 | This means that only the latest version will receive security updates.
7 | If you are using an older version, you are strongly encouraged to upgrade to the latest version.
8 |
9 | ## Reporting a Vulnerability
10 |
11 | Please contact us using the [#synapse-admin:etke.cc](https://matrix.to/#/#synapse-admin:etke.cc) Matrix room.
12 | The Synapse Admin project is a static JS UI for the Synapse server,
13 | so it is unlikely that there are (or will be) any impactful security vulnerabilities in the project itself.
14 | However, we do not rule out the possibility of such cases, so we will be happy to receive any reports!
15 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "npm"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 | open-pull-requests-limit: 30
8 |
9 | - package-ecosystem: "docker"
10 | directory: "/"
11 | schedule:
12 | interval: "weekly"
13 | open-pull-requests-limit: 30
14 |
15 | - package-ecosystem: "github-actions"
16 | directory: "/"
17 | schedule:
18 | interval: "weekly"
19 | open-pull-requests-limit: 30
20 |
--------------------------------------------------------------------------------
/.github/workflows/workflow.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | push:
4 | branches: [ "main" ]
5 | tags: [ "v*" ]
6 | env:
7 | bunny_version: v0.1.0
8 | base_path: ./
9 | permissions:
10 | checks: write
11 | contents: write
12 | packages: write
13 | pull-requests: read
14 | jobs:
15 | build:
16 | name: Build
17 | runs-on: ubuntu-latest
18 | steps:
19 | - uses: actions/checkout@v4
20 | with:
21 | fetch-depth: 0
22 | - uses: actions/setup-node@v4
23 | with:
24 | node-version: lts/*
25 | cache: yarn
26 | - name: Install dependencies
27 | run: yarn install --immutable --network-timeout=300000
28 | - name: Build
29 | run: yarn build --base=${{ env.base_path }}
30 | - uses: actions/upload-artifact@v4
31 | with:
32 | path: dist/
33 | name: dist
34 | if-no-files-found: error
35 | retention-days: 1
36 | compression-level: 0
37 | overwrite: true
38 | include-hidden-files: true
39 |
40 | docker:
41 | name: Docker
42 | needs: build
43 | runs-on: self-hosted
44 | steps:
45 | - uses: actions/checkout@v4
46 | - uses: actions/download-artifact@v4
47 | with:
48 | name: dist
49 | path: dist/
50 | - name: Set up Docker Buildx
51 | uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
52 | - name: Login to ghcr.io
53 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
54 | with:
55 | registry: ghcr.io
56 | username: ${{ github.actor }}
57 | password: ${{ secrets.GITHUB_TOKEN }}
58 | - name: Login to hub.docker.com
59 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
60 | with:
61 | username: etkecc
62 | password: ${{ secrets.DOCKERHUB_TOKEN }}
63 | - name: Extract metadata (tags, labels) for Docker
64 | id: meta
65 | uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
66 | with:
67 | images: |
68 | ${{ github.repository }}
69 | ghcr.io/${{ github.repository }}
70 | registry.etke.cc/${{ github.repository }}
71 | tags: |
72 | type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
73 | type=semver,pattern={{raw}}
74 | - name: Build and push
75 | uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
76 | with:
77 | platforms: linux/amd64,linux/arm64
78 | context: .
79 | push: true
80 | tags: ${{ steps.meta.outputs.tags }}
81 | labels: ${{ steps.meta.outputs.labels }}
82 |
83 | cdn:
84 | name: CDN
85 | needs: build
86 | runs-on: ubuntu-latest
87 | steps:
88 | - uses: actions/checkout@v4
89 | - uses: actions/download-artifact@v4
90 | with:
91 | name: dist
92 | path: dist/
93 | - name: Upload
94 | run: |
95 | wget -O bunny-upload.tar.gz https://github.com/etkecc/bunny-upload/releases/download/${{ env.bunny_version }}/bunny-upload_Linux_x86_64.tar.gz
96 | tar -xzf bunny-upload.tar.gz
97 | echo "${{ secrets.BUNNY_CONFIG }}" > bunny-config.yaml
98 | ./bunny-upload -c bunny-config.yaml
99 |
100 | github-release:
101 | name: Github Release
102 | needs: build
103 | if: ${{ startsWith(github.ref, 'refs/tags/') }}
104 | runs-on: ubuntu-latest
105 | steps:
106 | - uses: actions/checkout@v4
107 | - uses: actions/download-artifact@v4
108 | with:
109 | name: dist
110 | path: dist/
111 | - name: Prepare release
112 | run: |
113 | mv dist synapse-admin
114 | tar chvzf synapse-admin.tar.gz synapse-admin
115 | - uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2
116 | with:
117 | files: synapse-admin.tar.gz
118 | generate_release_notes: true
119 | make_latest: "true"
120 | draft: false
121 | prerelease: false
122 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.toptal.com/developers/gitignore/api/node,yarn,react,visualstudiocode
2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,yarn,react,visualstudiocode
3 |
4 | ### Node ###
5 | # Logs
6 | logs
7 | *.log
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 | lerna-debug.log*
12 | .pnpm-debug.log*
13 |
14 | # Diagnostic reports (https://nodejs.org/api/report.html)
15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
16 |
17 | # Runtime data
18 | pids
19 | *.pid
20 | *.seed
21 | *.pid.lock
22 |
23 | # Directory for instrumented libs generated by jscoverage/JSCover
24 | lib-cov
25 |
26 | # Coverage directory used by tools like istanbul
27 | coverage
28 | *.lcov
29 |
30 | # nyc test coverage
31 | .nyc_output
32 |
33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
34 | .grunt
35 |
36 | # Bower dependency directory (https://bower.io/)
37 | bower_components
38 |
39 | # node-waf configuration
40 | .lock-wscript
41 |
42 | # Compiled binary addons (https://nodejs.org/api/addons.html)
43 | build/Release
44 |
45 | # Dependency directories
46 | node_modules/
47 | jspm_packages/
48 |
49 | # Snowpack dependency directory (https://snowpack.dev/)
50 | web_modules/
51 |
52 | # TypeScript cache
53 | *.tsbuildinfo
54 |
55 | # Optional npm cache directory
56 | .npm
57 |
58 | # Optional eslint cache
59 | .eslintcache
60 |
61 | # Optional stylelint cache
62 | .stylelintcache
63 |
64 | # Microbundle cache
65 | .rpt2_cache/
66 | .rts2_cache_cjs/
67 | .rts2_cache_es/
68 | .rts2_cache_umd/
69 |
70 | # Optional REPL history
71 | .node_repl_history
72 |
73 | # Output of 'npm pack'
74 | *.tgz
75 |
76 | # Yarn Integrity file
77 | .yarn-integrity
78 |
79 | # dotenv environment variable files
80 | .env
81 | .env.development.local
82 | .env.test.local
83 | .env.production.local
84 | .env.local
85 |
86 | # parcel-bundler cache (https://parceljs.org/)
87 | .cache
88 | .parcel-cache
89 |
90 | # Next.js build output
91 | .next
92 | out
93 |
94 | # Nuxt.js build / generate output
95 | .nuxt
96 | dist
97 |
98 | # Gatsby files
99 | .cache/
100 | # Comment in the public line in if your project uses Gatsby and not Next.js
101 | # https://nextjs.org/blog/next-9-1#public-directory-support
102 | # public
103 |
104 | # vuepress build output
105 | .vuepress/dist
106 |
107 | # vuepress v2.x temp and cache directory
108 | .temp
109 |
110 | # Docusaurus cache and generated files
111 | .docusaurus
112 |
113 | # Serverless directories
114 | .serverless/
115 |
116 | # FuseBox cache
117 | .fusebox/
118 |
119 | # DynamoDB Local files
120 | .dynamodb/
121 |
122 | # TernJS port file
123 | .tern-port
124 |
125 | # Stores VSCode versions used for testing VSCode extensions
126 | .vscode-test
127 |
128 | # yarn v2
129 | .yarn/cache
130 | .yarn/unplugged
131 | .yarn/build-state.yml
132 | .yarn/install-state.gz
133 | .pnp.*
134 |
135 | ### Node Patch ###
136 | # Serverless Webpack directories
137 | .webpack/
138 |
139 | # Optional stylelint cache
140 |
141 | # SvelteKit build / generate output
142 | .svelte-kit
143 |
144 | ### react ###
145 | .DS_*
146 | **/*.backup.*
147 | **/*.back.*
148 |
149 | node_modules
150 |
151 | *.sublime*
152 |
153 | psd
154 | thumb
155 | sketch
156 |
157 | ### VisualStudioCode ###
158 | .vscode/*
159 | !.vscode/settings.json
160 | !.vscode/tasks.json
161 | !.vscode/launch.json
162 | !.vscode/extensions.json
163 | !.vscode/*.code-snippets
164 |
165 | # Local History for Visual Studio Code
166 | .history/
167 |
168 | # Built Visual Studio Code Extensions
169 | *.vsix
170 |
171 | ### VisualStudioCode Patch ###
172 | # Ignore all local history of files
173 | .history
174 | .ionide
175 |
176 | ### yarn ###
177 | # https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
178 |
179 | .yarn/*
180 | !.yarn/releases
181 | !.yarn/patches
182 | !.yarn/plugins
183 | !.yarn/sdks
184 | !.yarn/versions
185 |
186 | # if you are NOT using Zero-installs, then:
187 | # comment the following lines
188 | !.yarn/cache
189 |
190 | # and uncomment the following lines
191 | # .pnp.*
192 |
193 | # End of https://www.toptal.com/developers/gitignore/api/node,yarn,react,visualstudiocode
194 |
195 | /testdata/synapse.data
196 | /testdata/postgres.data
197 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | .yarn
3 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "arcanis.vscode-zipfs",
4 | "dbaeumer.vscode-eslint",
5 | "esbenp.prettier-vscode"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "search.exclude": {
3 | "**/.yarn": true,
4 | "**/.pnp.*": true
5 | },
6 | "eslint.nodePath": ".yarn/sdks",
7 | "prettier.prettierPath": ".yarn/sdks/prettier/index.cjs",
8 | "typescript.tsdk": "node_modules/typescript/lib",
9 | "typescript.enablePromptUseWorkspaceTsdk": true
10 | }
11 |
--------------------------------------------------------------------------------
/.watchmanconfig:
--------------------------------------------------------------------------------
1 | {
2 | "ignore_dirs": [
3 | "testdata"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/.yarn/sdks/eslint/bin/eslint.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire} = require(`module`);
5 | const {resolve} = require(`path`);
6 |
7 | const relPnpApiPath = "../../../../.pnp.cjs";
8 |
9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
10 | const absRequire = createRequire(absPnpApiPath);
11 |
12 | if (existsSync(absPnpApiPath)) {
13 | if (!process.versions.pnp) {
14 | // Setup the environment to be able to require eslint/bin/eslint.js
15 | require(absPnpApiPath).setup();
16 | }
17 | }
18 |
19 | // Defer to the real eslint/bin/eslint.js your application uses
20 | module.exports = absRequire(`eslint/bin/eslint.js`);
21 |
--------------------------------------------------------------------------------
/.yarn/sdks/eslint/lib/api.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire} = require(`module`);
5 | const {resolve} = require(`path`);
6 |
7 | const relPnpApiPath = "../../../../.pnp.cjs";
8 |
9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
10 | const absRequire = createRequire(absPnpApiPath);
11 |
12 | if (existsSync(absPnpApiPath)) {
13 | if (!process.versions.pnp) {
14 | // Setup the environment to be able to require eslint
15 | require(absPnpApiPath).setup();
16 | }
17 | }
18 |
19 | // Defer to the real eslint your application uses
20 | module.exports = absRequire(`eslint`);
21 |
--------------------------------------------------------------------------------
/.yarn/sdks/eslint/lib/unsupported-api.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire} = require(`module`);
5 | const {resolve} = require(`path`);
6 |
7 | const relPnpApiPath = "../../../../.pnp.cjs";
8 |
9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
10 | const absRequire = createRequire(absPnpApiPath);
11 |
12 | if (existsSync(absPnpApiPath)) {
13 | if (!process.versions.pnp) {
14 | // Setup the environment to be able to require eslint/use-at-your-own-risk
15 | require(absPnpApiPath).setup();
16 | }
17 | }
18 |
19 | // Defer to the real eslint/use-at-your-own-risk your application uses
20 | module.exports = absRequire(`eslint/use-at-your-own-risk`);
21 |
--------------------------------------------------------------------------------
/.yarn/sdks/eslint/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "eslint",
3 | "version": "8.57.0-sdk",
4 | "main": "./lib/api.js",
5 | "type": "commonjs",
6 | "bin": {
7 | "eslint": "./bin/eslint.js"
8 | },
9 | "exports": {
10 | "./package.json": "./package.json",
11 | ".": "./lib/api.js",
12 | "./use-at-your-own-risk": "./lib/unsupported-api.js"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/.yarn/sdks/integrations.yml:
--------------------------------------------------------------------------------
1 | # This file is automatically generated by @yarnpkg/sdks.
2 | # Manual changes might be lost!
3 |
4 | integrations:
5 | - vscode
6 |
--------------------------------------------------------------------------------
/.yarn/sdks/prettier/bin/prettier.cjs:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire} = require(`module`);
5 | const {resolve} = require(`path`);
6 |
7 | const relPnpApiPath = "../../../../.pnp.cjs";
8 |
9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
10 | const absRequire = createRequire(absPnpApiPath);
11 |
12 | if (existsSync(absPnpApiPath)) {
13 | if (!process.versions.pnp) {
14 | // Setup the environment to be able to require prettier/bin/prettier.cjs
15 | require(absPnpApiPath).setup();
16 | }
17 | }
18 |
19 | // Defer to the real prettier/bin/prettier.cjs your application uses
20 | module.exports = absRequire(`prettier/bin/prettier.cjs`);
21 |
--------------------------------------------------------------------------------
/.yarn/sdks/prettier/index.cjs:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire} = require(`module`);
5 | const {resolve} = require(`path`);
6 |
7 | const relPnpApiPath = "../../../.pnp.cjs";
8 |
9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
10 | const absRequire = createRequire(absPnpApiPath);
11 |
12 | if (existsSync(absPnpApiPath)) {
13 | if (!process.versions.pnp) {
14 | // Setup the environment to be able to require prettier
15 | require(absPnpApiPath).setup();
16 | }
17 | }
18 |
19 | // Defer to the real prettier your application uses
20 | module.exports = absRequire(`prettier`);
21 |
--------------------------------------------------------------------------------
/.yarn/sdks/prettier/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "prettier",
3 | "version": "3.2.5-sdk",
4 | "main": "./index.cjs",
5 | "type": "commonjs",
6 | "bin": "./bin/prettier.cjs"
7 | }
8 |
--------------------------------------------------------------------------------
/.yarn/sdks/typescript/bin/tsc:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire} = require(`module`);
5 | const {resolve} = require(`path`);
6 |
7 | const relPnpApiPath = "../../../../.pnp.cjs";
8 |
9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
10 | const absRequire = createRequire(absPnpApiPath);
11 |
12 | if (existsSync(absPnpApiPath)) {
13 | if (!process.versions.pnp) {
14 | // Setup the environment to be able to require typescript/bin/tsc
15 | require(absPnpApiPath).setup();
16 | }
17 | }
18 |
19 | // Defer to the real typescript/bin/tsc your application uses
20 | module.exports = absRequire(`typescript/bin/tsc`);
21 |
--------------------------------------------------------------------------------
/.yarn/sdks/typescript/bin/tsserver:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire} = require(`module`);
5 | const {resolve} = require(`path`);
6 |
7 | const relPnpApiPath = "../../../../.pnp.cjs";
8 |
9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
10 | const absRequire = createRequire(absPnpApiPath);
11 |
12 | if (existsSync(absPnpApiPath)) {
13 | if (!process.versions.pnp) {
14 | // Setup the environment to be able to require typescript/bin/tsserver
15 | require(absPnpApiPath).setup();
16 | }
17 | }
18 |
19 | // Defer to the real typescript/bin/tsserver your application uses
20 | module.exports = absRequire(`typescript/bin/tsserver`);
21 |
--------------------------------------------------------------------------------
/.yarn/sdks/typescript/lib/tsc.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire} = require(`module`);
5 | const {resolve} = require(`path`);
6 |
7 | const relPnpApiPath = "../../../../.pnp.cjs";
8 |
9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
10 | const absRequire = createRequire(absPnpApiPath);
11 |
12 | if (existsSync(absPnpApiPath)) {
13 | if (!process.versions.pnp) {
14 | // Setup the environment to be able to require typescript/lib/tsc.js
15 | require(absPnpApiPath).setup();
16 | }
17 | }
18 |
19 | // Defer to the real typescript/lib/tsc.js your application uses
20 | module.exports = absRequire(`typescript/lib/tsc.js`);
21 |
--------------------------------------------------------------------------------
/.yarn/sdks/typescript/lib/typescript.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire} = require(`module`);
5 | const {resolve} = require(`path`);
6 |
7 | const relPnpApiPath = "../../../../.pnp.cjs";
8 |
9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
10 | const absRequire = createRequire(absPnpApiPath);
11 |
12 | if (existsSync(absPnpApiPath)) {
13 | if (!process.versions.pnp) {
14 | // Setup the environment to be able to require typescript
15 | require(absPnpApiPath).setup();
16 | }
17 | }
18 |
19 | // Defer to the real typescript your application uses
20 | module.exports = absRequire(`typescript`);
21 |
--------------------------------------------------------------------------------
/.yarn/sdks/typescript/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "typescript",
3 | "version": "5.4.5-sdk",
4 | "main": "./lib/typescript.js",
5 | "type": "commonjs",
6 | "bin": {
7 | "tsc": "./bin/tsc",
8 | "tsserver": "./bin/tsserver"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM ghcr.io/static-web-server/static-web-server:2
2 |
3 | ENV SERVER_ROOT=/app
4 |
5 | COPY ./dist /app
6 |
--------------------------------------------------------------------------------
/Dockerfile.build:
--------------------------------------------------------------------------------
1 | FROM node:lts AS builder
2 | ARG BASE_PATH=./
3 | WORKDIR /src
4 | COPY . /src
5 | RUN yarn config set enableTelemetry 0 && \
6 | yarn install --immutable --network-timeout=300000 && \
7 | yarn build --base=$BASE_PATH
8 |
9 | FROM ghcr.io/static-web-server/static-web-server:2
10 | ENV SERVER_ROOT=/app
11 | COPY --from=builder /src/dist /app
12 |
--------------------------------------------------------------------------------
/docker-compose-dev.yml:
--------------------------------------------------------------------------------
1 | services:
2 | synapse:
3 | image: ghcr.io/element-hq/synapse:develop
4 | entrypoint: python
5 | command: "-m synapse.app.homeserver -c /config/homeserver.yaml"
6 | ports:
7 | - "8008:8008"
8 | volumes:
9 | - ./testdata/synapse:/config
10 | - ./testdata/synapse.data:/media-store
11 |
12 | postgres:
13 | image: postgres:alpine
14 | volumes:
15 | - ./testdata/postgres.data:/var/lib/postgresql/data
16 | environment:
17 | POSTGRES_USER: synapse
18 | POSTGRES_PASSWORD: synapse
19 | POSTGRES_DB: synapse
20 | POSTGRES_INITDB_ARGS: "--lc-collate C --lc-ctype C --encoding UTF8"
21 |
22 | element:
23 | image: docker.io/vectorim/element-web:latest
24 | depends_on:
25 | synapse:
26 | condition: service_healthy
27 | restart: true
28 | ports:
29 | - "8080:8080"
30 | volumes:
31 | - ./testdata/element/nginx.conf:/etc/nginx/nginx.conf:ro
32 | - /dev/null:/etc/nginx/conf.d/default.conf:ro
33 | - ./testdata/element/config.json:/app/config.json:ro
34 |
35 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | synapse-admin:
3 | container_name: synapse-admin
4 | hostname: synapse-admin
5 | image: ghcr.io/etkecc/synapse-admin:latest
6 | # build:
7 | # context: .
8 | # dockerfile: Dockerfile.build
9 |
10 | # to use the docker-compose as standalone without a local repo clone,
11 | # replace the context definition with this:
12 | # context: https://github.com/etkecc/synapse-admin.git
13 |
14 | # args:
15 | # - BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
16 | # if you're building on an architecture other than amd64, make sure
17 | # to define a maximum ram for node. otherwise the build will fail.
18 | # - NODE_OPTIONS="--max_old_space_size=1024"
19 | # - BASE_PATH="/synapse-admin"
20 | ports:
21 | - "8080:80"
22 | restart: unless-stopped
23 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # Documentation
2 |
3 | Synapse Admin documentation is under construction right now, so PRs are greatly appreciated!
4 |
5 | Table of contents:
6 |
7 |
8 | * [Configuration](#configuration)
9 | * [Features](#features)
10 | * [Deployment](#deployment)
11 |
12 |
13 |
14 | ## Configuration
15 |
16 | [Full configuration documentation](./config.md)
17 |
18 | Specific configuration options:
19 |
20 | * [Customizing CORS credentials](./cors-credentials.md)
21 | * [Restricting available homeserver](./restrict-hs.md)
22 | * [System / Appservice-managed Users](./system-users.md)
23 | * [Custom Menu Items](./custom-menu.md)
24 |
25 | ## Features
26 |
27 | * [User Badges](./user-badges.md)
28 | * [Prefilling the Login Form](./prefill-login-form.md)
29 |
30 | for [etke.cc](https://etke.cc) customers only:
31 |
32 | > **Note:** The following features are only available for etke.cc customers. Due to specifics of the implementation,
33 | they are not available for any other Synapse Admin deployments.
34 |
35 | * [Server Status icon](../src/components/etke.cc/README.md#server-status-icon)
36 | * [Server Status page](../src/components/etke.cc/README.md#server-status-page)
37 | * [Server Actions page](../src/components/etke.cc/README.md#server-actions-page)
38 | * [Server Commands Panel](../src/components/etke.cc/README.md#server-commands-panel)
39 | * [Server Notifications icon](../src/components/etke.cc/README.md#server-notifications-icon)
40 | * [Server Notifications page](../src/components/etke.cc/README.md#server-notifications-page)
41 |
42 | ## Deployment
43 |
44 | * [Serving Synapse Admin behind a reverse proxy](./reverse-proxy.md)
45 |
--------------------------------------------------------------------------------
/docs/config.md:
--------------------------------------------------------------------------------
1 | # Configuration
2 |
3 | Synapse Admin could be configured using the following ways (both are optional, and both could be used together):
4 |
5 | * By providing the `config.json` file alongside with the Synapse Admin deployment, example: [admin.etke.cc/config.json](https://admin.etke.cc/config.json)
6 | * By providing configuration under the `cc.etke.synapse-admin` key in the `/.well-known/matrix/client` file, example:
7 | [demo.etke.host/.well-known/matrix/client](https://demo.etke.host/.well-known/matrix/client)
8 |
9 | In case you are an [etke.cc](https://etke.cc) customer,
10 | or use [spantaleev/matrix-docker-ansible-deploy](https://github.com/spantaleev/matrix-docker-ansible-deploy),
11 | or [etkecc/ansible](https://github.com/etkecc/ansible),
12 | configuration will be automatically added to the `/.well-known/matrix/client` file.
13 |
14 | **Why `/.well-known/matrix/client`?**
15 |
16 | Because any instance of Synapse Admin will automatically pick up the configuration from the homeserver.
17 | Common use case when you have a Synapse server running, but don't want (or can't) deploy Synapse Admin alongside with it.
18 | In this case, you could provide the configuration in the `/.well-known/matrix/client` file,
19 | and any Synapse Admin instance (e.g., [admin.etke.cc](https://admin.etke.cc) will pick it up.
20 |
21 | Another common case is when you have multiple Synapse servers running and want to use a single Synapse Admin instance to manage them all.
22 | In this case, you could provide the configuration in the `/.well-known/matrix/client` file for each of the servers.
23 |
24 | ## Configuration options
25 |
26 | * `restrictBaseUrl` - restrictBaseUrl restricts the Synapse Admin instance to work only with specific homeserver(-s).
27 | It accepts both a string and an array of strings.
28 | The homeserver URL should be the _actual_ homeserver URL, and not the delegated one.
29 | Example: `https://matrix.example.com` or `https://synapse.example.net`
30 | [More details](restrict-hs.md)
31 | * `corsCredentials` - configure the CORS credentials for the Synapse Admin instance.
32 | It accepts the following values:
33 | * `same-origin` (default): Cookies will be sent only if the request is made from the same origin as the server.
34 | * `include`: Cookies will be sent regardless of the origin of the request.
35 | * `omit`: Cookies will not be sent with the request.
36 | [More details](cors-credentials.md)
37 | * `asManagedUsers` - protect system user accounts managed by appservices (such as bridges) / system (such as bots) from accidental changes.
38 | By defining a list of MXID regex patterns, you can protect these accounts from accidental changes.
39 | Example: `^@baibot:example\\.com$`, `^@slackbot:example\\.com$`, `^@slack_[a-zA-Z0-9\\-]+:example\\.com$`, `^@telegrambot:example\\.com$`, `^@telegram_[a-zA-Z0-9]+:example\\.com$`
40 | [More details](system-users.md)
41 | * `menu` - add custom menu items to the main menu (sidebar) by providing a `menu` array in the config.
42 | Each `menu` item can contain the following fields:
43 | * `label` (required): The text to display in the menu.
44 | * `icon` (optional): 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.
45 | * `url` (required): The URL to navigate to when the menu item is clicked.
46 | [More details](custom-menu.md)
47 |
48 | ## Examples
49 |
50 | ### config.json
51 |
52 | ```json
53 | {
54 | "restrictBaseUrl": [
55 | "https://matrix.example.com",
56 | "https://synapse.example.net"
57 | ],
58 | "asManagedUsers": [
59 | "^@baibot:example\\.com$",
60 | "^@slackbot:example\\.com$",
61 | "^@slack_[a-zA-Z0-9\\-]+:example\\.com$",
62 | "^@telegrambot:example\\.com$",
63 | "^@telegram_[a-zA-Z0-9]+:example\\.com$"
64 | ],
65 | "menu": [
66 | {
67 | "label": "Contact support",
68 | "icon": "SupportAgent",
69 | "url": "https://github.com/etkecc/synapse-admin/issues"
70 | }
71 | ]
72 | }
73 | ```
74 |
75 | ### `/.well-known/matrix/client`
76 |
77 | ```json
78 | {
79 | "cc.etke.synapse-admin": {
80 | "restrictBaseUrl": [
81 | "https://matrix.example.com",
82 | "https://synapse.example.net"
83 | ],
84 | "asManagedUsers": [
85 | "^@baibot:example\\.com$",
86 | "^@slackbot:example\\.com$",
87 | "^@slack_[a-zA-Z0-9\\-]+:example\\.com$",
88 | "^@telegrambot:example\\.com$",
89 | "^@telegram_[a-zA-Z0-9]+:example\\.com$"
90 | ],
91 | "menu": [
92 | {
93 | "label": "Contact support",
94 | "icon": "SupportAgent",
95 | "url": "https://github.com/etkecc/synapse-admin/issues"
96 | }
97 | ]
98 | }
99 | }
100 | ```
101 |
--------------------------------------------------------------------------------
/docs/cors-credentials.md:
--------------------------------------------------------------------------------
1 | # CORS Credentials
2 |
3 | If you'd like to use cookie-based authentication
4 | (for example, [ForwardAuth with Authelia](https://github.com/Awesome-Technologies/synapse-admin/issues/655)),
5 | you can configure the `corsCredentials` option in the `config.json` file or in the `/.well-known/matrix/client` file.
6 |
7 | ## Configuration
8 |
9 | > [Documentation on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#including_credentials)
10 |
11 | The `corsCredentials` option accepts the following values:
12 |
13 | * `same-origin` (default): Cookies will be sent only if the request is made from the same origin as the server.
14 | * `include`: Cookies will be sent regardless of the origin of the request.
15 | * `omit`: Cookies will not be sent with the request.
16 |
17 | [Configuration options](config.md)
18 |
19 | ### config.json
20 |
21 | ```json
22 | {
23 | "corsCredentials": "include"
24 | }
25 | ```
26 |
27 | ### `/.well-known/matrix/client`
28 |
29 | ```json
30 | {
31 | "cc.etke.synapse-admin": {
32 | "corsCredentials": "include"
33 | }
34 | }
35 | ```
36 |
--------------------------------------------------------------------------------
/docs/custom-menu.md:
--------------------------------------------------------------------------------
1 | # Custom Menu Items
2 |
3 | You can add custom menu items to the main menu (sidebar) by providing a `menu` array in the config.
4 | This is useful for adding links to external sites or other pages in your documentation, like a support page or internal wiki.
5 |
6 | ## Configuration
7 |
8 | The examples below contain the configuration settings to add a link to the [Synapse Admin issues](https://github.com/etke.cc/synapse-admin/issues).
9 |
10 | Each `menu` item can contain the following fields:
11 |
12 | * `label` (required): The text to display in the menu.
13 | * `icon` (optional): The icon to display next to the label, one of the [src/utils/icons.ts](../src/utils/icons.ts) icons, otherwise a
14 | default icon will be used.
15 | * `url` (required): The URL to navigate to when the menu item is clicked.
16 |
17 | [Configuration options](config.md)
18 |
19 | ### config.json
20 |
21 | ```json
22 | {
23 | "menu": [
24 | {
25 | "label": "Contact support",
26 | "icon": "SupportAgent",
27 | "url": "https://github.com/etkecc/synapse-admin/issues"
28 | }
29 | ]
30 | }
31 | ```
32 |
33 | ### `/.well-known/matrix/client`
34 |
35 | ```json
36 | {
37 | "cc.etke.synapse-admin": {
38 | "menu": [
39 | {
40 | "label": "Contact support",
41 | "icon": "SupportAgent",
42 | "url": "https://github.com/etkecc/synapse-admin/issues"
43 | }
44 | ]
45 | }
46 | }
47 | ```
48 |
--------------------------------------------------------------------------------
/docs/prefill-login-form.md:
--------------------------------------------------------------------------------
1 | # Prefilling the Login Form
2 |
3 | In some cases you may wish to prefill/preset the login form fields when sharing a link to a Synapse Admin instance.
4 | This can be done by adding the following query parameters to the URL:
5 |
6 | * `username` - The username to prefill in the username field.
7 | * `server` - The server to prefill in the homeserver url field.
8 |
9 | The following query params will work only if the Synapse Admin is loaded from `localhost` or `127.0.0.1`:
10 |
11 | * `password` - The password to prefill in the password field (credentials auth). **NEVER** use this in production.
12 | * `accessToken` - The access token to prefill in the access token field (access token auth). **NEVER** use this in production.
13 |
14 | > **WARNING**: Never use the `password` or `accessToken` query parameters in production as they can be easily extracted
15 | from the URL. These are only meant for development purposes and local environments.
16 |
17 |
18 | ## Examples
19 |
20 | ### Production
21 |
22 | ```bash
23 | https://admin.etke.cc?username=admin&server=https://matrix.example.com
24 | ```
25 |
26 | This will open `Credentials` (username/password) login form with the username field prefilled with `admin` and the
27 | Homeserver URL field prefilled with `https://matrix.example.com`.
28 |
29 | ### Development and Local environments
30 |
31 | **With Password**
32 |
33 | ```bash
34 | http://localhost:8080?username=admin&server=https://matrix.example.com&password=secret
35 | ```
36 |
37 | This will open `Credentials` (username/password) login form with the username field prefilled with `admin`, the
38 | Homeserver URL field prefilled with `https://matrix.example.com` and the password field prefilled with `secret`.
39 |
40 |
41 | **With Access Token**
42 |
43 | ```bash
44 | http://localhost:8080?server=https://matrix.example.com&accessToken=secret
45 | ```
46 |
47 | This will open `Access Token` login form with the Homeserver URL field prefilled with `https://matrix.example.com` and
48 | the access token field prefilled with `secret`.
49 |
--------------------------------------------------------------------------------
/docs/restrict-hs.md:
--------------------------------------------------------------------------------
1 | # Restricting available homeserver
2 |
3 | If you want to have your Synapse Admin instance work only with specific homeserver(-s),
4 | you can do that by setting `restrictBaseUrl` in the configuration.
5 |
6 | ## Configuration
7 |
8 | You can do that for a single homeserver or multiple homeservers at once, as `restrictBaseUrl` accepts both a string and
9 | an array of strings.
10 |
11 | The examples below contain the configuration settings to restrict the Synapse Admin instance to work only with
12 | `example.com` (with Synapse runing at `matrix.example.com`) and
13 | `example.net` (with Synapse running at `synapse.example.net`) homeservers.
14 | Note that the homeserver URL should be the _actual_ homeserver URL, and not the delegated one.
15 |
16 | So, if you have a homeserver `example.com` where users have MXIDs like `@user:example.com`,
17 | but actual Synapse is installed on `matrix.example.com` subdomain, you should use `https://matrix.example.com` in the
18 | configuration.
19 |
20 | [Configuration options](config.md)
21 |
22 | ### config.json
23 |
24 | ```json
25 | {
26 | "restrictBaseUrl": [
27 | "https://matrix.example.com",
28 | "https://synapse.example.net"
29 | ]
30 | }
31 | ```
32 |
33 | ### `/.well-known/matrix/client`
34 |
35 | ```json
36 | {
37 | "cc.etke.synapse-admin": {
38 | "restrictBaseUrl": [
39 | "https://matrix.example.com",
40 | "https://synapse.example.net"
41 | ]
42 | }
43 | }
44 | ```
45 |
--------------------------------------------------------------------------------
/docs/reverse-proxy.md:
--------------------------------------------------------------------------------
1 | # Serving Synapse Admin behind a reverse proxy
2 |
3 | Your are supposed to do so for any service you want to expose to the internet,
4 | and here you can find specific instructions and example configurations for Synapse Admin.
5 |
6 | ## Nginx
7 |
8 | Place the config below into `/etc/nginx/conf.d/synapse-admin.conf` (don't forget to replace `server_name` and `root`):
9 |
10 | ```nginx
11 | server {
12 | listen 80;
13 | listen [::]:80;
14 | server_name example.com; # REPLACE with your domain
15 | root /var/www/synapse-admin; # REPLACE with path where you extracted synapse admin
16 | index index.html;
17 | location / {
18 | try_files $uri $uri/ /index.html;
19 | }
20 | location ~* \.(?:css|js|jpg|jpeg|gif|png|svg|ico|woff|woff2|ttf|eot|webp)$ {
21 | expires 30d; # Set caching for static assets
22 | add_header Cache-Control "public";
23 | }
24 |
25 | gzip on;
26 | gzip_types text/plain application/javascript application/json text/css text/xml application/xml+rss;
27 | gzip_min_length 1000;
28 | }
29 | ```
30 |
31 | After you've done that, ensure that the configuration is correct by running `nginx -t` and then reload Nginx
32 | (e.g. `systemctl reload nginx`).
33 |
34 | > **Note:** This configuration doesn't cover HTTPS, which is highly recommended to use. You can find more information
35 | about setting up HTTPS in the [Nginx documentation](https://nginx.org/en/docs/http/configuring_https_servers.html).
36 |
37 | ## Traefik (docker labels)
38 |
39 | If you are using Traefik as a reverse proxy, you can use the following labels, `docker-compose.yml` example:
40 |
41 | ```yaml
42 | services:
43 | synapse-admin:
44 | image: ghcr.io/etkecc/synapse-admin:latest
45 | restart: unless-stopped
46 | labels:
47 | - "traefik.enable=true"
48 | - "traefik.http.routers.synapse-admin.rule=Host(`example.com`)"
49 | ```
50 |
51 | ## Other reverse proxies
52 |
53 | There is no examples for other reverse proxies yet, and so PRs are greatly appreciated.
54 |
--------------------------------------------------------------------------------
/docs/system-users.md:
--------------------------------------------------------------------------------
1 | # System / Appservice-managed Users
2 |
3 | Inadvertently altering system user accounts managed by appservices (such as bridges) / system (such as bots) is a common issue.
4 | Editing, deleting, locking, or changing the passwords of these appservice-managed accounts can cause serious problems.
5 | To prevent this, we've added a new feature that blocks these types of modifications to such accounts,
6 | while still allowing other risk-free changes (changing display names and avatars).
7 | By defining a list of MXID regex patterns in the new `asManagedUsers` configuration setting,
8 | you can protect these accounts from accidental changes.
9 |
10 | ## Configuration
11 |
12 | The examples below contain the configuration settings to mark
13 | [Telegram bridge (mautrix-telegram)](https://github.com/mautrix/telegram),
14 | [Slack bridge (mautrix-slack)](https://github.com/mautrix/slack),
15 | and [Baibot](https://github.com/etkecc/baibot) users of `example.com` homeserver as appservice-managed users,
16 | just to illustrate 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).
17 |
18 | [Configuration options](config.md)
19 |
20 | ### config.json
21 |
22 | ```json
23 | "asManagedUsers": [
24 | "^@baibot:example\\.com$",
25 | "^@slackbot:example\\.com$",
26 | "^@slack_[a-zA-Z0-9\\-]+:example\\.com$",
27 | "^@telegrambot:example\\.com$",
28 | "^@telegram_[a-zA-Z0-9]+:example\\.com$"
29 | ]
30 | ```
31 |
32 | ### `/.well-known/matrix/client`
33 |
34 | ```json
35 | "cc.etke.synapse-admin": {
36 | "asManagedUsers": [
37 | "^@baibot:example\\.com$",
38 | "^@slackbot:example\\.com$",
39 | "^@slack_[a-zA-Z0-9\\-]+:example\\.com$",
40 | "^@telegrambot:example\\.com$",
41 | "^@telegram_[a-zA-Z0-9]+:example\\.com$"
42 | ]
43 | }
44 | ```
45 |
--------------------------------------------------------------------------------
/docs/user-badges.md:
--------------------------------------------------------------------------------
1 | # User Badges
2 |
3 | To help with identifying users with certain roles or permissions, we have implemented a badge system.
4 | These badges are displayed on the user's avatar and have a handy tooltip that explains what the badge means.
5 |
6 | ## Available Badges
7 |
8 | ### 🧙 You
9 |
10 | This badge is displayed on your user's avatar.
11 | Tooltip for this badge will contain additional information, e.g.: `You (Admin)`.
12 |
13 | ### 👑 Admin
14 |
15 | This badge is displayed on homeserver admins' avatars.
16 | Tooltip for this badge is `Admin`.
17 |
18 | ### 🛡️ Appservice/System-managed
19 |
20 | This badge is displayed on users that are managed by an appservices (or system), [more details](./system-users.md).
21 | Tooltip for this badge will contain additional information, e.g.: `System-managed (Bot)`.
22 |
23 | ### 🤖 Bot
24 |
25 | This badge is displayed on bots' avatars (users with the `user_type` set to `bot`).
26 | Tooltip for this badge is `Bot`.
27 |
28 | ### 📞 Support
29 |
30 | This badge is displayed on users that are part of the support team (users with the `user_type` set to `support`).
31 | Tooltip for this badge is `Support`.
32 |
33 | ### 👤 Regular User
34 |
35 | This badge is displayed on regular users' avatars.
36 | Tooltip for this badge is `Regular User`.
37 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 | Synapse Admin
15 |
116 |
117 |
118 |
119 |
124 |
125 |
126 |
127 |
128 |
129 |
--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
1 | import type { JestConfigWithTsJest } from "ts-jest";
2 |
3 | const config: JestConfigWithTsJest = {
4 | preset: "ts-jest",
5 | testEnvironment: "jest-fixed-jsdom",
6 | collectCoverage: true,
7 | coveragePathIgnorePatterns: ["node_modules", "dist"],
8 | coverageDirectory: "/coverage/",
9 | coverageReporters: ["html", "text", "text-summary", "cobertura"],
10 | extensionsToTreatAsEsm: [".ts", ".tsx"],
11 | setupFilesAfterEnv: ["/src/jest.setup.ts"],
12 | transform: {
13 | "^.+\\.tsx?$": [
14 | "ts-jest",
15 | {
16 | diagnostics: {
17 | ignoreCodes: [1343],
18 | },
19 | astTransformers: {
20 | before: [
21 | {
22 | path: "ts-jest-mock-import-meta",
23 | options: { metaObjectReplacement: { env: { BASE_URL: "/" } } },
24 | },
25 | ],
26 | },
27 | },
28 | ],
29 | },
30 | };
31 | export default config;
32 |
--------------------------------------------------------------------------------
/justfile:
--------------------------------------------------------------------------------
1 | # Shows help
2 | default:
3 | @just --list --justfile {{ justfile() }}
4 |
5 | # build the app
6 | build: __install
7 | @yarn run build --base=./
8 |
9 | update:
10 | yarn upgrade-interactive --latest
11 |
12 | # run the app in a development mode
13 | run:
14 | @yarn start --host 0.0.0.0
15 |
16 | # run dev stack and start the app in a development mode
17 | run-dev:
18 | @echo "Starting the database..."
19 | @docker-compose -f docker-compose-dev.yml up -d postgres
20 | @echo "Starting Synapse..."
21 | @docker-compose -f docker-compose-dev.yml up -d synapse
22 | @echo "Starting Element Web..."
23 | @docker-compose -f docker-compose-dev.yml up -d element
24 | @echo "Ensure admin user is registered..."
25 | @docker-compose -f docker-compose-dev.yml exec synapse register_new_matrix_user --admin -u admin -p admin -c /config/homeserver.yaml http://localhost:8008 || true
26 | @echo "Starting the app..."
27 | @yarn start --host 0.0.0.0
28 |
29 | # stop the dev stack
30 | stop-dev:
31 | @docker-compose -f docker-compose-dev.yml stop
32 |
33 | # register a user in the dev stack
34 | register-user localpart password *admin:
35 | docker-compose exec synapse register_new_matrix_user {{ if admin == "1" {"--admin"} else {"--no-admin"} }} -u {{ localpart }} -p {{ password }} -c /config/homeserver.yaml http://localhost:8008
36 |
37 | # run yarn {fix,lint,test} commands
38 | test:
39 | @-yarn run fix
40 | @-yarn run lint
41 | @-yarn run test
42 |
43 | # run the app in a production mode
44 | run-prod: build
45 | @python -m http.server -d dist 1313
46 |
47 | # install the project
48 | __install:
49 | @yarn install --immutable --network-timeout=300000
50 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "synapse-admin",
3 | "version": "0.11.0",
4 | "description": "Admin GUI for the Matrix.org server Synapse",
5 | "type": "module",
6 | "author": "etke.cc (originally by Awesome Technologies Innovationslabor GmbH)",
7 | "license": "Apache-2.0",
8 | "homepage": ".",
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/etkecc/synapse-admin"
12 | },
13 | "devDependencies": {
14 | "@eslint/js": "^9.25.0",
15 | "@testing-library/dom": "^10.0.0",
16 | "@testing-library/jest-dom": "^6.6.3",
17 | "@testing-library/react": "^16.3.0",
18 | "@testing-library/user-event": "^14.6.1",
19 | "@types/jest": "^29.5.14",
20 | "@types/lodash": "^4.17.17",
21 | "@types/node": "^22.15.21",
22 | "@types/papaparse": "^5.3.16",
23 | "@types/react": "^19.1.5",
24 | "@typescript-eslint/eslint-plugin": "^8.32.0",
25 | "@typescript-eslint/parser": "^8.32.0",
26 | "@vitejs/plugin-react": "^4.5.0",
27 | "eslint": "^9.27.0",
28 | "eslint-config-prettier": "^10.1.5",
29 | "eslint-plugin-import": "^2.31.0",
30 | "eslint-plugin-jsx-a11y": "^6.10.2",
31 | "eslint-plugin-prettier": "^5.4.0",
32 | "eslint-plugin-unused-imports": "^4.1.4",
33 | "jest": "^29.7.0",
34 | "jest-environment-jsdom": "^29.7.0",
35 | "jest-fetch-mock": "^3.0.3",
36 | "prettier": "^3.5.3",
37 | "react-test-renderer": "^19.1.0",
38 | "ts-jest": "^29.3.4",
39 | "ts-node": "^10.9.2",
40 | "typescript": "^5.8.3",
41 | "typescript-eslint": "^8.32.1",
42 | "vite": "^6.3.5",
43 | "vite-plugin-version-mark": "^0.1.4"
44 | },
45 | "dependencies": {
46 | "@emotion/react": "^11.14.0",
47 | "@emotion/styled": "^11.14.0",
48 | "@haleos/ra-language-german": "^1.0.0",
49 | "@haxqer/ra-language-chinese": "^4.16.2",
50 | "@mui/icons-material": "^7.1.0",
51 | "@mui/material": "^7.1.0",
52 | "@mui/utils": "^7.1.0",
53 | "@tanstack/react-query": "^5.77.1",
54 | "history": "^5.3.0",
55 | "jest-fixed-jsdom": "^0.0.9",
56 | "lodash": "^4.17.21",
57 | "papaparse": "^5.5.3",
58 | "ra-core": "^5.4.4",
59 | "ra-i18n-polyglot": "^5.4.4",
60 | "ra-language-english": "^5.4.4",
61 | "ra-language-farsi": "^5.1.0",
62 | "ra-language-french": "^5.8.2",
63 | "ra-language-italian": "^3.13.1",
64 | "ra-language-russian": "^5.4.3",
65 | "react": "^19.1.0",
66 | "react-admin": "^5.8.2",
67 | "react-dom": "^19.1.0",
68 | "react-hook-form": "^7.56.4",
69 | "react-is": "^19.1.0",
70 | "react-router": "^7.6.0",
71 | "react-router-dom": "^7.6.1",
72 | "ts-jest-mock-import-meta": "^1.3.0"
73 | },
74 | "scripts": {
75 | "start": "vite serve",
76 | "build": "vite build",
77 | "lint": "ESLINT_USE_FLAT_CONFIG=false eslint --ignore-path .gitignore --ignore-pattern testdata/ --ext .ts,.tsx .",
78 | "fix": "yarn lint --fix",
79 | "test": "yarn jest",
80 | "test:watch": "yarn jest --watch"
81 | },
82 | "eslintConfig": {
83 | "env": {
84 | "browser": true
85 | },
86 | "plugins": [
87 | "import",
88 | "prettier",
89 | "unused-imports",
90 | "@typescript-eslint"
91 | ],
92 | "extends": [
93 | "eslint:recommended",
94 | "plugin:@typescript-eslint/recommended",
95 | "plugin:@typescript-eslint/stylistic",
96 | "plugin:import/typescript"
97 | ],
98 | "parser": "@typescript-eslint/parser",
99 | "parserOptions": {
100 | "project": "./tsconfig.eslint.json"
101 | },
102 | "root": true,
103 | "rules": {
104 | "prettier/prettier": "error",
105 | "import/no-extraneous-dependencies": [
106 | "error",
107 | {
108 | "devDependencies": [
109 | "**/vite.config.ts",
110 | "**/jest.setup.ts",
111 | "**/*.test.ts",
112 | "**/*.test.tsx"
113 | ]
114 | }
115 | ],
116 | "import/order": [
117 | "error",
118 | {
119 | "alphabetize": {
120 | "order": "asc",
121 | "caseInsensitive": false
122 | },
123 | "newlines-between": "always",
124 | "groups": [
125 | "external",
126 | "builtin",
127 | "internal",
128 | [
129 | "parent",
130 | "sibling",
131 | "index"
132 | ]
133 | ]
134 | }
135 | ]
136 | }
137 | },
138 | "prettier": {
139 | "printWidth": 120,
140 | "tabWidth": 2,
141 | "useTabs": false,
142 | "semi": true,
143 | "singleQuote": false,
144 | "trailingComma": "es5",
145 | "bracketSpacing": true,
146 | "arrowParens": "avoid"
147 | },
148 | "browserslist": {
149 | "production": [
150 | ">0.2%",
151 | "not dead",
152 | "not op_mini all"
153 | ],
154 | "development": [
155 | "last 1 chrome version",
156 | "last 1 firefox version",
157 | "last 1 safari version"
158 | ]
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/public/config.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/public/data/example.csv:
--------------------------------------------------------------------------------
1 | id,displayname,password,is_guest,admin,deactivated
2 | testuser22,Jane Doe,secretpassword,false,true,false
3 | ,John Doe,,false,false,false
4 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/etkecc/synapse-admin/c82d8653fd6500d57368d422d9c9a54c43377ca1/public/favicon.ico
--------------------------------------------------------------------------------
/public/images/logo.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/etkecc/synapse-admin/c82d8653fd6500d57368d422d9c9a54c43377ca1/public/images/logo.webp
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow: /
4 |
--------------------------------------------------------------------------------
/screenshots/auth.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/etkecc/synapse-admin/c82d8653fd6500d57368d422d9c9a54c43377ca1/screenshots/auth.webp
--------------------------------------------------------------------------------
/screenshots/etke.cc/server-actions/page.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/etkecc/synapse-admin/c82d8653fd6500d57368d422d9c9a54c43377ca1/screenshots/etke.cc/server-actions/page.webp
--------------------------------------------------------------------------------
/screenshots/etke.cc/server-commands/panel.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/etkecc/synapse-admin/c82d8653fd6500d57368d422d9c9a54c43377ca1/screenshots/etke.cc/server-commands/panel.webp
--------------------------------------------------------------------------------
/screenshots/etke.cc/server-notifications/badge.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/etkecc/synapse-admin/c82d8653fd6500d57368d422d9c9a54c43377ca1/screenshots/etke.cc/server-notifications/badge.webp
--------------------------------------------------------------------------------
/screenshots/etke.cc/server-notifications/page.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/etkecc/synapse-admin/c82d8653fd6500d57368d422d9c9a54c43377ca1/screenshots/etke.cc/server-notifications/page.webp
--------------------------------------------------------------------------------
/screenshots/etke.cc/server-status/indicator-sidebar.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/etkecc/synapse-admin/c82d8653fd6500d57368d422d9c9a54c43377ca1/screenshots/etke.cc/server-status/indicator-sidebar.webp
--------------------------------------------------------------------------------
/screenshots/etke.cc/server-status/indicator.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/etkecc/synapse-admin/c82d8653fd6500d57368d422d9c9a54c43377ca1/screenshots/etke.cc/server-status/indicator.webp
--------------------------------------------------------------------------------
/screenshots/etke.cc/server-status/page.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/etkecc/synapse-admin/c82d8653fd6500d57368d422d9c9a54c43377ca1/screenshots/etke.cc/server-status/page.webp
--------------------------------------------------------------------------------
/screenshots/screenshots.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/etkecc/synapse-admin/c82d8653fd6500d57368d422d9c9a54c43377ca1/screenshots/screenshots.jpg
--------------------------------------------------------------------------------
/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from "@testing-library/react";
2 | import fetchMock from "jest-fetch-mock";
3 | import { BrowserRouter } from "react-router-dom";
4 | fetchMock.enableMocks();
5 |
6 | jest.mock("./synapse/authProvider", () => ({
7 | __esModule: true,
8 | default: {
9 | logout: jest.fn().mockResolvedValue(undefined),
10 | },
11 | }));
12 |
13 | import App from "./App";
14 |
15 | describe("App", () => {
16 | beforeEach(() => {
17 | // Reset all mocks before each test
18 | fetchMock.resetMocks();
19 | // Mock any fetch call to return empty JSON immediately
20 | fetchMock.mockResponseOnce(JSON.stringify({}));
21 | });
22 |
23 | it("renders", async () => {
24 | render(
25 |
26 |
27 |
28 | );
29 |
30 | await screen.findAllByText("Welcome to Synapse Admin");
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2 | import { merge } from "lodash";
3 | import polyglotI18nProvider from "ra-i18n-polyglot";
4 | import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin";
5 | import { Route } from "react-router-dom";
6 |
7 | import AdminLayout from "./components/AdminLayout";
8 | import ServerActionsPage from "./components/etke.cc/ServerActionsPage";
9 | import ServerNotificationsPage from "./components/etke.cc/ServerNotificationsPage";
10 | import ServerStatusPage from "./components/etke.cc/ServerStatusPage";
11 | import RecurringCommandEdit from "./components/etke.cc/schedules/components/recurring/RecurringCommandEdit";
12 | import ScheduledCommandEdit from "./components/etke.cc/schedules/components/scheduled/ScheduledCommandEdit";
13 | import ScheduledCommandShow from "./components/etke.cc/schedules/components/scheduled/ScheduledCommandShow";
14 | import UserImport from "./components/user-import/UserImport";
15 | import germanMessages from "./i18n/de";
16 | import englishMessages from "./i18n/en";
17 | import frenchMessages from "./i18n/fr";
18 | import italianMessages from "./i18n/it";
19 | import russianMessages from "./i18n/ru";
20 | import chineseMessages from "./i18n/zh";
21 | import LoginPage from "./pages/LoginPage";
22 | import destinations from "./resources/destinations";
23 | import registrationToken from "./resources/registration_tokens";
24 | import reports from "./resources/reports";
25 | import roomDirectory from "./resources/room_directory";
26 | import rooms from "./resources/rooms";
27 | import userMediaStats from "./resources/user_media_statistics";
28 | import users from "./resources/users";
29 | import authProvider from "./synapse/authProvider";
30 | import dataProvider from "./synapse/dataProvider";
31 |
32 | // TODO: Can we use lazy loading together with browser locale?
33 | const messages = {
34 | de: germanMessages,
35 | en: englishMessages,
36 | fr: frenchMessages,
37 | it: italianMessages,
38 | ru: russianMessages,
39 | zh: chineseMessages,
40 | };
41 | const i18nProvider = polyglotI18nProvider(
42 | locale => (messages[locale] ? merge({}, messages.en, messages[locale]) : messages.en),
43 | resolveBrowserLocale(),
44 | [
45 | { locale: "en", name: "English" },
46 | { locale: "de", name: "Deutsch" },
47 | { locale: "fr", name: "Français" },
48 | { locale: "it", name: "Italiano" },
49 | { locale: "fa", name: "Persian(فارسی)" },
50 | { locale: "ru", name: "Russian(Русский)" },
51 | { locale: "zh", name: "简体中文" },
52 | ]
53 | );
54 |
55 | const queryClient = new QueryClient();
56 |
57 | export const App = () => (
58 |
59 |
68 |
69 | } />
70 | } />
71 | } />
72 | } />
73 | } />
74 | } />
75 | } />
76 | } />
77 | } />
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 | );
99 |
100 | export default App;
101 |
--------------------------------------------------------------------------------
/src/Context.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from "react";
2 |
3 | import { Config } from "./utils/config";
4 |
5 | export const AppContext = createContext({} as Config);
6 |
7 | export const useAppContext = () => useContext(AppContext) as Config;
8 |
--------------------------------------------------------------------------------
/src/components/AdminLayout.tsx:
--------------------------------------------------------------------------------
1 | import ManageHistoryIcon from "@mui/icons-material/ManageHistory";
2 | import { useEffect, useState, Suspense } from "react";
3 | import {
4 | CheckForApplicationUpdate,
5 | AppBar,
6 | TitlePortal,
7 | InspectorButton,
8 | Confirm,
9 | Layout,
10 | Logout,
11 | Menu,
12 | useLogout,
13 | UserMenu,
14 | useStore,
15 | } from "react-admin";
16 |
17 | import Footer from "./Footer";
18 | import { LoginMethod } from "../pages/LoginPage";
19 | import { MenuItem, GetConfig, ClearConfig } from "../utils/config";
20 | import { Icons, DefaultIcon } from "../utils/icons";
21 | import { ServerNotificationsBadge } from "./etke.cc/ServerNotificationsBadge";
22 | import { ServerProcessResponse, ServerStatusResponse } from "../synapse/dataProvider";
23 | import ServerStatusBadge from "./etke.cc/ServerStatusBadge";
24 | import { ServerStatusStyledBadge } from "./etke.cc/ServerStatusBadge";
25 |
26 | const AdminUserMenu = () => {
27 | const [open, setOpen] = useState(false);
28 | const logout = useLogout();
29 | const checkLoginType = (ev: React.MouseEvent) => {
30 | const loginType: LoginMethod = (localStorage.getItem("login_type") || "credentials") as LoginMethod;
31 | if (loginType === "accessToken") {
32 | ev.stopPropagation();
33 | setOpen(true);
34 | }
35 | };
36 |
37 | const handleConfirm = () => {
38 | setOpen(false);
39 | logout();
40 | };
41 |
42 | const handleDialogClose = () => {
43 | setOpen(false);
44 | ClearConfig();
45 | window.location.reload();
46 | };
47 |
48 | return (
49 |
50 |
51 |
52 |
53 |
62 |
63 | );
64 | };
65 |
66 | const AdminAppBar = () => {
67 | return (
68 | }>
69 |
70 |
71 |
72 |
73 |
74 | );
75 | };
76 |
77 | const AdminMenu = props => {
78 | const [menu, setMenu] = useState([] as MenuItem[]);
79 | const [etkeRoutesEnabled, setEtkeRoutesEnabled] = useState(false);
80 | useEffect(() => {
81 | setMenu(GetConfig().menu);
82 | if (GetConfig().etkeccAdmin) {
83 | setEtkeRoutesEnabled(true);
84 | }
85 | }, []);
86 | const [serverProcess, setServerProcess] = useStore("serverProcess", {
87 | command: "",
88 | locked_at: "",
89 | });
90 | const [serverStatus, setServerStatus] = useStore("serverStatus", {
91 | success: false,
92 | ok: false,
93 | host: "",
94 | results: [],
95 | });
96 |
97 | return (
98 |
142 | );
143 | };
144 |
145 | export const AdminLayout = ({ children }) => {
146 | return (
147 | <>
148 |
161 | {children}
162 |
163 |
164 |
165 | >
166 | );
167 | };
168 |
169 | export default AdminLayout;
170 |
--------------------------------------------------------------------------------
/src/components/AvatarField.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, waitFor } from "@testing-library/react";
2 | import { act } from "react";
3 | import { RecordContextProvider } from "react-admin";
4 |
5 | import AvatarField from "./AvatarField";
6 |
7 | describe("AvatarField", () => {
8 | beforeEach(() => {
9 | // Mock fetch
10 | global.fetch = jest.fn(() =>
11 | Promise.resolve({
12 | blob: () => Promise.resolve(new Blob(["mock image data"], { type: "image/jpeg" })),
13 | })
14 | ) as jest.Mock;
15 |
16 | // Mock URL.createObjectURL
17 | global.URL.createObjectURL = jest.fn(() => "mock-object-url");
18 | });
19 |
20 | afterEach(() => {
21 | jest.restoreAllMocks();
22 | });
23 |
24 | it.only("shows image", async () => {
25 | const value = {
26 | avatar: "mxc://serverName/mediaId",
27 | };
28 |
29 | await act(async () => {
30 | render(
31 |
32 |
33 |
34 | );
35 | });
36 |
37 | await waitFor(() => {
38 | const img = screen.getByRole("img");
39 | expect(img.getAttribute("src")).toBe("mock-object-url");
40 | });
41 |
42 | expect(global.fetch).toHaveBeenCalled();
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/src/components/AvatarField.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, AvatarProps, Badge, Tooltip } from "@mui/material";
2 | import { get } from "lodash";
3 | import { useState, useEffect, useCallback } from "react";
4 | import { FieldProps, useRecordContext, useTranslate } from "react-admin";
5 |
6 | import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
7 | import { isMXID, isASManaged } from "../utils/mxid";
8 |
9 | const AvatarField = ({ source, ...rest }: AvatarProps & FieldProps) => {
10 | const { alt, classes, sizes, sx, variant } = rest;
11 | const record = useRecordContext(rest);
12 | const mxcURL = get(record, source)?.toString();
13 |
14 | const [src, setSrc] = useState("");
15 |
16 | const fetchAvatar = useCallback(async (mxcURL: string) => {
17 | const response = await fetchAuthenticatedMedia(mxcURL, "thumbnail");
18 | const blob = await response.blob();
19 | const blobURL = URL.createObjectURL(blob);
20 | setSrc(blobURL);
21 | }, []);
22 |
23 | useEffect(() => {
24 | if (mxcURL) {
25 | fetchAvatar(mxcURL);
26 | }
27 |
28 | // Cleanup function to revoke the object URL
29 | return () => {
30 | if (src) {
31 | URL.revokeObjectURL(src);
32 | }
33 | };
34 | }, [mxcURL, fetchAvatar]);
35 |
36 | // a hacky way to handle both users and rooms,
37 | // where users have an ID, may have a name, and may have a displayname
38 | // and rooms have an ID and may have a name
39 | let letter = "";
40 | if (record?.id) {
41 | letter = record.id[0].toUpperCase();
42 | }
43 | if (record?.name) {
44 | letter = record.name[0].toUpperCase();
45 | }
46 | if (record?.displayname) {
47 | letter = record.displayname[0].toUpperCase();
48 | }
49 |
50 | // hacky way to determine the user type
51 | let badge = "";
52 | let tooltip = "";
53 | if (isMXID(record?.id)) {
54 | const translate = useTranslate();
55 | switch (record?.user_type) {
56 | case "bot":
57 | badge = "🤖";
58 | tooltip = translate("resources.users.badge.bot");
59 | break;
60 | case "support":
61 | badge = "📞";
62 | tooltip = translate("resources.users.badge.support");
63 | break;
64 | default:
65 | badge = "👤";
66 | tooltip = translate("resources.users.badge.regular");
67 | break;
68 | }
69 | if (record?.admin) {
70 | badge = "👑";
71 | tooltip = translate("resources.users.badge.admin");
72 | }
73 | if (isASManaged(record?.name)) {
74 | badge = "🛡️";
75 | tooltip = `${translate("resources.users.badge.system_managed")} (${tooltip})`;
76 | }
77 | if (localStorage.getItem("user_id") === record?.id) {
78 | badge = "🧙";
79 | tooltip = `${translate("resources.users.badge.you")} (${tooltip})`;
80 | }
81 | }
82 |
83 | // if there is a badge, wrap the Avatar in a Badge and a Tooltip
84 | if (badge) {
85 | return (
86 |
87 |
96 |
97 | {letter}
98 |
99 |
100 |
101 | );
102 | }
103 |
104 | return (
105 |
106 | {letter}
107 |
108 | );
109 | };
110 |
111 | export default AvatarField;
112 |
--------------------------------------------------------------------------------
/src/components/DeleteRoomButton.tsx:
--------------------------------------------------------------------------------
1 | import ActionCheck from "@mui/icons-material/CheckCircle";
2 | import ActionDelete from "@mui/icons-material/Delete";
3 | import AlertError from "@mui/icons-material/ErrorOutline";
4 | import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
5 | import { Fragment, useState } from "react";
6 | import {
7 | SimpleForm,
8 | BooleanInput,
9 | useTranslate,
10 | RaRecord,
11 | useNotify,
12 | useRedirect,
13 | useDelete,
14 | NotificationType,
15 | useDeleteMany,
16 | Identifier,
17 | useUnselectAll,
18 | } from "react-admin";
19 |
20 | interface DeleteRoomButtonProps {
21 | selectedIds: Identifier[];
22 | confirmTitle: string;
23 | confirmContent: string;
24 | }
25 |
26 | const resourceName = "rooms";
27 |
28 | const DeleteRoomButton: React.FC = props => {
29 | const translate = useTranslate();
30 | const [open, setOpen] = useState(false);
31 | const [block, setBlock] = useState(false);
32 |
33 | const notify = useNotify();
34 | const redirect = useRedirect();
35 |
36 | const [deleteMany, { isLoading }] = useDeleteMany();
37 | const unselectAll = useUnselectAll(resourceName);
38 | const recordIds = props.selectedIds;
39 |
40 | const handleDialogOpen = () => setOpen(true);
41 | const handleDialogClose = () => setOpen(false);
42 |
43 | const handleDelete = (values: { block: boolean }) => {
44 | deleteMany(
45 | resourceName,
46 | { ids: recordIds, meta: values },
47 | {
48 | onSuccess: () => {
49 | notify("resources.rooms.action.erase.success");
50 | handleDialogClose();
51 | unselectAll();
52 | redirect("/rooms");
53 | },
54 | onError: error => notify("resources.rooms.action.erase.failure", { type: "error" as NotificationType }),
55 | }
56 | );
57 | };
58 |
59 | const handleConfirm = () => {
60 | setOpen(false);
61 | handleDelete({ block: block });
62 | };
63 |
64 | return (
65 |
66 | }
79 | >
80 | {translate("ra.action.delete")}
81 |
82 |
111 |
112 | );
113 | };
114 |
115 | export default DeleteRoomButton;
116 |
--------------------------------------------------------------------------------
/src/components/DeleteUserButton.tsx:
--------------------------------------------------------------------------------
1 | import ActionCheck from "@mui/icons-material/CheckCircle";
2 | import ActionDelete from "@mui/icons-material/Delete";
3 | import AlertError from "@mui/icons-material/ErrorOutline";
4 | import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
5 | import { Fragment, useState } from "react";
6 | import {
7 | SimpleForm,
8 | BooleanInput,
9 | useTranslate,
10 | RaRecord,
11 | useNotify,
12 | useRedirect,
13 | useDelete,
14 | NotificationType,
15 | useDeleteMany,
16 | Identifier,
17 | useUnselectAll,
18 | } from "react-admin";
19 |
20 | interface DeleteUserButtonProps {
21 | selectedIds: Identifier[];
22 | confirmTitle: string;
23 | confirmContent: string;
24 | }
25 |
26 | const resourceName = "users";
27 |
28 | const DeleteUserButton: React.FC = props => {
29 | const translate = useTranslate();
30 | const [open, setOpen] = useState(false);
31 | const [deleteMedia, setDeleteMedia] = useState(false);
32 | const [redactEvents, setRedactEvents] = useState(false);
33 |
34 | const notify = useNotify();
35 | const redirect = useRedirect();
36 |
37 | const [deleteMany, { isLoading }] = useDeleteMany();
38 | const unselectAll = useUnselectAll(resourceName);
39 | const recordIds = props.selectedIds;
40 |
41 | const handleDialogOpen = () => setOpen(true);
42 | const handleDialogClose = () => setOpen(false);
43 |
44 | const handleDelete = (values: { deleteMedia: boolean; redactEvents: boolean }) => {
45 | deleteMany(
46 | resourceName,
47 | { ids: recordIds, meta: values },
48 | {
49 | onSuccess: () => {
50 | notify("ra.notification.deleted", {
51 | messageArgs: {
52 | smart_count: recordIds.length,
53 | },
54 | type: "info" as NotificationType,
55 | });
56 | handleDialogClose();
57 | unselectAll();
58 | redirect("/users");
59 | },
60 | onError: error => notify("ra.notification.data_provider_error", { type: "error" as NotificationType }),
61 | }
62 | );
63 | };
64 |
65 | const handleConfirm = () => {
66 | setOpen(false);
67 | handleDelete({ deleteMedia: deleteMedia, redactEvents: redactEvents });
68 | };
69 |
70 | return (
71 |
72 | }
85 | >
86 | {translate("ra.action.delete")}
87 |
88 |
124 |
125 | );
126 | };
127 |
128 | export default DeleteUserButton;
129 |
--------------------------------------------------------------------------------
/src/components/DeviceRemoveButton.tsx:
--------------------------------------------------------------------------------
1 | import { DeleteWithConfirmButton, DeleteWithConfirmButtonProps, useRecordContext } from "react-admin";
2 |
3 | import { isASManaged } from "../utils/mxid";
4 |
5 | export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
6 | const record = useRecordContext();
7 | if (!record) return null;
8 |
9 | let isASManagedUser = false;
10 | if (record.user_id) {
11 | isASManagedUser = isASManaged(record.user_id);
12 | }
13 |
14 | return (
15 |
28 | );
29 | };
30 |
31 | export default DeviceRemoveButton;
32 |
--------------------------------------------------------------------------------
/src/components/ExperimentalFeatures.tsx:
--------------------------------------------------------------------------------
1 | import { Stack, Switch, Typography } from "@mui/material";
2 | import { useState, useEffect } from "react";
3 | import { useRecordContext } from "react-admin";
4 | import { useNotify } from "react-admin";
5 | import { useDataProvider } from "react-admin";
6 |
7 | import { ExperimentalFeaturesModel, SynapseDataProvider } from "../synapse/dataProvider";
8 |
9 | const experimentalFeaturesMap = {
10 | msc3881: "enable remotely toggling push notifications for another client",
11 | msc3575: "enable experimental sliding sync support",
12 | };
13 | const ExperimentalFeatureRow = (props: {
14 | featureKey: string;
15 | featureValue: boolean;
16 | updateFeature: (feature_name: string, feature_value: boolean) => void;
17 | }) => {
18 | const featureKey = props.featureKey;
19 | const featureValue = props.featureValue;
20 | const featureDescription = experimentalFeaturesMap[featureKey] ?? "";
21 | const [checked, setChecked] = useState(featureValue);
22 |
23 | const handleChange = (event: React.ChangeEvent) => {
24 | setChecked(event.target.checked);
25 | props.updateFeature(featureKey, event.target.checked);
26 | };
27 |
28 | return (
29 |
37 |
38 |
39 |
46 | {featureKey}
47 |
48 |
49 | {featureDescription}
50 |
51 |
52 |
53 | );
54 | };
55 |
56 | export const ExperimentalFeaturesList = () => {
57 | const record = useRecordContext();
58 | const notify = useNotify();
59 | const dataProvider = useDataProvider() as SynapseDataProvider;
60 | const [features, setFeatures] = useState({});
61 | if (!record) {
62 | return null;
63 | }
64 |
65 | useEffect(() => {
66 | const fetchFeatures = async () => {
67 | const features = await dataProvider.getFeatures(record.id);
68 | setFeatures(features);
69 | };
70 |
71 | fetchFeatures();
72 | }, []);
73 |
74 | const updateFeature = async (feature_name: string, feature_value: boolean) => {
75 | const updatedFeatures = { ...features, [feature_name]: feature_value } as ExperimentalFeaturesModel;
76 | setFeatures(updatedFeatures);
77 | const reponse = await dataProvider.updateFeatures(record.id, updatedFeatures);
78 | notify("ra.notification.updated", {
79 | messageArgs: { smart_count: 1 },
80 | type: "success",
81 | });
82 | };
83 |
84 | return (
85 | <>
86 |
87 | {Object.keys(features).map((featureKey: string) => (
88 |
94 | ))}
95 |
96 | >
97 | );
98 | };
99 |
100 | export default ExperimentalFeaturesList;
101 |
--------------------------------------------------------------------------------
/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, Box, Link, Typography } from "@mui/material";
2 | import { useTheme } from "@mui/material/styles";
3 | import { useEffect, useState } from "react";
4 |
5 | const Footer = () => {
6 | const [version, setVersion] = useState(null);
7 | const theme = useTheme();
8 |
9 | useEffect(() => {
10 | const version = document.getElementById("js-version")?.textContent;
11 | if (version) {
12 | setVersion(version);
13 | }
14 | }, []);
15 |
16 | return (
17 |
37 |
41 |
42 | Synapse Admin {version}
43 |
44 | by
45 |
49 | etke.cc
50 |
51 | (originally developed by Awesome Technologies Innovationslabor GmbH).
52 |
53 | #synapse-admin:etke.cc
54 |
55 |
56 | );
57 | };
58 |
59 | export default Footer;
60 |
--------------------------------------------------------------------------------
/src/components/LoginFormBox.tsx:
--------------------------------------------------------------------------------
1 | import { Box } from "@mui/material";
2 | import { styled } from "@mui/material/styles";
3 |
4 | const LoginFormBox = styled(Box)(({ theme }) => ({
5 | display: "flex",
6 | flexDirection: "column",
7 | minHeight: "calc(100vh - 1rem)",
8 | alignItems: "center",
9 | justifyContent: "flex-start",
10 | background: "url(./images/floating-cogs.svg)",
11 | backgroundColor: theme.palette.mode === "dark" ? theme.palette.background.default : theme.palette.background.paper,
12 | backgroundRepeat: "no-repeat",
13 | backgroundSize: "cover",
14 |
15 | [`& .card`]: {
16 | width: "30rem",
17 | marginTop: "6rem",
18 | marginBottom: "6rem",
19 | },
20 | [`& .avatar`]: {
21 | margin: "1rem",
22 | display: "flex",
23 | justifyContent: "center",
24 | },
25 | [`& .icon`]: {
26 | backgroundColor: theme.palette.grey[500],
27 | },
28 | [`& .hint`]: {
29 | marginTop: "1em",
30 | marginBottom: "1em",
31 | display: "flex",
32 | justifyContent: "center",
33 | color: theme.palette.grey[600],
34 | },
35 | [`& .form`]: {
36 | padding: "0 1rem 1rem 1rem",
37 | },
38 | [`& .select`]: {
39 | marginBottom: "2rem",
40 | },
41 | [`& .actions`]: {
42 | padding: "0 1rem 1rem 1rem",
43 | },
44 | [`& .serverVersion`]: {
45 | color: theme.palette.grey[500],
46 | fontFamily: "Roboto, Helvetica, Arial, sans-serif",
47 | marginLeft: "0.5rem",
48 | },
49 | [`& .matrixVersions`]: {
50 | color: theme.palette.grey[500],
51 | fontFamily: "Roboto, Helvetica, Arial, sans-serif",
52 | fontSize: "0.8rem",
53 | marginBottom: "1rem",
54 | marginLeft: "0.5rem",
55 | },
56 | }));
57 |
58 | export default LoginFormBox;
59 |
--------------------------------------------------------------------------------
/src/components/ServerNotices.tsx:
--------------------------------------------------------------------------------
1 | import IconCancel from "@mui/icons-material/Cancel";
2 | import MessageIcon from "@mui/icons-material/Message";
3 | import { Dialog, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
4 | import { useMutation } from "@tanstack/react-query";
5 | import { useState } from "react";
6 | import {
7 | Button,
8 | RaRecord,
9 | SaveButton,
10 | SimpleForm,
11 | TextInput,
12 | Toolbar,
13 | ToolbarProps,
14 | required,
15 | useCreate,
16 | useDataProvider,
17 | useListContext,
18 | useNotify,
19 | useRecordContext,
20 | useTranslate,
21 | useUnselectAll,
22 | } from "react-admin";
23 |
24 | const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
25 | const translate = useTranslate();
26 |
27 | const ServerNoticeToolbar = (props: ToolbarProps & { pristine?: boolean }) => (
28 |
29 |
30 |
33 |
34 | );
35 |
36 | return (
37 |
53 | );
54 | };
55 |
56 | export const ServerNoticeButton = () => {
57 | const record = useRecordContext();
58 | const [open, setOpen] = useState(false);
59 | const notify = useNotify();
60 | const [create, { isLoading }] = useCreate();
61 |
62 | const handleDialogOpen = () => setOpen(true);
63 | const handleDialogClose = () => setOpen(false);
64 |
65 | if (!record) {
66 | return null;
67 | }
68 |
69 | const handleSend = (values: Partial) => {
70 | create(
71 | "servernotices",
72 | { data: { id: record.id, ...values } },
73 | {
74 | onSuccess: () => {
75 | notify("resources.servernotices.action.send_success");
76 | handleDialogClose();
77 | },
78 | onError: () =>
79 | notify("resources.servernotices.action.send_failure", {
80 | type: "error",
81 | }),
82 | }
83 | );
84 | };
85 |
86 | return (
87 | <>
88 |
91 |
92 | >
93 | );
94 | };
95 |
96 | export const ServerNoticeBulkButton = () => {
97 | const { selectedIds } = useListContext();
98 | const [open, setOpen] = useState(false);
99 | const openDialog = () => setOpen(true);
100 | const closeDialog = () => setOpen(false);
101 | const notify = useNotify();
102 | const unselectAllUsers = useUnselectAll("users");
103 | const dataProvider = useDataProvider();
104 |
105 | const { mutate: sendNotices, isPending } = useMutation({
106 | mutationFn: data =>
107 | dataProvider.createMany("servernotices", {
108 | ids: selectedIds,
109 | data: data,
110 | }),
111 | onSuccess: () => {
112 | notify("resources.servernotices.action.send_success");
113 | unselectAllUsers();
114 | closeDialog();
115 | },
116 | onError: () =>
117 | notify("resources.servernotices.action.send_failure", {
118 | type: "error",
119 | }),
120 | });
121 |
122 | return (
123 | <>
124 |
127 |
128 | >
129 | );
130 | };
131 |
--------------------------------------------------------------------------------
/src/components/UserAccountData.tsx:
--------------------------------------------------------------------------------
1 | import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
2 | import { Typography, Box, Stack, Accordion, AccordionSummary, AccordionDetails } from "@mui/material";
3 | import { useEffect, useState } from "react";
4 | import { useDataProvider, useRecordContext, useTranslate } from "react-admin";
5 |
6 | import { SynapseDataProvider } from "../synapse/dataProvider";
7 |
8 | const UserAccountData = () => {
9 | const dataProvider = useDataProvider() as SynapseDataProvider;
10 | const record = useRecordContext();
11 | const translate = useTranslate();
12 | const [globalAccountData, setGlobalAccountData] = useState({});
13 | const [roomsAccountData, setRoomsAccountData] = useState({});
14 |
15 | if (!record) {
16 | return null;
17 | }
18 |
19 | useEffect(() => {
20 | const fetchAccountData = async () => {
21 | const accountData = await dataProvider.getAccountData(record.id);
22 | setGlobalAccountData(accountData.account_data.global);
23 | setRoomsAccountData(accountData.account_data.rooms);
24 | };
25 | fetchAccountData();
26 | }, []);
27 |
28 | if (Object.keys(globalAccountData).length === 0 && Object.keys(roomsAccountData).length === 0) {
29 | return (
30 |
31 | {translate("ra.navigation.no_results", {
32 | resource: "Account Data",
33 | _: "No results found.",
34 | })}
35 |
36 | );
37 | }
38 |
39 | return (
40 | <>
41 |
42 | {translate("resources.users.account_data.title")}
43 |
44 |
45 |
46 | }>
47 | {translate("resources.users.account_data.global")}
48 |
49 |
50 | {JSON.stringify(globalAccountData, null, 4)}
51 |
52 |
53 |
54 | }>
55 | {translate("resources.users.account_data.rooms")}
56 |
57 |
58 | {JSON.stringify(roomsAccountData, null, 4)}
59 |
60 |
61 |
62 |
63 |
64 | >
65 | );
66 | };
67 |
68 | export default UserAccountData;
69 |
--------------------------------------------------------------------------------
/src/components/UserRateLimits.tsx:
--------------------------------------------------------------------------------
1 | import { Stack, Typography } from "@mui/material";
2 | import { TextField } from "@mui/material";
3 | import { useEffect, useState } from "react";
4 | import { useDataProvider, useNotify, useRecordContext, useTranslate } from "react-admin";
5 | import { useFormContext } from "react-hook-form";
6 |
7 | const RateLimitRow = ({
8 | limit,
9 | value,
10 | updateRateLimit,
11 | }: {
12 | limit: string;
13 | value: any;
14 | updateRateLimit: (limit: string, value: any) => void;
15 | }) => {
16 | const translate = useTranslate();
17 |
18 | const handleChange = (event: React.ChangeEvent) => {
19 | const value = parseInt(event.target.value);
20 | if (isNaN(value)) {
21 | updateRateLimit(limit, null);
22 | return;
23 | }
24 | updateRateLimit(limit, value);
25 | };
26 |
27 | return (
28 |
35 |
47 |
48 |
49 | {translate(`resources.users.limits.${limit}_text`)}
50 |
51 |
52 |
53 | );
54 | };
55 |
56 | const UserRateLimits = () => {
57 | const translate = useTranslate();
58 | const notify = useNotify();
59 | const record = useRecordContext();
60 | const form = useFormContext();
61 | const dataProvider = useDataProvider();
62 | const [rateLimits, setRateLimits] = useState({
63 | messages_per_second: "", // we are setting string here to make the number field empty by default, null is prohibited by the field validation
64 | burst_count: "",
65 | });
66 |
67 | if (!record) {
68 | return null;
69 | }
70 |
71 | useEffect(() => {
72 | const fetchRateLimits = async () => {
73 | const rateLimits = await dataProvider.getRateLimits(record.id);
74 | if (Object.keys(rateLimits).length > 0) {
75 | setRateLimits(rateLimits);
76 | }
77 | };
78 |
79 | fetchRateLimits();
80 | }, []);
81 |
82 | const updateRateLimit = async (limit: string, value: any) => {
83 | const updatedRateLimits = { ...rateLimits, [limit]: value };
84 | setRateLimits(updatedRateLimits);
85 | form.setValue(`rates.${limit}`, value, { shouldDirty: true });
86 | };
87 |
88 | return (
89 | <>
90 |
91 | {Object.keys(rateLimits).map((limit: string) => (
92 |
93 | ))}
94 |
95 | >
96 | );
97 | };
98 |
99 | export default UserRateLimits;
100 |
--------------------------------------------------------------------------------
/src/components/etke.cc/CurrentlyRunningCommand.tsx:
--------------------------------------------------------------------------------
1 | import { Stack, Tooltip, Typography, Box, Link } from "@mui/material";
2 | import { useStore } from "react-admin";
3 |
4 | import { ServerProcessResponse } from "../../synapse/dataProvider";
5 | import { getTimeSince } from "../../utils/date";
6 |
7 | const CurrentlyRunningCommand = () => {
8 | const [serverProcess, setServerProcess] = useStore("serverProcess", {
9 | command: "",
10 | locked_at: "",
11 | });
12 | const { command, locked_at } = serverProcess;
13 |
14 | if (!command || !locked_at) {
15 | return null;
16 | }
17 |
18 | return (
19 |
20 |
21 | Currently running:
22 |
23 |
24 | {command}
25 |
26 |
27 |
28 | (started {getTimeSince(locked_at)} ago)
29 |
30 |
31 |
32 |
33 |
34 | );
35 | };
36 |
37 | export default CurrentlyRunningCommand;
38 |
--------------------------------------------------------------------------------
/src/components/etke.cc/README.md:
--------------------------------------------------------------------------------
1 | # etke.cc-specific components
2 |
3 | This directory contains [etke.cc](https://etke.cc)-specific components, unusable for any other purposes and/or configuration.
4 |
5 | We at [etke.cc](https://etke.cc) attempting to develop everything open-source, but some things are too specific to be used by anyone else. This directory contains such components - they are only available for [etke.cc](https://etke.cc) customers.
6 |
7 | Due to the specifics mentioned above, these components are documented here rather than in the [docs](../../../docs/README.md), plus they are not supported as part of the Synapse Admin open-source project (i.e.: no issues, no PRs, no support, no requests, etc.).
8 |
9 | ## Components
10 |
11 | ### Server Status icon
12 |
13 | 
14 |
15 | In the application bar the new monitoring icon is displayed that shows the current server status, and has the following color dot (and tooltip indicators):
16 |
17 | * 🟢 (green) - the server is up and running, everything is fine, no issues detected
18 | * 🟡 (yellow) - the server is up and running, but there is a command in progress (likely [maintenance](https://etke.cc/help/extras/scheduler/#maintenance)), so some temporary issues may occur - that's totally fine
19 | * 🔴 (red) - there is at least 1 issue with one of the server's components
20 |
21 | 
22 |
23 | The same icon (and link to the [Server Status page](#server-status-page)) is displayed in the sidebar.
24 |
25 | ### Server Status page
26 |
27 | 
28 |
29 | When you click on the [Server Status icon](#server-status-icon) in the application bar, you will be redirected to the
30 | Server Status page. This page contains the following information:
31 |
32 | * Overall server status (up/updating/has issues)
33 | * Details about the currently running command (if any)
34 | * Details about the server's components statuses (up/down with error details and suggested actions) by categories
35 |
36 | This is [a Monitoring report](https://etke.cc/services/monitoring/)
37 |
38 | ### Server Notifications icon
39 |
40 | 
41 |
42 | In the application bar the new notifications icon is displayed that shows the number of unread (not removed) notifications
43 |
44 | ### Server Notifications page
45 |
46 | 
47 |
48 | When you click on a notification from the [Server Notifications icon](#server-notifications-icon)'s list in the application bar, you will be redirected to the Server Notifications page. This page contains the full text of all the notifications you have about your server.
49 |
50 | ### Server Actions Page
51 |
52 | 
53 |
54 | When you click on the `Server Actions` sidebar menu item, you will be redirected to the Server Actions page.
55 | On this page you can do the following:
56 |
57 | * [Run a command](#server-commands-panel) on your server immediately
58 | * [Schedule a command](https://etke.cc/help/extras/scheduler/#schedule) to run at a specific date and time
59 | * [Configure a recurring schedule](https://etke.cc/help/extras/scheduler/#recurring) for a command to run at a specific time every week
60 |
61 | ### Server Commands Panel
62 |
63 | 
64 |
65 | When you open [Server Actions page](#server-status-page), you will see the Server Commands panel.
66 | This panel contains all [the commands](https://etke.cc/help/extras/scheduler/#commands) you can run on your server in 1 click.
67 | Once command is finished, you will get a notification about the result.
68 |
--------------------------------------------------------------------------------
/src/components/etke.cc/ServerActionsPage.tsx:
--------------------------------------------------------------------------------
1 | import RestoreIcon from "@mui/icons-material/Restore";
2 | import ScheduleIcon from "@mui/icons-material/Schedule";
3 | import { Box, Typography, Link, Divider } from "@mui/material";
4 | import { Stack } from "@mui/material";
5 |
6 | import CurrentlyRunningCommand from "./CurrentlyRunningCommand";
7 | import ServerCommandsPanel from "./ServerCommandsPanel";
8 | import RecurringCommandsList from "./schedules/components/recurring/RecurringCommandsList";
9 | import ScheduledCommandsList from "./schedules/components/scheduled/ScheduledCommandsList";
10 | const ServerActionsPage = () => {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | Scheduled commands
21 |
22 |
23 | The following commands are scheduled to run at specific times. You can view their details and modify them as
24 | needed. More details about the mode can be found{" "}
25 |
26 | here
27 |
28 | .
29 |
30 |
31 |
32 |
33 |
34 |
35 | Recurring commands
36 |
37 |
38 | The following commands are set to run at specific weekday and time (weekly). You can view their details and
39 | modify them as needed. More details about the mode can be found{" "}
40 |
41 | here
42 |
43 | .
44 |
45 |
46 |
47 |
48 | );
49 | };
50 |
51 | export default ServerActionsPage;
52 |
--------------------------------------------------------------------------------
/src/components/etke.cc/ServerNotificationsPage.tsx:
--------------------------------------------------------------------------------
1 | import DeleteIcon from "@mui/icons-material/Delete";
2 | import { Box, Typography, Paper, Button } from "@mui/material";
3 | import { Stack } from "@mui/material";
4 | import { Tooltip } from "@mui/material";
5 | import { useStore } from "react-admin";
6 |
7 | import { useAppContext } from "../../Context";
8 | import dataProvider, { ServerNotificationsResponse } from "../../synapse/dataProvider";
9 | import { getTimeSince } from "../../utils/date";
10 |
11 | const DisplayTime = ({ date }: { date: string }) => {
12 | const dateFromDateString = new Date(date.replace(" ", "T") + "Z");
13 | return {{getTimeSince(date) + " ago"}};
14 | };
15 |
16 | const ServerNotificationsPage = () => {
17 | const { etkeccAdmin } = useAppContext();
18 | const [serverNotifications, setServerNotifications] = useStore("serverNotifications", {
19 | notifications: [],
20 | success: false,
21 | });
22 |
23 | const notifications = serverNotifications.notifications;
24 |
25 | return (
26 |
27 |
28 |
29 | Server Notifications
30 |
43 |
44 |
45 |
46 | {notifications.length === 0 ? (
47 |
48 | No new notifications.
49 |
50 | ) : (
51 | notifications.map((notification, index) => (
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | ))
61 | )}
62 |
63 | );
64 | };
65 |
66 | export default ServerNotificationsPage;
67 |
--------------------------------------------------------------------------------
/src/components/etke.cc/ServerStatusPage.tsx:
--------------------------------------------------------------------------------
1 | import CheckIcon from "@mui/icons-material/Check";
2 | import CloseIcon from "@mui/icons-material/Close";
3 | import EngineeringIcon from "@mui/icons-material/Engineering";
4 | import { Alert, Box, Stack, Typography, Paper, Link, Chip, Divider, Tooltip, ChipProps } from "@mui/material";
5 | import { useStore } from "ra-core";
6 |
7 | import CurrentlyRunningCommand from "./CurrentlyRunningCommand";
8 | import { ServerProcessResponse, ServerStatusComponent, ServerStatusResponse } from "../../synapse/dataProvider";
9 | import { getTimeSince } from "../../utils/date";
10 |
11 | const StatusChip = ({
12 | isOkay,
13 | size = "medium",
14 | command,
15 | }: {
16 | isOkay: boolean;
17 | size?: "small" | "medium";
18 | command?: string;
19 | }) => {
20 | let label = "OK";
21 | let icon = ;
22 | let color: ChipProps["color"] = "success";
23 | if (!isOkay) {
24 | label = "Error";
25 | icon = ;
26 | color = "error";
27 | }
28 |
29 | if (command) {
30 | label = command;
31 | color = "warning";
32 | icon = ;
33 | }
34 |
35 | return ;
36 | };
37 |
38 | const ServerComponentText = ({ text }: { text: string }) => {
39 | return ;
40 | };
41 |
42 | const ServerStatusPage = () => {
43 | const [serverStatus, setServerStatus] = useStore("serverStatus", {
44 | ok: false,
45 | success: false,
46 | host: "",
47 | results: [],
48 | });
49 | const [serverProcess, setServerProcess] = useStore("serverProcess", {
50 | command: "",
51 | locked_at: "",
52 | });
53 | const { command, locked_at } = serverProcess;
54 | const successCheck = serverStatus.success;
55 | const isOkay = serverStatus.ok;
56 | const host = serverStatus.host;
57 | const results = serverStatus.results;
58 |
59 | const groupedResults: Record = {};
60 | for (const result of results) {
61 | if (!groupedResults[result.category]) {
62 | groupedResults[result.category] = [];
63 | }
64 | groupedResults[result.category].push(result);
65 | }
66 |
67 | if (!successCheck) {
68 | return (
69 |
70 |
71 | Fetching real-time server health... Just a moment!
72 |
73 |
74 | );
75 | }
76 |
77 | return (
78 |
79 |
80 |
81 | Status:
82 |
83 |
84 |
85 | {host}
86 |
87 |
88 |
89 |
90 |
91 |
92 | This is a{" "}
93 |
94 | monitoring report
95 | {" "}
96 | of the server. If any of the checks below concern you, please check the{" "}
97 |
101 | suggested actions
102 |
103 | .
104 |
105 |
106 |
107 | {Object.keys(groupedResults).map((category, idx) => (
108 |
109 |
110 | {category}
111 |
112 |
113 | }>
114 | {groupedResults[category].map((result, idx) => (
115 |
116 |
117 |
118 |
119 | {result.label.url ? (
120 |
121 |
122 |
123 | ) : (
124 |
125 | )}
126 |
127 | {result.reason && (
128 |
129 | )}
130 | {!result.ok && result.help && (
131 |
132 | Learn more
133 |
134 | )}
135 |
136 |
137 | ))}
138 |
139 |
140 |
141 | ))}
142 |
143 |
144 | );
145 | };
146 |
147 | export default ServerStatusPage;
148 |
--------------------------------------------------------------------------------
/src/components/etke.cc/hooks/useServerCommands.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { useDataProvider } from "react-admin";
3 |
4 | import { useAppContext } from "../../../Context";
5 | import { ServerCommand } from "../../../synapse/dataProvider";
6 |
7 | export const useServerCommands = () => {
8 | const { etkeccAdmin } = useAppContext();
9 | const [isLoading, setLoading] = useState(true);
10 | const [serverCommands, setServerCommands] = useState>({});
11 | const dataProvider = useDataProvider();
12 |
13 | useEffect(() => {
14 | const fetchServerCommands = async () => {
15 | const serverCommandsResponse = await dataProvider.getServerCommands(etkeccAdmin);
16 | if (serverCommandsResponse) {
17 | const serverCommands = serverCommandsResponse;
18 | Object.keys(serverCommandsResponse).forEach((command: string) => {
19 | serverCommands[command].additionalArgs = "";
20 | });
21 | setServerCommands(serverCommands);
22 | }
23 | setLoading(false);
24 | };
25 | fetchServerCommands();
26 | }, [dataProvider, etkeccAdmin]);
27 |
28 | return { isLoading, serverCommands, setServerCommands };
29 | };
30 |
--------------------------------------------------------------------------------
/src/components/etke.cc/schedules/components/ScheduledCommandCreate.tsx:
--------------------------------------------------------------------------------
1 | const transformCommandsToChoices = (commands: Record) => {
2 | return Object.entries(commands).map(([key, value]) => ({
3 | id: key,
4 | name: value.name,
5 | description: value.description,
6 | }));
7 | };
8 |
9 | const ScheduledCommandCreate = () => {
10 | const commandChoices = transformCommandsToChoices(serverCommands);
11 |
12 | return (
13 |
14 | `${choice.name} - ${choice.description}`}
18 | />
19 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/src/components/etke.cc/schedules/components/ScheduledCommandEdit.tsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/etkecc/synapse-admin/c82d8653fd6500d57368d422d9c9a54c43377ca1/src/components/etke.cc/schedules/components/ScheduledCommandEdit.tsx
--------------------------------------------------------------------------------
/src/components/etke.cc/schedules/components/recurring/RecurringCommandsList.tsx:
--------------------------------------------------------------------------------
1 | import AddIcon from "@mui/icons-material/Add";
2 | import { Paper } from "@mui/material";
3 | import { Loading, Button } from "react-admin";
4 | import { DateField } from "react-admin";
5 | import { Datagrid } from "react-admin";
6 | import { ListContextProvider, TextField, TopToolbar, Identifier } from "react-admin";
7 | import { ResourceContextProvider, useList } from "react-admin";
8 | import { useNavigate } from "react-router-dom";
9 |
10 | import { DATE_FORMAT } from "../../../../../utils/date";
11 | import { useRecurringCommands } from "../../hooks/useRecurringCommands";
12 |
13 | const ListActions = () => {
14 | const navigate = useNavigate();
15 |
16 | return (
17 |
18 |
20 | );
21 | };
22 |
23 | const RecurringCommandsList = () => {
24 | const { data, isLoading, error } = useRecurringCommands();
25 |
26 | const listContext = useList({
27 | resource: "recurring",
28 | sort: { field: "scheduled_at", order: "DESC" },
29 | perPage: 50,
30 | data: data || [],
31 | isLoading: isLoading,
32 | });
33 |
34 | if (isLoading) return ;
35 |
36 | return (
37 |
38 |
39 |
40 |
41 | {
44 | if (!record) {
45 | return "";
46 | }
47 |
48 | return `/server_actions/${resource}/${id}`;
49 | }}
50 | >
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | );
60 | };
61 |
62 | export default RecurringCommandsList;
63 |
--------------------------------------------------------------------------------
/src/components/etke.cc/schedules/components/recurring/RecurringDeleteButton.tsx:
--------------------------------------------------------------------------------
1 | import DeleteIcon from "@mui/icons-material/Delete";
2 | import { useTheme } from "@mui/material/styles";
3 | import { useState } from "react";
4 | import { useNotify, useDataProvider, useRecordContext } from "react-admin";
5 | import { Button, Confirm } from "react-admin";
6 | import { useNavigate } from "react-router-dom";
7 |
8 | import { useAppContext } from "../../../../../Context";
9 | import { RecurringCommand } from "../../../../../synapse/dataProvider";
10 |
11 | const RecurringDeleteButton = () => {
12 | const record = useRecordContext() as RecurringCommand;
13 | const { etkeccAdmin } = useAppContext();
14 | const dataProvider = useDataProvider();
15 | const notify = useNotify();
16 | const theme = useTheme();
17 | const navigate = useNavigate();
18 | const [open, setOpen] = useState(false);
19 | const [isDeleting, setIsDeleting] = useState(false);
20 |
21 | const handleClick = e => {
22 | e.stopPropagation();
23 | setOpen(true);
24 | };
25 |
26 | const handleConfirm = async () => {
27 | setIsDeleting(true);
28 | try {
29 | await dataProvider.deleteRecurringCommand(etkeccAdmin, record.id);
30 | notify("recurring_commands.action.delete_success", { type: "success" });
31 | navigate("/server_actions");
32 | } catch (error) {
33 | const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
34 | notify(`Error: ${errorMessage}`, { type: "error" });
35 | } finally {
36 | setIsDeleting(false);
37 | setOpen(false);
38 | }
39 | };
40 |
41 | const handleCancel = () => {
42 | setOpen(false);
43 | };
44 |
45 | return (
46 | <>
47 | }
53 | />
54 |
61 | >
62 | );
63 | };
64 |
65 | export default RecurringDeleteButton;
66 |
--------------------------------------------------------------------------------
/src/components/etke.cc/schedules/components/scheduled/ScheduledCommandEdit.tsx:
--------------------------------------------------------------------------------
1 | import ArrowBackIcon from "@mui/icons-material/ArrowBack";
2 | import { Card, CardContent, CardHeader, Box } from "@mui/material";
3 | import { Typography, Link } from "@mui/material";
4 | import { useState, useEffect } from "react";
5 | import {
6 | Form,
7 | TextInput,
8 | DateTimeInput,
9 | SaveButton,
10 | useNotify,
11 | useDataProvider,
12 | Loading,
13 | Button,
14 | BooleanInput,
15 | SelectInput,
16 | } from "react-admin";
17 | import { useWatch } from "react-hook-form";
18 | import { useParams, useNavigate } from "react-router-dom";
19 |
20 | import ScheduleDeleteButton from "./ScheduledDeleteButton";
21 | import { useAppContext } from "../../../../../Context";
22 | import { ScheduledCommand } from "../../../../../synapse/dataProvider";
23 | import { useServerCommands } from "../../../hooks/useServerCommands";
24 | import { useScheduledCommands } from "../../hooks/useScheduledCommands";
25 |
26 | const transformCommandsToChoices = (commands: Record) => {
27 | return Object.entries(commands).map(([key, value]) => ({
28 | id: key,
29 | name: value.name,
30 | description: value.description,
31 | }));
32 | };
33 |
34 | const ArgumentsField = ({ serverCommands }) => {
35 | const selectedCommand = useWatch({ name: "command" });
36 | const showArgs = selectedCommand && serverCommands[selectedCommand]?.args === true;
37 |
38 | if (!showArgs) return null;
39 |
40 | return ;
41 | };
42 |
43 | const ScheduledCommandEdit = () => {
44 | const { id } = useParams();
45 | const navigate = useNavigate();
46 | const notify = useNotify();
47 | const dataProvider = useDataProvider();
48 | const { etkeccAdmin } = useAppContext();
49 | const [command, setCommand] = useState(null);
50 | const isCreating = typeof id === "undefined";
51 | const [loading, setLoading] = useState(!isCreating);
52 | const { data: scheduledCommands, isLoading: isLoadingList } = useScheduledCommands();
53 | const { serverCommands, isLoading: isLoadingServerCommands } = useServerCommands();
54 | const pageTitle = isCreating ? "Create Scheduled Command" : "Edit Scheduled Command";
55 |
56 | const commandChoices = transformCommandsToChoices(serverCommands);
57 |
58 | useEffect(() => {
59 | if (!isCreating && scheduledCommands) {
60 | const commandToEdit = scheduledCommands.find(cmd => cmd.id === id);
61 | if (commandToEdit) {
62 | setCommand(commandToEdit);
63 | }
64 | setLoading(false);
65 | }
66 | }, [id, scheduledCommands, isCreating]);
67 |
68 | const handleSubmit = async data => {
69 | try {
70 | let result;
71 |
72 | data.scheduled_at = new Date(data.scheduled_at).toISOString();
73 |
74 | if (isCreating) {
75 | result = await dataProvider.createScheduledCommand(etkeccAdmin, data);
76 | notify("scheduled_commands.action.create_success", { type: "success" });
77 | } else {
78 | result = await dataProvider.updateScheduledCommand(etkeccAdmin, {
79 | ...data,
80 | id: id,
81 | });
82 | notify("scheduled_commands.action.update_success", { type: "success" });
83 | }
84 |
85 | navigate("/server_actions");
86 | } catch (error) {
87 | notify("scheduled_commands.action.update_failure", { type: "error" });
88 | }
89 | };
90 |
91 | if (loading || isLoadingList) {
92 | return ;
93 | }
94 |
95 | return (
96 |
97 |
138 | );
139 | };
140 |
141 | export default ScheduledCommandEdit;
142 |
--------------------------------------------------------------------------------
/src/components/etke.cc/schedules/components/scheduled/ScheduledCommandShow.tsx:
--------------------------------------------------------------------------------
1 | import ArrowBackIcon from "@mui/icons-material/ArrowBack";
2 | import { Alert, Box, Card, CardContent, CardHeader, Typography, Link } from "@mui/material";
3 | import { useState, useEffect } from "react";
4 | import {
5 | Loading,
6 | Button,
7 | useDataProvider,
8 | useNotify,
9 | SimpleShowLayout,
10 | TextField,
11 | BooleanField,
12 | DateField,
13 | RecordContextProvider,
14 | } from "react-admin";
15 | import { useParams, useNavigate } from "react-router-dom";
16 |
17 | import ScheduledDeleteButton from "./ScheduledDeleteButton";
18 | import { useAppContext } from "../../../../../Context";
19 | import { ScheduledCommand } from "../../../../../synapse/dataProvider";
20 | import { useScheduledCommands } from "../../hooks/useScheduledCommands";
21 |
22 | const ScheduledCommandShow = () => {
23 | const { id } = useParams();
24 | const navigate = useNavigate();
25 | const [command, setCommand] = useState(null);
26 | const [loading, setLoading] = useState(true);
27 | const { data: scheduledCommands, isLoading: isLoadingList } = useScheduledCommands();
28 |
29 | useEffect(() => {
30 | if (scheduledCommands) {
31 | const commandToShow = scheduledCommands.find(cmd => cmd.id === id);
32 | if (commandToShow) {
33 | setCommand(commandToShow);
34 | }
35 | setLoading(false);
36 | }
37 | }, [id, scheduledCommands]);
38 |
39 | if (loading || isLoadingList) {
40 | return ;
41 | }
42 |
43 | if (!command) {
44 | return null;
45 | }
46 |
47 | return (
48 |
49 |
86 | );
87 | };
88 |
89 | export default ScheduledCommandShow;
90 |
--------------------------------------------------------------------------------
/src/components/etke.cc/schedules/components/scheduled/ScheduledCommandsList.tsx:
--------------------------------------------------------------------------------
1 | import AddIcon from "@mui/icons-material/Add";
2 | import { Paper } from "@mui/material";
3 | import { Loading, Button, useNotify, useRefresh, useCreatePath, useRecordContext } from "react-admin";
4 | import { ResourceContextProvider, useList } from "react-admin";
5 | import { ListContextProvider, TextField } from "react-admin";
6 | import { Datagrid } from "react-admin";
7 | import { BooleanField, DateField, TopToolbar } from "react-admin";
8 | import { useDataProvider } from "react-admin";
9 | import { Identifier } from "react-admin";
10 | import { useNavigate } from "react-router-dom";
11 |
12 | import { useAppContext } from "../../../../../Context";
13 | import { DATE_FORMAT } from "../../../../../utils/date";
14 | import { useScheduledCommands } from "../../hooks/useScheduledCommands";
15 | const ListActions = () => {
16 | const navigate = useNavigate();
17 |
18 | const handleCreate = () => {
19 | navigate("/server_actions/scheduled/create");
20 | };
21 |
22 | return (
23 |
24 | } />
25 |
26 | );
27 | };
28 |
29 | const ScheduledCommandsList = () => {
30 | const { data, isLoading, error } = useScheduledCommands();
31 |
32 | const listContext = useList({
33 | resource: "scheduled",
34 | sort: { field: "scheduled_at", order: "DESC" },
35 | perPage: 50,
36 | data: data || [],
37 | isLoading: isLoading,
38 | });
39 |
40 | if (isLoading) return ;
41 |
42 | return (
43 |
44 |
45 |
46 |
47 | {
50 | if (!record) {
51 | return "";
52 | }
53 |
54 | if (record.is_recurring) {
55 | return `/server_actions/${resource}/${id}/show`;
56 | }
57 |
58 | return `/server_actions/${resource}/${id}`;
59 | }}
60 | >
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | );
70 | };
71 |
72 | export default ScheduledCommandsList;
73 |
--------------------------------------------------------------------------------
/src/components/etke.cc/schedules/components/scheduled/ScheduledDeleteButton.tsx:
--------------------------------------------------------------------------------
1 | import DeleteIcon from "@mui/icons-material/Delete";
2 | import { useTheme } from "@mui/material/styles";
3 | import { useState } from "react";
4 | import { useNotify, useDataProvider, useRecordContext } from "react-admin";
5 | import { Button, Confirm } from "react-admin";
6 | import { useNavigate } from "react-router-dom";
7 |
8 | import { useAppContext } from "../../../../../Context";
9 | import { ScheduledCommand } from "../../../../../synapse/dataProvider";
10 |
11 | const ScheduledDeleteButton = () => {
12 | const record = useRecordContext() as ScheduledCommand;
13 | const { etkeccAdmin } = useAppContext();
14 | const dataProvider = useDataProvider();
15 | const notify = useNotify();
16 | const theme = useTheme();
17 | const navigate = useNavigate();
18 | const [open, setOpen] = useState(false);
19 | const [isDeleting, setIsDeleting] = useState(false);
20 |
21 | const handleClick = e => {
22 | e.stopPropagation();
23 | setOpen(true);
24 | };
25 |
26 | const handleConfirm = async () => {
27 | setIsDeleting(true);
28 | try {
29 | await dataProvider.deleteScheduledCommand(etkeccAdmin, record.id);
30 | notify("scheduled_commands.action.delete_success", { type: "success" });
31 | navigate("/server_actions");
32 | } catch (error) {
33 | const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
34 | notify(`Error: ${errorMessage}`, { type: "error" });
35 | } finally {
36 | setIsDeleting(false);
37 | setOpen(false);
38 | }
39 | };
40 |
41 | const handleCancel = () => {
42 | setOpen(false);
43 | };
44 |
45 | return (
46 | <>
47 | }
53 | />
54 |
61 | >
62 | );
63 | };
64 |
65 | export default ScheduledDeleteButton;
66 |
--------------------------------------------------------------------------------
/src/components/etke.cc/schedules/hooks/useRecurringCommands.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { useDataProvider } from "react-admin";
3 |
4 | import { useAppContext } from "../../../../Context";
5 |
6 | export const useRecurringCommands = () => {
7 | const { etkeccAdmin } = useAppContext();
8 | const dataProvider = useDataProvider();
9 | const { data, isLoading, error } = useQuery({
10 | queryKey: ["recurringCommands"],
11 | queryFn: () => dataProvider.getRecurringCommands(etkeccAdmin),
12 | });
13 |
14 | return { data, isLoading, error };
15 | };
16 |
--------------------------------------------------------------------------------
/src/components/etke.cc/schedules/hooks/useScheduledCommands.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { useDataProvider } from "react-admin";
3 |
4 | import { useAppContext } from "../../../../Context";
5 |
6 | export const useScheduledCommands = () => {
7 | const { etkeccAdmin } = useAppContext();
8 | const dataProvider = useDataProvider();
9 | const { data, isLoading, error } = useQuery({
10 | queryKey: ["scheduledCommands"],
11 | queryFn: () => dataProvider.getScheduledCommands(etkeccAdmin),
12 | });
13 |
14 | return { data, isLoading, error };
15 | };
16 |
--------------------------------------------------------------------------------
/src/components/user-import/ConflictModeCard.tsx:
--------------------------------------------------------------------------------
1 | import { NativeSelect, Paper } from "@mui/material";
2 | import { CardContent, CardHeader, Container } from "@mui/material";
3 | import { useTranslate } from "ra-core";
4 | import { ChangeEventHandler } from "react";
5 |
6 | import { ParsedStats, Progress } from "./types";
7 |
8 | const TranslatableOption = ({ value, text }: { value: string; text: string }) => {
9 | const translate = useTranslate();
10 | return ;
11 | };
12 |
13 | const ConflictModeCard = ({
14 | stats,
15 | importResults,
16 | onConflictModeChanged,
17 | conflictMode,
18 | progress,
19 | }: {
20 | stats: ParsedStats | null;
21 | importResults: any;
22 | onConflictModeChanged: ChangeEventHandler;
23 | conflictMode: string;
24 | progress: Progress;
25 | }) => {
26 | const translate = useTranslate();
27 |
28 | if (!stats || importResults) {
29 | return null;
30 | }
31 |
32 | return (
33 |
34 |
35 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | export default ConflictModeCard;
51 |
--------------------------------------------------------------------------------
/src/components/user-import/ErrorsCard.tsx:
--------------------------------------------------------------------------------
1 | import { Container, Paper, CardHeader, CardContent, Stack, Typography } from "@mui/material";
2 | import { useTranslate } from "ra-core";
3 |
4 | const ErrorsCard = ({ errors }: { errors: string[] }) => {
5 | const translate = useTranslate();
6 |
7 | if (errors.length === 0) {
8 | return null;
9 | }
10 |
11 | return (
12 |
13 |
14 |
22 |
23 |
24 | {errors.map((e, idx) => (
25 |
26 | {e}
27 |
28 | ))}
29 |
30 |
31 |
32 |
33 | );
34 | };
35 |
36 | export default ErrorsCard;
37 |
--------------------------------------------------------------------------------
/src/components/user-import/ResultsCard.tsx:
--------------------------------------------------------------------------------
1 | import ArrowBackIcon from "@mui/icons-material/ArrowBack";
2 | import DownloadIcon from "@mui/icons-material/Download";
3 | import {
4 | Alert,
5 | Box,
6 | CardContent,
7 | CardHeader,
8 | Container,
9 | List,
10 | ListItem,
11 | ListItemText,
12 | Paper,
13 | Stack,
14 | Typography,
15 | } from "@mui/material";
16 | import { Button, Link, useTranslate } from "react-admin";
17 |
18 | import { ImportResult } from "./types";
19 |
20 | const ResultsCard = ({
21 | importResults,
22 | downloadSkippedRecords,
23 | }: {
24 | importResults: ImportResult | null;
25 | downloadSkippedRecords: () => void;
26 | }) => {
27 | const translate = useTranslate();
28 |
29 | if (!importResults) {
30 | return null;
31 | }
32 |
33 | return (
34 |
35 |
36 |
43 |
44 |
45 |
46 | {translate("import_users.cards.results.total", importResults.totalRecordCount)}
47 |
48 |
49 | {translate("import_users.cards.results.successful", importResults.succeededRecords.length)}
50 |
51 |
52 | {importResults.succeededRecords.map(record => (
53 |
54 |
55 |
56 | ))}
57 |
58 | {importResults.skippedRecords.length > 0 && (
59 |
60 |
61 | {translate("import_users.cards.results.skipped", importResults.skippedRecords.length)}
62 |
63 | }
66 | onClick={downloadSkippedRecords}
67 | sx={{ mt: 2 }}
68 | label={translate("import_users.cards.results.download_skipped")}
69 | >
70 |
71 | )}
72 | {importResults.erroredRecords.length > 0 && (
73 |
74 | {translate("import_users.cards.results.skipped", importResults.erroredRecords.length)}
75 |
76 | )}
77 |
78 | {importResults.wasDryRun && (
79 |
80 | {translate("import_users.cards.results.simulated_only")}
81 |
82 | )}
83 |
84 |
85 |
86 |
87 |
88 | } label={translate("ra.action.back")} />
89 |
90 |
91 |
92 | );
93 | };
94 |
95 | export default ResultsCard;
96 |
--------------------------------------------------------------------------------
/src/components/user-import/StartImportCard.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Checkbox, Paper, Container } from "@mui/material";
2 | import { CardActions, FormControlLabel } from "@mui/material";
3 | import { useTranslate } from "ra-core";
4 | import { ChangeEventHandler } from "react";
5 |
6 | import { Progress, ImportLine, ImportResult } from "./types";
7 |
8 | const StartImportCard = ({
9 | csvData,
10 | importResults,
11 | progress,
12 | dryRun,
13 | onDryRunModeChanged,
14 | runImport,
15 | }: {
16 | csvData: ImportLine[];
17 | importResults: ImportResult | null;
18 | progress: Progress;
19 | dryRun: boolean;
20 | onDryRunModeChanged: ChangeEventHandler;
21 | runImport: () => void;
22 | }) => {
23 | const translate = useTranslate();
24 | if (!csvData || csvData.length === 0 || importResults) {
25 | return null;
26 | }
27 |
28 | return (
29 |
30 |
31 |
32 | }
34 | label={translate("import_users.cards.startImport.simulate_only")}
35 | />
36 |
39 | {progress !== null ? (
40 |
41 | {progress.done} of {progress.limit} done
42 |
43 | ) : null}
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | export default StartImportCard;
51 |
--------------------------------------------------------------------------------
/src/components/user-import/StatsCard.tsx:
--------------------------------------------------------------------------------
1 | import { Card, Paper, Stack, CardContent, CardHeader, Container, Typography } from "@mui/material";
2 | import { NativeSelect } from "@mui/material";
3 | import { FormControlLabel } from "@mui/material";
4 | import { Checkbox } from "@mui/material";
5 | import { useTranslate } from "ra-core";
6 | import { ChangeEventHandler } from "react";
7 |
8 | import { ParsedStats, Progress } from "./types";
9 |
10 | const StatsCard = ({
11 | stats,
12 | progress,
13 | importResults,
14 | useridMode,
15 | passwordMode,
16 | onUseridModeChanged,
17 | onPasswordModeChange,
18 | }: {
19 | stats: ParsedStats | null;
20 | progress: Progress;
21 | importResults: any;
22 | useridMode: string;
23 | passwordMode: boolean;
24 | onUseridModeChanged: ChangeEventHandler;
25 | onPasswordModeChange: ChangeEventHandler;
26 | }) => {
27 | const translate = useTranslate();
28 |
29 | if (!stats) {
30 | return null;
31 | }
32 |
33 | if (importResults) {
34 | return null;
35 | }
36 |
37 | return (
38 | <>
39 |
40 |
41 |
42 |
46 |
47 |
48 | {translate("import_users.cards.importstats.users_total", stats.total)}
49 | {translate("import_users.cards.importstats.guest_count", stats.is_guest)}
50 | {translate("import_users.cards.importstats.admin_count", stats.admin)}
51 |
52 |
53 |
57 |
58 |
59 |
60 | {stats.id === stats.total
61 | ? translate("import_users.cards.ids.all_ids_present")
62 | : translate("import_users.cards.ids.count_ids_present", stats.id)}
63 |
64 | {stats.id > 0 && (
65 |
66 |
67 |
68 |
69 | )}
70 |
71 |
72 |
76 |
77 |
78 |
79 | {stats.password === stats.total
80 | ? translate("import_users.cards.passwords.all_passwords_present")
81 | : translate("import_users.cards.passwords.count_passwords_present", stats.password)}
82 |
83 | {stats.password > 0 && (
84 |
87 | }
88 | label={translate("import_users.cards.passwords.use_passwords")}
89 | />
90 | )}
91 |
92 |
93 |
94 |
95 |
96 | >
97 | );
98 | };
99 |
100 | export default StatsCard;
101 |
--------------------------------------------------------------------------------
/src/components/user-import/UploadCard.tsx:
--------------------------------------------------------------------------------
1 | import { CardHeader, CardContent, Container, Link, Stack, Typography, Paper } from "@mui/material";
2 | import { useTranslate } from "ra-core";
3 | import { ChangeEventHandler } from "react";
4 |
5 | import { Progress } from "./types";
6 |
7 | const UploadCard = ({
8 | importResults,
9 | onFileChange,
10 | progress,
11 | }: {
12 | importResults: any;
13 | onFileChange: ChangeEventHandler;
14 | progress: Progress;
15 | }) => {
16 | const translate = useTranslate();
17 | if (importResults) {
18 | return null;
19 | }
20 |
21 | return (
22 |
23 |
24 |
28 |
29 |
30 |
31 | {translate("import_users.cards.upload.explanation")}
32 |
33 | example.csv
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | );
42 | };
43 |
44 | export default UploadCard;
45 |
--------------------------------------------------------------------------------
/src/components/user-import/UserImport.tsx:
--------------------------------------------------------------------------------
1 | import { Stack } from "@mui/material";
2 | import { useTranslate } from "ra-core";
3 | import { Title } from "react-admin";
4 |
5 | import ConflictModeCard from "./ConflictModeCard";
6 | import ErrorsCard from "./ErrorsCard";
7 | import ResultsCard from "./ResultsCard";
8 | import StartImportCard from "./StartImportCard";
9 | import StatsCard from "./StatsCard";
10 | import UploadCard from "./UploadCard";
11 | import useImportFile from "./useImportFile";
12 |
13 | const UserImport = () => {
14 | const {
15 | csvData,
16 | dryRun,
17 | importResults,
18 | progress,
19 | errors,
20 | stats,
21 | conflictMode,
22 | passwordMode,
23 | useridMode,
24 | onFileChange,
25 | onDryRunModeChanged,
26 | runImport,
27 | onConflictModeChanged,
28 | onPasswordModeChange,
29 | onUseridModeChanged,
30 | downloadSkippedRecords,
31 | } = useImportFile();
32 |
33 | const translate = useTranslate();
34 |
35 | return (
36 |
37 |
38 |
39 |
40 |
47 |
56 |
64 |
65 |
66 | );
67 | };
68 |
69 | export default UserImport;
70 |
--------------------------------------------------------------------------------
/src/components/user-import/types.ts:
--------------------------------------------------------------------------------
1 | import { RaRecord } from "react-admin";
2 |
3 | export interface ImportLine {
4 | id: string;
5 | displayname: string;
6 | user_type?: string;
7 | name?: string;
8 | deactivated?: boolean;
9 | is_guest?: boolean;
10 | admin?: boolean;
11 | is_admin?: boolean;
12 | password?: string;
13 | avatar_url?: string;
14 | }
15 |
16 | export interface ParsedStats {
17 | user_types: Record;
18 | is_guest: number;
19 | admin: number;
20 | deactivated: number;
21 | password: number;
22 | avatar_url: number;
23 | id: number;
24 | total: number;
25 | }
26 |
27 | export interface ChangeStats {
28 | total: number;
29 | id: number;
30 | is_guest: number;
31 | admin: number;
32 | password: number;
33 | }
34 |
35 | export type Progress = {
36 | done: number;
37 | limit: number;
38 | } | null;
39 |
40 | export interface ImportResult {
41 | skippedRecords: RaRecord[];
42 | erroredRecords: RaRecord[];
43 | succeededRecords: RaRecord[];
44 | totalRecordCount: number;
45 | changeStats: ChangeStats;
46 | wasDryRun: boolean;
47 | }
48 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { createRoot } from "react-dom/client";
3 |
4 | import { App } from "./App";
5 | import { AppContext } from "./Context";
6 | import { FetchConfig, GetConfig } from "./utils/config";
7 |
8 | await FetchConfig();
9 |
10 | createRoot(document.getElementById("root")).render(
11 |
12 |
13 |
14 |
15 |
16 | );
17 |
--------------------------------------------------------------------------------
/src/jest.setup.ts:
--------------------------------------------------------------------------------
1 | import "@testing-library/jest-dom";
2 |
--------------------------------------------------------------------------------
/src/pages/LoginPage.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from "@testing-library/react";
2 | import polyglotI18nProvider from "ra-i18n-polyglot";
3 | import { AdminContext } from "react-admin";
4 |
5 | import LoginPage from "./LoginPage";
6 | import { AppContext } from "../Context";
7 | import englishMessages from "../i18n/en";
8 |
9 | const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]);
10 | import { act } from "@testing-library/react";
11 |
12 | describe("LoginForm", () => {
13 | it("renders with no restriction to homeserver", async () => {
14 | await act(async () => {
15 | render(
16 |
17 |
18 |
19 | );
20 | });
21 |
22 | screen.getByText(englishMessages.synapseadmin.auth.welcome);
23 | screen.getByRole("combobox", { name: "" });
24 | screen.getByRole("textbox", { name: englishMessages.ra.auth.username });
25 | screen.getByText(englishMessages.ra.auth.password);
26 | const baseUrlInput = screen.getByRole("textbox", {
27 | name: englishMessages.synapseadmin.auth.base_url,
28 | });
29 | expect(baseUrlInput.className.split(" ")).not.toContain("Mui-readOnly");
30 | screen.getByRole("button", { name: englishMessages.ra.auth.sign_in });
31 | });
32 |
33 | it("renders with single restricted homeserver", () => {
34 | render(
35 |
43 |
44 |
45 |
46 |
47 | );
48 |
49 | screen.getByText(englishMessages.synapseadmin.auth.welcome);
50 | screen.getByRole("combobox", { name: "" });
51 | screen.getByRole("textbox", { name: englishMessages.ra.auth.username });
52 | screen.getByText(englishMessages.ra.auth.password);
53 | const baseUrlInput = screen.getByRole("textbox", {
54 | name: englishMessages.synapseadmin.auth.base_url,
55 | });
56 | expect(baseUrlInput.className.split(" ")).toContain("Mui-readOnly");
57 | screen.getByRole("button", { name: englishMessages.ra.auth.sign_in });
58 | });
59 |
60 | it("renders with multiple restricted homeservers", async () => {
61 | render(
62 |
70 |
71 |
72 |
73 |
74 | );
75 |
76 | screen.getByText(englishMessages.synapseadmin.auth.welcome);
77 | screen.getByRole("combobox", { name: "" });
78 | screen.getByRole("textbox", { name: englishMessages.ra.auth.username });
79 | screen.getByText(englishMessages.ra.auth.password);
80 | screen.getByRole("combobox", {
81 | name: englishMessages.synapseadmin.auth.base_url,
82 | });
83 | screen.getByRole("button", { name: englishMessages.ra.auth.sign_in });
84 | });
85 | });
86 |
--------------------------------------------------------------------------------
/src/resources/destinations.tsx:
--------------------------------------------------------------------------------
1 | import AutorenewIcon from "@mui/icons-material/Autorenew";
2 | import DestinationsIcon from "@mui/icons-material/CloudQueue";
3 | import ErrorIcon from "@mui/icons-material/Error";
4 | import FolderSharedIcon from "@mui/icons-material/FolderShared";
5 | import ViewListIcon from "@mui/icons-material/ViewList";
6 | import { get } from "lodash";
7 | import { MouseEvent } from "react";
8 | import {
9 | Button,
10 | Datagrid,
11 | DatagridConfigurable,
12 | DateField,
13 | List,
14 | ListProps,
15 | Pagination,
16 | RaRecord,
17 | ReferenceField,
18 | ReferenceManyField,
19 | ResourceProps,
20 | SearchInput,
21 | Show,
22 | ShowProps,
23 | Tab,
24 | TabbedShowLayout,
25 | TextField,
26 | FunctionField,
27 | TopToolbar,
28 | useRecordContext,
29 | useDelete,
30 | useNotify,
31 | useRefresh,
32 | useTranslate,
33 | DateFieldProps,
34 | } from "react-admin";
35 |
36 | import { DATE_FORMAT } from "../utils/date";
37 |
38 | const DestinationPagination = () => ;
39 |
40 | const destinationFilters = [];
41 |
42 | export const DestinationReconnectButton = () => {
43 | const record = useRecordContext();
44 | const refresh = useRefresh();
45 | const notify = useNotify();
46 | const [handleReconnect, { isLoading }] = useDelete();
47 |
48 | // Reconnect is not required if no error has occurred. (`failure_ts`)
49 | if (!record || !record.failure_ts) return null;
50 |
51 | const handleClick = (e: MouseEvent) => {
52 | // Prevents redirection to the detail page when clicking in the list
53 | e.stopPropagation();
54 |
55 | handleReconnect(
56 | "destinations",
57 | { id: record.id },
58 | {
59 | onSuccess: () => {
60 | notify("ra.notification.updated", {
61 | messageArgs: { smart_count: 1 },
62 | });
63 | refresh();
64 | },
65 | onError: () => {
66 | notify("ra.message.error", { type: "error" });
67 | },
68 | }
69 | );
70 | };
71 |
72 | return (
73 |
76 | );
77 | };
78 |
79 | const DestinationShowActions = () => (
80 |
81 |
82 |
83 | );
84 |
85 | const DestinationTitle = () => {
86 | const record = useRecordContext();
87 | const translate = useTranslate();
88 | return (
89 |
90 | {translate("resources.destinations.name", 1)} {record?.destination}
91 |
92 | );
93 | };
94 |
95 | const RetryDateField = (props: DateFieldProps) => {
96 | const record = useRecordContext(props);
97 | if (props.source && get(record, props.source) === 0) {
98 | return ;
99 | }
100 | return ;
101 | };
102 |
103 | const destinationFieldRender = (record: RaRecord) => {
104 | if (record.retry_last_ts > 0) {
105 | return (
106 | <>
107 |
108 |
109 | {record.destination}
110 | >
111 | );
112 | }
113 | return <> {record.destination} >;
114 | };
115 |
116 | export const DestinationList = (props: ListProps) => {
117 | const record = useRecordContext(props);
118 | return (
119 |
}
123 | sort={{ field: "destination", order: "ASC" }}
124 | perPage={50}
125 | >
126 | `${id}/show/rooms`} bulkActionButtons={false}>
127 |
132 |
138 |
144 |
145 |
149 |
150 |
151 |
152 | );
153 | };
154 |
155 | export const DestinationShow = (props: ShowProps) => {
156 | const translate = useTranslate();
157 | return (
158 | } title={} {...props}>
159 |
160 | }>
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 | } path="rooms">
169 | }
174 | perPage={50}
175 | >
176 | `/rooms/${id}/show`}>
177 |
178 |
179 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 | );
194 | };
195 |
196 | const resource: ResourceProps = {
197 | name: "destinations",
198 | icon: DestinationsIcon,
199 | list: DestinationList,
200 | show: DestinationShow,
201 | };
202 |
203 | export default resource;
204 |
--------------------------------------------------------------------------------
/src/resources/registration_tokens.tsx:
--------------------------------------------------------------------------------
1 | import RegistrationTokenIcon from "@mui/icons-material/ConfirmationNumber";
2 | import {
3 | BooleanInput,
4 | Create,
5 | CreateProps,
6 | Datagrid,
7 | DatagridConfigurable,
8 | DateField,
9 | DateTimeInput,
10 | Edit,
11 | EditProps,
12 | List,
13 | ListProps,
14 | maxValue,
15 | number,
16 | NumberField,
17 | NumberInput,
18 | regex,
19 | ResourceProps,
20 | SaveButton,
21 | SimpleForm,
22 | TextInput,
23 | TextField,
24 | Toolbar,
25 | } from "react-admin";
26 |
27 | import { DATE_FORMAT, dateFormatter, dateParser } from "../utils/date";
28 |
29 | const validateToken = [regex(/^[A-Za-z0-9._~-]{0,64}$/)];
30 | const validateUsesAllowed = [number()];
31 | const validateLength = [number(), maxValue(64)];
32 |
33 | const registrationTokenFilters = [];
34 |
35 | export const RegistrationTokenList = (props: ListProps) => (
36 |
43 |
44 |
45 |
46 |
47 |
48 |
55 |
56 |
57 | );
58 |
59 | export const RegistrationTokenCreate = (props: CreateProps) => (
60 |
61 |
64 | {/* It is possible to create tokens per default without input. */}
65 |
66 |
67 | }
68 | >
69 |
70 |
76 |
77 |
78 |
79 |
80 | );
81 |
82 | export const RegistrationTokenEdit = (props: EditProps) => (
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 | );
93 |
94 | const resource: ResourceProps = {
95 | name: "registration_tokens",
96 | icon: RegistrationTokenIcon,
97 | list: RegistrationTokenList,
98 | edit: RegistrationTokenEdit,
99 | create: RegistrationTokenCreate,
100 | };
101 |
102 | export default resource;
103 |
--------------------------------------------------------------------------------
/src/resources/reports.tsx:
--------------------------------------------------------------------------------
1 | import PageviewIcon from "@mui/icons-material/Pageview";
2 | import ViewListIcon from "@mui/icons-material/ViewList";
3 | import ReportIcon from "@mui/icons-material/Warning";
4 | import {
5 | Datagrid,
6 | DatagridConfigurable,
7 | DateField,
8 | DeleteButton,
9 | List,
10 | ListProps,
11 | NumberField,
12 | Pagination,
13 | ReferenceField,
14 | ResourceProps,
15 | Show,
16 | ShowProps,
17 | Tab,
18 | TabbedShowLayout,
19 | TextField,
20 | TopToolbar,
21 | useRecordContext,
22 | useTranslate,
23 | } from "react-admin";
24 |
25 | import { ReportMediaContent } from "../components/media";
26 | import { DATE_FORMAT } from "../utils/date";
27 |
28 | const ReportPagination = () => ;
29 |
30 | export const ReportShow = (props: ShowProps) => {
31 | const translate = useTranslate();
32 | return (
33 | }>
34 |
35 | }
40 | >
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | } path="detail">
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | );
75 | };
76 |
77 | const ReportShowActions = () => {
78 | const record = useRecordContext();
79 |
80 | return (
81 |
82 |
88 |
89 | );
90 | };
91 |
92 | export const ReportList = (props: ListProps) => (
93 |
} perPage={50} sort={{ field: "received_ts", order: "DESC" }}>
94 |
95 |
96 |
103 |
104 |
105 |
106 |
107 |
108 | );
109 |
110 | const resource: ResourceProps = {
111 | name: "reports",
112 | icon: ReportIcon,
113 | list: ReportList,
114 | show: ReportShow,
115 | };
116 |
117 | export default resource;
118 |
--------------------------------------------------------------------------------
/src/resources/room_directory.tsx:
--------------------------------------------------------------------------------
1 | import RoomDirectoryIcon from "@mui/icons-material/FolderShared";
2 | import { useMutation } from "@tanstack/react-query";
3 | import {
4 | BooleanField,
5 | BulkDeleteButton,
6 | BulkDeleteButtonProps,
7 | Button,
8 | ButtonProps,
9 | DatagridConfigurable,
10 | DeleteButtonProps,
11 | ExportButton,
12 | DeleteButton,
13 | List,
14 | NumberField,
15 | Pagination,
16 | ResourceProps,
17 | SelectColumnsButton,
18 | TextField,
19 | TopToolbar,
20 | useCreate,
21 | useDataProvider,
22 | useListContext,
23 | useNotify,
24 | useTranslate,
25 | useRecordContext,
26 | useRefresh,
27 | useUnselectAll,
28 | } from "react-admin";
29 |
30 | import { MakeAdminBtn } from "./rooms";
31 | import AvatarField from "../components/AvatarField";
32 | const RoomDirectoryPagination = () => ;
33 |
34 | export const RoomDirectoryUnpublishButton = (props: DeleteButtonProps) => {
35 | const translate = useTranslate();
36 |
37 | return (
38 | }
51 | />
52 | );
53 | };
54 |
55 | export const RoomDirectoryBulkUnpublishButton = (props: BulkDeleteButtonProps) => (
56 | }
64 | />
65 | );
66 |
67 | export const RoomDirectoryBulkPublishButton = (props: ButtonProps) => {
68 | const { selectedIds } = useListContext();
69 | const notify = useNotify();
70 | const refresh = useRefresh();
71 | const unselectAllRooms = useUnselectAll("rooms");
72 | const dataProvider = useDataProvider();
73 | const { mutate, isPending } = useMutation({
74 | mutationFn: () =>
75 | dataProvider.createMany("room_directory", {
76 | ids: selectedIds,
77 | data: {},
78 | }),
79 | onSuccess: () => {
80 | notify("resources.room_directory.action.send_success");
81 | unselectAllRooms();
82 | refresh();
83 | },
84 | onError: () =>
85 | notify("resources.room_directory.action.send_failure", {
86 | type: "error",
87 | }),
88 | });
89 |
90 | return (
91 |
94 | );
95 | };
96 |
97 | export const RoomDirectoryPublishButton = (props: ButtonProps) => {
98 | const record = useRecordContext();
99 | const notify = useNotify();
100 | const refresh = useRefresh();
101 | const [create, { isLoading }] = useCreate();
102 |
103 | if (!record) {
104 | return null;
105 | }
106 |
107 | const handleSend = () => {
108 | create(
109 | "room_directory",
110 | { data: { id: record.id } },
111 | {
112 | onSuccess: () => {
113 | notify("resources.room_directory.action.send_success");
114 | refresh();
115 | },
116 | onError: () =>
117 | notify("resources.room_directory.action.send_failure", {
118 | type: "error",
119 | }),
120 | }
121 | );
122 | };
123 |
124 | return (
125 |
128 | );
129 | };
130 |
131 | const RoomDirectoryListActions = () => (
132 |
133 |
134 |
135 |
136 | );
137 |
138 | export const RoomDirectoryList = () => (
139 |
} perPage={50} actions={}>
140 | "/rooms/" + id + "/show"}
142 | bulkActionButtons={}
143 | omit={["room_id", "canonical_alias", "topic"]}
144 | >
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 | );
157 |
158 | const resource: ResourceProps = {
159 | name: "room_directory",
160 | icon: RoomDirectoryIcon,
161 | list: RoomDirectoryList,
162 | };
163 |
164 | export default resource;
165 |
--------------------------------------------------------------------------------
/src/resources/user_media_statistics.tsx:
--------------------------------------------------------------------------------
1 | import PermMediaIcon from "@mui/icons-material/PermMedia";
2 | import {
3 | Datagrid,
4 | DatagridConfigurable,
5 | ExportButton,
6 | List,
7 | ListProps,
8 | NumberField,
9 | Pagination,
10 | ResourceProps,
11 | SearchInput,
12 | TextField,
13 | TopToolbar,
14 | useListContext,
15 | } from "react-admin";
16 |
17 | import { DeleteMediaButton, PurgeRemoteMediaButton } from "../components/media";
18 |
19 | const ListActions = () => {
20 | const { isLoading, total } = useListContext();
21 | return (
22 |
23 |
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | const UserMediaStatsPagination = () => ;
31 |
32 | const userMediaStatsFilters = [];
33 |
34 | export const UserMediaStatsList = (props: ListProps) => (
35 |
}
38 | filters={userMediaStatsFilters}
39 | pagination={}
40 | sort={{ field: "media_length", order: "DESC" }}
41 | perPage={50}
42 | >
43 | "/users/" + id + "/media"} bulkActionButtons={false}>
44 |
45 |
46 |
47 |
48 |
49 |
50 | );
51 |
52 | const resource: ResourceProps = {
53 | name: "user_media_statistics",
54 | icon: PermMediaIcon,
55 | list: UserMediaStatsList,
56 | };
57 |
58 | export default resource;
59 |
--------------------------------------------------------------------------------
/src/synapse/authProvider.test.ts:
--------------------------------------------------------------------------------
1 | import fetchMock from "jest-fetch-mock";
2 | import { HttpError } from "ra-core";
3 |
4 | import authProvider from "./authProvider";
5 |
6 | fetchMock.enableMocks();
7 |
8 | describe("authProvider", () => {
9 | beforeEach(() => {
10 | fetchMock.resetMocks();
11 | localStorage.clear();
12 | });
13 |
14 | describe("login", () => {
15 | it("should successfully login with username and password", async () => {
16 | fetchMock.once(
17 | JSON.stringify({
18 | home_server: "example.com",
19 | user_id: "@user:example.com",
20 | access_token: "foobar",
21 | device_id: "some_device",
22 | })
23 | );
24 |
25 | const ret = await authProvider.login({
26 | base_url: "http://example.com",
27 | username: "@user:example.com",
28 | password: "secret",
29 | });
30 |
31 | expect(ret).toEqual({ redirectTo: "/" });
32 | expect(fetch).toHaveBeenCalledWith("http://example.com/_matrix/client/v3/login", {
33 | body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.password","identifier":{"type":"m.id.user","user":"@user:example.com"},"password":"secret"}',
34 | headers: new Headers({
35 | Accept: "application/json",
36 | "Content-Type": "application/json",
37 | }),
38 | credentials: "same-origin",
39 | method: "POST",
40 | });
41 | expect(localStorage.getItem("base_url")).toEqual("http://example.com");
42 | expect(localStorage.getItem("user_id")).toEqual("@user:example.com");
43 | expect(localStorage.getItem("access_token")).toEqual("foobar");
44 | expect(localStorage.getItem("device_id")).toEqual("some_device");
45 | });
46 | });
47 |
48 | it("should successfully login with token", async () => {
49 | fetchMock.once(
50 | JSON.stringify({
51 | home_server: "example.com",
52 | user_id: "@user:example.com",
53 | access_token: "foobar",
54 | device_id: "some_device",
55 | })
56 | );
57 |
58 | const ret = await authProvider.login({
59 | base_url: "https://example.com/",
60 | loginToken: "login_token",
61 | });
62 |
63 | expect(ret).toEqual({ redirectTo: "/" });
64 | expect(fetch).toHaveBeenCalledWith("https://example.com/_matrix/client/v3/login", {
65 | body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.token","token":"login_token"}',
66 | headers: new Headers({
67 | Accept: "application/json",
68 | "Content-Type": "application/json",
69 | }),
70 | credentials: "same-origin",
71 | method: "POST",
72 | });
73 | expect(localStorage.getItem("base_url")).toEqual("https://example.com");
74 | expect(localStorage.getItem("user_id")).toEqual("@user:example.com");
75 | expect(localStorage.getItem("access_token")).toEqual("foobar");
76 | expect(localStorage.getItem("device_id")).toEqual("some_device");
77 | });
78 |
79 | describe("logout", () => {
80 | it("should remove the access_token from storage", async () => {
81 | localStorage.setItem("base_url", "example.com");
82 | localStorage.setItem("access_token", "foo");
83 | fetchMock.mockResponse(JSON.stringify({}));
84 |
85 | await authProvider.logout(null);
86 |
87 | expect(fetch).toHaveBeenCalledWith("example.com/_matrix/client/v3/logout", {
88 | headers: new Headers({
89 | Accept: "application/json",
90 | "Content-Type": "application/json",
91 | Authorization: "Bearer foo",
92 | }),
93 | method: "POST",
94 | credentials: "same-origin",
95 | user: { authenticated: true, token: "Bearer foo" },
96 | });
97 | expect(localStorage.getItem("access_token")).toBeNull();
98 | });
99 | });
100 |
101 | describe("checkError", () => {
102 | it("should resolve if error.status is not 401 or 403", async () => {
103 | await expect(authProvider.checkError({ status: 200 })).resolves.toBeUndefined();
104 | });
105 |
106 | it("should reject if error.status is 401", async () => {
107 | await expect(
108 | authProvider.checkError(new HttpError("test-error", 401, { errcode: "test-errcode", error: "test-error" }))
109 | ).rejects.toBeDefined();
110 | });
111 |
112 | it("should reject if error.status is 403", async () => {
113 | await expect(
114 | authProvider.checkError(new HttpError("test-error", 403, { errcode: "test-errcode", error: "test-error" }))
115 | ).rejects.toBeDefined();
116 | });
117 | });
118 |
119 | describe("checkAuth", () => {
120 | it("should reject when not logged in", async () => {
121 | await expect(authProvider.checkAuth({})).rejects.toBeUndefined();
122 | });
123 |
124 | it("should resolve when logged in", async () => {
125 | localStorage.setItem("access_token", "foobar");
126 |
127 | await expect(authProvider.checkAuth({})).resolves.toBeUndefined();
128 | });
129 | });
130 |
131 | describe("getPermissions", () => {
132 | it("should do nothing", async () => {
133 | if (authProvider.getPermissions) {
134 | await expect(authProvider.getPermissions(null)).resolves.toBeUndefined();
135 | }
136 | });
137 | });
138 | });
139 |
--------------------------------------------------------------------------------
/src/synapse/dataProvider.test.ts:
--------------------------------------------------------------------------------
1 | import fetchMock from "jest-fetch-mock";
2 |
3 | import dataProvider from "./dataProvider";
4 |
5 | fetchMock.enableMocks();
6 |
7 | beforeEach(() => {
8 | fetchMock.resetMocks();
9 | });
10 |
11 | describe("dataProvider", () => {
12 | localStorage.setItem("base_url", "http://localhost");
13 | localStorage.setItem("access_token", "access_token");
14 |
15 | it("fetches all users", async () => {
16 | fetchMock.mockResponseOnce(
17 | JSON.stringify({
18 | users: [
19 | {
20 | name: "@user_id1:provider",
21 | password_hash: "password_hash1",
22 | is_guest: 0,
23 | admin: 0,
24 | user_type: null,
25 | deactivated: 0,
26 | displayname: "User One",
27 | },
28 | {
29 | name: "@user_id2:provider",
30 | password_hash: "password_hash2",
31 | is_guest: 0,
32 | admin: 1,
33 | user_type: null,
34 | deactivated: 0,
35 | displayname: "User Two",
36 | },
37 | ],
38 | next_token: "100",
39 | total: 200,
40 | })
41 | );
42 |
43 | const users = await dataProvider.getList("users", {
44 | pagination: { page: 1, perPage: 5 },
45 | sort: { field: "title", order: "ASC" },
46 | filter: { author_id: 12 },
47 | });
48 |
49 | expect(users.data[0].id).toEqual("@user_id1:provider");
50 | expect(users.total).toEqual(200);
51 | expect(fetch).toHaveBeenCalledTimes(1);
52 | });
53 |
54 | it("fetches one user", async () => {
55 | fetchMock.mockResponseOnce(
56 | JSON.stringify({
57 | name: "@user_id1:provider",
58 | password: "user_password",
59 | displayname: "User",
60 | threepids: [
61 | {
62 | medium: "email",
63 | address: "user@mail_1.com",
64 | },
65 | {
66 | medium: "email",
67 | address: "user@mail_2.com",
68 | },
69 | ],
70 | avatar_url: "mxc://localhost/user1",
71 | admin: false,
72 | deactivated: false,
73 | })
74 | );
75 |
76 | const user = await dataProvider.getOne("users", { id: "@user_id1:provider" });
77 |
78 | expect(user.data.id).toEqual("@user_id1:provider");
79 | expect(user.data.displayname).toEqual("User");
80 | expect(fetch).toHaveBeenCalledTimes(1);
81 | });
82 | });
83 |
--------------------------------------------------------------------------------
/src/synapse/matrix.test.ts:
--------------------------------------------------------------------------------
1 | import { isValidBaseUrl, splitMxid } from "./matrix";
2 |
3 | describe("splitMxid", () => {
4 | it("splits valid MXIDs", () =>
5 | expect(splitMxid("@name:domain.tld")).toEqual({
6 | name: "name",
7 | domain: "domain.tld",
8 | }));
9 | it("rejects invalid MXIDs", () => expect(splitMxid("foo")).toBeUndefined());
10 | });
11 |
12 | describe("isValidBaseUrl", () => {
13 | it("accepts a http URL", () => expect(isValidBaseUrl("http://foo.bar")).toBeTruthy());
14 | it("accepts a https URL", () => expect(isValidBaseUrl("https://foo.bar")).toBeTruthy());
15 | it("accepts a valid URL with port", () => expect(isValidBaseUrl("https://foo.bar:1234")).toBeTruthy());
16 | it("rejects undefined base URLs", () => expect(isValidBaseUrl(undefined)).toBeFalsy());
17 | it("rejects null base URLs", () => expect(isValidBaseUrl(null)).toBeFalsy());
18 | it("rejects empty base URLs", () => expect(isValidBaseUrl("")).toBeFalsy());
19 | it("rejects non-string base URLs", () => expect(isValidBaseUrl({})).toBeFalsy());
20 | it("rejects base URLs without protocol", () => expect(isValidBaseUrl("foo.bar")).toBeFalsy());
21 | it("rejects base URLs with path", () => expect(isValidBaseUrl("http://foo.bar/path")).toBeFalsy());
22 | it("rejects invalid base URLs", () => expect(isValidBaseUrl("http:/foo.bar")).toBeFalsy());
23 | });
24 |
--------------------------------------------------------------------------------
/src/synapse/matrix.ts:
--------------------------------------------------------------------------------
1 | import { Identifier, fetchUtils } from "react-admin";
2 |
3 | import { isMXID } from "../utils/mxid";
4 |
5 | export const splitMxid = mxid => {
6 | const re = /^@(?[a-zA-Z0-9._=\-/]+):(?[a-zA-Z0-9\-.]+\.[a-zA-Z]+)$/;
7 | return re.exec(mxid)?.groups;
8 | };
9 |
10 | export const isValidBaseUrl = baseUrl => /^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?$/.test(baseUrl);
11 |
12 | /**
13 | * Resolve the homeserver URL using the well-known lookup
14 | * @param domain the domain part of an MXID
15 | * @returns homeserver base URL
16 | */
17 | export const getWellKnownUrl = async domain => {
18 | const wellKnownUrl = `https://${domain}/.well-known/matrix/client`;
19 | try {
20 | const response = await fetchUtils.fetchJson(wellKnownUrl, { method: "GET" });
21 | return response.json["m.homeserver"].base_url;
22 | } catch {
23 | // if there is no .well-known entry, return the domain itself
24 | return `https://${domain}`;
25 | }
26 | };
27 |
28 | /**
29 | * Get synapse server version
30 | * @param base_url the base URL of the homeserver
31 | * @returns server version
32 | */
33 | export const getServerVersion = async baseUrl => {
34 | const versionUrl = `${baseUrl}/_synapse/admin/v1/server_version`;
35 | const response = await fetchUtils.fetchJson(versionUrl, { method: "GET" });
36 | return response.json.server_version;
37 | };
38 |
39 | /** Get supported Matrix features */
40 | export const getSupportedFeatures = async baseUrl => {
41 | const versionUrl = `${baseUrl}/_matrix/client/versions`;
42 | const response = await fetchUtils.fetchJson(versionUrl, { method: "GET" });
43 | return response.json;
44 | };
45 |
46 | /**
47 | * Get supported login flows
48 | * @param baseUrl the base URL of the homeserver
49 | * @returns array of supported login flows
50 | */
51 | export const getSupportedLoginFlows = async baseUrl => {
52 | const loginFlowsUrl = `${baseUrl}/_matrix/client/v3/login`;
53 | const response = await fetchUtils.fetchJson(loginFlowsUrl, { method: "GET" });
54 | return response.json.flows;
55 | };
56 |
--------------------------------------------------------------------------------
/src/utils/config.ts:
--------------------------------------------------------------------------------
1 | export interface Config {
2 | restrictBaseUrl: string | string[];
3 | corsCredentials: string;
4 | asManagedUsers: RegExp[];
5 | menu: MenuItem[];
6 | etkeccAdmin?: string;
7 | }
8 |
9 | export interface MenuItem {
10 | label: string;
11 | icon: string;
12 | url: string;
13 | }
14 |
15 | export const WellKnownKey = "cc.etke.synapse-admin";
16 |
17 | // current configuration
18 | let config: Config = {
19 | restrictBaseUrl: "",
20 | corsCredentials: "same-origin",
21 | asManagedUsers: [],
22 | menu: [],
23 | etkeccAdmin: "",
24 | };
25 |
26 | export const FetchConfig = async () => {
27 | // load config.json and honor vite base url (import.meta.env.BASE_URL)
28 | // if that url doesn't have a trailing slash - add it
29 | let configJSONUrl = "config.json";
30 | if (import.meta.env.BASE_URL) {
31 | configJSONUrl = `${import.meta.env.BASE_URL.replace(/\/?$/, "/")}config.json`;
32 | }
33 | try {
34 | const resp = await fetch(configJSONUrl);
35 | const configJSON = await resp.json();
36 | console.log("Loaded", configJSONUrl, configJSON);
37 | LoadConfig(configJSON);
38 | } catch (e) {
39 | console.error(e);
40 | }
41 |
42 | let protocol = "https";
43 | const baseURL = localStorage.getItem("base_url");
44 | if (baseURL && baseURL.startsWith("http://")) {
45 | protocol = "http";
46 | }
47 |
48 | // if home_server is set, try to load https://home_server/.well-known/matrix/client
49 | const homeserver = localStorage.getItem("home_server");
50 | if (homeserver) {
51 | try {
52 | const resp = await fetch(`${protocol}://${homeserver}/.well-known/matrix/client`);
53 | const configWK = await resp.json();
54 | if (!configWK[WellKnownKey]) {
55 | console.log(
56 | `Loaded ${protocol}://${homeserver}.well-known/matrix/client, but it doesn't contain ${WellKnownKey} key, skipping`,
57 | configWK
58 | );
59 | } else {
60 | console.log(`Loaded ${protocol}://${homeserver}.well-known/matrix/client`, configWK);
61 | LoadConfig(configWK[WellKnownKey]);
62 | }
63 | } catch (e) {
64 | console.log(`${protocol}://${homeserver}/.well-known/matrix/client not found, skipping`, e);
65 | }
66 | }
67 | };
68 |
69 | // load config from context
70 | // we deliberately processing each key separately to avoid overwriting the whole config, loosing some keys, and messing
71 | // with typescript types
72 | export const LoadConfig = (context: any) => {
73 | if (context?.restrictBaseUrl) {
74 | config.restrictBaseUrl = context.restrictBaseUrl as string | string[];
75 | }
76 |
77 | if (context?.corsCredentials) {
78 | config.corsCredentials = context.corsCredentials;
79 | }
80 |
81 | if (context?.asManagedUsers) {
82 | config.asManagedUsers = context.asManagedUsers.map((regex: string) => new RegExp(regex));
83 | }
84 |
85 | let menu: MenuItem[] = [];
86 | if (context?.menu) {
87 | menu = context.menu as MenuItem[];
88 | }
89 | if (menu.length > 0) {
90 | config.menu = menu;
91 | }
92 |
93 | if (context?.etkeccAdmin) {
94 | config.etkeccAdmin = context.etkeccAdmin;
95 | }
96 | };
97 |
98 | // get config
99 | export const GetConfig = (): Config => {
100 | return config;
101 | };
102 |
103 | // clear config
104 | export const ClearConfig = () => {
105 | // config.json
106 | config = {} as Config;
107 | // session
108 | localStorage.clear();
109 | };
110 |
--------------------------------------------------------------------------------
/src/utils/date.ts:
--------------------------------------------------------------------------------
1 | export const DATE_FORMAT: Intl.DateTimeFormatOptions = {
2 | year: "numeric",
3 | month: "2-digit",
4 | day: "2-digit",
5 | hour: "2-digit",
6 | minute: "2-digit",
7 | second: "2-digit",
8 | };
9 |
10 | export const dateParser = (v: string | number | Date): number => {
11 | const d = new Date(v);
12 | return d.getTime();
13 | };
14 |
15 | export const dateFormatter = (v: string | number | Date | undefined | null): string => {
16 | if (v === undefined || v === null) return "";
17 | const d = new Date(v);
18 |
19 | const pad = "00";
20 | const year = d.getFullYear().toString();
21 | const month = (pad + (d.getMonth() + 1).toString()).slice(-2);
22 | const day = (pad + d.getDate().toString()).slice(-2);
23 | const hour = (pad + d.getHours().toString()).slice(-2);
24 | const minute = (pad + d.getMinutes().toString()).slice(-2);
25 |
26 | // target format yyyy-MM-ddThh:mm
27 | return `${year}-${month}-${day}T${hour}:${minute}`;
28 | };
29 |
30 | // assuming date is in format "2025-02-26 20:52:00" where no timezone is specified
31 | export const getTimeSince = (dateToCompare: string) => {
32 | const nowUTC = new Date().getTime();
33 | if (!dateToCompare.includes("Z")) {
34 | dateToCompare = dateToCompare + "Z";
35 | }
36 | const past = new Date(dateToCompare);
37 |
38 | const pastUTC = past.getTime();
39 | const diffInMs = nowUTC - pastUTC;
40 |
41 | const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
42 |
43 | if (diffInMinutes < 1) return "a couple of seconds";
44 | if (diffInMinutes === 1) return "1 minute";
45 | if (diffInMinutes < 60) return `${diffInMinutes} minutes`;
46 | if (diffInMinutes < 120) return "1 hour";
47 | if (diffInMinutes < 24 * 60) return `${Math.floor(diffInMinutes / 60)} hours`;
48 | if (diffInMinutes < 48 * 60) return "1 day";
49 | if (diffInMinutes < 7 * 24 * 60) return `${Math.floor(diffInMinutes / (24 * 60))} days`;
50 | if (diffInMinutes < 14 * 24 * 60) return "1 week";
51 | if (diffInMinutes < 30 * 24 * 60) return `${Math.floor(diffInMinutes / (7 * 24 * 60))} weeks`;
52 | if (diffInMinutes < 60 * 24 * 60) return "1 month";
53 | return `${Math.floor(diffInMinutes / (30 * 24 * 60))} months`;
54 | };
55 |
--------------------------------------------------------------------------------
/src/utils/decodeURLComponent.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Decode a URI component, and if it fails, return the original string.
3 | * @param str The string to decode.
4 | * @returns The decoded string, or the original string if decoding fails.
5 | * @example decodeURIComponent("Hello%20World") // "Hello World"
6 | */
7 | const decodeURLComponent = (str: any): any => {
8 | try {
9 | return decodeURIComponent(str);
10 | } catch (e) {
11 | return str;
12 | }
13 | };
14 |
15 | export default decodeURLComponent;
16 |
--------------------------------------------------------------------------------
/src/utils/error.ts:
--------------------------------------------------------------------------------
1 | export interface MatrixError {
2 | errcode: string;
3 | error: string;
4 | }
5 |
6 | export const displayError = (errcode: string, status: number, message: string) => `${errcode} (${status}): ${message}`;
7 |
--------------------------------------------------------------------------------
/src/utils/fetchMedia.ts:
--------------------------------------------------------------------------------
1 | export const getServerAndMediaIdFromMxcUrl = (mxcUrl: string): { serverName: string; mediaId: string } => {
2 | const re = /^mxc:\/\/([^/]+)\/([\w-]+)$/;
3 | const ret = re.exec(mxcUrl);
4 | if (ret == null) {
5 | throw new Error("Invalid mxcUrl");
6 | }
7 | const serverName = ret[1];
8 | const mediaId = ret[2];
9 | return { serverName, mediaId };
10 | };
11 |
12 | export type MediaType = "thumbnail" | "original";
13 |
14 | export const fetchAuthenticatedMedia = async (mxcUrl: string, type: MediaType): Promise => {
15 | const homeserver = localStorage.getItem("base_url");
16 | const accessToken = localStorage.getItem("access_token");
17 |
18 | const { serverName, mediaId } = getServerAndMediaIdFromMxcUrl(mxcUrl);
19 | if (!serverName || !mediaId) {
20 | throw new Error("Invalid mxcUrl");
21 | }
22 |
23 | let url = "";
24 | if (type === "thumbnail") {
25 | // ref: https://spec.matrix.org/latest/client-server-api/#thumbnails
26 | url = `${homeserver}/_matrix/client/v1/media/thumbnail/${serverName}/${mediaId}?width=320&height=240&method=scale`;
27 | } else if (type === "original") {
28 | url = `${homeserver}/_matrix/client/v1/media/download/${serverName}/${mediaId}`;
29 | } else {
30 | throw new Error("Invalid authenticated media type");
31 | }
32 |
33 | const response = await fetch(`${url}`, {
34 | headers: {
35 | authorization: `Bearer ${accessToken}`,
36 | },
37 | });
38 |
39 | return response;
40 | };
41 |
--------------------------------------------------------------------------------
/src/utils/icons.ts:
--------------------------------------------------------------------------------
1 | import AnnouncementIcon from "@mui/icons-material/Announcement";
2 | import EngineeringIcon from "@mui/icons-material/Engineering";
3 | import HelpCenterIcon from "@mui/icons-material/HelpCenter";
4 | import OpenInNewIcon from "@mui/icons-material/OpenInNew";
5 | import PieChartIcon from "@mui/icons-material/PieChart";
6 | import PriceCheckIcon from "@mui/icons-material/PriceCheck";
7 | import RestartAltIcon from "@mui/icons-material/RestartAlt";
8 | import RouterIcon from "@mui/icons-material/Router";
9 | import SupportAgentIcon from "@mui/icons-material/SupportAgent";
10 | import UpgradeIcon from "@mui/icons-material/Upgrade";
11 |
12 | export const Icons = {
13 | Announcement: AnnouncementIcon,
14 | Engineering: EngineeringIcon,
15 | HelpCenter: HelpCenterIcon,
16 | SupportAgent: SupportAgentIcon,
17 | Default: OpenInNewIcon,
18 | PieChart: PieChartIcon,
19 | Upgrade: UpgradeIcon,
20 | Router: RouterIcon,
21 | PriceCheck: PriceCheckIcon,
22 | RestartAlt: RestartAltIcon,
23 | // Add more icons as needed
24 | };
25 |
26 | export const DefaultIcon = Icons.Default;
27 |
--------------------------------------------------------------------------------
/src/utils/mxid.ts:
--------------------------------------------------------------------------------
1 | import { Identifier } from "ra-core";
2 |
3 | import { GetConfig } from "../utils/config";
4 |
5 | const mxidPattern = /^@[^@:]+:[^@:]+$/;
6 |
7 | /*
8 | * Check if id is a valid Matrix ID (user)
9 | * @param id The ID to check
10 | * @returns Whether the ID is a valid Matrix ID
11 | */
12 | export const isMXID = (id: string | Identifier): boolean => mxidPattern.test(id as string);
13 |
14 | /**
15 | * Check if a user is managed by an application service
16 | * @param id The user ID to check
17 | * @returns Whether the user is managed by an application service
18 | */
19 | export const isASManaged = (id: string | Identifier): boolean => {
20 | const managedUsers = GetConfig().asManagedUsers;
21 | if (!managedUsers) {
22 | return false;
23 | }
24 | return managedUsers.some(regex => regex.test(id as string));
25 | };
26 |
27 | /**
28 | * Generate a random MXID for current homeserver
29 | * @returns full MXID as string
30 | */
31 | export function generateRandomMXID(): string {
32 | const homeserver = localStorage.getItem("home_server");
33 | const characters = "0123456789abcdefghijklmnopqrstuvwxyz";
34 | const localpart = Array.from(crypto.getRandomValues(new Uint32Array(8)))
35 | .map(x => characters[x % characters.length])
36 | .join("");
37 | return `@${localpart}:${homeserver}`;
38 | }
39 |
40 | /**
41 | * Return the full MXID from an arbitrary input
42 | * @param input the input string
43 | * @returns full MXID as string
44 | */
45 | export function returnMXID(input: string | Identifier): string {
46 | const inputStr = input as string;
47 | const homeserver = localStorage.getItem("home_server") || "";
48 |
49 | // when homeserver is not (just) a domain name, but a domain:port or even an IPv6 address
50 | if (homeserver != "" && inputStr.endsWith(homeserver) && inputStr.startsWith("@")) {
51 | return inputStr; // Already a valid MXID
52 | }
53 |
54 | // Check if the input already looks like a valid MXID (i.e., starts with "@" and contains ":")
55 | if (isMXID(input)) {
56 | return inputStr; // Already a valid MXID
57 | }
58 |
59 | // If input is not a valid MXID, assume it's a localpart and construct the MXID
60 | const localpart = typeof input === "string" && inputStr.startsWith("@") ? inputStr.slice(1) : inputStr;
61 | return `@${localpart}:${homeserver}`;
62 | }
63 |
--------------------------------------------------------------------------------
/src/utils/password.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Generate a random user password
3 | * @returns a new random password as string
4 | */
5 | export function generateRandomPassword(length = 64): string {
6 | const characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~`!@#$%^&*()_-+={[}]|:;'.?/<>,";
7 | return Array.from(crypto.getRandomValues(new Uint32Array(length)))
8 | .map(x => characters[x % characters.length])
9 | .join("");
10 | }
11 |
--------------------------------------------------------------------------------
/testdata/element/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "default_hs_url": "http://localhost:8008",
3 | "default_is_url": "https://vector.im",
4 | "integrations_ui_url": "https://scalar.vector.im/",
5 | "integrations_rest_url": "https://scalar.vector.im/api",
6 | "bug_report_endpoint_url": "https://riot.im/bugreports/submit",
7 | "enableLabs": true
8 | }
9 |
10 |
--------------------------------------------------------------------------------
/testdata/element/nginx.conf:
--------------------------------------------------------------------------------
1 | worker_processes 1;
2 | error_log /var/log/nginx/error.log warn;
3 | pid /tmp/nginx.pid;
4 | events {
5 | worker_connections 1024;
6 | }
7 | http {
8 | client_body_temp_path /tmp/client_body_temp;
9 | proxy_temp_path /tmp/proxy_temp;
10 | fastcgi_temp_path /tmp/fastcgi_temp;
11 | uwsgi_temp_path /tmp/uwsgi_temp;
12 | scgi_temp_path /tmp/scgi_temp;
13 | include /etc/nginx/mime.types;
14 | default_type application/octet-stream;
15 |
16 | log_format main '$remote_addr - $remote_user [$time_local] "$request" '
17 | '$status $body_bytes_sent "$http_referer" '
18 | '"$http_user_agent" "$http_x_forwarded_for"';
19 | access_log /var/log/nginx/access.log main;
20 | sendfile on;
21 | keepalive_timeout 65;
22 | server {
23 | listen 8080;
24 | server_name localhost;
25 | location / {
26 | root /usr/share/nginx/html;
27 | index index.html index.htm;
28 | }
29 | error_page 500 502 503 504 /50x.html;
30 | location = /50x.html {
31 | root /usr/share/nginx/html;
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/testdata/synapse/homeserver.yaml:
--------------------------------------------------------------------------------
1 | account_threepid_delegates:
2 | msisdn: ''
3 | alias_creation_rules:
4 | - action: allow
5 | alias: '*'
6 | room_id: '*'
7 | user_id: '*'
8 | allow_guest_access: false
9 | allow_public_rooms_over_federation: true
10 | allow_public_rooms_without_auth: true
11 | app_service_config_files: []
12 | autocreate_auto_join_rooms: true
13 | background_updates: null
14 | caches:
15 | global_factor: 0.5
16 | per_cache_factors: null
17 | cas_config: null
18 | database:
19 | args:
20 | cp_max: 10
21 | cp_min: 5
22 | database: synapse
23 | host: postgres
24 | password: synapse
25 | port: 5432
26 | user: synapse
27 | name: psycopg2
28 | txn_limit: 0
29 | default_room_version: '10'
30 | disable_msisdn_registration: true
31 | email:
32 | enable_media_repo: true
33 | enable_metrics: false
34 | enable_registration: false
35 | enable_registration_captcha: false
36 | enable_registration_without_verification: false
37 | enable_room_list_search: true
38 | encryption_enabled_by_default_for_room_type: 'off'
39 | event_cache_size: 100K
40 | federation_rr_transactions_per_room_per_second: 50
41 | form_secret: sLKKoFMsQUZgLAW0vU1PQQ8ca1POGMDheurGtKW0uJ20iGqtxR9O7JQ6Knvs44Wi
42 | include_profile_data_on_invite: true
43 | instance_map: {}
44 | limit_profile_requests_to_users_who_share_rooms: false
45 | limit_remote_rooms: null
46 | listeners:
47 | - bind_addresses:
48 | - '::'
49 | port: 8008
50 | resources:
51 | - compress: false
52 | names:
53 | - client
54 | tls: false
55 | type: http
56 | x_forwarded: true
57 | log_config: /config/synapse.log.config
58 | macaroon_secret_key: Lg8DxGGfy95J367eVJZHLxmqP9XtN4FKdKxWpPvBS3mhviq9at8sw7KHRPkGmyqE
59 | manhole_settings: null
60 | max_spider_size: 10M
61 | max_upload_size: 1024M
62 | media_retention:
63 | local_media_lifetime: 30d
64 | remote_media_lifetime: 7d
65 | media_storage_providers: []
66 | media_store_path: /media-store
67 | metrics_flags: null
68 | modules: []
69 | oembed: null
70 | oidc_providers: null
71 | old_signing_keys: null
72 | opentracing: null
73 | password_config:
74 | enabled: true
75 | localdb_enabled: true
76 | pepper: zfvnYqxe3GTkdJ9BlfZiAqy2zMsjOg02uBTEiWLp2hjQGqlDw33pTSTplE6HoWlF
77 | policy: null
78 | pid_file: /homeserver.pid
79 | presence:
80 | enabled: true
81 | public_baseurl: http://localhost:8008/
82 | push:
83 | include_content: true
84 | rc_admin_redaction:
85 | burst_count: 50
86 | per_second: 1
87 | rc_federation:
88 | concurrent: 3
89 | reject_limit: 50
90 | sleep_delay: 500
91 | sleep_limit: 10
92 | window_size: 1000
93 | rc_invites:
94 | per_issuer:
95 | burst_count: 10
96 | per_second: 0.3
97 | per_room:
98 | burst_count: 10
99 | per_second: 0.3
100 | per_user:
101 | burst_count: 5
102 | per_second: 0.003
103 | rc_joins:
104 | local:
105 | burst_count: 10
106 | per_second: 0.1
107 | remote:
108 | burst_count: 10
109 | per_second: 0.01
110 | rc_login:
111 | account:
112 | burst_count: 3
113 | per_second: 0.17
114 | address:
115 | burst_count: 3
116 | per_second: 0.17
117 | failed_attempts:
118 | burst_count: 3
119 | per_second: 0.17
120 | rc_message:
121 | burst_count: 10
122 | per_second: 0.2
123 | rc_registration:
124 | burst_count: 3
125 | per_second: 0.17
126 | recaptcha_private_key: ''
127 | recaptcha_public_key: ''
128 | redaction_retention_period: 5m
129 | redis:
130 | enabled: false
131 | host: null
132 | password: null
133 | port: 6379
134 | registration_requires_token: false
135 | registration_shared_secret: jBUKJozByo8s3bvKtYFpB350ZAnxGlzXsDpAZkgOFJuQfKAFHhqbc2dw8D54u4T9
136 | report_stats: false
137 | require_auth_for_profile_requests: false
138 | retention:
139 | enabled: true
140 | purge_jobs:
141 | - interval: 12h
142 | room_list_publication_rules:
143 | - action: allow
144 | alias: '*'
145 | room_id: '*'
146 | user_id: '*'
147 | room_prejoin_state: null
148 | saml2_config:
149 | sp_config: null
150 | user_mapping_provider:
151 | config: null
152 | server_name: synapse:8008
153 | signing_key_path: /config/synapse.signing.key
154 | spam_checker: []
155 | sso: null
156 | stats: null
157 | stream_writers: {}
158 | templates: null
159 | tls_certificate_path: null
160 | tls_private_key_path: null
161 | trusted_key_servers:
162 | - server_name: matrix.org
163 | turn_allow_guests: false
164 | ui_auth: null
165 | url_preview_accept_language:
166 | - en-US
167 | - en
168 | url_preview_enabled: true
169 | url_preview_ip_range_blacklist:
170 | - 127.0.0.0/8
171 | - 10.0.0.0/8
172 | - 172.16.0.0/12
173 | - 192.168.0.0/16
174 | - 100.64.0.0/10
175 | - 192.0.0.0/24
176 | - 169.254.0.0/16
177 | - 192.88.99.0/24
178 | - 198.18.0.0/15
179 | - 192.0.2.0/24
180 | - 198.51.100.0/24
181 | - 203.0.113.0/24
182 | - 224.0.0.0/4
183 | - ::1/128
184 | - fe80::/10
185 | - fc00::/7
186 | - 2001:db8::/32
187 | - ff00::/8
188 | - fec0::/10
189 | user_directory: null
190 | user_ips_max_age: 5m
191 |
192 |
--------------------------------------------------------------------------------
/testdata/synapse/synapse.log.config:
--------------------------------------------------------------------------------
1 | version: 1
2 | formatters:
3 | precise:
4 | format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
5 | filters:
6 | context:
7 | (): synapse.util.logcontext.LoggingContextFilter
8 | request: ""
9 | handlers:
10 | console:
11 | class: logging.StreamHandler
12 | formatter: precise
13 | filters: [context]
14 | loggers:
15 | synapse:
16 | level: INFO
17 | shared_secret_authenticator:
18 | level: INFO
19 | rest_auth_provider:
20 | level: INFO
21 | synapse.storage.SQL:
22 | # beware: increasing this to DEBUG will make synapse log sensitive
23 | # information such as access tokens.
24 | level: INFO
25 | root:
26 | level: INFO
27 | handlers: [console]
28 |
29 |
--------------------------------------------------------------------------------
/testdata/synapse/synapse.signing.key:
--------------------------------------------------------------------------------
1 | ed25519 a_FswB rsh+VxdR4YUv6rFM6393VmSEJJxzaDrdwlVwLe2rcRo
2 |
--------------------------------------------------------------------------------
/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": ["./**/*.ts", "./**/*.tsx"]
4 | }
5 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | // prettier-ignore
2 | {
3 | "compilerOptions": {
4 | /* Basic Options */
5 | "target": "ESNext" /* Specify ECMAScript target version */,
6 | "module": "ESNext" /* Specify module code generation */,
7 | "lib": ["DOM", "DOM.Iterable", "ESNext"] /* Specify library files to be included in the compilation. */,
8 | "allowJs": false /* Allow javascript files to be compiled. */,
9 | // "checkJs": true, /* Report errors in .js files. */
10 | "jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
11 | "declaration": true /* Generates corresponding '.d.ts' file. */,
12 | "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */,
13 | "sourceMap": true /* Generates corresponding '.map' file. */,
14 | // "outFile": "./", /* Concatenate and emit output to single file. */
15 | // "outDir": "./lib", /* Redirect output structure to the directory. */
16 | "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
17 | // "composite": true, /* Enable project compilation */
18 | // "removeComments": true, /* Do not emit comments to output. */
19 | "noEmit": true, /* Do not emit outputs. */
20 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
21 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
22 | "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
23 |
24 | /* Strict Type-Checking Options */
25 | "strict": true /* Enable all strict type-checking options. */,
26 | "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */,
27 | "strictNullChecks": true, /* Enable strict null checks. */
28 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
29 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
30 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
31 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
32 |
33 | /* Additional Checks */
34 | // "noUnusedLocals": true, /* Report errors on unused locals. */
35 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
36 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
37 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
38 |
39 | /* Module Resolution Options */
40 | "moduleResolution": "Bundler" /* Specify module resolution strategy */,
41 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
42 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
43 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
44 | // "typeRoots": [], /* List of folders to include type definitions from. */
45 | "types": ["vite/client"], /* Type declaration files to be included in compilation. */
46 | "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */,
47 | "esModuleInterop": false /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
48 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
49 | "resolveJsonModule": true,
50 |
51 | /* Source Map Options */
52 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
53 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
54 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
55 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
56 |
57 | /* Experimental Options */
58 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
59 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
60 | "skipLibCheck": false
61 | },
62 | "include": ["src"],
63 | "references": [{ "path": "./tsconfig.vite.json" }]
64 | }
65 |
--------------------------------------------------------------------------------
/tsconfig.vite.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "esnext",
5 | "moduleResolution": "node"
6 | },
7 | "include": ["vite.config.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from "@vitejs/plugin-react";
2 | import { defineConfig } from "vite";
3 | import { vitePluginVersionMark } from "vite-plugin-version-mark";
4 |
5 | export default defineConfig({
6 | base: "./",
7 | build: {
8 | target: "esnext",
9 | },
10 | plugins: [
11 | react(),
12 | vitePluginVersionMark({
13 | name: "Synapse Admin",
14 | command: 'git describe --tags || git rev-parse --short HEAD || echo "${SYNAPSE_ADMIN_VERSION:-unknown}"',
15 | ifMeta: false,
16 | ifLog: false,
17 | ifGlobal: true,
18 | outputFile: version => ({
19 | path: "manifest.json",
20 | content: JSON.stringify({
21 | name: "Synapse Admin",
22 | version: version,
23 | description: "Synapse Admin is an admin console for synapse Matrix homeserver with additional features.",
24 | categories: ["productivity", "utilities"],
25 | orientation: "landscape",
26 | icons: [
27 | {
28 | src: "favicon.ico",
29 | sizes: "32x32",
30 | type: "image/x-icon",
31 | },
32 | {
33 | src: "images/logo.webp",
34 | sizes: "512x512",
35 | type: "image/webp",
36 | purpose: "any maskable",
37 | },
38 | ],
39 | start_url: ".",
40 | display: "standalone",
41 | theme_color: "#000000",
42 | background_color: "#ffffff",
43 | }),
44 | }),
45 | }),
46 | ],
47 | });
48 |
--------------------------------------------------------------------------------