Create and delete tokens from web ui
This commit is contained in:
		
							
								
								
									
										17
									
								
								moneymgr_web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										17
									
								
								moneymgr_web/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -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", | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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 ? ( | ||||
|         <Route path="*" element={<BaseAuthenticatedPage />}> | ||||
|           <Route path="" element={<HomeRoute />} /> | ||||
|           <Route path="tokens" element={<TokensRoute />} /> | ||||
|  | ||||
|           <Route path="*" element={<NotFoundRoute />} /> | ||||
|         </Route> | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
							
								
								
									
										70
									
								
								moneymgr_web/src/api/TokensApi.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								moneymgr_web/src/api/TokensApi.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<Token[]> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         uri: "/tokens/list", | ||||
|         method: "GET", | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Create a new token | ||||
|    */ | ||||
|   static async Create(t: NewToken): Promise<TokenWithSecret> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         uri: "/tokens", | ||||
|         method: "POST", | ||||
|         jsonData: t, | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Delete a token | ||||
|    */ | ||||
|   static async Delete(t: Token): Promise<void> { | ||||
|     await APIClient.exec({ | ||||
|       uri: `/tokens/${t.id}`, | ||||
|       method: "DELETE", | ||||
|     }); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										198
									
								
								moneymgr_web/src/dialogs/CreateTokenDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										198
									
								
								moneymgr_web/src/dialogs/CreateTokenDialog.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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<NewToken | undefined>(); | ||||
|   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 ( | ||||
|     <Dialog open={p.open} onClose={cancel}> | ||||
|       <DialogTitle>Nouveau jeton</DialogTitle> | ||||
|       <DialogContent> | ||||
|         <TextInput | ||||
|           editable | ||||
|           label="Token name" | ||||
|           value={newToken.name} | ||||
|           onValueChange={(v) => { | ||||
|             setNewToken({ | ||||
|               ...newToken, | ||||
|               name: v ?? "", | ||||
|             }); | ||||
|           }} | ||||
|           size={ServerApi.Config.constraints.token_name} | ||||
|         /> | ||||
|         <TextInput | ||||
|           editable | ||||
|           label="IP Network (optional)" | ||||
|           value={newToken.ip_net} | ||||
|           onValueChange={(v) => { | ||||
|             setNewToken({ | ||||
|               ...newToken, | ||||
|               ip_net: v ?? "", | ||||
|             }); | ||||
|           }} | ||||
|           size={ServerApi.Config.constraints.token_ip_net} | ||||
|         /> | ||||
|         <TextInput | ||||
|           editable | ||||
|           label="Max inactivity period (days)" | ||||
|           type="number" | ||||
|           value={(newToken.max_inactivity / SECS_IN_DAY).toString()} | ||||
|           onValueChange={(i) => { | ||||
|             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, | ||||
|           }} | ||||
|         /> | ||||
|         <CheckboxInput | ||||
|           editable | ||||
|           label="Read only" | ||||
|           checked={newToken.read_only} | ||||
|           onValueChange={(v) => { | ||||
|             setNewToken({ | ||||
|               ...newToken, | ||||
|               read_only: v, | ||||
|             }); | ||||
|           }} | ||||
|         /> | ||||
|         <br /> | ||||
|         <CheckboxInput | ||||
|           editable | ||||
|           label="Right: account routes" | ||||
|           checked={newToken.right_account} | ||||
|           onValueChange={(v) => { | ||||
|             setNewToken({ | ||||
|               ...newToken, | ||||
|               right_account: v, | ||||
|             }); | ||||
|           }} | ||||
|         /> | ||||
|         <br /> | ||||
|         <CheckboxInput | ||||
|           editable | ||||
|           label="Right: movement routes" | ||||
|           checked={newToken.right_movement} | ||||
|           onValueChange={(v) => { | ||||
|             setNewToken({ | ||||
|               ...newToken, | ||||
|               right_movement: v, | ||||
|             }); | ||||
|           }} | ||||
|         /> | ||||
|         <br /> | ||||
|         <CheckboxInput | ||||
|           editable | ||||
|           label="Right: inbox routes" | ||||
|           checked={newToken.right_inbox} | ||||
|           onValueChange={(v) => { | ||||
|             setNewToken({ | ||||
|               ...newToken, | ||||
|               right_inbox: v, | ||||
|             }); | ||||
|           }} | ||||
|         /> | ||||
|         <br /> | ||||
|         <CheckboxInput | ||||
|           editable | ||||
|           label="Right: attachment routes" | ||||
|           checked={newToken.right_attachment} | ||||
|           onValueChange={(v) => { | ||||
|             setNewToken({ | ||||
|               ...newToken, | ||||
|               right_attachment: v, | ||||
|             }); | ||||
|           }} | ||||
|         /> | ||||
|         <br /> | ||||
|         <CheckboxInput | ||||
|           editable | ||||
|           label="Right: auth routes" | ||||
|           checked={newToken.right_auth} | ||||
|           onValueChange={(v) => { | ||||
|             setNewToken({ | ||||
|               ...newToken, | ||||
|               right_auth: v, | ||||
|             }); | ||||
|           }} | ||||
|         /> | ||||
|       </DialogContent> | ||||
|       <DialogActions> | ||||
|         <Button onClick={cancel}>Annuler</Button> | ||||
|         <Button onClick={submit} autoFocus disabled={!isValid}> | ||||
|           Créer | ||||
|         </Button> | ||||
|       </DialogActions> | ||||
|     </Dialog> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										262
									
								
								moneymgr_web/src/routes/TokensRoute.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										262
									
								
								moneymgr_web/src/routes/TokensRoute.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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<Token[] | undefined>(); | ||||
|  | ||||
|   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 ( | ||||
|     <AsyncWidget | ||||
|       loadKey={count.current} | ||||
|       ready={list !== undefined} | ||||
|       load={load} | ||||
|       errMsg="Failed to load API token list!" | ||||
|       build={() => ( | ||||
|         <> | ||||
|           <CreateTokenDialog | ||||
|             open={openCreateTokenDialog} | ||||
|             onClose={closeCreateTokenDialog} | ||||
|             onCreated={onCreatedToken} | ||||
|           /> | ||||
|  | ||||
|           <TokensRouteInner | ||||
|             list={list!} | ||||
|             onReload={reload} | ||||
|             onRequestCreateToken={onRequestCreateToken} | ||||
|             createdToken={createdToken} | ||||
|           /> | ||||
|         </> | ||||
|       )} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| 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 ?? ( | ||||
|             <span style={{ fontStyle: "italic" }}>Unrestricted</span> | ||||
|           ) | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "time_create", | ||||
|       headerName: "Creation", | ||||
|       flex: 3, | ||||
|       renderCell(params) { | ||||
|         return <TimeWidget time={params.row.time_create} />; | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "last_used", | ||||
|       headerName: "Last usage", | ||||
|       flex: 3, | ||||
|       renderCell(params) { | ||||
|         return <TimeWidget time={params.row.last_used} />; | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "max_inactivity", | ||||
|       headerName: "Max inactivity", | ||||
|       flex: 3, | ||||
|       renderCell(params) { | ||||
|         return <TimeWidget time={params.row.max_inactivity} isDuration />; | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       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 [ | ||||
|           <GridActionsCellItem | ||||
|             icon={<DeleteIcon />} | ||||
|             label="Delete" | ||||
|             onClick={handleDeleteClick(id)} | ||||
|             color="inherit" | ||||
|           />, | ||||
|         ]; | ||||
|       }, | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <MoneyMgrWebRouteContainer | ||||
|       label="API Tokens" | ||||
|       actions={<Button onClick={p.onRequestCreateToken}>New</Button>} | ||||
|     > | ||||
|       {p.createdToken && <CreatedToken token={p.createdToken} />} | ||||
|       <DataGrid | ||||
|         style={{ flex: "1" }} | ||||
|         rows={p.list} | ||||
|         columns={columns} | ||||
|         autoPageSize | ||||
|         getRowId={(c) => c.id} | ||||
|         isCellEditable={() => false} | ||||
|         isRowSelectable={() => false} | ||||
|       /> | ||||
|     </MoneyMgrWebRouteContainer> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function CreatedToken(p: { token: TokenWithSecret }): React.ReactElement { | ||||
|   return ( | ||||
|     <Alert severity="success" style={{ margin: "10px" }}> | ||||
|       <div | ||||
|         style={{ | ||||
|           display: "flex", | ||||
|           flexDirection: "row", | ||||
|         }} | ||||
|       > | ||||
|         <div style={{ textAlign: "center", marginRight: "10px" }}> | ||||
|           <div style={{ padding: "15px", backgroundColor: "white" }}> | ||||
|             <QRCodeCanvas | ||||
|               value={`moneymgr://api=${encodeURIComponent( | ||||
|                 APIClient.backendURL() | ||||
|               )}&id=${p.token.id}&secret=${p.token.token}`} | ||||
|             /> | ||||
|           </div> | ||||
|           <br /> | ||||
|           <em>Mobile App Qr Code</em> | ||||
|         </div> | ||||
|         <div> | ||||
|           <AlertTitle>Token successfully created</AlertTitle> | ||||
|           The API token was successfully created. Please note the following | ||||
|           information as they won't be available next. | ||||
|           <br /> | ||||
|           Token ID: <CopyTextChip text={p.token.id.toString()} /> | ||||
|           <br /> | ||||
|           Token value: <CopyTextChip text={p.token.token} /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </Alert> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										20
									
								
								moneymgr_web/src/utils/DateUtils.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								moneymgr_web/src/utils/DateUtils.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| } | ||||
							
								
								
									
										32
									
								
								moneymgr_web/src/utils/FormUtils.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								moneymgr_web/src/utils/FormUtils.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										22
									
								
								moneymgr_web/src/widgets/CopyTextChip.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								moneymgr_web/src/widgets/CopyTextChip.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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 ( | ||||
|     <Tooltip title="Copy to clipboard"> | ||||
|       <Chip | ||||
|         label={p.text} | ||||
|         variant="outlined" | ||||
|         style={{ margin: "5px" }} | ||||
|         onClick={copyTextToClipboard} | ||||
|       /> | ||||
|     </Tooltip> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										34
									
								
								moneymgr_web/src/widgets/MoneyMgrWebRouteContainer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								moneymgr_web/src/widgets/MoneyMgrWebRouteContainer.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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 ( | ||||
|     <div | ||||
|       style={{ | ||||
|         margin: "50px", | ||||
|         flex: "1", | ||||
|         display: "flex", | ||||
|         flexDirection: "column", | ||||
|       }} | ||||
|     > | ||||
|       <div | ||||
|         style={{ | ||||
|           display: "flex", | ||||
|           justifyContent: "space-between", | ||||
|           alignItems: "center", | ||||
|           marginBottom: "20px", | ||||
|         }} | ||||
|       > | ||||
|         <Typography variant="h4">{p.label}</Typography> | ||||
|         {p.actions ?? <></>} | ||||
|       </div> | ||||
|  | ||||
|       {p.children} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										75
									
								
								moneymgr_web/src/widgets/TimeWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								moneymgr_web/src/widgets/TimeWidget.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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 ( | ||||
|     <Tooltip | ||||
|       title={formatDate( | ||||
|         p.isDuration ? new Date().getTime() / 1000 - p.time : p.time | ||||
|       )} | ||||
|       arrow | ||||
|     > | ||||
|       <span> | ||||
|         {p.isDuration ? timeDiff(0, p.time) : timeDiffFromNow(p.time)} | ||||
|       </span> | ||||
|     </Tooltip> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										21
									
								
								moneymgr_web/src/widgets/forms/CheckboxInput.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								moneymgr_web/src/widgets/forms/CheckboxInput.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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 ( | ||||
|     <FormControlLabel | ||||
|       control={ | ||||
|         <Checkbox | ||||
|           disabled={!p.editable} | ||||
|           checked={p.checked} | ||||
|           onChange={(e) => p.onValueChange(e.target.checked)} | ||||
|         /> | ||||
|       } | ||||
|       label={p.label} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										62
									
								
								moneymgr_web/src/widgets/forms/TextInput.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								moneymgr_web/src/widgets/forms/TextInput.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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 ( | ||||
|     <TextField | ||||
|       label={p.label} | ||||
|       value={p.value ?? ""} | ||||
|       onChange={(e) => | ||||
|         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} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
		Reference in New Issue
	
	Block a user