Add API tokens support #9

Merged
pierre merged 40 commits from api into master 2024-04-23 17:04:45 +00:00
8 changed files with 240 additions and 19 deletions
Showing only changes of commit 91127ea61f - Show all commits

View File

@ -14,6 +14,7 @@ mod rest_token {
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
pub struct RestToken { pub struct RestToken {
#[serde(flatten)]
token: Token, token: Token,
} }

View File

@ -27,6 +27,7 @@
"@types/react-syntax-highlighter": "^15.5.11", "@types/react-syntax-highlighter": "^15.5.11",
"@types/uuid": "^9.0.5", "@types/uuid": "^9.0.5",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"date-and-time": "^3.1.1",
"filesize": "^10.0.12", "filesize": "^10.0.12",
"humanize-duration": "^3.29.0", "humanize-duration": "^3.29.0",
"mui-file-input": "^4.0.4", "mui-file-input": "^4.0.4",
@ -35,7 +36,7 @@
"react-router-dom": "^6.15.0", "react-router-dom": "^6.15.0",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"react-vnc": "^1.0.0", "react-vnc": "^1.0.0",
"typescript": "^4.1.6", "typescript": "^5.0.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"vite": "^5.0.8", "vite": "^5.0.8",
"vite-tsconfig-paths": "^4.2.2", "vite-tsconfig-paths": "^4.2.2",
@ -8877,6 +8878,11 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/date-and-time": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-3.1.1.tgz",
"integrity": "sha512-N9kstidT3P0VUk1iKOFilOZ6251r6iTUNx9M9kvgL2jqOk9mljWZUq5CjAtYwCnppWHbERk5YFQUrSbY7FQOpA=="
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.4", "version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@ -21890,15 +21896,15 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "4.9.5", "version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
}, },
"engines": { "engines": {
"node": ">=4.2.0" "node": ">=14.17"
} }
}, },
"node_modules/unbox-primitive": { "node_modules/unbox-primitive": {
@ -22231,20 +22237,6 @@
} }
} }
}, },
"node_modules/vite-tsconfig-paths/node_modules/typescript": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.4.tgz",
"integrity": "sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==",
"optional": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/w3c-hr-time": { "node_modules/w3c-hr-time": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",

View File

@ -23,6 +23,7 @@
"@types/react-syntax-highlighter": "^15.5.11", "@types/react-syntax-highlighter": "^15.5.11",
"@types/uuid": "^9.0.5", "@types/uuid": "^9.0.5",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"date-and-time": "^3.1.1",
"filesize": "^10.0.12", "filesize": "^10.0.12",
"humanize-duration": "^3.29.0", "humanize-duration": "^3.29.0",
"mui-file-input": "^4.0.4", "mui-file-input": "^4.0.4",

View File

