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