Create and delete tokens from web ui

This commit is contained in:
Pierre HUBERT 2025-03-19 21:08:59 +01:00
parent 544513d118
commit 7155d91bed
14 changed files with 822 additions and 1 deletions

View File

@ -17,7 +17,9 @@
"@mui/material": "^6.4.8", "@mui/material": "^6.4.8",
"@mui/x-data-grid": "^7.28.0", "@mui/x-data-grid": "^7.28.0",
"@mui/x-date-pickers": "^7.28.0", "@mui/x-date-pickers": "^7.28.0",
"date-and-time": "^3.6.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"qrcode.react": "^4.2.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router": "^7.3.0", "react-router": "^7.3.0",
@ -2640,6 +2642,12 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT" "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": { "node_modules/dayjs": {
"version": "1.11.13", "version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
@ -3779,6 +3787,15 @@
"node": ">=6" "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": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@ -19,7 +19,9 @@
"@mui/material": "^6.4.8", "@mui/material": "^6.4.8",
"@mui/x-data-grid": "^7.28.0", "@mui/x-data-grid": "^7.28.0",
"@mui/x-date-pickers": "^7.28.0", "@mui/x-date-pickers": "^7.28.0",
"date-and-time": "^3.6.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"qrcode.react": "^4.2.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router": "^7.3.0", "react-router": "^7.3.0",

View File

@ -13,6 +13,7 @@ import { LoginRoute } from "./routes/auth/LoginRoute";
import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute"; import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
import { BaseLoginPage } from "./widgets/BaseLoginPage"; import { BaseLoginPage } from "./widgets/BaseLoginPage";
import { TokensRoute } from "./routes/TokensRoute";
interface AuthContext { interface AuthContext {
signedIn: boolean; signedIn: boolean;
@ -37,6 +38,7 @@ export function App() {
signedIn || ServerApi.Config.auth_disabled ? ( signedIn || ServerApi.Config.auth_disabled ? (
<Route path="*" element={<BaseAuthenticatedPage />}> <Route path="*" element={<BaseAuthenticatedPage />}>
<Route path="" element={<HomeRoute />} /> <Route path="" element={<HomeRoute />} />
<Route path="tokens" element={<TokensRoute />} />
<Route path="*" element={<NotFoundRoute />} /> <Route path="*" element={<NotFoundRoute />} />
</Route> </Route>

View File

@ -6,7 +6,11 @@ export interface ServerConfig {
constraints: ServerConstraints; constraints: ServerConstraints;
} }
export interface ServerConstraints {} export interface ServerConstraints {
token_name: LenConstraint;
token_ip_net: LenConstraint;
token_max_inactivity: LenConstraint;
}
export interface LenConstraint { export interface LenConstraint {
min: number; min: number;

View 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",
});
}
}

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

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

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

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

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

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

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

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

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