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 ( + + ); +}