Create and delete tokens from web ui
This commit is contained in:
parent
544513d118
commit
7155d91bed
17
moneymgr_web/package-lock.json
generated
17
moneymgr_web/package-lock.json
generated
@ -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",
|
||||||
|
@ -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",
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user