From 72aaf7b082873261b267a438d7ba414867542c46 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Thu, 13 Nov 2025 21:03:38 +0100 Subject: [PATCH] Add token creation dialog --- matrixgw_frontend/package-lock.json | 158 +++++++++++++++++ matrixgw_frontend/package.json | 4 + matrixgw_frontend/src/App.tsx | 4 +- matrixgw_frontend/src/api/TokensApi.ts | 46 +++++ .../src/dialogs/CreateTokenDialog.tsx | 159 ++++++++++++++++++ matrixgw_frontend/src/main.tsx | 52 +++--- .../src/routes/APITokensRoute.tsx | 98 +++++++++++ matrixgw_frontend/src/utils/DateUtils.ts | 8 + matrixgw_frontend/src/utils/FormUtils.ts | 52 ++++++ .../src/widgets/CopyTextChip.tsx | 29 ++++ .../src/widgets/forms/CheckboxInput.tsx | 23 +++ .../src/widgets/forms/DateInput.tsx | 49 ++++++ .../src/widgets/forms/NetworksInput.tsx | 26 +++ .../src/widgets/forms/TextInput.tsx | 65 +++++++ 14 files changed, 748 insertions(+), 25 deletions(-) create mode 100644 matrixgw_frontend/src/api/TokensApi.ts create mode 100644 matrixgw_frontend/src/dialogs/CreateTokenDialog.tsx create mode 100644 matrixgw_frontend/src/routes/APITokensRoute.tsx create mode 100644 matrixgw_frontend/src/utils/DateUtils.ts create mode 100644 matrixgw_frontend/src/utils/FormUtils.ts create mode 100644 matrixgw_frontend/src/widgets/CopyTextChip.tsx create mode 100644 matrixgw_frontend/src/widgets/forms/CheckboxInput.tsx create mode 100644 matrixgw_frontend/src/widgets/forms/DateInput.tsx create mode 100644 matrixgw_frontend/src/widgets/forms/NetworksInput.tsx create mode 100644 matrixgw_frontend/src/widgets/forms/TextInput.tsx diff --git a/matrixgw_frontend/package-lock.json b/matrixgw_frontend/package-lock.json index 152f32f..9caabde 100644 --- a/matrixgw_frontend/package-lock.json +++ b/matrixgw_frontend/package-lock.json @@ -15,6 +15,10 @@ "@mdi/react": "^1.6.1", "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", + "@mui/x-date-pickers": "^8.17.0", + "dayjs": "^1.11.19", + "is-cidr": "^6.0.1", + "qrcode.react": "^4.2.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router": "^7.9.5" @@ -1039,6 +1043,94 @@ } } }, + "node_modules/@mui/x-date-pickers": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-8.17.0.tgz", + "integrity": "sha512-mrrkTJ1+r6MsPnKH/N5lCNJHkP0dZc2Fvd8fp5tyxa0jRyzwbxJKsadXooccoJWp65Z2vUjUuctXYUmubYP/Sg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.3", + "@mui/x-internals": "8.17.0", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2 || ^3.0.0", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.17.0.tgz", + "integrity": "sha512-KvmR0PPX1j2i44y0DXwzs45jIPMu/YZYXYy7xvzo+ZNdYebbW5LbVeG4zdEUnKHyOG02oHdI7MM9AxcZE16TBw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.3", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", @@ -1987,6 +2079,18 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cidr-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/cidr-regex/-/cidr-regex-5.0.1.tgz", + "integrity": "sha512-2Apfc6qH9uwF3QHmlYBA8ExB9VHq+1/Doj9sEMY55TVBcpQ3y/+gmMpcNIBBtfb5k54Vphmta+1IxjMqPlWWAA==", + "license": "BSD-2-Clause", + "dependencies": { + "ip-regex": "5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2085,6 +2189,12 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2615,12 +2725,36 @@ "node": ">=0.8.19" } }, + "node_modules/ip-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-5.0.0.tgz", + "integrity": "sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, + "node_modules/is-cidr": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/is-cidr/-/is-cidr-6.0.1.tgz", + "integrity": "sha512-JIJlvXodfsoWFAvvjB7Elqu8qQcys2SZjkIJCLdk4XherUqZ6+zH7WIpXkp4B3ZxMH0Fz7zIsZwyvs6JfM0csw==", + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "5.0.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -3368,6 +3502,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3464,6 +3607,12 @@ "react-dom": ">=16.6.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -3858,6 +4007,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "name": "rolldown-vite", "version": "7.1.14", diff --git a/matrixgw_frontend/package.json b/matrixgw_frontend/package.json index 823193f..99ed122 100644 --- a/matrixgw_frontend/package.json +++ b/matrixgw_frontend/package.json @@ -17,6 +17,10 @@ "@mdi/react": "^1.6.1", "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", + "@mui/x-date-pickers": "^8.17.0", + "dayjs": "^1.11.19", + "is-cidr": "^6.0.1", + "qrcode.react": "^4.2.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router": "^7.9.5" diff --git a/matrixgw_frontend/src/App.tsx b/matrixgw_frontend/src/App.tsx index 7cd3001..a202bcb 100644 --- a/matrixgw_frontend/src/App.tsx +++ b/matrixgw_frontend/src/App.tsx @@ -7,14 +7,15 @@ import { } from "react-router"; import { AuthApi } from "./api/AuthApi"; import { ServerApi } from "./api/ServerApi"; +import { APITokensRoute } from "./routes/APITokensRoute"; import { LoginRoute } from "./routes/auth/LoginRoute"; import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute"; import { HomeRoute } from "./routes/HomeRoute"; +import { MatrixAuthCallback } from "./routes/MatrixAuthCallback"; import { MatrixLinkRoute } from "./routes/MatrixLinkRoute"; import { NotFoundRoute } from "./routes/NotFoundRoute"; import { BaseLoginPage } from "./widgets/auth/BaseLoginPage"; import BaseAuthenticatedPage from "./widgets/dashboard/BaseAuthenticatedPage"; -import { MatrixAuthCallback } from "./routes/MatrixAuthCallback"; interface AuthContext { signedIn: boolean; @@ -41,6 +42,7 @@ export function App(): React.ReactElement { } /> } /> } /> + } /> } /> ) : ( diff --git a/matrixgw_frontend/src/api/TokensApi.ts b/matrixgw_frontend/src/api/TokensApi.ts new file mode 100644 index 0000000..268a2ae --- /dev/null +++ b/matrixgw_frontend/src/api/TokensApi.ts @@ -0,0 +1,46 @@ +import { APIClient } from "./ApiClient"; + +export interface BaseToken { + name: string; + networks?: string[]; + max_inactivity: number; + expiration?: number; + read_only: boolean; +} + +export interface Token extends BaseToken { + id: number; + created: number; + last_used: number; +} + +export interface TokenWithSecret extends Token { + secret: string; +} + +export class TokensApi { + /** + * Get the list of tokens of the current user + */ + static async GetList(): Promise { + return ( + await APIClient.exec({ + uri: "/tokens", + method: "GET", + }) + ).data; + } + + /** + * Create a new token + */ + static async Create(t: BaseToken): Promise { + return ( + await APIClient.exec({ + uri: "/token", + method: "POST", + jsonData: t, + }) + ).data; + } +} diff --git a/matrixgw_frontend/src/dialogs/CreateTokenDialog.tsx b/matrixgw_frontend/src/dialogs/CreateTokenDialog.tsx new file mode 100644 index 0000000..077e174 --- /dev/null +++ b/matrixgw_frontend/src/dialogs/CreateTokenDialog.tsx @@ -0,0 +1,159 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from "@mui/material"; +import React from "react"; +import { ServerApi } from "../api/ServerApi"; +import { + TokensApi, + type BaseToken, + type TokenWithSecret, +} from "../api/TokensApi"; +import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider"; +import { useLoadingMessage } from "../hooks/contexts_provider/LoadingMessageProvider"; +import { time } from "../utils/DateUtils"; +import { + checkConstraint, + checkNumberConstraint, + isIPNetworkValid, +} from "../utils/FormUtils"; +import { CheckboxInput } from "../widgets/forms/CheckboxInput"; +import { DateInput } from "../widgets/forms/DateInput"; +import { NetworksInput } from "../widgets/forms/NetworksInput"; +import { TextInput } from "../widgets/forms/TextInput"; + +const SECS_IN_DAY = 3600 * 24; + +export function CreateTokenDialog(p: { + open: boolean; + onClose: () => void; + onCreated: (t: TokenWithSecret) => void; +}): React.ReactElement { + const alert = useAlert(); + const loadingMessage = useLoadingMessage(); + + const [newTokenUndef, setNewToken] = React.useState(); + const newToken: BaseToken = newTokenUndef ?? { + name: "", + max_inactivity: 3600 * 24 * 90, + read_only: false, + }; + + const valid = + checkConstraint(ServerApi.Config.constraints.token_name, newToken.name) === + undefined && + checkNumberConstraint( + ServerApi.Config.constraints.token_max_inactivity, + newToken.max_inactivity + ) === undefined && + (newToken.networks === undefined || + newToken.networks.every((n) => isIPNetworkValid(n))); + + const handleSubmit = async () => { + try { + loadingMessage.show("Creating access token..."); + const token = await TokensApi.Create(newToken); + p.onCreated(token); + + // Clear form + setNewToken(undefined); + } catch (e) { + console.error(`Failed to create token! ${e}`); + alert(`Failed to create API token! ${e}`); + } finally { + loadingMessage.hide(); + } + }; + + return ( + + Create new API token + + { + setNewToken({ + ...newToken, + name: v ?? "", + }); + }} + size={ServerApi.Config.constraints.token_name} + /> + + { + setNewToken({ + ...newToken, + networks: v, + }); + }} + /> + + { + setNewToken({ + ...newToken, + max_inactivity: Number(i) * SECS_IN_DAY, + }); + }} + size={{ + min: + ServerApi.Config.constraints.token_max_inactivity.min / + SECS_IN_DAY, + max: + ServerApi.Config.constraints.token_max_inactivity.max / + SECS_IN_DAY, + }} + /> + + { + setNewToken((t) => { + return { + ...(t ?? newToken), + expiration: i ?? undefined, + }; + }); + }} + disablePast + checkValue={(s) => s > time()} + /> + + { + setNewToken({ + ...newToken, + read_only: v, + }); + }} + /> + + + + + + + ); +} diff --git a/matrixgw_frontend/src/main.tsx b/matrixgw_frontend/src/main.tsx index afd73ad..6508bc0 100644 --- a/matrixgw_frontend/src/main.tsx +++ b/matrixgw_frontend/src/main.tsx @@ -3,39 +3,43 @@ import "@fontsource/roboto/400.css"; import "@fontsource/roboto/500.css"; import "@fontsource/roboto/700.css"; +import { CssBaseline } from "@mui/material"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import "./index.css"; +import { ServerApi } from "./api/ServerApi"; import { App } from "./App"; import { AlertDialogProvider } from "./hooks/contexts_provider/AlertDialogProvider"; import { ConfirmDialogProvider } from "./hooks/contexts_provider/ConfirmDialogProvider"; -import { SnackbarProvider } from "./hooks/contexts_provider/SnackbarProvider"; import { LoadingMessageProvider } from "./hooks/contexts_provider/LoadingMessageProvider"; -import { AsyncWidget } from "./widgets/AsyncWidget"; -import { ServerApi } from "./api/ServerApi"; +import { SnackbarProvider } from "./hooks/contexts_provider/SnackbarProvider"; +import "./index.css"; import { AppTheme } from "./theme/AppTheme"; -import { CssBaseline } from "@mui/material"; +import { AsyncWidget } from "./widgets/AsyncWidget"; createRoot(document.getElementById("root")!).render( - - - - - - - { - await ServerApi.LoadConfig(); - }} - errMsg="Failed to load static server configuration!" - build={() => } - /> - - - - - + + + + + + + + { + await ServerApi.LoadConfig(); + }} + errMsg="Failed to load static server configuration!" + build={() => } + /> + + + + + + ); diff --git a/matrixgw_frontend/src/routes/APITokensRoute.tsx b/matrixgw_frontend/src/routes/APITokensRoute.tsx new file mode 100644 index 0000000..5225354 --- /dev/null +++ b/matrixgw_frontend/src/routes/APITokensRoute.tsx @@ -0,0 +1,98 @@ +import AddIcon from "@mui/icons-material/Add"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { Alert, AlertTitle, IconButton, Tooltip } from "@mui/material"; +import { MatrixGWRouteContainer } from "../widgets/MatrixGWRouteContainer"; +import React from "react"; +import { CreateTokenDialog } from "../dialogs/CreateTokenDialog"; +import { type TokenWithSecret } from "../api/TokensApi"; +import { APIClient } from "../api/ApiClient"; +import { QRCodeCanvas } from "qrcode.react"; +import { CopyTextChip } from "../widgets/CopyTextChip"; + +export function APITokensRoute(): React.ReactElement { + const [openCreateTokenDialog, setOpenCreateTokenDialog] = + React.useState(false); + + const [createdToken, setCreatedToken] = + React.useState(null); + + const handleRefreshTokensList = () => { + //throw new Error("todo"); + }; + + const handleOpenCreateTokenDialog = () => setOpenCreateTokenDialog(true); + + const handleCancelCreateToken = () => setOpenCreateTokenDialog(false); + + const handleCreatedToken = (s: TokenWithSecret) => { + setCreatedToken(s); + setOpenCreateTokenDialog(false); + handleRefreshTokensList(); + }; + + return ( + + + + + + +    + + + + + + + } + > + + {createdToken && } +

TODO list

+
+ ); +} + +function CreatedToken(p: { token: TokenWithSecret }): React.ReactElement { + return ( + +
+
+
+ +
+
+ Mobile App Qr Code +
+
+ Token successfully created + The API token {p.token.name} was successfully created. Please + note the following information as they won't be available after. +
+
+ API URL: +
+ Token ID: +
+ Token secret: +
+
+
+ ); +} diff --git a/matrixgw_frontend/src/utils/DateUtils.ts b/matrixgw_frontend/src/utils/DateUtils.ts new file mode 100644 index 0000000..c44c16f --- /dev/null +++ b/matrixgw_frontend/src/utils/DateUtils.ts @@ -0,0 +1,8 @@ +/** + * Get UNIX time + * + * @returns Number of seconds since Epoch + */ +export function time(): number { + return Math.floor(new Date().getTime() / 1000); +} diff --git a/matrixgw_frontend/src/utils/FormUtils.ts b/matrixgw_frontend/src/utils/FormUtils.ts new file mode 100644 index 0000000..e47e9ec --- /dev/null +++ b/matrixgw_frontend/src/utils/FormUtils.ts @@ -0,0 +1,52 @@ +import isCidr from "is-cidr"; +import type { LenConstraint } from "../api/ServerApi"; + +/** + * Check if a constraint was respected or not + * + * @returns An error message appropriate for the constraint + * violation, if any, or undefined otherwise + */ +export function checkConstraint( + constraint: LenConstraint, + value: string | undefined +): string | undefined { + value = value ?? ""; + if (value.length < constraint.min) + return `Please specify at least ${constraint.min} characters!`; + + if (value.length > constraint.max) + return `Please specify at least ${constraint.min} characters!`; + + return undefined; +} + +/** + * Check if a number constraint was respected or not + * + * @returns An error message appropriate for the constraint + * violation, if any, or undefined otherwise + */ +export function checkNumberConstraint( + constraint: LenConstraint, + value: number +): string | undefined { + value = value ?? ""; + if (value < constraint.min) + return `Value is below accepted minimum (${constraint.min})!`; + + if (value > constraint.max) + return `Value is above accepted maximum (${constraint.min})!`; + + return undefined; +} + +/** + * Check whether a given IP network address is valid or not + * + * @param ip The IP network to check + * @returns true if the address is valid, false otherwise + */ +export function isIPNetworkValid(ip: string): boolean { + return isCidr(ip) !== 0; +} diff --git a/matrixgw_frontend/src/widgets/CopyTextChip.tsx b/matrixgw_frontend/src/widgets/CopyTextChip.tsx new file mode 100644 index 0000000..864ef58 --- /dev/null +++ b/matrixgw_frontend/src/widgets/CopyTextChip.tsx @@ -0,0 +1,29 @@ +import { Chip, Tooltip } from "@mui/material"; +import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider"; +import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider"; + +export function CopyTextChip(p: { text: string }): React.ReactElement { + const snackbar = useSnackbar(); + const alert = useAlert(); + + const copyTextToClipboard = () => { + try { + navigator.clipboard.writeText(p.text); + snackbar(`'${p.text}' was copied to clipboard.`); + } catch (e) { + console.error(`Failed to copy text to the clipboard! ${e}`); + alert(p.text); + } + }; + + return ( + + + + ); +} diff --git a/matrixgw_frontend/src/widgets/forms/CheckboxInput.tsx b/matrixgw_frontend/src/widgets/forms/CheckboxInput.tsx new file mode 100644 index 0000000..dcf7c71 --- /dev/null +++ b/matrixgw_frontend/src/widgets/forms/CheckboxInput.tsx @@ -0,0 +1,23 @@ +import { Checkbox, FormControlLabel } from "@mui/material"; + +export function CheckboxInput(p: { + editable: boolean; + label: string; + checked: boolean | undefined; + onValueChange: (v: boolean) => void; +}): React.ReactElement { + return ( + { + p.onValueChange(e.target.checked); + }} + /> + } + label={p.label} + /> + ); +} diff --git a/matrixgw_frontend/src/widgets/forms/DateInput.tsx b/matrixgw_frontend/src/widgets/forms/DateInput.tsx new file mode 100644 index 0000000..c186e14 --- /dev/null +++ b/matrixgw_frontend/src/widgets/forms/DateInput.tsx @@ -0,0 +1,49 @@ +import { DateField } from "@mui/x-date-pickers"; +import dayjs from "dayjs"; +import { TextInput } from "./TextInput"; + +export function DateInput(p: { + editable?: boolean; + required?: boolean; + label: string; + value: number | undefined | null; + checkValue?: (s: number) => boolean; + disableFuture?: boolean; + disablePast?: boolean; + onChange: (newVal: number | undefined | null) => void; +}): React.ReactElement { + const date = p.value ? dayjs.unix(p.value) : undefined; + + const error = p.value && p.checkValue && !p.checkValue(p.value); + + if (!p.editable) + return ( + + ); + + return ( + p.onChange(v?.unix())} + slotProps={{ + textField: { + fullWidth: true, + label: p.label, + variant: "standard", + }, + inputAdornment: { + variant: "standard", + }, + }} + disableFuture={p.disableFuture} + disablePast={p.disablePast} + error={error === true} + format="DD/MM/YYYY" + /> + ); +} diff --git a/matrixgw_frontend/src/widgets/forms/NetworksInput.tsx b/matrixgw_frontend/src/widgets/forms/NetworksInput.tsx new file mode 100644 index 0000000..a583196 --- /dev/null +++ b/matrixgw_frontend/src/widgets/forms/NetworksInput.tsx @@ -0,0 +1,26 @@ +import { isIPNetworkValid } from "../../utils/FormUtils"; +import { TextInput } from "./TextInput"; + +function rebuildNetworksList(val?: string): string[] | undefined { + if (!val || val.trim() === "") return undefined; + + return val.split(",").map((v) => v.trim()); +} + +export function NetworksInput(p: { + editable?: boolean; + label: string; + value?: string[]; + onChange: (n: string[] | undefined) => void; +}): React.ReactElement { + const textValue = (p.value ?? []).join(", ").trim(); + return ( + p.onChange(rebuildNetworksList(i))} + checkValue={(v) => (rebuildNetworksList(v) ?? []).every(isIPNetworkValid)} + /> + ); +} diff --git a/matrixgw_frontend/src/widgets/forms/TextInput.tsx b/matrixgw_frontend/src/widgets/forms/TextInput.tsx new file mode 100644 index 0000000..8b461a8 --- /dev/null +++ b/matrixgw_frontend/src/widgets/forms/TextInput.tsx @@ -0,0 +1,65 @@ +import { TextField, type TextFieldVariants } from "@mui/material"; +import type { LenConstraint } from "../../api/ServerApi"; + +/** + * Text input + */ +export function TextInput(p: { + label?: string; + editable?: boolean; + required?: boolean; + value?: string; + onValueChange?: (newVal: string | undefined) => void; + size?: LenConstraint; + checkValue?: (s: string) => boolean; + multiline?: boolean; + minRows?: number; + maxRows?: number; + placeholder?: string; + type?: React.HTMLInputTypeAttribute; + style?: React.CSSProperties; + helperText?: string; + variant?: TextFieldVariants; +}): React.ReactElement { + if (!p.editable && (p.value ?? "") === "") return <>; + + let valueError = undefined; + if (p.value && p.value.length > 0) { + if (p.size?.min && p.type !== "number" && p.value.length < p.size.min) + valueError = `Please specify at least ${p.size.min} characters !`; + if (p.checkValue && !p.checkValue(p.value)) valueError = "Invalid value!"; + if ( + p.type === "number" && + p.size && + (Number(p.value) > p.size.max || Number(p.value) < p.size.min) + ) + valueError = "Invalid size range!"; + } + + return ( + + p.onValueChange?.( + e.target.value.length === 0 ? undefined : e.target.value + ) + } + slotProps={{ + input: { + readOnly: !p.editable, + type: p.type, + }, + htmlInput: { maxLength: p.size?.max, placeholder: p.placeholder }, + }} + variant={p.variant ?? "standard"} + style={p.style ?? { width: "100%", marginBottom: "15px" }} + multiline={p.multiline} + minRows={p.minRows} + maxRows={p.maxRows} + error={valueError !== undefined} + helperText={valueError ?? p.helperText} + /> + ); +}