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/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}
|
||||
/>
|
||||
);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user