@ -32,6 +32,7 @@ import {
CreateNWFilterRoute, CreateNWFilterRoute,
EditNWFilterRoute, EditNWFilterRoute,
} from "./routes/EditNWFilterRoute"; } from "./routes/EditNWFilterRoute";
import { TokensListRoute } from "./routes/TokensListRoute";
interface AuthContext { interface AuthContext {
signedIn: boolean; signedIn: boolean;
@ -72,6 +73,8 @@ export function App() {
<Route path="nwfilter/:uuid" element={<ViewNWFilterRoute />} /> <Route path="nwfilter/:uuid" element={<ViewNWFilterRoute />} />
<Route path="nwfilter/:uuid/edit" element={<EditNWFilterRoute />} /> <Route path="nwfilter/:uuid/edit" element={<EditNWFilterRoute />} />
<Route path="tokens" element={<TokensListRoute />} />
<Route path="sysinfo" element={<SysInfoRoute />} /> <Route path="sysinfo" element={<SysInfoRoute />} />
<Route path="*" element={<NotFoundRoute />} /> <Route path="*" element={<NotFoundRoute />} />
</Route> </Route>

View File

@ -0,0 +1,36 @@
import { APIClient } from "./ApiClient";
export interface TokenRight {
verb: "POST" | "GET" | "PUT" | "DELETE" | "PATCH";
path: string;
}
export interface APIToken {
id: string;
name: string;
description: string;
created: number;
updated: number;
rights: TokenRight[];
last_used: number;
ip_restriction?: string;
max_inactivity?: number;
}
export function APITokenURL(t: APIToken, edit: boolean = false): string {
return `/tokens/${t.id}${edit ? "/edit" : ""}`;
}
export class TokensApi {
/**
* Get the full list of tokens
*/
static async GetList(): Promise<APIToken[]> {
return (
await APIClient.exec({
method: "GET",
uri: "/tokens/list",
})
).data;
}
}

View File

@ -0,0 +1,118 @@
import React from "react";
import { APIToken, APITokenURL, TokensApi } from "../api/TokensApi";
import { AsyncWidget } from "../widgets/AsyncWidget";
import {
Button,
IconButton,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from "@mui/material";
import { RouterLink } from "../widgets/RouterLink";
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
import { useNavigate } from "react-router-dom";
import VisibilityIcon from "@mui/icons-material/Visibility";
import { TimeWidget, timeDiff } from "../widgets/TimeWidget";
export function TokensListRoute(): React.ReactElement {
const [list, setList] = React.useState<APIToken[] | undefined>();
const [count] = React.useState(1);
const load = async () => {
setList(await TokensApi.GetList());
};
return (
<AsyncWidget
loadKey={count}
load={load}
ready={list !== undefined}
errMsg="Failed to load the list of tokens!"
build={() => <TokensListRouteInner list={list!} />}
/>
);
}
export function TokensListRouteInner(p: {
list: APIToken[];
}): React.ReactElement {
const navigate = useNavigate();
return (
<VirtWebRouteContainer
label="API tokens"
actions={
<RouterLink to="/tokens/new">
<Button>New</Button>
</RouterLink>
}
>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Description</TableCell>
<TableCell>Created</TableCell>
<TableCell>Updated</TableCell>
<TableCell>Last used</TableCell>
<TableCell>IP restriction</TableCell>
<TableCell>Max inactivity</TableCell>
<TableCell>Rights</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{p.list.map((t) => {
return (
<TableRow
key={t.id}
hover
onDoubleClick={() => navigate(APITokenURL(t))}
>
<TableCell>{t.name}</TableCell>
<TableCell>{t.description}</TableCell>
<TableCell>
<TimeWidget time={t.created} />
</TableCell>
<TableCell>
<TimeWidget time={t.updated} />
</TableCell>
<TableCell>
<TimeWidget time={t.last_used} />
</TableCell>
<TableCell>{t.ip_restriction}</TableCell>
<TableCell>
{t.max_inactivity && timeDiff(0, t.max_inactivity)}
</TableCell>
<TableCell>
{t.rights.map((r) => {
return (
<div>
{r.verb} {r.path}
</div>
);
})}
</TableCell>
<TableCell>
<RouterLink to={APITokenURL(t)}>
<IconButton>
<VisibilityIcon />
</IconButton>
</RouterLink>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</VirtWebRouteContainer>
);
}

View File

@ -1,4 +1,5 @@
import { import {
mdiApi,
mdiBoxShadow, mdiBoxShadow,
mdiDisc, mdiDisc,
mdiHome, mdiHome,
@ -72,6 +73,11 @@ export function BaseAuthenticatedPage(): React.ReactElement {
uri="/iso" uri="/iso"
icon={<Icon path={mdiDisc} size={1} />} icon={<Icon path={mdiDisc} size={1} />}
/> />
<NavLink
label="API tokens"
uri="/tokens"
icon={<Icon path={mdiApi} size={1} />}
/>
<NavLink <NavLink
label="Sysinfo" label="Sysinfo"
uri="/sysinfo" uri="/sysinfo"

View File

@ -0,0 +1,64 @@
import { Tooltip } from "@mui/material";
import date from "date-and-time";
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 < 24) {
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(time: number): string {
return timeDiff(time, Math.floor(new Date().getTime() / 1000));
}
export function TimeWidget(p: { time?: number }): React.ReactElement {
if (!p.time) return <></>;
return (
<Tooltip title={formatDate(p.time)}>
<span>{timeDiffFromNow(p.time)}</span>
</Tooltip>
);
}