Compare commits

..

19 commits

Author SHA1 Message Date
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
53 changed files with 2329 additions and 3555 deletions

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

View file

@ -17,5 +17,7 @@ jobs:
node-version: "18" node-version: "18"
- name: Install dependencies - name: Install dependencies
run: yarn --immutable run: yarn --immutable
- name: Run checks
run: yarn lint
- name: Run tests - name: Run tests
run: yarn test run: yarn test

View file

@ -1 +1,2 @@
.vscode
.yarn .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

@ -13,7 +13,7 @@ This project is built using [react-admin](https://marmelab.com/react-admin/).
### Supported Synapse ### Supported Synapse
It needs at least [Synapse](https://github.com/element-hq/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`. You get your server version with the request `/_synapse/admin/v1/server_version`.
See also [Synapse version API](https://element-hq.github.io/synapse/latest/admin_api/version_api.html). See also [Synapse version API](https://element-hq.github.io/synapse/latest/admin_api/version_api.html).
@ -104,10 +104,7 @@ or to a list of homeservers:
```json ```json
{ {
"restrictBaseUrl": [ "restrictBaseUrl": ["https://your-first-matrix-server.example.com", "https://your-second-matrix-server.example.com"]
"https://your-first-matrix-server.example.com",
"https://your-second-matrix-server.example.com"
]
} }
``` ```
@ -166,5 +163,6 @@ services:
## Development ## Development
- See https://yarnpkg.com/getting-started/editor-sdks how to setup your IDE - See https://yarnpkg.com/getting-started/editor-sdks how to setup your IDE
- Use `yarn test` to run all style, lint and unit tests - 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 - Use `yarn fix` to fix the coding style

View file

@ -119,7 +119,7 @@
<div class="loader">Loading...</div> <div class="loader">Loading...</div>
</div> </div>
</div> </div>
<script type="module" src="/src/index.jsx"></script> <script type="module" src="/src/index.tsx"></script>
<footer <footer
style="position: relative; z-index: 2; height: 2em; margin-top: -2em; line-height: 2em; background-color: #eee; border: 0.5px solid #ddd"> 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" <a id="copyright" href="https://github.com/Awesome-Technologies/synapse-admin"

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: "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

@ -12,24 +12,35 @@
}, },
"packageManager": "yarn@4.1.1", "packageManager": "yarn@4.1.1",
"devDependencies": { "devDependencies": {
"@babel/core": "^7.24.4", "@eslint/js": "^9.1.1",
"@babel/preset-env": "^7.24.4",
"@babel/preset-react": "^7.24.1",
"@testing-library/dom": "^10.0.0", "@testing-library/dom": "^10.0.0",
"@testing-library/jest-dom": "^6.0.0", "@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^15.0.2", "@testing-library/react": "^15.0.2",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.0",
"@types/node": "^20.12.7",
"@types/papaparse": "^5.3.14",
"@types/react": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^7.7.1",
"@typescript-eslint/parser": "^7.8.0",
"@vitejs/plugin-react": "^4.0.0", "@vitejs/plugin-react": "^4.0.0",
"babel-jest": "^29.7.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-config-react-app": "^7.0.1", "eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unused-imports": "^3.2.0",
"eslint-plugin-yaml": "^0.5.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3", "jest-fetch-mock": "^3.0.3",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"react-test-renderer": "^18.2.0", "react-test-renderer": "^18.3.1",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"typescript": "^5.4.5",
"typescript-eslint": "^7.8.0",
"vite": "^5.0.0", "vite": "^5.0.0",
"vite-plugin-version-mark": "^0.0.13" "vite-plugin-version-mark": "^0.0.13"
}, },
@ -38,58 +49,103 @@
"@emotion/styled": "^11.3.0", "@emotion/styled": "^11.3.0",
"@haleos/ra-language-german": "^1.0.0", "@haleos/ra-language-german": "^1.0.0",
"@haxqer/ra-language-chinese": "^4.16.2", "@haxqer/ra-language-chinese": "^4.16.2",
"@mui/icons-material": "^5.15.15", "@mui/icons-material": "^5.15.16",
"@mui/material": "^5.15.15", "@mui/material": "^5.15.16",
"history": "^5.1.0", "history": "^5.1.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"query-string": "^7.1.1", "query-string": "^7.1.1",
"ra-core": "^4.16.15", "ra-core": "^4.16.17",
"ra-i18n-polyglot": "^4.16.15", "ra-i18n-polyglot": "^4.16.17",
"ra-language-english": "^4.16.15", "ra-language-english": "^4.16.17",
"ra-language-farsi": "^4.2.0", "ra-language-farsi": "^4.2.0",
"ra-language-french": "^4.16.15", "ra-language-french": "^4.16.17",
"ra-language-italian": "^3.13.1", "ra-language-italian": "^3.13.1",
"react": "^18.0.0", "react": "^18.3.1",
"react-admin": "^4.16.15", "react-admin": "^4.16.17",
"react-dom": "^18.0.0", "react-dom": "^18.3.1",
"react-hook-form": "^7.43.9", "react-hook-form": "^7.43.9",
"react-is": "^18.2.0", "react-is": "^18.3.1",
"react-query": "^3.32.1", "react-query": "^3.32.1",
"react-router": "^6.1.0", "react-router": "^6.23.0",
"react-router-dom": "^6.1.0" "react-router-dom": "^6.23.0"
}, },
"scripts": { "scripts": {
"start": "vite serve", "start": "vite serve",
"build": "vite build", "build": "vite build",
"fix:other": "yarn prettier --write", "lint": "eslint --ignore-path .gitignore --ext .ts,.tsx,.yml,.yaml .",
"fix:code": "yarn test:lint --fix", "fix": "yarn lint --fix",
"fix": "yarn fix:code && yarn fix:other", "test": "yarn jest",
"prettier": "prettier \"**/*.{js,jsx,json,md,scss,yaml,yml}\"", "test:watch": "yarn jest --watch"
"test:code": "jest",
"test:lint": "eslint --ignore-path .gitignore --ext .js,.jsx .",
"test:style": "yarn prettier --check",
"test": "yarn test:style && yarn test:lint && yarn test:code"
},
"babel": {
"presets": [
"@babel/preset-env",
[
"@babel/preset-react",
{
"runtime": "automatic"
}
]
]
}, },
"eslintConfig": { "eslintConfig": {
"extends": "react-app" "env": {
"browser": true
}, },
"jest": { "plugins": [
"testEnvironment": "jsdom", "import",
"setupFilesAfterEnv": [ "prettier",
"<rootDir>/src/setupTests.js" "unused-imports",
"@typescript-eslint",
"yaml"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/stylistic",
"plugin:import/typescript",
"plugin:yaml/recommended"
],
"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": { "browserslist": {
"production": [ "production": [

View file

@ -1,5 +1,5 @@
import React from "react";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import App from "./App"; import App from "./App";
describe("App", () => { describe("App", () => {

View file

@ -1,29 +1,25 @@
import React from "react"; import { merge } from "lodash";
import {
Admin,
CustomRoutes,
Resource,
resolveBrowserLocale,
} from "react-admin";
import polyglotI18nProvider from "ra-i18n-polyglot"; import polyglotI18nProvider from "ra-i18n-polyglot";
import merge from "lodash/merge";
import authProvider from "./synapse/authProvider"; import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin";
import dataProvider from "./synapse/dataProvider"; import { Route } from "react-router-dom";
import users from "./components/users";
import rooms from "./components/rooms";
import userMediaStats from "./components/statistics";
import reports from "./components/EventReports"; import reports from "./components/EventReports";
import { ImportFeature } from "./components/ImportFeature";
import LoginPage from "./components/LoginPage";
import registrationToken from "./components/RegistrationTokens";
import roomDirectory from "./components/RoomDirectory"; import roomDirectory from "./components/RoomDirectory";
import destinations from "./components/destinations"; import destinations from "./components/destinations";
import registrationToken from "./components/RegistrationTokens"; import rooms from "./components/rooms";
import LoginPage from "./components/LoginPage"; import userMediaStats from "./components/statistics";
import { ImportFeature } from "./components/ImportFeature"; import users from "./components/users";
import { Route } from "react-router-dom";
import germanMessages from "./i18n/de"; import germanMessages from "./i18n/de";
import englishMessages from "./i18n/en"; import englishMessages from "./i18n/en";
import frenchMessages from "./i18n/fr"; import frenchMessages from "./i18n/fr";
import chineseMessages from "./i18n/zh";
import italianMessages from "./i18n/it"; import italianMessages from "./i18n/it";
import chineseMessages from "./i18n/zh";
import authProvider from "./synapse/authProvider";
import dataProvider from "./synapse/dataProvider";
// TODO: Can we use lazy loading together with browser locale? // TODO: Can we use lazy loading together with browser locale?
const messages = { const messages = {
@ -34,8 +30,7 @@ const messages = {
zh: chineseMessages, zh: chineseMessages,
}; };
const i18nProvider = polyglotI18nProvider( const i18nProvider = polyglotI18nProvider(
locale => locale => (messages[locale] ? merge({}, messages.en, messages[locale]) : messages.en),
messages[locale] ? merge({}, messages.en, messages[locale]) : messages.en,
resolveBrowserLocale(), resolveBrowserLocale(),
[ [
{ locale: "en", name: "English" }, { locale: "en", name: "English" },

View file

@ -1,5 +0,0 @@
import { createContext, useContext } from "react";
export const AppContext = createContext({});
export const useAppContext = () => useContext(AppContext);

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

@ -1,6 +1,6 @@
import React from "react";
import { RecordContextProvider } from "react-admin";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { RecordContextProvider } from "react-admin";
import AvatarField from "./AvatarField"; import AvatarField from "./AvatarField";
describe("AvatarField", () => { describe("AvatarField", () => {

View file

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

View file

@ -1,13 +1,18 @@
import React from "react"; import PageviewIcon from "@mui/icons-material/Pageview";
import ViewListIcon from "@mui/icons-material/ViewList";
import ReportIcon from "@mui/icons-material/Warning";
import { import {
Datagrid, Datagrid,
DateField, DateField,
DeleteButton, DeleteButton,
List, List,
ListProps,
NumberField, NumberField,
Pagination, Pagination,
ReferenceField, ReferenceField,
ResourceProps,
Show, Show,
ShowProps,
Tab, Tab,
TabbedShowLayout, TabbedShowLayout,
TextField, TextField,
@ -15,25 +20,13 @@ import {
useRecordContext, useRecordContext,
useTranslate, useTranslate,
} from "react-admin"; } from "react-admin";
import { DATE_FORMAT } from "./date";
import { MXCField } from "./media"; import { MXCField } from "./media";
import PageviewIcon from "@mui/icons-material/Pageview";
import ReportIcon from "@mui/icons-material/Warning";
import ViewListIcon from "@mui/icons-material/ViewList";
const date_format = { const ReportPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const ReportPagination = () => ( export const ReportShow = (props: ShowProps) => {
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
export const ReportShow = props => {
const translate = useTranslate(); const translate = useTranslate();
return ( return (
<Show {...props} actions={<ReportShowActions />}> <Show {...props} actions={<ReportShowActions />}>
@ -44,43 +37,21 @@ export const ReportShow = props => {
})} })}
icon={<ViewListIcon />} icon={<ViewListIcon />}
> >
<DateField <DateField source="received_ts" showTime options={DATE_FORMAT} sortable={true} />
source="received_ts"
showTime
options={date_format}
sortable={true}
/>
<ReferenceField source="user_id" reference="users"> <ReferenceField source="user_id" reference="users">
<TextField source="id" /> <TextField source="id" />
</ReferenceField> </ReferenceField>
<NumberField source="score" /> <NumberField source="score" />
<TextField source="reason" /> <TextField source="reason" />
<TextField source="name" /> <TextField source="name" />
<TextField <TextField source="canonical_alias" label="resources.rooms.fields.canonical_alias" />
source="canonical_alias" <ReferenceField source="room_id" reference="rooms" link="show" label="resources.rooms.fields.room_id">
label="resources.rooms.fields.canonical_alias"
/>
<ReferenceField
source="room_id"
reference="rooms"
link="show"
label="resources.rooms.fields.room_id"
>
<TextField source="id" /> <TextField source="id" />
</ReferenceField> </ReferenceField>
</Tab> </Tab>
<Tab <Tab label="synapseadmin.reports.tabs.detail" icon={<PageviewIcon />} path="detail">
label="synapseadmin.reports.tabs.detail" <DateField source="event_json.origin_server_ts" showTime options={DATE_FORMAT} sortable={true} />
icon={<PageviewIcon />}
path="detail"
>
<DateField
source="event_json.origin_server_ts"
showTime
options={date_format}
sortable={true}
/>
<ReferenceField source="sender" reference="users"> <ReferenceField source="sender" reference="users">
<TextField source="id" /> <TextField source="id" />
</ReferenceField> </ReferenceField>
@ -95,10 +66,7 @@ export const ReportShow = props => {
<TextField source="event_json.content.format" /> <TextField source="event_json.content.format" />
<TextField source="event_json.content.formatted_body" /> <TextField source="event_json.content.formatted_body" />
<TextField source="event_json.content.algorithm" /> <TextField source="event_json.content.algorithm" />
<TextField <TextField source="event_json.content.device_id" label="resources.devices.fields.device_id" />
source="event_json.content.device_id"
label="resources.devices.fields.device_id"
/>
</Tab> </Tab>
</TabbedShowLayout> </TabbedShowLayout>
</Show> </Show>
@ -120,20 +88,11 @@ const ReportShowActions = () => {
); );
}; };
export const ReportList = props => ( export const ReportList = (props: ListProps) => (
<List <List {...props} pagination={<ReportPagination />} sort={{ field: "received_ts", order: "DESC" }}>
{...props}
pagination={<ReportPagination />}
sort={{ field: "received_ts", order: "DESC" }}
>
<Datagrid rowClick="show" bulkActionButtons={false}> <Datagrid rowClick="show" bulkActionButtons={false}>
<TextField source="id" sortable={false} /> <TextField source="id" sortable={false} />
<DateField <DateField source="received_ts" showTime options={DATE_FORMAT} sortable={true} />
source="received_ts"
showTime
options={date_format}
sortable={true}
/>
<TextField sortable={false} source="user_id" /> <TextField sortable={false} source="user_id" />
<TextField sortable={false} source="name" /> <TextField sortable={false} source="name" />
<TextField sortable={false} source="score" /> <TextField sortable={false} source="score" />
@ -141,7 +100,7 @@ export const ReportList = props => (
</List> </List>
); );
const resource = { const resource: ResourceProps = {
name: "reports", name: "reports",
icon: ReportIcon, icon: ReportIcon,
list: ReportList, list: ReportList,

View file

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

View file

@ -1,71 +0,0 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { AdminContext } from "react-admin";
import LoginPage from "./LoginPage";
import { AppContext } from "../AppContext";
describe("LoginForm", () => {
it("renders with no restriction to homeserver", () => {
render(
<AdminContext>
<LoginPage />
</AdminContext>
);
screen.getByText("synapseadmin.auth.welcome");
screen.getByRole("combobox", { name: "" });
screen.getByRole("textbox", { name: "ra.auth.username" });
screen.getByText("ra.auth.password");
const baseUrlInput = screen.getByRole("textbox", {
name: "synapseadmin.auth.base_url",
});
expect(baseUrlInput.className.split(" ")).not.toContain("Mui-readOnly");
screen.getByRole("button", { name: "ra.auth.sign_in" });
});
it("renders with single restricted homeserver", () => {
render(
<AppContext.Provider
value={{ restrictBaseUrl: "https://matrix.example.com" }}
>
<AdminContext>
<LoginPage />
</AdminContext>
</AppContext.Provider>
);
screen.getByText("synapseadmin.auth.welcome");
screen.getByRole("combobox", { name: "" });
screen.getByRole("textbox", { name: "ra.auth.username" });
screen.getByText("ra.auth.password");
const baseUrlInput = screen.getByRole("textbox", {
name: "synapseadmin.auth.base_url",
});
expect(baseUrlInput.className.split(" ")).toContain("Mui-readOnly");
screen.getByRole("button", { name: "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>
<LoginPage />
</AdminContext>
</AppContext.Provider>
);
screen.getByText("synapseadmin.auth.welcome");
screen.getByRole("combobox", { name: "" });
screen.getByRole("textbox", { name: "ra.auth.username" });
screen.getByText("ra.auth.password");
screen.getByRole("combobox", { name: "synapseadmin.auth.base_url" });
screen.getByRole("button", { name: "ra.auth.sign_in" });
});
});

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 });
});
});

View file

@ -1,4 +1,8 @@
import React, { useState, useEffect } from "react"; 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 { import {
Form, Form,
FormDataConsumer, FormDataConsumer,
@ -13,19 +17,6 @@ import {
useLocales, useLocales,
} from "react-admin"; } from "react-admin";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import {
Avatar,
Box,
Button,
Card,
CardActions,
CircularProgress,
MenuItem,
Select,
Typography,
} from "@mui/material";
import { styled } from "@mui/material/styles";
import LockIcon from "@mui/icons-material/Lock";
import { useAppContext } from "../AppContext"; import { useAppContext } from "../AppContext";
import { import {
@ -103,9 +94,7 @@ const LoginPage = () => {
const [locale, setLocale] = useLocaleState(); const [locale, setLocale] = useLocaleState();
const locales = useLocales(); const locales = useLocales();
const translate = useTranslate(); const translate = useTranslate();
const base_url = allowSingleBaseUrl const base_url = allowSingleBaseUrl ? restrictBaseUrl : localStorage.getItem("base_url");
? restrictBaseUrl
: localStorage.getItem("base_url");
const [ssoBaseUrl, setSSOBaseUrl] = useState(""); const [ssoBaseUrl, setSSOBaseUrl] = useState("");
const loginToken = /\?loginToken=([a-zA-Z0-9_-]+)/.exec(window.location.href); const loginToken = /\?loginToken=([a-zA-Z0-9_-]+)/.exec(window.location.href);
@ -113,11 +102,7 @@ const LoginPage = () => {
const ssoToken = loginToken[1]; const ssoToken = loginToken[1];
console.log("SSO token is", ssoToken); console.log("SSO token is", ssoToken);
// Prevent further requests // Prevent further requests
window.history.replaceState( window.history.replaceState({}, "", window.location.href.replace(loginToken[0], "#").split("#")[0]);
{},
"",
window.location.href.replace(loginToken[0], "#").split("#")[0]
);
const baseUrl = localStorage.getItem("sso_base_url"); const baseUrl = localStorage.getItem("sso_base_url");
localStorage.removeItem("sso_base_url"); localStorage.removeItem("sso_base_url");
if (baseUrl) { if (baseUrl) {
@ -146,9 +131,7 @@ const LoginPage = () => {
const validateBaseUrl = value => { const validateBaseUrl = value => {
if (!value.match(/^(http|https):\/\//)) { if (!value.match(/^(http|https):\/\//)) {
return translate("synapseadmin.auth.protocol_error"); return translate("synapseadmin.auth.protocol_error");
} else if ( } else if (!value.match(/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?[^?&\s]*$/)) {
!value.match(/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?[^?&\s]*$/)
) {
return translate("synapseadmin.auth.url_error"); return translate("synapseadmin.auth.url_error");
} else { } else {
return undefined; return undefined;
@ -183,16 +166,13 @@ const LoginPage = () => {
const [serverVersion, setServerVersion] = useState(""); const [serverVersion, setServerVersion] = useState("");
const [matrixVersions, setMatrixVersions] = useState(""); const [matrixVersions, setMatrixVersions] = useState("");
const handleUsernameChange = _ => { const handleUsernameChange = () => {
if (formData.base_url || allowSingleBaseUrl) return; if (formData.base_url || allowSingleBaseUrl) return;
// check if username is a full qualified userId then set base_url accordingly // check if username is a full qualified userId then set base_url accordingly
const domain = splitMxid(formData.username)?.domain; const domain = splitMxid(formData.username)?.domain;
if (domain) { if (domain) {
getWellKnownUrl(domain).then(url => { getWellKnownUrl(domain).then(url => {
if ( if (allowAnyBaseUrl || (allowMultipleBaseUrls && restrictBaseUrl.includes(url)))
allowAnyBaseUrl ||
(allowMultipleBaseUrls && restrictBaseUrl.includes(url))
)
form.setValue("base_url", url); form.setValue("base_url", url);
}); });
} }
@ -205,28 +185,20 @@ const LoginPage = () => {
if (!isValidBaseUrl(formData.base_url)) return; if (!isValidBaseUrl(formData.base_url)) return;
getServerVersion(formData.base_url) getServerVersion(formData.base_url)
.then(serverVersion => .then(serverVersion => setServerVersion(`${translate("synapseadmin.auth.server_version")} ${serverVersion}`))
setServerVersion(
`${translate("synapseadmin.auth.server_version")} ${serverVersion}`
)
)
.catch(() => setServerVersion("")); .catch(() => setServerVersion(""));
getSupportedFeatures(formData.base_url) getSupportedFeatures(formData.base_url)
.then(features => .then(features =>
setMatrixVersions( setMatrixVersions(`${translate("synapseadmin.auth.supports_specs")} ${features.versions.join(", ")}`)
`${translate("synapseadmin.auth.supports_specs")} ${features.versions.join(", ")}`
)
) )
.catch(() => setMatrixVersions("")); .catch(() => setMatrixVersions(""));
// Set SSO Url // Set SSO Url
getSupportedLoginFlows(formData.base_url) getSupportedLoginFlows(formData.base_url)
.then(loginFlows => { .then(loginFlows => {
const supportPass = const supportPass = loginFlows.find(f => f.type === "m.login.password") !== undefined;
loginFlows.find(f => f.type === "m.login.password") !== undefined; const supportSSO = loginFlows.find(f => f.type === "m.login.sso") !== undefined;
const supportSSO =
loginFlows.find(f => f.type === "m.login.sso") !== undefined;
setSupportPassAuth(supportPass); setSupportPassAuth(supportPass);
setSSOBaseUrl(supportSSO ? formData.base_url : ""); setSSOBaseUrl(supportSSO ? formData.base_url : "");
}) })
@ -238,7 +210,7 @@ const LoginPage = () => {
<Box> <Box>
<TextInput <TextInput
autoFocus autoFocus
name="username" source="username"
label="ra.auth.username" label="ra.auth.username"
autoComplete="username" autoComplete="username"
disabled={loading || !supportPassAuth} disabled={loading || !supportPassAuth}
@ -250,7 +222,7 @@ const LoginPage = () => {
</Box> </Box>
<Box> <Box>
<PasswordInput <PasswordInput
name="password" source="password"
label="ra.auth.password" label="ra.auth.password"
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
@ -262,7 +234,7 @@ const LoginPage = () => {
</Box> </Box>
<Box> <Box>
<TextInput <TextInput
name="base_url" source="base_url"
label="synapseadmin.auth.base_url" label="synapseadmin.auth.base_url"
select={allowMultipleBaseUrls} select={allowMultipleBaseUrls}
autoComplete="url" autoComplete="url"
@ -287,11 +259,7 @@ const LoginPage = () => {
}; };
return ( return (
<Form <Form defaultValues={{ base_url: base_url }} onSubmit={handleSubmit} mode="onTouched">
defaultValues={{ base_url: base_url }}
onSubmit={handleSubmit}
mode="onTouched"
>
<FormBox> <FormBox>
<Card className="card"> <Card className="card">
<Box className="avatar"> <Box className="avatar">
@ -318,9 +286,7 @@ const LoginPage = () => {
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
<FormDataConsumer> <FormDataConsumer>{formDataProps => <UserData {...formDataProps} />}</FormDataConsumer>
{formDataProps => <UserData {...formDataProps} />}
</FormDataConsumer>
<CardActions className="actions"> <CardActions className="actions">
<Button <Button
variant="contained" variant="contained"

View file

@ -1,62 +1,37 @@
import React from "react"; import RegistrationTokenIcon from "@mui/icons-material/ConfirmationNumber";
import { import {
BooleanInput, BooleanInput,
Create, Create,
CreateProps,
Datagrid, Datagrid,
DateField, DateField,
DateTimeInput, DateTimeInput,
Edit, Edit,
EditProps,
List, List,
ListProps,
maxValue, maxValue,
number, number,
NumberField, NumberField,
NumberInput, NumberInput,
regex, regex,
ResourceProps,
SaveButton, SaveButton,
SimpleForm, SimpleForm,
TextInput, TextInput,
TextField, TextField,
Toolbar, Toolbar,
} from "react-admin"; } from "react-admin";
import RegistrationTokenIcon from "@mui/icons-material/ConfirmationNumber";
const date_format = { import { DATE_FORMAT, dateFormatter, dateParser } from "./date";
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 validateToken = [regex(/^[A-Za-z0-9._~-]{0,64}$/)];
const validateUsesAllowed = [number()]; const validateUsesAllowed = [number()];
const validateLength = [number(), maxValue(64)]; 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 registrationTokenFilters = [<BooleanInput source="valid" alwaysOn />]; const registrationTokenFilters = [<BooleanInput source="valid" alwaysOn />];
export const RegistrationTokenList = props => ( export const RegistrationTokenList = (props: ListProps) => (
<List <List
{...props} {...props}
filters={registrationTokenFilters} filters={registrationTokenFilters}
@ -69,17 +44,12 @@ export const RegistrationTokenList = props => (
<NumberField source="uses_allowed" sortable={false} /> <NumberField source="uses_allowed" sortable={false} />
<NumberField source="pending" sortable={false} /> <NumberField source="pending" sortable={false} />
<NumberField source="completed" sortable={false} /> <NumberField source="completed" sortable={false} />
<DateField <DateField source="expiry_time" showTime options={DATE_FORMAT} sortable={false} />
source="expiry_time"
showTime
options={date_format}
sortable={false}
/>
</Datagrid> </Datagrid>
</List> </List>
); );
export const RegistrationTokenCreate = props => ( export const RegistrationTokenCreate = (props: CreateProps) => (
<Create {...props} redirect="list"> <Create {...props} redirect="list">
<SimpleForm <SimpleForm
toolbar={ toolbar={
@ -89,49 +59,32 @@ export const RegistrationTokenCreate = props => (
</Toolbar> </Toolbar>
} }
> >
<TextInput <TextInput source="token" autoComplete="off" validate={validateToken} resettable />
source="token"
autoComplete="off"
validate={validateToken}
resettable
/>
<NumberInput <NumberInput
source="length" source="length"
validate={validateLength} validate={validateLength}
helperText="resources.registration_tokens.helper.length" helperText="resources.registration_tokens.helper.length"
step={1} step={1}
/> />
<NumberInput <NumberInput source="uses_allowed" validate={validateUsesAllowed} step={1} />
source="uses_allowed"
validate={validateUsesAllowed}
step={1}
/>
<DateTimeInput source="expiry_time" parse={dateParser} /> <DateTimeInput source="expiry_time" parse={dateParser} />
</SimpleForm> </SimpleForm>
</Create> </Create>
); );
export const RegistrationTokenEdit = props => ( export const RegistrationTokenEdit = (props: EditProps) => (
<Edit {...props}> <Edit {...props}>
<SimpleForm> <SimpleForm>
<TextInput source="token" disabled /> <TextInput source="token" disabled />
<NumberInput source="pending" disabled /> <NumberInput source="pending" disabled />
<NumberInput source="completed" disabled /> <NumberInput source="completed" disabled />
<NumberInput <NumberInput source="uses_allowed" validate={validateUsesAllowed} step={1} />
source="uses_allowed" <DateTimeInput source="expiry_time" parse={dateParser} format={dateFormatter} />
validate={validateUsesAllowed}
step={1}
/>
<DateTimeInput
source="expiry_time"
parse={dateParser}
format={dateFormatter}
/>
</SimpleForm> </SimpleForm>
</Edit> </Edit>
); );
const resource = { const resource: ResourceProps = {
name: "registration_tokens", name: "registration_tokens",
icon: RegistrationTokenIcon, icon: RegistrationTokenIcon,
list: RegistrationTokenList, list: RegistrationTokenList,

View file

@ -1,14 +1,18 @@
import React from "react"; import RoomDirectoryIcon from "@mui/icons-material/FolderShared";
import { import {
BooleanField, BooleanField,
BulkDeleteButton, BulkDeleteButton,
BulkDeleteButtonProps,
Button, Button,
ButtonProps,
DatagridConfigurable, DatagridConfigurable,
DeleteButtonProps,
ExportButton, ExportButton,
DeleteButton, DeleteButton,
List, List,
NumberField, NumberField,
Pagination, Pagination,
ResourceProps,
SelectColumnsButton, SelectColumnsButton,
TextField, TextField,
TopToolbar, TopToolbar,
@ -22,14 +26,12 @@ import {
useUnselectAll, useUnselectAll,
} from "react-admin"; } from "react-admin";
import { useMutation } from "react-query"; import { useMutation } from "react-query";
import RoomDirectoryIcon from "@mui/icons-material/FolderShared";
import AvatarField from "./AvatarField"; import AvatarField from "./AvatarField";
const RoomDirectoryPagination = () => ( const RoomDirectoryPagination = () => <Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />;
<Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />
);
export const RoomDirectoryUnpublishButton = props => { export const RoomDirectoryUnpublishButton = (props: DeleteButtonProps) => {
const translate = useTranslate(); const translate = useTranslate();
return ( return (
@ -50,7 +52,7 @@ export const RoomDirectoryUnpublishButton = props => {
); );
}; };
export const RoomDirectoryBulkUnpublishButton = props => ( export const RoomDirectoryBulkUnpublishButton = (props: BulkDeleteButtonProps) => (
<BulkDeleteButton <BulkDeleteButton
{...props} {...props}
label="resources.room_directory.action.erase" label="resources.room_directory.action.erase"
@ -62,7 +64,7 @@ export const RoomDirectoryBulkUnpublishButton = props => (
/> />
); );
export const RoomDirectoryBulkPublishButton = props => { export const RoomDirectoryBulkPublishButton = (props: ButtonProps) => {
const { selectedIds } = useListContext(); const { selectedIds } = useListContext();
const notify = useNotify(); const notify = useNotify();
const refresh = useRefresh(); const refresh = useRefresh();
@ -88,18 +90,13 @@ export const RoomDirectoryBulkPublishButton = props => {
); );
return ( return (
<Button <Button {...props} label="resources.room_directory.action.create" onClick={mutate} disabled={isLoading}>
{...props}
label="resources.room_directory.action.create"
onClick={mutate}
disabled={isLoading}
>
<RoomDirectoryIcon /> <RoomDirectoryIcon />
</Button> </Button>
); );
}; };
export const RoomDirectoryPublishButton = props => { export const RoomDirectoryPublishButton = (props: ButtonProps) => {
const record = useRecordContext(); const record = useRecordContext();
const notify = useNotify(); const notify = useNotify();
const refresh = useRefresh(); const refresh = useRefresh();
@ -123,12 +120,7 @@ export const RoomDirectoryPublishButton = props => {
}; };
return ( return (
<Button <Button {...props} label="resources.room_directory.action.create" onClick={handleSend} disabled={isLoading}>
{...props}
label="resources.room_directory.action.create"
onClick={handleSend}
disabled={isLoading}
>
<RoomDirectoryIcon /> <RoomDirectoryIcon />
</Button> </Button>
); );
@ -142,13 +134,9 @@ const RoomDirectoryListActions = () => (
); );
export const RoomDirectoryList = () => ( export const RoomDirectoryList = () => (
<List <List pagination={<RoomDirectoryPagination />} perPage={100} actions={<RoomDirectoryListActions />}>
pagination={<RoomDirectoryPagination />}
perPage={100}
actions={<RoomDirectoryListActions />}
>
<DatagridConfigurable <DatagridConfigurable
rowClick={(id, _resource, _record) => "/rooms/" + id + "/show"} rowClick={id => "/rooms/" + id + "/show"}
bulkActionButtons={<RoomDirectoryBulkUnpublishButton />} bulkActionButtons={<RoomDirectoryBulkUnpublishButton />}
omit={["room_id", "canonical_alias", "topic"]} omit={["room_id", "canonical_alias", "topic"]}
> >
@ -158,46 +146,18 @@ export const RoomDirectoryList = () => (
sx={{ height: "40px", width: "40px" }} sx={{ height: "40px", width: "40px" }}
label="resources.rooms.fields.avatar" label="resources.rooms.fields.avatar"
/> />
<TextField <TextField source="name" sortable={false} label="resources.rooms.fields.name" />
source="name" <TextField source="room_id" sortable={false} label="resources.rooms.fields.room_id" />
sortable={false} <TextField source="canonical_alias" sortable={false} label="resources.rooms.fields.canonical_alias" />
label="resources.rooms.fields.name" <TextField source="topic" sortable={false} label="resources.rooms.fields.topic" />
/> <NumberField source="num_joined_members" sortable={false} label="resources.rooms.fields.joined_members" />
<TextField <BooleanField source="world_readable" sortable={false} label="resources.room_directory.fields.world_readable" />
source="room_id" <BooleanField source="guest_can_join" sortable={false} label="resources.room_directory.fields.guest_can_join" />
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> </DatagridConfigurable>
</List> </List>
); );
const resource = { const resource: ResourceProps = {
name: "room_directory", name: "room_directory",
icon: RoomDirectoryIcon, icon: RoomDirectoryIcon,
list: RoomDirectoryList, list: RoomDirectoryList,

View file

@ -1,10 +1,16 @@
import React, { useState } from "react"; 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 { import {
Button, Button,
RaRecord,
SaveButton, SaveButton,
SimpleForm, SimpleForm,
TextInput, TextInput,
Toolbar, Toolbar,
ToolbarProps,
required, required,
useCreate, useCreate,
useDataProvider, useDataProvider,
@ -15,24 +21,13 @@ import {
useUnselectAll, useUnselectAll,
} from "react-admin"; } from "react-admin";
import { useMutation } from "react-query"; import { useMutation } from "react-query";
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, onSubmit }) => { const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
const translate = useTranslate(); const translate = useTranslate();
const ServerNoticeToolbar = props => ( const ServerNoticeToolbar = (props: ToolbarProps & { pristine?: boolean }) => (
<Toolbar {...props}> <Toolbar {...props}>
<SaveButton <SaveButton label="resources.servernotices.action.send" disabled={props.pristine} />
label="resources.servernotices.action.send"
disabled={props.pristine}
/>
<Button label="ra.action.cancel" onClick={onClose}> <Button label="ra.action.cancel" onClick={onClose}>
<IconCancel /> <IconCancel />
</Button> </Button>
@ -40,14 +35,10 @@ const ServerNoticeDialog = ({ open, loading, onClose, onSubmit }) => {
); );
return ( return (
<Dialog open={open} onClose={onClose} loading={loading}> <Dialog open={open} onClose={onClose}>
<DialogTitle> <DialogTitle>{translate("resources.servernotices.action.send")}</DialogTitle>
{translate("resources.servernotices.action.send")}
</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>{translate("resources.servernotices.helper.send")}</DialogContentText>
{translate("resources.servernotices.helper.send")}
</DialogContentText>
<SimpleForm toolbar={<ServerNoticeToolbar />} onSubmit={onSubmit}> <SimpleForm toolbar={<ServerNoticeToolbar />} onSubmit={onSubmit}>
<TextInput <TextInput
source="body" source="body"
@ -68,12 +59,12 @@ export const ServerNoticeButton = () => {
const record = useRecordContext(); const record = useRecordContext();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const notify = useNotify(); const notify = useNotify();
const [create, { isloading }] = useCreate(); const [create, { isLoading }] = useCreate();
const handleDialogOpen = () => setOpen(true); const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false); const handleDialogClose = () => setOpen(false);
const handleSend = values => { const handleSend = (values: Partial<RaRecord>) => {
create( create(
"servernotices", "servernotices",
{ data: { id: record.id, ...values } }, { data: { id: record.id, ...values } },
@ -92,18 +83,10 @@ export const ServerNoticeButton = () => {
return ( return (
<> <>
<Button <Button label="resources.servernotices.send" onClick={handleDialogOpen} disabled={isLoading}>
label="resources.servernotices.send"
onClick={handleDialogOpen}
disabled={isloading}
>
<MessageIcon /> <MessageIcon />
</Button> </Button>
<ServerNoticeDialog <ServerNoticeDialog open={open} onClose={handleDialogClose} onSubmit={handleSend} />
open={open}
onClose={handleDialogClose}
onSubmit={handleSend}
/>
</> </>
); );
}; };
@ -138,18 +121,10 @@ export const ServerNoticeBulkButton = () => {
return ( return (
<> <>
<Button <Button label="resources.servernotices.send" onClick={openDialog} disabled={isLoading}>
label="resources.servernotices.send"
onClick={openDialog}
disabled={isLoading}
>
<MessageIcon /> <MessageIcon />
</Button> </Button>
<ServerNoticeDialog <ServerNoticeDialog open={open} onClose={closeDialog} onSubmit={sendNotices} />
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,14 +1,23 @@
import React from "react"; 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 { import {
Button, Button,
Datagrid, Datagrid,
DateField, DateField,
List, List,
ListProps,
Pagination, Pagination,
RaRecord,
ReferenceField, ReferenceField,
ReferenceManyField, ReferenceManyField,
ResourceProps,
SearchInput, SearchInput,
Show, Show,
ShowProps,
Tab, Tab,
TabbedShowLayout, TabbedShowLayout,
TextField, TextField,
@ -19,25 +28,12 @@ import {
useRefresh, useRefresh,
useTranslate, useTranslate,
} from "react-admin"; } from "react-admin";
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";
const DestinationPagination = () => ( import { DATE_FORMAT } from "./date";
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const date_format = { const DestinationPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const destinationRowSx = (record, _index) => ({ const destinationRowSx = (record: RaRecord) => ({
backgroundColor: record.retry_last_ts > 0 ? "#ffcccc" : "white", backgroundColor: record.retry_last_ts > 0 ? "#ffcccc" : "white",
}); });
@ -52,7 +48,7 @@ export const DestinationReconnectButton = () => {
// Reconnect is not required if no error has occurred. (`failure_ts`) // Reconnect is not required if no error has occurred. (`failure_ts`)
if (!record || !record.failure_ts) return null; if (!record || !record.failure_ts) return null;
const handleClick = e => { const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
// Prevents redirection to the detail page when clicking in the list // Prevents redirection to the detail page when clicking in the list
e.stopPropagation(); e.stopPropagation();
@ -74,11 +70,7 @@ export const DestinationReconnectButton = () => {
}; };
return ( return (
<Button <Button label="resources.destinations.action.reconnect" onClick={handleClick} disabled={isLoading}>
label="resources.destinations.action.reconnect"
onClick={handleClick}
disabled={isLoading}
>
<AutorenewIcon /> <AutorenewIcon />
</Button> </Button>
); );
@ -100,7 +92,7 @@ const DestinationTitle = () => {
); );
}; };
export const DestinationList = props => { export const DestinationList = (props: ListProps) => {
return ( return (
<List <List
{...props} {...props}
@ -108,14 +100,10 @@ export const DestinationList = props => {
pagination={<DestinationPagination />} pagination={<DestinationPagination />}
sort={{ field: "destination", order: "ASC" }} sort={{ field: "destination", order: "ASC" }}
> >
<Datagrid <Datagrid rowSx={destinationRowSx} rowClick={id => `${id}/show/rooms`} bulkActionButtons={false}>
rowSx={destinationRowSx}
rowClick={(id, _resource, _record) => `${id}/show/rooms`}
bulkActionButtons={false}
>
<TextField source="destination" /> <TextField source="destination" />
<DateField source="failure_ts" showTime options={date_format} /> <DateField source="failure_ts" showTime options={DATE_FORMAT} />
<DateField source="retry_last_ts" showTime options={date_format} /> <DateField source="retry_last_ts" showTime options={DATE_FORMAT} />
<TextField source="retry_interval" /> <TextField source="retry_interval" />
<TextField source="last_successful_stream_ordering" /> <TextField source="last_successful_stream_ordering" />
<DestinationReconnectButton /> <DestinationReconnectButton />
@ -124,43 +112,29 @@ export const DestinationList = props => {
); );
}; };
export const DestinationShow = props => { export const DestinationShow = (props: ShowProps) => {
const translate = useTranslate(); const translate = useTranslate();
return ( return (
<Show <Show actions={<DestinationShowActions />} title={<DestinationTitle />} {...props}>
actions={<DestinationShowActions />}
title={<DestinationTitle />}
{...props}
>
<TabbedShowLayout> <TabbedShowLayout>
<Tab label="status" icon={<ViewListIcon />}> <Tab label="status" icon={<ViewListIcon />}>
<TextField source="destination" /> <TextField source="destination" />
<DateField source="failure_ts" showTime options={date_format} /> <DateField source="failure_ts" showTime options={DATE_FORMAT} />
<DateField source="retry_last_ts" showTime options={date_format} /> <DateField source="retry_last_ts" showTime options={DATE_FORMAT} />
<TextField source="retry_interval" /> <TextField source="retry_interval" />
<TextField source="last_successful_stream_ordering" /> <TextField source="last_successful_stream_ordering" />
</Tab> </Tab>
<Tab <Tab label={translate("resources.rooms.name", { smart_count: 2 })} icon={<FolderSharedIcon />} path="rooms">
label={translate("resources.rooms.name", { smart_count: 2 })}
icon={<FolderSharedIcon />}
path="rooms"
>
<ReferenceManyField <ReferenceManyField
reference="destination_rooms" reference="destination_rooms"
target="destination" target="destination"
addLabel={false} label={false}
pagination={<DestinationPagination />} pagination={<DestinationPagination />}
perPage={50} perPage={50}
> >
<Datagrid <Datagrid style={{ width: "100%" }} rowClick={id => `/rooms/${id}/show`}>
style={{ width: "100%" }} <TextField source="room_id" label="resources.rooms.fields.room_id" />
rowClick={(id, resource, record) => `/rooms/${id}/show`}
>
<TextField
source="room_id"
label="resources.rooms.fields.room_id"
/>
<TextField source="stream_ordering" sortable={false} /> <TextField source="stream_ordering" sortable={false} />
<ReferenceField <ReferenceField
label="resources.rooms.fields.name" label="resources.rooms.fields.name"
@ -179,7 +153,7 @@ export const DestinationShow = props => {
); );
}; };
const resource = { const resource: ResourceProps = {
name: "destinations", name: "destinations",
icon: DestinationsIcon, icon: DestinationsIcon,
list: DestinationList, list: DestinationList,

View file

@ -1,51 +0,0 @@
import React from "react";
import {
DeleteButton,
useDelete,
useNotify,
useRecordContext,
useRefresh,
} from "react-admin";
export const DeviceRemoveButton = props => {
const record = useRecordContext();
const refresh = useRefresh();
const notify = useNotify();
const [removeDevice] = useDelete();
if (!record) return null;
const handleConfirm = () => {
removeDevice(
"devices",
// needs previousData for user_id
{ id: record.id, previousData: record },
{
onSuccess: () => {
notify("resources.devices.action.erase.success");
refresh();
},
onError: () => {
notify("resources.devices.action.erase.failure", { type: "error" });
},
}
);
};
return (
<DeleteButton
{...props}
label="ra.action.remove"
confirmTitle="resources.devices.action.erase.title"
confirmContent="resources.devices.action.erase.content"
onConfirm={handleConfirm}
mutationMode="pessimistic"
redirect={false}
translateOptions={{
id: record.id,
name: record.display_name ? record.display_name : record.id,
}}
/>
);
};

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,13 +1,25 @@
import React, { useState } from "react"; import { get } from "lodash";
import get from "lodash/get"; 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 { import {
BooleanInput, BooleanInput,
Button, Button,
ButtonProps,
DateTimeInput, DateTimeInput,
NumberInput, NumberInput,
SaveButton, SaveButton,
SimpleForm, SimpleForm,
Toolbar, Toolbar,
ToolbarProps,
useCreate, useCreate,
useDelete, useDelete,
useNotify, useNotify,
@ -16,39 +28,16 @@ import {
useTranslate, useTranslate,
} from "react-admin"; } from "react-admin";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import BlockIcon from "@mui/icons-material/Block";
import ClearIcon from "@mui/icons-material/Clear"; import { dateParser } from "./date";
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
import {
Box,
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 FileOpenIcon from "@mui/icons-material/FileOpen";
import { alpha, useTheme } from "@mui/material/styles";
import { getMediaUrl } from "../synapse/synapse"; import { getMediaUrl } from "../synapse/synapse";
const DeleteMediaDialog = ({ open, loading, onClose, onSubmit }) => { const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
const translate = useTranslate(); const translate = useTranslate();
const dateParser = v => { const DeleteMediaToolbar = (props: ToolbarProps) => (
const d = new Date(v);
if (isNaN(d)) return 0;
return d.getTime();
};
const DeleteMediaToolbar = props => (
<Toolbar {...props}> <Toolbar {...props}>
<SaveButton <SaveButton label="resources.delete_media.action.send" icon={<DeleteSweepIcon />} />
label="resources.delete_media.action.send"
icon={<DeleteSweepIcon />}
/>
<Button label="ra.action.cancel" onClick={onClose}> <Button label="ra.action.cancel" onClick={onClose}>
<IconCancel /> <IconCancel />
</Button> </Button>
@ -56,14 +45,10 @@ const DeleteMediaDialog = ({ open, loading, onClose, onSubmit }) => {
); );
return ( return (
<Dialog open={open} onClose={onClose} loading={loading}> <Dialog open={open} onClose={onClose}>
<DialogTitle> <DialogTitle>{translate("resources.delete_media.action.send")}</DialogTitle>
{translate("resources.delete_media.action.send")}
</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>{translate("resources.delete_media.helper.send")}</DialogContentText>
{translate("resources.delete_media.helper.send")}
</DialogContentText>
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}> <SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
<DateTimeInput <DateTimeInput
fullWidth fullWidth
@ -92,7 +77,7 @@ const DeleteMediaDialog = ({ open, loading, onClose, onSubmit }) => {
); );
}; };
export const DeleteMediaButton = props => { export const DeleteMediaButton = (props: ButtonProps) => {
const theme = useTheme(); const theme = useTheme();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const notify = useNotify(); const notify = useNotify();
@ -101,7 +86,7 @@ export const DeleteMediaButton = props => {
const openDialog = () => setOpen(true); const openDialog = () => setOpen(true);
const closeDialog = () => setOpen(false); const closeDialog = () => setOpen(false);
const deleteMedia = values => { const deleteMedia = (values: { before_ts: string; size_gt: number; keep_profiles: boolean }) => {
deleteOne( deleteOne(
"delete_media", "delete_media",
// needs meta.before_ts, meta.size_gt and meta.keep_profiles // needs meta.before_ts, meta.size_gt and meta.keep_profiles
@ -139,16 +124,12 @@ export const DeleteMediaButton = props => {
> >
<DeleteSweepIcon /> <DeleteSweepIcon />
</Button> </Button>
<DeleteMediaDialog <DeleteMediaDialog open={open} onClose={closeDialog} onSubmit={deleteMedia} />
open={open}
onClose={closeDialog}
onSubmit={deleteMedia}
/>
</> </>
); );
}; };
export const ProtectMediaButton = () => { export const ProtectMediaButton = (props: ButtonProps) => {
const record = useRecordContext(); const record = useRecordContext();
const translate = useTranslate(); const translate = useTranslate();
const refresh = useRefresh(); const refresh = useRefresh();
@ -209,7 +190,7 @@ export const ProtectMediaButton = () => {
Button instead BooleanField for Button instead BooleanField for
consistent appearance and position in the column consistent appearance and position in the column
*/} */}
<Button disabled={true}> <Button {...props} disabled={true}>
<ClearIcon /> <ClearIcon />
</Button> </Button>
</div> </div>
@ -223,7 +204,7 @@ export const ProtectMediaButton = () => {
arrow arrow
> >
<div> <div>
<Button onClick={handleUnprotect} disabled={isLoading}> <Button {...props} onClick={handleUnprotect} disabled={isLoading}>
<LockIcon /> <LockIcon />
</Button> </Button>
</div> </div>
@ -236,7 +217,7 @@ export const ProtectMediaButton = () => {
})} })}
> >
<div> <div>
<Button onClick={handleProtect} disabled={isLoading}> <Button {...props} onClick={handleProtect} disabled={isLoading}>
<LockOpenIcon /> <LockOpenIcon />
</Button> </Button>
</div> </div>
@ -246,7 +227,7 @@ export const ProtectMediaButton = () => {
); );
}; };
export const QuarantineMediaButton = props => { export const QuarantineMediaButton = (props: ButtonProps) => {
const record = useRecordContext(); const record = useRecordContext();
const translate = useTranslate(); const translate = useTranslate();
const refresh = useRefresh(); const refresh = useRefresh();
@ -312,11 +293,7 @@ export const QuarantineMediaButton = props => {
})} })}
> >
<div> <div>
<Button <Button {...props} onClick={handleRemoveQuarantaine} disabled={isLoading}>
{...props}
onClick={handleRemoveQuarantaine}
disabled={isLoading}
>
<BlockIcon color="error" /> <BlockIcon color="error" />
</Button> </Button>
</div> </div>
@ -329,7 +306,7 @@ export const QuarantineMediaButton = props => {
})} })}
> >
<div> <div>
<Button onClick={handleQuarantaine} disabled={isLoading}> <Button {...props} onClick={handleQuarantaine} disabled={isLoading}>
<BlockIcon /> <BlockIcon />
</Button> </Button>
</div> </div>

View file

@ -1,4 +1,14 @@
import React from "react"; 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 { import {
BooleanField, BooleanField,
BulkDeleteButton, BulkDeleteButton,
@ -9,14 +19,17 @@ import {
ExportButton, ExportButton,
FunctionField, FunctionField,
List, List,
ListProps,
NumberField, NumberField,
Pagination, Pagination,
ReferenceField, ReferenceField,
ReferenceManyField, ReferenceManyField,
ResourceProps,
SearchInput, SearchInput,
SelectColumnsButton, SelectColumnsButton,
SelectField, SelectField,
Show, Show,
ShowProps,
Tab, Tab,
TabbedShowLayout, TabbedShowLayout,
TextField, TextField,
@ -24,41 +37,21 @@ import {
useRecordContext, useRecordContext,
useTranslate, useTranslate,
} from "react-admin"; } from "react-admin";
import { useTheme } from "@mui/material/styles";
import Box from "@mui/material/Box";
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 RoomIcon from "@mui/icons-material/ViewList";
import { import {
RoomDirectoryBulkUnpublishButton, RoomDirectoryBulkUnpublishButton,
RoomDirectoryBulkPublishButton, RoomDirectoryBulkPublishButton,
RoomDirectoryUnpublishButton, RoomDirectoryUnpublishButton,
RoomDirectoryPublishButton, RoomDirectoryPublishButton,
} from "./RoomDirectory"; } from "./RoomDirectory";
import { DATE_FORMAT } from "./date";
const date_format = { const RoomPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const RoomPagination = () => (
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const RoomTitle = () => { const RoomTitle = () => {
const record = useRecordContext(); const record = useRecordContext();
const translate = useTranslate(); const translate = useTranslate();
var name = ""; let name = "";
if (record) { if (record) {
name = record.name !== "" ? record.name : record.id; name = record.name !== "" ? record.name : record.id;
} }
@ -72,15 +65,11 @@ const RoomTitle = () => {
const RoomShowActions = () => { const RoomShowActions = () => {
const record = useRecordContext(); const record = useRecordContext();
var roomDirectoryStatus = ""; const publishButton = record.public ? <RoomDirectoryUnpublishButton /> : <RoomDirectoryPublishButton />;
if (record) { // FIXME: refresh after (un)publish
roomDirectoryStatus = record.public;
}
return ( return (
<TopToolbar> <TopToolbar>
{roomDirectoryStatus === false && <RoomDirectoryPublishButton />} {publishButton}
{roomDirectoryStatus === true && <RoomDirectoryUnpublishButton />}
<DeleteButton <DeleteButton
mutationMode="pessimistic" mutationMode="pessimistic"
confirmTitle="resources.rooms.action.erase.title" confirmTitle="resources.rooms.action.erase.title"
@ -90,7 +79,7 @@ const RoomShowActions = () => {
); );
}; };
export const RoomShow = props => { export const RoomShow = (props: ShowProps) => {
const translate = useTranslate(); const translate = useTranslate();
return ( return (
<Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}> <Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}>
@ -105,42 +94,19 @@ export const RoomShow = props => {
</ReferenceField> </ReferenceField>
</Tab> </Tab>
<Tab <Tab label="synapseadmin.rooms.tabs.detail" icon={<PageviewIcon />} path="detail">
label="synapseadmin.rooms.tabs.detail"
icon={<PageviewIcon />}
path="detail"
>
<TextField source="joined_members" /> <TextField source="joined_members" />
<TextField source="joined_local_members" /> <TextField source="joined_local_members" />
<TextField source="joined_local_devices" /> <TextField source="joined_local_devices" />
<TextField source="state_events" /> <TextField source="state_events" />
<TextField source="version" /> <TextField source="version" />
<TextField <TextField source="encryption" emptyText={translate("resources.rooms.enums.unencrypted")} />
source="encryption"
emptyText={translate("resources.rooms.enums.unencrypted")}
/>
</Tab> </Tab>
<Tab <Tab label="synapseadmin.rooms.tabs.members" icon={<UserIcon />} path="members">
label="synapseadmin.rooms.tabs.members" <ReferenceManyField reference="room_members" target="room_id" label={false}>
icon={<UserIcon />} <Datagrid style={{ width: "100%" }} rowClick={id => "/users/" + id} bulkActionButtons={false}>
path="members" <TextField source="id" sortable={false} label="resources.users.fields.id" />
>
<ReferenceManyField
reference="room_members"
target="room_id"
addLabel={false}
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, resource, record) => "/users/" + id}
bulkActionButtons={false}
>
<TextField
source="id"
sortable={false}
label="resources.users.fields.id"
/>
<ReferenceField <ReferenceField
label="resources.users.fields.displayname" label="resources.users.fields.displayname"
source="id" source="id"
@ -154,11 +120,7 @@ export const RoomShow = props => {
</ReferenceManyField> </ReferenceManyField>
</Tab> </Tab>
<Tab <Tab label="synapseadmin.rooms.tabs.permission" icon={<VisibilityIcon />} path="permission">
label="synapseadmin.rooms.tabs.permission"
icon={<VisibilityIcon />}
path="permission"
>
<BooleanField source="federatable" /> <BooleanField source="federatable" />
<BooleanField source="public" /> <BooleanField source="public" />
<SelectField <SelectField
@ -209,41 +171,20 @@ export const RoomShow = props => {
/> />
</Tab> </Tab>
<Tab <Tab label={translate("resources.room_state.name", { smart_count: 2 })} icon={<EventIcon />} path="state">
label={translate("resources.room_state.name", { smart_count: 2 })} <ReferenceManyField reference="room_state" target="room_id" label={false}>
icon={<EventIcon />}
path="state"
>
<ReferenceManyField
reference="room_state"
target="room_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}> <Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="type" sortable={false} /> <TextField source="type" sortable={false} />
<DateField <DateField source="origin_server_ts" showTime options={DATE_FORMAT} sortable={false} />
source="origin_server_ts"
showTime
options={date_format}
sortable={false}
/>
<TextField source="content" sortable={false} /> <TextField source="content" sortable={false} />
<ReferenceField <ReferenceField source="sender" reference="users" sortable={false}>
source="sender"
reference="users"
sortable={false}
>
<TextField source="id" /> <TextField source="id" />
</ReferenceField> </ReferenceField>
</Datagrid> </Datagrid>
</ReferenceManyField> </ReferenceManyField>
</Tab> </Tab>
<Tab <Tab label="resources.forward_extremities.name" icon={<FastForwardIcon />} path="forward_extremities">
label="resources.forward_extremities.name"
icon={<FastForwardIcon />}
path="forward_extremities"
>
<Box <Box
sx={{ sx={{
fontFamily: "Roboto, Helvetica, Arial, sans-serif", fontFamily: "Roboto, Helvetica, Arial, sans-serif",
@ -252,19 +193,10 @@ export const RoomShow = props => {
> >
{translate("resources.rooms.helper.forward_extremities")} {translate("resources.rooms.helper.forward_extremities")}
</Box> </Box>
<ReferenceManyField <ReferenceManyField reference="forward_extremities" target="room_id" label={false}>
reference="forward_extremities"
target="room_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}> <Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="id" sortable={false} /> <TextField source="id" sortable={false} />
<DateField <DateField source="received_ts" showTime options={DATE_FORMAT} sortable={false} />
source="received_ts"
showTime
options={date_format}
sortable={false}
/>
<NumberField source="depth" sortable={false} /> <NumberField source="depth" sortable={false} />
<TextField source="state_group" sortable={false} /> <TextField source="state_group" sortable={false} />
</Datagrid> </Datagrid>
@ -296,7 +228,7 @@ const RoomListActions = () => (
</TopToolbar> </TopToolbar>
); );
export const RoomList = props => { export const RoomList = (props: ListProps) => {
const theme = useTheme(); const theme = useTheme();
return ( return (
@ -310,12 +242,7 @@ export const RoomList = props => {
<DatagridConfigurable <DatagridConfigurable
rowClick="show" rowClick="show"
bulkActionButtons={<RoomBulkActionButtons />} bulkActionButtons={<RoomBulkActionButtons />}
omit={[ omit={["joined_local_members", "state_events", "version", "federatable"]}
"joined_local_members",
"state_events",
"version",
"federatable",
]}
> >
<BooleanField <BooleanField
source="is_encrypted" source="is_encrypted"
@ -328,12 +255,7 @@ export const RoomList = props => {
[`& [data-testid="false"]`]: { color: theme.palette.error.main }, [`& [data-testid="false"]`]: { color: theme.palette.error.main },
}} }}
/> />
<FunctionField <FunctionField source="name" render={record => record["name"] || record["canonical_alias"] || record["id"]} />
source="name"
render={record =>
record["name"] || record["canonical_alias"] || record["id"]
}
/>
<TextField source="joined_members" /> <TextField source="joined_members" />
<TextField source="joined_local_members" /> <TextField source="joined_local_members" />
<TextField source="state_events" /> <TextField source="state_events" />
@ -345,7 +267,7 @@ export const RoomList = props => {
); );
}; };
const resource = { const resource: ResourceProps = {
name: "rooms", name: "rooms",
icon: RoomIcon, icon: RoomIcon,
list: RoomList, list: RoomList,

View file

@ -1,79 +0,0 @@
import React from "react";
import { cloneElement } from "react";
import {
Datagrid,
ExportButton,
List,
NumberField,
Pagination,
sanitizeListRestProps,
SearchInput,
TextField,
TopToolbar,
useListContext,
} from "react-admin";
import EqualizerIcon from "@mui/icons-material/Equalizer";
import { DeleteMediaButton } from "./media";
const ListActions = props => {
const { className, exporter, filters, maxResults, ...rest } = props;
const { sort, 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={sort}
filterValues={filterValues}
maxResults={maxResults}
/>
</TopToolbar>
);
};
const UserMediaStatsPagination = () => (
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const userMediaStatsFilters = [<SearchInput source="search_term" alwaysOn />];
export const UserMediaStatsList = props => (
<List
{...props}
actions={<ListActions />}
filters={userMediaStatsFilters}
pagination={<UserMediaStatsPagination />}
sort={{ field: "media_length", order: "DESC" }}
>
<Datagrid
rowClick={(id, resource, record) => "/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 = {
name: "user_media_statistics",
icon: EqualizerIcon,
list: UserMediaStatsList,
};
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 "./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;

View file

@ -1,13 +1,12 @@
import React, { cloneElement } from "react";
import AssignmentIndIcon from "@mui/icons-material/AssignmentInd"; import AssignmentIndIcon from "@mui/icons-material/AssignmentInd";
import ContactMailIcon from "@mui/icons-material/ContactMail"; import ContactMailIcon from "@mui/icons-material/ContactMail";
import DevicesIcon from "@mui/icons-material/Devices"; import DevicesIcon from "@mui/icons-material/Devices";
import GetAppIcon from "@mui/icons-material/GetApp"; import GetAppIcon from "@mui/icons-material/GetApp";
import UserIcon from "@mui/icons-material/Group";
import NotificationsIcon from "@mui/icons-material/Notifications"; import NotificationsIcon from "@mui/icons-material/Notifications";
import PermMediaIcon from "@mui/icons-material/PermMedia"; import PermMediaIcon from "@mui/icons-material/PermMedia";
import PersonPinIcon from "@mui/icons-material/PersonPin"; import PersonPinIcon from "@mui/icons-material/PersonPin";
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent"; import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
import UserIcon from "@mui/icons-material/Group";
import ViewListIcon from "@mui/icons-material/ViewList"; import ViewListIcon from "@mui/icons-material/ViewList";
import { import {
ArrayInput, ArrayInput,
@ -16,9 +15,11 @@ import {
Datagrid, Datagrid,
DateField, DateField,
Create, Create,
CreateProps,
Edit, Edit,
EditProps,
List, List,
Toolbar, ListProps,
SimpleForm, SimpleForm,
SimpleFormIterator, SimpleFormIterator,
TabbedForm, TabbedForm,
@ -30,11 +31,11 @@ import {
TextInput, TextInput,
ReferenceField, ReferenceField,
ReferenceManyField, ReferenceManyField,
ResourceProps,
SearchInput, SearchInput,
SelectInput, SelectInput,
BulkDeleteButton, BulkDeleteButton,
DeleteButton, DeleteButton,
SaveButton,
maxLength, maxLength,
regex, regex,
required, required,
@ -44,18 +45,16 @@ import {
CreateButton, CreateButton,
ExportButton, ExportButton,
TopToolbar, TopToolbar,
sanitizeListRestProps,
NumberField, NumberField,
useListContext,
} from "react-admin"; } from "react-admin";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import AvatarField from "./AvatarField"; import AvatarField from "./AvatarField";
import { ServerNoticeButton, ServerNoticeBulkButton } from "./ServerNotices"; import { ServerNoticeButton, ServerNoticeBulkButton } from "./ServerNotices";
import { DATE_FORMAT } from "./date";
import { DeviceRemoveButton } from "./devices"; import { DeviceRemoveButton } from "./devices";
import { import { MediaIDField, ProtectMediaButton, QuarantineMediaButton } from "./media";
MediaIDField,
ProtectMediaButton,
QuarantineMediaButton,
} from "./media";
const choices_medium = [ const choices_medium = [
{ id: "email", name: "resources.users.email" }, { id: "email", name: "resources.users.email" },
@ -67,52 +66,12 @@ const choices_type = [
{ id: "support", name: "support" }, { id: "support", name: "support" },
]; ];
const date_format = { const UserListActions = () => {
year: "numeric", const { isLoading, total } = useListContext();
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const UserListActions = ({
sort,
className,
resource,
filters,
displayedFilters,
exporter, // you can hide ExportButton if exporter = (null || false)
filterValues,
permanentFilter,
hasCreate, // you can hide CreateButton if hasCreate = false
selectedIds,
onUnselectItems,
showFilter,
maxResults,
total,
...rest
}) => {
return ( return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}> <TopToolbar>
{filters &&
cloneElement(filters, {
resource,
showFilter,
displayedFilters,
filterValues,
context: "button",
})}
<CreateButton /> <CreateButton />
<ExportButton <ExportButton disabled={isLoading || total === 0} maxResults={10000} />
disabled={total === 0}
resource={resource}
sort={sort}
filter={{ ...filterValues, ...permanentFilter }}
exporter={exporter}
maxResults={maxResults}
/>
{/* Add your custom actions */}
<Button component={Link} to="/import_users" label="CSV Import"> <Button component={Link} to="/import_users" label="CSV Import">
<GetAppIcon sx={{ transform: "rotate(180deg)", fontSize: "20px" }} /> <GetAppIcon sx={{ transform: "rotate(180deg)", fontSize: "20px" }} />
</Button> </Button>
@ -125,18 +84,12 @@ UserListActions.defaultProps = {
onUnselectItems: () => null, onUnselectItems: () => null,
}; };
const UserPagination = () => ( const UserPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const userFilters = [ const userFilters = [
<SearchInput source="name" alwaysOn />, <SearchInput source="name" alwaysOn />,
<BooleanInput source="guests" alwaysOn />, <BooleanInput source="guests" alwaysOn />,
<BooleanInput <BooleanInput label="resources.users.fields.show_deactivated" source="deactivated" alwaysOn />,
label="resources.users.fields.show_deactivated"
source="deactivated"
alwaysOn
/>,
]; ];
const UserBulkActionButtons = () => ( const UserBulkActionButtons = () => (
@ -150,32 +103,25 @@ const UserBulkActionButtons = () => (
</> </>
); );
export const UserList = props => ( export const UserList = (props: ListProps) => (
<List <List
{...props} {...props}
filters={userFilters} filters={userFilters}
filterDefaultValues={{ guests: true, deactivated: false }} filterDefaultValues={{ guests: true, deactivated: false }}
sort={{ field: "name", order: "ASC" }} sort={{ field: "name", order: "ASC" }}
actions={<UserListActions maxResults={10000} />} actions={<UserListActions />}
pagination={<UserPagination />} pagination={<UserPagination />}
> >
<Datagrid rowClick="edit" bulkActionButtons={<UserBulkActionButtons />}> <Datagrid rowClick="edit" bulkActionButtons={<UserBulkActionButtons />}>
<AvatarField <AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} sortBy="avatar_url" />
source="avatar_src"
sx={{ height: "40px", width: "40px" }}
sortBy="avatar_url"
/>
<TextField source="id" sortBy="name" /> <TextField source="id" sortBy="name" />
<TextField source="displayname" /> <TextField source="displayname" />
<BooleanField source="is_guest" /> <BooleanField source="is_guest" />
<BooleanField source="admin" /> <BooleanField source="admin" />
<BooleanField source="deactivated" /> <BooleanField source="deactivated" />
<DateField <BooleanField source="locked" />
source="creation_ts" <BooleanField source="erased" sortable={false} />
label="resources.users.fields.creation_ts_ms" <DateField source="creation_ts" label="resources.users.fields.creation_ts_ms" showTime options={DATE_FORMAT} />
showTime
options={date_format}
/>
</Datagrid> </Datagrid>
</List> </List>
); );
@ -184,73 +130,18 @@ export const UserList = props => (
// here only local part of user_id // here only local part of user_id
// maxLength = 255 - "@" - ":" - localStorage.getItem("home_server").length // maxLength = 255 - "@" - ":" - localStorage.getItem("home_server").length
// localStorage.getItem("home_server").length is not valid here // localStorage.getItem("home_server").length is not valid here
const validateUser = [ const validateUser = [required(), maxLength(253), regex(/^[a-z0-9._=\-/]+$/, "synapseadmin.users.invalid_user_id")];
required(),
maxLength(253),
regex(/^[a-z0-9._=\-/]+$/, "synapseadmin.users.invalid_user_id"),
];
const validateAddress = [required(), maxLength(255)]; const validateAddress = [required(), maxLength(255)];
export function generateRandomUser() { const UserEditActions = () => {
const homeserver = localStorage.getItem("home_server"); const record = useRecordContext();
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 disabled={props.pristine} />
</Toolbar>
);
const UserEditActions = ({ data }) => {
const translate = useTranslate(); const translate = useTranslate();
var userStatus = "";
if (data) {
userStatus = data.deactivated;
}
return ( return (
<TopToolbar> <TopToolbar>
{!userStatus && <ServerNoticeButton record={data} />} {!record.deactivated && <ServerNoticeButton />}
<DeleteButton <DeleteButton
record={data}
label="resources.users.action.erase" label="resources.users.action.erase"
confirmTitle={translate("resources.users.helper.erase", { confirmTitle={translate("resources.users.helper.erase", {
smart_count: 1, smart_count: 1,
@ -261,41 +152,24 @@ const UserEditActions = ({ data }) => {
); );
}; };
export const UserCreate = props => ( export const UserCreate = (props: CreateProps) => (
<Create {...props}> <Create {...props}>
<SimpleForm> <SimpleForm>
<TextInput source="id" autoComplete="off" validate={validateUser} /> <TextInput source="id" autoComplete="off" validate={validateUser} />
<TextInput source="displayname" validate={maxLength(256)} /> <TextInput source="displayname" validate={maxLength(256)} />
<PasswordInput <PasswordInput source="password" autoComplete="new-password" validate={maxLength(512)} />
source="password" <SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
autoComplete="new-password"
validate={maxLength(512)}
/>
<SelectInput
source="user_type"
choices={choices_type}
translateChoice={false}
resettable
/>
<BooleanInput source="admin" /> <BooleanInput source="admin" />
<ArrayInput source="threepids"> <ArrayInput source="threepids">
<SimpleFormIterator disableReordering> <SimpleFormIterator disableReordering>
<SelectInput <SelectInput source="medium" choices={choices_medium} validate={required()} />
source="medium"
choices={choices_medium}
validate={required()}
/>
<TextInput source="address" validate={validateAddress} /> <TextInput source="address" validate={validateAddress} />
</SimpleFormIterator> </SimpleFormIterator>
</ArrayInput> </ArrayInput>
<ArrayInput source="external_ids" label="synapseadmin.users.tabs.sso"> <ArrayInput source="external_ids" label="synapseadmin.users.tabs.sso">
<SimpleFormIterator disableReordering> <SimpleFormIterator disableReordering>
<TextInput source="auth_provider" validate={required()} /> <TextInput source="auth_provider" validate={required()} />
<TextInput <TextInput source="external_id" label="resources.users.fields.id" validate={required()} />
source="external_id"
label="resources.users.fields.id"
validate={required()}
/>
</SimpleFormIterator> </SimpleFormIterator>
</ArrayInput> </ArrayInput>
</SimpleForm> </SimpleForm>
@ -315,47 +189,26 @@ const UserTitle = () => {
); );
}; };
export const UserEdit = props => { export const UserEdit = (props: EditProps) => {
const translate = useTranslate(); const translate = useTranslate();
return ( return (
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}> <Edit {...props} title={<UserTitle />} actions={<UserEditActions />}>
<TabbedForm toolbar={<UserEditToolbar />}> <TabbedForm>
<FormTab <FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
label={translate("resources.users.name", { smart_count: 1 })} <AvatarField source="avatar_src" sortable={false} sx={{ height: "120px", width: "120px", float: "right" }} />
icon={<PersonPinIcon />}
>
<AvatarField
source="avatar_src"
sortable={false}
sx={{ height: "120px", width: "120px", float: "right" }}
/>
<TextInput source="id" disabled /> <TextInput source="id" disabled />
<TextInput source="displayname" /> <TextInput source="displayname" />
<PasswordInput <PasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
source="password" <SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
autoComplete="new-password"
helperText="resources.users.helper.password"
/>
<SelectInput
source="user_type"
choices={choices_type}
translateChoice={false}
resettable
/>
<BooleanInput source="admin" /> <BooleanInput source="admin" />
<BooleanInput <BooleanInput source="locked" />
source="deactivated" <BooleanInput source="deactivated" helperText="resources.users.helper.deactivate" />
helperText="resources.users.helper.deactivate" <BooleanInput source="erased" disabled />
/> <DateField source="creation_ts_ms" showTime options={DATE_FORMAT} />
<DateField source="creation_ts_ms" showTime options={date_format} />
<TextField source="consent_version" /> <TextField source="consent_version" />
</FormTab> </FormTab>
<FormTab <FormTab label="resources.users.threepid" icon={<ContactMailIcon />} path="threepid">
label="resources.users.threepid"
icon={<ContactMailIcon />}
path="threepid"
>
<ArrayInput source="threepids"> <ArrayInput source="threepids">
<SimpleFormIterator disableReordering> <SimpleFormIterator disableReordering>
<SelectInput source="medium" choices={choices_medium} /> <SelectInput source="medium" choices={choices_medium} />
@ -364,76 +217,34 @@ export const UserEdit = props => {
</ArrayInput> </ArrayInput>
</FormTab> </FormTab>
<FormTab <FormTab label="synapseadmin.users.tabs.sso" icon={<AssignmentIndIcon />} path="sso">
label="synapseadmin.users.tabs.sso"
icon={<AssignmentIndIcon />}
path="sso"
>
<ArrayInput source="external_ids" label={false}> <ArrayInput source="external_ids" label={false}>
<SimpleFormIterator disableReordering> <SimpleFormIterator disableReordering>
<TextInput source="auth_provider" validate={required()} /> <TextInput source="auth_provider" validate={required()} />
<TextInput <TextInput source="external_id" label="resources.users.fields.id" validate={required()} />
source="external_id"
label="resources.users.fields.id"
validate={required()}
/>
</SimpleFormIterator> </SimpleFormIterator>
</ArrayInput> </ArrayInput>
</FormTab> </FormTab>
<FormTab <FormTab label={translate("resources.devices.name", { smart_count: 2 })} icon={<DevicesIcon />} path="devices">
label={translate("resources.devices.name", { smart_count: 2 })} <ReferenceManyField reference="devices" target="user_id" label={false}>
icon={<DevicesIcon />}
path="devices"
>
<ReferenceManyField
reference="devices"
target="user_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }}> <Datagrid style={{ width: "100%" }}>
<TextField source="device_id" sortable={false} /> <TextField source="device_id" sortable={false} />
<TextField source="display_name" sortable={false} /> <TextField source="display_name" sortable={false} />
<TextField source="last_seen_ip" sortable={false} /> <TextField source="last_seen_ip" sortable={false} />
<DateField <DateField source="last_seen_ts" showTime options={DATE_FORMAT} sortable={false} />
source="last_seen_ts"
showTime
options={date_format}
sortable={false}
/>
<DeviceRemoveButton /> <DeviceRemoveButton />
</Datagrid> </Datagrid>
</ReferenceManyField> </ReferenceManyField>
</FormTab> </FormTab>
<FormTab <FormTab label="resources.connections.name" icon={<SettingsInputComponentIcon />} path="connections">
label="resources.connections.name" <ReferenceField reference="connections" source="id" label={false} link={false}>
icon={<SettingsInputComponentIcon />} <ArrayField source="devices[].sessions[0].connections" label="resources.connections.name">
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%" }} bulkActionButtons={false}> <Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="ip" sortable={false} /> <TextField source="ip" sortable={false} />
<DateField <DateField source="last_seen" showTime options={DATE_FORMAT} sortable={false} />
source="last_seen" <TextField source="user_agent" sortable={false} style={{ width: "100%" }} />
showTime
options={date_format}
sortable={false}
/>
<TextField
source="user_agent"
sortable={false}
style={{ width: "100%" }}
/>
</Datagrid> </Datagrid>
</ArrayField> </ArrayField>
</ReferenceField> </ReferenceField>
@ -447,19 +258,15 @@ export const UserEdit = props => {
<ReferenceManyField <ReferenceManyField
reference="users_media" reference="users_media"
target="user_id" target="user_id"
addLabel={false} label={false}
pagination={<UserPagination />} pagination={<UserPagination />}
perPage={50} perPage={50}
sort={{ field: "created_ts", order: "DESC" }} sort={{ field: "created_ts", order: "DESC" }}
> >
<Datagrid style={{ width: "100%" }}> <Datagrid style={{ width: "100%" }}>
<MediaIDField source="media_id" /> <MediaIDField source="media_id" />
<DateField source="created_ts" showTime options={date_format} /> <DateField source="created_ts" showTime options={DATE_FORMAT} />
<DateField <DateField source="last_access_ts" showTime options={DATE_FORMAT} />
source="last_access_ts"
showTime
options={date_format}
/>
<NumberField source="media_length" /> <NumberField source="media_length" />
<TextField source="media_type" /> <TextField source="media_type" />
<TextField source="upload_name" /> <TextField source="upload_name" />
@ -471,26 +278,10 @@ export const UserEdit = props => {
</ReferenceManyField> </ReferenceManyField>
</FormTab> </FormTab>
<FormTab <FormTab label={translate("resources.rooms.name", { smart_count: 2 })} icon={<ViewListIcon />} path="rooms">
label={translate("resources.rooms.name", { smart_count: 2 })} <ReferenceManyField reference="joined_rooms" target="user_id" label={false}>
icon={<ViewListIcon />} <Datagrid style={{ width: "100%" }} rowClick={id => "/rooms/" + id + "/show"} bulkActionButtons={false}>
path="rooms" <TextField source="id" sortable={false} label="resources.rooms.fields.room_id" />
>
<ReferenceManyField
reference="joined_rooms"
target="user_id"
addLabel={false}
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, resource, record) => "/rooms/" + id + "/show"}
bulkActionButtons={false}
>
<TextField
source="id"
sortable={false}
label="resources.rooms.fields.room_id"
/>
<ReferenceField <ReferenceField
label="resources.rooms.fields.name" label="resources.rooms.fields.name"
source="id" source="id"
@ -509,11 +300,7 @@ export const UserEdit = props => {
icon={<NotificationsIcon />} icon={<NotificationsIcon />}
path="pushers" path="pushers"
> >
<ReferenceManyField <ReferenceManyField reference="pushers" target="user_id" label={false}>
reference="pushers"
target="user_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}> <Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="kind" sortable={false} /> <TextField source="kind" sortable={false} />
<TextField source="app_display_name" sortable={false} /> <TextField source="app_display_name" sortable={false} />
@ -531,7 +318,7 @@ export const UserEdit = props => {
); );
}; };
const resource = { const resource: ResourceProps = {
name: "users", name: "users",
icon: UserIcon, icon: UserIcon,
list: UserList, list: UserList,

View file

@ -1,6 +1,8 @@
import { formalGermanMessages } from "@haleos/ra-language-german"; import { formalGermanMessages } from "@haleos/ra-language-german";
const de = { import { SynapseTranslationMessages } from ".";
const de: SynapseTranslationMessages = {
...formalGermanMessages, ...formalGermanMessages,
synapseadmin: { synapseadmin: {
auth: { auth: {
@ -44,11 +46,9 @@ const de = {
cards: { cards: {
importstats: { importstats: {
header: "Benutzer importieren", header: "Benutzer importieren",
users_total: users_total: "%{smart_count} Benutzer in der CSV Datei |||| %{smart_count} Benutzer in der CSV Datei",
"%{smart_count} Benutzer in der CSV Datei |||| %{smart_count} Benutzer in der CSV Datei",
guest_count: "%{smart_count} Gast |||| %{smart_count} Gäste", guest_count: "%{smart_count} Gast |||| %{smart_count} Gäste",
admin_count: admin_count: "%{smart_count} Server Administrator |||| %{smart_count} Server Administratoren",
"%{smart_count} Server Administrator |||| %{smart_count} Server Administratoren",
}, },
conflicts: { conflicts: {
header: "Konfliktstrategie", header: "Konfliktstrategie",
@ -60,8 +60,7 @@ const de = {
ids: { ids: {
header: "IDs", header: "IDs",
all_ids_present: "IDs in jedem Eintrag vorhanden", all_ids_present: "IDs in jedem Eintrag vorhanden",
count_ids_present: count_ids_present: "%{smart_count} Eintrag mit ID |||| %{smart_count} Einträge mit IDs",
"%{smart_count} Eintrag mit ID |||| %{smart_count} Einträge mit IDs",
mode: { mode: {
ignore: "Ignoriere IDs der CSV-Datei und erstelle neue", ignore: "Ignoriere IDs der CSV-Datei und erstelle neue",
update: "Aktualisiere existierende Benutzer", update: "Aktualisiere existierende Benutzer",
@ -70,8 +69,7 @@ const de = {
passwords: { passwords: {
header: "Passwörter", header: "Passwörter",
all_passwords_present: "Passwörter in jedem Eintrag vorhanden", all_passwords_present: "Passwörter in jedem Eintrag vorhanden",
count_passwords_present: count_passwords_present: "%{smart_count} Eintrag mit Passwort |||| %{smart_count} Einträge mit Passwörtern",
"%{smart_count} Eintrag mit Passwort |||| %{smart_count} Einträge mit Passwörtern",
use_passwords: "Verwende Passwörter aus der CSV Datei", use_passwords: "Verwende Passwörter aus der CSV Datei",
}, },
upload: { upload: {
@ -85,13 +83,11 @@ const de = {
}, },
results: { results: {
header: "Ergebnis", header: "Ergebnis",
total: total: "%{smart_count} Eintrag insgesamt |||| %{smart_count} Einträge insgesamt",
"%{smart_count} Eintrag insgesamt |||| %{smart_count} Einträge insgesamt",
successful: "%{smart_count} Einträge erfolgreich importiert", successful: "%{smart_count} Einträge erfolgreich importiert",
skipped: "%{smart_count} Einträge übersprungen", skipped: "%{smart_count} Einträge übersprungen",
download_skipped: "Übersprungene Einträge herunterladen", download_skipped: "Übersprungene Einträge herunterladen",
with_error: with_error: "%{smart_count} Eintrag mit Fehlern ||| %{smart_count} Einträge mit Fehlern",
"%{smart_count} Eintrag mit Fehlern ||| %{smart_count} Einträge mit Fehlern",
simulated_only: "Import-Vorgang war nur simuliert", simulated_only: "Import-Vorgang war nur simuliert",
}, },
}, },
@ -108,7 +104,9 @@ const de = {
name: "Name", name: "Name",
is_guest: "Gast", is_guest: "Gast",
admin: "Server Administrator", admin: "Server Administrator",
locked: "Gesperrt",
deactivated: "Deaktiviert", deactivated: "Deaktiviert",
erased: "Gelöscht",
guests: "Zeige Gäste", guests: "Zeige Gäste",
show_deactivated: "Zeige deaktivierte Benutzer", show_deactivated: "Zeige deaktivierte Benutzer",
user_id: "Suche Benutzer", user_id: "Suche Benutzer",
@ -125,10 +123,8 @@ const de = {
user_type: "Benutzertyp", user_type: "Benutzertyp",
}, },
helper: { helper: {
password: password: "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.",
"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.",
deactivate:
"Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
erase: "DSGVO konformes Löschen der Benutzerdaten", erase: "DSGVO konformes Löschen der Benutzerdaten",
}, },
action: { action: {
@ -211,6 +207,7 @@ const de = {
info: { info: {
mimetype: "Typ", mimetype: "Typ",
}, },
url: "URL",
}, },
}, },
}, },
@ -359,8 +356,7 @@ const de = {
guest_can_join: "Gastbenutzer dürfen beitreten", guest_can_join: "Gastbenutzer dürfen beitreten",
}, },
action: { action: {
title: title: "Raum aus Verzeichnis löschen |||| %{smart_count} Räume aus Verzeichnis löschen",
"Raum aus Verzeichnis löschen |||| %{smart_count} Räume aus Verzeichnis löschen",
content: 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?", "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", erase: "Lösche aus Verzeichnis",

View file

@ -1,6 +1,8 @@
import englishMessages from "ra-language-english"; import englishMessages from "ra-language-english";
const en = { import { SynapseTranslationMessages } from ".";
const en: SynapseTranslationMessages = {
...englishMessages, ...englishMessages,
synapseadmin: { synapseadmin: {
auth: { auth: {
@ -18,6 +20,7 @@ const en = {
tabs: { sso: "SSO" }, tabs: { sso: "SSO" },
}, },
rooms: { rooms: {
details: "Room details",
tabs: { tabs: {
basic: "Basic", basic: "Basic",
members: "Members", members: "Members",
@ -32,10 +35,8 @@ const en = {
at_entry: "At entry %{entry}: %{message}", at_entry: "At entry %{entry}: %{message}",
error: "Error", error: "Error",
required_field: "Required field '%{field}' is not present", required_field: "Required field '%{field}' is not present",
invalid_value: invalid_value: "Invalid value on line %{row}. '%{field}' field may only be 'true' or 'false'",
"Invalid value on line %{row}. '%{field}' field may only be 'true' or 'false'", unreasonably_big: "Refused to load unreasonably big file of %{size} megabytes",
unreasonably_big:
"Refused to load unreasonably big file of %{size} megabytes",
already_in_progress: "An import run is already in progress", already_in_progress: "An import run is already in progress",
id_exits: "ID %{id} already present", id_exits: "ID %{id} already present",
}, },
@ -44,8 +45,7 @@ const en = {
cards: { cards: {
importstats: { importstats: {
header: "Import users", header: "Import users",
users_total: users_total: "%{smart_count} user in CSV file |||| %{smart_count} users in CSV file",
"%{smart_count} user in CSV file |||| %{smart_count} users in CSV file",
guest_count: "%{smart_count} guest |||| %{smart_count} guests", guest_count: "%{smart_count} guest |||| %{smart_count} guests",
admin_count: "%{smart_count} admin |||| %{smart_count} admins", admin_count: "%{smart_count} admin |||| %{smart_count} admins",
}, },
@ -59,8 +59,7 @@ const en = {
ids: { ids: {
header: "IDs", header: "IDs",
all_ids_present: "IDs present on every entry", all_ids_present: "IDs present on every entry",
count_ids_present: count_ids_present: "%{smart_count} entry with ID |||| %{smart_count} entries with IDs",
"%{smart_count} entry with ID |||| %{smart_count} entries with IDs",
mode: { mode: {
ignore: "Ignore IDs in CSV and create new ones", ignore: "Ignore IDs in CSV and create new ones",
update: "Update existing records", update: "Update existing records",
@ -69,8 +68,7 @@ const en = {
passwords: { passwords: {
header: "Passwords", header: "Passwords",
all_passwords_present: "Passwords present on every entry", all_passwords_present: "Passwords present on every entry",
count_passwords_present: count_passwords_present: "%{smart_count} entry with password |||| %{smart_count} entries with passwords",
"%{smart_count} entry with password |||| %{smart_count} entries with passwords",
use_passwords: "Use passwords from CSV", use_passwords: "Use passwords from CSV",
}, },
upload: { upload: {
@ -84,13 +82,11 @@ const en = {
}, },
results: { results: {
header: "Import results", header: "Import results",
total: total: "%{smart_count} entry in total |||| %{smart_count} entries in total",
"%{smart_count} entry in total |||| %{smart_count} entries in total",
successful: "%{smart_count} entries successfully imported", successful: "%{smart_count} entries successfully imported",
skipped: "%{smart_count} entries skipped", skipped: "%{smart_count} entries skipped",
download_skipped: "Download skipped records", download_skipped: "Download skipped records",
with_error: with_error: "%{smart_count} entry with errors ||| %{smart_count} entries with errors",
"%{smart_count} entry with errors ||| %{smart_count} entries with errors",
simulated_only: "Run was only simulated", simulated_only: "Run was only simulated",
}, },
}, },
@ -107,7 +103,9 @@ const en = {
name: "Name", name: "Name",
is_guest: "Guest", is_guest: "Guest",
admin: "Server Administrator", admin: "Server Administrator",
locked: "Locked",
deactivated: "Deactivated", deactivated: "Deactivated",
erased: "Erased",
guests: "Show guests", guests: "Show guests",
show_deactivated: "Show deactivated users", show_deactivated: "Show deactivated users",
user_id: "Search user", user_id: "Search user",
@ -215,8 +213,7 @@ const en = {
action: { action: {
erase: { erase: {
title: "Delete reported event", title: "Delete reported event",
content: content: "Are you sure you want to delete the reported event? This cannot be undone.",
"Are you sure you want to delete the reported event? This cannot be undone.",
}, },
}, },
}, },
@ -357,8 +354,7 @@ const en = {
guest_can_join: "guest users may join", guest_can_join: "guest users may join",
}, },
action: { action: {
title: title: "Delete room from directory |||| Delete %{smart_count} rooms from directory",
"Delete room from directory |||| Delete %{smart_count} rooms from directory",
content: 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?", "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", erase: "Delete from room directory",

View file

@ -1,6 +1,8 @@
import farsiMessages from "ra-language-farsi"; import farsiMessages from "ra-language-farsi";
const fa = { import { SynapseTranslationMessages } from ".";
const fa: SynapseTranslationMessages = {
...farsiMessages, ...farsiMessages,
synapseadmin: { synapseadmin: {
auth: { auth: {
@ -31,10 +33,8 @@ const fa = {
at_entry: "در هنگام ورود %{entry}: %{message}", at_entry: "در هنگام ورود %{entry}: %{message}",
error: "Error", error: "Error",
required_field: "فیلد الزامی '%{field}' وجود ندارد", required_field: "فیلد الزامی '%{field}' وجود ندارد",
invalid_value: invalid_value: "خطا در خط %{row}. '%{field}' فیلد ممکن است فقط 'درست' یا 'نادرست' باشد",
"خطا در خط %{row}. '%{field}' فیلد ممکن است فقط 'درست' یا 'نادرست' باشد", unreasonably_big: "از بارگذاری فایل هایی با حجم غیر منطقی خودداری کنید %{size} مگابایت",
unreasonably_big:
"از بارگذاری فایل هایی با حجم غیر منطقی خودداری کنید %{size} مگابایت",
already_in_progress: "یک بارگذاری از قبل در حال انجام است", already_in_progress: "یک بارگذاری از قبل در حال انجام است",
id_exits: "شناسه %{id} موجود است", id_exits: "شناسه %{id} موجود است",
}, },
@ -43,8 +43,7 @@ const fa = {
cards: { cards: {
importstats: { importstats: {
header: "وارد کردن کاربران", header: "وارد کردن کاربران",
users_total: users_total: "%{smart_count} user in CSV file |||| %{smart_count} users in CSV file",
"%{smart_count} user in CSV file |||| %{smart_count} users in CSV file",
guest_count: "%{smart_count} guest |||| %{smart_count} guests", guest_count: "%{smart_count} guest |||| %{smart_count} guests",
admin_count: "%{smart_count} admin |||| %{smart_count} admins", admin_count: "%{smart_count} admin |||| %{smart_count} admins",
}, },
@ -58,8 +57,7 @@ const fa = {
ids: { ids: {
header: "شناسنامه ها", header: "شناسنامه ها",
all_ids_present: "شناسه های موجود در هر ورودی", all_ids_present: "شناسه های موجود در هر ورودی",
count_ids_present: count_ids_present: "%{smart_count} ورود با شناسه |||| %{smart_count} ورودی با شناسه",
"%{smart_count} ورود با شناسه |||| %{smart_count} ورودی با شناسه",
mode: { mode: {
ignore: "شناسه ها را در CSV نادیده بگیر و شناسه های جدید ایجاد کن", ignore: "شناسه ها را در CSV نادیده بگیر و شناسه های جدید ایجاد کن",
update: "سوابق موجود را به روز کنید", update: "سوابق موجود را به روز کنید",
@ -68,8 +66,7 @@ const fa = {
passwords: { passwords: {
header: "رمز عبور", header: "رمز عبور",
all_passwords_present: "رمزهای عبور موجود در هر ورودی", all_passwords_present: "رمزهای عبور موجود در هر ورودی",
count_passwords_present: count_passwords_present: "%{smart_count} ورود با رمز عبور |||| %{smart_count} ورودی با رمز عبور",
"%{smart_count} ورود با رمز عبور |||| %{smart_count} ورودی با رمز عبور",
use_passwords: "از پسوردهای CSV استفاده کنید", use_passwords: "از پسوردهای CSV استفاده کنید",
}, },
upload: { upload: {
@ -87,8 +84,7 @@ const fa = {
successful: "%{smart_count} ورودی ها با موفقیت وارد شدند", successful: "%{smart_count} ورودی ها با موفقیت وارد شدند",
skipped: "%{smart_count} ورودی ها نادیده گرفته شدند", skipped: "%{smart_count} ورودی ها نادیده گرفته شدند",
download_skipped: "دانلود رکوردهای نادیده گرفته شده", download_skipped: "دانلود رکوردهای نادیده گرفته شده",
with_error: with_error: "%{smart_count} ورود با خطا ||| %{smart_count} ورودی های دارای خطا",
"%{smart_count} ورود با خطا ||| %{smart_count} ورودی های دارای خطا",
simulated_only: "اجرا فقط شبیه سازی شد", simulated_only: "اجرا فقط شبیه سازی شد",
}, },
}, },
@ -226,8 +222,7 @@ const fa = {
action: { action: {
erase: { erase: {
title: "حذف کردن %{id}", title: "حذف کردن %{id}",
content: content: 'آیا مطمئن هستید که می خواهید دستگاه را حذف کنید؟ "%{name}"?',
'آیا مطمئن هستید که می خواهید دستگاه را حذف کنید؟ "%{name}"?',
success: "دستگاه با موفقیت حذف شد.", success: "دستگاه با موفقیت حذف شد.",
failure: "خطایی رخ داده است.", failure: "خطایی رخ داده است.",
}, },
@ -342,8 +337,7 @@ const fa = {
guest_can_join: "کاربران مهمان ممکن است ملحق شوند", guest_can_join: "کاربران مهمان ممکن است ملحق شوند",
}, },
action: { action: {
title: title: "اتاق را از فهرست حذف کنید |||| حذف کنید %{smart_count} اتاق ها از دایرکتوری",
"اتاق را از فهرست حذف کنید |||| حذف کنید %{smart_count} اتاق ها از دایرکتوری",
content: content:
"آیا مطمئنید که می خواهید این اتاق را از فهرست راهنمای حذف کنید؟ |||| آیا مطمئن هستید که می خواهید این موارد را %{smart_count} از راهنمای اتاق ها حذف کنید؟", "آیا مطمئنید که می خواهید این اتاق را از فهرست راهنمای حذف کنید؟ |||| آیا مطمئن هستید که می خواهید این موارد را %{smart_count} از راهنمای اتاق ها حذف کنید؟",
erase: "حذف از فهرست اتاق", erase: "حذف از فهرست اتاق",

View file

@ -1,21 +1,21 @@
import frenchMessages from "ra-language-french"; import frenchMessages from "ra-language-french";
const fr = { import { SynapseTranslationMessages } from ".";
const fr: SynapseTranslationMessages = {
...frenchMessages, ...frenchMessages,
synapseadmin: { synapseadmin: {
auth: { auth: {
base_url: "URL du serveur daccueil", base_url: "URL du serveur daccueil",
welcome: "Bienvenue sur Synapse-admin", welcome: "Bienvenue sur Synapse-admin",
server_version: "Version du serveur Synapse", server_version: "Version du serveur Synapse",
username_error: username_error: "Veuillez entrer un nom d'utilisateur complet : « @utilisateur:domaine »",
"Veuillez entrer un nom d'utilisateur complet : « @utilisateur:domaine »",
protocol_error: "L'URL doit commencer par « http:// » ou « https:// »", protocol_error: "L'URL doit commencer par « http:// » ou « https:// »",
url_error: "L'URL du serveur Matrix n'est pas valide", url_error: "L'URL du serveur Matrix n'est pas valide",
sso_sign_in: "Se connecter avec lauthentification unique", sso_sign_in: "Se connecter avec lauthentification unique",
}, },
users: { users: {
invalid_user_id: invalid_user_id: "Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur daccueil.",
"Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur daccueil.",
tabs: { sso: "Authentification unique" }, tabs: { sso: "Authentification unique" },
}, },
rooms: { rooms: {
@ -35,8 +35,7 @@ const fr = {
required_field: "Le champ requis « %{field} » est manquant", required_field: "Le champ requis « %{field} » est manquant",
invalid_value: invalid_value:
"Valeur non valide à la ligne %{row}. Le champ « %{field} » ne peut être que « true » ou « false »", "Valeur non valide à la ligne %{row}. Le champ « %{field} » ne peut être que « true » ou « false »",
unreasonably_big: unreasonably_big: "Refus de charger un fichier trop volumineux de %{size} mégaoctets",
"Refus de charger un fichier trop volumineux de %{size} mégaoctets",
already_in_progress: "Un import est déjà en cours", already_in_progress: "Un import est déjà en cours",
id_exits: "L'identifiant %{id} déjà présent", id_exits: "L'identifiant %{id} déjà présent",
}, },
@ -48,8 +47,7 @@ const fr = {
users_total: users_total:
"%{smart_count} utilisateur dans le fichier CSV |||| %{smart_count} utilisateurs dans le fichier CSV", "%{smart_count} utilisateur dans le fichier CSV |||| %{smart_count} utilisateurs dans le fichier CSV",
guest_count: "%{smart_count} visiteur |||| %{smart_count} visiteurs", guest_count: "%{smart_count} visiteur |||| %{smart_count} visiteurs",
admin_count: admin_count: "%{smart_count} administrateur |||| %{smart_count} administrateurs",
"%{smart_count} administrateur |||| %{smart_count} administrateurs",
}, },
conflicts: { conflicts: {
header: "Stratégie de résolution des conflits", header: "Stratégie de résolution des conflits",
@ -61,11 +59,9 @@ const fr = {
ids: { ids: {
header: "Identifiants", header: "Identifiants",
all_ids_present: "Identifiants présents pour chaque entrée", all_ids_present: "Identifiants présents pour chaque entrée",
count_ids_present: count_ids_present: "%{smart_count} entrée avec identifiant |||| %{smart_count} entrées avec identifiant",
"%{smart_count} entrée avec identifiant |||| %{smart_count} entrées avec identifiant",
mode: { mode: {
ignore: ignore: "Ignorer les identifiants dans le ficher CSV et en créer de nouveaux",
"Ignorer les identifiants dans le ficher CSV et en créer de nouveaux",
update: "Mettre à jour les enregistrements existants", update: "Mettre à jour les enregistrements existants",
}, },
}, },
@ -87,13 +83,11 @@ const fr = {
}, },
results: { results: {
header: "Résultats de l'import", header: "Résultats de l'import",
total: total: "%{smart_count} entrée au total |||| %{smart_count} entrées au total",
"%{smart_count} entrée au total |||| %{smart_count} entrées au total",
successful: "%{smart_count} entrées importées avec succès", successful: "%{smart_count} entrées importées avec succès",
skipped: "%{smart_count} entrées ignorées", skipped: "%{smart_count} entrées ignorées",
download_skipped: "Télécharger les entrées ignorées", download_skipped: "Télécharger les entrées ignorées",
with_error: with_error: "%{smart_count} entrée avec des erreurs ||| %{smart_count} entrées avec des erreurs",
"%{smart_count} entrée avec des erreurs ||| %{smart_count} entrées avec des erreurs",
simulated_only: "L'import était simulé", simulated_only: "L'import était simulé",
}, },
}, },
@ -110,6 +104,7 @@ const fr = {
name: "Nom", name: "Nom",
is_guest: "Visiteur", is_guest: "Visiteur",
admin: "Administrateur du serveur", admin: "Administrateur du serveur",
locked: "Verrouillé",
deactivated: "Désactivé", deactivated: "Désactivé",
guests: "Afficher les visiteurs", guests: "Afficher les visiteurs",
show_deactivated: "Afficher les utilisateurs désactivés", show_deactivated: "Afficher les utilisateurs désactivés",
@ -126,8 +121,7 @@ const fr = {
auth_provider: "Fournisseur d'identité", auth_provider: "Fournisseur d'identité",
}, },
helper: { helper: {
deactivate: deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.",
"Vous devrez fournir un mot de passe pour réactiver le compte.",
erase: "Marquer l'utilisateur comme effacé conformément au RGPD", erase: "Marquer l'utilisateur comme effacé conformément au RGPD",
}, },
action: { action: {
@ -341,13 +335,11 @@ const fr = {
room_directory: { room_directory: {
name: "Répertoire des salons", name: "Répertoire des salons",
fields: { fields: {
world_readable: world_readable: "Tout utilisateur peut avoir un aperçu du salon, sans en devenir membre",
"Tout utilisateur peut avoir un aperçu du salon, sans en devenir membre",
guest_can_join: "Les visiteurs peuvent rejoindre le salon", guest_can_join: "Les visiteurs peuvent rejoindre le salon",
}, },
action: { action: {
title: title: "Supprimer un salon du répertoire |||| Supprimer %{smart_count} salons du répertoire",
"Supprimer un salon du répertoire |||| Supprimer %{smart_count} salons du répertoire",
content: content:
"Voulez-vous vraiment supprimer ce salon du répertoire ? |||| Voulez-vous vraiment supprimer ces %{smart_count} salons du répertoire ?", "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", erase: "Supprimer du répertoire des salons",
@ -368,8 +360,7 @@ const fr = {
length: "Longueur", length: "Longueur",
}, },
helper: { helper: {
length: length: "Longueur du jeton généré aléatoirement si aucun jeton n'est pas spécifié",
"Longueur du jeton généré aléatoirement si aucun jeton n'est pas spécifié",
}, },
}, },
}, },

391
src/i18n/index.d.ts vendored Normal file
View file

@ -0,0 +1,391 @@
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;
};
};
};
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;
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;
};
};
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;
};
};
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;
};
};
};
}

View file

@ -1,14 +1,15 @@
import italianMessages from "ra-language-italian"; import italianMessages from "ra-language-italian";
const it = { import { SynapseTranslationMessages } from ".";
const it: SynapseTranslationMessages = {
...italianMessages, ...italianMessages,
synapseadmin: { synapseadmin: {
auth: { auth: {
base_url: "URL dell'homeserver", base_url: "URL dell'homeserver",
welcome: "Benvenuto in Synapse-admin", welcome: "Benvenuto in Synapse-admin",
server_version: "Versione di Synapse", server_version: "Versione di Synapse",
username_error: username_error: "Per favore inserisci un ID utente completo: '@utente:dominio'",
"Per favore inserisci un ID utente completo: '@utente:dominio'",
protocol_error: "L'URL deve iniziare per 'http://' o 'https://'", protocol_error: "L'URL deve iniziare per 'http://' o 'https://'",
url_error: "URL del server Matrix non valido", url_error: "URL del server Matrix non valido",
sso_sign_in: "Accedi con SSO", sso_sign_in: "Accedi con SSO",
@ -32,10 +33,8 @@ const it = {
at_entry: "Alla voce %{entry}: %{message}", at_entry: "Alla voce %{entry}: %{message}",
error: "Errore", error: "Errore",
required_field: "Il campo '%{field}' non è presente", required_field: "Il campo '%{field}' non è presente",
invalid_value: invalid_value: "Valore non valido alla riga %{row}. '%{field}' Il campo può essere solo 'true' o 'false'",
"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)",
unreasonably_big:
"Impossibile caricare un file così grosso (%{size} megabyte)",
already_in_progress: "Un import è attualmente già in caricamento", already_in_progress: "Un import è attualmente già in caricamento",
id_exits: "L'ID %{id} è già presente", id_exits: "L'ID %{id} è già presente",
}, },
@ -44,11 +43,9 @@ const it = {
cards: { cards: {
importstats: { importstats: {
header: "Importa utenti", header: "Importa utenti",
users_total: users_total: "%{smart_count} utente nel file CSV |||| %{smart_count} utenti nel file CSV",
"%{smart_count} utente nel file CSV |||| %{smart_count} utenti nel file CSV",
guest_count: "%{smart_count} ospite |||| %{smart_count} ospiti", guest_count: "%{smart_count} ospite |||| %{smart_count} ospiti",
admin_count: admin_count: "%{smart_count} amministratore |||| %{smart_count} amministratori",
"%{smart_count} amministratore |||| %{smart_count} amministratori",
}, },
conflicts: { conflicts: {
header: "Strategia di conflitto", header: "Strategia di conflitto",
@ -60,8 +57,7 @@ const it = {
ids: { ids: {
header: "ID", header: "ID",
all_ids_present: "ID presenti in ogni voce", all_ids_present: "ID presenti in ogni voce",
count_ids_present: count_ids_present: "%{smart_count} voce con ID |||| %{smart_count} voci con ID",
"%{smart_count} voce con ID |||| %{smart_count} voci con ID",
mode: { mode: {
ignore: "Ignora gli ID nel file CSV e creane di nuovi", ignore: "Ignora gli ID nel file CSV e creane di nuovi",
update: "Aggiorna le voci esistenti", update: "Aggiorna le voci esistenti",
@ -70,8 +66,7 @@ const it = {
passwords: { passwords: {
header: "Passwords", header: "Passwords",
all_passwords_present: "Password presenti in ogni voce", all_passwords_present: "Password presenti in ogni voce",
count_passwords_present: count_passwords_present: "%{smart_count} voce con password |||| %{smart_count} voci con password",
"%{smart_count} voce con password |||| %{smart_count} voci con password",
use_passwords: "Usa le password dal file CSV", use_passwords: "Usa le password dal file CSV",
}, },
upload: { upload: {
@ -85,13 +80,11 @@ const it = {
}, },
results: { results: {
header: "Importa i risultati", header: "Importa i risultati",
total: total: "%{smart_count} voce in totale |||| %{smart_count} voci in totale",
"%{smart_count} voce in totale |||| %{smart_count} voci in totale",
successful: "%{smart_count} voci importate con successo", successful: "%{smart_count} voci importate con successo",
skipped: "%{smart_count} voci ignorate", skipped: "%{smart_count} voci ignorate",
download_skipped: "Scarica le voci ignorate", download_skipped: "Scarica le voci ignorate",
with_error: with_error: "%{smart_count} voce con errori ||| %{smart_count} voci con errori",
"%{smart_count} voce con errori ||| %{smart_count} voci con errori",
simulated_only: "Il processo era stato solamente simulato", simulated_only: "Il processo era stato solamente simulato",
}, },
}, },
@ -108,6 +101,7 @@ const it = {
name: "Nome", name: "Nome",
is_guest: "Ospite", is_guest: "Ospite",
admin: "Amministratore", admin: "Amministratore",
locked: "Bloccato",
deactivated: "Disattivato", deactivated: "Disattivato",
guests: "Mostra gli ospiti", guests: "Mostra gli ospiti",
show_deactivated: "Mostra gli utenti disattivati", show_deactivated: "Mostra gli utenti disattivati",
@ -125,8 +119,7 @@ const it = {
user_type: "Tipo d'utente", user_type: "Tipo d'utente",
}, },
helper: { helper: {
password: password: "Cambiando la password l'utente verrà disconnesso da tutte le sessioni attive.",
"Cambiando la password l'utente verrà disconnesso da tutte le sessioni attive.",
deactivate: "Devi fornire una password per riattivare l'account.", deactivate: "Devi fornire una password per riattivare l'account.",
erase: "Constrassegna l'utente come cancellato dal GDPR", erase: "Constrassegna l'utente come cancellato dal GDPR",
}, },
@ -345,8 +338,7 @@ const it = {
guest_can_join: "gli utenti ospite possono entrare", guest_can_join: "gli utenti ospite possono entrare",
}, },
action: { action: {
title: title: "Cancella stanza dall'elenco |||| Cancella %{smart_count} stanze dall'elenco",
"Cancella stanza dall'elenco |||| Cancella %{smart_count} stanze dall'elenco",
content: content:
"Sei sicuro di voler rimuovere questa stanza dall'elenco? |||| Sei sicuro di voler rimuovere %{smart_count} stanze dall'elenco?", "Sei sicuro di voler rimuovere questa stanza dall'elenco? |||| Sei sicuro di voler rimuovere %{smart_count} stanze dall'elenco?",
erase: "Rimuovi dall'elenco", erase: "Rimuovi dall'elenco",

View file

@ -1,6 +1,8 @@
import chineseMessages from "@haxqer/ra-language-chinese"; import chineseMessages from "@haxqer/ra-language-chinese";
const zh = { import { SynapseTranslationMessages } from ".";
const zh: SynapseTranslationMessages = {
...chineseMessages, ...chineseMessages,
synapseadmin: { synapseadmin: {
auth: { auth: {
@ -13,8 +15,7 @@ const zh = {
sso_sign_in: "使用 SSO 登录", sso_sign_in: "使用 SSO 登录",
}, },
users: { users: {
invalid_user_id: invalid_user_id: "必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver",
"必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver",
tabs: { sso: "SSO" }, tabs: { sso: "SSO" },
}, },
rooms: { rooms: {
@ -24,11 +25,6 @@ const zh = {
detail: "细节", detail: "细节",
permission: "权限", permission: "权限",
}, },
delete: {
title: "删除房间",
message:
"您确定要删除这个房间吗?该操作无法被撤销。这个房间里所有的消息和分享的媒体都将被从服务器上删除!",
},
}, },
reports: { tabs: { basic: "基本", detail: "细节" } }, reports: { tabs: { basic: "基本", detail: "细节" } },
}, },
@ -37,8 +33,7 @@ const zh = {
at_entry: "在条目 %{entry}: %{message}", at_entry: "在条目 %{entry}: %{message}",
error: "错误", error: "错误",
required_field: "需要的值 '%{field}' 未被设置。", required_field: "需要的值 '%{field}' 未被设置。",
invalid_value: invalid_value: "第 %{row} 行出现无效值。 '%{field}' 只可以是 'true' 或 'false'。",
"第 %{row} 行出现无效值。 '%{field}' 只可以是 'true' 或 'false'。",
unreasonably_big: "拒绝加载过大的文件: %{size} MB", unreasonably_big: "拒绝加载过大的文件: %{size} MB",
already_in_progress: "一个导入进程已经在运行中", already_in_progress: "一个导入进程已经在运行中",
id_exits: "ID %{id} 已经存在", id_exits: "ID %{id} 已经存在",
@ -48,8 +43,7 @@ const zh = {
cards: { cards: {
importstats: { importstats: {
header: "导入用户", header: "导入用户",
users_total: users_total: "%{smart_count} 用户在 CSV 文件中 |||| %{smart_count} 用户在 CSV 文件中",
"%{smart_count} 用户在 CSV 文件中 |||| %{smart_count} 用户在 CSV 文件中",
guest_count: "%{smart_count} 访客 |||| %{smart_count} 访客", guest_count: "%{smart_count} 访客 |||| %{smart_count} 访客",
admin_count: "%{smart_count} 管理员 |||| %{smart_count} 管理员", admin_count: "%{smart_count} 管理员 |||| %{smart_count} 管理员",
}, },
@ -63,8 +57,7 @@ const zh = {
ids: { ids: {
header: "IDs", header: "IDs",
all_ids_present: "每条记录的 ID", all_ids_present: "每条记录的 ID",
count_ids_present: count_ids_present: "%{smart_count} 个含 ID 的记录 |||| %{smart_count} 个含 ID 的记录",
"%{smart_count} 个含 ID 的记录 |||| %{smart_count} 个含 ID 的记录",
mode: { mode: {
ignore: "忽略 CSV 中的 ID 并创建新的", ignore: "忽略 CSV 中的 ID 并创建新的",
update: "更新已经存在的记录", update: "更新已经存在的记录",
@ -73,8 +66,7 @@ const zh = {
passwords: { passwords: {
header: "密码", header: "密码",
all_passwords_present: "每条记录的密码", all_passwords_present: "每条记录的密码",
count_passwords_present: count_passwords_present: "%{smart_count} 个含密码的记录 |||| %{smart_count} 个含密码的记录",
"%{smart_count} 个含密码的记录 |||| %{smart_count} 个含密码的记录",
use_passwords: "使用 CSV 中标记的密码", use_passwords: "使用 CSV 中标记的密码",
}, },
upload: { upload: {
@ -92,8 +84,7 @@ const zh = {
successful: "%{smart_count} 条记录导入成功", successful: "%{smart_count} 条记录导入成功",
skipped: "跳过 %{smart_count} 条记录", skipped: "跳过 %{smart_count} 条记录",
download_skipped: "下载跳过的记录", download_skipped: "下载跳过的记录",
with_error: with_error: "%{smart_count} 条记录出现错误 ||| %{smart_count} 条记录出现错误",
"%{smart_count} 条记录出现错误 ||| %{smart_count} 条记录出现错误",
simulated_only: "只是一次模拟运行", simulated_only: "只是一次模拟运行",
}, },
}, },

View file

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";

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

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

View file

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

View file

@ -1,14 +1,18 @@
import fetchMock from "jest-fetch-mock";
import authProvider from "./authProvider"; import authProvider from "./authProvider";
fetchMock.enableMocks();
describe("authProvider", () => { describe("authProvider", () => {
beforeEach(() => { beforeEach(() => {
fetch.resetMocks(); fetchMock.resetMocks();
localStorage.clear(); localStorage.clear();
}); });
describe("login", () => { describe("login", () => {
it("should successfully login with username and password", async () => { it("should successfully login with username and password", async () => {
fetch.once( fetchMock.once(
JSON.stringify({ JSON.stringify({
home_server: "example.com", home_server: "example.com",
user_id: "@user:example.com", user_id: "@user:example.com",
@ -17,24 +21,21 @@ describe("authProvider", () => {
}) })
); );
const ret = await authProvider.login({ const ret: undefined = await authProvider.login({
base_url: "http://example.com", base_url: "http://example.com",
username: "@user:example.com", username: "@user:example.com",
password: "secret", password: "secret",
}); });
expect(ret).toBe(undefined); expect(ret).toBe(undefined);
expect(fetch).toBeCalledWith( expect(fetch).toBeCalledWith("http://example.com/_matrix/client/r0/login", {
"http://example.com/_matrix/client/r0/login",
{
body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.password","user":"@user:example.com","password":"secret"}', body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.password","user":"@user:example.com","password":"secret"}',
headers: new Headers({ headers: new Headers({
Accept: ["application/json"], Accept: "application/json",
"Content-Type": ["application/json"], "Content-Type": "application/json",
}), }),
method: "POST", method: "POST",
} });
);
expect(localStorage.getItem("base_url")).toEqual("http://example.com"); expect(localStorage.getItem("base_url")).toEqual("http://example.com");
expect(localStorage.getItem("user_id")).toEqual("@user:example.com"); expect(localStorage.getItem("user_id")).toEqual("@user:example.com");
expect(localStorage.getItem("access_token")).toEqual("foobar"); expect(localStorage.getItem("access_token")).toEqual("foobar");
@ -43,7 +44,7 @@ describe("authProvider", () => {
}); });
it("should successfully login with token", async () => { it("should successfully login with token", async () => {
fetch.once( fetchMock.once(
JSON.stringify({ JSON.stringify({
home_server: "example.com", home_server: "example.com",
user_id: "@user:example.com", user_id: "@user:example.com",
@ -52,23 +53,20 @@ describe("authProvider", () => {
}) })
); );
const ret = await authProvider.login({ const ret: undefined = await authProvider.login({
base_url: "https://example.com/", base_url: "https://example.com/",
loginToken: "login_token", loginToken: "login_token",
}); });
expect(ret).toBe(undefined); expect(ret).toBe(undefined);
expect(fetch).toBeCalledWith( expect(fetch).toHaveBeenCalledWith("https://example.com/_matrix/client/r0/login", {
"https://example.com/_matrix/client/r0/login",
{
body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.token","token":"login_token"}', body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.token","token":"login_token"}',
headers: new Headers({ headers: new Headers({
Accept: ["application/json"], Accept: "application/json",
"Content-Type": ["application/json"], "Content-Type": "application/json",
}), }),
method: "POST", method: "POST",
} });
);
expect(localStorage.getItem("base_url")).toEqual("https://example.com"); expect(localStorage.getItem("base_url")).toEqual("https://example.com");
expect(localStorage.getItem("user_id")).toEqual("@user:example.com"); expect(localStorage.getItem("user_id")).toEqual("@user:example.com");
expect(localStorage.getItem("access_token")).toEqual("foobar"); expect(localStorage.getItem("access_token")).toEqual("foobar");
@ -79,14 +77,14 @@ describe("authProvider", () => {
it("should remove the access_token from localStorage", async () => { it("should remove the access_token from localStorage", async () => {
localStorage.setItem("base_url", "example.com"); localStorage.setItem("base_url", "example.com");
localStorage.setItem("access_token", "foo"); localStorage.setItem("access_token", "foo");
fetch.mockResponse(JSON.stringify({})); fetchMock.mockResponse(JSON.stringify({}));
await authProvider.logout(); await authProvider.logout(null);
expect(fetch).toBeCalledWith("example.com/_matrix/client/r0/logout", { expect(fetch).toBeCalledWith("example.com/_matrix/client/r0/logout", {
headers: new Headers({ headers: new Headers({
Accept: ["application/json"], Accept: "application/json",
Authorization: ["Bearer foo"], Authorization: "Bearer foo",
}), }),
method: "POST", method: "POST",
user: { authenticated: true, token: "Bearer foo" }, user: { authenticated: true, token: "Bearer foo" },
@ -97,21 +95,15 @@ describe("authProvider", () => {
describe("checkError", () => { describe("checkError", () => {
it("should resolve if error.status is not 401 or 403", async () => { it("should resolve if error.status is not 401 or 403", async () => {
await expect( await expect(authProvider.checkError({ status: 200 })).resolves.toBeUndefined();
authProvider.checkError({ status: 200 })
).resolves.toBeUndefined();
}); });
it("should reject if error.status is 401", async () => { it("should reject if error.status is 401", async () => {
await expect( await expect(authProvider.checkError({ status: 401 })).rejects.toBeUndefined();
authProvider.checkError({ status: 401 })
).rejects.toBeUndefined();
}); });
it("should reject if error.status is 403", async () => { it("should reject if error.status is 403", async () => {
await expect( await expect(authProvider.checkError({ status: 403 })).rejects.toBeUndefined();
authProvider.checkError({ status: 403 })
).rejects.toBeUndefined();
}); });
}); });
@ -129,7 +121,7 @@ describe("authProvider", () => {
describe("getPermissions", () => { describe("getPermissions", () => {
it("should do nothing", async () => { it("should do nothing", async () => {
await expect(authProvider.getPermissions()).resolves.toBeUndefined(); await expect(authProvider.getPermissions(null)).resolves.toBeUndefined();
}); });
}); });
}); });

View file

@ -1,10 +1,20 @@
import { fetchUtils } from "react-admin"; import { AuthProvider, Options, fetchUtils } from "react-admin";
const authProvider = { const authProvider: AuthProvider = {
// called when the user attempts to log in // called when the user attempts to log in
login: async ({ base_url, username, password, loginToken }) => { login: async ({
base_url,
username,
password,
loginToken,
}: {
base_url: string;
username: string;
password: string;
loginToken: string;
}) => {
console.log("login "); console.log("login ");
const options = { const options: Options = {
method: "POST", method: "POST",
body: JSON.stringify( body: JSON.stringify(
Object.assign( Object.assign(
@ -45,11 +55,10 @@ const authProvider = {
logout: async () => { logout: async () => {
console.log("logout"); console.log("logout");
const logout_api_url = const logout_api_url = localStorage.getItem("base_url") + "/_matrix/client/r0/logout";
localStorage.getItem("base_url") + "/_matrix/client/r0/logout";
const access_token = localStorage.getItem("access_token"); const access_token = localStorage.getItem("access_token");
const options = { const options: Options = {
method: "POST", method: "POST",
user: { user: {
authenticated: true, authenticated: true,
@ -63,7 +72,7 @@ const authProvider = {
} }
}, },
// called when the API returns an error // called when the API returns an error
checkError: ({ status }) => { checkError: ({ status }: { status: number }) => {
console.log("checkError " + status); console.log("checkError " + status);
if (status === 401 || status === 403) { if (status === 401 || status === 403) {
return Promise.reject(); return Promise.reject();
@ -74,9 +83,7 @@ const authProvider = {
checkAuth: () => { checkAuth: () => {
const access_token = localStorage.getItem("access_token"); const access_token = localStorage.getItem("access_token");
console.log("checkAuth " + access_token); console.log("checkAuth " + access_token);
return typeof access_token === "string" return typeof access_token === "string" ? Promise.resolve() : Promise.reject();
? Promise.resolve()
: Promise.reject();
}, },
// called when the user navigates to a new location, to check for permissions / roles // called when the user navigates to a new location, to check for permissions / roles
getPermissions: () => Promise.resolve(), getPermissions: () => Promise.resolve(),

View file

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

View file

@ -1,8 +1,9 @@
import { fetchUtils } from "react-admin";
import { stringify } from "query-string"; import { stringify } from "query-string";
import { DataProvider, DeleteParams, Identifier, Options, RaRecord, fetchUtils } from "react-admin";
// Adds the access token to all requests // Adds the access token to all requests
const jsonClient = (url, options = {}) => { const jsonClient = (url: string, options: Options = {}) => {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
console.log("httpClient " + url); console.log("httpClient " + url);
if (token != null) { if (token != null) {
@ -14,10 +15,10 @@ const jsonClient = (url, options = {}) => {
return fetchUtils.fetchJson(url, options); return fetchUtils.fetchJson(url, options);
}; };
const mxcUrlToHttp = mxcUrl => { const mxcUrlToHttp = (mxcUrl: string) => {
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
const re = /^mxc:\/\/([^/]+)\/(\w+)/; const re = /^mxc:\/\/([^/]+)\/(\w+)/;
var ret = re.exec(mxcUrl); const ret = re.exec(mxcUrl);
console.log("mxcClient " + ret); console.log("mxcClient " + ret);
if (ret == null) return null; if (ret == null) return null;
const serverName = ret[1]; const serverName = ret[1];
@ -25,13 +26,188 @@ const mxcUrlToHttp = mxcUrl => {
return `${homeserver}/_matrix/media/r0/thumbnail/${serverName}/${mediaId}?width=24&height=24&method=scale`; 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;
}
const resourceMap = { const resourceMap = {
users: { users: {
path: "/_synapse/admin/v2/users", path: "/_synapse/admin/v2/users",
map: u => ({ map: (u: User) => ({
...u, ...u,
id: u.name, id: u.name,
avatar_src: mxcUrlToHttp(u.avatar_url), avatar_src: u.avatar_url ? mxcUrlToHttp(u.avatar_url) : undefined,
is_guest: !!u.is_guest, is_guest: !!u.is_guest,
admin: !!u.admin, admin: !!u.admin,
deactivated: !!u.deactivated, deactivated: !!u.deactivated,
@ -40,24 +216,20 @@ const resourceMap = {
}), }),
data: "users", data: "users",
total: json => json.total, total: json => json.total,
create: data => ({ create: (data: RaRecord) => ({
endpoint: `/_synapse/admin/v2/users/@${encodeURIComponent( endpoint: `/_synapse/admin/v2/users/@${encodeURIComponent(data.id)}:${localStorage.getItem("home_server")}`,
data.id
)}:${localStorage.getItem("home_server")}`,
body: data, body: data,
method: "PUT", method: "PUT",
}), }),
delete: params => ({ delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent( endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(params.id)}`,
params.id
)}`,
body: { erase: true }, body: { erase: true },
method: "POST", method: "POST",
}), }),
}, },
rooms: { rooms: {
path: "/_synapse/admin/v1/rooms", path: "/_synapse/admin/v1/rooms",
map: r => ({ map: (r: Room) => ({
...r, ...r,
id: r.room_id, id: r.room_id,
alias: r.canonical_alias, alias: r.canonical_alias,
@ -67,121 +239,98 @@ const resourceMap = {
public: !!r.public, public: !!r.public,
}), }),
data: "rooms", data: "rooms",
total: json => { total: json => json.total_rooms,
return json.total_rooms; delete: (params: DeleteParams) => ({
},
delete: params => ({
endpoint: `/_synapse/admin/v2/rooms/${params.id}`, endpoint: `/_synapse/admin/v2/rooms/${params.id}`,
body: { block: false }, body: { block: false },
}), }),
}, },
reports: { reports: {
path: "/_synapse/admin/v1/event_reports", path: "/_synapse/admin/v1/event_reports",
map: er => ({ map: (er: EventReport) => ({ ...er }),
...er,
id: er.id,
}),
data: "event_reports", data: "event_reports",
total: json => json.total, total: json => json.total,
}, },
devices: { devices: {
map: d => ({ map: (d: Device) => ({
...d, ...d,
id: d.device_id, id: d.device_id,
}), }),
data: "devices", data: "devices",
total: json => { total: json => json.total,
return json.total; reference: (id: Identifier) => ({
},
reference: id => ({
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(id)}/devices`, endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(id)}/devices`,
}), }),
delete: params => ({ delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent( endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(params.previousData.user_id)}/devices/${params.id}`,
params.previousData.user_id
)}/devices/${params.id}`,
}), }),
}, },
connections: { connections: {
path: "/_synapse/admin/v1/whois", path: "/_synapse/admin/v1/whois",
map: c => ({ map: (c: Whois) => ({
...c, ...c,
id: c.user_id, id: c.user_id,
}), }),
data: "connections", data: "connections",
}, },
room_members: { room_members: {
map: m => ({ map: (m: string) => ({
id: m, id: m,
}), }),
reference: id => ({ reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/rooms/${id}/members`, endpoint: `/_synapse/admin/v1/rooms/${id}/members`,
}), }),
data: "members", data: "members",
total: json => { total: json => json.total,
return json.total;
},
}, },
room_state: { room_state: {
map: rs => ({ map: (rs: RoomState) => ({
...rs, ...rs,
id: rs.event_id, id: rs.event_id,
}), }),
reference: id => ({ reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/rooms/${id}/state`, endpoint: `/_synapse/admin/v1/rooms/${id}/state`,
}), }),
data: "state", data: "state",
total: json => { total: json => json.state.length,
return json.state.length;
},
}, },
pushers: { pushers: {
map: p => ({ map: (p: Pusher) => ({
...p, ...p,
id: p.pushkey, id: p.pushkey,
}), }),
reference: id => ({ reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/pushers`, endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/pushers`,
}), }),
data: "pushers", data: "pushers",
total: json => { total: json => json.total,
return json.total;
},
}, },
joined_rooms: { joined_rooms: {
map: jr => ({ map: (jr: string) => ({
id: jr, id: jr,
}), }),
reference: id => ({ reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent( endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/joined_rooms`,
id
)}/joined_rooms`,
}), }),
data: "joined_rooms", data: "joined_rooms",
total: json => { total: json => json.total,
return json.total;
},
}, },
users_media: { users_media: {
map: um => ({ map: (um: UserMedia) => ({
...um, ...um,
id: um.media_id, id: um.media_id,
}), }),
reference: id => ({ reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/media`, endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/media`,
}), }),
data: "media", data: "media",
total: json => { total: json => json.total,
return json.total; delete: (params: DeleteParams) => ({
}, endpoint: `/_synapse/admin/v1/media/${localStorage.getItem("home_server")}/${params.id}`,
delete: params => ({
endpoint: `/_synapse/admin/v1/media/${localStorage.getItem(
"home_server"
)}/${params.id}`,
}), }),
}, },
delete_media: { delete_media: {
delete: params => ({ delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/media/${localStorage.getItem( endpoint: `/_synapse/admin/v1/media/${localStorage.getItem(
"home_server" "home_server"
)}/delete?before_ts=${params.meta.before_ts}&size_gt=${ )}/delete?before_ts=${params.meta.before_ts}&size_gt=${
@ -191,34 +340,30 @@ const resourceMap = {
}), }),
}, },
protect_media: { protect_media: {
map: pm => ({ id: pm.media_id }), map: (pm: UserMedia) => ({ id: pm.media_id }),
create: params => ({ create: (params: UserMedia) => ({
endpoint: `/_synapse/admin/v1/media/protect/${params.media_id}`, endpoint: `/_synapse/admin/v1/media/protect/${params.media_id}`,
method: "POST", method: "POST",
}), }),
delete: params => ({ delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/media/unprotect/${params.id}`, endpoint: `/_synapse/admin/v1/media/unprotect/${params.id}`,
method: "POST", method: "POST",
}), }),
}, },
quarantine_media: { quarantine_media: {
map: qm => ({ id: qm.media_id }), map: (qm: UserMedia) => ({ id: qm.media_id }),
create: params => ({ create: (params: UserMedia) => ({
endpoint: `/_synapse/admin/v1/media/quarantine/${localStorage.getItem( endpoint: `/_synapse/admin/v1/media/quarantine/${localStorage.getItem("home_server")}/${params.media_id}`,
"home_server"
)}/${params.media_id}`,
method: "POST", method: "POST",
}), }),
delete: params => ({ delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/media/unquarantine/${localStorage.getItem( endpoint: `/_synapse/admin/v1/media/unquarantine/${localStorage.getItem("home_server")}/${params.id}`,
"home_server"
)}/${params.id}`,
method: "POST", method: "POST",
}), }),
}, },
servernotices: { servernotices: {
map: n => ({ id: n.event_id }), map: (n: { event_id: string }) => ({ id: n.event_id }),
create: data => ({ create: (data: RaServerNotice) => ({
endpoint: "/_synapse/admin/v1/send_server_notice", endpoint: "/_synapse/admin/v1/send_server_notice",
body: { body: {
user_id: data.id, user_id: data.id,
@ -232,50 +377,44 @@ const resourceMap = {
}, },
user_media_statistics: { user_media_statistics: {
path: "/_synapse/admin/v1/statistics/users/media", path: "/_synapse/admin/v1/statistics/users/media",
map: usms => ({ map: (usms: UserMediaStatistic) => ({
...usms, ...usms,
id: usms.user_id, id: usms.user_id,
}), }),
data: "users", data: "users",
total: json => { total: json => json.total,
return json.total;
},
}, },
forward_extremities: { forward_extremities: {
map: fe => ({ map: (fe: ForwardExtremity) => ({
...fe, ...fe,
id: fe.event_id, id: fe.event_id,
}), }),
reference: id => ({ reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/rooms/${id}/forward_extremities`, endpoint: `/_synapse/admin/v1/rooms/${id}/forward_extremities`,
}), }),
data: "results", data: "results",
total: json => { total: json => json.count,
return json.count; delete: (params: DeleteParams) => ({
},
delete: params => ({
endpoint: `/_synapse/admin/v1/rooms/${params.id}/forward_extremities`, endpoint: `/_synapse/admin/v1/rooms/${params.id}/forward_extremities`,
}), }),
}, },
room_directory: { room_directory: {
path: "/_matrix/client/r0/publicRooms", path: "/_matrix/client/r0/publicRooms",
map: rd => ({ map: (rd: Room) => ({
...rd, ...rd,
id: rd.room_id, id: rd.room_id,
public: !!rd.public, public: !!rd.public,
guest_access: !!rd.guest_access, guest_access: !!rd.guest_access,
avatar_src: mxcUrlToHttp(rd.avatar_url), avatar_src: rd.avatar_url ? mxcUrlToHttp(rd.avatar_url) : undefined,
}), }),
data: "chunk", data: "chunk",
total: json => { total: json => json.total_room_count_estimate,
return json.total_room_count_estimate; create: (params: RaRecord) => ({
},
create: params => ({
endpoint: `/_matrix/client/r0/directory/list/room/${params.id}`, endpoint: `/_matrix/client/r0/directory/list/room/${params.id}`,
body: { visibility: "public" }, body: { visibility: "public" },
method: "PUT", method: "PUT",
}), }),
delete: params => ({ delete: (params: DeleteParams) => ({
endpoint: `/_matrix/client/r0/directory/list/room/${params.id}`, endpoint: `/_matrix/client/r0/directory/list/room/${params.id}`,
body: { visibility: "private" }, body: { visibility: "private" },
method: "PUT", method: "PUT",
@ -283,54 +422,49 @@ const resourceMap = {
}, },
destinations: { destinations: {
path: "/_synapse/admin/v1/federation/destinations", path: "/_synapse/admin/v1/federation/destinations",
map: dst => ({ map: (dst: Destination) => ({
...dst, ...dst,
id: dst.destination, id: dst.destination,
}), }),
data: "destinations", data: "destinations",
total: json => { total: json => json.total,
return json.total;
},
delete: params => ({ delete: params => ({
endpoint: `/_synapse/admin/v1/federation/destinations/${params.id}/reset_connection`, endpoint: `/_synapse/admin/v1/federation/destinations/${params.id}/reset_connection`,
method: "POST", method: "POST",
}), }),
}, },
destination_rooms: { destination_rooms: {
map: dstroom => ({ map: (dstroom: DestinationRoom) => ({
...dstroom, ...dstroom,
id: dstroom.room_id, id: dstroom.room_id,
}), }),
reference: id => ({ reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/federation/destinations/${id}/rooms`, endpoint: `/_synapse/admin/v1/federation/destinations/${id}/rooms`,
}), }),
data: "rooms", data: "rooms",
total: json => { total: json => json.total,
return json.total;
},
}, },
registration_tokens: { registration_tokens: {
path: "/_synapse/admin/v1/registration_tokens", path: "/_synapse/admin/v1/registration_tokens",
map: rt => ({ map: (rt: RegistrationToken) => ({
...rt, ...rt,
id: rt.token, id: rt.token,
}), }),
data: "registration_tokens", data: "registration_tokens",
total: json => { total: json => json.registration_tokens.length,
return json.registration_tokens.length; create: (params: RaRecord) => ({
},
create: params => ({
endpoint: "/_synapse/admin/v1/registration_tokens/new", endpoint: "/_synapse/admin/v1/registration_tokens/new",
body: params, body: params,
method: "POST", method: "POST",
}), }),
delete: params => ({ delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/registration_tokens/${params.id}`, endpoint: `/_synapse/admin/v1/registration_tokens/${params.id}`,
}), }),
}, },
}; };
function filterNullValues(key, value) { /* eslint-disable @typescript-eslint/no-explicit-any */
function filterNullValues(key: string, value: any) {
// Filtering out null properties // Filtering out null properties
// to reset user_type from user, it must be null // to reset user_type from user, it must be null
if (value === null && key !== "user_type") { if (value === null && key !== "user_type") {
@ -339,7 +473,7 @@ function filterNullValues(key, value) {
return value; return value;
} }
function getSearchOrder(order) { function getSearchOrder(order: "ASC" | "DESC") {
if (order === "DESC") { if (order === "DESC") {
return "b"; return "b";
} else { } else {
@ -347,18 +481,10 @@ function getSearchOrder(order) {
} }
} }
const dataProvider = { const dataProvider: DataProvider = {
getList: async (resource, params) => { getList: async (resource, params) => {
console.log("getList " + resource); console.log("getList " + resource);
const { const { user_id, name, guests, deactivated, search_term, destination, valid } = params.filter;
user_id,
name,
guests,
deactivated,
search_term,
destination,
valid,
} = params.filter;
const { page, perPage } = params.pagination; const { page, perPage } = params.pagination;
const { field, order } = params.sort; const { field, order } = params.sort;
const from = (page - 1) * perPage; const from = (page - 1) * perPage;
@ -376,7 +502,7 @@ const dataProvider = {
dir: getSearchOrder(order), dir: getSearchOrder(order),
}; };
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource]; const res = resourceMap[resource];
@ -393,30 +519,24 @@ const dataProvider = {
getOne: async (resource, params) => { getOne: async (resource, params) => {
console.log("getOne " + resource); console.log("getOne " + resource);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource]; const res = resourceMap[resource];
const endpoint_url = homeserver + res.path; const endpoint_url = homeserver + res.path;
const { json } = await jsonClient( const { json } = await jsonClient(`${endpoint_url}/${encodeURIComponent(params.id)}`);
`${endpoint_url}/${encodeURIComponent(params.id)}`
);
return { data: res.map(json) }; return { data: res.map(json) };
}, },
getMany: async (resource, params) => { getMany: async (resource, params) => {
console.log("getMany " + resource); console.log("getMany " + resource);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) throw Error("Homerserver not set");
const res = resourceMap[resource]; const res = resourceMap[resource];
const endpoint_url = homeserver + res.path; const endpoint_url = homeserver + res.path;
const responses = await Promise.all( const responses = await Promise.all(params.ids.map(id => jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`)));
params.ids.map(id =>
jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`)
)
);
return { return {
data: responses.map(({ json }) => res.map(json)), data: responses.map(({ json }) => res.map(json)),
total: responses.length, total: responses.length,
@ -436,11 +556,11 @@ const dataProvider = {
}; };
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource]; const res = resourceMap[resource];
const ref = res["reference"](params.id); const ref = res.reference(params.id);
const endpoint_url = `${homeserver}${ref.endpoint}?${stringify(query)}`; const endpoint_url = `${homeserver}${ref.endpoint}?${stringify(query)}`;
const { json } = await jsonClient(endpoint_url); const { json } = await jsonClient(endpoint_url);
@ -453,37 +573,31 @@ const dataProvider = {
update: async (resource, params) => { update: async (resource, params) => {
console.log("update " + resource); console.log("update " + resource);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource]; const res = resourceMap[resource];
const endpoint_url = homeserver + res.path; const endpoint_url = homeserver + res.path;
const { json } = await jsonClient( const { json } = await jsonClient(`${endpoint_url}/${encodeURIComponent(params.id)}`, {
`${endpoint_url}/${encodeURIComponent(params.id)}`,
{
method: "PUT", method: "PUT",
body: JSON.stringify(params.data, filterNullValues), body: JSON.stringify(params.data, filterNullValues),
} });
);
return { data: res.map(json) }; return { data: res.map(json) };
}, },
updateMany: async (resource, params) => { updateMany: async (resource, params) => {
console.log("updateMany " + resource); console.log("updateMany " + resource);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource]; const res = resourceMap[resource];
const endpoint_url = homeserver + res.path; const endpoint_url = homeserver + res.path;
const responses = await Promise.all( const responses = await Promise.all(
params.ids.map( params.ids.map(id => jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`), {
id => jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`),
{
method: "PUT", method: "PUT",
body: JSON.stringify(params.data, filterNullValues), body: JSON.stringify(params.data, filterNullValues),
} })
)
); );
return { data: responses.map(({ json }) => json) }; return { data: responses.map(({ json }) => json) };
}, },
@ -491,12 +605,12 @@ const dataProvider = {
create: async (resource, params) => { create: async (resource, params) => {
console.log("create " + resource); console.log("create " + resource);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource]; const res = resourceMap[resource];
if (!("create" in res)) return Promise.reject(); if (!("create" in res)) return Promise.reject();
const create = res["create"](params.data); const create = res.create(params.data);
const endpoint_url = homeserver + create.endpoint; const endpoint_url = homeserver + create.endpoint;
const { json } = await jsonClient(endpoint_url, { const { json } = await jsonClient(endpoint_url, {
method: create.method, method: create.method,
@ -505,18 +619,18 @@ const dataProvider = {
return { data: res.map(json) }; return { data: res.map(json) };
}, },
createMany: async (resource, params) => { createMany: async (resource: string, params: { ids: Identifier[]; data: RaRecord }) => {
console.log("createMany " + resource); console.log("createMany " + resource);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource]; const res = resourceMap[resource];
if (!("create" in res)) return Promise.reject(); if (!("create" in res)) throw Error(`Create ${resource} is not allowed`);
const responses = await Promise.all( const responses = await Promise.all(
params.ids.map(id => { params.ids.map(id => {
params.data.id = id; params.data.id = id;
const cre = res["create"](params.data); const cre = res.create(params.data);
const endpoint_url = homeserver + cre.endpoint; const endpoint_url = homeserver + cre.endpoint;
return jsonClient(endpoint_url, { return jsonClient(endpoint_url, {
method: cre.method, method: cre.method,
@ -530,12 +644,12 @@ const dataProvider = {
delete: async (resource, params) => { delete: async (resource, params) => {
console.log("delete " + resource); console.log("delete " + resource);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource]; const res = resourceMap[resource];
if ("delete" in res) { if ("delete" in res) {
const del = res["delete"](params); const del = res.delete(params);
const endpoint_url = homeserver + del.endpoint; const endpoint_url = homeserver + del.endpoint;
const { json } = await jsonClient(endpoint_url, { const { json } = await jsonClient(endpoint_url, {
method: "method" in del ? del.method : "DELETE", method: "method" in del ? del.method : "DELETE",
@ -555,14 +669,14 @@ const dataProvider = {
deleteMany: async (resource, params) => { deleteMany: async (resource, params) => {
console.log("deleteMany " + resource); console.log("deleteMany " + resource);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource]; const res = resourceMap[resource];
if ("delete" in res) { if ("delete" in res) {
const responses = await Promise.all( const responses = await Promise.all(
params.ids.map(id => { params.ids.map(id => {
const del = res["delete"]({ ...params, id: id }); const del = res.delete({ ...params, id: id });
const endpoint_url = homeserver + del.endpoint; const endpoint_url = homeserver + del.endpoint;
return jsonClient(endpoint_url, { return jsonClient(endpoint_url, {
method: "method" in del ? del.method : "DELETE", method: "method" in del ? del.method : "DELETE",
@ -579,7 +693,7 @@ const dataProvider = {
params.ids.map(id => params.ids.map(id =>
jsonClient(`${endpoint_url}/${id}`, { jsonClient(`${endpoint_url}/${id}`, {
method: "DELETE", method: "DELETE",
body: JSON.stringify(params.data, filterNullValues), // body: JSON.stringify(params.data, filterNullValues), @FIXME
}) })
) )
); );

View file

@ -1,31 +0,0 @@
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());
});

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());
});

View file

@ -1,13 +1,11 @@
import { fetchUtils } from "react-admin"; import { fetchUtils } from "react-admin";
export const splitMxid = mxid => { export const splitMxid = mxid => {
const re = const re = /^@(?<name>[a-zA-Z0-9._=\-/]+):(?<domain>[a-zA-Z0-9\-.]+\.[a-zA-Z]+)$/;
/^@(?<name>[a-zA-Z0-9._=\-/]+):(?<domain>[a-zA-Z0-9\-.]+\.[a-zA-Z]+)$/;
return re.exec(mxid)?.groups; return re.exec(mxid)?.groups;
}; };
export const isValidBaseUrl = baseUrl => export const isValidBaseUrl = baseUrl => /^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?$/.test(baseUrl);
/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?$/.test(baseUrl);
/** /**
* Resolve the homeserver URL using the well-known lookup * Resolve the homeserver URL using the well-known lookup
@ -58,3 +56,27 @@ export const getMediaUrl = media_id => {
const baseUrl = localStorage.getItem("base_url"); const baseUrl = localStorage.getItem("base_url");
return `${baseUrl}/_matrix/media/v1/download/${media_id}?allow_redirect=true`; 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 = localStorage.getItem("home_server");
const characters = "0123456789abcdefghijklmnopqrstuvwxyz";
const localpart = Array.from(crypto.getRandomValues(new Uint32Array(8)))
.map(x => characters[x % characters.length])
.join("");
return `@${localpart}:${homeserver}`;
}
/**
* 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"]
}

View file

@ -1,7 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { vitePluginVersionMark } from "vite-plugin-version-mark"; import { vitePluginVersionMark } from "vite-plugin-version-mark";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react(), react(),

2515
yarn.lock

File diff suppressed because it is too large Load diff