diff --git a/.gitignore b/.gitignore index 16214eed6..9e926c609 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist/ README.dev.md docs/parameters.json +local \ No newline at end of file diff --git a/images/dev-config.yaml b/images/dev-config.yaml index 789b78b9f..1b2033c32 100644 --- a/images/dev-config.yaml +++ b/images/dev-config.yaml @@ -1,2 +1,3 @@ OriginUrl: https://localhost:8444 TLSSkipVerify: true + diff --git a/registry/registry_db.go b/registry/registry_db.go index dbc011cd1..8113b17ef 100644 --- a/registry/registry_db.go +++ b/registry/registry_db.go @@ -584,6 +584,12 @@ func updateNamespace(ns *Namespace) error { if err != nil || existingNs == nil { return errors.Wrap(err, "Failed to get namespace") } + if ns.Prefix == "" { + ns.Prefix = existingNs.Prefix + } + if ns.Pubkey == "" { + ns.Pubkey = existingNs.Pubkey + } existingNsAdmin := existingNs.AdminMetadata // We prevent the following fields from being modified by the user for now. // They are meant for "internal" use only and we don't support changing @@ -602,12 +608,12 @@ func updateNamespace(ns *Namespace) error { // We intentionally exclude updating "identity" as this should only be updated // when user registered through Pelican client with identity - query := `UPDATE namespace SET pubkey = ?, admin_metadata = ? WHERE id = ?` + query := `UPDATE namespace SET prefix = ?, pubkey = ?, admin_metadata = ? WHERE id = ?` tx, err := db.Begin() if err != nil { return err } - _, err = tx.Exec(query, ns.Pubkey, strAdminMetadata, ns.ID) + _, err = tx.Exec(query, ns.Prefix, ns.Pubkey, strAdminMetadata, ns.ID) if err != nil { if errRoll := tx.Rollback(); errRoll != nil { log.Errorln("Failed to rollback transaction:", errRoll) diff --git a/registry/registry_ui.go b/registry/registry_ui.go index 862b17d06..6568027e0 100644 --- a/registry/registry_ui.go +++ b/registry/registry_ui.go @@ -293,6 +293,8 @@ func createUpdateNamespace(ctx *gin.Context, isUpdate bool) { ctx.JSON(400, gin.H{"error": "Invalid create or update namespace request"}) return } + // Assign ID from path param because the request data doesn't have ID set + ns.ID = id // Basic validation (type, required, etc) errs := config.GetValidate().Struct(ns) if errs != nil { @@ -376,25 +378,25 @@ func createUpdateNamespace(ctx *gin.Context, isUpdate bool) { // Then check if the user has previlege to update isAdmin, _ := web_ui.CheckAdmin(user) if !isAdmin { // Not admin, need to check if the namespace belongs to the user - found, err := namespaceBelongsToUserId(id, user) + found, err := namespaceBelongsToUserId(ns.ID, user) if err != nil { log.Error("Error checking if namespace belongs to the user: ", err) ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error checking if namespace belongs to the user"}) return } if !found { - log.Errorf("Namespace not found for id: %d", id) + log.Errorf("Namespace not found for id: %d", ns.ID) ctx.JSON(http.StatusNotFound, gin.H{"error": "Namespace not found. Check the id or if you own the namespace"}) return } - existingStatus, err := getNamespaceStatusById(id) + existingStatus, err := getNamespaceStatusById(ns.ID) if err != nil { log.Error("Error checking namespace status: ", err) ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error checking namespace status"}) return } if existingStatus == Approved { - log.Errorf("User '%s' is trying to modify approved namespace registration with id=%d", user, id) + log.Errorf("User '%s' is trying to modify approved namespace registration with id=%d", user, ns.ID) ctx.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to modify an approved registration. Please contact your federation administrator"}) return } diff --git a/web_ui/frontend/README.md b/web_ui/frontend/README.md index 7cec5d1b2..abd23a104 100644 --- a/web_ui/frontend/README.md +++ b/web_ui/frontend/README.md @@ -18,7 +18,7 @@ as they would in production. # From repo root make web-build goreleaser --clean --snapshot -docker run --rm -it -p 8444:8444 -w /app -v $PWD/dist/pelican_linux_arm64/:/app hub.opensciencegrid.org/pelican_platform/pelican-dev:latest-itb /bin/bash +docker run --rm -it -p 8444:8444 -w /app -v $PWD/dist/pelican_linux_arm64/:/app -v $PWD/local/:/etc/pelican/ pelican-dev /bin/bash ``` ```shell diff --git a/web_ui/frontend/app/(login)/components/PasswordInput.tsx b/web_ui/frontend/app/(login)/components/PasswordInput.tsx index 30a0d04f2..06ea5d3f1 100644 --- a/web_ui/frontend/app/(login)/components/PasswordInput.tsx +++ b/web_ui/frontend/app/(login)/components/PasswordInput.tsx @@ -50,6 +50,7 @@ export default function PasswordInput({FormControlProps, TextFieldProps}: Passwo ("") let [loading, setLoading] = useState(false); + let [enabledServers, setEnabledServers] = useState([]) let [error, setError] = useState(undefined); + useEffect(() => { + (async () => { + const response = await fetch("/api/v1.0/servers") + if (response.ok) { + const data = await response.json() + setEnabledServers(data) + } + })() + }, []); + async function submit(password: string) { setLoading(true) @@ -88,18 +99,43 @@ export default function Home() { + { enabledServers !== undefined && enabledServers.includes("registry") && + <> + + For Outside Administrators + + + + + + For Registry Administrator + + + }
- - { - setPassword(e.target.value) - setError(undefined) + + { + setPassword(e.target.value) + setError(undefined) + } } - } - }}/> + }} + /> - + - + {"Pelican + + + + + + + + + - + diff --git a/web_ui/frontend/app/registry/namespace/components/NamespaceForm.tsx b/web_ui/frontend/app/registry/namespace/components/NamespaceForm.tsx new file mode 100644 index 000000000..557677684 --- /dev/null +++ b/web_ui/frontend/app/registry/namespace/components/NamespaceForm.tsx @@ -0,0 +1,192 @@ +import { + Box, + Button, + FormControl, + FormHelperText, + InputLabel, + MenuItem, + Select, + TextareaAutosize, + TextField +} from "@mui/material"; +import React, {useEffect, useState} from "react"; +import {getServerType} from "@/components/Namespace"; +import {Namespace} from "@/components/Main"; + +interface Institution { + id: string; + name: string; +} + +interface NamespaceFormProps { + namespace?: Namespace; + handleSubmit: (e: React.FormEvent) => Promise; +} + +const NamespaceForm = ({ + namespace, + handleSubmit +}: NamespaceFormProps) => { + + const [institutions, setInstitutions] = useState([]) + const [institution, setInstitution] = useState(namespace?.admin_metadata?.institution || '') + const [serverType, setServerType] = useState<"origin" | "cache" | ''>(namespace !== undefined ? getServerType(namespace) : "") + + useEffect(() => { + (async () => { + const url = new URL("/api/v1.0/registry_ui/institutions", window.location.origin) + const response = await fetch(url) + if (response.ok) { + const responseData: Institution[] = await response.json() + setInstitutions(responseData) + } + })() + }, []); + + const onSubmit = async (e: React.FormEvent) => { + + const form = e.currentTarget + + const successfulSubmit = await handleSubmit(e) + + // Clear the form on successful submit + if (successfulSubmit) { + form.reset() + setInstitution("") + setServerType("") + } + } + + return ( + + + { + if (event.target.value == "") { + setServerType("") + } else if (event.target.value.startsWith("/cache")) { + setServerType("cache") + } else { + setServerType("origin") + } + }} + /> + + + + Namespace Type + + Read Only: Caches are declared with a '/cache' prefix + + + + ", + "kty": "EC", + "x": "", + "y": "" + } + ] +} + `} + /> + + + + + + + + + + Institution + + + + + + + + + + + ) +} + +export default NamespaceForm \ No newline at end of file diff --git a/web_ui/frontend/app/registry/namespace/edit/page.tsx b/web_ui/frontend/app/registry/namespace/edit/page.tsx new file mode 100644 index 000000000..498a8b2d0 --- /dev/null +++ b/web_ui/frontend/app/registry/namespace/edit/page.tsx @@ -0,0 +1,146 @@ +/*************************************************************** + * + * Copyright (C) 2023, Pelican Project, Morgridge Institute for Research + * + * Licensed under the Apache License, Version 2.0 (the "License"); you + * may not use this file except in compliance with the License. You may + * obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ***************************************************************/ + +"use client" + +import { + Box, + Button, + Grid, + Typography, + Collapse, + Alert, + Skeleton +} from "@mui/material"; +import React, {ReactNode, useEffect, useMemo, useState} from "react"; + +import Link from "next/link"; + +import AuthenticatedContent from "@/components/layout/AuthenticatedContent"; +import {secureFetch} from "@/helpers/login"; +import {Namespace, Alert as AlertType} from "@/components/Main"; +import NamespaceForm from "@/app/registry/namespace/components/NamespaceForm"; + +interface Institution { + id: string; + name: string; +} + +export default function Register() { + + const [id, setId] = useState(undefined) + const [namespace, setNamespace] = useState(undefined) + const [alert, setAlert] = useState(undefined) + + + useEffect(() => { + + const urlParams = new URLSearchParams(window.location.search); + const id = urlParams.get('id') + + if(id === null){ + setAlert({severity: "error", message: "No Namespace ID Provided"}) + } else { + setId(id) + } + + (async () => { + + const urlParams = new URLSearchParams(window.location.search); + const id = urlParams.get('id'); + + const url = new URL(`/api/v1.0/registry_ui/namespaces/${id}`, window.location.origin) + const response = await fetch(url) + if (response.ok) { + const namespace: Namespace = await response.json() + setNamespace(namespace) + } else { + setAlert({severity: "error", message: `Failed to fetch namespace: ${id}`}) + } + })() + }, [id]) + + const handleSubmit = async (e: React.FormEvent) => { + + e.preventDefault() + + const formData = new FormData(e.currentTarget); + + try { + const response = await secureFetch(`/api/v1.0/registry_ui/namespaces/${id}`, { + body: JSON.stringify({ + prefix: formData.get("prefix"), + pubkey: formData.get("pubkey"), + admin_metadata: { + description: formData.get("description"), + site_name: formData.get("site-name"), + institution: formData.get("institution"), + security_contact_user_id: formData.get("security-contact-user-id") + } + }), + method: "PUT", + headers: { + "Content-Type": "application/json" + }, + credentials: "include" + }) + + if(!response.ok){ + try { + let data = await response.json() + setAlert({severity: "error", message: response.status + ": " + data['error']}) + } catch (e) { + setAlert({severity: "error", message: `Failed to edit namespace: ${formData.get("prefix")}`}) + } + } else { + setAlert({severity: "success", message: `Successfully edited namespace: ${formData.get("prefix")}`}) + } + + } catch (e) { + console.error(e) + setAlert({severity: "error", message: `Fetch error: ${e}`}) + } + + return false + } + + return ( + + + + Namespace Registry + + + Register Namespace + + + {alert?.message} + + + { + namespace ? + : + + } + + + + + + ) +} diff --git a/web_ui/frontend/app/registry/namespace/register/page.tsx b/web_ui/frontend/app/registry/namespace/register/page.tsx new file mode 100644 index 000000000..c6aa71e4b --- /dev/null +++ b/web_ui/frontend/app/registry/namespace/register/page.tsx @@ -0,0 +1,113 @@ +/*************************************************************** + * + * Copyright (C) 2023, Pelican Project, Morgridge Institute for Research + * + * Licensed under the Apache License, Version 2.0 (the "License"); you + * may not use this file except in compliance with the License. You may + * obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ***************************************************************/ + +"use client" + +import { + Box, + Button, + Grid, + Typography, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + TextareaAutosize, + FormHelperText, + Collapse, + Alert +} from "@mui/material"; +import React, {ReactNode, useEffect, useMemo, useState} from "react"; + +import Link from "next/link"; + +import {Alert as AlertType} from "@/components/Main"; +import NamespaceForm from "@/app/registry/namespace/components/NamespaceForm"; +import AuthenticatedContent from "@/components/layout/AuthenticatedContent"; +import {secureFetch} from "@/helpers/login"; + +export default function Register() { + + const [alert, setAlert] = useState(undefined) + + const handleSubmit = async (e: React.FormEvent) : Promise => { + + e.preventDefault() + + const formData = new FormData(e.currentTarget); + + try { + const response = await secureFetch("/api/v1.0/registry_ui/namespaces", { + body: JSON.stringify({ + prefix: formData.get("prefix"), + pubkey: formData.get("pubkey"), + admin_metadata: { + description: formData.get("description"), + site_name: formData.get("site-name"), + institution: formData.get("institution"), + security_contact_user_id: formData.get("security-contact-user-id") + } + }), + method: "POST", + headers: { + "Content-Type": "application/json" + }, + credentials: "include" + }) + + if(!response.ok){ + try { + let data = await response.json() + setAlert({severity: "error", message: response.status + ": " + data['error']}) + } catch (e) { + setAlert({severity: "error", message: `Failed to register namespace: ${formData.get("prefix")}`}) + } + } else { + setAlert({severity: "success", message: `Successfully registered namespace: ${formData.get("prefix")}`}) + return true + } + + } catch (e) { + setAlert({severity: "error", message: `Fetch error: ${e}`}) + } + + return false + } + + return ( + + + + Namespace Registry + + + Register Namespace + + + {alert?.message} + + + + + + + + + ) +} diff --git a/web_ui/frontend/app/registry/page.tsx b/web_ui/frontend/app/registry/page.tsx index d5efef370..e06ee65e3 100644 --- a/web_ui/frontend/app/registry/page.tsx +++ b/web_ui/frontend/app/registry/page.tsx @@ -18,29 +18,129 @@ "use client" -import RateGraph from "@/components/graphs/RateGraph"; -import StatusBox from "@/components/StatusBox"; +import {Box, Button, Grid, Typography, Skeleton, Alert, Collapse} from "@mui/material"; +import React, {useEffect, useMemo, useState} from "react"; -import {TimeDuration} from "@/components/graphs/prometheus"; - -import {Box, Grid, Typography} from "@mui/material"; -import FederationOverview from "@/components/FederationOverview"; -import {ServerTable} from "@/components/ServerTable"; -import NamespaceTable from "@/components/NamespaceTable"; +import {PendingCard, Card, NamespaceCardSkeleton, CreateNamespaceCard} from "@/components/Namespace"; +import Link from "next/link"; +import {Namespace, Alert as AlertType} from "@/components/Main"; +import UnauthenticatedContent from "@/components/layout/UnauthenticatedContent"; +import {Authenticated, getAuthenticated, isLoggedIn} from "@/helpers/login"; export default function Home() { + const [data, setData] = useState(undefined); + const [alert, setAlert] = useState(undefined) + const [authenticated, setAuthenticated] = useState(undefined) + + const getData = async () => { + + let data : Namespace[] = [] + + const url = new URL("/api/v1.0/registry_ui/namespaces", window.location.origin) + + const response = await fetch(url) + if (response.ok) { + const responseData: Namespace[] = await response.json() + responseData.sort((a, b) => a.id > b.id ? 1 : -1) + responseData.forEach((namespace) => { + if (namespace.prefix.startsWith("/cache")) { + namespace.type = "cache" + } else { + namespace.type = "origin" + } + }) + data = responseData + } + + return data + } + + const _setData = async () => {setData(await getData())} + + useEffect(() => { + _setData(); + (async () => { + if(await isLoggedIn()){ + setAuthenticated(getAuthenticated() as Authenticated) + } + })(); + }, []) + + const pendingData = useMemo( + () => data?.filter( + (namespace) => namespace.admin_metadata.status === "Pending" && + (authenticated?.user == namespace.admin_metadata.user_id || authenticated?.role == "admin") + ), [data, authenticated] + ) + const approvedCacheData = useMemo( + () => data?.filter( + (namespace) => namespace.admin_metadata.status === "Approved" && namespace.type == "cache" + ), + [data] + ) + const approvedOriginData = useMemo( + () => data?.filter( + (namespace) => namespace.admin_metadata.status === "Approved" && namespace.type == "origin" + ), + [data] + ) + return ( - - Origins - + + Namespace Registry + + + {alert?.message} + + + + + + + + + Login to register new namespaces. + + + + { + pendingData && pendingData.length > 0 && + + Pending Registrations + + {authenticated !== undefined && authenticated?.role == "admin" && "Awaiting approval from you."} + {authenticated !== undefined && authenticated?.role != "admin" && "Awaiting approval from registry administrators."} + + + {pendingData.map((namespace) => setAlert(a)} onUpdate={_setData}/>)} + + } + + Public Namespaces + + + {authenticated !== undefined && authenticated?.role == "admin" && + "As an administrator, you can edit Public Namespaces by click the pencil button" + } + {authenticated !== undefined && authenticated?.role != "admin" && + "Public Namespaces are approved by the registry administrators. To edit a Namespace you own please contact the registry administrators." + } + + + Origins + { approvedOriginData !== undefined ? approvedOriginData.map((namespace) => ) : } + { approvedOriginData !== undefined && approvedOriginData.length === 0 && } + + Caches + { approvedCacheData !== undefined ? approvedCacheData.map((namespace) => ) : } + { approvedCacheData !== undefined && approvedCacheData.length === 0 && } + - - Caches - + diff --git a/web_ui/frontend/components/Cell.tsx b/web_ui/frontend/components/Cell.tsx index 8840ec131..0bfa0c1a5 100644 --- a/web_ui/frontend/components/Cell.tsx +++ b/web_ui/frontend/components/Cell.tsx @@ -22,7 +22,7 @@ export const TableCellOverflow: FunctionComponent = ({ children, ...props } border: "solid #ececec 1px", ...props?.sx }}> - {children} + {String(children)} ) } diff --git a/web_ui/frontend/components/Main.d.ts b/web_ui/frontend/components/Main.d.ts new file mode 100644 index 000000000..cfe0ce320 --- /dev/null +++ b/web_ui/frontend/components/Main.d.ts @@ -0,0 +1,14 @@ +import {NamespaceAdminMetadata} from "@/components/Namespace"; + +interface Alert { + severity: "error" | "warning" | "info" | "success"; + message: string; +} + +export interface Namespace { + id: number; + prefix: string; + pubkey: string; + type: "origin" | "cache"; + admin_metadata: NamespaceAdminMetadata; +} diff --git a/web_ui/frontend/components/Namespace.tsx b/web_ui/frontend/components/Namespace.tsx new file mode 100644 index 000000000..27311cf62 --- /dev/null +++ b/web_ui/frontend/components/Namespace.tsx @@ -0,0 +1,302 @@ +import {Box, Typography, Collapse, Grid, IconButton, Button, Tooltip, Skeleton, BoxProps, Avatar} from "@mui/material"; +import {Edit, Block, Check, Download, Add, Person} from "@mui/icons-material"; +import React, {useEffect, useRef, useState} from "react"; +import Link from "next/link"; + +import {Namespace, Alert} from "@/components/Main"; +import {getAuthenticated, secureFetch, Authenticated} from "@/helpers/login"; + +export interface NamespaceAdminMetadata { + user_id: string; + description: string; + site_name: string; + institution: string; + security_contact_user_id: string; + status: "Pending" | "Approved" | "Denied" | "Unknown"; + approver_id: number; + approved_at: string; + created_at: string; + updated_at: string; +} + +interface InformationDropdownProps { + adminMetadata: NamespaceAdminMetadata; + transition: boolean; + parentRef?: React.RefObject; +} + +export const getServerType = (namespace: Namespace) => { + + // If the namespace is empty the value is undefined + if (namespace?.prefix == null || namespace.prefix == ""){ + return "" + } + + // If the namespace prefix starts with /cache, it is a cache server + if (namespace.prefix.startsWith("/cache")) { + return "cache" + } + + // Otherwise it is an origin server + return "origin" + +} + +const InformationSpan = ({name, value}: {name: string, value: string}) => { + return ( + + {name}: + {value} + + ) +} + +const InformationDropdown = ({adminMetadata, transition, parentRef} : InformationDropdownProps) => { + + const information = [ + {name: "User ID", value: adminMetadata.user_id}, + {name: "Description", value: adminMetadata.description}, + {name: "Site Name", value: adminMetadata.site_name}, + {name: "Institution", value: adminMetadata.institution}, + {name: "Security Contact User ID", value: adminMetadata.security_contact_user_id}, + {name: "Status", value: adminMetadata.status}, + {name: "Approver ID", value: adminMetadata.approver_id.toString()}, + {name: "Approved At", value: adminMetadata.approved_at}, + {name: "Created At", value: adminMetadata.created_at}, + {name: "Updated At", value: adminMetadata.updated_at} + ] + + return ( + + + + + + {information.map((info) => )} + + + + + + ) +} + +export const CreateNamespaceCard = ({text}: {text: string}) => { + return ( + + + + {text ? text : "Register Namespace"} + + + + + e.stopPropagation()}> + + + + + + + + ) +} + +export const Card = ({ + namespace, + authenticated +} : {namespace: Namespace, authenticated?: Authenticated}) => { + const ref = useRef(null); + const [transition, setTransition] = useState(false); + + return ( + + setTransition(!transition)} + > + + {namespace.prefix} + { authenticated !== undefined && authenticated.user == namespace.admin_metadata.user_id && + + + + + + } + + + + + e.stopPropagation()} sx={{mx: 1}}> + + + + + { + authenticated?.role == "admin" && + + + e.stopPropagation()}> + + + + + } + + + + + + + ) +} + +export const NamespaceCardSkeleton = () => { + return +} + +interface PendingCardProps { + namespace: Namespace; + onUpdate: () => void; + onAlert: (alert: Alert) => void; + authenticated?: Authenticated +} + +export const PendingCard = ({ + namespace, + onUpdate, + onAlert, + authenticated +}: PendingCardProps) => { + + const ref = useRef(null); + const [transition, setTransition] = useState(false); + + const approveNamespace = async (e: React.MouseEvent) => { + try { + const response = await secureFetch(`/api/v1.0/registry_ui/namespaces/${namespace.id}/approve`, { + method: "PATCH" + }) + + if (!response.ok){ + onAlert({severity: "error", message: `Failed to approve namespace: ${namespace.prefix}`}) + } else { + onUpdate() + onAlert({severity: "success", message: `Successfully approved namespace: ${namespace.prefix}`}) + } + + } catch (error) { + console.error(error) + } finally { + e.stopPropagation() + } + } + + const denyNamespace = async (e: React.MouseEvent) => { + try { + const response = await secureFetch(`/api/v1.0/registry_ui/namespaces/${namespace.id}/deny`, { + method: "PATCH" + }) + + if (!response.ok){ + onAlert({severity: "error", message: `Failed to deny namespace: ${namespace.prefix}`}) + } else { + onUpdate() + onAlert({severity: "success", message: `Successfully denied namespace: ${namespace.prefix}`}) + } + + } catch (error) { + console.error(error) + } finally { + e.stopPropagation() + } + } + + return ( + + setTransition(!transition)} + > + + {namespace.prefix} + + + { authenticated?.role == "admin" && + <> + + denyNamespace(e)}> + + + approveNamespace(e)}> + + + } + { + (authenticated?.role == "admin" || authenticated?.user == namespace.admin_metadata.user_id) && + + + e.stopPropagation()}> + + + + + } + + + + + + + ) +} \ No newline at end of file diff --git a/web_ui/frontend/components/NamespaceTable.tsx b/web_ui/frontend/components/NamespaceTable.tsx index f72bcb46f..f886df032 100644 --- a/web_ui/frontend/components/NamespaceTable.tsx +++ b/web_ui/frontend/components/NamespaceTable.tsx @@ -6,17 +6,8 @@ import React, { } from "react"; import {Skeleton} from "@mui/material"; -import DataTable, {Record} from "@/components/DataTable"; -import {TableCellOverflow, TableCellButton} from "@/components/Cell"; - - -interface Namespace extends Record { - id: number - prefix: string - pubKey: string - identity: string - adminMetadata: string -} +import {Card} from "@/components/Namespace"; +import {Namespace} from "@/components/Main"; interface ServerTableProps { @@ -28,25 +19,6 @@ const NamespaceTable = ({type} : ServerTableProps) => { const [data, setData] = useState(undefined); const [error, setError] = useState(undefined); - const keyToName = { - "prefix": { - name: "Prefix", - cellNode: TableCellOverflow - }, - "identity": { - name: "Identity", - cellNode: TableCellOverflow - }, - "admin_metadata": { - name: "Admin Metadata", - cellNode: TableCellOverflow - }, - "id": { - name: "JWK Download", - cellNode: ({children} : {children: number}) => Download - } - } - const getData = useCallback(async () => { const url = new URL("/api/v1.0/registry_ui/namespaces", window.location.origin) if (type){ @@ -77,9 +49,9 @@ const NamespaceTable = ({type} : ServerTableProps) => { } return ( - <> - {data ? : } - + + {data ? data.map((namespace) => ) : } + ) } diff --git a/web_ui/frontend/components/layout/AuthenticatedContent.tsx b/web_ui/frontend/components/layout/AuthenticatedContent.tsx new file mode 100644 index 000000000..ef5c02fe0 --- /dev/null +++ b/web_ui/frontend/components/layout/AuthenticatedContent.tsx @@ -0,0 +1,31 @@ +import {Box, BoxProps, Skeleton} from "@mui/material"; +import {useEffect, useState} from "react"; +import {isLoggedIn} from "@/helpers/login"; + +const AuthenticatedContent = ({...props} : BoxProps) => { + + const [authenticated, setAuthenticated] = useState(undefined) + + useEffect(() => { + (async () => { + const loggedIn = await isLoggedIn() + setAuthenticated(loggedIn) + if(!loggedIn){ + window.location.replace("/view/login/index.html") + } + })() + }, []); + + if(authenticated === false){ + return null + } + + return ( + + {authenticated === undefined && } + {authenticated && props.children} + + ) +} + +export default AuthenticatedContent \ No newline at end of file diff --git a/web_ui/frontend/components/layout/UnauthenticatedContent.tsx b/web_ui/frontend/components/layout/UnauthenticatedContent.tsx new file mode 100644 index 000000000..0e8921db9 --- /dev/null +++ b/web_ui/frontend/components/layout/UnauthenticatedContent.tsx @@ -0,0 +1,30 @@ +import {Box, BoxProps, Skeleton, Alert} from "@mui/material"; +import {useEffect, useState} from "react"; +import {isLoggedIn} from "@/helpers/login"; + +const UnauthenticatedContent = ({...props} : BoxProps) => { + + const [authenticated, setAuthenticated] = useState(undefined) + + useEffect(() => { + (async () => { + const loggedIn = await isLoggedIn() + setAuthenticated(loggedIn) + })() + }, []); + + if(authenticated === true){ + return null + } + + return ( + + {authenticated === undefined && } + {authenticated === false && + {props.children} + } + + ) +} + +export default UnauthenticatedContent \ No newline at end of file diff --git a/web_ui/frontend/dev/data/pubkey.json b/web_ui/frontend/dev/data/pubkey.json new file mode 100644 index 000000000..854032223 --- /dev/null +++ b/web_ui/frontend/dev/data/pubkey.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "alg": "ES256", + "crv": "P-256", + "kid": "", + "kty": "EC", + "x": "", + "y": "" + } + ] +} \ No newline at end of file diff --git a/web_ui/frontend/helpers/login.tsx b/web_ui/frontend/helpers/login.tsx index c122fa742..63c48ed5a 100644 --- a/web_ui/frontend/helpers/login.tsx +++ b/web_ui/frontend/helpers/login.tsx @@ -1,8 +1,81 @@ -export async function isLoggedIn() { - let response = await fetch("/api/v1.0/auth/whoami") - if(!response.ok){ + +export interface Authenticated { + authenticated: boolean + csrf_token: string + role: string + time: number + user: string +} + +export async function secureFetch(url: string | URL, options: RequestInit = {}) { + if(await isLoggedIn()) { + + // If they are logged in, this key must exist + const authenticated = getJsonFromSessionStorage("authenticated") as Authenticated + + return await fetch(url, { + ...options, + headers: { + ...options.headers, + "X-CSRF-Token": authenticated.csrf_token + } + }) + } + + throw new Error("You must be logged in to make this request") +} + +export function getJsonFromSessionStorage(key: string) : O | null { + if(sessionStorage.getItem(key) !== null) { + return JSON.parse(sessionStorage.getItem(key) as string) + } + return null +} + +export function getAuthenticated() : Authenticated | null { + return getJsonFromSessionStorage("authenticated") +} + +// Allow them to see a page if logged in +export async function isLoggedIn() : Promise { + + // If the session is valid then read it + const authenticated = getJsonFromSessionStorage("authenticated") + if(authenticated != null){ + if(authenticated.time + 10000 > Date.now()){ + return authenticated.authenticated + } + } + + // Check if the user is authenticated + try { + + let response = await fetch("/api/v1.0/auth/whoami") + if(!response.ok){ + return false + } + + const json = await response.json() + const authenticated = json['authenticated'] + + // If authenticated, store status and csrf token + if(authenticated){ + sessionStorage.setItem( + "authenticated", + JSON.stringify({ + time: Date.now(), + authenticated: true, + user: json['user'], + role: json['role'], + csrf_token: response.headers.get('X-CSRF-Token') + }) + ) + return true + } + + return false + + } catch (error) { return false } - let json = await response.json() - return json['authenticated'] } diff --git a/web_ui/frontend/tsconfig.json b/web_ui/frontend/tsconfig.json index 23ba4fd54..c443fefcc 100644 --- a/web_ui/frontend/tsconfig.json +++ b/web_ui/frontend/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es6", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, diff --git a/web_ui/ui.go b/web_ui/ui.go index 45b8d2959..fa22fccd6 100644 --- a/web_ui/ui.go +++ b/web_ui/ui.go @@ -91,43 +91,47 @@ func configureWebResource(engine *gin.Engine) error { db := authDB.Load() user, err := GetUser(ctx) - // Redirect initialized users from initialization pages - if strings.HasPrefix(path, "/initialization") && strings.HasSuffix(path, "index.html") { + // If requesting servers other than the registry + if !strings.HasPrefix(path, "/registry") { - // If the user has been initialized previously - if db != nil { - ctx.Redirect(http.StatusFound, "/view/") - return + // Redirect initialized users from initialization pages + if strings.HasPrefix(path, "/initialization") && strings.HasSuffix(path, "index.html") { + + // If the user has been initialized previously + if db != nil { + ctx.Redirect(http.StatusFound, "/view/") + return + } } - } - // Redirect authenticated users from login pages - if strings.HasPrefix(path, "/login") && strings.HasSuffix(path, "index.html") { + // Redirect authenticated users from login pages + if strings.HasPrefix(path, "/login") && strings.HasSuffix(path, "index.html") { - // If the user has been authenticated previously - if err == nil && user != "" { - ctx.Redirect(http.StatusFound, "/view/") - return + // If the user has been authenticated previously + if err == nil && user != "" { + ctx.Redirect(http.StatusFound, "/view/") + return + } } - } - // Direct uninitialized users to initialization pages - if !strings.HasPrefix(path, "/initialization") && strings.HasSuffix(path, "index.html") { + // Direct uninitialized users to initialization pages + if !strings.HasPrefix(path, "/initialization") && strings.HasSuffix(path, "index.html") { - // If the user has not been initialized previously - if db == nil { - ctx.Redirect(http.StatusFound, "/view/initialization/code/") - return + // If the user has not been initialized previously + if db == nil { + ctx.Redirect(http.StatusFound, "/view/initialization/code/") + return + } } - } - // Direct unauthenticated initialized users to login pages - if !strings.HasPrefix(path, "/login") && strings.HasSuffix(path, "index.html") { + // Direct unauthenticated initialized users to login pages + if !strings.HasPrefix(path, "/login") && strings.HasSuffix(path, "index.html") { - // If the user is not authenticated but initialized - if (err != nil || user == "") && db != nil { - ctx.Redirect(http.StatusFound, "/view/login/") - return + // If the user is not authenticated but initialized + if (err != nil || user == "") && db != nil { + ctx.Redirect(http.StatusFound, "/view/login/") + return + } } }