diff --git a/virtweb_frontend/src/App.tsx b/virtweb_frontend/src/App.tsx
index 14f3f57..adff3b3 100644
--- a/virtweb_frontend/src/App.tsx
+++ b/virtweb_frontend/src/App.tsx
@@ -9,30 +9,35 @@ import "./App.css";
import { AuthApi } from "./api/AuthApi";
import { ServerApi } from "./api/ServerApi";
import {
- CreateNetworkRoute,
- EditNetworkRoute,
-} from "./routes/EditNetworkRoute";
-import { CreateVMRoute, EditVMRoute } from "./routes/EditVMRoute";
-import { IsoFilesRoute } from "./routes/IsoFilesRoute";
-import { NetworksListRoute } from "./routes/NetworksListRoute";
-import { NotFoundRoute } from "./routes/NotFound";
-import { SysInfoRoute } from "./routes/SysInfoRoute";
-import { VMListRoute } from "./routes/VMListRoute";
-import { VMRoute } from "./routes/VMRoute";
-import { VNCRoute } from "./routes/VNCRoute";
-import { LoginRoute } from "./routes/auth/LoginRoute";
-import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
-import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
-import { BaseLoginPage } from "./widgets/BaseLoginPage";
-import { ViewNetworkRoute } from "./routes/ViewNetworkRoute";
-import { HomeRoute } from "./routes/HomeRoute";
-import { NetworkFiltersListRoute } from "./routes/NetworkFiltersListRoute";
-import { ViewNWFilterRoute } from "./routes/ViewNWFilterRoute";
+ CreateApiTokenRoute,
+ EditApiTokenRoute,
+} from "./routes/EditAPITokenRoute";
import {
CreateNWFilterRoute,
EditNWFilterRoute,
} from "./routes/EditNWFilterRoute";
+import {
+ CreateNetworkRoute,
+ EditNetworkRoute,
+} from "./routes/EditNetworkRoute";
+import { CreateVMRoute, EditVMRoute } from "./routes/EditVMRoute";
+import { HomeRoute } from "./routes/HomeRoute";
+import { IsoFilesRoute } from "./routes/IsoFilesRoute";
+import { NetworkFiltersListRoute } from "./routes/NetworkFiltersListRoute";
+import { NetworksListRoute } from "./routes/NetworksListRoute";
+import { NotFoundRoute } from "./routes/NotFound";
+import { SysInfoRoute } from "./routes/SysInfoRoute";
import { TokensListRoute } from "./routes/TokensListRoute";
+import { VMListRoute } from "./routes/VMListRoute";
+import { VMRoute } from "./routes/VMRoute";
+import { VNCRoute } from "./routes/VNCRoute";
+import { ViewApiTokenRoute } from "./routes/ViewApiTokenRoute";
+import { ViewNWFilterRoute } from "./routes/ViewNWFilterRoute";
+import { ViewNetworkRoute } from "./routes/ViewNetworkRoute";
+import { LoginRoute } from "./routes/auth/LoginRoute";
+import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
+import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
+import { BaseLoginPage } from "./widgets/BaseLoginPage";
interface AuthContext {
signedIn: boolean;
@@ -74,6 +79,9 @@ export function App() {
} />
} />
+ } />
+ } />
+ } />
} />
} />
diff --git a/virtweb_frontend/src/api/ServerApi.ts b/virtweb_frontend/src/api/ServerApi.ts
index 99e710c..203d0a0 100644
--- a/virtweb_frontend/src/api/ServerApi.ts
+++ b/virtweb_frontend/src/api/ServerApi.ts
@@ -27,6 +27,8 @@ export interface ServerConstraints {
nwfilter_comment_size: LenConstraint;
nwfilter_priority: LenConstraint;
nwfilter_selectors_count: LenConstraint;
+ api_token_name_size: LenConstraint;
+ api_token_description_size: LenConstraint;
}
export interface LenConstraint {
diff --git a/virtweb_frontend/src/api/TokensApi.ts b/virtweb_frontend/src/api/TokensApi.ts
index d593a31..696d4cc 100644
--- a/virtweb_frontend/src/api/TokensApi.ts
+++ b/virtweb_frontend/src/api/TokensApi.ts
@@ -21,7 +21,30 @@ export function APITokenURL(t: APIToken, edit: boolean = false): string {
return `/tokens/${t.id}${edit ? "/edit" : ""}`;
}
+export interface APITokenPrivateKey {
+ alg: string;
+ priv: string;
+}
+
+export interface CreatedAPIToken {
+ token: APIToken;
+ priv_key: APITokenPrivateKey;
+}
+
export class TokensApi {
+ /**
+ * Create a new API token
+ */
+ static async Create(n: APIToken): Promise {
+ return (
+ await APIClient.exec({
+ method: "POST",
+ uri: "/tokens/create",
+ jsonData: n,
+ })
+ ).data;
+ }
+
/**
* Get the full list of tokens
*/
@@ -33,4 +56,39 @@ export class TokensApi {
})
).data;
}
+
+ /**
+ * Get the information about a single token
+ */
+ static async GetSingle(uuid: string): Promise {
+ return (
+ await APIClient.exec({
+ method: "GET",
+ uri: `/tokens/${uuid}`,
+ })
+ ).data;
+ }
+
+ /**
+ * Update an existing API token information
+ */
+ static async Update(n: APIToken): Promise {
+ return (
+ await APIClient.exec({
+ method: "PATCH",
+ uri: `/tokens/${n.id}`,
+ jsonData: n,
+ })
+ ).data;
+ }
+
+ /**
+ * Delete an API token
+ */
+ static async Delete(n: APIToken): Promise {
+ await APIClient.exec({
+ method: "DELETE",
+ uri: `/tokens/${n.id}`,
+ });
+ }
}
diff --git a/virtweb_frontend/src/routes/EditAPITokenRoute.tsx b/virtweb_frontend/src/routes/EditAPITokenRoute.tsx
new file mode 100644
index 0000000..4f1358d
--- /dev/null
+++ b/virtweb_frontend/src/routes/EditAPITokenRoute.tsx
@@ -0,0 +1,148 @@
+import { Button } from "@mui/material";
+import React from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { APIToken, APITokenURL, TokensApi } from "../api/TokensApi";
+import { useAlert } from "../hooks/providers/AlertDialogProvider";
+import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
+import { useSnackbar } from "../hooks/providers/SnackbarProvider";
+import { time } from "../utils/DateUtils";
+import { AsyncWidget } from "../widgets/AsyncWidget";
+import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
+import {
+ APITokenDetails,
+ TokenWidgetStatus,
+} from "../widgets/tokens/APITokenDetails";
+
+export function CreateApiTokenRoute(): React.ReactElement {
+ const alert = useAlert();
+ const snackbar = useSnackbar();
+ const navigate = useNavigate();
+
+ const [token] = React.useState({
+ id: "",
+ name: "",
+ description: "",
+ created: time(),
+ updated: time(),
+ last_used: time(),
+ rights: [],
+ });
+
+ const createApiToken = async (n: APIToken) => {
+ try {
+ const res = await TokensApi.Create(n);
+ snackbar("The api token was successfully created!");
+ // TODO : show a modal to give token information
+ navigate(APITokenURL(res.token));
+ } catch (e) {
+ console.error(e);
+ alert(`Failed to create API token!\n${e}`);
+ }
+ };
+
+ return (
+ navigate("/tokens")}
+ onSave={createApiToken}
+ />
+ );
+}
+
+export function EditApiTokenRoute(): React.ReactElement {
+ const alert = useAlert();
+ const snackbar = useSnackbar();
+
+ const { id } = useParams();
+ const navigate = useNavigate();
+
+ const [token, setToken] = React.useState();
+
+ const load = async () => {
+ setToken(await TokensApi.GetSingle(id!));
+ };
+
+ const updateApiToken = async (n: APIToken) => {
+ try {
+ await TokensApi.Update(n);
+ snackbar("The token was successfully updated!");
+ navigate(APITokenURL(token!));
+ } catch (e) {
+ console.error(e);
+ alert(`Failed to update token!\n${e}`);
+ }
+ };
+
+ return (
+ (
+ navigate(`/token/${id}`)}
+ onSave={updateApiToken}
+ />
+ )}
+ />
+ );
+}
+
+function EditApiTokenRouteInner(p: {
+ token: APIToken;
+ creating: boolean;
+ onCancel: () => void;
+ onSave: (token: APIToken) => Promise;
+}): React.ReactElement {
+ const loadingMessage = useLoadingMessage();
+
+ const [changed, setChanged] = React.useState(false);
+
+ const [, updateState] = React.useState();
+ const forceUpdate = React.useCallback(() => updateState({}), []);
+
+ const valueChanged = () => {
+ setChanged(true);
+ forceUpdate();
+ };
+
+ const save = async () => {
+ loadingMessage.show("Saving API token configuration...");
+ await p.onSave(p.token);
+ loadingMessage.hide();
+ };
+
+ return (
+
+ {changed && (
+
+ )}
+
+
+ }
+ >
+
+
+ );
+}
diff --git a/virtweb_frontend/src/routes/NetworksListRoute.tsx b/virtweb_frontend/src/routes/NetworksListRoute.tsx
index 8f7c5d3..a8ef9ce 100644
--- a/virtweb_frontend/src/routes/NetworksListRoute.tsx
+++ b/virtweb_frontend/src/routes/NetworksListRoute.tsx
@@ -1,4 +1,3 @@
-import DeleteIcon from "@mui/icons-material/Delete";
import VisibilityIcon from "@mui/icons-material/Visibility";
import {
Button,
@@ -13,13 +12,13 @@ import {
Typography,
} from "@mui/material";
import React from "react";
+import { useNavigate } from "react-router-dom";
import { NetworkApi, NetworkInfo, NetworkURL } from "../api/NetworksApi";
import { AsyncWidget } from "../widgets/AsyncWidget";
import { RouterLink } from "../widgets/RouterLink";
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
-import { NetworkStatusWidget } from "../widgets/net/NetworkStatusWidget";
-import { useNavigate } from "react-router-dom";
import { NetworkHookStatusWidget } from "../widgets/net/NetworkHookStatusWidget";
+import { NetworkStatusWidget } from "../widgets/net/NetworkStatusWidget";
export function NetworksListRoute(): React.ReactElement {
const [list, setList] = React.useState();
diff --git a/virtweb_frontend/src/routes/ViewApiTokenRoute.tsx b/virtweb_frontend/src/routes/ViewApiTokenRoute.tsx
new file mode 100644
index 0000000..f94e952
--- /dev/null
+++ b/virtweb_frontend/src/routes/ViewApiTokenRoute.tsx
@@ -0,0 +1,53 @@
+import { Button } from "@mui/material";
+import React from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { APIToken, APITokenURL, TokensApi } from "../api/TokensApi";
+import { AsyncWidget } from "../widgets/AsyncWidget";
+import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
+import {
+ APITokenDetails,
+ TokenWidgetStatus,
+} from "../widgets/tokens/APITokenDetails";
+
+export function ViewApiTokenRoute() {
+ const { id } = useParams();
+
+ const [token, setToken] = React.useState();
+
+ const load = async () => {
+ setToken(await TokensApi.GetSingle(id!));
+ };
+
+ return (
+ }
+ />
+ );
+}
+
+function ViewAPITokenRouteInner(p: { token: APIToken }): React.ReactElement {
+ const navigate = useNavigate();
+
+ return (
+
+
+
+ }
+ >
+
+
+ );
+}
diff --git a/virtweb_frontend/src/widgets/forms/IPInput.tsx b/virtweb_frontend/src/widgets/forms/IPInput.tsx
index c7db678..90df36a 100644
--- a/virtweb_frontend/src/widgets/forms/IPInput.tsx
+++ b/virtweb_frontend/src/widgets/forms/IPInput.tsx
@@ -22,14 +22,16 @@ export function IPInput(p: {
export function IPInputWithMask(p: {
label: string;
editable: boolean;
+ ipAndMask?: string;
ip?: string;
mask?: number;
- onValueChange?: (ip?: string, mask?: number) => void;
+ onValueChange?: (ip?: string, mask?: number, ipAndMask?: string) => void;
version: 4 | 6;
}): React.ReactElement {
const showSlash = React.useRef(!!p.mask);
const currValue =
+ p.ipAndMask ??
(p.ip ?? "") + (p.mask || showSlash.current ? "/" : "") + (p.mask ?? "");
const { onValueChange, ...props } = p;
@@ -38,7 +40,7 @@ export function IPInputWithMask(p: {
onValueChange={(v) => {
showSlash.current = false;
if (!v) {
- onValueChange?.(undefined, undefined);
+ onValueChange?.(undefined, undefined, undefined);
return;
}
@@ -52,7 +54,11 @@ export function IPInputWithMask(p: {
mask = sanitizeMask(p.version, split[1]);
}
- onValueChange?.(ip, mask);
+ onValueChange?.(
+ ip,
+ mask,
+ mask || showSlash.current ? `${ip}/${mask ?? ""}` : ip
+ );
}}
value={currValue}
{...props}
diff --git a/virtweb_frontend/src/widgets/tokens/APITokenDetails.tsx b/virtweb_frontend/src/widgets/tokens/APITokenDetails.tsx
new file mode 100644
index 0000000..776c969
--- /dev/null
+++ b/virtweb_frontend/src/widgets/tokens/APITokenDetails.tsx
@@ -0,0 +1,216 @@
+import { Button, Grid } from "@mui/material";
+import React from "react";
+import { useNavigate } from "react-router-dom";
+import { NWFilter, NWFilterApi } from "../../api/NWFilterApi";
+import { NetworkApi, NetworkInfo } from "../../api/NetworksApi";
+import { ServerApi } from "../../api/ServerApi";
+import { APIToken, TokensApi } from "../../api/TokensApi";
+import { VMApi, VMInfo } from "../../api/VMApi";
+import { useAlert } from "../../hooks/providers/AlertDialogProvider";
+import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
+import { useSnackbar } from "../../hooks/providers/SnackbarProvider";
+import { AsyncWidget } from "../AsyncWidget";
+import { TabsWidget } from "../TabsWidget";
+import { EditSection } from "../forms/EditSection";
+import { IPInput, IPInputWithMask } from "../forms/IPInput";
+import { ResAutostartInput } from "../forms/ResAutostartInput";
+import { SelectInput } from "../forms/SelectInput";
+import { TextInput } from "../forms/TextInput";
+import { RadioGroupInput } from "../forms/RadioGroupInput";
+
+export enum TokenWidgetStatus {
+ Create,
+ Read,
+ Update,
+}
+
+interface DetailsProps {
+ token: APIToken;
+ status: TokenWidgetStatus;
+ onChange?: () => void;
+}
+
+export function APITokenDetails(p: DetailsProps): React.ReactElement {
+ const [vms, setVMs] = React.useState();
+ const [networks, setNetworks] = React.useState();
+ const [nwFilters, setNetworkFilters] = React.useState();
+ const [tokens, setTokens] = React.useState();
+
+ const load = async () => {
+ setVMs(await VMApi.GetList());
+ setNetworks(await NetworkApi.GetList());
+ setNetworkFilters(await NWFilterApi.GetList());
+ setTokens(await TokensApi.GetList());
+ };
+
+ return (
+ (
+
+ )}
+ />
+ );
+}
+
+enum TokenTab {
+ General = 0,
+ Rights,
+ RawRights,
+ Danger,
+}
+
+type DetailsInnerProps = DetailsProps & {
+ vms: VMInfo[];
+ networks: NetworkInfo[];
+ nwFilters: NWFilter[];
+ tokens: APIToken[];
+};
+
+function APITokenDetailsInner(p: DetailsInnerProps): React.ReactElement {
+ const [currTab, setCurrTab] = React.useState(TokenTab.General);
+
+ return (
+ <>
+
+ {currTab === TokenTab.General && }
+ {/* todo: rights */}
+ {currTab === TokenTab.Danger && }
+ >
+ );
+}
+
+function NetworkDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement {
+ const [ipVersion, setIpVersion] = React.useState<4 | 6>(
+ (p.token.ip_restriction ?? "").includes(":") ? 6 : 4
+ );
+
+ return (
+
+ {/* Metadata section */}
+
+ {p.status !== TokenWidgetStatus.Create && (
+
+ )}
+
+ {
+ p.token.name = v ?? "";
+ p.onChange?.();
+ }}
+ size={ServerApi.Config.constraints.api_token_name_size}
+ />
+
+ {
+ p.token.description = v ?? "";
+ p.onChange?.();
+ }}
+ multiline={true}
+ size={ServerApi.Config.constraints.api_token_description_size}
+ />
+
+
+
+ {(p.status === TokenWidgetStatus.Create || p.token.ip_restriction) && (
+ {
+ setIpVersion(Number(v) as any);
+ }}
+ />
+ )}
+ {
+ p.token.ip_restriction = ipAndMask;
+ p.onChange?.();
+ }}
+ />
+
+ {/* TODO : remaining */}
+
+
+ );
+}
+
+function APITokenTabDanger(p: DetailsInnerProps): React.ReactElement {
+ const confirm = useConfirm();
+ const snackbar = useSnackbar();
+ const alert = useAlert();
+ const navigate = useNavigate();
+
+ const requestDelete = async () => {
+ try {
+ if (
+ !(await confirm(
+ "Do you really want to delete this API token?",
+ `Delete API token ${p.token.name}`,
+ "Delete"
+ ))
+ )
+ return;
+
+ await TokensApi.Delete(p.token);
+
+ navigate("/tokens");
+ snackbar("The API token was successfully deleted!");
+ } catch (e) {
+ console.error(e);
+ alert(`Failed to delete the API token!\n${e}`);
+ }
+ };
+
+ return (
+
+ );
+}