From 7155d91bed3b02863f50920b66f6fe7095f0994d Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Wed, 19 Mar 2025 21:08:59 +0100 Subject: [PATCH] Create and delete tokens from web ui --- moneymgr_web/package-lock.json | 17 ++ moneymgr_web/package.json | 2 + moneymgr_web/src/App.tsx | 2 + moneymgr_web/src/api/ServerApi.ts | 6 +- moneymgr_web/src/api/TokensApi.ts | 70 +++++ .../src/dialogs/CreateTokenDialog.tsx | 198 +++++++++++++ moneymgr_web/src/routes/TokensRoute.tsx | 262 ++++++++++++++++++ moneymgr_web/src/utils/DateUtils.tsx | 20 ++ moneymgr_web/src/utils/FormUtils.tsx | 32 +++ moneymgr_web/src/widgets/CopyTextChip.tsx | 22 ++ .../src/widgets/MoneyMgrWebRouteContainer.tsx | 34 +++ moneymgr_web/src/widgets/TimeWidget.tsx | 75 +++++ .../src/widgets/forms/CheckboxInput.tsx | 21 ++ moneymgr_web/src/widgets/forms/TextInput.tsx | 62 +++++ 14 files changed, 822 insertions(+), 1 deletion(-) create mode 100644 moneymgr_web/src/api/TokensApi.ts create mode 100644 moneymgr_web/src/dialogs/CreateTokenDialog.tsx create mode 100644 moneymgr_web/src/routes/TokensRoute.tsx create mode 100644 moneymgr_web/src/utils/DateUtils.tsx create mode 100644 moneymgr_web/src/utils/FormUtils.tsx create mode 100644 moneymgr_web/src/widgets/CopyTextChip.tsx create mode 100644 moneymgr_web/src/widgets/MoneyMgrWebRouteContainer.tsx create mode 100644 moneymgr_web/src/widgets/TimeWidget.tsx create mode 100644 moneymgr_web/src/widgets/forms/CheckboxInput.tsx create mode 100644 moneymgr_web/src/widgets/forms/TextInput.tsx diff --git a/moneymgr_web/package-lock.json b/moneymgr_web/package-lock.json index 85fbb70..bc7b0bc 100644 --- a/moneymgr_web/package-lock.json +++ b/moneymgr_web/package-lock.json @@ -17,7 +17,9 @@ "@mui/material": "^6.4.8", "@mui/x-data-grid": "^7.28.0", "@mui/x-date-pickers": "^7.28.0", + "date-and-time": "^3.6.0", "dayjs": "^1.11.13", + "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^7.3.0", @@ -2640,6 +2642,12 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/date-and-time": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-3.6.0.tgz", + "integrity": "sha512-V99gLaMqNQxPCObBumb31Bfy3OByXnpqUM0yHPi/aBQE61g42A2rGk6Z2CDnpLrWsOFLQwOgl4Vgshw6D44ebw==", + "license": "MIT" + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -3779,6 +3787,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", diff --git a/moneymgr_web/package.json b/moneymgr_web/package.json index 9bf3f31..ba14bfc 100644 --- a/moneymgr_web/package.json +++ b/moneymgr_web/package.json @@ -19,7 +19,9 @@ "@mui/material": "^6.4.8", "@mui/x-data-grid": "^7.28.0", "@mui/x-date-pickers": "^7.28.0", + "date-and-time": "^3.6.0", "dayjs": "^1.11.13", + "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^7.3.0", diff --git a/moneymgr_web/src/App.tsx b/moneymgr_web/src/App.tsx index 9233fd6..50411fc 100644 --- a/moneymgr_web/src/App.tsx +++ b/moneymgr_web/src/App.tsx @@ -13,6 +13,7 @@ import { LoginRoute } from "./routes/auth/LoginRoute"; import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute"; import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; import { BaseLoginPage } from "./widgets/BaseLoginPage"; +import { TokensRoute } from "./routes/TokensRoute"; interface AuthContext { signedIn: boolean; @@ -37,6 +38,7 @@ export function App() { signedIn || ServerApi.Config.auth_disabled ? ( }> } /> + } /> } /> diff --git a/moneymgr_web/src/api/ServerApi.ts b/moneymgr_web/src/api/ServerApi.ts index 104da9d..eb047bf 100644 --- a/moneymgr_web/src/api/ServerApi.ts +++ b/moneymgr_web/src/api/ServerApi.ts @@ -6,7 +6,11 @@ export interface ServerConfig { constraints: ServerConstraints; } -export interface ServerConstraints {} +export interface ServerConstraints { + token_name: LenConstraint; + token_ip_net: LenConstraint; + token_max_inactivity: LenConstraint; +} export interface LenConstraint { min: number; diff --git a/moneymgr_web/src/api/TokensApi.ts b/moneymgr_web/src/api/TokensApi.ts new file mode 100644 index 0000000..d7705a0 --- /dev/null +++ b/moneymgr_web/src/api/TokensApi.ts @@ -0,0 +1,70 @@ +import { APIClient } from "./ApiClient"; + +export interface Token { + id: number; + name: string; + time_create: number; + user_id: number; + time_used: number; + max_inactivity: number; + ip_net?: string; + read_only: boolean; + right_account: boolean; + right_movement: boolean; + right_inbox: boolean; + right_attachment: boolean; + right_auth: boolean; +} + +export interface TokenWithSecret extends Token { + token: string; +} + +export interface NewToken { + name: string; + ip_net?: string; + max_inactivity: number; + read_only: boolean; + right_account: boolean; + right_movement: boolean; + right_inbox: boolean; + right_attachment: boolean; + right_auth: boolean; +} + +export class TokensApi { + /** + * Get the list of tokens of the current user + */ + static async GetList(): Promise { + return ( + await APIClient.exec({ + uri: "/tokens/list", + method: "GET", + }) + ).data; + } + + /** + * Create a new token + */ + static async Create(t: NewToken): Promise { + return ( + await APIClient.exec({ + uri: "/tokens", + method: "POST", + jsonData: t, + }) + ).data; + } + + /** + * Delete a token + */ + static async Delete(t: Token): Promise { + await APIClient.exec({ + uri: `/tokens/${t.id}`, + method: "DELETE", + }); + } +} diff --git a/moneymgr_web/src/dialogs/CreateTokenDialog.tsx b/moneymgr_web/src/dialogs/CreateTokenDialog.tsx new file mode 100644 index 0000000..8ef32e1 --- /dev/null +++ b/moneymgr_web/src/dialogs/CreateTokenDialog.tsx @@ -0,0 +1,198 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from "@mui/material"; +import React from "react"; +import { ServerApi } from "../api/ServerApi"; +import { NewToken, TokenWithSecret, TokensApi } from "../api/TokensApi"; +import { useAlert } from "../hooks/context_providers/AlertDialogProvider"; +import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider"; +import { checkConstraint } from "../utils/FormUtils"; +import { CheckboxInput } from "../widgets/forms/CheckboxInput"; +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 = newTokenUndef || { + name: "", + ip_net: undefined, + max_inactivity: 3600 * 24 * 90, + read_only: false, + right_account: false, + right_attachment: false, + right_auth: false, + right_inbox: false, + right_movement: false, + }; + + const clearForm = () => { + setNewToken(undefined); + }; + + const cancel = () => { + p.onClose(); + clearForm(); + }; + + const nameErr = checkConstraint( + ServerApi.Config.constraints.token_name, + newToken.name + ); + const isValid = nameErr === undefined; + + const submit = async () => { + try { + loadingMessage.show("Création du jeton en cours..."); + const token = await TokensApi.Create(newToken); + + clearForm(); + p.onCreated(token); + } catch (e) { + console.error(e); + alert("Failed to create token !"); + } finally { + loadingMessage.hide(); + } + }; + + return ( + + Nouveau jeton + + { + setNewToken({ + ...newToken, + name: v ?? "", + }); + }} + size={ServerApi.Config.constraints.token_name} + /> + { + setNewToken({ + ...newToken, + ip_net: v ?? "", + }); + }} + size={ServerApi.Config.constraints.token_ip_net} + /> + { + 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({ + ...newToken, + read_only: v, + }); + }} + /> +
+ { + setNewToken({ + ...newToken, + right_account: v, + }); + }} + /> +
+ { + setNewToken({ + ...newToken, + right_movement: v, + }); + }} + /> +
+ { + setNewToken({ + ...newToken, + right_inbox: v, + }); + }} + /> +
+ { + setNewToken({ + ...newToken, + right_attachment: v, + }); + }} + /> +
+ { + setNewToken({ + ...newToken, + right_auth: v, + }); + }} + /> +
+ + + + +
+ ); +} diff --git a/moneymgr_web/src/routes/TokensRoute.tsx b/moneymgr_web/src/routes/TokensRoute.tsx new file mode 100644 index 0000000..340eafd --- /dev/null +++ b/moneymgr_web/src/routes/TokensRoute.tsx @@ -0,0 +1,262 @@ +import DeleteIcon from "@mui/icons-material/DeleteOutlined"; +import { Alert, AlertTitle, Button } from "@mui/material"; +import { + DataGrid, + GridActionsCellItem, + GridColDef, + GridRowId, +} from "@mui/x-data-grid"; +import React from "react"; +import { Token, TokenWithSecret, TokensApi } from "../api/TokensApi"; +import { CreateTokenDialog } from "../dialogs/CreateTokenDialog"; +import { useAlert } from "../hooks/context_providers/AlertDialogProvider"; +import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider"; +import { useSnackbar } from "../hooks/context_providers/SnackbarProvider"; +import { AsyncWidget } from "../widgets/AsyncWidget"; +import { CopyTextChip } from "../widgets/CopyTextChip"; +import { MoneyMgrWebRouteContainer } from "../widgets/MoneyMgrWebRouteContainer"; +import { TimeWidget } from "../widgets/TimeWidget"; +import { QRCodeCanvas } from "qrcode.react"; +import { APIClient } from "../api/ApiClient"; + +export function TokensRoute(): React.ReactElement { + const count = React.useRef(0); + const [list, setList] = React.useState(); + + const [createdToken, setCreatedToken] = React.useState< + TokenWithSecret | undefined + >(); + + const [openCreateTokenDialog, setOpenCreateTokenDialog] = + React.useState(false); + + const load = async () => { + setList(await TokensApi.GetList()); + }; + + const reload = () => { + count.current += 1; + setList(undefined); + }; + + const onRequestCreateToken = () => { + setOpenCreateTokenDialog(true); + }; + + const closeCreateTokenDialog = () => { + setOpenCreateTokenDialog(false); + }; + + const onCreatedToken = (t: TokenWithSecret) => { + setOpenCreateTokenDialog(false); + setCreatedToken(t); + reload(); + }; + + return ( + ( + <> + + + + + )} + /> + ); +} + +function TokensRouteInner(p: { + list: Token[]; + onReload: () => void; + onRequestCreateToken: () => void; + createdToken?: TokenWithSecret; +}): React.ReactElement { + const confirm = useConfirm(); + const alert = useAlert(); + const snackbar = useSnackbar(); + + // Delete a token + const handleDeleteClick = (id: GridRowId) => async () => { + try { + const token = p.list.find((t) => t.id === id)!; + if ( + !(await confirm( + `Do you really want to delete the token named '${token.name}' ?` + )) + ) + return; + + await TokensApi.Delete(token); + p.onReload(); + + snackbar("The token was successfully deleted!"); + } catch (e) { + console.error(e); + alert("Failed to delete API token!"); + } + }; + + const columns: GridColDef[] = [ + { field: "id", headerName: "ID", flex: 1 }, + { + field: "name", + headerName: "Name", + flex: 3, + }, + { + field: "ip_net", + headerName: "IP restriction", + flex: 3, + renderCell(params) { + return ( + params.row.ip_net ?? ( + Unrestricted + ) + ); + }, + }, + { + field: "time_create", + headerName: "Creation", + flex: 3, + renderCell(params) { + return ; + }, + }, + { + field: "last_used", + headerName: "Last usage", + flex: 3, + renderCell(params) { + return ; + }, + }, + { + field: "max_inactivity", + headerName: "Max inactivity", + flex: 3, + renderCell(params) { + return ; + }, + }, + { + field: "read_only", + headerName: "Read only", + flex: 2, + type: "boolean", + }, + { + field: "right_account", + headerName: "Account", + flex: 2, + type: "boolean", + }, + { + field: "right_movement", + headerName: "Movement", + flex: 2, + type: "boolean", + }, + { + field: "right_inbox", + headerName: "Inbox", + flex: 2, + type: "boolean", + }, + { + field: "right_attachment", + headerName: "Attachment", + flex: 2, + type: "boolean", + }, + { + field: "right_auth", + headerName: "Auth", + flex: 2, + type: "boolean", + }, + { + field: "actions", + type: "actions", + headerName: "Actions", + flex: 2, + cellClassName: "actions", + getActions: ({ id }) => { + return [ + } + label="Delete" + onClick={handleDeleteClick(id)} + color="inherit" + />, + ]; + }, + }, + ]; + + return ( + New} + > + {p.createdToken && } + c.id} + isCellEditable={() => false} + isRowSelectable={() => false} + /> + + ); +} + +function CreatedToken(p: { token: TokenWithSecret }): React.ReactElement { + return ( + +
+
+
+ +
+
+ Mobile App Qr Code +
+
+ Token successfully created + The API token was successfully created. Please note the following + information as they won't be available next. +
+ Token ID: +
+ Token value: +
+
+
+ ); +} diff --git a/moneymgr_web/src/utils/DateUtils.tsx b/moneymgr_web/src/utils/DateUtils.tsx new file mode 100644 index 0000000..c087ab9 --- /dev/null +++ b/moneymgr_web/src/utils/DateUtils.tsx @@ -0,0 +1,20 @@ +import dayjs, { Dayjs } from "dayjs"; + +export function timeToDate(time: number | undefined): Dayjs | undefined { + if (!time) return undefined; + return dayjs(new Date(time * 1000)); +} + +export function dateToTime(date: Dayjs | undefined): number | undefined { + if (!date) return undefined; + return Math.floor(date.toDate().getTime() / 1000); +} + +/** + * Get UNIX time + * + * @returns Number of seconds since Epoch + */ +export function time(): number { + return Math.floor(new Date().getTime() / 1000); +} diff --git a/moneymgr_web/src/utils/FormUtils.tsx b/moneymgr_web/src/utils/FormUtils.tsx new file mode 100644 index 0000000..c5af2ed --- /dev/null +++ b/moneymgr_web/src/utils/FormUtils.tsx @@ -0,0 +1,32 @@ +import { 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 out whether a given URL is valid or not + */ +export function checkURL(s: string): boolean { + try { + return Boolean(new URL(s)); + } catch (e) { + return false; + } +} diff --git a/moneymgr_web/src/widgets/CopyTextChip.tsx b/moneymgr_web/src/widgets/CopyTextChip.tsx new file mode 100644 index 0000000..be8dd31 --- /dev/null +++ b/moneymgr_web/src/widgets/CopyTextChip.tsx @@ -0,0 +1,22 @@ +import { Chip, Tooltip } from "@mui/material"; +import { useSnackbar } from "../hooks/context_providers/SnackbarProvider"; + +export function CopyTextChip(p: { text: string }): React.ReactElement { + const snackbar = useSnackbar(); + + const copyTextToClipboard = () => { + navigator.clipboard.writeText(p.text); + snackbar(`'${p.text}' was copied to clipboard.`); + }; + + return ( + + + + ); +} diff --git a/moneymgr_web/src/widgets/MoneyMgrWebRouteContainer.tsx b/moneymgr_web/src/widgets/MoneyMgrWebRouteContainer.tsx new file mode 100644 index 0000000..bc9ab57 --- /dev/null +++ b/moneymgr_web/src/widgets/MoneyMgrWebRouteContainer.tsx @@ -0,0 +1,34 @@ +import { Typography } from "@mui/material"; +import React, { PropsWithChildren } from "react"; + +export function MoneyMgrWebRouteContainer( + p: { + label: string; + actions?: React.ReactElement; + } & PropsWithChildren +): React.ReactElement { + return ( +
+
+ {p.label} + {p.actions ?? <>} +
+ + {p.children} +
+ ); +} diff --git a/moneymgr_web/src/widgets/TimeWidget.tsx b/moneymgr_web/src/widgets/TimeWidget.tsx new file mode 100644 index 0000000..9a8d665 --- /dev/null +++ b/moneymgr_web/src/widgets/TimeWidget.tsx @@ -0,0 +1,75 @@ +import { Tooltip } from "@mui/material"; +import date from "date-and-time"; +import { time } from "../utils/DateUtils"; + +export function formatDate(time: number): string { + const t = new Date(); + t.setTime(1000 * time); + return date.format(t, "DD/MM/YYYY HH:mm:ss"); +} + +export function timeDiff(a: number, b: number): string { + let diff = b - a; + + if (diff === 0) return "now"; + if (diff === 1) return "1 second"; + + if (diff < 60) { + return `${diff} seconds`; + } + + diff = Math.floor(diff / 60); + + if (diff === 1) return "1 minute"; + if (diff < 60) { + return `${diff} minutes`; + } + + diff = Math.floor(diff / 60); + + if (diff === 1) return "1 hour"; + if (diff < 24) { + return `${diff} hours`; + } + + const diffDays = Math.floor(diff / 24); + + if (diffDays === 1) return "1 day"; + if (diffDays < 31) { + return `${diffDays} days`; + } + + diff = Math.floor(diffDays / 31); + + if (diff < 12) { + return `${diff} month`; + } + + const diffYears = Math.floor(diffDays / 365); + + if (diffYears === 1) return "1 year"; + return `${diffYears} years`; +} + +export function timeDiffFromNow(t: number): string { + return timeDiff(t, time()); +} + +export function TimeWidget(p: { + time?: number; + isDuration?: boolean; +}): React.ReactElement { + if (!p.time) return <>; + return ( + + + {p.isDuration ? timeDiff(0, p.time) : timeDiffFromNow(p.time)} + + + ); +} diff --git a/moneymgr_web/src/widgets/forms/CheckboxInput.tsx b/moneymgr_web/src/widgets/forms/CheckboxInput.tsx new file mode 100644 index 0000000..078c1b4 --- /dev/null +++ b/moneymgr_web/src/widgets/forms/CheckboxInput.tsx @@ -0,0 +1,21 @@ +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/moneymgr_web/src/widgets/forms/TextInput.tsx b/moneymgr_web/src/widgets/forms/TextInput.tsx new file mode 100644 index 0000000..5913537 --- /dev/null +++ b/moneymgr_web/src/widgets/forms/TextInput.tsx @@ -0,0 +1,62 @@ +import { TextField, TextFieldVariants } from "@mui/material"; +import { LenConstraint } from "../../api/ServerApi"; + +/** + * Text input + */ +export function TextInput(p: { + label?: string; + editable?: boolean; + value?: string; + onValueChange?: (newVal: string | undefined) => void; + size?: LenConstraint; + checkValue?: (s: string) => boolean; + multiline?: boolean; + minRows?: number; + maxRows?: number; + 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 }, + }} + 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} + /> + ); +}