Compare commits

..

205 Commits

Author SHA1 Message Date
Dirk Klimpel
e15411a04c
chore: bump react-admin to 5.8 and mui to 7.1 (#679) 2025-06-02 14:50:28 +02:00
Dirk Klimpel
3902dcd3d1
fix(Dockerfile): FROM and AS both in uppercase, FromAsCasing (#680) 2025-06-02 14:48:44 +02:00
Dirk Klimpel
ef8ae9b38f
fix: Start if BASE_URL is not defined (#677) 2025-05-30 09:01:22 +02:00
Dirk Klimpel
7a286ad506
Downgrade react-router to 6.27.0 to fix import users (#678) 2025-05-30 08:58:39 +02:00
dependabot[bot]
fa3f2437a3
Bump softprops/action-gh-release from 2.0.8 to 2.2.2 (#660)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.0.8 to 2.2.2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](c062e08bd5...da05d55257)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 2.2.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-20 17:35:55 +02:00
dependabot[bot]
8dc5238fcb
Bump JamesIves/github-pages-deploy-action from 4.6.8 to 4.7.3 (#659)
Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.6.8 to 4.7.3.
- [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases)
- [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/v4.6.8...v4.7.3)

---
updated-dependencies:
- dependency-name: JamesIves/github-pages-deploy-action
  dependency-version: 4.7.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-20 17:35:45 +02:00
dependabot[bot]
238350b940
Bump typescript-eslint from 8.32.0 to 8.32.1 (#661)
Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.32.0 to 8.32.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.32.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.32.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-20 17:35:26 +02:00
dependabot[bot]
99bf7b1889
Bump vite from 5.4.19 to 6.3.5 (#664)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.19 to 6.3.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.5
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-20 17:30:15 +02:00
dependabot[bot]
d72c91644d
Bump ra-language-english from 5.2.3 to 5.8.2 (#667)
Bumps [ra-language-english](https://github.com/marmelab/react-admin) from 5.2.3 to 5.8.2.
- [Release notes](https://github.com/marmelab/react-admin/releases)
- [Changelog](https://github.com/marmelab/react-admin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/marmelab/react-admin/compare/v5.2.3...v5.8.2)

---
updated-dependencies:
- dependency-name: ra-language-english
  dependency-version: 5.8.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-20 17:09:09 +02:00
Dirk Klimpel
e8e28b5df1
Upgrade to react-admin v5 (#633)
* bump dependencies
* replace react-query with @tanstack/react-query
* remove darkTheme
* remove obsolete fullWidth
* fix pagination
* fix language for ra.navigation
* fix 'record' is possibly 'undefined'
* add missing @mui/utils
* bump yarn to 4.4.x
2025-05-09 20:53:41 +02:00
rkfg
d5c10b6e02
Federation page improvements (#583)
* Hide zero last retry timestamps in federation list
* Federation dark theme improvements
2025-05-09 17:54:46 +02:00
Huw Carpenter
3085b9ffa0
Add ability to toggle whether to show locked users (#573) 2025-05-09 17:52:55 +02:00
Patrick Kranz
b2a3fb0f87
Update fetch URL to use BASE_URL for config.json (#652)
Fixes #644.
2025-05-09 17:44:41 +02:00
milkomeda
1e8b4cc885
Fixed detection of missingFields in verifyCsv function of CSV import (#650)
Fixes #600, #552, #188.
2025-05-09 17:43:13 +02:00
Manuel Stahl
4d1a9cc147 Add content read permission to build-test workflow
Change-Id: I5dc911e79e7d0d8fbd88d6a9a686fc10f11a97cd
2025-05-09 17:36:42 +02:00
Manuel Stahl
1b8b702270 Add content write permission to ghpage workflow
Change-Id: I13a10316400dd80a8149e6e8a265c13a51aee652
2025-05-09 17:27:55 +02:00
Dirk Klimpel
61c32fb473 Add identifier when login with password (#601)
* Add identifier when login with password
* add identifier to unit tests
2025-05-09 17:24:23 +02:00
dependabot[bot]
ad876bb790
Bump the npm_and_yarn group with 3 updates (#656)
Bumps the npm_and_yarn group with 3 updates: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite), [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) and [dompurify](https://github.com/cure53/DOMPurify).


Updates `vite` from 5.4.9 to 5.4.19
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.19/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.19/packages/vite)

Updates `@babel/runtime` from 7.24.4 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-runtime)

Updates `dompurify` from 2.5.0 to 2.5.8
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/2.5.0...2.5.8)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.19
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: "@babel/runtime"
  dependency-version: 7.27.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: dompurify
  dependency-version: 2.5.8
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-09 17:04:48 +02:00
dependabot[bot]
2524848dae
Bump typescript-eslint from 7.16.1 to 8.11.0 (#638)
Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 7.16.1 to 8.11.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.11.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-09 12:03:03 +02:00
dependabot[bot]
669c1f3079
Bump JamesIves/github-pages-deploy-action from 4.6.3 to 4.6.8 (#626)
Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.6.3 to 4.6.8.
- [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases)
- [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/v4.6.3...v4.6.8)

---
updated-dependencies:
- dependency-name: JamesIves/github-pages-deploy-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-09 11:17:06 +02:00
dependabot[bot]
590f673167
Bump eslint-plugin-unused-imports from 3.2.0 to 4.1.4 (#620)
Bumps [eslint-plugin-unused-imports](https://github.com/sweepline/eslint-plugin-unused-imports) from 3.2.0 to 4.1.4.
- [Commits](https://github.com/sweepline/eslint-plugin-unused-imports/commits/v4.1.4)

---
updated-dependencies:
- dependency-name: eslint-plugin-unused-imports
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-09 11:16:23 +02:00
dependabot[bot]
307793f000
Bump @testing-library/dom from 10.0.0 to 10.4.0 (#585)
Bumps [@testing-library/dom](https://github.com/testing-library/dom-testing-library) from 10.0.0 to 10.4.0.
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.0.0...v10.4.0)

---
updated-dependencies:
- dependency-name: "@testing-library/dom"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-09 11:14:41 +02:00
dependabot[bot]
96f549fe42
Bump micromatch from 4.0.5 to 4.0.8 in the npm_and_yarn group (#610)
Bumps the npm_and_yarn group with 1 update: [micromatch](https://github.com/micromatch/micromatch).


Updates `micromatch` from 4.0.5 to 4.0.8
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

---
updated-dependencies:
- dependency-name: micromatch
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-09 11:14:29 +02:00
dependabot[bot]
3de4332477
Bump vite from 5.3.4 to 5.4.9 (#634)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.3.4 to 5.4.9.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.9/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.9/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-09 11:13:22 +02:00
Dirk Klimpel
9fc005032c
Fix for empty user default tab after creation (#628) 2024-10-08 09:20:55 +02:00
Manuel Stahl
dbcb4f92dc Use central defintion of storage system
Change-Id: Ibf31c650b08920bf82827607c3421556ac90ae61
2024-08-17 10:50:29 +02:00
dependabot[bot]
035baa786a
Bump softprops/action-gh-release from 2.0.6 to 2.0.8 (#588)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.0.6 to 2.0.8.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](a74c6b72af...c062e08bd5)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-24 14:13:08 +02:00
Manuel Stahl
31fe23d688 Upgrade dependencies
Change-Id: I0ebdf0f111a10f3934059966b5ea90cc5aefede8
2024-07-24 14:03:08 +02:00
Manuel Stahl
d3e623e578 Upgrade react-admin
Change-Id: Id355a462f3533cace724b4d4b88921c9aafadfda
2024-07-24 14:00:47 +02:00
Manuel Stahl
a38bc442cb Upgrade vite, react and jest
Change-Id: I9501f8dc3a9759255fa94434f5c0d4eb8efc01d7
2024-07-24 13:46:25 +02:00
Manuel Stahl
f88eacee2a Upgrade eslint plugins
Change-Id: I8c7a21ccadaefa6fe740be6d903b9b6915c8bd4e
2024-07-22 13:52:04 +02:00
rkfg
77cc936710
Fix a few undefined records (#580) 2024-07-18 20:38:46 +02:00
rkfg
eb626a7e9e
Add Russian language support (#581) 2024-07-18 20:32:22 +02:00
Manuel Stahl
ce5d6587c1 Bump version to 0.10.3
Change-Id: If0ebb483c0ef30e2331649f903a602b9e22e98f3
2024-07-18 20:27:39 +02:00
Manuel Stahl
4adf2c2bca Fix getWellKnownUrl()
Change-Id: I494831a7608e80c4d9fa1a2455755d915607a22d
2024-07-09 13:03:53 +02:00
Manuel Stahl
fce6e03fc5 Regroup source code
- components directory contains react components
- pages directory contains all custom pages
- resources directory contains everything that exports ResourceProps

Change-Id: I5b9b68f67e232044fabf11810482873ce5b32053
2024-07-09 13:03:53 +02:00
Manuel Stahl
ec0fc14b68 Use custom data provider method for "delete_media"
This is not a REST endpoint, so it's better to use a custom method, see
https://marmelab.com/react-admin/DataProviders.html#adding-custom-methods

Change-Id: I256286949e77b998f759f671b2d4e9790f8ca39c
2024-07-09 13:03:53 +02:00
Manuel Stahl
f55e02730e Dedupe yarn.lock
Change-Id: Ib7177d237e5411634fa8d0ab07220e9990f526c0
2024-07-09 13:03:53 +02:00
dependabot[bot]
c48f560fb0
Bump @mui/material from 5.15.16 to 5.16.0 (#571)
Bumps [@mui/material](https://github.com/mui/material-ui/tree/HEAD/packages/mui-material) from 5.15.16 to 5.16.0.
- [Release notes](https://github.com/mui/material-ui/releases)
- [Changelog](https://github.com/mui/material-ui/blob/v5.16.0/CHANGELOG.md)
- [Commits](https://github.com/mui/material-ui/commits/v5.16.0/packages/mui-material)

---
updated-dependencies:
- dependency-name: "@mui/material"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-09 11:24:42 +02:00
dependabot[bot]
a5714386f4
Bump @types/node from 20.14.0 to 20.14.10 (#568)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.14.0 to 20.14.10.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-09 11:24:33 +02:00
dependabot[bot]
659730ce2e
Bump ts-jest from 29.1.2 to 29.2.0 (#570)
Bumps [ts-jest](https://github.com/kulshekhar/ts-jest) from 29.1.2 to 29.2.0.
- [Release notes](https://github.com/kulshekhar/ts-jest/releases)
- [Changelog](https://github.com/kulshekhar/ts-jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/kulshekhar/ts-jest/compare/v29.1.2...v29.2.0)

---
updated-dependencies:
- dependency-name: ts-jest
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-09 11:21:42 +02:00
dependabot[bot]
cc51b3edbe
Bump react-hook-form from 7.51.3 to 7.52.1 (#569)
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.51.3 to 7.52.1.
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.51.3...v7.52.1)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-09 11:21:21 +02:00
dependabot[bot]
c07ec04b4e
Bump JamesIves/github-pages-deploy-action from 4.6.0 to 4.6.3 (#572)
Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.6.0 to 4.6.3.
- [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases)
- [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/v4.6.0...v4.6.3)

---
updated-dependencies:
- dependency-name: JamesIves/github-pages-deploy-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-09 11:17:46 +02:00
dependabot[bot]
c7f3fa9212
Bump eslint-plugin-jsx-a11y from 6.8.0 to 6.9.0 (#567)
Bumps [eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y) from 6.8.0 to 6.9.0.
- [Release notes](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/releases)
- [Changelog](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/compare/v6.8.0...v6.9.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-jsx-a11y
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-09 11:13:20 +02:00
dependabot[bot]
9c5e755f3f
Bump softprops/action-gh-release from 2.0.5 to 2.0.6 (#561)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.0.5 to 2.0.6.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](69320dbe05...a74c6b72af)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-09 11:12:58 +02:00
dependabot[bot]
048a43f404
Bump docker/build-push-action from 5 to 6 (#554)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-09 11:12:34 +02:00
dependabot[bot]
f8ac0403a9
Bump @typescript-eslint/parser from 7.8.0 to 7.15.0 (#565)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 7.8.0 to 7.15.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.15.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-03 08:32:53 +02:00
dependabot[bot]
cbfdc1d6f6
Bump @eslint/js from 9.1.1 to 9.6.0 (#563)
Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.1.1 to 9.6.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/commits/v9.6.0/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-03 08:32:40 +02:00
Dirk Klimpel
8cadfbd3af
Support darkTheme (#459)
* add darkTheme

* set `palette` to `darkTheme`
2024-07-03 08:31:24 +02:00
dependabot[bot]
dbc7d328cc
Bump the npm_and_yarn group with 2 updates (#560)
Bumps the npm_and_yarn group with 2 updates: [braces](https://github.com/micromatch/braces) and [ws](https://github.com/websockets/ws).


Updates `braces` from 3.0.2 to 3.0.3
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

Updates `ws` from 8.16.0 to 8.17.1
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.16.0...8.17.1)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: ws
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-03 08:30:20 +02:00
dependabot[bot]
e21e44362c
Bump @types/node from 20.12.7 to 20.14.0 (#547)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.12.7 to 20.14.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-04 09:28:56 +02:00
dependabot[bot]
38057de5c8
Bump softprops/action-gh-release from 2.0.4 to 2.0.5 (#530)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.0.4 to 2.0.5.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](9d7c94cfd0...69320dbe05)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-04 09:26:35 +02:00
dependabot[bot]
882fe264b2
Bump vite from 5.2.9 to 5.2.12 (#545)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.2.9 to 5.2.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.2.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-04 09:21:45 +02:00
Dirk Klimpel
002b63acad
Set relative base path in vite.config.ts (#536) 2024-06-04 09:21:17 +02:00
Dirk Klimpel
b5ca951b32
Add locked status to users (#413) 2024-05-07 13:14:15 +02:00
Dirk Klimpel
fac09cb9bb
Add erasure status to users (#294) 2024-05-07 13:01:24 +02:00
Manuel Stahl
c9f5360779 Bump typescript-eslint from 7.7.1 to 7.8.0
Change-Id: Iee221c0a0582ad1b2e19cfba49bc512ac51db0ea
2024-05-07 10:48:06 +02:00
dependabot[bot]
5c492a2ecf Bump @typescript-eslint/parser from 7.7.1 to 7.8.0
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 7.7.1 to 7.8.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.8.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-07 10:44:34 +02:00
dependabot[bot]
107d60704b Bump eslint-plugin-unused-imports from 3.1.0 to 3.2.0
Bumps [eslint-plugin-unused-imports](https://github.com/sweepline/eslint-plugin-unused-imports) from 3.1.0 to 3.2.0.
- [Commits](https://github.com/sweepline/eslint-plugin-unused-imports/commits)

---
updated-dependencies:
- dependency-name: eslint-plugin-unused-imports
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-07 10:44:16 +02:00
Manuel Stahl
5b50838fb7 Fix interface SynapseTranslationMessages
Add missing entries.

Change-Id: Ied393e07b61f5463c849118196a1ad32eee45c23
2024-05-07 10:36:09 +02:00
Manuel Stahl
9da953e78a Dedupe yarn.lock
Change-Id: Ibbb2cb5f64898b8aef23ed04ddb7f3827a51b6ab
2024-05-06 17:52:34 +02:00
Manuel Stahl
91af5068c0 Bump react-admin to 4.16.17
Change-Id: I414befd8876683e59b9882b4bd56bc4be2cb6c6b
2024-05-06 17:52:34 +02:00
Manuel Stahl
ce1d806818 Bump react-router and react-router-dom to 6.23.0
Change-Id: Ice8fb5a70009341e759217789c95d999f76f2aed
2024-05-06 17:52:34 +02:00
Manuel Stahl
211e6e6915 Bump react and react-dom to 18.3.1
Change-Id: I5e2af201bad026a2292d6bcac71ade87a9911197
2024-05-06 17:52:34 +02:00
dependabot[bot]
3ae4dcffab Bump ra-language-french from 4.16.15 to 4.16.16
Bumps [ra-language-french](https://github.com/marmelab/react-admin) from 4.16.15 to 4.16.16.
- [Release notes](https://github.com/marmelab/react-admin/releases)
- [Changelog](https://github.com/marmelab/react-admin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/marmelab/react-admin/compare/v4.16.15...v4.16.16)

---
updated-dependencies:
- dependency-name: ra-language-french
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 16:58:41 +02:00
dependabot[bot]
7061c5cbff Bump ra-language-english from 4.16.15 to 4.16.16
Bumps [ra-language-english](https://github.com/marmelab/react-admin) from 4.16.15 to 4.16.16.
- [Release notes](https://github.com/marmelab/react-admin/releases)
- [Changelog](https://github.com/marmelab/react-admin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/marmelab/react-admin/compare/v4.16.15...v4.16.16)

---
updated-dependencies:
- dependency-name: ra-language-english
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 16:58:31 +02:00
dependabot[bot]
6fe8ab3115 Bump react-test-renderer from 18.2.0 to 18.3.1
Bumps [react-test-renderer](https://github.com/facebook/react/tree/HEAD/packages/react-test-renderer) from 18.2.0 to 18.3.1.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v18.3.1/packages/react-test-renderer)

---
updated-dependencies:
- dependency-name: react-test-renderer
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 16:57:28 +02:00
dependabot[bot]
04243eefa9 Bump react-is from 18.2.0 to 18.3.1
Bumps [react-is](https://github.com/facebook/react/tree/HEAD/packages/react-is) from 18.2.0 to 18.3.1.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v18.3.1/packages/react-is)

---
updated-dependencies:
- dependency-name: react-is
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 16:57:10 +02:00
Manuel Stahl
4761ea36bc Update eslint for typescript
Change-Id: I39ad44666fe958dd4f6c8f0d88b3dc960d7cb6c7
2024-05-06 08:33:32 +02:00
Manuel Stahl
72f5ab937e Refactor random MXID and password generator
Change-Id: Ifd8afde0a294adba2d28ca4f620e298aac9a1fa6
2024-05-06 08:33:10 +02:00
Manuel Stahl
39dd6617de Extract date formatting into separate file
Change-Id: I0004617349253450c6c706e4334d63924203a804
2024-04-26 11:48:53 +02:00
Manuel Stahl
2466af6936 Transform code base to typescript
Change-Id: Ia1f862fb5962ddd54b8d7643abbc39bb314d1f8e
2024-04-26 11:48:52 +02:00
Manuel Stahl
03fcd8126a Fix warnings in LoginPage test
Change-Id: I844bb190e1d3ea172395035224bab497f3950912
2024-04-24 20:37:42 +02:00
Manuel Stahl
b112689b8c Bump version to 0.10.1
Change-Id: Ic63e74250db0986a5c141212932bad2c4294947a
2024-04-24 17:31:02 +02:00
Manuel Stahl
ac3b40b188 Add BASE_PATH argument to Dockerfile
Fixes #512, #517.

Change-Id: I0367f681aaf35af4a0f1b324fb0bdbef272e6db4
2024-04-24 17:02:42 +02:00
Manuel Stahl
a490b7bc85 Add BUILDKIT_CONTEXT_KEEP_GIT_DIR=1 to README and docker-compose.yml
Fixes #513, #516.

Change-Id: I18d67a53db51cab622b315f153a106fda153476b
2024-04-24 17:02:42 +02:00
Andreas Schildbach
e094047388 Update README.md
Always mount config files as read-only. Otherwise, the app can corrupt your configuration. This would be an isolation violation.
2024-04-23 21:02:31 +02:00
Manuel Stahl
5d1e43611c Migrate to yarn v4
Use yarn PnP which forces us to install some more explicit dependencies.

Change-Id: Ib35c5c71a37081c98778937bde5a23bf997dd54c
2024-04-23 13:35:19 +02:00
Manuel Stahl
630e809e78 Bump version to 0.10.0
Change-Id: Ifa1a8334ff0352c91d168ec106a9d569b50e39b2
2024-04-23 12:34:28 +02:00
Manuel Stahl
264e0b5ec6 Set base path for github pages
Change-Id: I9e3a9287c288531793aca7f89faa7f1dab4b795a
2024-04-23 12:28:00 +02:00
Manuel Stahl
1837733e07 Fix build output path
Change-Id: I6b77d9942324254b5312d80156f089b183a02201
2024-04-23 12:18:38 +02:00
Manuel Stahl
77be88402f Set package type to "module"
Change-Id: Ie131ec9d5b86c167d561a375aa0dee59c1799531
2024-04-23 11:56:39 +02:00
Manuel Stahl
f4ea63c8f4 Use vite-plugin-version-mark to read project version
Change-Id: I0d46dc57df025538379a2f0786d3e972c56dd248
2024-04-23 11:56:39 +02:00
Manuel Stahl
2e2085cdfe Use vitejs instead of react-scripts
- react-scripts are not maintained anymore
- vitejs is well suited for single page applications

Fixes #335

See https://darekkay.com/blog/create-react-app-to-vite/

Change-Id: Ib884748e373094a640b576894ff67b98c3584ec8
2024-04-23 11:56:39 +02:00
Manuel Stahl
4b1277f653 Rework configuration process
Dynamically loads `config.json` on startup.

Fixes #167, #284, #449, #486

Change-Id: I9efb1079c0c88e6e0272c5fda734a367aa8f84a3
2024-04-23 11:56:39 +02:00
dependabot[bot]
ef3836313c
Bump softprops/action-gh-release from 2.0.3 to 2.0.4 (#510)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.0.3 to 2.0.4.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](3198ee18f8...9d7c94cfd0)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-23 10:50:40 +02:00
dependabot[bot]
428cb56fdf
Bump JamesIves/github-pages-deploy-action from 4.5.0 to 4.6.0 (#509)
Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.5.0 to 4.6.0.
- [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases)
- [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/v4.5.0...v4.6.0)

---
updated-dependencies:
- dependency-name: JamesIves/github-pages-deploy-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-23 10:50:17 +02:00
Alexander Tumin
c6f9dbec18 Show links to media content from local users and reports (#508)
Change-Id: Ia8993470a9dd5e479c028013b5be6723f569814d
2024-04-23 10:37:52 +02:00
Manuel Stahl
531d8f2d7f Fix translations for registration tokens
Change-Id: I31ec0a60e3aa94d55c86b1b73ef21b91f2356729
2024-04-23 10:37:52 +02:00
Manuel Stahl
0986d95de2 Use officially recommended language packages
See https://marmelab.com/react-admin/TranslationLocales.html

Change-Id: I1e29aef26e8cf02b34cd4c0ba105c3bd68e8637e
2024-04-22 15:20:11 +02:00
Manuel Stahl
5f1dfc95c7 Remove obsolete react-admin translations for german
These are now provided by ra-language-german.

Change-Id: I5f820139fe5322f488398abf879582508507d38d
2024-04-22 11:24:22 +02:00
Manuel Stahl
e666c9c7bd Fallback to english if no translation in the current language is available
Change-Id: I94ecf5f2d742b1653177c49cef6b1b7fd6e96df0
2024-04-22 11:24:22 +02:00
Manuel Stahl
441f7749a2 Get available translations from context in LoginPage
Change-Id: Ie9febb82c0c93ba797241a4e6e22c6b6e72c6b02
2024-04-22 11:23:32 +02:00
Manuel Stahl
028babc885 Bump version to 0.9.4
Change-Id: Ic91bd40e242605986abc48982a5bec8a95c20c7a
2024-04-22 10:17:05 +02:00
Manuel Stahl
400e9aa416 Fix fetching tags in github workflows
The actions/checkout step has some bugs:
https://github.com/actions/checkout/issues/1467

Change-Id: I6bd88433425657081c2033b55bf01587979983df
2024-04-22 10:16:42 +02:00
Manuel Stahl
6d9abe85b0 Bump version to 0.9.3
Change-Id: Ib6bf464fb08d8119cbbcb0d387545f3e58642ade
2024-04-22 09:55:00 +02:00
Manuel Stahl
df1fbbc16b Use nginx:stable-alpine as base image for docker container
Change-Id: Ibc9b430cb79b8c05b111d3993fc3b1543853a515
2024-04-19 09:38:47 +02:00
Manuel Stahl
6bdeadcc3e Use node 20 in github workflows
Change-Id: I5850572bf604edd91b8a6f4e7b34ebf788e5c65b
2024-04-19 09:38:47 +02:00
Manuel Stahl
881760c8d8 Fetch tags in github workflows
Tags are required to construct the version information.

Change-Id: Ic1af3e8f50eafafcc8a0c3ca37f362d6bd05e116
2024-04-19 09:11:20 +02:00
Manuel Stahl
03c4955ef7 Push docker images also to ghcr.io
Fixes #350.

Change-Id: Ifdb7e4e7fda46efd0ed9e760587033f52ff4a130
2024-04-19 09:11:20 +02:00
Manuel Stahl
c9cb9aa9e0 Show Matrix specs supported by the homeserver
Change-Id: I01c110fb4b3de4de49b34f290c91c8bf424521fe
2024-04-18 10:01:52 +02:00
Manuel Stahl
25020c2d5b Remove unused function "renderInput"
Seems to be obsolete since react-admin v4.

Change-Id: I9f1d528a43510efd61befd23a05d1c8ebf40ddfd
2024-04-18 10:01:52 +02:00
Manuel Stahl
1acffdb618 Make functions in dataProvider async
Change-Id: Iab36ba6379340e47e7d58b1b2d882cd7cc111f41
2024-04-18 10:01:52 +02:00
Manuel Stahl
0b4f3a60c0 Make login and logout in authProvider async
Change-Id: I6bfb1c7a5a3c5a43f9fa622e87d9d487a95a0b6e
2024-04-18 10:01:52 +02:00
Manuel Stahl
33d29e01b1 Add authProvider test
Change-Id: Ia5acce659a386437687e38ae03d578e3bccb9324
2024-04-18 10:01:52 +02:00
Gavin Mogan
a2e47cb793
Add source urls to docker so tools can find sourcecode (#506)
For tools like renovate or dependabot, they like to put changelog notes in PRs updating deps. Having the labels allows the tools to link it back to sourcecode and share commits/release notes
2024-04-17 20:32:41 +02:00
Manuel Stahl
7deb9bcf7e Bump version to 0.9.2
Change-Id: I8d5f98f10fe16189c12b2ce0f0fff073ec81fed5
2024-04-17 09:53:48 +02:00
Manuel Stahl
8185d7f0b0 Remove obsolete .travis.yml
Change-Id: I65f80b5bcf6db3fd85ddbb3ea25f86bb2466bace
2024-04-17 09:53:48 +02:00
Manuel Stahl
37e1fcc96d Fix App test
Change-Id: Iacaa6f5e70925b857f24554e6aba64234b1cae44
2024-04-17 09:53:48 +02:00
Fateme Shamohammadi
f6e193c51c Add farsi translations (#504)
Change-Id: Iee74dbf229197359a148dec7e75ef6f744a1856d
2024-04-16 14:53:05 +02:00
dependabot[bot]
fa1f86491f
Bump ra-language-french from 4.16.12 to 4.16.15 (#503)
Bumps [ra-language-french](https://github.com/marmelab/react-admin) from 4.16.12 to 4.16.15.
- [Release notes](https://github.com/marmelab/react-admin/releases)
- [Changelog](https://github.com/marmelab/react-admin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/marmelab/react-admin/compare/v4.16.12...v4.16.15)

---
updated-dependencies:
- dependency-name: ra-language-french
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 09:27:50 +02:00
dependabot[bot]
b6546b89ad
Bump the npm_and_yarn group with 3 updates (#496)
Bumps the npm_and_yarn group with 3 updates: [express](https://github.com/expressjs/express), [follow-redirects](https://github.com/follow-redirects/follow-redirects) and [webpack-dev-middleware](https://github.com/webpack/webpack-dev-middleware).


Updates `express` from 4.18.2 to 4.19.2
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.18.2...4.19.2)

Updates `follow-redirects` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.5...v1.15.6)

Updates `webpack-dev-middleware` from 5.3.3 to 5.3.4
- [Release notes](https://github.com/webpack/webpack-dev-middleware/releases)
- [Changelog](https://github.com/webpack/webpack-dev-middleware/blob/v5.3.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-dev-middleware/compare/v5.3.3...v5.3.4)

---
updated-dependencies:
- dependency-name: express
  dependency-type: indirect
  dependency-group: npm_and_yarn-security-group
- dependency-name: follow-redirects
  dependency-type: indirect
  dependency-group: npm_and_yarn-security-group
- dependency-name: webpack-dev-middleware
  dependency-type: indirect
  dependency-group: npm_and_yarn-security-group
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 09:22:55 +02:00
dependabot[bot]
baa8e4ad95
Bump @testing-library/react from 14.2.1 to 15.0.2 (#505)
Bumps [@testing-library/react](https://github.com/testing-library/react-testing-library) from 14.2.1 to 15.0.2.
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v14.2.1...v15.0.2)

---
updated-dependencies:
- dependency-name: "@testing-library/react"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 09:22:43 +02:00
dependabot[bot]
bd6d5847e1
Bump react-admin from 4.16.11 to 4.16.15 (#502)
Bumps [react-admin](https://github.com/marmelab/react-admin) from 4.16.11 to 4.16.15.
- [Release notes](https://github.com/marmelab/react-admin/releases)
- [Changelog](https://github.com/marmelab/react-admin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/marmelab/react-admin/compare/v4.16.11...v4.16.15)

---
updated-dependencies:
- dependency-name: react-admin
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 09:22:03 +02:00
dependabot[bot]
32c5867e2a
Bump @mui/material from 5.15.14 to 5.15.15 (#501)
Bumps [@mui/material](https://github.com/mui/material-ui/tree/HEAD/packages/mui-material) from 5.15.14 to 5.15.15.
- [Release notes](https://github.com/mui/material-ui/releases)
- [Changelog](https://github.com/mui/material-ui/blob/next/CHANGELOG.md)
- [Commits](https://github.com/mui/material-ui/commits/v5.15.15/packages/mui-material)

---
updated-dependencies:
- dependency-name: "@mui/material"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 09:21:33 +02:00
dependabot[bot]
f68a5de64a
Bump softprops/action-gh-release from 1 to 2 (#482)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](de2c0eb89a...3198ee18f8)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 09:19:14 +02:00
dependabot[bot]
e326599da2
Bump @mui/icons-material from 5.15.10 to 5.15.15 (#499)
Bumps [@mui/icons-material](https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material) from 5.15.10 to 5.15.15.
- [Release notes](https://github.com/mui/material-ui/releases)
- [Changelog](https://github.com/mui/material-ui/blob/v5.15.15/CHANGELOG.md)
- [Commits](https://github.com/mui/material-ui/commits/v5.15.15/packages/mui-icons-material)

---
updated-dependencies:
- dependency-name: "@mui/icons-material"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-04 12:28:59 +02:00
dependabot[bot]
8d6852ca8c
Bump @mui/styles from 5.15.10 to 5.15.15 (#498)
Bumps [@mui/styles](https://github.com/mui/material-ui/tree/HEAD/packages/mui-styles) from 5.15.10 to 5.15.15.
- [Release notes](https://github.com/mui/material-ui/releases)
- [Changelog](https://github.com/mui/material-ui/blob/v5.15.15/CHANGELOG.md)
- [Commits](https://github.com/mui/material-ui/commits/v5.15.15/packages/mui-styles)

---
updated-dependencies:
- dependency-name: "@mui/styles"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-04 12:28:49 +02:00
dependabot[bot]
ee859a2926
Bump @mui/material from 5.15.10 to 5.15.14 (#492)
Bumps [@mui/material](https://github.com/mui/material-ui/tree/HEAD/packages/mui-material) from 5.15.10 to 5.15.14.
- [Release notes](https://github.com/mui/material-ui/releases)
- [Changelog](https://github.com/mui/material-ui/blob/next/CHANGELOG.md)
- [Commits](https://github.com/mui/material-ui/commits/v5.15.14/packages/mui-material)

---
updated-dependencies:
- dependency-name: "@mui/material"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-04 12:22:31 +02:00
dependabot[bot]
0c4ca1459f
Bump ra-language-french from 4.16.11 to 4.16.12 (#481)
Bumps [ra-language-french](https://github.com/marmelab/react-admin) from 4.16.11 to 4.16.12.
- [Release notes](https://github.com/marmelab/react-admin/releases)
- [Changelog](https://github.com/marmelab/react-admin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/marmelab/react-admin/compare/v4.16.11...v4.16.12)

---
updated-dependencies:
- dependency-name: ra-language-french
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-04 12:22:19 +02:00
dependabot[bot]
ebba0f66f7
Bump eslint from 8.56.0 to 8.57.0 (#479)
Bumps [eslint](https://github.com/eslint/eslint) from 8.56.0 to 8.57.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.56.0...v8.57.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-04 12:22:09 +02:00
Manuel Stahl
7d4d765ab4 Bump react-admin from 4.16.9 to 4.16.11
Bumps [react-admin](https://github.com/marmelab/react-admin) from 4.16.9 to 4.16.11.
- [Release notes](https://github.com/marmelab/react-admin/releases)
- [Changelog](https://github.com/marmelab/react-admin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/marmelab/react-admin/compare/v4.16.9...v4.16.11)

Change-Id: I6ae1c3ad892a65b707f9ee6e3a22b6be8f706394
2024-02-19 12:37:26 +01:00
Manuel Stahl
dfee94af96 Bump @mui/* from 5.15.7 to 5.15.10
- [Release notes](https://github.com/mui/material-ui/releases)
- [Changelog](https://github.com/mui/material-ui/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mui/material-ui/commits/v5.15.10/packages/mui-material)

Change-Id: I59b486e1b3351f4e685d7bd317ea4d96c3d24209
2024-02-19 12:36:02 +01:00
Manuel Stahl
ae7f6e18e5 Use --immutable flag whenever "yarn install" is called by a tool
Fixes #347

Change-Id: I1b8423f9cef46a425c1ec7665c8285af10c56df6
2024-02-19 11:58:44 +01:00
Manuel Stahl
d1e9f38b14 Fix example.csv
User must be only the name part, not the full MXID as we can only create
local users.

Fixes #406

Change-Id: Ida7b6db28d88417f28b59a02df1f3d7a010aa110
2024-02-19 11:58:44 +01:00
Sebastian
4054249359
Update RegistrationTokens.jsx: Fix resource name (#469)
fixes #468
2024-02-08 17:30:21 +01:00
Manuel Stahl
f240318525 Bump version to 0.9.1
Change-Id: I8436d0ebd48a99f4b2ca2b7b213d94689b440d57
2024-02-08 15:05:26 +01:00
Dirk Klimpel
0852b54a8e
Disable bulkActionButtons for not needed room and user tabs (#466) 2024-02-08 09:31:11 +01:00
Manuel Stahl
8688ab7d0e Fix update in dataProvider
Fixes #461

Change-Id: Icc4b0264cfda04a8a28595d153c43cdf75524673
2024-02-07 16:48:28 +01:00
Manuel Stahl
abc677dc16 Simplify DeviceRemoveButton
Change-Id: I23dcb327d2612db7fc132889d623b709dce34f06
2024-02-07 16:40:42 +01:00
Steffo
9d26a1ce3a
Allow deletion of event reports (#462)
* feat: Allow event reports to get deleted
* chore: Change german translation of reports name to be more fitting
2024-02-07 16:34:50 +01:00
Timo Gurr
3116b4e07a Show topic in room basic view 2024-02-07 16:23:54 +01:00
dependabot[bot]
3a34c03509
Bump @mui/styles from 5.15.7 to 5.15.8 (#463)
Bumps [@mui/styles](https://github.com/mui/material-ui/tree/HEAD/packages/mui-styles) from 5.15.7 to 5.15.8.
- [Release notes](https://github.com/mui/material-ui/releases)
- [Changelog](https://github.com/mui/material-ui/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mui/material-ui/commits/v5.15.8/packages/mui-styles)

---
updated-dependencies:
- dependency-name: "@mui/styles"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-07 16:18:50 +01:00
dependabot[bot]
384bc6553c
Bump JamesIves/github-pages-deploy-action from 4.4.3 to 4.5.0 (#457)
Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.4.3 to 4.5.0.
- [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases)
- [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/v4.4.3...v4.5.0)

---
updated-dependencies:
- dependency-name: JamesIves/github-pages-deploy-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-07 16:18:15 +01:00
dependabot[bot]
df87432157
Bump follow-redirects from 1.14.8 to 1.15.5 (#450)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.8 to 1.15.5.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.8...v1.15.5)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-07 16:17:49 +01:00
Manuel Stahl
2afc7aeca4 Rename all JSX files to have proper file extension
Change-Id: I4ab382f7673a815164f74154e6b03b370fd76a33
2024-02-07 15:27:34 +01:00
Manuel Stahl
ac843b3244 Upgrade packages to latest version
yarn upgrade --latest

Change-Id: I07c71927ffa6c811fe7cbf8bd2a47503e55499ce
2024-02-07 15:27:34 +01:00
Manuel Stahl
82155c23a1 Upgrade react to v18
https://react.dev/blog/2022/03/08/react-18-upgrade-guide#updates-to-client-rendering-apis

Change-Id: Ibac40eb3d900f54955dfbc8f5e2833a0c47941a6
2024-02-07 15:27:33 +01:00
Manuel Stahl
64a89f6552 Extract helper functions from LoginPage
Change-Id: I507e223d0eff00bac3963d0b71f9bd648b9ab7b1
2024-02-07 15:15:40 +01:00
Manuel Stahl
5b8882bd80 Fix AvatarField
Change-Id: I9614163942fcb8667885b524caf944500605c55d
2024-02-07 15:15:36 +01:00
Manuel Stahl
3fe0e95069 Set "requireAuth" for all pages
Change-Id: I1b68d46f9f7d9a843a5b26f0906d1f71569487cf
2024-02-07 12:03:00 +01:00
Dirk Klimpel
3cd0aa4446
Update links to Synapse in README.md (#458) 2024-02-07 11:14:25 +01:00
dklimpel
3adc6b4663 Use new API of dataProvider
Change-Id: I2789f1f1384b48e876bee5af421ff5db66fa3416
2024-02-07 08:49:26 +01:00
Manuel Stahl
76ef017244 Refactor media
Change-Id: Ic24c53048c35b76532af24d9c5c9bf831688344b
2024-02-07 08:49:11 +01:00
dklimpel
00ecb29d6b Refactor RoomDirectory
Change-Id: Ie3bd606fc91b2673d2a3422f8fd465258d3211b0
2024-02-07 08:28:12 +01:00
Manuel Stahl
4204eb902f Refactor ServerNotices
https://marmelab.com/react-admin/Upgrade.html#usequery-usemutation-and-usequerywithstore-have-been-removed

Change-Id: Id12f727d8813f78c3ae300035aeb1333a1272e02
2024-02-07 08:28:12 +01:00
dklimpel
6430aca02b Rename save to onSubmit in SimpleForm
https: //marmelab.com/react-admin/Upgrade.html#the-form-components-save-prop-has-been-renamed-to-onsubmit

Change-Id: Iaf2c0b665c8058336d4df6326531780a2790e71d
2024-02-07 08:28:12 +01:00
dklimpel
b8a0b4bef5 Move redirect from SimpleForm to Create
Change-Id: I7c5c0043a49bcb16c131e400b2ebe022e233c5ae
2024-02-06 15:11:20 +01:00
dklimpel
2c769c309e Move Toolbar's alwaysEnableSaveButton into SaveButton
https: //marmelab.com/react-admin/Upgrade.html#toolbars-alwaysenablesavebutton-prop-has-been-removed

Change-Id: I6c8693d4f55bfabdeaa677bd294d8663b7f14d69
2024-02-06 15:11:20 +01:00
dklimpel
82578c6570 Change unselectAll syntax
https: //marmelab.com/react-admin/Upgrade.html#useunselectall-syntax-changed

Change-Id: Ie8d261e863fe4726b3a5925ed0446eb824c6e517
2024-02-06 15:11:20 +01:00
dklimpel
005abfb4a2 Update pagination
https: //marmelab.com/react-admin/Upgrade.html#no-more-props-injection-in-custom-pagination-and-empty-components

Change-Id: I6f4d3941dee22cf00da30bada5442f3fdd345127
2024-02-06 15:11:20 +01:00
dklimpel
155e73b9c6 Rename currentSort to sort
https: //marmelab.com/react-admin/Upgrade.html#currentsort-renamed-to-sort

Change-Id: I676adefe0073a9a0343dcd598e9559ecf30c38af
2024-02-06 15:11:20 +01:00
dklimpel
1eb787fd9b Replace "onFailure" with "onError"
https://marmelab.com/react-admin/Upgrade.html#onsuccess-and-onfailure-props-have-moved

Change-Id: I30ae51e06df0293391988a7a84be9c6ef2b158b3
2024-02-06 15:11:12 +01:00
dklimpel
691969e1a1 Fix translation of device_id in EventReport
Change-Id: Ife6cfdae1fce9b477fc12b2e0cdd6bcea4b8b734
2024-02-06 15:11:07 +01:00
Manuel Stahl
d520c6d618 Export resources as objects
Change-Id: I3c501369abf27fa21293c0434c56a00aaf8a64cd
2024-02-05 15:59:43 +01:00
Manuel Stahl
af453eea71 Remove/mark unused parameters
All top level components should pass props to the generic react-admin
component to be more versatile.

Change-Id: I25dd099cde1aefacbc748dc4716a8b0a3db9ab93
2024-02-05 15:59:43 +01:00
Manuel Stahl
78d1d34a84 Simplify filters
Change-Id: I3e4cb7134a92c949bfb62d753c682a6c8fca6736
2024-02-05 15:59:43 +01:00
dklimpel
a222af273f Update dataProvider hooks
Change-Id: Ic19f7a6ad97b1392c96c91a19e76b8983c9d0fd2
2024-02-05 15:59:43 +01:00
Manuel Stahl
51def5775d Replace Fragment with short form
https://legacy.reactjs.org/docs/fragments.html#short-syntax

Change-Id: Ib1af57fc5e87ded8c1fee38dcbd60fae8621cb07
2024-02-05 15:59:43 +01:00
Manuel Stahl
6363e3d32e Use icon as loading spinner in login page
Change-Id: Ie0e8d0a9e1242849fb8b18875d752dd15facaaf9
2024-02-05 15:59:43 +01:00
Manuel Stahl
ba4345be1e Bump version to 0.9.0
Change-Id: I2acd7e62b4a3c7cd664e3e33200cfb4db96d638a
2024-02-05 15:59:40 +01:00
dklimpel
b70ee7c55d Upgrade to React-Admin 4 (#332)
Change-Id: Ia03486edfd934438580e614af754a0966f6fd6e3
2024-02-05 13:44:22 +01:00
Manuel Stahl
9f03ec9b0f Remove unused Menu.js
Change-Id: Idac1d6bcfd703ee499a2522eace6411e48ac176b
2024-02-05 12:47:14 +01:00
Dirk Klimpel
d8d393cdf6
Update deprecated resource definitions (#331) 2024-02-02 17:14:07 +01:00
Dirk Klimpel
58e02d6dff
Migrate makeStyles to MUI v5 (#330) 2024-02-02 16:37:39 +01:00
dependabot[bot]
17379a7325
Bump docker/setup-buildx-action from 2 to 3 (#395)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 15:04:39 +01:00
dependabot[bot]
13fbc419c2
Bump docker/login-action from 2 to 3 (#394)
Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 3.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 15:04:29 +01:00
dependabot[bot]
e757876c35
Bump docker/setup-qemu-action from 2 to 3 (#396)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 15:04:05 +01:00
dependabot[bot]
8b99695e60
Bump docker/build-push-action from 4 to 5 (#397)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 4 to 5.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 15:03:52 +01:00
dependabot[bot]
4ab5ae2585
Bump @babel/traverse from 7.16.5 to 7.23.2 (#418)
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.16.5 to 7.23.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 15:03:27 +01:00
dependabot[bot]
7579873d87
Bump actions/setup-node from 3 to 4 (#421)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3 to 4.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 15:03:17 +01:00
dependabot[bot]
e2ce934f2a
Bump @adobe/css-tools from 4.3.1 to 4.3.2 (#438)
Bumps [@adobe/css-tools](https://github.com/adobe/css-tools) from 4.3.1 to 4.3.2.
- [Changelog](https://github.com/adobe/css-tools/blob/main/History.md)
- [Commits](https://github.com/adobe/css-tools/commits)

---
updated-dependencies:
- dependency-name: "@adobe/css-tools"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 15:03:02 +01:00
dependabot[bot]
56bc4a56b9
Bump ra-language-french from 4.13.3 to 4.16.2 (#439)
Bumps [ra-language-french](https://github.com/marmelab/react-admin) from 4.13.3 to 4.16.2.
- [Release notes](https://github.com/marmelab/react-admin/releases)
- [Changelog](https://github.com/marmelab/react-admin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/marmelab/react-admin/compare/v4.13.3...v4.16.2)

---
updated-dependencies:
- dependency-name: ra-language-french
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 15:02:53 +01:00
dependabot[bot]
4bae32b57c
Bump @mui/icons-material from 5.14.8 to 5.14.19 (#440)
Bumps [@mui/icons-material](https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material) from 5.14.8 to 5.14.19.
- [Release notes](https://github.com/mui/material-ui/releases)
- [Changelog](https://github.com/mui/material-ui/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mui/material-ui/commits/v5.14.19/packages/mui-icons-material)

---
updated-dependencies:
- dependency-name: "@mui/icons-material"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 15:02:41 +01:00
dependabot[bot]
6e395e3b0f
Bump eslint from 8.48.0 to 8.55.0 (#441)
Bumps [eslint](https://github.com/eslint/eslint) from 8.48.0 to 8.55.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.48.0...v8.55.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 15:02:28 +01:00
Dirk Klimpel
323ad9f9e2
Disallow crawling in robots.txt (#448) 2024-01-31 19:24:46 +01:00
Dirk Klimpel
50af6499b0
Bump NodeJS to v18 (#407) 2023-10-04 09:23:43 +02:00
dependabot[bot]
12c22a170b
Bump papaparse from 5.3.1 to 5.4.1 (#388)
Bumps [papaparse](https://github.com/mholt/PapaParse) from 5.3.1 to 5.4.1.
- [Release notes](https://github.com/mholt/PapaParse/releases)
- [Commits](https://github.com/mholt/PapaParse/compare/5.3.1...5.4.1)

---
updated-dependencies:
- dependency-name: papaparse
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-12 15:00:40 +02:00
dependabot[bot]
5bbb2fc77b
Bump prop-types from 15.7.2 to 15.8.1 (#390)
Bumps [prop-types](https://github.com/facebook/prop-types) from 15.7.2 to 15.8.1.
- [Changelog](https://github.com/facebook/prop-types/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/prop-types/compare/v15.7.2...v15.8.1)

---
updated-dependencies:
- dependency-name: prop-types
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-12 13:41:00 +02:00
dependabot[bot]
499d15e667
Bump @mui/material from 5.4.0 to 5.14.8 (#391)
Bumps [@mui/material](https://github.com/mui/material-ui/tree/HEAD/packages/mui-material) from 5.4.0 to 5.14.8.
- [Release notes](https://github.com/mui/material-ui/releases)
- [Changelog](https://github.com/mui/material-ui/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mui/material-ui/commits/v5.14.8/packages/mui-material)

---
updated-dependencies:
- dependency-name: "@mui/material"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-12 13:40:40 +02:00
dependabot[bot]
bcf7dec10c
Bump eslint-config-prettier from 8.10.0 to 9.0.0 (#389)
Bumps [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) from 8.10.0 to 9.0.0.
- [Changelog](https://github.com/prettier/eslint-config-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-config-prettier/compare/v8.10.0...v9.0.0)

---
updated-dependencies:
- dependency-name: eslint-config-prettier
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-12 13:27:26 +02:00
dependabot[bot]
e1c7f44fec
Bump @mui/icons-material from 5.14.7 to 5.14.8 (#392)
Bumps [@mui/icons-material](https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material) from 5.14.7 to 5.14.8.
- [Release notes](https://github.com/mui/material-ui/releases)
- [Changelog](https://github.com/mui/material-ui/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mui/material-ui/commits/v5.14.8/packages/mui-icons-material)

---
updated-dependencies:
- dependency-name: "@mui/icons-material"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-12 13:26:27 +02:00
dependabot[bot]
0e25633398
Bump ra-language-french from 4.13.2 to 4.13.3 (#382)
Bumps [ra-language-french](https://github.com/marmelab/react-admin) from 4.13.2 to 4.13.3.
- [Release notes](https://github.com/marmelab/react-admin/releases)
- [Changelog](https://github.com/marmelab/react-admin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/marmelab/react-admin/compare/v4.13.2...v4.13.3)

---
updated-dependencies:
- dependency-name: ra-language-french
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-05 12:54:58 +02:00
Ezwen
8097fa4da4
Add raw sender uid in reports page (#334)
Fixes #333.
2023-09-05 10:50:38 +02:00
Stefan Möhrle
ad9a1c502b
Fix SSO-login for base urls with explicit port (#337) 2023-09-05 10:49:24 +02:00
dependabot[bot]
ec2e7b2ccb
Bump @emotion/react from 11.7.1 to 11.11.1 (#385)
Bumps [@emotion/react](https://github.com/emotion-js/emotion) from 11.7.1 to 11.11.1.
- [Release notes](https://github.com/emotion-js/emotion/releases)
- [Changelog](https://github.com/emotion-js/emotion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emotion-js/emotion/compare/@emotion/react@11.7.1...@emotion/react@11.11.1)

---
updated-dependencies:
- dependency-name: "@emotion/react"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-05 10:47:45 +02:00
dependabot[bot]
8bdeddd8f6
Bump ra-test from 3.19.4 to 3.19.12 (#384)
Bumps [ra-test](https://github.com/marmelab/react-admin) from 3.19.4 to 3.19.12.
- [Release notes](https://github.com/marmelab/react-admin/releases)
- [Changelog](https://github.com/marmelab/react-admin/blob/v3.19.12/CHANGELOG.md)
- [Commits](https://github.com/marmelab/react-admin/compare/v3.19.4...v3.19.12)

---
updated-dependencies:
- dependency-name: ra-test
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-05 10:47:25 +02:00
dependabot[bot]
e253fcb516
Bump @mui/icons-material from 5.3.1 to 5.14.7 (#386)
Bumps [@mui/icons-material](https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material) from 5.3.1 to 5.14.7.
- [Release notes](https://github.com/mui/material-ui/releases)
- [Changelog](https://github.com/mui/material-ui/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mui/material-ui/commits/v5.14.7/packages/mui-icons-material)

---
updated-dependencies:
- dependency-name: "@mui/icons-material"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-05 08:26:09 +02:00
dependabot[bot]
05a4f94d2f
Bump eslint from 8.32.0 to 8.48.0 (#383)
Bumps [eslint](https://github.com/eslint/eslint) from 8.32.0 to 8.48.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.32.0...v8.48.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-05 08:25:48 +02:00
dependabot[bot]
d747e24a96
Bump JamesIves/github-pages-deploy-action from 4.4.1 to 4.4.3 (#381)
Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.4.1 to 4.4.3.
- [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases)
- [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/v4.4.1...v4.4.3)

---
updated-dependencies:
- dependency-name: JamesIves/github-pages-deploy-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-05 08:23:21 +02:00
dependabot[bot]
35e3bbeb88
Bump actions/checkout from 3 to 4 (#380)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-05 08:23:03 +02:00
Francesco Carmelo Capria
c750f6b8e9
Add italian language option to login page (#379) 2023-08-31 21:57:51 +02:00
Francesco Carmelo Capria
0c5c762224
Italian translations (#374)
* Added italian language

* Bump @testing-library/react from 11.2.7 to 12.1.5 (#327)

Bumps [@testing-library/react](https://github.com/testing-library/react-testing-library) from 11.2.7 to 12.1.5.
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v11.2.7...v12.1.5)

---
updated-dependencies:
- dependency-name: "@testing-library/react"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump docker/build-push-action from 3 to 4 (#328)

Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3 to 4.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Added italian language

* Fix

* Updated yarn.lock

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-31 14:50:44 +02:00
dependabot[bot]
8fa78356a0
Bump docker/build-push-action from 3 to 4 (#328)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3 to 4.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-31 09:24:22 +02:00
dependabot[bot]
3fec64021a
Bump @testing-library/react from 11.2.7 to 12.1.5 (#327)
Bumps [@testing-library/react](https://github.com/testing-library/react-testing-library) from 11.2.7 to 12.1.5.
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v11.2.7...v12.1.5)

---
updated-dependencies:
- dependency-name: "@testing-library/react"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-31 09:23:51 +02:00
dependabot[bot]
15528e3e9b
Bump tough-cookie from 4.0.0 to 4.1.3 (#378)
Bumps [tough-cookie](https://github.com/salesforce/tough-cookie) from 4.0.0 to 4.1.3.
- [Release notes](https://github.com/salesforce/tough-cookie/releases)
- [Changelog](https://github.com/salesforce/tough-cookie/blob/master/CHANGELOG.md)
- [Commits](https://github.com/salesforce/tough-cookie/compare/v4.0.0...v4.1.3)

---
updated-dependencies:
- dependency-name: tough-cookie
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-30 22:15:18 +02:00
dependabot[bot]
39643bd8ab
Bump @adobe/css-tools from 4.0.2 to 4.3.1 (#377)
Bumps [@adobe/css-tools](https://github.com/adobe/css-tools) from 4.0.2 to 4.3.1.
- [Changelog](https://github.com/adobe/css-tools/blob/main/History.md)
- [Commits](https://github.com/adobe/css-tools/commits)

---
updated-dependencies:
- dependency-name: "@adobe/css-tools"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-30 22:15:06 +02:00
dependabot[bot]
d592234d4e
Bump word-wrap from 1.2.3 to 1.2.5 (#376)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.5.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.5)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-30 22:14:56 +02:00
Dirk Klimpel
67500a6023
Migrate material-ui icons to v5 (#329) 2023-08-30 22:13:14 +02:00
dependabot[bot]
ffce8bd64c
Bump semver from 6.3.0 to 6.3.1 (#375)
Bumps [semver](https://github.com/npm/node-semver) from 6.3.0 to 6.3.1.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/v6.3.1/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v6.3.0...v6.3.1)

---
updated-dependencies:
- dependency-name: semver
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-30 22:11:22 +02:00
dependabot[bot]
8b00f7d5ac
Bump react-admin from 3.19.7 to 3.19.12 (#339)
Bumps [react-admin](https://github.com/marmelab/react-admin) from 3.19.7 to 3.19.12.
- [Release notes](https://github.com/marmelab/react-admin/releases)
- [Changelog](https://github.com/marmelab/react-admin/blob/v3.19.12/CHANGELOG.md)
- [Commits](https://github.com/marmelab/react-admin/compare/v3.19.7...v3.19.12)

---
updated-dependencies:
- dependency-name: react-admin
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-30 22:09:57 +02:00
dependabot[bot]
bbd63f4838
Bump @emotion/styled from 11.6.0 to 11.10.6 (#342)
Bumps [@emotion/styled](https://github.com/emotion-js/emotion) from 11.6.0 to 11.10.6.
- [Release notes](https://github.com/emotion-js/emotion/releases)
- [Changelog](https://github.com/emotion-js/emotion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emotion-js/emotion/compare/@emotion/styled@11.6.0...@emotion/styled@11.10.6)

---
updated-dependencies:
- dependency-name: "@emotion/styled"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-30 22:09:42 +02:00
dependabot[bot]
bdf29afda0
Bump webpack from 5.74.0 to 5.76.1 (#349)
Bumps [webpack](https://github.com/webpack/webpack) from 5.74.0 to 5.76.1.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.74.0...v5.76.1)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-30 22:09:30 +02:00
dependabot[bot]
46fa936f20
Bump eslint-config-prettier from 8.3.0 to 8.8.0 (#353)
Bumps [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) from 8.3.0 to 8.8.0.
- [Release notes](https://github.com/prettier/eslint-config-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-config-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-config-prettier/compare/v8.3.0...v8.8.0)

---
updated-dependencies:
- dependency-name: eslint-config-prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-30 22:09:16 +02:00
dependabot[bot]
d502696c1f
Bump ra-language-french from 4.2.0 to 4.9.3 (#359)
Bumps [ra-language-french](https://github.com/marmelab/react-admin) from 4.2.0 to 4.9.3.
- [Release notes](https://github.com/marmelab/react-admin/releases)
- [Changelog](https://github.com/marmelab/react-admin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/marmelab/react-admin/compare/v4.2.0...v4.9.3)

---
updated-dependencies:
- dependency-name: ra-language-french
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-30 22:08:20 +02:00
100 changed files with 16320 additions and 15429 deletions

View File

@ -1,10 +1,13 @@
# Exclude a bunch of stuff which can make the build context a larger than it needs to be
tests/
build/
dist/
lib/
node_modules/
electron_app/
karma-reports/
.pnp.cjs
.pnp.loader.mjs
.idea/
.tmp/
config.json*

13
.editorconfig Normal file
View File

@ -0,0 +1,13 @@
# EditorConfig https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 120
trim_trailing_whitespace = true

5
.env
View File

@ -1,5 +0,0 @@
# This setting allows to fix the homeserver.
# If you set this setting, the user will not be able to select
# the server and have to use synapse-admin with this server.
#REACT_APP_SERVER=https://yourmatrixserver.example.com

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
yarn*.cjs binary

View File

@ -5,17 +5,22 @@ on:
branches: ["master"]
pull_request:
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 16
node-version: "18"
- name: Install dependencies
run: yarn --frozen-lockfile
run: yarn --immutable
- name: Run checks
run: yarn lint
- name: Run tests
run: yarn test

View File

@ -1,51 +1,63 @@
name: Create docker image(s) and push to docker hub
name: Create docker image(s) and push to docker hub and ghcr.io
# see https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#publishing-images-to-docker-hub-and-github-packages
on:
push:
# Sequence of patterns matched against refs/heads
# prettier-ignore
branches:
branches:
# Push events on master branch
- master
# Sequence of patterns matched against refs/tags
tags:
tags:
- '[0-9]+\.[0-9]+\.[0-9]+' # Push events to 0.X.X tag
jobs:
docker:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Calculate docker image tag
id: set-tag
run: |
case "${GITHUB_REF}" in
refs/heads/master|refs/heads/main)
tag=latest
;;
refs/tags/*)
tag=${GITHUB_REF#refs/tags/}
;;
*)
tag=${GITHUB_SHA}
;;
esac
echo "::set-output name=tag::$tag"
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
awesometechnologies/synapse-admin
ghcr.io/${{ github.repository }}
- name: Build and Push Tag
uses: docker/build-push-action@v3
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: "awesometechnologies/synapse-admin:${{ steps.set-tag.outputs.tag }}"
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64

View File

@ -5,22 +5,27 @@ on:
branches:
- main
- master
permissions:
contents: write
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v3
- uses: actions/setup-node@v3
uses: actions/checkout@v4
with:
node-version: "16"
fetch-depth: 100
fetch-tags: true
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install and Build 🔧
run: |
yarn install
yarn build
yarn install --immutable
yarn build --base=/synapse-admin
- name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@v4.4.1
uses: JamesIves/github-pages-deploy-action@v4.7.3
with:
branch: gh-pages
folder: build
folder: dist

View File

@ -13,18 +13,17 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "16"
- run: yarn install
node-version: "20"
- run: yarn install --immutable
- run: yarn build
- run: |
version=`git describe --dirty --tags || echo unknown`
mkdir -p dist
cp -r build synapse-admin-$version
cp -r dist synapse-admin-$version
tar chvzf dist/synapse-admin-$version.tar.gz synapse-admin-$version
- uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844
- uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
with:
files: dist/*.tar.gz
env:

View File

@ -1,51 +0,0 @@
name: Test docker image creation
on:
push:
# Sequence of patterns matched against refs/heads
# prettier-ignore
branches:
# Push events on branch fix_docker_cd
- fix_docker_cd
# Sequence of patterns matched against refs/tags
tags:
- '[0-9]+\.[0-9]+\.[0-9]+' # Push events to 0.X.X tag
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Calculate docker image tag
id: set-tag
run: |
case "${GITHUB_REF}" in
refs/heads/master|refs/heads/main)
tag=latest
;;
refs/tags/*)
tag=${GITHUB_REF#refs/tags/}
;;
*)
tag=${GITHUB_SHA}
;;
esac
echo "::set-output name=tag::$tag"
- name: Build and Push Tag
uses: docker/build-push-action@v3
with:
context: .
push: false
tags: "awesometechnologies/synapse-admin:${{ steps.set-tag.outputs.tag }}"
platforms: linux/amd64,linux/arm64

208
.gitignore vendored
View File

@ -1,23 +1,193 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
# Created by https://www.toptal.com/developers/gitignore/api/node,yarn,react,visualstudiocode
# Edit at https://www.toptal.com/developers/gitignore?templates=node,yarn,react,visualstudiocode
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### react ###
.DS_*
**/*.backup.*
**/*.back.*
node_modules
*.sublime*
psd
thumb
sketch
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
### yarn ###
# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
.yarn/*
!.yarn/releases
!.yarn/patches
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
# if you are NOT using Zero-installs, then:
# comment the following lines
!.yarn/cache
# and uncomment the following lines
# .pnp.*
# End of https://www.toptal.com/developers/gitignore/api/node,yarn,react,visualstudiocode

2
.prettierignore Normal file
View File

@ -0,0 +1,2 @@
.vscode
.yarn

View File

@ -1,11 +0,0 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid"
}

View File

@ -1,6 +0,0 @@
dist: focal
language: node_js
node_js:
- 17
cache: yarn

7
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"recommendations": [
"arcanis.vscode-zipfs",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}

10
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"search.exclude": {
"**/.yarn": true,
"**/.pnp.*": true
},
"eslint.nodePath": ".yarn/sdks",
"prettier.prettierPath": ".yarn/sdks/prettier/index.cjs",
"typescript.tsdk": ".yarn/sdks/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

925
.yarn/releases/yarn-4.4.1.cjs vendored Executable file

File diff suppressed because one or more lines are too long

20
.yarn/sdks/eslint/bin/eslint.js vendored Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/bin/eslint.js
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint/bin/eslint.js your application uses
module.exports = absRequire(`eslint/bin/eslint.js`);

20
.yarn/sdks/eslint/lib/api.js vendored Normal file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint your application uses
module.exports = absRequire(`eslint`);

View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/use-at-your-own-risk
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint/use-at-your-own-risk your application uses
module.exports = absRequire(`eslint/use-at-your-own-risk`);

14
.yarn/sdks/eslint/package.json vendored Normal file
View File

@ -0,0 +1,14 @@
{
"name": "eslint",
"version": "8.57.0-sdk",
"main": "./lib/api.js",
"type": "commonjs",
"bin": {
"eslint": "./bin/eslint.js"
},
"exports": {
"./package.json": "./package.json",
".": "./lib/api.js",
"./use-at-your-own-risk": "./lib/unsupported-api.js"
}
}

5
.yarn/sdks/integrations.yml vendored Normal file
View File

@ -0,0 +1,5 @@
# This file is automatically generated by @yarnpkg/sdks.
# Manual changes might be lost!
integrations:
- vscode

20
.yarn/sdks/prettier/bin/prettier.cjs vendored Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier/bin/prettier.cjs
require(absPnpApiPath).setup();
}
}
// Defer to the real prettier/bin/prettier.cjs your application uses
module.exports = absRequire(`prettier/bin/prettier.cjs`);

20
.yarn/sdks/prettier/index.cjs vendored Normal file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier
require(absPnpApiPath).setup();
}
}
// Defer to the real prettier your application uses
module.exports = absRequire(`prettier`);

7
.yarn/sdks/prettier/package.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"name": "prettier",
"version": "3.2.5-sdk",
"main": "./index.cjs",
"type": "commonjs",
"bin": "./bin/prettier.cjs"
}

20
.yarn/sdks/typescript/bin/tsc vendored Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsc
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/bin/tsc your application uses
module.exports = absRequire(`typescript/bin/tsc`);

20
.yarn/sdks/typescript/bin/tsserver vendored Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsserver
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/bin/tsserver your application uses
module.exports = absRequire(`typescript/bin/tsserver`);

20
.yarn/sdks/typescript/lib/tsc.js vendored Normal file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsc.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsc.js your application uses
module.exports = absRequire(`typescript/lib/tsc.js`);

225
.yarn/sdks/typescript/lib/tsserver.js vendored Normal file
View File

@ -0,0 +1,225 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const moduleWrapper = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default: {
str = `zip:${str}`;
} break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;
};
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserver.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsserver.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`));

View File

@ -0,0 +1,225 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const moduleWrapper = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default: {
str = `zip:${str}`;
} break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;
};
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserverlibrary.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsserverlibrary.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`));

20
.yarn/sdks/typescript/lib/typescript.js vendored Normal file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript your application uses
module.exports = absRequire(`typescript`);

10
.yarn/sdks/typescript/package.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"name": "typescript",
"version": "5.4.5-sdk",
"main": "./lib/typescript.js",
"type": "commonjs",
"bin": {
"tsc": "./bin/tsc",
"tsserver": "./bin/tsserver"
}
}

1
.yarnrc.yml Normal file
View File

@ -0,0 +1 @@
yarnPath: .yarn/releases/yarn-4.4.1.cjs

View File

@ -1,19 +1,26 @@
# Builder
FROM node:lts as builder
ARG REACT_APP_SERVER
FROM node:lts AS builder
LABEL org.opencontainers.image.url=https://github.com/Awesome-Technologies/synapse-admin org.opencontainers.image.source=https://github.com/Awesome-Technologies/synapse-admin
# Base path for synapse admin
ARG BASE_PATH=./
WORKDIR /src
COPY . /src
RUN yarn --network-timeout=300000 install
RUN REACT_APP_SERVER=$REACT_APP_SERVER yarn build
# Copy .yarn directory to the working directory (must be on a separate line!)
# Use https://docs.docker.com/engine/reference/builder/#copy---parents when available
COPY .yarn .yarn
COPY package.json .yarnrc.yml yarn.lock ./
# Disable telemetry and install packages
RUN yarn config set enableTelemetry 0 && yarn install --immutable --network-timeout=300000
COPY . /src
RUN yarn build --base=$BASE_PATH
# App
FROM nginx:alpine
FROM nginx:stable-alpine
COPY --from=builder /src/build /app
COPY --from=builder /src/dist /app
RUN rm -rf /usr/share/nginx/html \
&& ln -s /app /usr/share/nginx/html

View File

@ -13,10 +13,10 @@ This project is built using [react-admin](https://marmelab.com/react-admin/).
### Supported Synapse
It needs at least [Synapse](https://github.com/matrix-org/synapse) v1.52.0 for all functions to work as expected!
It needs at least [Synapse](https://github.com/element-hq/synapse) v1.93.0 for all functions to work as expected!
You get your server version with the request `/_synapse/admin/v1/server_version`.
See also [Synapse version API](https://matrix-org.github.io/synapse/develop/admin_api/version_api.html).
See also [Synapse version API](https://element-hq.github.io/synapse/latest/admin_api/version_api.html).
After entering the URL on the login page of synapse-admin the server version appears below the input field.
@ -27,7 +27,7 @@ You need access to the following endpoints:
- `/_matrix`
- `/_synapse/admin`
See also [Synapse administration endpoints](https://matrix-org.github.io/synapse/develop/reverse_proxy.html#synapse-administration-endpoints)
See also [Synapse administration endpoints](https://element-hq.github.io/synapse/latest/reverse_proxy.html#synapse-administration-endpoints)
### Use without install
@ -64,11 +64,6 @@ You have three options:
- download dependencies: `yarn install`
- start web server: `yarn start`
You can fix the homeserver, so that the user can no longer define it himself.
Either you define it at startup (e.g. `REACT_APP_SERVER=https://yourmatrixserver.example.com yarn start`)
or by editing it in the [.env](.env) file. See also the
[documentation](https://create-react-app.dev/docs/adding-custom-environment-variables/).
#### Steps for 3)
- run the Docker container from the public docker registry: `docker run -p 8080:80 awesometechnologies/synapse-admin` or use the [docker-compose.yml](docker-compose.yml): `docker-compose up -d`
@ -76,19 +71,16 @@ or by editing it in the [.env](.env) file. See also the
> note: if you're building on an architecture other than amd64 (for example a raspberry pi), make sure to define a maximum ram for node. otherwise the build will fail.
```yml
version: "3"
services:
synapse-admin:
container_name: synapse-admin
hostname: synapse-admin
build:
context: https://github.com/Awesome-Technologies/synapse-admin.git
# args:
args:
- BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
# - NODE_OPTIONS="--max_old_space_size=1024"
# # see #266, PUBLIC_URL must be without surrounding quotation marks
# - PUBLIC_URL=/synapse-admin
# - REACT_APP_SERVER="https://matrix.example.com"
# - BASE_PATH="/synapse-admin"
ports:
- "8080:80"
restart: unless-stopped
@ -96,11 +88,81 @@ or by editing it in the [.env](.env) file. See also the
- browse to http://localhost:8080
### Restricting available homeserver
You can restrict the homeserver(s), so that the user can no longer define it himself.
Edit `config.json` to restrict either to a single homeserver:
```json
{
"restrictBaseUrl": "https://your-matrixs-erver.example.com"
}
```
or to a list of homeservers:
```json
{
"restrictBaseUrl": ["https://your-first-matrix-server.example.com", "https://your-second-matrix-server.example.com"]
}
```
The `config.json` can be injected into a Docker container using a bind mount.
```yml
services:
synapse-admin:
...
volumes:
./config.json:/app/config.json:ro
...
```
### Serving Synapse-Admin on a different path
The path prefix where synapse-admin is served can only be changed during the build step.
If you downloaded the source code, use `yarn build --base=/my-prefix` to set a path prefix.
If you want to build your own Docker container, use the `BASE_PATH` argument.
We do not support directly changing the path where Synapse-Admin is served in the pre-built Docker container. Instead please use a reverse proxy if you need to move Synapse-Admin to a different base path. If you want to serve multiple applications with different paths on the same domain, you need a reverse proxy anyway.
Example for Traefik:
`docker-compose.yml`
```yml
services:
traefik:
image: traefik:mimolette
restart: unless-stopped
ports:
- 80:80
- 443:443
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
synapse-admin:
image: awesometechnologies/synapse-admin:latest
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.synapse-admin.rule=Host(`example.com`)&&PathPrefix(`/admin`)"
- "traefik.http.routers.synapse-admin.middlewares=admin,admin_path"
- "traefik.http.middlewares.admin.redirectregex.regex=^(.*)/admin/?"
- "traefik.http.middlewares.admin.redirectregex.replacement=$${1}/admin/"
- "traefik.http.middlewares.admin_path.stripprefix.prefixes=/admin"
```
## Screenshots
![Screenshots](./screenshots.jpg)
## Development
- Use `yarn test` to run all style, lint and unit tests
- See https://yarnpkg.com/getting-started/editor-sdks how to setup your IDE
- Use `yarn lint` to run all style and linter checks
- Use `yarn test` to run all unit tests
- Use `yarn fix` to fix the coding style

View File

@ -1,26 +1,21 @@
version: "3"
services:
synapse-admin:
container_name: synapse-admin
hostname: synapse-admin
image: awesometechnologies/synapse-admin:latest
# build:
# context: .
# context: .
# to use the docker-compose as standalone without a local repo clone,
# replace the context definition with this:
# context: https://github.com/Awesome-Technologies/synapse-admin.git
# args:
# if you're building on an architecture other than amd64, make sure
# to define a maximum ram for node. otherwise the build will fail.
# - NODE_OPTIONS="--max_old_space_size=1024"
# default is .
# - PUBLIC_URL=/synapse-admin
# You can use a fixed homeserver, so that the user can no longer
# define it himself
# - REACT_APP_SERVER="https://matrix.example.com"
# args:
# - BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
# if you're building on an architecture other than amd64, make sure
# to define a maximum ram for node. otherwise the build will fail.
# - NODE_OPTIONS="--max_old_space_size=1024"
# - BASE_PATH="/synapse-admin"
ports:
- "8080:80"
restart: unless-stopped

132
index.html Normal file
View File

@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Synapse-Admin"
/>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="./manifest.json" />
<link rel="shortcut icon" href="./favicon.ico" />
<title>Synapse-Admin</title>
<style>
body {
margin: 0;
padding: 0;
font-family: sans-serif;
}
.loader-container {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: #fafafa;
}
/* CSS Spinner from https://projects.lukehaas.me/css-loaders/ */
.loader,
.loader:before,
.loader:after {
border-radius: 50%;
}
.loader {
color: #283593;
font-size: 11px;
text-indent: -99999em;
margin: 55px auto;
position: relative;
width: 10em;
height: 10em;
box-shadow: inset 0 0 0 1em;
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
}
.loader:before,
.loader:after {
position: absolute;
content: '';
}
.loader:before {
width: 5.2em;
height: 10.2em;
background: #fafafa;
border-radius: 10.2em 0 0 10.2em;
top: -0.1em;
left: -0.1em;
-webkit-transform-origin: 5.2em 5.1em;
transform-origin: 5.2em 5.1em;
-webkit-animation: load2 2s infinite ease 1.5s;
animation: load2 2s infinite ease 1.5s;
}
.loader:after {
width: 5.2em;
height: 10.2em;
background: #fafafa;
border-radius: 0 10.2em 10.2em 0;
top: -0.1em;
left: 5.1em;
-webkit-transform-origin: 0px 5.1em;
transform-origin: 0px 5.1em;
-webkit-animation: load2 2s infinite ease;
animation: load2 2s infinite ease;
}
@-webkit-keyframes load2 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes load2 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">
<div class="loader-container">
<div class="loader">Loading...</div>
</div>
</div>
<script type="module" src="/src/index.tsx"></script>
<footer
style="position: relative; z-index: 2; height: 2em; margin-top: -2em; line-height: 2em; background-color: #eee; border: 0.5px solid #ddd">
<a id="copyright" href="https://github.com/Awesome-Technologies/synapse-admin"
style="margin-left: 1em; color: #888; font-family: Roboto, Helvetica, Arial, sans-serif; font-weight: 100; font-size: 0.8em; text-decoration: none;">
Synapse-Admin <b><span id="version"></span></b> by Awesome Technologies Innovationslabor GmbH
</a>
</footer>
</body>
<script>document.getElementById("version").textContent = __SYNAPSE_ADMIN_VERSION__</script>
</html>

13
jest.config.ts Normal file
View File

@ -0,0 +1,13 @@
import type { JestConfigWithTsJest } from "ts-jest";
const config: JestConfigWithTsJest = {
preset: "ts-jest",
testEnvironment: "jest-fixed-jsdom",
collectCoverage: true,
coveragePathIgnorePatterns: ["node_modules", "dist"],
coverageDirectory: "<rootDir>/coverage/",
coverageReporters: ["html", "text", "text-summary", "cobertura"],
extensionsToTreatAsEsm: [".ts", ".tsx"],
setupFilesAfterEnv: ["<rootDir>/src/jest.setup.ts"],
};
export default config;

View File

@ -1,7 +1,8 @@
{
"name": "synapse-admin",
"version": "0.8.5",
"version": "0.10.3",
"description": "Admin GUI for the Matrix.org server Synapse",
"type": "module",
"author": "Awesome Technologies Innovationslabor GmbH",
"license": "Apache-2.0",
"homepage": ".",
@ -9,48 +10,146 @@
"type": "git",
"url": "https://github.com/Awesome-Technologies/synapse-admin"
},
"packageManager": "yarn@4.4.1",
"devDependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^11.2.6",
"@testing-library/user-event": "^14.4.3",
"eslint": "^8.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-prettier": "^4.2.1",
"@eslint/js": "^9.7.0",
"@mui/system": "^7.1.0",
"@mui/utils": "^7.1.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.7",
"@types/node": "^20.14.12",
"@types/papaparse": "^5.3.14",
"@types/react": "^18.3.3",
"@typescript-eslint/eslint-plugin": "^7.16.1",
"@typescript-eslint/parser": "^7.16.1",
"@vitejs/plugin-react": "^4.0.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.9.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-unused-imports": "^4.1.4",
"eslint-plugin-yaml": "^1.0.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"prettier": "^2.2.0",
"ra-test": "^3.15.0"
"jest-fixed-jsdom": "^0.0.9",
"prettier": "^3.3.3",
"react-test-renderer": "^18.3.1",
"ts-jest": "^29.3.4",
"ts-node": "^10.9.2",
"typescript": "^5.4.5",
"typescript-eslint": "^8.32.1",
"vite": "^6.3.5",
"vite-plugin-version-mark": "^0.1.0"
},
"dependencies": {
"@emotion/react": "^11.7.1",
"@emotion/styled": "^11.6.0",
"@mui/icons-material": "^5.3.1",
"@mui/material": "^5.4.0",
"papaparse": "^5.2.0",
"prop-types": "^15.7.2",
"ra-language-chinese": "^2.0.10",
"ra-language-french": "^4.2.0",
"ra-language-german": "^3.13.4",
"react": "^17.0.0",
"react-admin": "^3.19.7",
"react-dom": "^17.0.2",
"react-scripts": "^5.0.1"
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@haleos/ra-language-german": "^1.0.0",
"@haxqer/ra-language-chinese": "^4.16.2",
"@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0",
"@tanstack/react-query": "^5.59.12",
"history": "^5.3.0",
"lodash": "^4.17.21",
"papaparse": "^5.4.1",
"query-string": "^7.1.3",
"ra-core": "^5.8.3",
"ra-i18n-polyglot": "^5.8.3",
"ra-language-english": "^5.8.3",
"ra-language-farsi": "^5.0.0",
"ra-language-french": "^5.8.3",
"ra-language-italian": "^3.13.1",
"ra-language-russian": "^4.14.2",
"react": "^18.3.1",
"react-admin": "^5.8.3",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.1",
"react-is": "^18.3.1",
"react-router": "^7.6.1",
"react-router-dom": "^7.6.1"
},
"scripts": {
"start": "REACT_APP_VERSION=$(git describe --tags) react-scripts start",
"build": "REACT_APP_VERSION=$(git describe --tags) react-scripts build",
"fix:other": "yarn prettier --write",
"fix:code": "yarn test:lint --fix",
"fix": "yarn fix:code && yarn fix:other",
"prettier": "prettier --ignore-path .gitignore \"**/*.{js,jsx,json,md,scss,yaml,yml}\"",
"test:code": "react-scripts test",
"test:lint": "eslint --ignore-path .gitignore --ext .js,.jsx .",
"test:style": "yarn prettier --list-different",
"test": "yarn test:style && yarn test:lint && yarn test:code",
"eject": "react-scripts eject"
"start": "vite serve",
"build": "vite build",
"lint": "eslint --ignore-path .gitignore --ext .ts,.tsx,.yml,.yaml .",
"fix": "yarn lint --fix",
"test": "yarn jest",
"test:watch": "yarn jest --watch"
},
"eslintConfig": {
"extends": "react-app"
"env": {
"browser": true
},
"plugins": [
"import",
"prettier",
"unused-imports",
"@typescript-eslint",
"yaml"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/stylistic",
"plugin:import/typescript",
"plugin:yaml/legacy"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.eslint.json"
},
"root": true,
"rules": {
"prettier/prettier": "error",
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": [
"**/vite.config.ts",
"**/jest.setup.ts",
"**/*.test.ts",
"**/*.test.tsx"
]
}
],
"import/order": [
"error",
{
"alphabetize": {
"order": "asc",
"caseInsensitive": false
},
"newlines-between": "always",
"groups": [
"external",
"builtin",
"internal",
[
"parent",
"sibling",
"index"
]
]
}
],
"unused-imports/no-unused-imports-ts": 2
}
},
"prettier": {
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "avoid"
},
"browserslist": {
"production": [

1
public/config.json Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -1,3 +1,3 @@
id,displayname,password,is_guest,admin,deactivated
@testuser22:example.org,Jane Doe,secretpassword,false,true,false
testuser22,Jane Doe,secretpassword,false,true,false
,John Doe,,false,false,false

1 id displayname password is_guest admin deactivated
2 @testuser22:example.org testuser22 Jane Doe secretpassword false true false
3 John Doe false false false

View File

@ -1,49 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Synapse-Admin"
/>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Synapse-Admin</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<footer
style="position: relative; z-index: 2; height: 2em; margin-top: -2em; line-height: 2em; background-color: #eee; border: 0.5px solid #ddd">
<a id="copyright" href="https://github.com/Awesome-Technologies/synapse-admin"
style="margin-left: 1em; color: #888; font-family: Roboto, Helvetica, Arial, sans-serif; font-weight: 100; font-size: 0.8em; text-decoration: none;">
Synapse-Admin <b>(%REACT_APP_VERSION%)</b> by Awesome Technologies Innovationslabor GmbH
</a>
</footer>
</body>
</html>

View File

@ -1,2 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow: /

View File

@ -1,105 +0,0 @@
import React from "react";
import { Admin, Resource, resolveBrowserLocale } from "react-admin";
import polyglotI18nProvider from "ra-i18n-polyglot";
import authProvider from "./synapse/authProvider";
import dataProvider from "./synapse/dataProvider";
import { UserList, UserCreate, UserEdit } from "./components/users";
import { RoomList, RoomShow } from "./components/rooms";
import { ReportList, ReportShow } from "./components/EventReports";
import LoginPage from "./components/LoginPage";
import ConfirmationNumberIcon from "@mui/icons-material/ConfirmationNumber";
import CloudQueueIcon from "@mui/icons-material/CloudQueue";
import EqualizerIcon from "@mui/icons-material/Equalizer";
import UserIcon from "@mui/icons-material/Group";
import { UserMediaStatsList } from "./components/statistics";
import RoomIcon from "@mui/icons-material/ViewList";
import ReportIcon from "@mui/icons-material/Warning";
import FolderSharedIcon from "@mui/icons-material/FolderShared";
import { DestinationList, DestinationShow } from "./components/destinations";
import { ImportFeature } from "./components/ImportFeature";
import {
RegistrationTokenCreate,
RegistrationTokenEdit,
RegistrationTokenList,
} from "./components/RegistrationTokens";
import { RoomDirectoryList } from "./components/RoomDirectory";
import { Route } from "react-router-dom";
import germanMessages from "./i18n/de";
import englishMessages from "./i18n/en";
import frenchMessages from "./i18n/fr";
import chineseMessages from "./i18n/zh";
// TODO: Can we use lazy loading together with browser locale?
const messages = {
de: germanMessages,
en: englishMessages,
fr: frenchMessages,
zh: chineseMessages,
};
const i18nProvider = polyglotI18nProvider(
locale => (messages[locale] ? messages[locale] : messages.en),
resolveBrowserLocale()
);
const App = () => (
<Admin
disableTelemetry
loginPage={LoginPage}
authProvider={authProvider}
dataProvider={dataProvider}
i18nProvider={i18nProvider}
customRoutes={[
<Route key="userImport" path="/import_users" component={ImportFeature} />,
]}
>
<Resource
name="users"
list={UserList}
create={UserCreate}
edit={UserEdit}
icon={UserIcon}
/>
<Resource name="rooms" list={RoomList} show={RoomShow} icon={RoomIcon} />
<Resource
name="user_media_statistics"
list={UserMediaStatsList}
icon={EqualizerIcon}
/>
<Resource
name="reports"
list={ReportList}
show={ReportShow}
icon={ReportIcon}
/>
<Resource
name="room_directory"
list={RoomDirectoryList}
icon={FolderSharedIcon}
/>
<Resource
name="destinations"
list={DestinationList}
show={DestinationShow}
icon={CloudQueueIcon}
/>
<Resource
name="registration_tokens"
list={RegistrationTokenList}
create={RegistrationTokenCreate}
edit={RegistrationTokenEdit}
icon={ConfirmationNumberIcon}
/>
<Resource name="connections" />
<Resource name="devices" />
<Resource name="room_members" />
<Resource name="users_media" />
<Resource name="joined_rooms" />
<Resource name="pushers" />
<Resource name="servernotices" />
<Resource name="forward_extremities" />
<Resource name="room_state" />
<Resource name="destination_rooms" />
</Admin>
);
export default App;

View File

@ -1,9 +0,0 @@
import React from "react";
import { render } from "@testing-library/react";
import App from "./App";
describe("App", () => {
it("renders", () => {
render(<App />);
});
});

12
src/App.test.tsx Normal file
View File

@ -0,0 +1,12 @@
import { render, screen } from "@testing-library/react";
import fetchMock from "jest-fetch-mock";
fetchMock.enableMocks();
import App from "./App";
describe("App", () => {
it("renders", async () => {
render(<App />);
await screen.findAllByText("Welcome to Synapse-admin");
});
});

85
src/App.tsx Normal file
View File

@ -0,0 +1,85 @@
import { merge } from "lodash";
import polyglotI18nProvider from "ra-i18n-polyglot";
import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin";
import { Route } from "react-router-dom";
import { ImportFeature } from "./components/ImportFeature";
import germanMessages from "./i18n/de";
import englishMessages from "./i18n/en";
import frenchMessages from "./i18n/fr";
import italianMessages from "./i18n/it";
import russianMessages from "./i18n/ru";
import chineseMessages from "./i18n/zh";
import LoginPage from "./pages/LoginPage";
import destinations from "./resources/destinations";
import registrationToken from "./resources/registration_tokens";
import reports from "./resources/reports";
import roomDirectory from "./resources/room_directory";
import rooms from "./resources/rooms";
import userMediaStats from "./resources/user_media_statistics";
import users from "./resources/users";
import authProvider from "./synapse/authProvider";
import dataProvider from "./synapse/dataProvider";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// TODO: Can we use lazy loading together with browser locale?
const messages = {
de: germanMessages,
en: englishMessages,
fr: frenchMessages,
it: italianMessages,
ru: russianMessages,
zh: chineseMessages,
};
const i18nProvider = polyglotI18nProvider(
locale => (messages[locale] ? merge({}, messages.en, messages[locale]) : messages.en),
resolveBrowserLocale(),
[
{ locale: "en", name: "English" },
{ locale: "de", name: "Deutsch" },
{ locale: "fr", name: "Français" },
{ locale: "it", name: "Italiano" },
{ locale: "fa", name: "Persian(فارسی)" },
{ locale: "ru", name: "Russian(Русский)" },
{ locale: "zh", name: "简体中文" },
]
);
const queryClient = new QueryClient();
const App = () => (
<QueryClientProvider client={queryClient}>
<Admin
disableTelemetry
requireAuth
loginPage={LoginPage}
authProvider={authProvider}
dataProvider={dataProvider}
i18nProvider={i18nProvider}
>
<CustomRoutes>
<Route path="/import_users" element={<ImportFeature />} />
</CustomRoutes>
<Resource {...users} />
<Resource {...rooms} />
<Resource {...userMediaStats} />
<Resource {...reports} />
<Resource {...roomDirectory} />
<Resource {...destinations} />
<Resource {...registrationToken} />
<Resource name="connections" />
<Resource name="devices" />
<Resource name="room_members" />
<Resource name="users_media" />
<Resource name="joined_rooms" />
<Resource name="pushers" />
<Resource name="servernotices" />
<Resource name="forward_extremities" />
<Resource name="room_state" />
<Resource name="destination_rooms" />
</Admin>
</QueryClientProvider>
);
export default App;

9
src/AppContext.tsx Normal file
View File

@ -0,0 +1,9 @@
import { createContext, useContext } from "react";
interface AppContextType {
restrictBaseUrl: string | string[];
}
export const AppContext = createContext({});
export const useAppContext = () => useContext(AppContext) as AppContextType;

View File

@ -0,0 +1,18 @@
import { render, screen } from "@testing-library/react";
import { RecordContextProvider } from "react-admin";
import AvatarField from "./AvatarField";
describe("AvatarField", () => {
it("shows image", () => {
const value = {
avatar: "foo",
};
render(
<RecordContextProvider value={value}>
<AvatarField source="avatar" />
</RecordContextProvider>
);
expect(screen.getByRole("img").getAttribute("src")).toBe("foo");
});
});

View File

@ -0,0 +1,13 @@
import { get } from "lodash";
import { Avatar } from "@mui/material";
import { useRecordContext } from "react-admin";
const AvatarField = ({ source, ...rest }) => {
const record = useRecordContext(rest);
const src = get(record, source)?.toString();
const { alt, classes, sizes, sx, variant } = rest;
return <Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant} />;
};
export default AvatarField;

View File

@ -1,122 +0,0 @@
import React from "react";
import {
Datagrid,
DateField,
List,
NumberField,
Pagination,
ReferenceField,
Show,
Tab,
TabbedShowLayout,
TextField,
useTranslate,
} from "react-admin";
import PageviewIcon from "@mui/icons-material/Pageview";
import ViewListIcon from "@mui/icons-material/ViewList";
const date_format = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const ReportPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
export const ReportShow = props => {
const translate = useTranslate();
return (
<Show {...props}>
<TabbedShowLayout>
<Tab
label={translate("synapseadmin.reports.tabs.basic", {
smart_count: 1,
})}
icon={<ViewListIcon />}
>
<DateField
source="received_ts"
showTime
options={date_format}
sortable={true}
/>
<ReferenceField source="user_id" reference="users">
<TextField source="id" />
</ReferenceField>
<NumberField source="score" />
<TextField source="reason" />
<TextField source="name" />
<TextField
source="canonical_alias"
label="resources.rooms.fields.canonical_alias"
/>
<ReferenceField
source="room_id"
reference="rooms"
link="show"
label="resources.rooms.fields.room_id"
>
<TextField source="id" />
</ReferenceField>
</Tab>
<Tab
label="synapseadmin.reports.tabs.detail"
icon={<PageviewIcon />}
path="detail"
>
<DateField
source="event_json.origin_server_ts"
showTime
options={date_format}
sortable={true}
/>
<ReferenceField source="sender" reference="users">
<TextField source="id" />
</ReferenceField>
<TextField source="event_id" />
<TextField source="event_json.origin" />
<TextField source="event_json.type" />
<TextField source="event_json.content.msgtype" />
<TextField source="event_json.content.body" />
<TextField source="event_json.content.format" />
<TextField source="event_json.content.formatted_body" />
<TextField source="event_json.content.algorithm" />
<TextField
source="event_json.content.device_id"
label="resources.users.fields.device_id"
/>
</Tab>
</TabbedShowLayout>
</Show>
);
};
export const ReportList = ({ ...props }) => {
return (
<List
{...props}
pagination={<ReportPagination />}
sort={{ field: "received_ts", order: "DESC" }}
bulkActionButtons={false}
>
<Datagrid rowClick="show">
<TextField source="id" sortable={false} />
<DateField
source="received_ts"
showTime
options={date_format}
sortable={true}
/>
<TextField sortable={false} source="user_id" />
<TextField sortable={false} source="name" />
<TextField sortable={false} source="score" />
</Datagrid>
</List>
);
};

View File

@ -1,12 +1,6 @@
import React, { useState } from "react";
import {
Button as ReactAdminButton,
useDataProvider,
useNotify,
Title,
} from "react-admin";
import { parse as parseCsv, unparse as unparseCsv } from "papaparse";
import GetAppIcon from "@mui/icons-material/GetApp";
import { parse as parseCsv, unparse as unparseCsv, ParseResult } from "papaparse";
import { ChangeEvent, useState } from "react";
import {
Button,
Card,
@ -18,49 +12,65 @@ import {
FormControlLabel,
NativeSelect,
} from "@mui/material";
import { useTranslate } from "ra-core";
import { generateRandomUser } from "./users";
import { DataProvider, useTranslate } from "ra-core";
import { useDataProvider, useNotify, RaRecord, Title } from "react-admin";
import { generateRandomMxId, generateRandomPassword } from "../synapse/synapse";
const LOGGING = true;
export const ImportButton = ({ label, variant = "text" }) => {
return (
<ReactAdminButton
color="primary"
component="span"
variant={variant}
label={label}
>
<GetAppIcon style={{ transform: "rotate(180deg)", fontSize: "20" }} />
</ReactAdminButton>
);
};
const expectedFields = ["id", "displayname"].sort();
const optionalFields = [
"user_type",
"guest",
"admin",
"deactivated",
"avatar_url",
"password",
].sort();
function TranslatableOption({ value, text }) {
const translate = useTranslate();
return <option value={value}>{translate(text)}</option>;
}
const FilePicker = props => {
const [values, setValues] = useState(null);
const [error, setError] = useState(null);
const [stats, setStats] = useState(null);
type Progress = {
done: number;
limit: number;
} | null;
interface ImportLine {
id: string;
displayname: string;
user_type?: string;
name?: string;
deactivated?: boolean;
guest?: boolean;
admin?: boolean;
is_admin?: boolean;
password?: string;
avatar_url?: string;
}
interface ChangeStats {
total: number;
id: number;
is_guest: number;
admin: number;
password: number;
}
interface ImportResult {
skippedRecords: RaRecord[];
erroredRecords: RaRecord[];
succeededRecords: RaRecord[];
totalRecordCount: number;
changeStats: ChangeStats;
wasDryRun: boolean;
}
const FilePicker = () => {
const [values, setValues] = useState<ImportLine[]>([]);
const [error, setError] = useState<string | string[] | null>(null);
const [stats, setStats] = useState<ChangeStats | null>(null);
const [dryRun, setDryRun] = useState(true);
const [progress, setProgress] = useState(null);
const [progress, setProgress] = useState<Progress>(null);
const [importResults, setImportResults] = useState(null);
const [skippedRecords, setSkippedRecords] = useState(null);
const [importResults, setImportResults] = useState<ImportResult | null>(null);
const [skippedRecords, setSkippedRecords] = useState<string>("");
const [conflictMode, setConflictMode] = useState("stop");
const [passwordMode, setPasswordMode] = useState(true);
@ -71,14 +81,15 @@ const FilePicker = props => {
const dataProvider = useDataProvider();
const onFileChange = async e => {
const onFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (progress !== null) return;
setValues(null);
setValues([]);
setError(null);
setStats(null);
setImportResults(null);
const file = e.target.files ? e.target.files[0] : null;
if (!file) return;
/* Let's refuse some unreasonably big files instead of freezing
* up the browser */
if (file.size > 100000000) {
@ -90,12 +101,12 @@ const FilePicker = props => {
return;
}
try {
parseCsv(file, {
parseCsv<ImportLine>(file, {
header: true,
skipEmptyLines: true /* especially for a final EOL in the csv file */,
complete: result => {
if (result.error) {
setError(result.error);
if (result.errors) {
setError(result.errors.map(e => e.toString()));
}
/* Papaparse is very lenient, we may be able to salvage
* the data in the file. */
@ -103,32 +114,17 @@ const FilePicker = props => {
},
});
} catch {
setError(true);
setError("Unknown error");
return null;
}
};
const verifyCsv = (
{ data, meta, errors },
{ setValues, setStats, setError }
) => {
const verifyCsv = ({ data, meta, errors }: ParseResult<ImportLine>, { setValues, setStats, setError }) => {
/* First, verify the presence of required fields */
let eF = Array.from(expectedFields);
let oF = Array.from(optionalFields);
const missingFields = expectedFields.filter(eF => !meta.fields?.includes(eF));
meta.fields.forEach(name => {
if (eF.includes(name)) {
eF = eF.filter(v => v !== name);
}
if (oF.includes(name)) {
oF = oF.filter(v => v !== name);
}
});
if (eF.length !== 0) {
setError(
translate("import_users.error.required_field", { field: eF[0] })
);
if (missingFields.length > 0) {
setError(translate("import_users.error.required_field", { field: missingFields[0] }));
return false;
}
@ -138,7 +134,7 @@ const FilePicker = props => {
/* Collect some stats to prevent sneaky csv files from adding admin
users or something.
*/
let stats = {
const stats = {
user_types: { default: 0 },
is_guest: 0,
admin: 0,
@ -150,6 +146,7 @@ const FilePicker = props => {
total: data.length,
};
const errorMessages = errors.map(e => e.message);
data.forEach((line, idx) => {
if (line.user_type === undefined || line.user_type === "") {
stats.user_types.default++;
@ -160,14 +157,13 @@ const FilePicker = props => {
* resource so it gives sensible field names and doesn't duplicate
* id as "name"?
*/
if (meta.fields.includes("name")) {
if (meta.fields?.includes("name")) {
delete line.name;
}
if (meta.fields.includes("user_type")) {
if (meta.fields?.includes("user_type")) {
delete line.user_type;
}
if (meta.fields.includes("is_admin")) {
line.admin = line.is_admin;
if (meta.fields?.includes("is_admin")) {
delete line.is_admin;
}
@ -177,7 +173,7 @@ const FilePicker = props => {
line[f] = true; // we need true booleans instead of strings
} else {
if (line[f] !== "false" && line[f] !== "") {
errors.push(
errorMessages.push(
translate("import_users.error.invalid_value", {
field: f,
row: idx,
@ -201,8 +197,8 @@ const FilePicker = props => {
}
});
if (errors.length > 0) {
setError(errors);
if (errorMessages.length > 0) {
setError(errorMessages);
}
setStats(stats);
setValues(data);
@ -210,7 +206,7 @@ const FilePicker = props => {
return true;
};
const runImport = async e => {
const runImport = async () => {
if (progress !== null) {
notify("import_users.errors.already_in_progress");
return;
@ -239,61 +235,40 @@ const FilePicker = props => {
// which doesn't look very good.
const doImport = async (
dataProvider,
data,
conflictMode,
passwordMode,
useridMode,
dryRun,
setProgress,
setError
) => {
let skippedRecords = [];
let erroredRecords = [];
let succeededRecords = [];
let changeStats = {
toAdmin: 0,
toGuest: 0,
toRegular: 0,
replacedPassword: 0,
dataProvider: DataProvider,
data: ImportLine[],
conflictMode: string,
passwordMode: boolean,
useridMode: string,
dryRun: boolean,
setProgress: (progress: Progress) => void,
setError: (message: string) => void
): Promise<ImportResult> => {
const skippedRecords: ImportLine[] = [];
const erroredRecords: ImportLine[] = [];
const succeededRecords: ImportLine[] = [];
const changeStats: ChangeStats = {
total: 0,
id: 0,
is_guest: 0,
admin: 0,
password: 0,
};
let entriesDone = 0;
let entriesCount = data.length;
const entriesCount = data.length;
try {
setProgress({ done: entriesDone, limit: entriesCount });
for (const entry of data) {
let userRecord = {};
let overwriteData = {};
const userRecord = { ...entry };
// No need to do a bunch of cryptographic random number getting if
// we are using neither a generated password nor a generated user id.
if (
useridMode === "ignore" ||
entry.id === undefined ||
entry.password === undefined ||
passwordMode === false
) {
overwriteData = generateRandomUser();
// Ignoring IDs or the entry lacking an ID means we keep the
// ID field in the overwrite data.
if (!(useridMode === "ignore" || entry.id === undefined)) {
delete overwriteData.id;
}
// Not using passwords from the csv or this entry lacking a password
// means we keep the password field in the overwrite data.
if (
!(
passwordMode === false ||
entry.password === undefined ||
entry.password === ""
)
) {
delete overwriteData.password;
}
if (useridMode === "ignore" || userRecord.id === undefined) {
userRecord.id = generateRandomMxId();
}
if (passwordMode === false || entry.password === undefined) {
userRecord.password = generateRandomPassword();
}
/* TODO record update stats (especially admin no -> yes, deactivated x -> !x, ... */
Object.assign(userRecord, entry);
Object.assign(userRecord, overwriteData);
/* For these modes we will consider the ID that's in the record.
* If the mode is "stop", we will not continue adding more records, and
@ -319,14 +294,11 @@ const FilePicker = props => {
* We do a simple retry loop so that an accidental hit on an existing ID
* doesn't trip us up.
*/
if (LOGGING)
console.log(
"will check for existence of record " + JSON.stringify(userRecord)
);
if (LOGGING) console.log("will check for existence of record " + JSON.stringify(userRecord));
let retries = 0;
const submitRecord = recordData => {
const submitRecord = (recordData: ImportLine) => {
return dataProvider.getOne("users", { id: recordData.id }).then(
async alreadyExists => {
async () => {
if (LOGGING) console.log("already existed");
if (useridMode === "update" || conflictMode === "skip") {
@ -338,9 +310,8 @@ const FilePicker = props => {
})
);
} else {
const overwriteData = generateRandomUser();
const newRecordData = Object.assign({}, recordData, {
id: overwriteData.id,
id: generateRandomMxId(),
});
retries++;
if (retries > 512) {
@ -351,15 +322,8 @@ const FilePicker = props => {
}
}
},
async okToSubmit => {
if (LOGGING)
console.log(
"OK to create record " +
recordData.id +
" (" +
recordData.displayname +
")."
);
async () => {
if (LOGGING) console.log("OK to create record " + recordData.id + " (" + recordData.displayname + ").");
if (!dryRun) {
await dataProvider.create("users", { data: recordData });
@ -379,7 +343,7 @@ const FilePicker = props => {
setError(
translate("import_users.error.at_entry", {
entry: entriesDone + 1,
message: e.message,
message: e instanceof Error ? e.message : String(e),
})
);
setProgress(null);
@ -406,7 +370,7 @@ const FilePicker = props => {
element.click();
};
const onConflictModeChanged = async e => {
const onConflictModeChanged = async (e: ChangeEvent<HTMLSelectElement>) => {
if (progress !== null) {
return;
}
@ -415,7 +379,7 @@ const FilePicker = props => {
setConflictMode(value);
};
const onPasswordModeChange = e => {
const onPasswordModeChange = (e: ChangeEvent<HTMLInputElement>) => {
if (progress !== null) {
return;
}
@ -423,7 +387,7 @@ const FilePicker = props => {
setPasswordMode(e.target.checked);
};
const onUseridModeChanged = async e => {
const onUseridModeChanged = async (e: ChangeEvent<HTMLSelectElement>) => {
if (progress !== null) {
return;
}
@ -432,11 +396,11 @@ const FilePicker = props => {
setUseridMode(value);
};
const onDryRunModeChanged = ev => {
const onDryRunModeChanged = (e: ChangeEvent<HTMLInputElement>) => {
if (progress !== null) {
return;
}
setDryRun(ev.target.checked);
setDryRun(e.target.checked);
};
// render individual small components
@ -444,28 +408,11 @@ const FilePicker = props => {
const statsCards = stats &&
!importResults && [
<Container>
<CardHeader
title={translate("import_users.cards.importstats.header")}
/>
<CardHeader title={translate("import_users.cards.importstats.header")} />
<CardContent>
<div>
{translate(
"import_users.cards.importstats.users_total",
stats.total
)}
</div>
<div>
{translate(
"import_users.cards.importstats.guest_count",
stats.is_guest
)}
</div>
<div>
{translate(
"import_users.cards.importstats.admin_count",
stats.admin
)}
</div>
<div>{translate("import_users.cards.importstats.users_total", stats.total)}</div>
<div>{translate("import_users.cards.importstats.guest_count", stats.is_guest)}</div>
<div>{translate("import_users.cards.importstats.admin_count", stats.admin)}</div>
</CardContent>
</Container>,
<Container>
@ -478,19 +425,9 @@ const FilePicker = props => {
</div>
{stats.id > 0 ? (
<div>
<NativeSelect
onChange={onUseridModeChanged}
value={useridMode}
enabled={(progress !== null).toString()}
>
<TranslatableOption
value="ignore"
text="import_users.cards.ids.mode.ignore"
/>
<TranslatableOption
value="update"
text="import_users.cards.ids.mode.update"
/>
<NativeSelect onChange={onUseridModeChanged} value={useridMode} disabled={progress !== null}>
<TranslatableOption value="ignore" text="import_users.cards.ids.mode.ignore" />
<TranslatableOption value="update" text="import_users.cards.ids.mode.update" />
</NativeSelect>
</div>
) : (
@ -504,20 +441,13 @@ const FilePicker = props => {
<div>
{stats.password === stats.total
? translate("import_users.cards.passwords.all_passwords_present")
: translate(
"import_users.cards.passwords.count_passwords_present",
stats.password
)}
: translate("import_users.cards.passwords.count_passwords_present", stats.password)}
</div>
{stats.password > 0 ? (
<div>
<FormControlLabel
control={
<Checkbox
checked={passwordMode}
enabled={(progress !== null).toString()}
onChange={onPasswordModeChange}
/>
<Checkbox checked={passwordMode} disabled={progress !== null} onChange={onPasswordModeChange} />
}
label={translate("import_users.cards.passwords.use_passwords")}
/>
@ -529,31 +459,21 @@ const FilePicker = props => {
</Container>,
];
let conflictCards = stats && !importResults && (
const conflictCards = stats && !importResults && (
<Container>
<CardHeader title={translate("import_users.cards.conflicts.header")} />
<CardContent>
<div>
<NativeSelect
onChange={onConflictModeChanged}
value={conflictMode}
enabled={(progress !== null).toString()}
>
<TranslatableOption
value="stop"
text="import_users.cards.conflicts.mode.stop"
/>
<TranslatableOption
value="skip"
text="import_users.cards.conflicts.mode.skip"
/>
<NativeSelect onChange={onConflictModeChanged} value={conflictMode} disabled={progress !== null}>
<TranslatableOption value="stop" text="import_users.cards.conflicts.mode.stop" />
<TranslatableOption value="skip" text="import_users.cards.conflicts.mode.skip" />
</NativeSelect>
</div>
</CardContent>
</Container>
);
let errorCards = error && (
const errorCards = error && (
<Container>
<CardHeader title={translate("import_users.error.error")} />
<CardContent>
@ -564,7 +484,7 @@ const FilePicker = props => {
</Container>
);
let uploadCard = !importResults && (
const uploadCard = !importResults && (
<Container>
<CardHeader title={translate("import_users.cards.upload.header")} />
<CardContent>
@ -572,35 +492,22 @@ const FilePicker = props => {
<a href="./data/example.csv">example.csv</a>
<br />
<br />
<input
type="file"
onChange={onFileChange}
enabled={(progress !== null).toString()}
/>
<input type="file" onChange={onFileChange} disabled={progress !== null} />
</CardContent>
</Container>
);
let resultsCard = importResults && (
const resultsCard = importResults && (
<CardContent>
<CardHeader title={translate("import_users.cards.results.header")} />
<div>
{translate(
"import_users.cards.results.total",
importResults.totalRecordCount
)}
{translate("import_users.cards.results.total", importResults.totalRecordCount)}
<br />
{translate(
"import_users.cards.results.successful",
importResults.succeededRecords.length
)}
{translate("import_users.cards.results.successful", importResults.succeededRecords.length)}
<br />
{importResults.skippedRecords.length
? [
translate(
"import_users.cards.results.skipped",
importResults.skippedRecords.length
),
translate("import_users.cards.results.skipped", importResults.skippedRecords.length),
<div>
<button onClick={downloadSkippedRecords}>
{translate("import_users.cards.results.download_skipped")}
@ -610,41 +517,22 @@ const FilePicker = props => {
]
: ""}
{importResults.erroredRecords.length
? [
translate(
"import_users.cards.results.skipped",
importResults.erroredRecords.length
),
<br />,
]
? [translate("import_users.cards.results.skipped", importResults.erroredRecords.length), <br />]
: ""}
<br />
{importResults.wasDryRun && [
translate("import_users.cards.results.simulated_only"),
<br />,
]}
{importResults.wasDryRun && [translate("import_users.cards.results.simulated_only"), <br />]}
</div>
</CardContent>
);
let startImportCard =
const startImportCard =
!values || values.length === 0 || importResults ? undefined : (
<CardActions>
<FormControlLabel
control={
<Checkbox
checked={dryRun}
onChange={onDryRunModeChanged}
enabled={(progress !== null).toString()}
/>
}
control={<Checkbox checked={dryRun} onChange={onDryRunModeChanged} disabled={progress !== null} />}
label={translate("import_users.cards.startImport.simulate_only")}
/>
<Button
size="large"
onClick={runImport}
enabled={(progress !== null).toString()}
>
<Button size="large" onClick={runImport} disabled={progress !== null}>
{translate("import_users.cards.startImport.run_import")}
</Button>
{progress !== null ? (
@ -655,7 +543,7 @@ const FilePicker = props => {
</CardActions>
);
let allCards = [];
const allCards: JSX.Element[] = [];
if (uploadCard) allCards.push(uploadCard);
if (errorCards) allCards.push(errorCards);
if (conflictCards) allCards.push(conflictCards);
@ -663,12 +551,9 @@ const FilePicker = props => {
if (startImportCard) allCards.push(startImportCard);
if (resultsCard) allCards.push(resultsCard);
let cardContainer = <Card>{allCards}</Card>;
const cardContainer = <Card>{allCards}</Card>;
return [
<Title defaultTitle={translate("import_users.title")} />,
cardContainer,
];
return [<Title defaultTitle={translate("import_users.title")} />, cardContainer];
};
export const ImportFeature = FilePicker;

View File

@ -1,375 +0,0 @@
import React, { useState, useEffect } from "react";
import {
fetchUtils,
FormDataConsumer,
Notification,
useLogin,
useNotify,
useLocale,
useSetLocale,
useTranslate,
PasswordInput,
TextInput,
} from "react-admin";
import { Form, useForm } from "react-final-form";
import {
Avatar,
Button,
Card,
CardActions,
CircularProgress,
MenuItem,
Select,
TextField,
} from "@mui/material";
import { makeStyles } from "@material-ui/core/styles";
import LockIcon from "@mui/icons-material/Lock";
const useStyles = makeStyles(theme => ({
main: {
display: "flex",
flexDirection: "column",
minHeight: "calc(100vh - 1em)",
alignItems: "center",
justifyContent: "flex-start",
background: "url(./images/floating-cogs.svg)",
backgroundColor: "#f9f9f9",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
},
card: {
minWidth: "30em",
marginTop: "6em",
marginBottom: "6em",
},
avatar: {
margin: "1em",
display: "flex",
justifyContent: "center",
},
icon: {
backgroundColor: theme.palette.secondary.main,
},
hint: {
marginTop: "1em",
display: "flex",
justifyContent: "center",
color: theme.palette.grey[500],
},
form: {
padding: "0 1em 1em 1em",
},
input: {
marginTop: "1em",
},
actions: {
padding: "0 1em 1em 1em",
},
serverVersion: {
color: "#9e9e9e",
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
marginBottom: "1em",
marginLeft: "0.5em",
},
}));
const LoginPage = ({ theme }) => {
const classes = useStyles({ theme });
const login = useLogin();
const notify = useNotify();
const [loading, setLoading] = useState(false);
const [supportPassAuth, setSupportPassAuth] = useState(true);
var locale = useLocale();
const setLocale = useSetLocale();
const translate = useTranslate();
const base_url = localStorage.getItem("base_url");
const cfg_base_url = process.env.REACT_APP_SERVER;
const [ssoBaseUrl, setSSOBaseUrl] = useState("");
const loginToken = /\?loginToken=([a-zA-Z0-9_-]+)/.exec(window.location.href);
if (loginToken) {
const ssoToken = loginToken[1];
console.log("SSO token is", ssoToken);
// Prevent further requests
window.history.replaceState(
{},
"",
window.location.href.replace(loginToken[0], "#").split("#")[0]
);
const baseUrl = localStorage.getItem("sso_base_url");
localStorage.removeItem("sso_base_url");
if (baseUrl) {
const auth = {
base_url: baseUrl,
username: null,
password: null,
loginToken: ssoToken,
};
console.log("Base URL is:", baseUrl);
console.log("SSO Token is:", ssoToken);
console.log("Let's try token login...");
login(auth).catch(error => {
alert(
typeof error === "string"
? error
: typeof error === "undefined" || !error.message
? "ra.auth.sign_in_error"
: error.message
);
console.error(error);
});
}
}
const renderInput = ({
meta: { touched, error } = {},
input: { ...inputProps },
...props
}) => (
<TextField
error={!!(touched && error)}
helperText={touched && error}
{...inputProps}
{...props}
fullWidth
/>
);
const validate = values => {
const errors = {};
if (!values.username) {
errors.username = translate("ra.validation.required");
}
if (!values.password) {
errors.password = translate("ra.validation.required");
}
if (!values.base_url) {
errors.base_url = translate("ra.validation.required");
} else {
if (!values.base_url.match(/^(http|https):\/\//)) {
errors.base_url = translate("synapseadmin.auth.protocol_error");
} else if (
!values.base_url.match(
/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?[^?&\s]*$/
)
) {
errors.base_url = translate("synapseadmin.auth.url_error");
}
}
return errors;
};
const handleSubmit = auth => {
setLoading(true);
login(auth).catch(error => {
setLoading(false);
notify(
typeof error === "string"
? error
: typeof error === "undefined" || !error.message
? "ra.auth.sign_in_error"
: error.message,
{ type: "warning" }
);
});
};
const handleSSO = () => {
localStorage.setItem("sso_base_url", ssoBaseUrl);
const ssoFullUrl = `${ssoBaseUrl}/_matrix/client/r0/login/sso/redirect?redirectUrl=${encodeURIComponent(
window.location.href
)}`;
window.location.href = ssoFullUrl;
};
const extractHomeServer = username => {
const usernameRegex = /@[a-zA-Z0-9._=\-/]+:([a-zA-Z0-9\-.]+\.[a-zA-Z]+)/;
if (!username) return null;
const res = username.match(usernameRegex);
if (res) return res[1];
return null;
};
const UserData = ({ formData }) => {
const form = useForm();
const [serverVersion, setServerVersion] = useState("");
const handleUsernameChange = _ => {
if (formData.base_url || cfg_base_url) return;
// check if username is a full qualified userId then set base_url accordially
const home_server = extractHomeServer(formData.username);
const wellKnownUrl = `https://${home_server}/.well-known/matrix/client`;
if (home_server) {
// fetch .well-known entry to get base_url
fetchUtils
.fetchJson(wellKnownUrl, { method: "GET" })
.then(({ json }) => {
form.change("base_url", json["m.homeserver"].base_url);
})
.catch(_ => {
// if there is no .well-known entry, try the home server name
form.change("base_url", `https://${home_server}`);
});
}
};
useEffect(
_ => {
if (
!formData.base_url ||
!formData.base_url.match(/^(http|https):\/\/[a-zA-Z0-9\-.]+$/)
)
return;
const versionUrl = `${formData.base_url}/_synapse/admin/v1/server_version`;
fetchUtils
.fetchJson(versionUrl, { method: "GET" })
.then(({ json }) => {
setServerVersion(
`${translate("synapseadmin.auth.server_version")} ${
json["server_version"]
}`
);
})
.catch(_ => {
setServerVersion("");
});
// Set SSO Url
const authMethodUrl = `${formData.base_url}/_matrix/client/r0/login`;
let supportPass = false,
supportSSO = false;
fetchUtils
.fetchJson(authMethodUrl, { method: "GET" })
.then(({ json }) => {
json.flows.forEach(f => {
if (f.type === "m.login.password") {
supportPass = true;
} else if (f.type === "m.login.sso") {
supportSSO = true;
}
});
setSupportPassAuth(supportPass);
if (supportSSO) {
setSSOBaseUrl(formData.base_url);
} else {
setSSOBaseUrl("");
}
})
.catch(_ => {
setSSOBaseUrl("");
});
},
[formData.base_url]
);
return (
<div>
<div className={classes.input}>
<TextInput
autoFocus
name="username"
component={renderInput}
label="ra.auth.username"
disabled={loading || !supportPassAuth}
onBlur={handleUsernameChange}
resettable
fullWidth
/>
</div>
<div className={classes.input}>
<PasswordInput
name="password"
component={renderInput}
label="ra.auth.password"
type="password"
disabled={loading || !supportPassAuth}
resettable
fullWidth
/>
</div>
<div className={classes.input}>
<TextInput
name="base_url"
component={renderInput}
label="synapseadmin.auth.base_url"
disabled={cfg_base_url || loading}
resettable
fullWidth
/>
</div>
<div className={classes.serverVersion}>{serverVersion}</div>
</div>
);
};
return (
<Form
initialValues={{ base_url: cfg_base_url || base_url }}
onSubmit={handleSubmit}
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit} noValidate>
<div className={classes.main}>
<Card className={classes.card}>
<div className={classes.avatar}>
<Avatar className={classes.icon}>
<LockIcon />
</Avatar>
</div>
<div className={classes.hint}>
{translate("synapseadmin.auth.welcome")}
</div>
<div className={classes.form}>
<div className={classes.input}>
<Select
value={locale}
onChange={e => {
setLocale(e.target.value);
}}
fullWidth
disabled={loading}
>
<MenuItem value="de">Deutsch</MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="fr">Français</MenuItem>
<MenuItem value="zh">简体中文</MenuItem>
</Select>
</div>
<FormDataConsumer>
{formDataProps => <UserData {...formDataProps} />}
</FormDataConsumer>
</div>
<CardActions className={classes.actions}>
<Button
variant="contained"
type="submit"
color="primary"
disabled={loading || !supportPassAuth}
className={classes.button}
fullWidth
>
{loading && <CircularProgress size={25} thickness={2} />}
{translate("ra.auth.sign_in")}
</Button>
<Button
variant="contained"
color="secondary"
onClick={handleSSO}
disabled={loading || ssoBaseUrl === ""}
className={classes.button}
fullWidth
>
{loading && <CircularProgress size={25} thickness={2} />}
{translate("synapseadmin.auth.sso_sign_in")}
</Button>
</CardActions>
</Card>
<Notification />
</div>
</form>
)}
/>
);
};
export default LoginPage;

View File

@ -1,14 +0,0 @@
import React from "react";
import { render } from "@testing-library/react";
import { TestContext } from "ra-test";
import LoginPage from "./LoginPage";
describe("LoginForm", () => {
it("renders", () => {
render(
<TestContext>
<LoginPage />
</TestContext>
);
});
});

View File

@ -1,39 +0,0 @@
// in src/Menu.js
import * as React from "react";
import { useSelector } from "react-redux";
import { useMediaQuery } from "@mui/material";
import { MenuItemLink, getResources } from "react-admin";
import DefaultIcon from "@mui/icons-material/ViewList";
import LabelIcon from "@mui/icons-material/Label";
const Menu = ({ onMenuClick, logout }) => {
const isXSmall = useMediaQuery(theme => theme.breakpoints.down("xs"));
const open = useSelector(state => state.admin.ui.sidebarOpen);
const resources = useSelector(getResources);
return (
<div>
{resources.map(resource => (
<MenuItemLink
key={resource.name}
to={`/${resource.name}`}
primaryText={
(resource.options && resource.options.label) || resource.name
}
leftIcon={resource.icon ? <resource.icon /> : <DefaultIcon />}
onClick={onMenuClick}
sidebarIsOpen={open}
/>
))}
<MenuItemLink
to="/custom-route"
primaryText="Miscellaneous"
leftIcon={<LabelIcon />}
onClick={onMenuClick}
sidebarIsOpen={open}
/>
{isXSmall && logout}
</div>
);
};
export default Menu;

View File

@ -1,132 +0,0 @@
import React from "react";
import {
BooleanInput,
Create,
Datagrid,
DateField,
DateTimeInput,
Edit,
Filter,
List,
maxValue,
number,
NumberField,
NumberInput,
regex,
SimpleForm,
TextInput,
TextField,
Toolbar,
} from "react-admin";
const date_format = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const validateToken = [regex(/^[A-Za-z0-9._~-]{0,64}$/)];
const validateUsesAllowed = [number()];
const validateLength = [number(), maxValue(64)];
const dateParser = v => {
const d = new Date(v);
if (isNaN(d)) return 0;
return d.getTime();
};
const dateFormatter = v => {
if (v === undefined || v === null) return;
const d = new Date(v);
const pad = "00";
const year = d.getFullYear().toString();
const month = (pad + (d.getMonth() + 1).toString()).slice(-2);
const day = (pad + d.getDate().toString()).slice(-2);
const hour = (pad + d.getHours().toString()).slice(-2);
const minute = (pad + d.getMinutes().toString()).slice(-2);
// target format yyyy-MM-ddThh:mm
return `${year}-${month}-${day}T${hour}:${minute}`;
};
const RegistrationTokenFilter = props => (
<Filter {...props}>
<BooleanInput source="valid" alwaysOn />
</Filter>
);
export const RegistrationTokenList = props => {
return (
<List
{...props}
filters={<RegistrationTokenFilter />}
filterDefaultValues={{ valid: true }}
pagination={false}
perPage={500}
>
<Datagrid rowClick="edit">
<TextField source="token" sortable={false} />
<NumberField source="uses_allowed" sortable={false} />
<NumberField source="pending" sortable={false} />
<NumberField source="completed" sortable={false} />
<DateField
source="expiry_time"
showTime
options={date_format}
sortable={false}
/>
</Datagrid>
</List>
);
};
export const RegistrationTokenCreate = props => (
<Create {...props}>
<SimpleForm redirect="list" toolbar={<Toolbar alwaysEnableSaveButton />}>
<TextInput
source="token"
autoComplete="off"
validate={validateToken}
resettable
/>
<NumberInput
source="length"
validate={validateLength}
helperText="resources.registration_tokens.helper.length"
step={1}
/>
<NumberInput
source="uses_allowed"
validate={validateUsesAllowed}
step={1}
/>
<DateTimeInput source="expiry_time" parse={dateParser} />
</SimpleForm>
</Create>
);
export const RegistrationTokenEdit = props => {
return (
<Edit {...props}>
<SimpleForm>
<TextInput source="token" disabled />
<NumberInput source="pending" disabled />
<NumberInput source="completed" disabled />
<NumberInput
source="uses_allowed"
validate={validateUsesAllowed}
step={1}
/>
<DateTimeInput
source="expiry_time"
parse={dateParser}
format={dateFormatter}
/>
</SimpleForm>
</Edit>
);
};

View File

@ -1,260 +0,0 @@
import React, { Fragment } from "react";
import { Avatar, Chip } from "@mui/material";
import { connect } from "react-redux";
import FolderSharedIcon from "@mui/icons-material/FolderShared";
import { makeStyles } from "@material-ui/core/styles";
import {
BooleanField,
BulkDeleteButton,
Button,
Datagrid,
DeleteButton,
Filter,
List,
NumberField,
Pagination,
TextField,
useCreate,
useMutation,
useNotify,
useTranslate,
useRecordContext,
useRefresh,
useUnselectAll,
} from "react-admin";
const useStyles = makeStyles({
small: {
height: "40px",
width: "40px",
},
});
const RoomDirectoryPagination = props => (
<Pagination {...props} rowsPerPageOptions={[100, 500, 1000, 2000]} />
);
export const RoomDirectoryDeleteButton = props => {
const translate = useTranslate();
return (
<DeleteButton
{...props}
label="resources.room_directory.action.erase"
redirect={false}
mutationMode="pessimistic"
confirmTitle={translate("resources.room_directory.action.title", {
smart_count: 1,
})}
confirmContent={translate("resources.room_directory.action.content", {
smart_count: 1,
})}
resource="room_directory"
icon={<FolderSharedIcon />}
/>
);
};
export const RoomDirectoryBulkDeleteButton = props => (
<BulkDeleteButton
{...props}
label="resources.room_directory.action.erase"
mutationMode="pessimistic"
confirmTitle="resources.room_directory.action.title"
confirmContent="resources.room_directory.action.content"
resource="room_directory"
icon={<FolderSharedIcon />}
/>
);
export const RoomDirectoryBulkSaveButton = ({ selectedIds }) => {
const notify = useNotify();
const refresh = useRefresh();
const unselectAll = useUnselectAll();
const [createMany, { loading }] = useMutation();
const handleSend = values => {
createMany(
{
type: "createMany",
resource: "room_directory",
payload: { ids: selectedIds, data: {} },
},
{
onSuccess: ({ data }) => {
notify("resources.room_directory.action.send_success");
unselectAll("rooms");
refresh();
},
onFailure: error =>
notify("resources.room_directory.action.send_failure", {
type: "error",
}),
}
);
};
return (
<Button
label="resources.room_directory.action.create"
onClick={handleSend}
disabled={loading}
>
<FolderSharedIcon />
</Button>
);
};
export const RoomDirectorySaveButton = props => {
const record = useRecordContext();
const notify = useNotify();
const refresh = useRefresh();
const [create, { loading }] = useCreate("room_directory");
const handleSend = values => {
create(
{
payload: { data: { id: record.id } },
},
{
onSuccess: ({ data }) => {
notify("resources.room_directory.action.send_success");
refresh();
},
onFailure: error =>
notify("resources.room_directory.action.send_failure", {
type: "error",
}),
}
);
};
return (
<Button
label="resources.room_directory.action.create"
onClick={handleSend}
disabled={loading}
>
<FolderSharedIcon />
</Button>
);
};
const RoomDirectoryBulkActionButtons = props => (
<Fragment>
<RoomDirectoryBulkDeleteButton {...props} />
</Fragment>
);
const AvatarField = ({ source, className, record = {} }) => (
<Avatar src={record[source]} className={className} />
);
const RoomDirectoryFilter = ({ ...props }) => {
const translate = useTranslate();
return (
<Filter {...props}>
<Chip
label={translate("resources.rooms.fields.room_id")}
source="room_id"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
<Chip
label={translate("resources.rooms.fields.topic")}
source="topic"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
<Chip
label={translate("resources.rooms.fields.canonical_alias")}
source="canonical_alias"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
</Filter>
);
};
export const FilterableRoomDirectoryList = ({
roomDirectoryFilters,
dispatch,
...props
}) => {
const classes = useStyles();
const filter = roomDirectoryFilters;
const roomIdFilter = filter && filter.room_id ? true : false;
const topicFilter = filter && filter.topic ? true : false;
const canonicalAliasFilter = filter && filter.canonical_alias ? true : false;
return (
<List
{...props}
pagination={<RoomDirectoryPagination />}
bulkActionButtons={<RoomDirectoryBulkActionButtons />}
filters={<RoomDirectoryFilter />}
perPage={100}
>
<Datagrid rowClick={(id, basePath, record) => "/rooms/" + id + "/show"}>
<AvatarField
source="avatar_src"
sortable={false}
className={classes.small}
label="resources.rooms.fields.avatar"
/>
<TextField
source="name"
sortable={false}
label="resources.rooms.fields.name"
/>
{roomIdFilter && (
<TextField
source="room_id"
sortable={false}
label="resources.rooms.fields.room_id"
/>
)}
{canonicalAliasFilter && (
<TextField
source="canonical_alias"
sortable={false}
label="resources.rooms.fields.canonical_alias"
/>
)}
{topicFilter && (
<TextField
source="topic"
sortable={false}
label="resources.rooms.fields.topic"
/>
)}
<NumberField
source="num_joined_members"
sortable={false}
label="resources.rooms.fields.joined_members"
/>
<BooleanField
source="world_readable"
sortable={false}
label="resources.room_directory.fields.world_readable"
/>
<BooleanField
source="guest_can_join"
sortable={false}
label="resources.room_directory.fields.guest_can_join"
/>
</Datagrid>
</List>
);
};
function mapStateToProps(state) {
return {
roomDirectoryFilters:
state.admin.resources.room_directory.list.params.displayedFilters,
};
}
export const RoomDirectoryList = connect(mapStateToProps)(
FilterableRoomDirectoryList
);

View File

@ -1,159 +0,0 @@
import React, { Fragment, useState } from "react";
import {
Button,
SaveButton,
SimpleForm,
TextInput,
Toolbar,
required,
useCreate,
useMutation,
useNotify,
useRecordContext,
useTranslate,
useUnselectAll,
} from "react-admin";
import MessageIcon from "@mui/icons-material/Message";
import IconCancel from "@mui/icons-material/Cancel";
import {
Dialog,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
const ServerNoticeDialog = ({ open, loading, onClose, onSend }) => {
const translate = useTranslate();
const ServerNoticeToolbar = props => (
<Toolbar {...props}>
<SaveButton
label="resources.servernotices.action.send"
disabled={props.pristine}
/>
<Button label="ra.action.cancel" onClick={onClose}>
<IconCancel />
</Button>
</Toolbar>
);
return (
<Dialog open={open} onClose={onClose} loading={loading}>
<DialogTitle>
{translate("resources.servernotices.action.send")}
</DialogTitle>
<DialogContent>
<DialogContentText>
{translate("resources.servernotices.helper.send")}
</DialogContentText>
<SimpleForm
toolbar={<ServerNoticeToolbar />}
submitOnEnter={false}
redirect={false}
save={onSend}
>
<TextInput
source="body"
label="resources.servernotices.fields.body"
fullWidth
multiline
rows="4"
resettable
validate={required()}
/>
</SimpleForm>
</DialogContent>
</Dialog>
);
};
export const ServerNoticeButton = props => {
const record = useRecordContext();
const [open, setOpen] = useState(false);
const notify = useNotify();
const [create, { loading }] = useCreate("servernotices");
const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const handleSend = values => {
create(
{ payload: { data: { id: record.id, ...values } } },
{
onSuccess: () => {
notify("resources.servernotices.action.send_success");
handleDialogClose();
},
onFailure: () =>
notify("resources.servernotices.action.send_failure", {
type: "error",
}),
}
);
};
return (
<Fragment>
<Button
label="resources.servernotices.send"
onClick={handleDialogOpen}
disabled={loading}
>
<MessageIcon />
</Button>
<ServerNoticeDialog
open={open}
onClose={handleDialogClose}
onSend={handleSend}
/>
</Fragment>
);
};
export const ServerNoticeBulkButton = ({ selectedIds }) => {
const [open, setOpen] = useState(false);
const notify = useNotify();
const unselectAll = useUnselectAll();
const [createMany, { loading }] = useMutation();
const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const handleSend = values => {
createMany(
{
type: "createMany",
resource: "servernotices",
payload: { ids: selectedIds, data: values },
},
{
onSuccess: ({ data }) => {
notify("resources.servernotices.action.send_success");
unselectAll("users");
handleDialogClose();
},
onFailure: error =>
notify("resources.servernotices.action.send_failure", {
type: "error",
}),
}
);
};
return (
<Fragment>
<Button
label="resources.servernotices.send"
onClick={handleDialogOpen}
disabled={loading}
>
<MessageIcon />
</Button>
<ServerNoticeDialog
open={open}
onClose={handleDialogClose}
onSend={handleSend}
/>
</Fragment>
);
};

View File

@ -0,0 +1,131 @@
import { useState } from "react";
import IconCancel from "@mui/icons-material/Cancel";
import MessageIcon from "@mui/icons-material/Message";
import { Dialog, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
import {
Button,
RaRecord,
SaveButton,
SimpleForm,
TextInput,
Toolbar,
ToolbarProps,
required,
useCreate,
useDataProvider,
useListContext,
useNotify,
useRecordContext,
useTranslate,
useUnselectAll,
} from "react-admin";
import { useMutation } from "@tanstack/react-query";
const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
const translate = useTranslate();
const ServerNoticeToolbar = (props: ToolbarProps & { pristine?: boolean }) => (
<Toolbar {...props}>
<SaveButton label="resources.servernotices.action.send" disabled={props.pristine} />
<Button label="ra.action.cancel" onClick={onClose}>
<IconCancel />
</Button>
</Toolbar>
);
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{translate("resources.servernotices.action.send")}</DialogTitle>
<DialogContent>
<DialogContentText>{translate("resources.servernotices.helper.send")}</DialogContentText>
<SimpleForm toolbar={<ServerNoticeToolbar />} onSubmit={onSubmit}>
<TextInput
source="body"
label="resources.servernotices.fields.body"
multiline
rows="4"
resettable
validate={required()}
/>
</SimpleForm>
</DialogContent>
</Dialog>
);
};
export const ServerNoticeButton = () => {
const record = useRecordContext();
const [open, setOpen] = useState(false);
const notify = useNotify();
const [create, { isLoading }] = useCreate();
const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
if (!record) {
return;
}
const handleSend = (values: Partial<RaRecord>) => {
create(
"servernotices",
{ data: { id: record.id, ...values } },
{
onSuccess: () => {
notify("resources.servernotices.action.send_success");
handleDialogClose();
},
onError: () =>
notify("resources.servernotices.action.send_failure", {
type: "error",
}),
}
);
};
return (
<>
<Button label="resources.servernotices.send" onClick={handleDialogOpen} disabled={isLoading}>
<MessageIcon />
</Button>
<ServerNoticeDialog open={open} onClose={handleDialogClose} onSubmit={handleSend} />
</>
);
};
export const ServerNoticeBulkButton = () => {
const { selectedIds } = useListContext();
const [open, setOpen] = useState(false);
const openDialog = () => setOpen(true);
const closeDialog = () => setOpen(false);
const notify = useNotify();
const unselectAllUsers = useUnselectAll("users");
const dataProvider = useDataProvider();
const { mutate: sendNotices, isPending } = useMutation({
mutationFn: (data) =>
dataProvider.createMany("servernotices", {
ids: selectedIds,
data: data,
}),
onSuccess: () => {
notify("resources.servernotices.action.send_success");
unselectAllUsers();
closeDialog();
},
onError: () =>
notify("resources.servernotices.action.send_failure", {
type: "error",
}),
});
return (
<>
<Button label="resources.servernotices.send" onClick={openDialog} disabled={isPending}>
<MessageIcon />
</Button>
<ServerNoticeDialog open={open} onClose={closeDialog} onSubmit={sendNotices} />
</>
);
};

28
src/components/date.ts Normal file
View File

@ -0,0 +1,28 @@
export const DATE_FORMAT: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
export const dateParser = (v: string | number | Date): number => {
const d = new Date(v);
return d.getTime();
};
export const dateFormatter = (v: string | number | Date | undefined | null): string => {
if (v === undefined || v === null) return "";
const d = new Date(v);
const pad = "00";
const year = d.getFullYear().toString();
const month = (pad + (d.getMonth() + 1).toString()).slice(-2);
const day = (pad + d.getDate().toString()).slice(-2);
const hour = (pad + d.getHours().toString()).slice(-2);
const minute = (pad + d.getMinutes().toString()).slice(-2);
// target format yyyy-MM-ddThh:mm
return `${year}-${month}-${day}T${hour}:${minute}`;
};

View File

@ -1,185 +0,0 @@
import React from "react";
import {
Button,
Datagrid,
DateField,
Filter,
List,
Pagination,
ReferenceField,
ReferenceManyField,
SearchInput,
Show,
Tab,
TabbedShowLayout,
TextField,
TopToolbar,
useRecordContext,
useDelete,
useNotify,
useRefresh,
useTranslate,
} from "react-admin";
import AutorenewIcon from "@material-ui/icons/Autorenew";
import FolderSharedIcon from "@material-ui/icons/FolderShared";
import ViewListIcon from "@material-ui/icons/ViewList";
const DestinationPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const date_format = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const destinationRowStyle = (record, index) => ({
backgroundColor: record.retry_last_ts > 0 ? "#ffcccc" : "white",
});
const DestinationFilter = ({ ...props }) => {
return (
<Filter {...props}>
<SearchInput source="destination" alwaysOn />
</Filter>
);
};
export const DestinationReconnectButton = props => {
const record = useRecordContext();
const refresh = useRefresh();
const notify = useNotify();
const [handleReconnect, { isLoading }] = useDelete("destinations");
// Reconnect is not required if no error has occurred. (`failure_ts`)
if (!record || !record.failure_ts) return null;
const handleClick = e => {
// Prevents redirection to the detail page when clicking in the list
e.stopPropagation();
handleReconnect(
{ payload: { id: record.id } },
{
onSuccess: () => {
notify("ra.notification.updated", {
messageArgs: { smart_count: 1 },
});
refresh();
},
onFailure: () => {
notify("ra.message.error", { type: "error" });
},
}
);
};
return (
<Button
label="resources.destinations.action.reconnect"
onClick={handleClick}
disabled={isLoading}
>
<AutorenewIcon />
</Button>
);
};
const DestinationShowActions = props => (
<TopToolbar>
<DestinationReconnectButton />
</TopToolbar>
);
const DestinationTitle = props => {
const record = useRecordContext();
const translate = useTranslate();
return (
<span>
{translate("resources.destinations.name", 1)} {record.destination}
</span>
);
};
export const DestinationList = props => {
return (
<List
{...props}
filters={<DestinationFilter />}
pagination={<DestinationPagination />}
sort={{ field: "destination", order: "ASC" }}
bulkActionButtons={false}
>
<Datagrid
rowStyle={destinationRowStyle}
rowClick={(id, basePath, record) => `${basePath}/${id}/show/rooms`}
>
<TextField source="destination" />
<DateField source="failure_ts" showTime options={date_format} />
<DateField source="retry_last_ts" showTime options={date_format} />
<TextField source="retry_interval" />
<TextField source="last_successful_stream_ordering" />
<DestinationReconnectButton />
</Datagrid>
</List>
);
};
export const DestinationShow = props => {
const translate = useTranslate();
return (
<Show
actions={<DestinationShowActions />}
title={<DestinationTitle />}
{...props}
>
<TabbedShowLayout>
<Tab label="status" icon={<ViewListIcon />}>
<TextField source="destination" />
<DateField source="failure_ts" showTime options={date_format} />
<DateField source="retry_last_ts" showTime options={date_format} />
<TextField source="retry_interval" />
<TextField source="last_successful_stream_ordering" />
</Tab>
<Tab
label={translate("resources.rooms.name", { smart_count: 2 })}
icon={<FolderSharedIcon />}
path="rooms"
>
<ReferenceManyField
reference="destination_rooms"
target="destination"
addLabel={false}
pagination={<DestinationPagination />}
perPage={50}
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, basePath, record) => `/rooms/${id}/show`}
>
<TextField
source="room_id"
label="resources.rooms.fields.room_id"
/>
<TextField source="stream_ordering" sortable={false} />
<ReferenceField
label="resources.rooms.fields.name"
source="id"
reference="rooms"
sortable={false}
link=""
>
<TextField source="name" sortable={false} />
</ReferenceField>
</Datagrid>
</ReferenceManyField>
</Tab>
</TabbedShowLayout>
</Show>
);
};

View File

@ -1,84 +0,0 @@
import React, { Fragment, useState } from "react";
import {
Button,
useDelete,
useNotify,
Confirm,
useRecordContext,
useRefresh,
} from "react-admin";
import ActionDelete from "@mui/icons-material/Delete";
import { makeStyles } from "@material-ui/core/styles";
import { alpha } from "@mui/material/styles";
import classnames from "classnames";
const useStyles = makeStyles(
theme => ({
deleteButton: {
color: theme.palette.error.main,
"&:hover": {
backgroundColor: alpha(theme.palette.error.main, 0.12),
// Reset on mouse devices
"@media (hover: none)": {
backgroundColor: "transparent",
},
},
},
}),
{ name: "RaDeleteDeviceButton" }
);
export const DeviceRemoveButton = props => {
const record = useRecordContext();
const classes = useStyles(props);
const [open, setOpen] = useState(false);
const refresh = useRefresh();
const notify = useNotify();
const [removeDevice, { isLoading }] = useDelete("devices");
if (!record) return null;
const handleClick = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const handleConfirm = () => {
removeDevice(
{ payload: { id: record.id, user_id: record.user_id } },
{
onSuccess: () => {
notify("resources.devices.action.erase.success");
refresh();
},
onFailure: () => {
notify("resources.devices.action.erase.failure", { type: "error" });
},
}
);
setOpen(false);
};
return (
<Fragment>
<Button
label="ra.action.remove"
onClick={handleClick}
className={classnames("ra-delete-button", classes.deleteButton)}
>
<ActionDelete />
</Button>
<Confirm
isOpen={open}
loading={isLoading}
onConfirm={handleConfirm}
onClose={handleDialogClose}
title="resources.devices.action.erase.title"
content="resources.devices.action.erase.content"
translateOptions={{
id: record.id,
name: record.display_name ? record.display_name : record.id,
}}
/>
</Fragment>
);
};

View File

@ -0,0 +1,21 @@
import { DeleteWithConfirmButton, DeleteWithConfirmButtonProps, useRecordContext } from "react-admin";
export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
const record = useRecordContext();
if (!record) return null;
return (
<DeleteWithConfirmButton
{...props}
label="ra.action.remove"
confirmTitle="resources.devices.action.erase.title"
confirmContent="resources.devices.action.erase.content"
mutationMode="pessimistic"
redirect={false}
translateOptions={{
id: record.id,
name: record.display_name ? record.display_name : record.id,
}}
/>
);
};

View File

@ -1,109 +1,75 @@
import React, { Fragment, useState } from "react";
import classnames from "classnames";
import { alpha } from "@mui/material/styles";
import { makeStyles } from "@material-ui/core/styles";
import { get } from "lodash";
import { useState } from "react";
import BlockIcon from "@mui/icons-material/Block";
import IconCancel from "@mui/icons-material/Cancel";
import ClearIcon from "@mui/icons-material/Clear";
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
import FileOpenIcon from "@mui/icons-material/FileOpen";
import LockIcon from "@mui/icons-material/Lock";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import { Box, Dialog, DialogContent, DialogContentText, DialogTitle, Tooltip } from "@mui/material";
import { alpha, useTheme } from "@mui/material/styles";
import {
BooleanInput,
Button,
ButtonProps,
DateTimeInput,
NumberInput,
SaveButton,
SimpleForm,
Toolbar,
ToolbarProps,
useCreate,
useDataProvider,
useDelete,
useNotify,
useRecordContext,
useRefresh,
useTranslate,
} from "react-admin";
import BlockIcon from "@mui/icons-material/Block";
import ClearIcon from "@mui/icons-material/Clear";
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
import {
Dialog,
DialogContent,
DialogContentText,
DialogTitle,
Tooltip,
} from "@mui/material";
import IconCancel from "@mui/icons-material/Cancel";
import LockIcon from "@mui/icons-material/Lock";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import { useMutation } from "@tanstack/react-query";
import { Link } from "react-router-dom";
const useStyles = makeStyles(
theme => ({
deleteButton: {
color: theme.palette.error.main,
"&:hover": {
backgroundColor: alpha(theme.palette.error.main, 0.12),
// Reset on mouse devices
"@media (hover: none)": {
backgroundColor: "transparent",
},
},
},
}),
{ name: "RaDeleteDeviceButton" }
);
import { dateParser } from "./date";
import { DeleteMediaParams, SynapseDataProvider } from "../synapse/dataProvider";
import { getMediaUrl } from "../synapse/synapse";
import storage from "../storage";
const DeleteMediaDialog = ({ open, loading, onClose, onSend }) => {
const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
const translate = useTranslate();
const dateParser = v => {
const d = new Date(v);
if (isNaN(d)) return 0;
return d.getTime();
};
const DeleteMediaToolbar = props => {
return (
<Toolbar {...props}>
<SaveButton
label="resources.delete_media.action.send"
icon={<DeleteSweepIcon />}
/>
<Button label="ra.action.cancel" onClick={onClose}>
<IconCancel />
</Button>
</Toolbar>
);
};
const DeleteMediaToolbar = (props: ToolbarProps) => (
<Toolbar {...props}>
<SaveButton label="delete_media.action.send" icon={<DeleteSweepIcon />} />
<Button label="ra.action.cancel" onClick={onClose}>
<IconCancel />
</Button>
</Toolbar>
);
return (
<Dialog open={open} onClose={onClose} loading={loading}>
<DialogTitle>
{translate("resources.delete_media.action.send")}
</DialogTitle>
<Dialog open={open} onClose={onClose}>
<DialogTitle>{translate("delete_media.action.send")}</DialogTitle>
<DialogContent>
<DialogContentText>
{translate("resources.delete_media.helper.send")}
</DialogContentText>
<SimpleForm
toolbar={<DeleteMediaToolbar />}
submitOnEnter={false}
redirect={false}
save={onSend}
>
<DialogContentText>{translate("delete_media.helper.send")}</DialogContentText>
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
<DateTimeInput
fullWidth
source="before_ts"
label="resources.delete_media.fields.before_ts"
label="delete_media.fields.before_ts"
defaultValue={0}
parse={dateParser}
/>
<NumberInput
fullWidth
source="size_gt"
label="resources.delete_media.fields.size_gt"
label="delete_media.fields.size_gt"
defaultValue={0}
min={0}
step={1024}
/>
<BooleanInput
fullWidth
source="keep_profiles"
label="resources.delete_media.fields.keep_profiles"
label="delete_media.fields.keep_profiles"
defaultValue={true}
/>
</SimpleForm>
@ -112,69 +78,72 @@ const DeleteMediaDialog = ({ open, loading, onClose, onSend }) => {
);
};
export const DeleteMediaButton = props => {
const classes = useStyles(props);
export const DeleteMediaButton = (props: ButtonProps) => {
const theme = useTheme();
const [open, setOpen] = useState(false);
const notify = useNotify();
const [deleteOne, { loading }] = useDelete("delete_media");
const dataProvider = useDataProvider<SynapseDataProvider>();
const { mutate: deleteMedia, isPending } = useMutation({
mutationFn: (values: DeleteMediaParams) => dataProvider.deleteMedia(values),
onSuccess: () => {
notify("delete_media.action.send_success");
closeDialog();
},
onError: () => {
notify("delete_media.action.send_failure", {
type: "error",
});
},
});
const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const handleSend = values => {
deleteOne(
{ payload: { ...values } },
{
onSuccess: () => {
notify("resources.delete_media.action.send_success");
handleDialogClose();
},
onFailure: () =>
notify("resources.delete_media.action.send_failure", {
type: "error",
}),
}
);
};
const openDialog = () => setOpen(true);
const closeDialog = () => setOpen(false);
return (
<Fragment>
<>
<Button
label="resources.delete_media.action.send"
onClick={handleDialogOpen}
disabled={loading}
className={classnames("ra-delete-button", classes.deleteButton)}
{...props}
label="delete_media.action.send"
onClick={openDialog}
disabled={isPending}
sx={{
color: theme.palette.error.main,
"&:hover": {
backgroundColor: alpha(theme.palette.error.main, 0.12),
// Reset on mouse devices
"@media (hover: none)": {
backgroundColor: "transparent",
},
},
}}
>
<DeleteSweepIcon />
</Button>
<DeleteMediaDialog
open={open}
onClose={handleDialogClose}
onSend={handleSend}
/>
</Fragment>
<DeleteMediaDialog open={open} onClose={closeDialog} onSubmit={deleteMedia} />
</>
);
};
export const ProtectMediaButton = props => {
export const ProtectMediaButton = (props: ButtonProps) => {
const record = useRecordContext();
const translate = useTranslate();
const refresh = useRefresh();
const notify = useNotify();
const [create, { loading }] = useCreate("protect_media");
const [deleteOne] = useDelete("protect_media");
const [create, { isLoading }] = useCreate();
const [deleteOne] = useDelete();
if (!record) return null;
const handleProtect = () => {
create(
{ payload: { data: record } },
"protect_media",
{ data: record },
{
onSuccess: () => {
notify("resources.protect_media.action.send_success");
refresh();
},
onFailure: () =>
onError: () =>
notify("resources.protect_media.action.send_failure", {
type: "error",
}),
@ -184,13 +153,14 @@ export const ProtectMediaButton = props => {
const handleUnprotect = () => {
deleteOne(
{ payload: { ...record } },
"protect_media",
{ id: record.id },
{
onSuccess: () => {
notify("resources.protect_media.action.send_success");
refresh();
},
onFailure: () =>
onError: () =>
notify("resources.protect_media.action.send_failure", {
type: "error",
}),
@ -203,7 +173,7 @@ export const ProtectMediaButton = props => {
Wrapping Tooltip with <div>
https://github.com/marmelab/react-admin/issues/4349#issuecomment-578594735
*/
<Fragment>
<>
{record.quarantined_by && (
<Tooltip
title={translate("resources.protect_media.action.none", {
@ -215,7 +185,7 @@ export const ProtectMediaButton = props => {
Button instead BooleanField for
consistent appearance and position in the column
*/}
<Button disabled={true}>
<Button {...props} disabled={true}>
<ClearIcon />
</Button>
</div>
@ -229,7 +199,7 @@ export const ProtectMediaButton = props => {
arrow
>
<div>
<Button onClick={handleUnprotect} disabled={loading}>
<Button {...props} onClick={handleUnprotect} disabled={isLoading}>
<LockIcon />
</Button>
</div>
@ -242,35 +212,36 @@ export const ProtectMediaButton = props => {
})}
>
<div>
<Button onClick={handleProtect} disabled={loading}>
<Button {...props} onClick={handleProtect} disabled={isLoading}>
<LockOpenIcon />
</Button>
</div>
</Tooltip>
)}
</Fragment>
</>
);
};
export const QuarantineMediaButton = props => {
export const QuarantineMediaButton = (props: ButtonProps) => {
const record = useRecordContext();
const translate = useTranslate();
const refresh = useRefresh();
const notify = useNotify();
const [create, { loading }] = useCreate("quarantine_media");
const [deleteOne] = useDelete("quarantine_media");
const [create, { isLoading }] = useCreate();
const [deleteOne] = useDelete();
if (!record) return null;
const handleQuarantaine = () => {
create(
{ payload: { data: record } },
"quarantine_media",
{ data: record },
{
onSuccess: () => {
notify("resources.quarantine_media.action.send_success");
refresh();
},
onFailure: () =>
onError: () =>
notify("resources.quarantine_media.action.send_failure", {
type: "error",
}),
@ -280,13 +251,14 @@ export const QuarantineMediaButton = props => {
const handleRemoveQuarantaine = () => {
deleteOne(
{ payload: { ...record } },
"quarantine_media",
{ id: record.id, previousData: record },
{
onSuccess: () => {
notify("resources.quarantine_media.action.send_success");
refresh();
},
onFailure: () =>
onError: () =>
notify("resources.quarantine_media.action.send_failure", {
type: "error",
}),
@ -295,7 +267,7 @@ export const QuarantineMediaButton = props => {
};
return (
<Fragment>
<>
{record.safe_from_quarantine && (
<Tooltip
title={translate("resources.quarantine_media.action.none", {
@ -303,7 +275,7 @@ export const QuarantineMediaButton = props => {
})}
>
<div>
<Button disabled={true}>
<Button {...props} disabled={true}>
<ClearIcon />
</Button>
</div>
@ -316,7 +288,7 @@ export const QuarantineMediaButton = props => {
})}
>
<div>
<Button onClick={handleRemoveQuarantaine} disabled={loading}>
<Button {...props} onClick={handleRemoveQuarantaine} disabled={isLoading}>
<BlockIcon color="error" />
</Button>
</div>
@ -329,12 +301,58 @@ export const QuarantineMediaButton = props => {
})}
>
<div>
<Button onClick={handleQuarantaine} disabled={loading}>
<Button {...props} onClick={handleQuarantaine} disabled={isLoading}>
<BlockIcon />
</Button>
</div>
</Tooltip>
)}
</Fragment>
</>
);
};
export const ViewMediaButton = ({ media_id, label }) => {
const translate = useTranslate();
const url = getMediaUrl(media_id);
return (
<Box style={{ whiteSpace: "pre" }}>
<Tooltip title={translate("resources.users_media.action.open")}>
<span>
<Button
component={Link}
to={url}
target="_blank"
rel="noopener"
style={{ minWidth: 0, paddingLeft: 0, paddingRight: 0 }}
>
<FileOpenIcon />
</Button>
</span>
</Tooltip>
{label}
</Box>
);
};
export const MediaIDField = ({ source }) => {
const homeserver = storage.getItem("home_server");
const record = useRecordContext();
if (!record) return null;
const src = get(record, source)?.toString();
if (!src) return null;
return <ViewMediaButton media_id={`${homeserver}/${src}`} label={src} />;
};
export const MXCField = ({ source }) => {
const record = useRecordContext();
if (!record) return null;
const src = get(record, source)?.toString();
if (!src) return null;
const media_id = src.replace("mxc://", "");
return <ViewMediaButton media_id={media_id} label={src} />;
};

View File

@ -1,410 +0,0 @@
import React, { Fragment } from "react";
import { connect } from "react-redux";
import {
BooleanField,
BulkDeleteButton,
DateField,
Datagrid,
DeleteButton,
Filter,
List,
NumberField,
Pagination,
ReferenceField,
ReferenceManyField,
SearchInput,
SelectField,
Show,
Tab,
TabbedShowLayout,
TextField,
TopToolbar,
useRecordContext,
useTranslate,
} from "react-admin";
import get from "lodash/get";
import PropTypes from "prop-types";
import { makeStyles } from "@material-ui/core/styles";
import { Tooltip, Typography, Chip } from "@mui/material";
import FastForwardIcon from "@mui/icons-material/FastForward";
import HttpsIcon from "@mui/icons-material/Https";
import NoEncryptionIcon from "@mui/icons-material/NoEncryption";
import PageviewIcon from "@mui/icons-material/Pageview";
import UserIcon from "@mui/icons-material/Group";
import ViewListIcon from "@mui/icons-material/ViewList";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EventIcon from "@mui/icons-material/Event";
import {
RoomDirectoryBulkDeleteButton,
RoomDirectoryBulkSaveButton,
RoomDirectoryDeleteButton,
RoomDirectorySaveButton,
} from "./RoomDirectory";
const date_format = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const useStyles = makeStyles(theme => ({
helper_forward_extremities: {
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
margin: "0.5em",
},
}));
const RoomPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const EncryptionField = ({ source, record = {}, emptyText }) => {
const translate = useTranslate();
const value = get(record, source);
let ariaLabel = value === false ? "ra.boolean.false" : "ra.boolean.true";
if (value === false || value === true) {
return (
<Typography component="span" variant="body2">
<Tooltip title={translate(ariaLabel, { _: ariaLabel })}>
{value === true ? (
<HttpsIcon data-testid="true" htmlColor="limegreen" />
) : (
<NoEncryptionIcon data-testid="false" color="error" />
)}
</Tooltip>
</Typography>
);
}
return (
<Typography component="span" variant="body2">
{emptyText}
</Typography>
);
};
const RoomTitle = props => {
const record = useRecordContext();
const translate = useTranslate();
var name = "";
if (record) {
name = record.name !== "" ? record.name : record.id;
}
return (
<span>
{translate("resources.rooms.name", 1)} {name}
</span>
);
};
const RoomShowActions = ({ basePath, data, resource }) => {
var roomDirectoryStatus = "";
if (data) {
roomDirectoryStatus = data.public;
}
return (
<TopToolbar>
{roomDirectoryStatus === false && (
<RoomDirectorySaveButton record={data} />
)}
{roomDirectoryStatus === true && (
<RoomDirectoryDeleteButton record={data} />
)}
<DeleteButton
basePath={basePath}
record={data}
resource={resource}
mutationMode="pessimistic"
confirmTitle="resources.rooms.action.erase.title"
confirmContent="resources.rooms.action.erase.content"
/>
</TopToolbar>
);
};
export const RoomShow = props => {
const classes = useStyles({ props });
const translate = useTranslate();
return (
<Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}>
<TabbedShowLayout>
<Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}>
<TextField source="room_id" />
<TextField source="name" />
<TextField source="canonical_alias" />
<ReferenceField source="creator" reference="users">
<TextField source="id" />
</ReferenceField>
</Tab>
<Tab
label="synapseadmin.rooms.tabs.detail"
icon={<PageviewIcon />}
path="detail"
>
<TextField source="joined_members" />
<TextField source="joined_local_members" />
<TextField source="joined_local_devices" />
<TextField source="state_events" />
<TextField source="version" />
<TextField
source="encryption"
emptyText={translate("resources.rooms.enums.unencrypted")}
/>
</Tab>
<Tab
label="synapseadmin.rooms.tabs.members"
icon={<UserIcon />}
path="members"
>
<ReferenceManyField
reference="room_members"
target="room_id"
addLabel={false}
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, basePath, record) => "/users/" + id}
>
<TextField
source="id"
sortable={false}
label="resources.users.fields.id"
/>
<ReferenceField
label="resources.users.fields.displayname"
source="id"
reference="users"
sortable={false}
link=""
>
<TextField source="displayname" sortable={false} />
</ReferenceField>
</Datagrid>
</ReferenceManyField>
</Tab>
<Tab
label="synapseadmin.rooms.tabs.permission"
icon={<VisibilityIcon />}
path="permission"
>
<BooleanField source="federatable" />
<BooleanField source="public" />
<SelectField
source="join_rules"
choices={[
{ id: "public", name: "resources.rooms.enums.join_rules.public" },
{ id: "knock", name: "resources.rooms.enums.join_rules.knock" },
{ id: "invite", name: "resources.rooms.enums.join_rules.invite" },
{
id: "private",
name: "resources.rooms.enums.join_rules.private",
},
]}
/>
<SelectField
source="guest_access"
choices={[
{
id: "can_join",
name: "resources.rooms.enums.guest_access.can_join",
},
{
id: "forbidden",
name: "resources.rooms.enums.guest_access.forbidden",
},
]}
/>
<SelectField
source="history_visibility"
choices={[
{
id: "invited",
name: "resources.rooms.enums.history_visibility.invited",
},
{
id: "joined",
name: "resources.rooms.enums.history_visibility.joined",
},
{
id: "shared",
name: "resources.rooms.enums.history_visibility.shared",
},
{
id: "world_readable",
name: "resources.rooms.enums.history_visibility.world_readable",
},
]}
/>
</Tab>
<Tab
label={translate("resources.room_state.name", { smart_count: 2 })}
icon={<EventIcon />}
path="state"
>
<ReferenceManyField
reference="room_state"
target="room_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }}>
<TextField source="type" sortable={false} />
<DateField
source="origin_server_ts"
showTime
options={date_format}
sortable={false}
/>
<TextField source="content" sortable={false} />
<ReferenceField
source="sender"
reference="users"
sortable={false}
>
<TextField source="id" />
</ReferenceField>
</Datagrid>
</ReferenceManyField>
</Tab>
<Tab
label="resources.forward_extremities.name"
icon={<FastForwardIcon />}
path="forward_extremities"
>
<div className={classes.helper_forward_extremities}>
{translate("resources.rooms.helper.forward_extremities")}
</div>
<ReferenceManyField
reference="forward_extremities"
target="room_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }}>
<TextField source="id" sortable={false} />
<DateField
source="received_ts"
showTime
options={date_format}
sortable={false}
/>
<NumberField source="depth" sortable={false} />
<TextField source="state_group" sortable={false} />
</Datagrid>
</ReferenceManyField>
</Tab>
</TabbedShowLayout>
</Show>
);
};
const RoomBulkActionButtons = props => (
<Fragment>
<RoomDirectoryBulkSaveButton {...props} />
<RoomDirectoryBulkDeleteButton {...props} />
<BulkDeleteButton
{...props}
confirmTitle="resources.rooms.action.erase.title"
confirmContent="resources.rooms.action.erase.content"
mutationMode="pessimistic"
/>
</Fragment>
);
const RoomFilter = ({ ...props }) => {
const translate = useTranslate();
return (
<Filter {...props}>
<SearchInput source="search_term" alwaysOn />
<Chip
label={translate("resources.rooms.fields.joined_local_members")}
source="joined_local_members"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
<Chip
label={translate("resources.rooms.fields.state_events")}
source="state_events"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
<Chip
label={translate("resources.rooms.fields.version")}
source="version"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
<Chip
label={translate("resources.rooms.fields.federatable")}
source="federatable"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
</Filter>
);
};
const RoomNameField = props => {
const { source } = props;
const record = useRecordContext();
return (
<span>{record[source] || record["canonical_alias"] || record["id"]}</span>
);
};
RoomNameField.propTypes = {
label: PropTypes.string,
record: PropTypes.object,
source: PropTypes.string.isRequired,
};
const FilterableRoomList = ({ roomFilters, dispatch, ...props }) => {
const filter = roomFilters;
const localMembersFilter =
filter && filter.joined_local_members ? true : false;
const stateEventsFilter = filter && filter.state_events ? true : false;
const versionFilter = filter && filter.version ? true : false;
const federateableFilter = filter && filter.federatable ? true : false;
return (
<List
{...props}
pagination={<RoomPagination />}
sort={{ field: "name", order: "ASC" }}
filters={<RoomFilter />}
bulkActionButtons={<RoomBulkActionButtons />}
>
<Datagrid rowClick="show">
<EncryptionField
source="is_encrypted"
sortBy="encryption"
label={<HttpsIcon />}
/>
<RoomNameField source="name" />
<TextField source="joined_members" />
{localMembersFilter && <TextField source="joined_local_members" />}
{stateEventsFilter && <TextField source="state_events" />}
{versionFilter && <TextField source="version" />}
{federateableFilter && <BooleanField source="federatable" />}
<BooleanField source="public" />
</Datagrid>
</List>
);
};
function mapStateToProps(state) {
return {
roomFilters: state.admin.resources.rooms.list.params.displayedFilters,
};
}
export const RoomList = connect(mapStateToProps)(FilterableRoomList);

View File

@ -1,81 +0,0 @@
import React from "react";
import { cloneElement } from "react";
import {
Datagrid,
ExportButton,
Filter,
List,
NumberField,
Pagination,
sanitizeListRestProps,
SearchInput,
TextField,
TopToolbar,
useListContext,
} from "react-admin";
import { DeleteMediaButton } from "./media";
const ListActions = props => {
const { className, exporter, filters, maxResults, ...rest } = props;
const {
currentSort,
resource,
displayedFilters,
filterValues,
showFilter,
total,
} = useListContext();
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
{filters &&
cloneElement(filters, {
resource,
showFilter,
displayedFilters,
filterValues,
context: "button",
})}
<DeleteMediaButton />
<ExportButton
disabled={total === 0}
resource={resource}
sort={currentSort}
filterValues={filterValues}
maxResults={maxResults}
/>
</TopToolbar>
);
};
const UserMediaStatsPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const UserMediaStatsFilter = props => (
<Filter {...props}>
<SearchInput source="search_term" alwaysOn />
</Filter>
);
export const UserMediaStatsList = props => {
return (
<List
{...props}
actions={<ListActions />}
filters={<UserMediaStatsFilter />}
pagination={<UserMediaStatsPagination />}
sort={{ field: "media_length", order: "DESC" }}
bulkActionButtons={false}
>
<Datagrid rowClick={(id, basePath, record) => "/users/" + id + "/media"}>
<TextField source="user_id" label="resources.users.fields.id" />
<TextField
source="displayname"
label="resources.users.fields.displayname"
/>
<NumberField source="media_count" />
<NumberField source="media_length" />
</Datagrid>
</List>
);
};

View File

@ -1,561 +0,0 @@
import React, { cloneElement, Fragment } from "react";
import Avatar from "@mui/material/Avatar";
import AssignmentIndIcon from "@mui/icons-material/AssignmentInd";
import ContactMailIcon from "@mui/icons-material/ContactMail";
import DevicesIcon from "@mui/icons-material/Devices";
import GetAppIcon from "@mui/icons-material/GetApp";
import NotificationsIcon from "@mui/icons-material/Notifications";
import PermMediaIcon from "@mui/icons-material/PermMedia";
import PersonPinIcon from "@mui/icons-material/PersonPin";
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
import ViewListIcon from "@mui/icons-material/ViewList";
import {
ArrayInput,
ArrayField,
Button,
Datagrid,
DateField,
Create,
Edit,
List,
Filter,
Toolbar,
SimpleForm,
SimpleFormIterator,
TabbedForm,
FormTab,
BooleanField,
BooleanInput,
PasswordInput,
TextField,
TextInput,
ReferenceField,
ReferenceManyField,
SearchInput,
SelectInput,
BulkDeleteButton,
DeleteButton,
SaveButton,
maxLength,
regex,
required,
useRecordContext,
useTranslate,
Pagination,
CreateButton,
ExportButton,
TopToolbar,
sanitizeListRestProps,
NumberField,
} from "react-admin";
import { Link } from "react-router-dom";
import { ServerNoticeButton, ServerNoticeBulkButton } from "./ServerNotices";
import { DeviceRemoveButton } from "./devices";
import { ProtectMediaButton, QuarantineMediaButton } from "./media";
import { makeStyles } from "@material-ui/core/styles";
const redirect = () => {
return {
pathname: "/import_users",
};
};
const useStyles = makeStyles({
small: {
height: "40px",
width: "40px",
},
large: {
height: "120px",
width: "120px",
float: "right",
},
});
const choices_medium = [
{ id: "email", name: "resources.users.email" },
{ id: "msisdn", name: "resources.users.msisdn" },
];
const choices_type = [
{ id: "bot", name: "bot" },
{ id: "support", name: "support" },
];
const date_format = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const UserListActions = ({
currentSort,
className,
resource,
filters,
displayedFilters,
exporter, // you can hide ExportButton if exporter = (null || false)
filterValues,
permanentFilter,
hasCreate, // you can hide CreateButton if hasCreate = false
basePath,
selectedIds,
onUnselectItems,
showFilter,
maxResults,
total,
...rest
}) => {
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
{filters &&
cloneElement(filters, {
resource,
showFilter,
displayedFilters,
filterValues,
context: "button",
})}
<CreateButton basePath={basePath} />
<ExportButton
disabled={total === 0}
resource={resource}
sort={currentSort}
filter={{ ...filterValues, ...permanentFilter }}
exporter={exporter}
maxResults={maxResults}
/>
{/* Add your custom actions */}
<Button component={Link} to={redirect} label="CSV Import">
<GetAppIcon style={{ transform: "rotate(180deg)", fontSize: "20" }} />
</Button>
</TopToolbar>
);
};
UserListActions.defaultProps = {
selectedIds: [],
onUnselectItems: () => null,
};
const UserPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const UserFilter = props => (
<Filter {...props}>
<SearchInput source="name" alwaysOn />
<BooleanInput source="guests" alwaysOn />
<BooleanInput
label="resources.users.fields.show_deactivated"
source="deactivated"
alwaysOn
/>
</Filter>
);
const UserBulkActionButtons = props => (
<Fragment>
<ServerNoticeBulkButton {...props} />
<BulkDeleteButton
{...props}
label="resources.users.action.erase"
confirmTitle="resources.users.helper.erase"
mutationMode="pessimistic"
/>
</Fragment>
);
const AvatarField = ({ source, className, record = {} }) => (
<Avatar src={record[source]} className={className} />
);
export const UserList = props => {
const classes = useStyles();
return (
<List
{...props}
filters={<UserFilter />}
filterDefaultValues={{ guests: true, deactivated: false }}
sort={{ field: "name", order: "ASC" }}
actions={<UserListActions maxResults={10000} />}
bulkActionButtons={<UserBulkActionButtons />}
pagination={<UserPagination />}
>
<Datagrid rowClick="edit">
<AvatarField
source="avatar_src"
className={classes.small}
sortBy="avatar_url"
/>
<TextField source="id" sortBy="name" />
<TextField source="displayname" />
<BooleanField source="is_guest" />
<BooleanField source="admin" />
<BooleanField source="deactivated" />
<DateField
source="creation_ts"
label="resources.users.fields.creation_ts_ms"
showTime
options={date_format}
/>
</Datagrid>
</List>
);
};
// https://matrix.org/docs/spec/appendices#user-identifiers
// here only local part of user_id
// maxLength = 255 - "@" - ":" - localStorage.getItem("home_server").length
// localStorage.getItem("home_server").length is not valid here
const validateUser = [
required(),
maxLength(253),
regex(/^[a-z0-9._=\-/]+$/, "synapseadmin.users.invalid_user_id"),
];
const validateAddress = [required(), maxLength(255)];
export function generateRandomUser() {
const homeserver = localStorage.getItem("home_server");
const user_id =
"@" +
Array(8)
.fill("0123456789abcdefghijklmnopqrstuvwxyz")
.map(
x =>
x[
Math.floor(
(crypto.getRandomValues(new Uint32Array(1))[0] /
(0xffffffff + 1)) *
x.length
)
]
)
.join("") +
":" +
homeserver;
const password = Array(20)
.fill(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~!@-#$"
)
.map(
x =>
x[
Math.floor(
(crypto.getRandomValues(new Uint32Array(1))[0] / (0xffffffff + 1)) *
x.length
)
]
)
.join("");
return {
id: user_id,
password: password,
};
}
const UserEditToolbar = props => (
<Toolbar {...props}>
<SaveButton submitOnEnter={true} disabled={props.pristine} />
</Toolbar>
);
const UserEditActions = ({ data }) => {
const translate = useTranslate();
var userStatus = "";
if (data) {
userStatus = data.deactivated;
}
return (
<TopToolbar>
{!userStatus && <ServerNoticeButton record={data} />}
<DeleteButton
record={data}
label="resources.users.action.erase"
confirmTitle={translate("resources.users.helper.erase", {
smart_count: 1,
})}
mutationMode="pessimistic"
/>
</TopToolbar>
);
};
export const UserCreate = props => (
<Create {...props}>
<SimpleForm>
<TextInput source="id" autoComplete="off" validate={validateUser} />
<TextInput source="displayname" validate={maxLength(256)} />
<PasswordInput
source="password"
autoComplete="new-password"
validate={maxLength(512)}
/>
<SelectInput
source="user_type"
choices={choices_type}
translateChoice={false}
allowEmpty={true}
resettable
/>
<BooleanInput source="admin" />
<ArrayInput source="threepids">
<SimpleFormIterator disableReordering>
<SelectInput
source="medium"
choices={choices_medium}
validate={required()}
/>
<TextInput source="address" validate={validateAddress} />
</SimpleFormIterator>
</ArrayInput>
<ArrayInput source="external_ids" label="synapseadmin.users.tabs.sso">
<SimpleFormIterator disableReordering>
<TextInput source="auth_provider" validate={required()} />
<TextInput
source="external_id"
label="resources.users.fields.id"
validate={required()}
/>
</SimpleFormIterator>
</ArrayInput>
</SimpleForm>
</Create>
);
const UserTitle = props => {
const record = useRecordContext();
const translate = useTranslate();
return (
<span>
{translate("resources.users.name", {
smart_count: 1,
})}{" "}
{record ? `"${record.displayname}"` : ""}
</span>
);
};
export const UserEdit = props => {
const classes = useStyles();
const translate = useTranslate();
return (
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}>
<TabbedForm toolbar={<UserEditToolbar />}>
<FormTab
label={translate("resources.users.name", { smart_count: 1 })}
icon={<PersonPinIcon />}
>
<AvatarField
source="avatar_src"
sortable={false}
className={classes.large}
/>
<TextInput source="id" disabled />
<TextInput source="displayname" />
<PasswordInput
source="password"
autoComplete="new-password"
helperText="resources.users.helper.password"
/>
<SelectInput
source="user_type"
choices={choices_type}
translateChoice={false}
allowEmpty={true}
resettable
/>
<BooleanInput source="admin" />
<BooleanInput
source="deactivated"
helperText="resources.users.helper.deactivate"
/>
<DateField source="creation_ts_ms" showTime options={date_format} />
<TextField source="consent_version" />
</FormTab>
<FormTab
label="resources.users.threepid"
icon={<ContactMailIcon />}
path="threepid"
>
<ArrayInput source="threepids">
<SimpleFormIterator disableReordering>
<SelectInput source="medium" choices={choices_medium} />
<TextInput source="address" />
</SimpleFormIterator>
</ArrayInput>
</FormTab>
<FormTab
label="synapseadmin.users.tabs.sso"
icon={<AssignmentIndIcon />}
path="sso"
>
<ArrayInput source="external_ids" label={false}>
<SimpleFormIterator disableReordering>
<TextInput source="auth_provider" validate={required()} />
<TextInput
source="external_id"
label="resources.users.fields.id"
validate={required()}
/>
</SimpleFormIterator>
</ArrayInput>
</FormTab>
<FormTab
label={translate("resources.devices.name", { smart_count: 2 })}
icon={<DevicesIcon />}
path="devices"
>
<ReferenceManyField
reference="devices"
target="user_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }}>
<TextField source="device_id" sortable={false} />
<TextField source="display_name" sortable={false} />
<TextField source="last_seen_ip" sortable={false} />
<DateField
source="last_seen_ts"
showTime
options={date_format}
sortable={false}
/>
<DeviceRemoveButton />
</Datagrid>
</ReferenceManyField>
</FormTab>
<FormTab
label="resources.connections.name"
icon={<SettingsInputComponentIcon />}
path="connections"
>
<ReferenceField
reference="connections"
source="id"
addLabel={false}
link={false}
>
<ArrayField
source="devices[].sessions[0].connections"
label="resources.connections.name"
>
<Datagrid style={{ width: "100%" }}>
<TextField source="ip" sortable={false} />
<DateField
source="last_seen"
showTime
options={date_format}
sortable={false}
/>
<TextField
source="user_agent"
sortable={false}
style={{ width: "100%" }}
/>
</Datagrid>
</ArrayField>
</ReferenceField>
</FormTab>
<FormTab
label={translate("resources.users_media.name", { smart_count: 2 })}
icon={<PermMediaIcon />}
path="media"
>
<ReferenceManyField
reference="users_media"
target="user_id"
addLabel={false}
pagination={<UserPagination />}
perPage={50}
sort={{ field: "created_ts", order: "DESC" }}
>
<Datagrid style={{ width: "100%" }}>
<DateField source="created_ts" showTime options={date_format} />
<DateField
source="last_access_ts"
showTime
options={date_format}
/>
<TextField source="media_id" />
<NumberField source="media_length" />
<TextField source="media_type" />
<TextField source="upload_name" />
<TextField source="quarantined_by" />
<QuarantineMediaButton label="resources.quarantine_media.action.name" />
<ProtectMediaButton label="resources.users_media.fields.safe_from_quarantine" />
<DeleteButton mutationMode="pessimistic" redirect={false} />
</Datagrid>
</ReferenceManyField>
</FormTab>
<FormTab
label={translate("resources.rooms.name", { smart_count: 2 })}
icon={<ViewListIcon />}
path="rooms"
>
<ReferenceManyField
reference="joined_rooms"
target="user_id"
addLabel={false}
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, basePath, record) => "/rooms/" + id + "/show"}
>
<TextField
source="id"
sortable={false}
label="resources.rooms.fields.room_id"
/>
<ReferenceField
label="resources.rooms.fields.name"
source="id"
reference="rooms"
sortable={false}
link=""
>
<TextField source="name" sortable={false} />
</ReferenceField>
</Datagrid>
</ReferenceManyField>
</FormTab>
<FormTab
label={translate("resources.pushers.name", { smart_count: 2 })}
icon={<NotificationsIcon />}
path="pushers"
>
<ReferenceManyField
reference="pushers"
target="user_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }}>
<TextField source="kind" sortable={false} />
<TextField source="app_display_name" sortable={false} />
<TextField source="app_id" sortable={false} />
<TextField source="data.url" sortable={false} />
<TextField source="device_display_name" sortable={false} />
<TextField source="lang" sortable={false} />
<TextField source="profile_tag" sortable={false} />
<TextField source="pushkey" sortable={false} />
</Datagrid>
</ReferenceManyField>
</FormTab>
</TabbedForm>
</Edit>
);
};

View File

@ -1,12 +1,23 @@
import germanMessages from "ra-language-german";
import { formalGermanMessages } from "@haleos/ra-language-german";
const de = {
...germanMessages,
import { SynapseTranslationMessages } from ".";
const de: SynapseTranslationMessages = {
...formalGermanMessages,
ra: {
...formalGermanMessages.ra,
navigation: {
...formalGermanMessages.ra.navigation,
no_filtered_results: "Keine Ergebnisse",
clear_filters: "Alle Filter entfernen",
},
},
synapseadmin: {
auth: {
base_url: "Heimserver URL",
welcome: "Willkommen bei Synapse-admin",
server_version: "Synapse Version",
supports_specs: "unterstützt Matrix-Specs",
username_error: "Bitte vollständigen Nutzernamen angeben: '@user:domain'",
protocol_error: "Die URL muss mit 'http://' oder 'https://' beginnen",
url_error: "Keine gültige Matrix Server URL",
@ -43,11 +54,9 @@ const de = {
cards: {
importstats: {
header: "Benutzer importieren",
users_total:
"%{smart_count} Benutzer in der CSV Datei |||| %{smart_count} Benutzer in der CSV Datei",
users_total: "%{smart_count} Benutzer in der CSV Datei |||| %{smart_count} Benutzer in der CSV Datei",
guest_count: "%{smart_count} Gast |||| %{smart_count} Gäste",
admin_count:
"%{smart_count} Server Administrator |||| %{smart_count} Server Administratoren",
admin_count: "%{smart_count} Server Administrator |||| %{smart_count} Server Administratoren",
},
conflicts: {
header: "Konfliktstrategie",
@ -59,8 +68,7 @@ const de = {
ids: {
header: "IDs",
all_ids_present: "IDs in jedem Eintrag vorhanden",
count_ids_present:
"%{smart_count} Eintrag mit ID |||| %{smart_count} Einträge mit IDs",
count_ids_present: "%{smart_count} Eintrag mit ID |||| %{smart_count} Einträge mit IDs",
mode: {
ignore: "Ignoriere IDs der CSV-Datei und erstelle neue",
update: "Aktualisiere existierende Benutzer",
@ -69,8 +77,7 @@ const de = {
passwords: {
header: "Passwörter",
all_passwords_present: "Passwörter in jedem Eintrag vorhanden",
count_passwords_present:
"%{smart_count} Eintrag mit Passwort |||| %{smart_count} Einträge mit Passwörtern",
count_passwords_present: "%{smart_count} Eintrag mit Passwort |||| %{smart_count} Einträge mit Passwörtern",
use_passwords: "Verwende Passwörter aus der CSV Datei",
},
upload: {
@ -84,17 +91,31 @@ const de = {
},
results: {
header: "Ergebnis",
total:
"%{smart_count} Eintrag insgesamt |||| %{smart_count} Einträge insgesamt",
total: "%{smart_count} Eintrag insgesamt |||| %{smart_count} Einträge insgesamt",
successful: "%{smart_count} Einträge erfolgreich importiert",
skipped: "%{smart_count} Einträge übersprungen",
download_skipped: "Übersprungene Einträge herunterladen",
with_error:
"%{smart_count} Eintrag mit Fehlern ||| %{smart_count} Einträge mit Fehlern",
with_error: "%{smart_count} Eintrag mit Fehlern ||| %{smart_count} Einträge mit Fehlern",
simulated_only: "Import-Vorgang war nur simuliert",
},
},
},
delete_media: {
name: "Medien",
fields: {
before_ts: "Letzter Zugriff vor",
size_gt: "Größer als (in Bytes)",
keep_profiles: "Behalte Profilbilder",
},
action: {
send: "Medien löschen",
send_success: "Anfrage erfolgreich versendet.",
send_failure: "Beim Versenden ist ein Fehler aufgetreten.",
},
helper: {
send: "Diese API löscht die lokalen Medien von der Festplatte des eigenen Servers. Dies umfasst alle lokalen Miniaturbilder und Kopien von Medien. Diese API wirkt sich nicht auf Medien aus, die sich in externen Medien-Repositories befinden.",
},
},
resources: {
users: {
name: "Benutzer",
@ -107,7 +128,9 @@ const de = {
name: "Name",
is_guest: "Gast",
admin: "Server Administrator",
locked: "Gesperrt",
deactivated: "Deaktiviert",
erased: "Gelöscht",
guests: "Zeige Gäste",
show_deactivated: "Zeige deaktivierte Benutzer",
user_id: "Suche Benutzer",
@ -124,10 +147,8 @@ const de = {
user_type: "Benutzertyp",
},
helper: {
password:
"Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.",
deactivate:
"Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
password: "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.",
deactivate: "Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
erase: "DSGVO konformes Löschen der Benutzerdaten",
},
action: {
@ -188,7 +209,7 @@ const de = {
},
},
reports: {
name: "Ereignisbericht |||| Ereignisberichte",
name: "Gemeldetes Ereignis |||| Gemeldete Ereignisse",
fields: {
id: "ID",
received_ts: "Meldezeit",
@ -207,9 +228,20 @@ const de = {
format: "Nachrichtenformat",
formatted_body: "Formatierter Nachrichteninhalt",
algorithm: "Verschlüsselungsalgorithmus",
info: {
mimetype: "Typ",
},
url: "URL",
},
},
},
action: {
erase: {
title: "Gemeldetes Event löschen",
content:
"Sind Sie sicher dass Sie das gemeldete Event löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
},
},
},
connections: {
name: "Verbindungen",
@ -248,21 +280,8 @@ const de = {
created_ts: "Erstellt",
last_access_ts: "Letzter Zugriff",
},
},
delete_media: {
name: "Medien",
fields: {
before_ts: "Letzter Zugriff vor",
size_gt: "Größer als (in Bytes)",
keep_profiles: "Behalte Profilbilder",
},
action: {
send: "Medien löschen",
send_success: "Anfrage erfolgreich versendet.",
send_failure: "Beim Versenden ist ein Fehler aufgetreten.",
},
helper: {
send: "Diese API löscht die lokalen Medien von der Festplatte des eigenen Servers. Dies umfasst alle lokalen Miniaturbilder und Kopien von Medien. Diese API wirkt sich nicht auf Medien aus, die sich in externen Medien-Repositories befinden.",
open: "Mediendatei in neuem Fenster öffnen",
},
},
protect_media: {
@ -345,8 +364,7 @@ const de = {
guest_can_join: "Gastbenutzer dürfen beitreten",
},
action: {
title:
"Raum aus Verzeichnis löschen |||| %{smart_count} Räume aus Verzeichnis löschen",
title: "Raum aus Verzeichnis löschen |||| %{smart_count} Räume aus Verzeichnis löschen",
content:
"Möchten Sie den Raum wirklich aus dem Raumverzeichnis löschen? |||| Möchten Sie die %{smart_count} Räume wirklich aus dem Raumverzeichnis löschen?",
erase: "Lösche aus Verzeichnis",
@ -381,37 +399,5 @@ const de = {
helper: { length: "Länge des Tokens, wenn kein Token vorgegeben wird." },
},
},
ra: {
...germanMessages.ra,
action: {
...germanMessages.ra.action,
unselect: "Abwählen",
},
auth: {
...germanMessages.ra.auth,
auth_check_error: "Anmeldung fehlgeschlagen",
},
input: {
...germanMessages.ra.input,
password: {
...germanMessages.ra.input.password,
toggle_hidden: "Anzeigen",
toggle_visible: "Verstecken",
},
},
notification: {
...germanMessages.ra.notification,
logged_out: "Abgemeldet",
},
page: {
...germanMessages.ra.page,
empty: "Keine Einträge vorhanden",
invite: "",
},
navigation: {
...germanMessages.ra.navigation,
skip_nav: "Zum Inhalt springen",
},
},
};
export default de;

View File

@ -1,12 +1,15 @@
import englishMessages from "ra-language-english";
const en = {
import { SynapseTranslationMessages } from ".";
const en: SynapseTranslationMessages = {
...englishMessages,
synapseadmin: {
auth: {
base_url: "Homeserver URL",
welcome: "Welcome to Synapse-admin",
server_version: "Synapse version",
supports_specs: "supports Matrix specs",
username_error: "Please enter fully qualified user ID: '@user:domain'",
protocol_error: "URL has to start with 'http://' or 'https://'",
url_error: "Not a valid Matrix server URL",
@ -17,6 +20,7 @@ const en = {
tabs: { sso: "SSO" },
},
rooms: {
details: "Room details",
tabs: {
basic: "Basic",
members: "Members",
@ -31,10 +35,8 @@ const en = {
at_entry: "At entry %{entry}: %{message}",
error: "Error",
required_field: "Required field '%{field}' is not present",
invalid_value:
"Invalid value on line %{row}. '%{field}' field may only be 'true' or 'false'",
unreasonably_big:
"Refused to load unreasonably big file of %{size} megabytes",
invalid_value: "Invalid value on line %{row}. '%{field}' field may only be 'true' or 'false'",
unreasonably_big: "Refused to load unreasonably big file of %{size} megabytes",
already_in_progress: "An import run is already in progress",
id_exits: "ID %{id} already present",
},
@ -43,8 +45,7 @@ const en = {
cards: {
importstats: {
header: "Import users",
users_total:
"%{smart_count} user in CSV file |||| %{smart_count} users in CSV file",
users_total: "%{smart_count} user in CSV file |||| %{smart_count} users in CSV file",
guest_count: "%{smart_count} guest |||| %{smart_count} guests",
admin_count: "%{smart_count} admin |||| %{smart_count} admins",
},
@ -58,8 +59,7 @@ const en = {
ids: {
header: "IDs",
all_ids_present: "IDs present on every entry",
count_ids_present:
"%{smart_count} entry with ID |||| %{smart_count} entries with IDs",
count_ids_present: "%{smart_count} entry with ID |||| %{smart_count} entries with IDs",
mode: {
ignore: "Ignore IDs in CSV and create new ones",
update: "Update existing records",
@ -68,8 +68,7 @@ const en = {
passwords: {
header: "Passwords",
all_passwords_present: "Passwords present on every entry",
count_passwords_present:
"%{smart_count} entry with password |||| %{smart_count} entries with passwords",
count_passwords_present: "%{smart_count} entry with password |||| %{smart_count} entries with passwords",
use_passwords: "Use passwords from CSV",
},
upload: {
@ -83,17 +82,31 @@ const en = {
},
results: {
header: "Import results",
total:
"%{smart_count} entry in total |||| %{smart_count} entries in total",
total: "%{smart_count} entry in total |||| %{smart_count} entries in total",
successful: "%{smart_count} entries successfully imported",
skipped: "%{smart_count} entries skipped",
download_skipped: "Download skipped records",
with_error:
"%{smart_count} entry with errors ||| %{smart_count} entries with errors",
with_error: "%{smart_count} entry with errors |||| %{smart_count} entries with errors",
simulated_only: "Run was only simulated",
},
},
},
delete_media: {
name: "Media",
fields: {
before_ts: "last access before",
size_gt: "Larger then (in bytes)",
keep_profiles: "Keep profile images",
},
action: {
send: "Delete media",
send_success: "Request successfully sent.",
send_failure: "An error has occurred.",
},
helper: {
send: "This API deletes the local media from the disk of your own server. This includes any local thumbnails and copies of media downloaded. This API will not affect media that has been uploaded to external media repositories.",
},
},
resources: {
users: {
name: "User |||| Users",
@ -106,9 +119,12 @@ const en = {
name: "Name",
is_guest: "Guest",
admin: "Server Administrator",
locked: "Locked",
deactivated: "Deactivated",
erased: "Erased",
guests: "Show guests",
show_deactivated: "Show deactivated users",
show_locked: "Show locked users",
user_id: "Search user",
displayname: "Displayname",
password: "Password",
@ -204,9 +220,19 @@ const en = {
format: "format",
formatted_body: "formatted content",
algorithm: "algorithm",
url: "URL",
info: {
mimetype: "Type",
},
},
},
},
action: {
erase: {
title: "Delete reported event",
content: "Are you sure you want to delete the reported event? This cannot be undone.",
},
},
},
connections: {
name: "Connections",
@ -245,21 +271,8 @@ const en = {
created_ts: "Created",
last_access_ts: "Last access",
},
},
delete_media: {
name: "Media",
fields: {
before_ts: "last access before",
size_gt: "Larger then (in bytes)",
keep_profiles: "Keep profile images",
},
action: {
send: "Delete media",
send_success: "Request successfully sent.",
send_failure: "An error has occurred.",
},
helper: {
send: "This API deletes the local media from the disk of your own server. This includes any local thumbnails and copies of media downloaded. This API will not affect media that has been uploaded to external media repositories.",
open: "Open media file in new window",
},
},
protect_media: {
@ -342,8 +355,7 @@ const en = {
guest_can_join: "guest users may join",
},
action: {
title:
"Delete room from directory |||| Delete %{smart_count} rooms from directory",
title: "Delete room from directory |||| Delete %{smart_count} rooms from directory",
content:
"Are you sure you want to remove this room from directory? |||| Are you sure you want to remove these %{smart_count} rooms from directory?",
erase: "Delete from room directory",
@ -364,19 +376,19 @@ const en = {
},
action: { reconnect: "Reconnect" },
},
},
registration_tokens: {
name: "Registration tokens",
fields: {
token: "Token",
valid: "Valid token",
uses_allowed: "Uses allowed",
pending: "Pending",
completed: "Completed",
expiry_time: "Expiry time",
length: "Length",
registration_tokens: {
name: "Registration tokens",
fields: {
token: "Token",
valid: "Valid token",
uses_allowed: "Uses allowed",
pending: "Pending",
completed: "Completed",
expiry_time: "Expiry time",
length: "Length",
},
helper: { length: "Length of the token if no token is given." },
},
helper: { length: "Length of the token if no token is given." },
},
};
export default en;

376
src/i18n/fa.ts Normal file
View File

@ -0,0 +1,376 @@
import farsiMessages from "ra-language-farsi";
import { SynapseTranslationMessages } from ".";
const fa: SynapseTranslationMessages = {
...farsiMessages,
synapseadmin: {
auth: {
base_url: "آدرس سرور",
welcome: "به پنل مدیریت سیناپس خوش آمدید!",
server_version: "نسخه",
username_error: "لطفاً شناسه کاربر را وارد کنید: '@user:domain'",
protocol_error: "URL باید با 'http://' یا 'https://' شروع شود",
url_error: "آدرس وارد شده یک سرور معتبر نیست",
sso_sign_in: "با SSO وارد شوید",
},
users: {
invalid_user_id: "بخش محلی یک شناسه کاربری ماتریکس بدون سرور خانگی.",
tabs: { sso: "SSO" },
},
rooms: {
tabs: {
basic: "اصلی",
members: "اعضا",
detail: "جزئیات",
permission: "مجوزها",
},
},
reports: { tabs: { basic: "اصلی", detail: "جزئیات" } },
},
import_users: {
error: {
at_entry: "در هنگام ورود %{entry}: %{message}",
error: "Error",
required_field: "فیلد الزامی '%{field}' وجود ندارد",
invalid_value: "خطا در خط %{row}. '%{field}' فیلد ممکن است فقط 'درست' یا 'نادرست' باشد",
unreasonably_big: "از بارگذاری فایل هایی با حجم غیر منطقی خودداری کنید %{size} مگابایت",
already_in_progress: "یک بارگذاری از قبل در حال انجام است",
id_exits: "شناسه %{id} موجود است",
},
title: "کاربران را از طریق فایل CSV وارد کنید",
goToPdf: "رفتن به PDF",
cards: {
importstats: {
header: "وارد کردن کاربران",
users_total: "%{smart_count} user in CSV file |||| %{smart_count} users in CSV file",
guest_count: "%{smart_count} guest |||| %{smart_count} guests",
admin_count: "%{smart_count} admin |||| %{smart_count} admins",
},
conflicts: {
header: "استراتژی متغارض",
mode: {
stop: "توقف",
skip: "نمایش خطا و رد شدن",
},
},
ids: {
header: "شناسنامه ها",
all_ids_present: "شناسه های موجود در هر ورودی",
count_ids_present: "%{smart_count} ورود با شناسه |||| %{smart_count} ورودی با شناسه",
mode: {
ignore: "شناسه ها را در CSV نادیده بگیر و شناسه های جدید ایجاد کن",
update: "سوابق موجود را به روز کنید",
},
},
passwords: {
header: "رمز عبور",
all_passwords_present: "رمزهای عبور موجود در هر ورودی",
count_passwords_present: "%{smart_count} ورود با رمز عبور |||| %{smart_count} ورودی با رمز عبور",
use_passwords: "از پسوردهای CSV استفاده کنید",
},
upload: {
header: "Input CSV file",
explanation:
"در اینجا می توانید فایلی را با مقادیر جدا شده با کاما بارگذاری کنید که برای ایجاد یا به روز رسانی کاربران پردازش می شود. فایل باید شامل فیلدهای 'id' و 'displayname' باشد. می توانید یک فایل نمونه را از اینجا دانلود و تطبیق دهید: ",
},
startImport: {
simulate_only: "فقط شبیه سازی",
run_import: "بارگذاری",
},
results: {
header: "بارگذاری نتایج",
total: "%{smart_count} ورودی در کل |||| %{smart_count} ورودی ها در کل",
successful: "%{smart_count} ورودی ها با موفقیت وارد شدند",
skipped: "%{smart_count} ورودی ها نادیده گرفته شدند",
download_skipped: "دانلود رکوردهای نادیده گرفته شده",
with_error: "%{smart_count} ورود با خطا ||| %{smart_count} ورودی های دارای خطا",
simulated_only: "اجرا فقط شبیه سازی شد",
},
},
},
delete_media: {
name: "رسانه ها",
fields: {
before_ts: "آخرین دسترسی قبل",
size_gt: "بزرگتر از آن (به بایت)",
keep_profiles: "تصاویر پروفایل را نگه دارید",
},
action: {
send: "حذف رسانه ها",
send_success: "درخواست با موفقیت ارسال شد.",
send_failure: "خطایی رخ داده است.",
},
helper: {
send: "این API رسانه های محلی را از دیسک سرور خود حذف می کند. این شامل هر تصویر کوچک محلی و کپی از رسانه دانلود شده است. این API بر رسانه‌هایی که در مخازن رسانه خارجی آپلود شده‌اند تأثیری نخواهد گذاشت.",
},
},
resources: {
users: {
name: "کاربر |||| کاربران",
email: "ایمیل",
msisdn: "شماره تلفن",
threepid: "ایمیل / شماره تلفن",
fields: {
avatar: "آواتار",
id: "شناسه کاربر",
name: "نام",
is_guest: "مهمان",
admin: "مدیر سرور",
deactivated: "غیرفعال",
guests: "نمایش مهمانان",
show_deactivated: "نمایش کاربران غیرفعال شده",
user_id: "جستجوی کاربر",
displayname: "نام نمایشی",
password: "رمز عبور",
avatar_url: "آواتار سرور",
avatar_src: "آواتار",
medium: "متوسط",
threepids: "سرویس احراز هویت",
address: "آدرس",
creation_ts_ms: "ساخته شده در",
consent_version: "Consent نسخه",
auth_provider: "ارائه دهنده",
user_type: "نوع کاربر",
},
helper: {
password: "با تغییر رمز عبور کاربر از تمام دستگاه ها خارج می شود.",
deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.",
erase: "کاربر را به عنوان GDPR پاک شده علامت گذاری کنید",
},
action: {
erase: "پاک کردن اطلاعات کاربر",
},
},
rooms: {
name: "اتاق |||| اتاق ها",
fields: {
room_id: "شناسه اتاق",
name: "نام",
canonical_alias: "نام مستعار",
joined_members: "اعضا",
joined_local_members: "اعضای محلی",
joined_local_devices: "دستگاه های محلی",
state_events: "رویدادهای حالت / پیچیدگی",
version: "نسخه",
is_encrypted: "رمزگذاری شده است",
encryption: "رمزگذاری",
federatable: "Federatable",
public: "قابل مشاهده در فهرست اتاق",
creator: "سازنده",
join_rules: "به قوانین بپیوندید",
guest_access: "دسترسی مهمان",
history_visibility: "مشاهده تاریخچه",
topic: "موضوع",
avatar: "آواتار",
},
helper: {
forward_extremities:
"اندام های رو به جلو، رویدادهای برگ در انتهای نمودار غیر چرخه ای جهت دار (DAG) در یک اتاق هستند، رویدادهایی که فرزند ندارند. هر چه تعداد بیشتری در یک اتاق وجود داشته باشد، وضوح حالت بیشتری را که سیناپس باید انجام دهد (نکته: این یک عملیات گران است). در حالی که Synapse کدی برای جلوگیری از وجود تعداد زیادی از این موارد در یک زمان در اتاق دارد، گاهی اوقات باگ‌ها می‌توانند دوباره ظاهر شوند. اگر اتاقی بیش از 10 انتهای رو به جلو دارد، بهتر است بررسی کنید که کدام اتاق مقصر است و احتمالاً آنها را با استفاده از جستارهای SQL ذکر شده در آن حذف کنید. #1760.",
},
enums: {
join_rules: {
public: "عمومی",
knock: "در زدن",
invite: "دعوت کردن",
private: "خصوصی",
},
guest_access: {
can_join: "مهمانان می توانند ملحق شوند",
forbidden: "مهمانان نمی توانند ملحق شوند",
},
history_visibility: {
invited: "از آنجایی که دعوت شده است",
joined: "از زمانی که پیوست",
shared: "از آنجایی که به اشتراک گذاشته شده است",
world_readable: "هر کسی",
},
unencrypted: "رمزگذاری نشده",
},
action: {
erase: {
title: "حذف اتاق",
content:
"آیا مطمئن هستید که می خواهید اتاق را حذف کنید؟ این قابل بازگشت نیست. همه پیام ها و رسانه های مشترک در اتاق از سرور حذف می شوند!",
},
},
},
reports: {
name: "رویداد گزارش شده |||| رویدادهای گزارش شده",
fields: {
id: "شناسه",
received_ts: "زمان گزارش",
user_id: "گوینده",
name: "نام اتاق",
score: "نمره",
reason: "دلیل",
event_id: "شناسه رویداد",
event_json: {
origin: "سرور مبدا",
origin_server_ts: "زمان ارسال",
type: "نوع رویداد",
content: {
msgtype: "نوع محتوا",
body: "محتوا",
format: "قالب",
formatted_body: "محتوای قالب بندی شده",
algorithm: "الگوریتم",
},
},
},
},
connections: {
name: "اتصالات",
fields: {
last_seen: "تاریخ",
ip: "آدرس آی پی",
user_agent: "نماینده کاربر",
},
},
devices: {
name: "دستگاه |||| دستگاه ها",
fields: {
device_id: "شناسه دستگاه",
display_name: "نام دستگاه",
last_seen_ts: "مهر زمان",
last_seen_ip: "آدرس آی پی",
},
action: {
erase: {
title: "حذف کردن %{id}",
content: 'آیا مطمئن هستید که می خواهید دستگاه را حذف کنید؟ "%{name}"?',
success: "دستگاه با موفقیت حذف شد.",
failure: "خطایی رخ داده است.",
},
},
},
users_media: {
name: "رسانه ها",
fields: {
media_id: "شناسه رسانه",
media_length: "اندازه فایل (به بایت)",
media_type: "نوع",
upload_name: "نام فایل",
quarantined_by: "قرنطینه شده توسط",
safe_from_quarantine: "امان از قرنطینه",
created_ts: "ایجاد شده",
last_access_ts: "آخرین دسترسی",
},
},
protect_media: {
action: {
create: "محافظت نشده، حفاظت ایجاد کنید",
delete: "محافظت شده، حفاظت را بردارید",
none: "در قرنطینه",
send_success: "وضعیت حفاظت با موفقیت تغییر کرد.",
send_failure: "خطایی رخ داده است.",
},
},
quarantine_media: {
action: {
name: "قرنطینه",
create: "به قرنطینه اضافه کنید",
delete: "در قرنطینه، غیر قرنطینه",
none: "از قرنطینه محافظت می شود",
send_success: "وضعیت قرنطینه با موفقیت تغییر کرد.",
send_failure: "خطایی رخ داده است.",
},
},
pushers: {
name: "هل دهنده |||| هل دهنده ها",
fields: {
app: "برنامه",
app_display_name: "نام نمایش برنامه",
app_id: "شناسه برنامه",
device_display_name: "نام نمایشی برنامه",
kind: "نوع",
lang: "زبان",
profile_tag: "برچسب پروفایل",
pushkey: "کلید",
data: { url: "URL" },
},
},
servernotices: {
name: "اطلاعیه های سرور",
send: "ارسال اعلانات سرور",
fields: {
body: "پیام",
},
action: {
send: "ارسال یادداشت",
send_success: "اعلان سرور با موفقیت ارسال شد.",
send_failure: "خطایی رخ داده است.",
},
helper: {
send: "اعلان سرور را برای کاربران انتخاب شده ارسال می کند. ویژگی 'اعلامیه های سرور' باید در سرور فعال شود.",
},
},
user_media_statistics: {
name: "رسانه کاربران",
fields: {
media_count: "شمارش رسانه ها",
media_length: "طول رسانه",
},
},
forward_extremities: {
name: "Forward Extremities",
fields: {
id: "شناسه رویداد",
received_ts: "مهر زمان",
depth: "عمق",
state_group: "گروه دولتی",
},
},
room_state: {
name: "رویدادهای وضعیت",
fields: {
type: "نوع",
content: "محتوا",
origin_server_ts: "زمان ارسال",
sender: "فرستنده",
},
},
room_directory: {
name: "راهنمای اتاق",
fields: {
world_readable: "کاربران مهمان می توانند بدون عضویت مشاهده کنند",
guest_can_join: "کاربران مهمان ممکن است ملحق شوند",
},
action: {
title: "اتاق را از فهرست حذف کنید |||| حذف کنید %{smart_count} اتاق ها از دایرکتوری",
content:
"آیا مطمئنید که می خواهید این اتاق را از فهرست راهنمای حذف کنید؟ |||| آیا مطمئن هستید که می خواهید این موارد را %{smart_count} از راهنمای اتاق ها حذف کنید؟",
erase: "حذف از فهرست اتاق",
create: "انتشار در راهنما اتاق",
send_success: "اتاق با موفقیت منتشر شد.",
send_failure: "خطایی رخ داده است.",
},
},
destinations: {
name: "سرور های مرتبط",
fields: {
destination: "آدرس",
failure_ts: "زمان شکست",
retry_last_ts: "آخرین زمان اتصال",
retry_interval: "بازه امتحان مجدد",
last_successful_stream_ordering: "آخرین جریان موفق",
stream_ordering: "جریان",
},
action: { reconnect: "دوباره وصل شوید" },
},
registration_tokens: {
name: "توکن های ثبت نام",
fields: {
token: "توکن",
valid: "توکن معتبر",
uses_allowed: "موارد استفاده مجاز",
pending: "انتظار",
completed: "تکمیل شد",
expiry_time: "زمان انقضا",
length: "طول",
},
helper: { length: "طول توکن در صورت عدم ارائه توکن." },
},
},
};
export default fa;

View File

@ -1,21 +1,21 @@
import frenchMessages from "ra-language-french";
const fr = {
import { SynapseTranslationMessages } from ".";
const fr: SynapseTranslationMessages = {
...frenchMessages,
synapseadmin: {
auth: {
base_url: "URL du serveur daccueil",
welcome: "Bienvenue sur Synapse-admin",
server_version: "Version du serveur Synapse",
username_error:
"Veuillez entrer un nom d'utilisateur complet : « @utilisateur:domaine »",
username_error: "Veuillez entrer un nom d'utilisateur complet : « @utilisateur:domaine »",
protocol_error: "L'URL doit commencer par « http:// » ou « https:// »",
url_error: "L'URL du serveur Matrix n'est pas valide",
sso_sign_in: "Se connecter avec lauthentification unique",
},
users: {
invalid_user_id:
"Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur daccueil.",
invalid_user_id: "Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur daccueil.",
tabs: { sso: "Authentification unique" },
},
rooms: {
@ -35,8 +35,7 @@ const fr = {
required_field: "Le champ requis « %{field} » est manquant",
invalid_value:
"Valeur non valide à la ligne %{row}. Le champ « %{field} » ne peut être que « true » ou « false »",
unreasonably_big:
"Refus de charger un fichier trop volumineux de %{size} mégaoctets",
unreasonably_big: "Refus de charger un fichier trop volumineux de %{size} mégaoctets",
already_in_progress: "Un import est déjà en cours",
id_exits: "L'identifiant %{id} déjà présent",
},
@ -48,8 +47,7 @@ const fr = {
users_total:
"%{smart_count} utilisateur dans le fichier CSV |||| %{smart_count} utilisateurs dans le fichier CSV",
guest_count: "%{smart_count} visiteur |||| %{smart_count} visiteurs",
admin_count:
"%{smart_count} administrateur |||| %{smart_count} administrateurs",
admin_count: "%{smart_count} administrateur |||| %{smart_count} administrateurs",
},
conflicts: {
header: "Stratégie de résolution des conflits",
@ -61,11 +59,9 @@ const fr = {
ids: {
header: "Identifiants",
all_ids_present: "Identifiants présents pour chaque entrée",
count_ids_present:
"%{smart_count} entrée avec identifiant |||| %{smart_count} entrées avec identifiant",
count_ids_present: "%{smart_count} entrée avec identifiant |||| %{smart_count} entrées avec identifiant",
mode: {
ignore:
"Ignorer les identifiants dans le ficher CSV et en créer de nouveaux",
ignore: "Ignorer les identifiants dans le ficher CSV et en créer de nouveaux",
update: "Mettre à jour les enregistrements existants",
},
},
@ -87,17 +83,31 @@ const fr = {
},
results: {
header: "Résultats de l'import",
total:
"%{smart_count} entrée au total |||| %{smart_count} entrées au total",
total: "%{smart_count} entrée au total |||| %{smart_count} entrées au total",
successful: "%{smart_count} entrées importées avec succès",
skipped: "%{smart_count} entrées ignorées",
download_skipped: "Télécharger les entrées ignorées",
with_error:
"%{smart_count} entrée avec des erreurs ||| %{smart_count} entrées avec des erreurs",
with_error: "%{smart_count} entrée avec des erreurs ||| %{smart_count} entrées avec des erreurs",
simulated_only: "L'import était simulé",
},
},
},
delete_media: {
name: "Media",
fields: {
before_ts: "Dernier accès avant",
size_gt: "Plus grand que (en octets)",
keep_profiles: "Conserver les images de profil",
},
action: {
send: "Supprimer le média",
send_success: "Requête envoyée avec succès",
send_failure: "Une erreur s'est produite",
},
helper: {
send: "Cette API supprime les médias locaux du disque de votre propre serveur. Cela inclut toutes les vignettes locales et les copies des médias téléchargés. Cette API n'affectera pas les médias qui ont été téléversés dans des dépôts de médias externes.",
},
},
resources: {
users: {
name: "Utilisateur |||| Utilisateurs",
@ -110,6 +120,7 @@ const fr = {
name: "Nom",
is_guest: "Visiteur",
admin: "Administrateur du serveur",
locked: "Verrouillé",
deactivated: "Désactivé",
guests: "Afficher les visiteurs",
show_deactivated: "Afficher les utilisateurs désactivés",
@ -126,8 +137,7 @@ const fr = {
auth_provider: "Fournisseur d'identité",
},
helper: {
deactivate:
"Vous devrez fournir un mot de passe pour réactiver le compte.",
deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.",
erase: "Marquer l'utilisateur comme effacé conformément au RGPD",
},
action: {
@ -249,22 +259,6 @@ const fr = {
last_access_ts: "Dernier accès",
},
},
delete_media: {
name: "Media",
fields: {
before_ts: "Dernier accès avant",
size_gt: "Plus grand que (en octets)",
keep_profiles: "Conserver les images de profil",
},
action: {
send: "Supprimer le média",
send_success: "Requête envoyée avec succès",
send_failure: "Une erreur s'est produite",
},
helper: {
send: "Cette API supprime les médias locaux du disque de votre propre serveur. Cela inclut toutes les vignettes locales et les copies des médias téléchargés. Cette API n'affectera pas les médias qui ont été téléversés dans des dépôts de médias externes.",
},
},
protect_media: {
action: {
create: "Protéger",
@ -341,13 +335,11 @@ const fr = {
room_directory: {
name: "Répertoire des salons",
fields: {
world_readable:
"Tout utilisateur peut avoir un aperçu du salon, sans en devenir membre",
world_readable: "Tout utilisateur peut avoir un aperçu du salon, sans en devenir membre",
guest_can_join: "Les visiteurs peuvent rejoindre le salon",
},
action: {
title:
"Supprimer un salon du répertoire |||| Supprimer %{smart_count} salons du répertoire",
title: "Supprimer un salon du répertoire |||| Supprimer %{smart_count} salons du répertoire",
content:
"Voulez-vous vraiment supprimer ce salon du répertoire ? |||| Voulez-vous vraiment supprimer ces %{smart_count} salons du répertoire ?",
erase: "Supprimer du répertoire des salons",
@ -356,21 +348,20 @@ const fr = {
send_failure: "Une erreur s'est produite",
},
},
},
registration_tokens: {
name: "Jetons d'inscription",
fields: {
token: "Jeton",
valid: "Jeton valide",
uses_allowed: "Nombre d'inscription autorisées",
pending: "Nombre d'inscription en cours",
completed: "Nombre d'inscription accomplie",
expiry_time: "Date d'expiration",
length: "Longueur",
},
helper: {
length:
"Longueur du jeton généré aléatoirement si aucun jeton n'est pas spécifié",
registration_tokens: {
name: "Jetons d'inscription",
fields: {
token: "Jeton",
valid: "Jeton valide",
uses_allowed: "Nombre d'inscription autorisées",
pending: "Nombre d'inscription en cours",
completed: "Nombre d'inscription accomplie",
expiry_time: "Date d'expiration",
length: "Longueur",
},
helper: {
length: "Longueur du jeton généré aléatoirement si aucun jeton n'est pas spécifié",
},
},
},
};

392
src/i18n/index.d.ts vendored Normal file
View File

@ -0,0 +1,392 @@
import { TranslationMessages } from "ra-core";
interface SynapseTranslationMessages extends TranslationMessages {
synapseadmin: {
auth: {
base_url: string;
welcome: string;
server_version: string;
supports_specs?: string; // TODO: fa, fr, it, zh
username_error: string;
protocol_error: string;
url_error: string;
sso_sign_in: string;
};
users: {
invalid_user_id: string;
tabs: { sso: string };
};
rooms: {
details?: string; // TODO: fa, fr, it, zh
tabs: {
basic: string;
members: string;
detail: string;
permission: string;
};
};
reports: { tabs: { basic: string; detail: string } };
};
import_users: {
error: {
at_entry: string;
error: string;
required_field: string;
invalid_value: string;
unreasonably_big: string;
already_in_progress: string;
id_exits: string;
};
title: string;
goToPdf: string;
cards: {
importstats: {
header: string;
users_total: string;
guest_count: string;
admin_count: string;
};
conflicts: {
header: string;
mode: {
stop: string;
skip: string;
};
};
ids: {
header: string;
all_ids_present: string;
count_ids_present: string;
mode: {
ignore: string;
update: string;
};
};
passwords: {
header: string;
all_passwords_present: string;
count_passwords_present: string;
use_passwords: string;
};
upload: {
header: string;
explanation: string;
};
startImport: {
simulate_only: string;
run_import: string;
};
results: {
header: string;
total: string;
successful: string;
skipped: string;
download_skipped: string;
with_error: string;
simulated_only: string;
};
};
};
delete_media: {
name: string;
fields: {
before_ts: string;
size_gt: string;
keep_profiles: string;
};
action: {
send: string;
send_success: string;
send_failure: string;
};
helper: {
send: string;
};
};
resources: {
users: {
name: string;
email: string;
msisdn: string;
threepid: string;
fields: {
avatar: string;
id: string;
name: string;
is_guest: string;
admin: string;
locked?: string; // TODO: fa, zh
deactivated: string;
erased?: string; // TODO: fa, fr, it, zh
guests: string;
show_deactivated: string;
show_locked?: string; // TODO: de, fa, fr, it, zh
user_id: string;
displayname: string;
password: string;
avatar_url: string;
avatar_src: string;
medium: string;
threepids: string;
address: string;
creation_ts_ms: string;
consent_version: string;
auth_provider?: string;
user_type?: string;
};
helper: {
password?: string;
deactivate: string;
erase: string;
};
action: {
erase: string;
};
};
rooms: {
name: string;
fields: {
room_id: string;
name: string;
canonical_alias: string;
joined_members: string;
joined_local_members: string;
joined_local_devices?: string;
state_events: string;
version: string;
is_encrypted: string;
encryption: string;
federatable: string;
public: string;
creator: string;
join_rules: string;
guest_access: string;
history_visibility: string;
topic?: string;
avatar?: string;
};
helper?: {
forward_extremities: string;
};
enums: {
join_rules: {
public: string;
knock: string;
invite: string;
private: string;
};
guest_access: {
can_join: string;
forbidden: string;
};
history_visibility: {
invited: string;
joined: string;
shared: string;
world_readable: string;
};
unencrypted: string;
};
action?: {
erase: {
title: string;
content: string;
};
};
};
reports: {
name: string;
fields: {
id: string;
received_ts: string;
user_id: string;
name: string;
score: string;
reason: string;
event_id: string;
event_json: {
origin: string;
origin_server_ts: string;
type: string;
content: {
msgtype: string;
body: string;
format: string;
formatted_body: string;
algorithm: string;
url?: string;
info?: {
mimetype: string;
};
};
};
};
action?: {
erase: {
title: string;
content: string;
};
};
};
connections: {
name: string;
fields: {
last_seen: string;
ip: string;
user_agent: string;
};
};
devices: {
name: string;
fields: {
device_id: string;
display_name: string;
last_seen_ts: string;
last_seen_ip: string;
};
action: {
erase: {
title: string;
content: string;
success: string;
failure: string;
};
};
};
users_media: {
name: string;
fields: {
media_id: string;
media_length: string;
media_type: string;
upload_name: string;
quarantined_by: string;
safe_from_quarantine: string;
created_ts: string;
last_access_ts: string;
};
action?: {
open: string;
};
};
protect_media?: {
action: {
create: string;
delete: string;
none: string;
send_success: string;
send_failure: string;
};
};
quarantine_media?: {
action: {
name: string;
create: string;
delete: string;
none: string;
send_success: string;
send_failure: string;
};
};
pushers: {
name: string;
fields: {
app: string;
app_display_name: string;
app_id: string;
device_display_name: string;
kind: string;
lang: string;
profile_tag: string;
pushkey: string;
data: {
url: string;
};
};
};
servernotices: {
name: string;
send: string;
fields: {
body: string;
};
action: {
send: string;
send_success: string;
send_failure: string;
};
helper: {
send: string;
};
};
user_media_statistics: {
name: string;
fields: {
media_count: string;
media_length: string;
};
};
forward_extremities?: {
name: string;
fields: {
id: string;
received_ts: string;
depth: string;
state_group: string;
};
};
room_state?: {
name: string;
fields: {
type: string;
content: string;
origin_server_ts: string;
sender: string;
};
};
room_directory?: {
name: string;
fields: {
world_readable: string;
guest_can_join: string;
};
action: {
title: string;
content: string;
erase: string;
create: string;
send_success: string;
send_failure: string;
};
};
destinations?: {
name: string;
fields: {
destination: string;
failure_ts: string;
retry_last_ts: string;
retry_interval: string;
last_successful_stream_ordering: string;
stream_ordering: string;
};
action: {
reconnect: string;
};
};
registration_tokens?: {
name: string;
fields: {
token: string;
valid: string;
uses_allowed: string;
pending: string;
completed: string;
expiry_time: string;
length: string;
};
helper: {
length: string;
};
};
};
}

377
src/i18n/it.ts Normal file
View File

@ -0,0 +1,377 @@
import italianMessages from "ra-language-italian";
import { SynapseTranslationMessages } from ".";
const it: SynapseTranslationMessages = {
...italianMessages,
synapseadmin: {
auth: {
base_url: "URL dell'homeserver",
welcome: "Benvenuto in Synapse-admin",
server_version: "Versione di Synapse",
username_error: "Per favore inserisci un ID utente completo: '@utente:dominio'",
protocol_error: "L'URL deve iniziare per 'http://' o 'https://'",
url_error: "URL del server Matrix non valido",
sso_sign_in: "Accedi con SSO",
},
users: {
invalid_user_id: "ID utente non valido su questo homeserver.",
tabs: { sso: "SSO" },
},
rooms: {
tabs: {
basic: "Semplice",
members: "Membro",
detail: "Dettagli",
permission: "Permessi",
},
},
reports: { tabs: { basic: "Semplice", detail: "Dettagli" } },
},
import_users: {
error: {
at_entry: "Alla voce %{entry}: %{message}",
error: "Errore",
required_field: "Il campo '%{field}' non è presente",
invalid_value: "Valore non valido alla riga %{row}. '%{field}' Il campo può essere solo 'true' o 'false'",
unreasonably_big: "Impossibile caricare un file così grosso (%{size} megabyte)",
already_in_progress: "Un import è attualmente già in caricamento",
id_exits: "L'ID %{id} è già presente",
},
title: "Importa utenti tramite file CSV",
goToPdf: "Vai al PDF",
cards: {
importstats: {
header: "Importa utenti",
users_total: "%{smart_count} utente nel file CSV |||| %{smart_count} utenti nel file CSV",
guest_count: "%{smart_count} ospite |||| %{smart_count} ospiti",
admin_count: "%{smart_count} amministratore |||| %{smart_count} amministratori",
},
conflicts: {
header: "Strategia di conflitto",
mode: {
stop: "Stoppa al conflitto",
skip: "Mostra l'errore e ignora il conflitto",
},
},
ids: {
header: "ID",
all_ids_present: "ID presenti in ogni voce",
count_ids_present: "%{smart_count} voce con ID |||| %{smart_count} voci con ID",
mode: {
ignore: "Ignora gli ID nel file CSV e creane di nuovi",
update: "Aggiorna le voci esistenti",
},
},
passwords: {
header: "Passwords",
all_passwords_present: "Password presenti in ogni voce",
count_passwords_present: "%{smart_count} voce con password |||| %{smart_count} voci con password",
use_passwords: "Usa le password dal file CSV",
},
upload: {
header: "Input file CSV",
explanation:
"Qui puoi caricare un file con valori separati da virgole che verrà poi utilizzato per creare o aggiornare gli utenti. Il file deve includere i campi 'id' and 'displayname'. Puoi scaricare un file di esempio per adattarlo: ",
},
startImport: {
simulate_only: "Solo simulazione",
run_import: "Importa",
},
results: {
header: "Importa i risultati",
total: "%{smart_count} voce in totale |||| %{smart_count} voci in totale",
successful: "%{smart_count} voci importate con successo",
skipped: "%{smart_count} voci ignorate",
download_skipped: "Scarica le voci ignorate",
with_error: "%{smart_count} voce con errori ||| %{smart_count} voci con errori",
simulated_only: "Il processo era stato solamente simulato",
},
},
},
delete_media: {
name: "Media",
fields: {
before_ts: "ultimo accesso effettuato prima",
size_gt: "Più grande di (in byte)",
keep_profiles: "Mantieni le immagini del profilo",
},
action: {
send: "Cancella media",
send_success: "Richiesta inviata con successo.",
send_failure: "C'è stato un errore.",
},
helper: {
send: "Questa API cancella i media locali dal disco del tuo server. Questo include anche ogni miniatura e copia del media scaricato. Questa API non inciderà sui media che sono stati caricati nei repository esterni.",
},
},
resources: {
users: {
name: "Utente |||| Utenti",
email: "Email",
msisdn: "Telefono",
threepid: "Email / Telefono",
fields: {
avatar: "Avatar",
id: "ID utente",
name: "Nome",
is_guest: "Ospite",
admin: "Amministratore",
locked: "Bloccato",
deactivated: "Disattivato",
guests: "Mostra gli ospiti",
show_deactivated: "Mostra gli utenti disattivati",
user_id: "Cerca utente",
displayname: "Nickname",
password: "Password",
avatar_url: "URL dell'avatar",
avatar_src: "Avatar",
medium: "Medium",
threepids: "3PID",
address: "Indirizzo",
creation_ts_ms: "Creazione del timestamp",
consent_version: "Versione minima richiesta",
auth_provider: "Provider",
user_type: "Tipo d'utente",
},
helper: {
password: "Cambiando la password l'utente verrà disconnesso da tutte le sessioni attive.",
deactivate: "Devi fornire una password per riattivare l'account.",
erase: "Constrassegna l'utente come cancellato dal GDPR",
},
action: {
erase: "Cancella i dati dell'utente",
},
},
rooms: {
name: "Stanza |||| Stanze",
fields: {
room_id: "ID della stanza",
name: "Nome",
canonical_alias: "Alias",
joined_members: "Membri",
joined_local_members: "Membri locali",
joined_local_devices: "Dispositivi locali",
state_events: "Eventi di stato / Complessità",
version: "Versione",
is_encrypted: "Criptato",
encryption: "Crittografia",
federatable: "Federabile",
public: "Visibile nella cartella della stanza",
creator: "Creatore",
join_rules: "Regole per entrare",
guest_access: "Entra come ospite",
history_visibility: "Visibilità temporale",
topic: "Topic",
avatar: "Avatar",
},
helper: {
/* forward_extremities:
"Forward extremities are the leaf events at the end of a Directed acyclic graph (DAG) in a room, aka events that have no children. The more exist in a room, the more state resolution that Synapse needs to perform (hint: it's an expensive operation). While Synapse has code to prevent too many of these existing at one time in a room, bugs can sometimes make them crop up again. If a room has >10 forward extremities, it's worth checking which room is the culprit and potentially removing them using the SQL queries mentioned in #1760.", */
},
enums: {
join_rules: {
public: "Pubblica",
knock: "Bussa",
invite: "Invita",
private: "Privata",
},
guest_access: {
can_join: "Gli utenti ospiti possono entrare",
forbidden: "Gli utenti ospiti non possono entrare",
},
history_visibility: {
invited: "Dall'invito",
joined: "Dall'entrata",
shared: "Dalla condivisione",
world_readable: "Chiunque",
},
unencrypted: "Non criptata",
},
action: {
erase: {
title: "Cancella stanza",
content:
"Sei sicuro di voler eliminare questa stanza? Questa azione è definitiva. Tutti i messaggi e i media condivisi in questa stanza verranno eliminati dal server!",
},
},
},
reports: {
name: "Evento segnalato |||| Eventi segnalati",
fields: {
id: "ID",
received_ts: "Orario del report",
user_id: "richiedente",
name: "nome della stanza",
score: "punteggio",
reason: "ragione",
event_id: "ID dell'evento",
event_json: {
origin: "server di origine",
origin_server_ts: "ora dell'invio",
type: "tipo di evento",
content: {
msgtype: "tipo di contenuto",
body: "contenuto",
format: "formato",
formatted_body: "contenuto formattato",
algorithm: "algoritmo",
},
},
},
},
connections: {
name: "Connessioni",
fields: {
last_seen: "Data",
ip: "Indirizzo IP",
user_agent: "agente utente",
},
},
devices: {
name: "Dispositivo |||| Dispositivi",
fields: {
device_id: "ID del dispositivo",
display_name: "Nome del dispositivo",
last_seen_ts: "Timestamp",
last_seen_ip: "Indirizzo IP",
},
action: {
erase: {
title: "Rimozione del dispositivo %{id}",
content: 'Sei sicuro di voler rimuovere il dispositivo "%{name}"?',
success: "Dispositivo rimosso con successo.",
failure: "C'è stato un errore.",
},
},
},
users_media: {
name: "Media",
fields: {
media_id: "ID del media",
media_length: "Peso del file (in Byte)",
media_type: "Tipo",
upload_name: "Nome del file",
quarantined_by: "In quarantena da",
safe_from_quarantine: "Protetto dalla quarantena",
created_ts: "Creato",
last_access_ts: "Ultimo accesso",
},
},
protect_media: {
action: {
create: "Non protetto, proteggi",
delete: "Protetto, rimuovi protezione",
none: "In quarantena",
send_success: "Stato della protezione cambiato con successo.",
send_failure: "C'è stato un errore.",
},
},
quarantine_media: {
action: {
name: "Quarantina",
create: "Aggiungi alla quarantena",
delete: "In quarantena, rimuovi dalla quarantena",
none: "Protetto dalla quarantena",
send_success: "Stato della quarantena cambiato con successo.",
send_failure: "C'è stato un errore.",
},
},
pushers: {
name: "Pusher |||| Pusher",
fields: {
app: "App",
app_display_name: "Nome dell'app",
app_id: "ID dell'app",
device_display_name: "Nome del dispositivo",
kind: "Tipo",
lang: "Lingua",
profile_tag: "Tag del profilo",
pushkey: "Pushkey",
data: { url: "URL" },
},
},
servernotices: {
name: "Avvisi del server",
send: "Invia avvisi",
fields: {
body: "Messaggio",
},
action: {
send: "Invia nota",
send_success: "Avviso inviato con successo.",
send_failure: "C'è stato un errore.",
},
helper: {
send: 'Invia un avviso dal server agli utenti selezionati. La feature "Avvisi del server" è stata attivata sul server.',
},
},
user_media_statistics: {
name: "Media degli utenti",
fields: {
media_count: "Numero media",
media_length: "Lunghezza media",
},
},
forward_extremities: {
name: "Invia estremità",
fields: {
id: "Event ID",
received_ts: "Timestamp",
depth: "Profondità",
state_group: "State group",
},
},
room_state: {
name: "Eventi di stato",
fields: {
type: "Tipo",
content: "Contenuto",
origin_server_ts: "Ora dell'invio",
sender: "Mittente",
},
},
room_directory: {
name: "Elenco delle stanze",
fields: {
world_readable: "gli utenti ospite possono vedere senza entrare",
guest_can_join: "gli utenti ospite possono entrare",
},
action: {
title: "Cancella stanza dall'elenco |||| Cancella %{smart_count} stanze dall'elenco",
content:
"Sei sicuro di voler rimuovere questa stanza dall'elenco? |||| Sei sicuro di voler rimuovere %{smart_count} stanze dall'elenco?",
erase: "Rimuovi dall'elenco",
create: "Crea",
send_success: "Stanza creata con successo.",
send_failure: "C'è stato un errore.",
},
},
destinations: {
name: "Federazione",
fields: {
destination: "Destinazione",
failure_ts: "Timestamp dell'errore",
retry_last_ts: "Tentativo ultimo timestamp",
retry_interval: "Intervallo dei tentativi",
last_successful_stream_ordering: "Ultimo flusso riuscito con successo",
stream_ordering: "Flusso",
},
action: { reconnect: "Riconnetti" },
},
registration_tokens: {
name: "Token di registrazione",
fields: {
token: "Token",
valid: "Token valido",
uses_allowed: "Usi permessi",
pending: "In attesa",
completed: "Completato",
expiry_time: "Data della scadenza",
length: "Lunghezza",
},
helper: { length: "Lunghezza del token se non viene dato alcun token." },
},
},
};
export default it;

414
src/i18n/ru.ts Normal file
View File

@ -0,0 +1,414 @@
import russianMessages from "ra-language-russian";
import { SynapseTranslationMessages } from ".";
const ru: SynapseTranslationMessages = {
...russianMessages,
ra: {
...russianMessages.ra,
navigation: {
...russianMessages.ra.navigation,
no_filtered_results: "Нет результатов",
clear_filters: "Все фильтры сбросить",
},
},
synapseadmin: {
auth: {
base_url: "Адрес домашнего сервера",
welcome: "Добро пожаловать в Synapse-admin",
server_version: "Версия Synapse",
supports_specs: "поддерживает спецификации Matrix",
username_error: "Пожалуйста, укажите полный ID пользователя: '@user:domain'",
protocol_error: "Адрес должен начинаться с 'http://' или 'https://'",
url_error: "Неверный адрес сервера Matrix",
sso_sign_in: "Вход через SSO",
},
users: {
invalid_user_id: "Локальная часть ID пользователя Matrix без адреса домашнего сервера.",
tabs: { sso: "SSO" },
},
rooms: {
details: "Данные комнаты",
tabs: {
basic: "Основные",
members: "Участники",
detail: "Подробности",
permission: "Права доступа",
},
},
reports: { tabs: { basic: "Основные", detail: "Подробности" } },
},
import_users: {
error: {
at_entry: "В записи %{entry}: %{message}",
error: "Ошибка",
required_field: "Отсутствует обязательное поле '%{field}'",
invalid_value: "Неверное значение в строке %{row}. Поле '%{field}' может быть либо 'true', либо 'false'",
unreasonably_big: "Отказано в загрузке слишком большого файла размером %{size} мегабайт",
already_in_progress: "Импорт уже в процессе",
id_exits: "ID %{id} уже существует",
},
title: "Импорт пользователей из CSV",
goToPdf: "Перейти к PDF",
cards: {
importstats: {
header: "Импорт пользователей",
users_total:
"%{smart_count} пользователь в CSV файле |||| %{smart_count} пользователя в CSV файле |||| %{smart_count} пользователей в CSV файле",
guest_count: "%{smart_count} гость |||| %{smart_count} гостя |||| %{smart_count} гостей",
admin_count:
"%{smart_count} администратор |||| %{smart_count} администратора |||| %{smart_count} администраторов",
},
conflicts: {
header: "Стратегия разрешения конфликтов",
mode: {
stop: "Остановка при конфликте",
skip: "Показать ошибку и пропустить при конфликте",
},
},
ids: {
header: "Идентификаторы",
all_ids_present: "Идентификаторы присутствуют в каждой записи",
count_ids_present:
"%{smart_count} запись с ID |||| %{smart_count} записи с ID |||| %{smart_count} записей с ID",
mode: {
ignore: "Игнорировать идентификаторы в CSV и создать новые",
update: "Обновить существующие записи",
},
},
passwords: {
header: "Пароли",
all_passwords_present: "Пароли присутствуют в каждой записи",
count_passwords_present:
"%{smart_count} запись с паролем |||| %{smart_count} записи с паролями |||| %{smart_count} записей с паролями",
use_passwords: "Использовать пароли из CSV",
},
upload: {
header: "Загрузить CSV файл",
explanation:
"Здесь вы можете загрузить файл со значениями, разделёнными запятыми, которые будут использованы для создания или обновления данных пользователей. \
В файле должны быть поля 'id' и 'displayname'. Вы можете скачать и изменить файл-образец отсюда: ",
},
startImport: {
simulate_only: "Только симулировать",
run_import: "Импорт",
},
results: {
header: "Результаты импорта",
total: "%{smart_count} запись всего |||| %{smart_count} записи всего |||| %{smart_count} записей всего",
successful:
"%{smart_count} запись успешно импортирована |||| %{smart_count} записи успешно импортированы |||| %{smart_count} записей успешно импортированы",
skipped:
"%{smart_count} запись пропущена |||| %{smart_count} записи пропущены |||| %{smart_count} записей пропущено",
download_skipped: "Скачать пропущенные записи",
with_error:
"%{smart_count} запись с ошибкой |||| %{smart_count} записи с ошибками |||| %{smart_count} записей с ошибками",
simulated_only: "Импорт был симулирован",
},
},
},
delete_media: {
name: "Файлы",
fields: {
before_ts: "Последнее обращение до",
size_gt: "Более чем (в байтах)",
keep_profiles: "Сохранить аватары",
},
action: {
send: "Удалить файлы",
send_success: "Запрос успешно отправлен.",
send_failure: "Произошла ошибка.",
},
helper: {
send: "Это API удаляет локальные файлы с вашего собственного сервера, включая локальные миниатюры и копии скачанных файлов. \
Данный API не затрагивает файлы, загруженные во внешние хранилища.",
},
},
resources: {
users: {
name: "Пользователь |||| Пользователи",
email: "Почта",
msisdn: "Телефон",
threepid: "Почта / Телефон",
fields: {
avatar: "Аватар",
id: "ID пользователя",
name: "Имя",
is_guest: "Гость",
admin: "Администратор сервера",
locked: "Заблокирован",
deactivated: "Деактивирован",
erased: "Удалён",
guests: "Показывать гостей",
show_deactivated: "Показывать деактивированных",
user_id: "Поиск пользователя",
displayname: "Отображаемое имя",
password: "Пароль",
avatar_url: "Адрес аватары",
avatar_src: "Аватар",
medium: "Тип",
threepids: "3PID'ы",
address: "Адрес",
creation_ts_ms: "Дата создания",
consent_version: "Версия соглашения",
auth_provider: "Провайдер",
user_type: "Тип пользователя",
},
helper: {
password: "Смена пароля завершит все сессии пользователя.",
deactivate: "Вы должны предоставить пароль для реактивации учётной записи.",
erase: "Пометить пользователя как удалённого в соответствии с GDPR",
},
action: {
erase: "Удалить данные пользователя",
},
},
rooms: {
name: "Комната |||| Комнаты",
fields: {
room_id: "ID комнаты",
name: "Название",
canonical_alias: "Псевдоним",
joined_members: "Участники",
joined_local_members: "Локальные участники",
joined_local_devices: "Локальные устройства",
state_events: "События состояния / Сложность",
version: "Версия",
is_encrypted: "Зашифровано",
encryption: "Шифрование",
federatable: "Федерация",
public: "Отображается в каталоге комнат",
creator: "Создатель",
join_rules: "Правила входа",
guest_access: "Гостевой доступ",
history_visibility: "Видимость истории",
topic: "Тема",
avatar: "Аватар",
},
helper: {
forward_extremities:
"Оконечности это события-листья в конце ориентированного ациклического графа (DAG) в комнате, т.е. события без дочерних элементов. \
Чем больше их в комнате, тем больше Synapse работает над разрешением состояния (это дорогостоящая операция). \
Хотя Synapse старается не допускать существования слишком большого числа таких событий в комнате, из-за ошибок они иногда снова появляются. \
Если в комнате >10 оконечностей, стоит найти комнату-виновника и попробовать удалить их с помощью SQL-запросов из #1760.",
},
enums: {
join_rules: {
public: "Для всех",
knock: "Надо постучать",
invite: "По приглашению",
private: "Приватная",
},
guest_access: {
can_join: "Гости могут войти",
forbidden: "Гости не могут войти",
},
history_visibility: {
invited: "С момента приглашения",
joined: "С момента входа",
shared: "С момента открытия доступа",
world_readable: "Для всех",
},
unencrypted: "Без шифрования",
},
action: {
erase: {
title: "Удалить комнату",
content:
"Действительно удалить эту комнату? Это действие будет невозможно отменить. Все сообщения и файлы в комнате будут удалены с сервера!",
},
},
},
reports: {
name: "Жалоба |||| Жалобы",
fields: {
id: "ID",
received_ts: "Дата и время жалобы",
user_id: "Автор жалобы",
name: "Название комнаты",
score: "Баллы",
reason: "Причина",
event_id: "ID события",
event_json: {
origin: "Исходнный сервер",
origin_server_ts: "Дата и время отправки",
type: "Тип события",
content: {
msgtype: "Тип содержимого",
body: "Содержимое",
format: "Формат",
formatted_body: "Форматированное содержимое",
algorithm: "Алгоритм",
url: "Ссылка",
info: {
mimetype: "Тип",
},
},
},
},
action: {
erase: {
title: "Удалить жалобу",
content: "Действительно удалить жалобу? Это действие будет невозможно отменить.",
},
},
},
connections: {
name: "Подключения",
fields: {
last_seen: "Дата",
ip: "IP адрес",
user_agent: "Юзер-агент",
},
},
devices: {
name: "Устройство |||| Устройства",
fields: {
device_id: "ID устройства",
display_name: "Название",
last_seen_ts: "Дата и время",
last_seen_ip: "IP адрес",
},
action: {
erase: {
title: "Удаление %{id}",
content: 'Действительно удалить устройство "%{name}"?',
success: "Устройство успешно удалено.",
failure: "Произошла ошибка.",
},
},
},
users_media: {
name: "Файлы",
fields: {
media_id: "ID файла",
media_length: "Размер файла (в байтах)",
media_type: "Тип",
upload_name: "Имя файла",
quarantined_by: "На карантине",
safe_from_quarantine: "Защитить от карантина",
created_ts: "Создано",
last_access_ts: "Последний доступ",
},
action: {
open: "Открыть файл в новом окне",
},
},
protect_media: {
action: {
create: "Не защищён, установить защиту",
delete: "Защищён, снять защиту",
none: "На карантине",
send_success: "Статус защиты успешно изменён.",
send_failure: "Произошла ошибка.",
},
},
quarantine_media: {
action: {
name: "Карантин",
create: "Поместить на карантин",
delete: "На карантине, снять карантин",
none: "Защищено от карантина",
send_success: "Статус карантина успешно изменён.",
send_failure: "Произошла ошибка.",
},
},
pushers: {
name: "Пушер |||| Пушеры",
fields: {
app: "Приложение",
app_display_name: "Название приложения",
app_id: "ID приложения",
device_display_name: "Название устройства",
kind: "Вид",
lang: "Язык",
profile_tag: "Тег профиля",
pushkey: "Ключ",
data: { url: "URL" },
},
},
servernotices: {
name: "Серверные уведомления",
send: "Отправить серверные уведомления",
fields: {
body: "Сообщение",
},
action: {
send: "Отправить",
send_success: "Серверное уведомление успешно отправлено.",
send_failure: "Произошла ошибка.",
},
helper: {
send: 'Отправить серверное уведомление выбранным пользователям. На сервере должна быть активна функция "Server Notices".',
},
},
user_media_statistics: {
name: "Файлы пользователей",
fields: {
media_count: "Количество файлов",
media_length: "Размер файлов",
},
},
forward_extremities: {
name: "Оконечности",
fields: {
id: "ID события",
received_ts: "Дата и время",
depth: "Глубина",
state_group: "Группа состояния",
},
},
room_state: {
name: "События состояния",
fields: {
type: "Тип",
content: "Содержимое",
origin_server_ts: "Дата отправки",
sender: "Отправитель",
},
},
room_directory: {
name: "Каталог комнат",
fields: {
world_readable: "Гости могут просматривать без входа",
guest_can_join: "Гости могут войти",
},
action: {
title:
"Удалить комнату из каталога |||| Удалить %{smart_count} комнаты из каталога |||| Удалить %{smart_count} комнат из каталога",
content:
"Действительно удалить комнату из каталога? |||| Действительно удалить %{smart_count} комнаты из каталога? |||| Действительно удалить %{smart_count} комнат из каталога?",
erase: "Удалить из каталога комнат",
create: "Опубликовать в каталоге комнат",
send_success: "Комната успешно опубликована.",
send_failure: "Произошла ошибка.",
},
},
destinations: {
name: "Федерация",
fields: {
destination: "Назначение",
failure_ts: "Дата и время ошибки",
retry_last_ts: "Дата и время последней попытки",
retry_interval: "Интервал между попытками",
last_successful_stream_ordering: "Последний успешный поток",
stream_ordering: "Поток",
},
action: { reconnect: "Переподключиться" },
},
registration_tokens: {
name: "Токены регистрации",
fields: {
token: "Токен",
valid: "Рабочий токен",
uses_allowed: "Количество использований",
pending: "Ожидает",
completed: "Завершено",
expiry_time: "Дата окончания",
length: "Длина",
},
helper: { length: "Длина токена, если токен не задан." },
},
},
};
export default ru;

View File

@ -1,7 +1,17 @@
import chineseMessages from "ra-language-chinese";
import chineseMessages from "@haxqer/ra-language-chinese";
const zh = {
import { SynapseTranslationMessages } from ".";
const zh: SynapseTranslationMessages = {
...chineseMessages,
ra: {
...chineseMessages.ra,
navigation: {
...chineseMessages.ra.navigation,
no_filtered_results: "没有结果",
clear_filters: "清除所有过滤器",
},
},
synapseadmin: {
auth: {
base_url: "服务器 URL",
@ -13,8 +23,7 @@ const zh = {
sso_sign_in: "使用 SSO 登录",
},
users: {
invalid_user_id:
"必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver",
invalid_user_id: "必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver",
tabs: { sso: "SSO" },
},
rooms: {
@ -24,11 +33,6 @@ const zh = {
detail: "细节",
permission: "权限",
},
delete: {
title: "删除房间",
message:
"您确定要删除这个房间吗?该操作无法被撤销。这个房间里所有的消息和分享的媒体都将被从服务器上删除!",
},
},
reports: { tabs: { basic: "基本", detail: "细节" } },
},
@ -37,8 +41,7 @@ const zh = {
at_entry: "在条目 %{entry}: %{message}",
error: "错误",
required_field: "需要的值 '%{field}' 未被设置。",
invalid_value:
"第 %{row} 行出现无效值。 '%{field}' 只可以是 'true' 或 'false'。",
invalid_value: "第 %{row} 行出现无效值。 '%{field}' 只可以是 'true' 或 'false'。",
unreasonably_big: "拒绝加载过大的文件: %{size} MB",
already_in_progress: "一个导入进程已经在运行中",
id_exits: "ID %{id} 已经存在",
@ -48,8 +51,7 @@ const zh = {
cards: {
importstats: {
header: "导入用户",
users_total:
"%{smart_count} 用户在 CSV 文件中 |||| %{smart_count} 用户在 CSV 文件中",
users_total: "%{smart_count} 用户在 CSV 文件中 |||| %{smart_count} 用户在 CSV 文件中",
guest_count: "%{smart_count} 访客 |||| %{smart_count} 访客",
admin_count: "%{smart_count} 管理员 |||| %{smart_count} 管理员",
},
@ -63,8 +65,7 @@ const zh = {
ids: {
header: "IDs",
all_ids_present: "每条记录的 ID",
count_ids_present:
"%{smart_count} 个含 ID 的记录 |||| %{smart_count} 个含 ID 的记录",
count_ids_present: "%{smart_count} 个含 ID 的记录 |||| %{smart_count} 个含 ID 的记录",
mode: {
ignore: "忽略 CSV 中的 ID 并创建新的",
update: "更新已经存在的记录",
@ -73,8 +74,7 @@ const zh = {
passwords: {
header: "密码",
all_passwords_present: "每条记录的密码",
count_passwords_present:
"%{smart_count} 个含密码的记录 |||| %{smart_count} 个含密码的记录",
count_passwords_present: "%{smart_count} 个含密码的记录 |||| %{smart_count} 个含密码的记录",
use_passwords: "使用 CSV 中标记的密码",
},
upload: {
@ -92,12 +92,27 @@ const zh = {
successful: "%{smart_count} 条记录导入成功",
skipped: "跳过 %{smart_count} 条记录",
download_skipped: "下载跳过的记录",
with_error:
"%{smart_count} 条记录出现错误 ||| %{smart_count} 条记录出现错误",
with_error: "%{smart_count} 条记录出现错误 ||| %{smart_count} 条记录出现错误",
simulated_only: "只是一次模拟运行",
},
},
},
delete_media: {
name: "媒体文件",
fields: {
before_ts: "最后访问时间",
size_gt: "大于 (字节)",
keep_profiles: "保留头像",
},
action: {
send: "删除媒体",
send_success: "请求发送成功。",
send_failure: "出现了一个错误。",
},
helper: {
send: "这个API会删除您硬盘上的本地媒体。包含了任何的本地缓存和下载的媒体备份。这个API不会影响上传到外部媒体存储库上的媒体文件。",
},
},
resources: {
users: {
name: "用户",
@ -233,22 +248,6 @@ const zh = {
last_access_ts: "上一次访问",
},
},
delete_media: {
name: "媒体文件",
fields: {
before_ts: "最后访问时间",
size_gt: "大于 (字节)",
keep_profiles: "保留头像",
},
action: {
send: "删除媒体",
send_success: "请求发送成功。",
send_failure: "出现了一个错误。",
},
helper: {
send: "这个API会删除您硬盘上的本地媒体。包含了任何的本地缓存和下载的媒体备份。这个API不会影响上传到外部媒体存储库上的媒体文件。",
},
},
pushers: {
name: "发布者",
fields: {

View File

@ -1,5 +0,0 @@
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
ReactDOM.render(<App />, document.getElementById("root"));

24
src/index.tsx Normal file
View File

@ -0,0 +1,24 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { AppContext } from "./AppContext";
const baseUrl = import.meta.env.BASE_URL;
const configJSON = "config.json";
// if import.meta.env.BASE_URL have a trailing slash, remove it
// load config.json from relative path if import.meta.env.BASE_URL is None or empty
const configJSONUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/${configJSON}` : configJSON;
fetch(configJSONUrl)
.then(res => res.json())
.then(props =>
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<AppContext.Provider value={props}>
<App />
</AppContext.Provider>
</React.StrictMode>
)
);

1
src/jest.setup.ts Normal file
View File

@ -0,0 +1 @@
import "@testing-library/jest-dom";

View File

@ -0,0 +1,73 @@
import polyglotI18nProvider from "ra-i18n-polyglot";
import { render, screen } from "@testing-library/react";
import { AdminContext } from "react-admin";
import LoginPage from "./LoginPage";
import { AppContext } from "../AppContext";
import englishMessages from "../i18n/en";
const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]);
describe("LoginForm", () => {
it("renders with no restriction to homeserver", () => {
render(
<AdminContext i18nProvider={i18nProvider}>
<LoginPage />
</AdminContext>
);
screen.getByText(englishMessages.synapseadmin.auth.welcome);
screen.getByRole("combobox", { name: "" });
screen.getByRole("textbox", { name: englishMessages.ra.auth.username });
screen.getByText(englishMessages.ra.auth.password);
const baseUrlInput = screen.getByRole("textbox", {
name: englishMessages.synapseadmin.auth.base_url,
});
expect(baseUrlInput.className.split(" ")).not.toContain("Mui-readOnly");
screen.getByRole("button", { name: englishMessages.ra.auth.sign_in });
});
it("renders with single restricted homeserver", () => {
render(
<AppContext.Provider value={{ restrictBaseUrl: "https://matrix.example.com" }}>
<AdminContext i18nProvider={i18nProvider}>
<LoginPage />
</AdminContext>
</AppContext.Provider>
);
screen.getByText(englishMessages.synapseadmin.auth.welcome);
screen.getByRole("combobox", { name: "" });
screen.getByRole("textbox", { name: englishMessages.ra.auth.username });
screen.getByText(englishMessages.ra.auth.password);
const baseUrlInput = screen.getByRole("textbox", {
name: englishMessages.synapseadmin.auth.base_url,
});
expect(baseUrlInput.className.split(" ")).toContain("Mui-readOnly");
screen.getByRole("button", { name: englishMessages.ra.auth.sign_in });
});
it("renders with multiple restricted homeservers", async () => {
render(
<AppContext.Provider
value={{
restrictBaseUrl: ["https://matrix.example.com", "https://matrix.example.org"],
}}
>
<AdminContext i18nProvider={i18nProvider}>
<LoginPage />
</AdminContext>
</AppContext.Provider>
);
screen.getByText(englishMessages.synapseadmin.auth.welcome);
screen.getByRole("combobox", { name: "" });
screen.getByRole("textbox", { name: englishMessages.ra.auth.username });
screen.getByText(englishMessages.ra.auth.password);
screen.getByRole("combobox", {
name: englishMessages.synapseadmin.auth.base_url,
});
screen.getByRole("button", { name: englishMessages.ra.auth.sign_in });
});
});

316
src/pages/LoginPage.tsx Normal file
View File

@ -0,0 +1,316 @@
import { useState, useEffect } from "react";
import LockIcon from "@mui/icons-material/Lock";
import { Avatar, Box, Button, Card, CardActions, CircularProgress, MenuItem, Select, Typography } from "@mui/material";
import { styled } from "@mui/material/styles";
import {
Form,
FormDataConsumer,
Notification,
required,
useLogin,
useNotify,
useLocaleState,
useTranslate,
PasswordInput,
TextInput,
useLocales,
} from "react-admin";
import { useFormContext } from "react-hook-form";
import { useAppContext } from "../AppContext";
import {
getServerVersion,
getSupportedFeatures,
getSupportedLoginFlows,
getWellKnownUrl,
isValidBaseUrl,
splitMxid,
} from "../synapse/synapse";
import storage from "../storage";
const FormBox = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
minHeight: "calc(100vh - 1rem)",
alignItems: "center",
justifyContent: "flex-start",
background: "url(./images/floating-cogs.svg)",
backgroundColor: "#f9f9f9",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
[`& .card`]: {
width: "30rem",
marginTop: "6rem",
marginBottom: "6rem",
},
[`& .avatar`]: {
margin: "1rem",
display: "flex",
justifyContent: "center",
},
[`& .icon`]: {
backgroundColor: theme.palette.grey[500],
},
[`& .hint`]: {
marginTop: "1em",
marginBottom: "1em",
display: "flex",
justifyContent: "center",
color: theme.palette.grey[600],
},
[`& .form`]: {
padding: "0 1rem 1rem 1rem",
},
[`& .select`]: {
marginBottom: "2rem",
},
[`& .actions`]: {
padding: "0 1rem 1rem 1rem",
},
[`& .serverVersion`]: {
color: theme.palette.grey[500],
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
marginLeft: "0.5rem",
},
[`& .matrixVersions`]: {
color: theme.palette.grey[500],
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
fontSize: "0.8rem",
marginBottom: "1rem",
marginLeft: "0.5rem",
},
}));
const LoginPage = () => {
const login = useLogin();
const notify = useNotify();
const { restrictBaseUrl } = useAppContext();
const allowSingleBaseUrl = typeof restrictBaseUrl === "string";
const allowMultipleBaseUrls = Array.isArray(restrictBaseUrl);
const allowAnyBaseUrl = !(allowSingleBaseUrl || allowMultipleBaseUrls);
const [loading, setLoading] = useState(false);
const [supportPassAuth, setSupportPassAuth] = useState(true);
const [locale, setLocale] = useLocaleState();
const locales = useLocales();
const translate = useTranslate();
const base_url = allowSingleBaseUrl ? restrictBaseUrl : storage.getItem("base_url");
const [ssoBaseUrl, setSSOBaseUrl] = useState("");
const loginToken = /\?loginToken=([a-zA-Z0-9_-]+)/.exec(window.location.href);
if (loginToken) {
const ssoToken = loginToken[1];
console.log("SSO token is", ssoToken);
// Prevent further requests
window.history.replaceState({}, "", window.location.href.replace(loginToken[0], "#").split("#")[0]);
const baseUrl = storage.getItem("sso_base_url");
storage.removeItem("sso_base_url");
if (baseUrl) {
const auth = {
base_url: baseUrl,
username: null,
password: null,
loginToken: ssoToken,
};
console.log("Base URL is:", baseUrl);
console.log("SSO Token is:", ssoToken);
console.log("Let's try token login...");
login(auth).catch(error => {
alert(
typeof error === "string"
? error
: typeof error === "undefined" || !error.message
? "ra.auth.sign_in_error"
: error.message
);
console.error(error);
});
}
}
const validateBaseUrl = value => {
if (!value.match(/^(http|https):\/\//)) {
return translate("synapseadmin.auth.protocol_error");
} else if (!value.match(/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?[^?&\s]*$/)) {
return translate("synapseadmin.auth.url_error");
} else {
return undefined;
}
};
const handleSubmit = auth => {
setLoading(true);
login(auth).catch(error => {
setLoading(false);
notify(
typeof error === "string"
? error
: typeof error === "undefined" || !error.message
? "ra.auth.sign_in_error"
: error.message,
{ type: "warning" }
);
});
};
const handleSSO = () => {
storage.setItem("sso_base_url", ssoBaseUrl);
const ssoFullUrl = `${ssoBaseUrl}/_matrix/client/r0/login/sso/redirect?redirectUrl=${encodeURIComponent(
window.location.href
)}`;
window.location.href = ssoFullUrl;
};
const UserData = ({ formData }) => {
const form = useFormContext();
const [serverVersion, setServerVersion] = useState("");
const [matrixVersions, setMatrixVersions] = useState("");
const handleUsernameChange = () => {
if (formData.base_url || allowSingleBaseUrl) return;
// check if username is a full qualified userId then set base_url accordingly
const domain = splitMxid(formData.username)?.domain;
if (domain) {
getWellKnownUrl(domain).then(url => {
if (allowAnyBaseUrl || (allowMultipleBaseUrls && restrictBaseUrl.includes(url)))
form.setValue("base_url", url);
});
}
};
useEffect(() => {
if (formData.base_url === "" && allowMultipleBaseUrls) {
form.setValue("base_url", restrictBaseUrl[0]);
}
if (!isValidBaseUrl(formData.base_url)) return;
getServerVersion(formData.base_url)
.then(serverVersion => setServerVersion(`${translate("synapseadmin.auth.server_version")} ${serverVersion}`))
.catch(() => setServerVersion(""));
getSupportedFeatures(formData.base_url)
.then(features =>
setMatrixVersions(`${translate("synapseadmin.auth.supports_specs")} ${features.versions.join(", ")}`)
)
.catch(() => setMatrixVersions(""));
// Set SSO Url
getSupportedLoginFlows(formData.base_url)
.then(loginFlows => {
const supportPass = loginFlows.find(f => f.type === "m.login.password") !== undefined;
const supportSSO = loginFlows.find(f => f.type === "m.login.sso") !== undefined;
setSupportPassAuth(supportPass);
setSSOBaseUrl(supportSSO ? formData.base_url : "");
})
.catch(() => setSSOBaseUrl(""));
}, [formData.base_url, form]);
return (
<>
<Box>
<TextInput
autoFocus
source="username"
label="ra.auth.username"
autoComplete="username"
disabled={loading || !supportPassAuth}
onBlur={handleUsernameChange}
resettable
validate={required()}
/>
</Box>
<Box>
<PasswordInput
source="password"
label="ra.auth.password"
type="password"
autoComplete="current-password"
disabled={loading || !supportPassAuth}
resettable
validate={required()}
/>
</Box>
<Box>
<TextInput
source="base_url"
label="synapseadmin.auth.base_url"
select={allowMultipleBaseUrls}
autoComplete="url"
disabled={loading}
readOnly={allowSingleBaseUrl}
resettable={allowAnyBaseUrl}
validate={[required(), validateBaseUrl]}
>
{allowMultipleBaseUrls &&
restrictBaseUrl.map(url => (
<MenuItem key={url} value={url}>
{url}
</MenuItem>
))}
</TextInput>
</Box>
<Typography className="serverVersion">{serverVersion}</Typography>
<Typography className="matrixVersions">{matrixVersions}</Typography>
</>
);
};
return (
<Form defaultValues={{ base_url: base_url }} onSubmit={handleSubmit} mode="onTouched">
<FormBox>
<Card className="card">
<Box className="avatar">
{loading ? (
<CircularProgress size={25} thickness={2} />
) : (
<Avatar className="icon">
<LockIcon />
</Avatar>
)}
</Box>
<Box className="hint">{translate("synapseadmin.auth.welcome")}</Box>
<Box className="form">
<Select
value={locale}
onChange={e => setLocale(e.target.value)}
fullWidth
disabled={loading}
className="select"
>
{locales.map(l => (
<MenuItem key={l.locale} value={l.locale}>
{l.name}
</MenuItem>
))}
</Select>
<FormDataConsumer>{formDataProps => <UserData {...formDataProps} />}</FormDataConsumer>
<CardActions className="actions">
<Button
variant="contained"
type="submit"
color="primary"
disabled={loading || !supportPassAuth}
fullWidth
>
{translate("ra.auth.sign_in")}
</Button>
<Button
variant="contained"
color="secondary"
onClick={handleSSO}
disabled={loading || ssoBaseUrl === ""}
fullWidth
>
{translate("synapseadmin.auth.sso_sign_in")}
</Button>
</CardActions>
</Box>
</Card>
</FormBox>
<Notification />
</Form>
);
};
export default LoginPage;

View File

@ -0,0 +1,181 @@
import { get } from "lodash";
import { MouseEvent } from "react";
import AutorenewIcon from "@mui/icons-material/Autorenew";
import DestinationsIcon from "@mui/icons-material/CloudQueue";
import FolderSharedIcon from "@mui/icons-material/FolderShared";
import ViewListIcon from "@mui/icons-material/ViewList";
import { blue } from "@mui/material/colors";
import {
Button,
Datagrid,
DateField,
List,
ListProps,
Pagination,
RaRecord,
ReferenceField,
ReferenceManyField,
ResourceProps,
SearchInput,
Show,
ShowProps,
Tab,
TabbedShowLayout,
TextField,
TopToolbar,
useRecordContext,
useDelete,
useNotify,
useRefresh,
useTranslate,
DateFieldProps,
} from "react-admin";
import { DATE_FORMAT } from "../components/date";
import { lighten, useTheme } from '@mui/material';
const DestinationPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
const destinationFilters = [<SearchInput source="destination" alwaysOn />];
export const DestinationReconnectButton = () => {
const record = useRecordContext();
const refresh = useRefresh();
const notify = useNotify();
const [handleReconnect, { isLoading }] = useDelete();
// Reconnect is not required if no error has occurred. (`failure_ts`)
if (!record || !record.failure_ts) return null;
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
// Prevents redirection to the detail page when clicking in the list
e.stopPropagation();
handleReconnect(
"destinations",
{ id: record.id },
{
onSuccess: () => {
notify("ra.notification.updated", {
messageArgs: { smart_count: 1 },
});
refresh();
},
onError: () => {
notify("ra.message.error", { type: "error" });
},
}
);
};
return (
<Button label="resources.destinations.action.reconnect" onClick={handleClick} disabled={isLoading}>
<AutorenewIcon />
</Button>
);
};
const DestinationShowActions = () => (
<TopToolbar>
<DestinationReconnectButton />
</TopToolbar>
);
const DestinationTitle = () => {
const record = useRecordContext();
const translate = useTranslate();
return (
<span>
{translate("resources.destinations.name", 1)} {record?.destination}
</span>
);
};
const RetryDateField = (props: DateFieldProps) => {
const record = useRecordContext(props);
if (props.source && get(record, props.source) === 0) {
return <DateField {...props} record={{ ...record, [props.source]: null }} />;
}
return <DateField {...props} />;
};
export const DestinationList = (props: ListProps) => {
const { palette: { error, mode }, } = useTheme();
const destinationRowSx = (record: RaRecord) => ({
backgroundColor: record.retry_last_ts > 0 ? lighten(error[mode], 0.5) : undefined,
"& > td": mode === 'dark' ? {
color: record.retry_last_ts > 0 ? "black" : "white",
"& > button": {
color: blue[700],
},
} : undefined,
});
return (
<List
{...props}
filters={destinationFilters}
pagination={<DestinationPagination />}
sort={{ field: "destination", order: "ASC" }}
>
<Datagrid rowSx={destinationRowSx} rowClick={id => `${id}/show/rooms`} bulkActionButtons={false}>
<TextField source="destination" />
<DateField source="failure_ts" showTime options={DATE_FORMAT} />
<RetryDateField source="retry_last_ts" showTime options={DATE_FORMAT} />
<TextField source="retry_interval" />
<TextField source="last_successful_stream_ordering" />
<DestinationReconnectButton />
</Datagrid>
</List>
);
};
export const DestinationShow = (props: ShowProps) => {
const translate = useTranslate();
return (
<Show actions={<DestinationShowActions />} title={<DestinationTitle />} {...props}>
<TabbedShowLayout>
<Tab label="status" icon={<ViewListIcon />}>
<TextField source="destination" />
<DateField source="failure_ts" showTime options={DATE_FORMAT} />
<DateField source="retry_last_ts" showTime options={DATE_FORMAT} />
<TextField source="retry_interval" />
<TextField source="last_successful_stream_ordering" />
</Tab>
<Tab label={translate("resources.rooms.name", { smart_count: 2 })} icon={<FolderSharedIcon />} path="rooms">
<ReferenceManyField
reference="destination_rooms"
target="destination"
label={false}
pagination={<DestinationPagination />}
perPage={50}
>
<Datagrid style={{ width: "100%" }} rowClick={id => `/rooms/${id}/show`}>
<TextField source="room_id" label="resources.rooms.fields.room_id" />
<TextField source="stream_ordering" sortable={false} />
<ReferenceField
label="resources.rooms.fields.name"
source="id"
reference="rooms"
sortable={false}
link=""
>
<TextField source="name" sortable={false} />
</ReferenceField>
</Datagrid>
</ReferenceManyField>
</Tab>
</TabbedShowLayout>
</Show>
);
};
const resource: ResourceProps = {
name: "destinations",
icon: DestinationsIcon,
list: DestinationList,
show: DestinationShow,
};
export default resource;

View File

@ -0,0 +1,95 @@
import RegistrationTokenIcon from "@mui/icons-material/ConfirmationNumber";
import {
BooleanInput,
Create,
CreateProps,
Datagrid,
DateField,
DateTimeInput,
Edit,
EditProps,
List,
ListProps,
maxValue,
number,
NumberField,
NumberInput,
regex,
ResourceProps,
SaveButton,
SimpleForm,
TextInput,
TextField,
Toolbar,
} from "react-admin";
import { DATE_FORMAT, dateFormatter, dateParser } from "../components/date";
const validateToken = [regex(/^[A-Za-z0-9._~-]{0,64}$/)];
const validateUsesAllowed = [number()];
const validateLength = [number(), maxValue(64)];
const registrationTokenFilters = [<BooleanInput source="valid" alwaysOn />];
export const RegistrationTokenList = (props: ListProps) => (
<List
{...props}
filters={registrationTokenFilters}
filterDefaultValues={{ valid: true }}
pagination={false}
perPage={500}
>
<Datagrid rowClick="edit">
<TextField source="token" sortable={false} />
<NumberField source="uses_allowed" sortable={false} />
<NumberField source="pending" sortable={false} />
<NumberField source="completed" sortable={false} />
<DateField source="expiry_time" showTime options={DATE_FORMAT} sortable={false} />
</Datagrid>
</List>
);
export const RegistrationTokenCreate = (props: CreateProps) => (
<Create {...props} redirect="list">
<SimpleForm
toolbar={
<Toolbar>
{/* It is possible to create tokens per default without input. */}
<SaveButton alwaysEnable />
</Toolbar>
}
>
<TextInput source="token" autoComplete="off" validate={validateToken} resettable />
<NumberInput
source="length"
validate={validateLength}
helperText="resources.registration_tokens.helper.length"
step={1}
/>
<NumberInput source="uses_allowed" validate={validateUsesAllowed} step={1} />
<DateTimeInput source="expiry_time" parse={dateParser} />
</SimpleForm>
</Create>
);
export const RegistrationTokenEdit = (props: EditProps) => (
<Edit {...props}>
<SimpleForm>
<TextInput source="token" disabled />
<NumberInput source="pending" disabled />
<NumberInput source="completed" disabled />
<NumberInput source="uses_allowed" validate={validateUsesAllowed} step={1} />
<DateTimeInput source="expiry_time" parse={dateParser} format={dateFormatter} />
</SimpleForm>
</Edit>
);
const resource: ResourceProps = {
name: "registration_tokens",
icon: RegistrationTokenIcon,
list: RegistrationTokenList,
edit: RegistrationTokenEdit,
create: RegistrationTokenCreate,
};
export default resource;

110
src/resources/reports.tsx Normal file
View File

@ -0,0 +1,110 @@
import PageviewIcon from "@mui/icons-material/Pageview";
import ViewListIcon from "@mui/icons-material/ViewList";
import ReportIcon from "@mui/icons-material/Warning";
import {
Datagrid,
DateField,
DeleteButton,
List,
ListProps,
NumberField,
Pagination,
ReferenceField,
ResourceProps,
Show,
ShowProps,
Tab,
TabbedShowLayout,
TextField,
TopToolbar,
useRecordContext,
useTranslate,
} from "react-admin";
import { DATE_FORMAT } from "../components/date";
import { MXCField } from "../components/media";
const ReportPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
export const ReportShow = (props: ShowProps) => {
const translate = useTranslate();
return (
<Show {...props} actions={<ReportShowActions />}>
<TabbedShowLayout>
<Tab
label={translate("synapseadmin.reports.tabs.basic", {
smart_count: 1,
})}
icon={<ViewListIcon />}
>
<DateField source="received_ts" showTime options={DATE_FORMAT} sortable={true} />
<ReferenceField source="user_id" reference="users">
<TextField source="id" />
</ReferenceField>
<NumberField source="score" />
<TextField source="reason" />
<TextField source="name" />
<TextField source="canonical_alias" label="resources.rooms.fields.canonical_alias" />
<ReferenceField source="room_id" reference="rooms" link="show" label="resources.rooms.fields.room_id">
<TextField source="id" />
</ReferenceField>
</Tab>
<Tab label="synapseadmin.reports.tabs.detail" icon={<PageviewIcon />} path="detail">
<DateField source="event_json.origin_server_ts" showTime options={DATE_FORMAT} sortable={true} />
<ReferenceField source="sender" reference="users">
<TextField source="id" />
</ReferenceField>
<TextField source="sender" label="Sender (raw user ID)" />
<TextField source="event_id" />
<TextField source="event_json.origin" />
<TextField source="event_json.type" />
<TextField source="event_json.content.msgtype" />
<TextField source="event_json.content.body" />
<TextField source="event_json.content.info.mimetype" />
<MXCField source="event_json.content.url" />
<TextField source="event_json.content.format" />
<TextField source="event_json.content.formatted_body" />
<TextField source="event_json.content.algorithm" />
<TextField source="event_json.content.device_id" label="resources.devices.fields.device_id" />
</Tab>
</TabbedShowLayout>
</Show>
);
};
const ReportShowActions = () => {
const record = useRecordContext();
return (
<TopToolbar>
<DeleteButton
record={record}
mutationMode="pessimistic"
confirmTitle="resources.reports.action.erase.title"
confirmContent="resources.reports.action.erase.content"
/>
</TopToolbar>
);
};
export const ReportList = (props: ListProps) => (
<List {...props} pagination={<ReportPagination />} sort={{ field: "received_ts", order: "DESC" }}>
<Datagrid rowClick="show" bulkActionButtons={false}>
<TextField source="id" sortable={false} />
<DateField source="received_ts" showTime options={DATE_FORMAT} sortable={true} />
<TextField sortable={false} source="user_id" />
<TextField sortable={false} source="name" />
<TextField sortable={false} source="score" />
</Datagrid>
</List>
);
const resource: ResourceProps = {
name: "reports",
icon: ReportIcon,
list: ReportList,
show: ReportShow,
};
export default resource;

View File

@ -0,0 +1,168 @@
import RoomDirectoryIcon from "@mui/icons-material/FolderShared";
import {
BooleanField,
BulkDeleteButton,
BulkDeleteButtonProps,
Button,
ButtonProps,
DatagridConfigurable,
DeleteButtonProps,
ExportButton,
DeleteButton,
List,
NumberField,
Pagination,
ResourceProps,
SelectColumnsButton,
TextField,
TopToolbar,
useCreate,
useDataProvider,
useListContext,
useNotify,
useTranslate,
useRecordContext,
useRefresh,
useUnselectAll,
} from "react-admin";
import { useMutation } from "@tanstack/react-query";
import AvatarField from "../components/AvatarField";
const RoomDirectoryPagination = () => <Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />;
export const RoomDirectoryUnpublishButton = (props: DeleteButtonProps) => {
const translate = useTranslate();
return (
<DeleteButton
{...props}
label="resources.room_directory.action.erase"
redirect={false}
mutationMode="pessimistic"
confirmTitle={translate("resources.room_directory.action.title", {
smart_count: 1,
})}
confirmContent={translate("resources.room_directory.action.content", {
smart_count: 1,
})}
resource="room_directory"
icon={<RoomDirectoryIcon />}
/>
);
};
export const RoomDirectoryBulkUnpublishButton = (props: BulkDeleteButtonProps) => (
<BulkDeleteButton
{...props}
label="resources.room_directory.action.erase"
mutationMode="pessimistic"
confirmTitle="resources.room_directory.action.title"
confirmContent="resources.room_directory.action.content"
resource="room_directory"
icon={<RoomDirectoryIcon />}
/>
);
export const RoomDirectoryBulkPublishButton = (props: ButtonProps) => {
const { selectedIds } = useListContext();
const notify = useNotify();
const refresh = useRefresh();
const unselectAllRooms = useUnselectAll("rooms");
const dataProvider = useDataProvider();
const { mutate, isPending } = useMutation({
mutationFn: () =>
dataProvider.createMany("room_directory", {
ids: selectedIds,
data: {},
}),
onSuccess: () => {
notify("resources.room_directory.action.send_success");
unselectAllRooms();
refresh();
},
onError: () =>
notify("resources.room_directory.action.send_failure", {
type: "error",
}),
});
return (
<Button {...props} label="resources.room_directory.action.create" onClick={mutate} disabled={isPending}>
<RoomDirectoryIcon />
</Button>
);
};
export const RoomDirectoryPublishButton = (props: ButtonProps) => {
const record = useRecordContext();
const notify = useNotify();
const refresh = useRefresh();
const [create, { isLoading }] = useCreate();
if (!record) {
return;
}
const handleSend = () => {
create(
"room_directory",
{ data: { id: record.id } },
{
onSuccess: () => {
notify("resources.room_directory.action.send_success");
refresh();
},
onError: () =>
notify("resources.room_directory.action.send_failure", {
type: "error",
}),
}
);
};
return (
<Button {...props} label="resources.room_directory.action.create" onClick={handleSend} disabled={isLoading}>
<RoomDirectoryIcon />
</Button>
);
};
const RoomDirectoryListActions = () => (
<TopToolbar>
<SelectColumnsButton />
<ExportButton />
</TopToolbar>
);
export const RoomDirectoryList = () => (
<List pagination={<RoomDirectoryPagination />} perPage={100} actions={<RoomDirectoryListActions />}>
<DatagridConfigurable
rowClick={id => "/rooms/" + id + "/show"}
bulkActionButtons={<RoomDirectoryBulkUnpublishButton />}
omit={["room_id", "canonical_alias", "topic"]}
>
<AvatarField
source="avatar_src"
sortable={false}
sx={{ height: "40px", width: "40px" }}
label="resources.rooms.fields.avatar"
/>
<TextField source="name" sortable={false} label="resources.rooms.fields.name" />
<TextField source="room_id" sortable={false} label="resources.rooms.fields.room_id" />
<TextField source="canonical_alias" sortable={false} label="resources.rooms.fields.canonical_alias" />
<TextField source="topic" sortable={false} label="resources.rooms.fields.topic" />
<NumberField source="num_joined_members" sortable={false} label="resources.rooms.fields.joined_members" />
<BooleanField source="world_readable" sortable={false} label="resources.room_directory.fields.world_readable" />
<BooleanField source="guest_can_join" sortable={false} label="resources.room_directory.fields.guest_can_join" />
</DatagridConfigurable>
</List>
);
const resource: ResourceProps = {
name: "room_directory",
icon: RoomDirectoryIcon,
list: RoomDirectoryList,
};
export default resource;

277
src/resources/rooms.tsx Normal file
View File

@ -0,0 +1,277 @@
import EventIcon from "@mui/icons-material/Event";
import FastForwardIcon from "@mui/icons-material/FastForward";
import UserIcon from "@mui/icons-material/Group";
import HttpsIcon from "@mui/icons-material/Https";
import NoEncryptionIcon from "@mui/icons-material/NoEncryption";
import PageviewIcon from "@mui/icons-material/Pageview";
import ViewListIcon from "@mui/icons-material/ViewList";
import RoomIcon from "@mui/icons-material/ViewList";
import VisibilityIcon from "@mui/icons-material/Visibility";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import {
BooleanField,
BulkDeleteButton,
DateField,
Datagrid,
DatagridConfigurable,
DeleteButton,
ExportButton,
FunctionField,
List,
ListProps,
NumberField,
Pagination,
ReferenceField,
ReferenceManyField,
ResourceProps,
SearchInput,
SelectColumnsButton,
SelectField,
Show,
ShowProps,
Tab,
TabbedShowLayout,
TextField,
TopToolbar,
useRecordContext,
useTranslate,
} from "react-admin";
import {
RoomDirectoryBulkUnpublishButton,
RoomDirectoryBulkPublishButton,
RoomDirectoryUnpublishButton,
RoomDirectoryPublishButton,
} from "./room_directory";
import { DATE_FORMAT } from "../components/date";
const RoomPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
const RoomTitle = () => {
const record = useRecordContext();
const translate = useTranslate();
let name = "";
if (record) {
name = record.name !== "" ? record.name : record.id;
}
return (
<span>
{translate("resources.rooms.name", 1)} {name}
</span>
);
};
const RoomShowActions = () => {
const record = useRecordContext();
const publishButton = record?.public ? <RoomDirectoryUnpublishButton /> : <RoomDirectoryPublishButton />;
// FIXME: refresh after (un)publish
return (
<TopToolbar>
{publishButton}
<DeleteButton
mutationMode="pessimistic"
confirmTitle="resources.rooms.action.erase.title"
confirmContent="resources.rooms.action.erase.content"
/>
</TopToolbar>
);
};
export const RoomShow = (props: ShowProps) => {
const translate = useTranslate();
return (
<Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}>
<TabbedShowLayout>
<Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}>
<TextField source="room_id" />
<TextField source="name" />
<TextField source="topic" />
<TextField source="canonical_alias" />
<ReferenceField source="creator" reference="users">
<TextField source="id" />
</ReferenceField>
</Tab>
<Tab label="synapseadmin.rooms.tabs.detail" icon={<PageviewIcon />} path="detail">
<TextField source="joined_members" />
<TextField source="joined_local_members" />
<TextField source="joined_local_devices" />
<TextField source="state_events" />
<TextField source="version" />
<TextField source="encryption" emptyText={translate("resources.rooms.enums.unencrypted")} />
</Tab>
<Tab label="synapseadmin.rooms.tabs.members" icon={<UserIcon />} path="members">
<ReferenceManyField reference="room_members" target="room_id" label={false}>
<Datagrid style={{ width: "100%" }} rowClick={id => "/users/" + id} bulkActionButtons={false}>
<TextField source="id" sortable={false} label="resources.users.fields.id" />
<ReferenceField
label="resources.users.fields.displayname"
source="id"
reference="users"
sortable={false}
link=""
>
<TextField source="displayname" sortable={false} />
</ReferenceField>
</Datagrid>
</ReferenceManyField>
</Tab>
<Tab label="synapseadmin.rooms.tabs.permission" icon={<VisibilityIcon />} path="permission">
<BooleanField source="federatable" />
<BooleanField source="public" />
<SelectField
source="join_rules"
choices={[
{ id: "public", name: "resources.rooms.enums.join_rules.public" },
{ id: "knock", name: "resources.rooms.enums.join_rules.knock" },
{ id: "invite", name: "resources.rooms.enums.join_rules.invite" },
{
id: "private",
name: "resources.rooms.enums.join_rules.private",
},
]}
/>
<SelectField
source="guest_access"
choices={[
{
id: "can_join",
name: "resources.rooms.enums.guest_access.can_join",
},
{
id: "forbidden",
name: "resources.rooms.enums.guest_access.forbidden",
},
]}
/>
<SelectField
source="history_visibility"
choices={[
{
id: "invited",
name: "resources.rooms.enums.history_visibility.invited",
},
{
id: "joined",
name: "resources.rooms.enums.history_visibility.joined",
},
{
id: "shared",
name: "resources.rooms.enums.history_visibility.shared",
},
{
id: "world_readable",
name: "resources.rooms.enums.history_visibility.world_readable",
},
]}
/>
</Tab>
<Tab label={translate("resources.room_state.name", { smart_count: 2 })} icon={<EventIcon />} path="state">
<ReferenceManyField reference="room_state" target="room_id" label={false}>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="type" sortable={false} />
<DateField source="origin_server_ts" showTime options={DATE_FORMAT} sortable={false} />
<TextField source="content" sortable={false} />
<ReferenceField source="sender" reference="users" sortable={false}>
<TextField source="id" />
</ReferenceField>
</Datagrid>
</ReferenceManyField>
</Tab>
<Tab label="resources.forward_extremities.name" icon={<FastForwardIcon />} path="forward_extremities">
<Box
sx={{
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
margin: "0.5em",
}}
>
{translate("resources.rooms.helper.forward_extremities")}
</Box>
<ReferenceManyField reference="forward_extremities" target="room_id" label={false}>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="id" sortable={false} />
<DateField source="received_ts" showTime options={DATE_FORMAT} sortable={false} />
<NumberField source="depth" sortable={false} />
<TextField source="state_group" sortable={false} />
</Datagrid>
</ReferenceManyField>
</Tab>
</TabbedShowLayout>
</Show>
);
};
const RoomBulkActionButtons = () => (
<>
<RoomDirectoryBulkPublishButton />
<RoomDirectoryBulkUnpublishButton />
<BulkDeleteButton
confirmTitle="resources.rooms.action.erase.title"
confirmContent="resources.rooms.action.erase.content"
mutationMode="pessimistic"
/>
</>
);
const roomFilters = [<SearchInput source="search_term" alwaysOn />];
const RoomListActions = () => (
<TopToolbar>
<SelectColumnsButton />
<ExportButton />
</TopToolbar>
);
export const RoomList = (props: ListProps) => {
const theme = useTheme();
return (
<List
{...props}
pagination={<RoomPagination />}
sort={{ field: "name", order: "ASC" }}
filters={roomFilters}
actions={<RoomListActions />}
>
<DatagridConfigurable
rowClick="show"
bulkActionButtons={<RoomBulkActionButtons />}
omit={["joined_local_members", "state_events", "version", "federatable"]}
>
<BooleanField
source="is_encrypted"
sortBy="encryption"
TrueIcon={HttpsIcon}
FalseIcon={NoEncryptionIcon}
label={<HttpsIcon />}
sx={{
[`& [data-testid="true"]`]: { color: theme.palette.success.main },
[`& [data-testid="false"]`]: { color: theme.palette.error.main },
}}
/>
<FunctionField source="name" render={record => record["name"] || record["canonical_alias"] || record["id"]} />
<TextField source="joined_members" />
<TextField source="joined_local_members" />
<TextField source="state_events" />
<TextField source="version" />
<BooleanField source="federatable" />
<BooleanField source="public" />
</DatagridConfigurable>
</List>
);
};
const resource: ResourceProps = {
name: "rooms",
icon: RoomIcon,
list: RoomList,
show: RoomShow,
};
export default resource;

View File

@ -0,0 +1,55 @@
import EqualizerIcon from "@mui/icons-material/Equalizer";
import {
Datagrid,
ExportButton,
List,
ListProps,
NumberField,
Pagination,
ResourceProps,
SearchInput,
TextField,
TopToolbar,
useListContext,
} from "react-admin";
import { DeleteMediaButton } from "../components/media";
const ListActions = () => {
const { isLoading, total } = useListContext();
return (
<TopToolbar>
<DeleteMediaButton />
<ExportButton disabled={isLoading || total === 0} />
</TopToolbar>
);
};
const UserMediaStatsPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
const userMediaStatsFilters = [<SearchInput source="search_term" alwaysOn />];
export const UserMediaStatsList = (props: ListProps) => (
<List
{...props}
actions={<ListActions />}
filters={userMediaStatsFilters}
pagination={<UserMediaStatsPagination />}
sort={{ field: "media_length", order: "DESC" }}
>
<Datagrid rowClick={id => "/users/" + id + "/media"} bulkActionButtons={false}>
<TextField source="user_id" label="resources.users.fields.id" />
<TextField source="displayname" label="resources.users.fields.displayname" />
<NumberField source="media_count" />
<NumberField source="media_length" />
</Datagrid>
</List>
);
const resource: ResourceProps = {
name: "user_media_statistics",
icon: EqualizerIcon,
list: UserMediaStatsList,
};
export default resource;

334
src/resources/users.tsx Normal file
View File

@ -0,0 +1,334 @@
import AssignmentIndIcon from "@mui/icons-material/AssignmentInd";
import ContactMailIcon from "@mui/icons-material/ContactMail";
import DevicesIcon from "@mui/icons-material/Devices";
import GetAppIcon from "@mui/icons-material/GetApp";
import UserIcon from "@mui/icons-material/Group";
import NotificationsIcon from "@mui/icons-material/Notifications";
import PermMediaIcon from "@mui/icons-material/PermMedia";
import PersonPinIcon from "@mui/icons-material/PersonPin";
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
import ViewListIcon from "@mui/icons-material/ViewList";
import {
ArrayInput,
ArrayField,
Button,
Datagrid,
DateField,
Create,
CreateProps,
Edit,
EditProps,
List,
ListProps,
SimpleForm,
SimpleFormIterator,
TabbedForm,
FormTab,
BooleanField,
BooleanInput,
PasswordInput,
TextField,
TextInput,
ReferenceField,
ReferenceManyField,
ResourceProps,
SearchInput,
SelectInput,
BulkDeleteButton,
DeleteButton,
maxLength,
regex,
required,
useRecordContext,
useTranslate,
Pagination,
CreateButton,
ExportButton,
TopToolbar,
NumberField,
useListContext,
Identifier,
} from "react-admin";
import { Link } from "react-router-dom";
import AvatarField from "../components/AvatarField";
import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/ServerNotices";
import { DATE_FORMAT } from "../components/date";
import { DeviceRemoveButton } from "../components/devices";
import { MediaIDField, ProtectMediaButton, QuarantineMediaButton } from "../components/media";
const choices_medium = [
{ id: "email", name: "resources.users.email" },
{ id: "msisdn", name: "resources.users.msisdn" },
];
const choices_type = [
{ id: "bot", name: "bot" },
{ id: "support", name: "support" },
];
const UserListActions = () => {
const { isLoading, total } = useListContext();
return (
<TopToolbar>
<CreateButton />
<ExportButton disabled={isLoading || total === 0} maxResults={10000} />
<Button component={Link} to="/import_users" label="CSV Import">
<GetAppIcon sx={{ transform: "rotate(180deg)", fontSize: "20px" }} />
</Button>
</TopToolbar>
);
};
const UserPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
const userFilters = [
<SearchInput source="name" alwaysOn />,
<BooleanInput source="guests" alwaysOn />,
<BooleanInput label="resources.users.fields.show_deactivated" source="deactivated" alwaysOn />,
<BooleanInput label="resources.users.fields.show_locked" source="locked" alwaysOn />,
];
const UserBulkActionButtons = () => (
<>
<ServerNoticeBulkButton />
<BulkDeleteButton
label="resources.users.action.erase"
confirmTitle="resources.users.helper.erase"
mutationMode="pessimistic"
/>
</>
);
export const UserList = (props: ListProps) => (
<List
{...props}
filters={userFilters}
filterDefaultValues={{ guests: true, deactivated: false, locked: false }}
sort={{ field: "name", order: "ASC" }}
actions={<UserListActions />}
pagination={<UserPagination />}
>
<Datagrid
rowClick={(id: Identifier, resource: string) => `/${resource}/${id}`}
bulkActionButtons={<UserBulkActionButtons />}
>
<AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} sortBy="avatar_url" />
<TextField source="id" sortBy="name" />
<TextField source="displayname" />
<BooleanField source="is_guest" />
<BooleanField source="admin" />
<BooleanField source="deactivated" />
<BooleanField source="locked" />
<BooleanField source="erased" sortable={false} />
<DateField source="creation_ts" label="resources.users.fields.creation_ts_ms" showTime options={DATE_FORMAT} />
</Datagrid>
</List>
);
// https://matrix.org/docs/spec/appendices#user-identifiers
// here only local part of user_id
// maxLength = 255 - "@" - ":" - storage.getItem("home_server").length
// storage.getItem("home_server").length is not valid here
const validateUser = [required(), maxLength(253), regex(/^[a-z0-9._=\-/]+$/, "synapseadmin.users.invalid_user_id")];
const validateAddress = [required(), maxLength(255)];
const UserEditActions = () => {
const record = useRecordContext();
const translate = useTranslate();
return (
<TopToolbar>
{!record?.deactivated && <ServerNoticeButton />}
<DeleteButton
label="resources.users.action.erase"
confirmTitle={translate("resources.users.helper.erase", {
smart_count: 1,
})}
mutationMode="pessimistic"
/>
</TopToolbar>
);
};
export const UserCreate = (props: CreateProps) => (
<Create
{...props}
redirect={(resource: string | undefined, id: Identifier | undefined) => {
return `${resource}/${id}`;
}}
>
<SimpleForm>
<TextInput source="id" autoComplete="off" validate={validateUser} />
<TextInput source="displayname" validate={maxLength(256)} />
<PasswordInput source="password" autoComplete="new-password" validate={maxLength(512)} />
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
<BooleanInput source="admin" />
<ArrayInput source="threepids">
<SimpleFormIterator disableReordering>
<SelectInput source="medium" choices={choices_medium} validate={required()} />
<TextInput source="address" validate={validateAddress} />
</SimpleFormIterator>
</ArrayInput>
<ArrayInput source="external_ids" label="synapseadmin.users.tabs.sso">
<SimpleFormIterator disableReordering>
<TextInput source="auth_provider" validate={required()} />
<TextInput source="external_id" label="resources.users.fields.id" validate={required()} />
</SimpleFormIterator>
</ArrayInput>
</SimpleForm>
</Create>
);
const UserTitle = () => {
const record = useRecordContext();
const translate = useTranslate();
return (
<span>
{translate("resources.users.name", {
smart_count: 1,
})}{" "}
{record ? `"${record.displayname}"` : ""}
</span>
);
};
export const UserEdit = (props: EditProps) => {
const translate = useTranslate();
return (
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}>
<TabbedForm>
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
<AvatarField source="avatar_src" sortable={false} sx={{ height: "120px", width: "120px", float: "right" }} />
<TextInput source="id" disabled />
<TextInput source="displayname" />
<PasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
<BooleanInput source="admin" />
<BooleanInput source="locked" />
<BooleanInput source="deactivated" helperText="resources.users.helper.deactivate" />
<BooleanInput source="erased" disabled />
<DateField source="creation_ts_ms" showTime options={DATE_FORMAT} />
<TextField source="consent_version" />
</FormTab>
<FormTab label="resources.users.threepid" icon={<ContactMailIcon />} path="threepid">
<ArrayInput source="threepids">
<SimpleFormIterator disableReordering>
<SelectInput source="medium" choices={choices_medium} />
<TextInput source="address" />
</SimpleFormIterator>
</ArrayInput>
</FormTab>
<FormTab label="synapseadmin.users.tabs.sso" icon={<AssignmentIndIcon />} path="sso">
<ArrayInput source="external_ids" label={false}>
<SimpleFormIterator disableReordering>
<TextInput source="auth_provider" validate={required()} />
<TextInput source="external_id" label="resources.users.fields.id" validate={required()} />
</SimpleFormIterator>
</ArrayInput>
</FormTab>
<FormTab label={translate("resources.devices.name", { smart_count: 2 })} icon={<DevicesIcon />} path="devices">
<ReferenceManyField reference="devices" target="user_id" label={false}>
<Datagrid style={{ width: "100%" }}>
<TextField source="device_id" sortable={false} />
<TextField source="display_name" sortable={false} />
<TextField source="last_seen_ip" sortable={false} />
<DateField source="last_seen_ts" showTime options={DATE_FORMAT} sortable={false} />
<DeviceRemoveButton />
</Datagrid>
</ReferenceManyField>
</FormTab>
<FormTab label="resources.connections.name" icon={<SettingsInputComponentIcon />} path="connections">
<ReferenceField reference="connections" source="id" label={false} link={false}>
<ArrayField source="devices[].sessions[0].connections" label="resources.connections.name">
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="ip" sortable={false} />
<DateField source="last_seen" showTime options={DATE_FORMAT} sortable={false} />
<TextField source="user_agent" sortable={false} style={{ width: "100%" }} />
</Datagrid>
</ArrayField>
</ReferenceField>
</FormTab>
<FormTab
label={translate("resources.users_media.name", { smart_count: 2 })}
icon={<PermMediaIcon />}
path="media"
>
<ReferenceManyField
reference="users_media"
target="user_id"
label={false}
pagination={<UserPagination />}
perPage={50}
sort={{ field: "created_ts", order: "DESC" }}
>
<Datagrid style={{ width: "100%" }}>
<MediaIDField source="media_id" />
<DateField source="created_ts" showTime options={DATE_FORMAT} />
<DateField source="last_access_ts" showTime options={DATE_FORMAT} />
<NumberField source="media_length" />
<TextField source="media_type" />
<TextField source="upload_name" />
<TextField source="quarantined_by" />
<QuarantineMediaButton label="resources.quarantine_media.action.name" />
<ProtectMediaButton label="resources.users_media.fields.safe_from_quarantine" />
<DeleteButton mutationMode="pessimistic" redirect={false} />
</Datagrid>
</ReferenceManyField>
</FormTab>
<FormTab label={translate("resources.rooms.name", { smart_count: 2 })} icon={<ViewListIcon />} path="rooms">
<ReferenceManyField reference="joined_rooms" target="user_id" label={false}>
<Datagrid style={{ width: "100%" }} rowClick={id => "/rooms/" + id + "/show"} bulkActionButtons={false}>
<TextField source="id" sortable={false} label="resources.rooms.fields.room_id" />
<ReferenceField
label="resources.rooms.fields.name"
source="id"
reference="rooms"
sortable={false}
link=""
>
<TextField source="name" sortable={false} />
</ReferenceField>
</Datagrid>
</ReferenceManyField>
</FormTab>
<FormTab
label={translate("resources.pushers.name", { smart_count: 2 })}
icon={<NotificationsIcon />}
path="pushers"
>
<ReferenceManyField reference="pushers" target="user_id" label={false}>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="kind" sortable={false} />
<TextField source="app_display_name" sortable={false} />
<TextField source="app_id" sortable={false} />
<TextField source="data.url" sortable={false} />
<TextField source="device_display_name" sortable={false} />
<TextField source="lang" sortable={false} />
<TextField source="profile_tag" sortable={false} />
<TextField source="pushkey" sortable={false} />
</Datagrid>
</ReferenceManyField>
</FormTab>
</TabbedForm>
</Edit>
);
};
const resource: ResourceProps = {
name: "users",
icon: UserIcon,
list: UserList,
edit: UserEdit,
create: UserCreate,
};
export default resource;

View File

@ -1,3 +0,0 @@
import fetchMock from "jest-fetch-mock";
fetchMock.enableMocks();

3
src/storage.ts Normal file
View File

@ -0,0 +1,3 @@
const storage = localStorage;
export default storage;

View File

@ -0,0 +1,138 @@
import fetchMock from "jest-fetch-mock";
import authProvider from "./authProvider";
import storage from "../storage";
fetchMock.enableMocks();
describe("authProvider", () => {
beforeEach(() => {
fetchMock.resetMocks();
storage.clear();
});
describe("login", () => {
it("should successfully login with username and password", async () => {
fetchMock.once(
JSON.stringify({
home_server: "example.com",
user_id: "@user:example.com",
access_token: "foobar",
device_id: "some_device",
})
);
const ret: undefined = await authProvider.login({
base_url: "http://example.com",
username: "@user:example.com",
password: "secret",
});
expect(ret).toBe(undefined);
expect(fetch).toBeCalledWith("http://example.com/_matrix/client/r0/login", {
body: JSON.stringify({
device_id: null,
initial_device_display_name: "Synapse Admin",
type: "m.login.password",
user: "@user:example.com",
password: "secret",
identifier: {
type: "m.id.user",
user: "@user:example.com",
}
}),
headers: new Headers({
Accept: "application/json",
"Content-Type": "application/json",
}),
method: "POST",
});
expect(storage.getItem("base_url")).toEqual("http://example.com");
expect(storage.getItem("user_id")).toEqual("@user:example.com");
expect(storage.getItem("access_token")).toEqual("foobar");
expect(storage.getItem("device_id")).toEqual("some_device");
});
});
it("should successfully login with token", async () => {
fetchMock.once(
JSON.stringify({
home_server: "example.com",
user_id: "@user:example.com",
access_token: "foobar",
device_id: "some_device",
})
);
const ret: undefined = await authProvider.login({
base_url: "https://example.com/",
loginToken: "login_token",
});
expect(ret).toBe(undefined);
expect(fetch).toHaveBeenCalledWith("https://example.com/_matrix/client/r0/login", {
body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.token","token":"login_token"}',
headers: new Headers({
Accept: "application/json",
"Content-Type": "application/json",
}),
method: "POST",
});
expect(storage.getItem("base_url")).toEqual("https://example.com");
expect(storage.getItem("user_id")).toEqual("@user:example.com");
expect(storage.getItem("access_token")).toEqual("foobar");
expect(storage.getItem("device_id")).toEqual("some_device");
});
describe("logout", () => {
it("should remove the access_token from storage", async () => {
storage.setItem("base_url", "example.com");
storage.setItem("access_token", "foo");
fetchMock.mockResponse(JSON.stringify({}));
await authProvider.logout(null);
expect(fetch).toBeCalledWith("example.com/_matrix/client/r0/logout", {
headers: new Headers({
Accept: "application/json",
Authorization: "Bearer foo",
}),
method: "POST",
user: { authenticated: true, token: "Bearer foo" },
});
expect(storage.getItem("access_token")).toBeNull();
});
});
describe("checkError", () => {
it("should resolve if error.status is not 401 or 403", async () => {
await expect(authProvider.checkError({ status: 200 })).resolves.toBeUndefined();
});
it("should reject if error.status is 401", async () => {
await expect(authProvider.checkError({ status: 401 })).rejects.toBeUndefined();
});
it("should reject if error.status is 403", async () => {
await expect(authProvider.checkError({ status: 403 })).rejects.toBeUndefined();
});
});
describe("checkAuth", () => {
it("should reject when not logged in", async () => {
await expect(authProvider.checkAuth({})).rejects.toBeUndefined();
});
it("should resolve when logged in", async () => {
storage.setItem("access_token", "foobar");
await expect(authProvider.checkAuth({})).resolves.toBeUndefined();
});
});
describe("getPermissions", () => {
it("should do nothing", async () => {
await expect(authProvider.getPermissions(null)).resolves.toBeUndefined();
});
});
});

View File

@ -1,18 +1,27 @@
import { fetchUtils } from "react-admin";
import { AuthProvider, Options, fetchUtils } from "react-admin";
const authProvider = {
import storage from "../storage";
const authProvider: AuthProvider = {
// called when the user attempts to log in
login: ({ base_url, username, password, loginToken }) => {
// force homeserver for protection in case the form is manipulated
base_url = process.env.REACT_APP_SERVER || base_url;
login: async ({
base_url,
username,
password,
loginToken,
}: {
base_url: string;
username: string;
password: string;
loginToken: string;
}) => {
console.log("login ");
const options = {
const options: Options = {
method: "POST",
body: JSON.stringify(
Object.assign(
{
device_id: localStorage.getItem("device_id"),
device_id: storage.getItem("device_id"),
initial_device_display_name: "Synapse Admin",
},
loginToken
@ -24,6 +33,10 @@ const authProvider = {
type: "m.login.password",
user: username,
password: password,
identifier: {
type: "m.id.user",
user: username,
},
}
)
),
@ -33,27 +46,25 @@ const authProvider = {
// server, since the admin might want to access the admin API via some
// private address
base_url = base_url.replace(/\/+$/g, "");
localStorage.setItem("base_url", base_url);
storage.setItem("base_url", base_url);
const decoded_base_url = window.decodeURIComponent(base_url);
const login_api_url = decoded_base_url + "/_matrix/client/r0/login";
return fetchUtils.fetchJson(login_api_url, options).then(({ json }) => {
localStorage.setItem("home_server", json.home_server);
localStorage.setItem("user_id", json.user_id);
localStorage.setItem("access_token", json.access_token);
localStorage.setItem("device_id", json.device_id);
});
const { json } = await fetchUtils.fetchJson(login_api_url, options);
storage.setItem("home_server", json.home_server);
storage.setItem("user_id", json.user_id);
storage.setItem("access_token", json.access_token);
storage.setItem("device_id", json.device_id);
},
// called when the user clicks on the logout button
logout: () => {
logout: async () => {
console.log("logout");
const logout_api_url =
localStorage.getItem("base_url") + "/_matrix/client/r0/logout";
const access_token = localStorage.getItem("access_token");
const logout_api_url = storage.getItem("base_url") + "/_matrix/client/r0/logout";
const access_token = storage.getItem("access_token");
const options = {
const options: Options = {
method: "POST",
user: {
authenticated: true,
@ -62,14 +73,12 @@ const authProvider = {
};
if (typeof access_token === "string") {
fetchUtils.fetchJson(logout_api_url, options).then(({ json }) => {
localStorage.removeItem("access_token");
});
await fetchUtils.fetchJson(logout_api_url, options);
storage.removeItem("access_token");
}
return Promise.resolve();
},
// called when the API returns an error
checkError: ({ status }) => {
checkError: ({ status }: { status: number }) => {
console.log("checkError " + status);
if (status === 401 || status === 403) {
return Promise.reject();
@ -78,11 +87,9 @@ const authProvider = {
},
// called when the user navigates to a new location, to check for authentication
checkAuth: () => {
const access_token = localStorage.getItem("access_token");
const access_token = storage.getItem("access_token");
console.log("checkAuth " + access_token);
return typeof access_token === "string"
? Promise.resolve()
: Promise.reject();
return typeof access_token === "string" ? Promise.resolve() : Promise.reject();
},
// called when the user navigates to a new location, to check for permissions / roles
getPermissions: () => Promise.resolve(),

View File

@ -1,592 +0,0 @@
import { fetchUtils } from "react-admin";
import { stringify } from "query-string";
// Adds the access token to all requests
const jsonClient = (url, options = {}) => {
const token = localStorage.getItem("access_token");
console.log("httpClient " + url);
if (token != null) {
options.user = {
authenticated: true,
token: `Bearer ${token}`,
};
}
return fetchUtils.fetchJson(url, options);
};
const mxcUrlToHttp = mxcUrl => {
const homeserver = localStorage.getItem("base_url");
const re = /^mxc:\/\/([^/]+)\/(\w+)/;
var ret = re.exec(mxcUrl);
console.log("mxcClient " + ret);
if (ret == null) return null;
const serverName = ret[1];
const mediaId = ret[2];
return `${homeserver}/_matrix/media/r0/thumbnail/${serverName}/${mediaId}?width=24&height=24&method=scale`;
};
const resourceMap = {
users: {
path: "/_synapse/admin/v2/users",
map: u => ({
...u,
id: u.name,
avatar_src: mxcUrlToHttp(u.avatar_url),
is_guest: !!u.is_guest,
admin: !!u.admin,
deactivated: !!u.deactivated,
// need timestamp in milliseconds
creation_ts_ms: u.creation_ts * 1000,
}),
data: "users",
total: json => json.total,
create: data => ({
endpoint: `/_synapse/admin/v2/users/@${encodeURIComponent(
data.id
)}:${localStorage.getItem("home_server")}`,
body: data,
method: "PUT",
}),
delete: params => ({
endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(
params.id
)}`,
body: { erase: true },
method: "POST",
}),
},
rooms: {
path: "/_synapse/admin/v1/rooms",
map: r => ({
...r,
id: r.room_id,
alias: r.canonical_alias,
members: r.joined_members,
is_encrypted: !!r.encryption,
federatable: !!r.federatable,
public: !!r.public,
}),
data: "rooms",
total: json => {
return json.total_rooms;
},
delete: params => ({
endpoint: `/_synapse/admin/v2/rooms/${params.id}`,
body: { block: false },
}),
},
reports: {
path: "/_synapse/admin/v1/event_reports",
map: er => ({
...er,
id: er.id,
}),
data: "event_reports",
total: json => json.total,
},
devices: {
map: d => ({
...d,
id: d.device_id,
}),
data: "devices",
total: json => {
return json.total;
},
reference: id => ({
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(id)}/devices`,
}),
delete: params => ({
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(
params.user_id
)}/devices/${params.id}`,
}),
},
connections: {
path: "/_synapse/admin/v1/whois",
map: c => ({
...c,
id: c.user_id,
}),
data: "connections",
},
room_members: {
map: m => ({
id: m,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/rooms/${id}/members`,
}),
data: "members",
total: json => {
return json.total;
},
},
room_state: {
map: rs => ({
...rs,
id: rs.event_id,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/rooms/${id}/state`,
}),
data: "state",
total: json => {
return json.state.length;
},
},
pushers: {
map: p => ({
...p,
id: p.pushkey,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/pushers`,
}),
data: "pushers",
total: json => {
return json.total;
},
},
joined_rooms: {
map: jr => ({
id: jr,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(
id
)}/joined_rooms`,
}),
data: "joined_rooms",
total: json => {
return json.total;
},
},
users_media: {
map: um => ({
...um,
id: um.media_id,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/media`,
}),
data: "media",
total: json => {
return json.total;
},
delete: params => ({
endpoint: `/_synapse/admin/v1/media/${localStorage.getItem(
"home_server"
)}/${params.id}`,
}),
},
delete_media: {
delete: params => ({
endpoint: `/_synapse/admin/v1/media/${localStorage.getItem(
"home_server"
)}/delete?before_ts=${params.before_ts}&size_gt=${
params.size_gt
}&keep_profiles=${params.keep_profiles}`,
method: "POST",
}),
},
protect_media: {
map: pm => ({ id: pm.media_id }),
create: params => ({
endpoint: `/_synapse/admin/v1/media/protect/${params.media_id}`,
method: "POST",
}),
delete: params => ({
endpoint: `/_synapse/admin/v1/media/unprotect/${params.media_id}`,
method: "POST",
}),
},
quarantine_media: {
map: qm => ({ id: qm.media_id }),
create: params => ({
endpoint: `/_synapse/admin/v1/media/quarantine/${localStorage.getItem(
"home_server"
)}/${params.media_id}`,
method: "POST",
}),
delete: params => ({
endpoint: `/_synapse/admin/v1/media/unquarantine/${localStorage.getItem(
"home_server"
)}/${params.media_id}`,
method: "POST",
}),
},
servernotices: {
map: n => ({ id: n.event_id }),
create: data => ({
endpoint: "/_synapse/admin/v1/send_server_notice",
body: {
user_id: data.id,
content: {
msgtype: "m.text",
body: data.body,
},
},
method: "POST",
}),
},
user_media_statistics: {
path: "/_synapse/admin/v1/statistics/users/media",
map: usms => ({
...usms,
id: usms.user_id,
}),
data: "users",
total: json => {
return json.total;
},
},
forward_extremities: {
map: fe => ({
...fe,
id: fe.event_id,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/rooms/${id}/forward_extremities`,
}),
data: "results",
total: json => {
return json.count;
},
delete: params => ({
endpoint: `/_synapse/admin/v1/rooms/${params.id}/forward_extremities`,
}),
},
room_directory: {
path: "/_matrix/client/r0/publicRooms",
map: rd => ({
...rd,
id: rd.room_id,
public: !!rd.public,
guest_access: !!rd.guest_access,
avatar_src: mxcUrlToHttp(rd.avatar_url),
}),
data: "chunk",
total: json => {
return json.total_room_count_estimate;
},
create: params => ({
endpoint: `/_matrix/client/r0/directory/list/room/${params.id}`,
body: { visibility: "public" },
method: "PUT",
}),
delete: params => ({
endpoint: `/_matrix/client/r0/directory/list/room/${params.id}`,
body: { visibility: "private" },
method: "PUT",
}),
},
destinations: {
path: "/_synapse/admin/v1/federation/destinations",
map: dst => ({
...dst,
id: dst.destination,
}),
data: "destinations",
total: json => {
return json.total;
},
delete: params => ({
endpoint: `/_synapse/admin/v1/federation/destinations/${params.id}/reset_connection`,
method: "POST",
}),
},
destination_rooms: {
map: dstroom => ({
...dstroom,
id: dstroom.room_id,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/federation/destinations/${id}/rooms`,
}),
data: "rooms",
total: json => {
return json.total;
},
},
registration_tokens: {
path: "/_synapse/admin/v1/registration_tokens",
map: rt => ({
...rt,
id: rt.token,
}),
data: "registration_tokens",
total: json => {
return json.registration_tokens.length;
},
create: params => ({
endpoint: "/_synapse/admin/v1/registration_tokens/new",
body: params,
method: "POST",
}),
delete: params => ({
endpoint: `/_synapse/admin/v1/registration_tokens/${params.id}`,
}),
},
};
function filterNullValues(key, value) {
// Filtering out null properties
// to reset user_type from user, it must be null
if (value === null && key !== "user_type") {
return undefined;
}
return value;
}
function getSearchOrder(order) {
if (order === "DESC") {
return "b";
} else {
return "f";
}
}
const dataProvider = {
getList: (resource, params) => {
console.log("getList " + resource);
const {
user_id,
name,
guests,
deactivated,
search_term,
destination,
valid,
} = params.filter;
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
const from = (page - 1) * perPage;
const query = {
from: from,
limit: perPage,
user_id: user_id,
search_term: search_term,
name: name,
destination: destination,
guests: guests,
deactivated: deactivated,
valid: valid,
order_by: field,
dir: getSearchOrder(order),
};
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const url = `${endpoint_url}?${stringify(query)}`;
return jsonClient(url).then(({ json }) => ({
data: json[res.data].map(res.map),
total: res.total(json, from, perPage),
}));
},
getOne: (resource, params) => {
console.log("getOne " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${encodeURIComponent(params.id)}`).then(
({ json }) => ({
data: res.map(json),
})
);
},
getMany: (resource, params) => {
console.log("getMany " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
return Promise.all(
params.ids.map(id =>
jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`)
)
).then(responses => ({
data: responses.map(({ json }) => res.map(json)),
total: responses.length,
}));
},
getManyReference: (resource, params) => {
console.log("getManyReference " + resource);
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
const from = (page - 1) * perPage;
const query = {
from: from,
limit: perPage,
order_by: field,
dir: getSearchOrder(order),
};
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const ref = res["reference"](params.id);
const endpoint_url = `${homeserver}${ref.endpoint}?${stringify(query)}`;
return jsonClient(endpoint_url).then(({ headers, json }) => ({
data: json[res.data].map(res.map),
total: res.total(json, from, perPage),
}));
},
update: (resource, params) => {
console.log("update " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${encodeURIComponent(params.data.id)}`, {
method: "PUT",
body: JSON.stringify(params.data, filterNullValues),
}).then(({ json }) => ({
data: res.map(json),
}));
},
updateMany: (resource, params) => {
console.log("updateMany " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
return Promise.all(
params.ids.map(
id => jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`),
{
method: "PUT",
body: JSON.stringify(params.data, filterNullValues),
}
)
).then(responses => ({
data: responses.map(({ json }) => json),
}));
},
create: (resource, params) => {
console.log("create " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
if (!("create" in res)) return Promise.reject();
const create = res["create"](params.data);
const endpoint_url = homeserver + create.endpoint;
return jsonClient(endpoint_url, {
method: create.method,
body: JSON.stringify(create.body, filterNullValues),
}).then(({ json }) => ({
data: res.map(json),
}));
},
createMany: (resource, params) => {
console.log("createMany " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
if (!("create" in res)) return Promise.reject();
return Promise.all(
params.ids.map(id => {
params.data.id = id;
const cre = res["create"](params.data);
const endpoint_url = homeserver + cre.endpoint;
return jsonClient(endpoint_url, {
method: cre.method,
body: JSON.stringify(cre.body, filterNullValues),
});
})
).then(responses => ({
data: responses.map(({ json }) => json),
}));
},
delete: (resource, params) => {
console.log("delete " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
if ("delete" in res) {
const del = res["delete"](params);
const endpoint_url = homeserver + del.endpoint;
return jsonClient(endpoint_url, {
method: "method" in del ? del.method : "DELETE",
body: "body" in del ? JSON.stringify(del.body) : null,
}).then(({ json }) => ({
data: json,
}));
} else {
const endpoint_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${params.id}`, {
method: "DELETE",
body: JSON.stringify(params.data, filterNullValues),
}).then(({ json }) => ({
data: json,
}));
}
},
deleteMany: (resource, params) => {
console.log("deleteMany " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
if ("delete" in res) {
return Promise.all(
params.ids.map(id => {
const del = res["delete"]({ ...params, id: id });
const endpoint_url = homeserver + del.endpoint;
return jsonClient(endpoint_url, {
method: "method" in del ? del.method : "DELETE",
body: "body" in del ? JSON.stringify(del.body) : null,
});
})
).then(responses => ({
data: responses.map(({ json }) => json),
}));
} else {
const endpoint_url = homeserver + res.path;
return Promise.all(
params.ids.map(id =>
jsonClient(`${endpoint_url}/${id}`, {
method: "DELETE",
body: JSON.stringify(params.data, filterNullValues),
})
)
).then(responses => ({
data: responses.map(({ json }) => json),
}));
}
},
};
export default dataProvider;

View File

@ -1,15 +1,20 @@
import fetchMock from "jest-fetch-mock";
import dataProvider from "./dataProvider";
import storage from "../storage";
fetchMock.enableMocks();
beforeEach(() => {
fetch.resetMocks();
fetchMock.resetMocks();
});
describe("dataProvider", () => {
localStorage.setItem("base_url", "http://localhost");
localStorage.setItem("access_token", "access_token");
storage.setItem("base_url", "http://localhost");
storage.setItem("access_token", "access_token");
it("fetches all users", async () => {
fetch.mockResponseOnce(
fetchMock.mockResponseOnce(
JSON.stringify({
users: [
{
@ -42,13 +47,13 @@ describe("dataProvider", () => {
filter: { author_id: 12 },
});
expect(users["data"][0]["id"]).toEqual("user_id1");
expect(users["total"]).toEqual(200);
expect(users.data[0].id).toEqual("user_id1");
expect(users.total).toEqual(200);
expect(fetch).toHaveBeenCalledTimes(1);
});
it("fetches one user", async () => {
fetch.mockResponseOnce(
fetchMock.mockResponseOnce(
JSON.stringify({
name: "user_id1",
password: "user_password",
@ -71,8 +76,8 @@ describe("dataProvider", () => {
const user = await dataProvider.getOne("users", { id: "user_id1" });
expect(user["data"]["id"]).toEqual("user_id1");
expect(user["data"]["displayname"]).toEqual("User");
expect(user.data.id).toEqual("user_id1");
expect(user.data.displayname).toEqual("User");
expect(fetch).toHaveBeenCalledTimes(1);
});
});

742
src/synapse/dataProvider.ts Normal file
View File

@ -0,0 +1,742 @@
import { stringify } from "query-string";
import {
DataProvider,
DeleteParams,
Identifier,
Options,
PaginationPayload,
RaRecord,
SortPayload,
fetchUtils
} from "react-admin";
import storage from "../storage";
// Adds the access token to all requests
const jsonClient = (url: string, options: Options = {}) => {
const token = storage.getItem("access_token");
console.log("httpClient " + url);
if (token != null) {
options.user = {
authenticated: true,
token: `Bearer ${token}`,
};
}
return fetchUtils.fetchJson(url, options);
};
const mxcUrlToHttp = (mxcUrl: string) => {
const homeserver = storage.getItem("base_url");
const re = /^mxc:\/\/([^/]+)\/(\w+)/;
const ret = re.exec(mxcUrl);
console.log("mxcClient " + ret);
if (ret == null) return null;
const serverName = ret[1];
const mediaId = ret[2];
return `${homeserver}/_matrix/media/r0/thumbnail/${serverName}/${mediaId}?width=24&height=24&method=scale`;
};
interface Room {
room_id: string;
name?: string;
canonical_alias?: string;
avatar_url?: string;
joined_members: number;
joined_local_members: number;
version: number;
creator: string;
encryption?: string;
federatable: boolean;
public: boolean;
join_rules: "public" | "knock" | "invite" | "private";
guest_access?: "can_join" | "forbidden";
history_visibility: "invited" | "joined" | "shared" | "world_readable";
state_events: number;
room_type?: string;
}
interface RoomState {
age: number;
content: {
alias?: string;
};
event_id: string;
origin_server_ts: number;
room_id: string;
sender: string;
state_key: string;
type: string;
user_id: string;
unsigned: {
age?: number;
};
}
interface ForwardExtremity {
event_id: string;
state_group: number;
depth: number;
received_ts: number;
}
interface EventReport {
id: number;
received_ts: number;
room_id: string;
name: string;
event_id: string;
user_id: string;
reason?: string;
score?: number;
sender: string;
canonical_alias?: string;
}
interface Threepid {
medium: string;
address: string;
added_at: number;
validated_at: number;
}
interface ExternalId {
auth_provider: string;
external_id: string;
}
interface User {
name: string;
displayname?: string;
threepids: Threepid[];
avatar_url?: string;
is_guest: 0 | 1;
admin: 0 | 1;
deactivated: 0 | 1;
erased: boolean;
shadow_banned: 0 | 1;
creation_ts: number;
appservice_id?: string;
consent_server_notice_sent?: string;
consent_version?: string;
consent_ts?: number;
external_ids: ExternalId[];
user_type?: string;
locked: boolean;
}
interface Device {
device_id: string;
display_name?: string;
last_seen_ip?: string;
last_seen_user_agent?: string;
last_seen_ts?: number;
user_id: string;
}
interface Connection {
ip: string;
last_seen: number;
user_agent: string;
}
interface Whois {
user_id: string;
devices: Record<
string,
{
sessions: {
connections: Connection[];
}[];
}
>;
}
interface Pusher {
app_display_name: string;
app_id: string;
data: {
url?: string;
format: string;
};
url: string;
format: string;
device_display_name: string;
profile_tag: string;
kind: string;
lang: string;
pushkey: string;
}
interface UserMedia {
created_ts: number;
last_access_ts?: number;
media_id: string;
media_length: number;
media_type: string;
quarantined_by?: string;
safe_from_quarantine: boolean;
upload_name?: string;
}
interface UserMediaStatistic {
displayname: string;
media_count: number;
media_length: number;
user_id: string;
}
interface RegistrationToken {
token: string;
uses_allowed: number;
pending: number;
completed: number;
expiry_time?: number;
}
interface RaServerNotice {
id: string;
body: string;
}
interface Destination {
destination: string;
retry_last_ts: number;
retry_interval: number;
failure_ts: number;
last_successful_stream_ordering?: number;
}
interface DestinationRoom {
room_id: string;
stream_ordering: number;
}
export interface DeleteMediaParams {
before_ts: string;
size_gt: number;
keep_profiles: boolean;
}
export interface DeleteMediaResult {
deleted_media: Identifier[];
total: number;
}
export interface SynapseDataProvider extends DataProvider {
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
}
const resourceMap = {
users: {
path: "/_synapse/admin/v2/users",
map: (u: User) => ({
...u,
id: u.name,
avatar_src: u.avatar_url ? mxcUrlToHttp(u.avatar_url) : undefined,
is_guest: !!u.is_guest,
admin: !!u.admin,
deactivated: !!u.deactivated,
// need timestamp in milliseconds
creation_ts_ms: u.creation_ts * 1000,
}),
data: "users",
total: json => json.total,
create: (data: RaRecord) => ({
endpoint: `/_synapse/admin/v2/users/@${encodeURIComponent(data.id)}:${storage.getItem("home_server")}`,
body: data,
method: "PUT",
}),
delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(params.id)}`,
body: { erase: true },
method: "POST",
}),
},
rooms: {
path: "/_synapse/admin/v1/rooms",
map: (r: Room) => ({
...r,
id: r.room_id,
alias: r.canonical_alias,
members: r.joined_members,
is_encrypted: !!r.encryption,
federatable: !!r.federatable,
public: !!r.public,
}),
data: "rooms",
total: json => json.total_rooms,
delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v2/rooms/${params.id}`,
body: { block: false },
}),
},
reports: {
path: "/_synapse/admin/v1/event_reports",
map: (er: EventReport) => ({ ...er }),
data: "event_reports",
total: json => json.total,
},
devices: {
map: (d: Device) => ({
...d,
id: d.device_id,
}),
data: "devices",
total: json => json.total,
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(id)}/devices`,
}),
delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(params.previousData.user_id)}/devices/${params.id}`,
}),
},
connections: {
path: "/_synapse/admin/v1/whois",
map: (c: Whois) => ({
...c,
id: c.user_id,
}),
data: "connections",
},
room_members: {
map: (m: string) => ({
id: m,
}),
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/rooms/${id}/members`,
}),
data: "members",
total: json => json.total,
},
room_state: {
map: (rs: RoomState) => ({
...rs,
id: rs.event_id,
}),
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/rooms/${id}/state`,
}),
data: "state",
total: json => json.state.length,
},
pushers: {
map: (p: Pusher) => ({
...p,
id: p.pushkey,
}),
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/pushers`,
}),
data: "pushers",
total: json => json.total,
},
joined_rooms: {
map: (jr: string) => ({
id: jr,
}),
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/joined_rooms`,
}),
data: "joined_rooms",
total: json => json.total,
},
users_media: {
map: (um: UserMedia) => ({
...um,
id: um.media_id,
}),
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/media`,
}),
data: "media",
total: json => json.total,
delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/media/${storage.getItem("home_server")}/${params.id}`,
}),
},
protect_media: {
map: (pm: UserMedia) => ({ id: pm.media_id }),
create: (params: UserMedia) => ({
endpoint: `/_synapse/admin/v1/media/protect/${params.media_id}`,
method: "POST",
}),
delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/media/unprotect/${params.id}`,
method: "POST",
}),
},
quarantine_media: {
map: (qm: UserMedia) => ({ id: qm.media_id }),
create: (params: UserMedia) => ({
endpoint: `/_synapse/admin/v1/media/quarantine/${storage.getItem("home_server")}/${params.media_id}`,
method: "POST",
}),
delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/media/unquarantine/${storage.getItem("home_server")}/${params.id}`,
method: "POST",
}),
},
servernotices: {
map: (n: { event_id: string }) => ({ id: n.event_id }),
create: (data: RaServerNotice) => ({
endpoint: "/_synapse/admin/v1/send_server_notice",
body: {
user_id: data.id,
content: {
msgtype: "m.text",
body: data.body,
},
},
method: "POST",
}),
},
user_media_statistics: {
path: "/_synapse/admin/v1/statistics/users/media",
map: (usms: UserMediaStatistic) => ({
...usms,
id: usms.user_id,
}),
data: "users",
total: json => json.total,
},
forward_extremities: {
map: (fe: ForwardExtremity) => ({
...fe,
id: fe.event_id,
}),
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/rooms/${id}/forward_extremities`,
}),
data: "results",
total: json => json.count,
delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/rooms/${params.id}/forward_extremities`,
}),
},
room_directory: {
path: "/_matrix/client/r0/publicRooms",
map: (rd: Room) => ({
...rd,
id: rd.room_id,
public: !!rd.public,
guest_access: !!rd.guest_access,
avatar_src: rd.avatar_url ? mxcUrlToHttp(rd.avatar_url) : undefined,
}),
data: "chunk",
total: json => json.total_room_count_estimate,
create: (params: RaRecord) => ({
endpoint: `/_matrix/client/r0/directory/list/room/${params.id}`,
body: { visibility: "public" },
method: "PUT",
}),
delete: (params: DeleteParams) => ({
endpoint: `/_matrix/client/r0/directory/list/room/${params.id}`,
body: { visibility: "private" },
method: "PUT",
}),
},
destinations: {
path: "/_synapse/admin/v1/federation/destinations",
map: (dst: Destination) => ({
...dst,
id: dst.destination,
}),
data: "destinations",
total: json => json.total,
delete: params => ({
endpoint: `/_synapse/admin/v1/federation/destinations/${params.id}/reset_connection`,
method: "POST",
}),
},
destination_rooms: {
map: (dstroom: DestinationRoom) => ({
...dstroom,
id: dstroom.room_id,
}),
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/federation/destinations/${id}/rooms`,
}),
data: "rooms",
total: json => json.total,
},
registration_tokens: {
path: "/_synapse/admin/v1/registration_tokens",
map: (rt: RegistrationToken) => ({
...rt,
id: rt.token,
}),
data: "registration_tokens",
total: json => json.registration_tokens.length,
create: (params: RaRecord) => ({
endpoint: "/_synapse/admin/v1/registration_tokens/new",
body: params,
method: "POST",
}),
delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/registration_tokens/${params.id}`,
}),
},
};
/* eslint-disable @typescript-eslint/no-explicit-any */
function filterNullValues(key: string, value: any) {
// Filtering out null properties
// to reset user_type from user, it must be null
if (value === null && key !== "user_type") {
return undefined;
}
return value;
}
function getSearchOrder(order: "ASC" | "DESC") {
if (order === "DESC") {
return "b";
} else {
return "f";
}
}
const dataProvider: SynapseDataProvider = {
getList: async (resource, params) => {
console.log("getList " + resource);
const { user_id, name, guests, deactivated, locked, search_term, destination, valid } = params.filter;
const { page, perPage } = params.pagination as PaginationPayload;
const { field, order } = params.sort as SortPayload;
const from = (page - 1) * perPage;
const query = {
from: from,
limit: perPage,
user_id: user_id,
search_term: search_term,
name: name,
destination: destination,
guests: guests,
deactivated: deactivated,
locked: locked,
valid: valid,
order_by: field,
dir: getSearchOrder(order),
};
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const url = `${endpoint_url}?${stringify(query)}`;
const { json } = await jsonClient(url);
return {
data: json[res.data].map(res.map),
total: res.total(json, from, perPage),
};
},
getOne: async (resource, params) => {
console.log("getOne " + resource);
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const { json } = await jsonClient(`${endpoint_url}/${encodeURIComponent(params.id)}`);
return { data: res.map(json) };
},
getMany: async (resource, params) => {
console.log("getMany " + resource);
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homerserver not set");
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const responses = await Promise.all(params.ids.map(id => jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`)));
return {
data: responses.map(({ json }) => res.map(json)),
total: responses.length,
};
},
getManyReference: async (resource, params) => {
console.log("getManyReference " + resource);
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
const from = (page - 1) * perPage;
const query = {
from: from,
limit: perPage,
order_by: field,
dir: getSearchOrder(order),
};
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
const ref = res.reference(params.id);
const endpoint_url = `${homeserver}${ref.endpoint}?${stringify(query)}`;
const { json } = await jsonClient(endpoint_url);
return {
data: json[res.data].map(res.map),
total: res.total(json, from, perPage),
};
},
update: async (resource, params) => {
console.log("update " + resource);
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const { json } = await jsonClient(`${endpoint_url}/${encodeURIComponent(params.id)}`, {
method: "PUT",
body: JSON.stringify(params.data, filterNullValues),
});
return { data: res.map(json) };
},
updateMany: async (resource, params) => {
console.log("updateMany " + resource);
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const responses = await Promise.all(
params.ids.map(id => jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`), {
method: "PUT",
body: JSON.stringify(params.data, filterNullValues),
})
);
return { data: responses.map(({ json }) => json) };
},
create: async (resource, params) => {
console.log("create " + resource);
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
if (!("create" in res)) return Promise.reject();
const create = res.create(params.data);
const endpoint_url = homeserver + create.endpoint;
const { json } = await jsonClient(endpoint_url, {
method: create.method,
body: JSON.stringify(create.body, filterNullValues),
});
return { data: res.map(json) };
},
createMany: async (resource: string, params: { ids: Identifier[]; data: RaRecord }) => {
console.log("createMany " + resource);
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
if (!("create" in res)) throw Error(`Create ${resource} is not allowed`);
const responses = await Promise.all(
params.ids.map(id => {
params.data.id = id;
const cre = res.create(params.data);
const endpoint_url = homeserver + cre.endpoint;
return jsonClient(endpoint_url, {
method: cre.method,
body: JSON.stringify(cre.body, filterNullValues),
});
})
);
return { data: responses.map(({ json }) => json) };
},
delete: async (resource, params) => {
console.log("delete " + resource);
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
if ("delete" in res) {
const del = res.delete(params);
const endpoint_url = homeserver + del.endpoint;
const { json } = await jsonClient(endpoint_url, {
method: "method" in del ? del.method : "DELETE",
body: "body" in del ? JSON.stringify(del.body) : null,
});
return { data: json };
} else {
const endpoint_url = homeserver + res.path;
const { json } = await jsonClient(`${endpoint_url}/${params.id}`, {
method: "DELETE",
body: JSON.stringify(params.previousData, filterNullValues),
});
return { data: json };
}
},
deleteMany: async (resource, params) => {
console.log("deleteMany " + resource);
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
if ("delete" in res) {
const responses = await Promise.all(
params.ids.map(id => {
const del = res.delete({ ...params, id: id });
const endpoint_url = homeserver + del.endpoint;
return jsonClient(endpoint_url, {
method: "method" in del ? del.method : "DELETE",
body: "body" in del ? JSON.stringify(del.body) : null,
});
})
);
return {
data: responses.map(({ json }) => json),
};
} else {
const endpoint_url = homeserver + res.path;
const responses = await Promise.all(
params.ids.map(id =>
jsonClient(`${endpoint_url}/${id}`, {
method: "DELETE",
// body: JSON.stringify(params.data, filterNullValues), @FIXME
})
)
);
return { data: responses.map(({ json }) => json) };
}
},
// Custom methods (https://marmelab.com/react-admin/DataProviders.html#adding-custom-methods)
/**
* Delete media by date or size
*
* @link https://matrix-org.github.io/synapse/latest/admin_api/media_admin_api.html#delete-local-media-by-date-or-size
*
* @param before_ts Unix timestamp in milliseconds. Files that were last used before this timestamp will be deleted. It is the timestamp of last access, not the timestamp when the file was created.
* @param size_gt Size of the media in bytes. Files that are larger will be deleted.
* @param keep_profiles Switch to also delete files that are still used in image data (e.g user profile, room avatar). If false these files will be deleted.
* @returns
*/
deleteMedia: async ({ before_ts, size_gt = 0, keep_profiles = true }) => {
const homeserver = storage.getItem("home_server"); // TODO only required for synapse < 1.78.0
const endpoint = `/_synapse/admin/v1/media/${homeserver}/delete?before_ts=${before_ts}&size_gt=${size_gt}&keep_profiles=${keep_profiles}`;
const base_url = storage.getItem("base_url");
const endpoint_url = base_url + endpoint;
const { json } = await jsonClient(endpoint_url, { method: "POST" });
return json as DeleteMediaResult;
},
};
export default dataProvider;

View File

@ -0,0 +1,23 @@
import { isValidBaseUrl, splitMxid } from "./synapse";
describe("splitMxid", () => {
it("splits valid MXIDs", () =>
expect(splitMxid("@name:domain.tld")).toEqual({
name: "name",
domain: "domain.tld",
}));
it("rejects invalid MXIDs", () => expect(splitMxid("foo")).toBeUndefined());
});
describe("isValidBaseUrl", () => {
it("accepts a http URL", () => expect(isValidBaseUrl("http://foo.bar")).toBeTruthy());
it("accepts a https URL", () => expect(isValidBaseUrl("https://foo.bar")).toBeTruthy());
it("accepts a valid URL with port", () => expect(isValidBaseUrl("https://foo.bar:1234")).toBeTruthy());
it("rejects undefined base URLs", () => expect(isValidBaseUrl(undefined)).toBeFalsy());
it("rejects null base URLs", () => expect(isValidBaseUrl(null)).toBeFalsy());
it("rejects empty base URLs", () => expect(isValidBaseUrl("")).toBeFalsy());
it("rejects non-string base URLs", () => expect(isValidBaseUrl({})).toBeFalsy());
it("rejects base URLs without protocol", () => expect(isValidBaseUrl("foo.bar")).toBeFalsy());
it("rejects base URLs with path", () => expect(isValidBaseUrl("http://foo.bar/path")).toBeFalsy());
it("rejects invalid base URLs", () => expect(isValidBaseUrl("http:/foo.bar")).toBeFalsy());
});

84
src/synapse/synapse.ts Normal file
View File

@ -0,0 +1,84 @@
import { fetchUtils } from "react-admin";
import storage from "../storage";
export const splitMxid = mxid => {
const re = /^@(?<name>[a-zA-Z0-9._=\-/]+):(?<domain>[a-zA-Z0-9\-.]+\.[a-zA-Z]+)$/;
return re.exec(mxid)?.groups;
};
export const isValidBaseUrl = baseUrl => /^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?$/.test(baseUrl);
/**
* Resolve the homeserver URL using the well-known lookup
* @param domain the domain part of an MXID
* @returns homeserver base URL
*/
export const getWellKnownUrl = async domain => {
const wellKnownUrl = `https://${domain}/.well-known/matrix/client`;
try {
const response = await fetchUtils.fetchJson(wellKnownUrl, { method: "GET" });
return response.json["m.homeserver"].base_url;
} catch {
// if there is no .well-known entry, return the domain itself
return `https://${domain}`;
}
};
/**
* Get synapse server version
* @param base_url the base URL of the homeserver
* @returns server version
*/
export const getServerVersion = async baseUrl => {
const versionUrl = `${baseUrl}/_synapse/admin/v1/server_version`;
const response = await fetchUtils.fetchJson(versionUrl, { method: "GET" });
return response.json.server_version;
};
/** Get supported Matrix features */
export const getSupportedFeatures = async baseUrl => {
const versionUrl = `${baseUrl}/_matrix/client/versions`;
const response = await fetchUtils.fetchJson(versionUrl, { method: "GET" });
return response.json;
};
/**
* Get supported login flows
* @param baseUrl the base URL of the homeserver
* @returns array of supported login flows
*/
export const getSupportedLoginFlows = async baseUrl => {
const loginFlowsUrl = `${baseUrl}/_matrix/client/r0/login`;
const response = await fetchUtils.fetchJson(loginFlowsUrl, { method: "GET" });
return response.json.flows;
};
export const getMediaUrl = media_id => {
const baseUrl = storage.getItem("base_url");
return `${baseUrl}/_matrix/media/v1/download/${media_id}?allow_redirect=true`;
};
/**
* Generate a random MXID for current homeserver
* @returns full MXID as string
*/
export function generateRandomMxId(): string {
const homeserver = storage.getItem("home_server");
const characters = "0123456789abcdefghijklmnopqrstuvwxyz";
const localpart = Array.from(crypto.getRandomValues(new Uint32Array(8)))
.map(x => characters[x % characters.length])
.join("");
return `@${localpart}:${homeserver}`;
}
/**
* Generate a random user password
* @returns a new random password as string
*/
export function generateRandomPassword(length = 20): string {
const characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~!@-#$";
return Array.from(crypto.getRandomValues(new Uint32Array(length)))
.map(x => characters[x % characters.length])
.join("");
}

4
tsconfig.eslint.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["./**/*.ts", "./**/*.tsx"]
}

64
tsconfig.json Normal file
View File

@ -0,0 +1,64 @@
// prettier-ignore
{
"compilerOptions": {
/* Basic Options */
"target": "ESNext" /* Specify ECMAScript target version */,
"module": "ESNext" /* Specify module code generation */,
"lib": ["DOM", "DOM.Iterable", "ESNext"] /* Specify library files to be included in the compilation. */,
"allowJs": false /* Allow javascript files to be compiled. */,
// "checkJs": true, /* Report errors in .js files. */
"jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
"declaration": true /* Generates corresponding '.d.ts' file. */,
"declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */,
"sourceMap": true /* Generates corresponding '.map' file. */,
// "outFile": "./", /* Concatenate and emit output to single file. */
// "outDir": "./lib", /* Redirect output structure to the directory. */
"rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "removeComments": true, /* Do not emit comments to output. */
"noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
"isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true /* Enable all strict type-checking options. */,
"noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */,
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
"noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
/* Module Resolution Options */
"moduleResolution": "Bundler" /* Specify module resolution strategy */,
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
"types": ["vite/client"], /* Type declaration files to be included in compilation. */
"allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */,
"esModuleInterop": false /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
"resolveJsonModule": true,
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
"skipLibCheck": false
},
"include": ["src"],
"references": [{ "path": "./tsconfig.vite.json" }]
}

8
tsconfig.vite.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"composite": true,
"module": "esnext",
"moduleResolution": "node"
},
"include": ["vite.config.ts"]
}

17
vite.config.ts Normal file
View File

@ -0,0 +1,17 @@
import { vitePluginVersionMark } from "vite-plugin-version-mark";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
base: "./",
plugins: [
react(),
vitePluginVersionMark({
command: "git describe --tags",
ifMeta: true,
ifLog: true,
ifGlobal: true,
}),
],
});

20374
yarn.lock

File diff suppressed because it is too large Load Diff