You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by ya...@apache.org on 2026/09/14 07:55:22 UTC
(airavata-custos) branch user-onboard updated: User onboard portal UI/UX (#570)
This is an automated email from the ASF dual-hosted git repository.
yasithdev pushed a commit to branch user-onboard
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
The following commit(s) were added to refs/heads/user-onboard by this push:
new 9f970cbc7 User onboard portal UI/UX (#570)
9f970cbc7 is described below
commit 9f970cbc7aa0ede7f51bc3fcfba24e467dc58147
Author: Lahiru Jayathilake <la...@gmail.com>
AuthorDate: Mon Sep 14 03:55:17 2026 -0400
User onboard portal UI/UX (#570)
* Implemented the user creation dialog in the `Users and Permissions`
* Enable the cluster admin option when adding a user
* Allow adding cluster admin role without portal admin selection
---
.../admin/users/management/AddUserDialog.tsx | 321 +++++++++++++++++++++
.../(portal)/admin/users/management/UsersTable.tsx | 29 ++
.../admin/users/management/UsersTableContainer.tsx | 71 ++++-
.../management/__tests__/AddUserDialog.test.tsx | 167 +++++++++++
web/src/features/core/users/api.ts | 9 +
web/src/features/core/users/queries.ts | 11 +-
web/src/features/core/users/schemas.ts | 5 +-
web/src/generated/core/index.ts | 2 +-
web/src/generated/core/sdk.gen.ts | 2 +
web/src/generated/core/types.gen.ts | 37 ++-
web/src/generated/core/zod.gen.ts | 34 ++-
web/src/mocks/handlers/users.ts | 20 ++
web/src/shared/ui/StatusBadge.tsx | 2 +-
13 files changed, 688 insertions(+), 22 deletions(-)
diff --git a/web/src/app/(portal)/admin/users/management/AddUserDialog.tsx b/web/src/app/(portal)/admin/users/management/AddUserDialog.tsx
new file mode 100644
index 000000000..4a2a4c02d
--- /dev/null
+++ b/web/src/app/(portal)/admin/users/management/AddUserDialog.tsx
@@ -0,0 +1,321 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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 * as React from "react";
+import type { ComputeAllocation } from "@/features/core/allocations/schemas";
+import type { CreateUserPayload } from "@/features/core/users/schemas";
+import { cn } from "@/lib/utils";
+import { Button } from "@/shared/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/ui/dialog";
+import { Input } from "@/shared/ui/input";
+import { Label } from "@/shared/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
+import { ShieldCheck } from "lucide-react";
+
+const NO_ALLOCATION = "none";
+
+export type AddUserDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSubmit: (payload: CreateUserPayload) => void;
+ isPending: boolean;
+ error?: string | null;
+ canCreateAdmins: boolean;
+ allocations: ComputeAllocation[];
+};
+
+export function AddUserDialog({
+ open,
+ onOpenChange,
+ onSubmit,
+ isPending,
+ error,
+ canCreateAdmins,
+ allocations,
+}: AddUserDialogProps) {
+ const [userType, setUserType] = React.useState<"researcher" | "admin">("researcher");
+ const [email, setEmail] = React.useState("");
+ const [firstName, setFirstName] = React.useState("");
+ const [lastName, setLastName] = React.useState("");
+ const [username, setUsername] = React.useState("");
+ const [allocationId, setAllocationId] = React.useState(NO_ALLOCATION);
+ const [portalAdmin, setPortalAdmin] = React.useState(true);
+ const [clusterAdmin, setClusterAdmin] = React.useState(false);
+
+ React.useEffect(() => {
+ if (!open) {
+ setUserType("researcher");
+ setEmail("");
+ setFirstName("");
+ setLastName("");
+ setUsername("");
+ setAllocationId(NO_ALLOCATION);
+ setPortalAdmin(true);
+ setClusterAdmin(false);
+ }
+ }, [open]);
+
+ const isAdmin = userType === "admin";
+ const noAdminAccess = isAdmin && !portalAdmin && !clusterAdmin;
+ const adminNotice = noAdminAccess
+ ? { title: "Pick at least one kind of admin access", detail: "Otherwise there is nothing to grant." }
+ : portalAdmin && clusterAdmin
+ ? {
+ title: "This user will be a portal and cluster admin",
+ detail: "Admin access to the portal, and sudo on the cluster.",
+ }
+ : clusterAdmin
+ ? {
+ title: "This user will be a cluster admin",
+ detail: "Sudo on the cluster, with no admin access in the portal.",
+ }
+ : { title: "This user will be a portal admin", detail: "Admin access to the portal." };
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (isPending || noAdminAccess) return;
+ const payload: CreateUserPayload = {
+ email: email.trim(),
+ first_name: firstName.trim(),
+ last_name: lastName.trim(),
+ };
+ if (isAdmin) {
+ if (portalAdmin) payload.portal_admin = true;
+ if (clusterAdmin) payload.cluster_admin = true;
+ } else {
+ if (username.trim()) payload.username = username.trim();
+ if (allocationId !== NO_ALLOCATION) {
+ payload.allocation_id = allocationId;
+ }
+ }
+ onSubmit(payload);
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-lg">
+ <DialogHeader>
+ <DialogTitle>Add user</DialogTitle>
+ </DialogHeader>
+ <form className="space-y-4" onSubmit={handleSubmit}>
+ <div className="space-y-2">
+ <Label>User type</Label>
+ <div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label="User type">
+ <TypeOption
+ name="Researcher"
+ description="Runs jobs under an allocation"
+ selected={userType === "researcher"}
+ onSelect={() => setUserType("researcher")}
+ />
+ <TypeOption
+ name="Admin"
+ description="Manages the portal, the clusters, or both"
+ selected={userType === "admin"}
+ onSelect={() => setUserType("admin")}
+ disabled={!canCreateAdmins}
+ disabledReason="Only super admins can create admins"
+ />
+ </div>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="add-user-email">Email</Label>
+ <Input
+ id="add-user-email"
+ type="email"
+ value={email}
+ onChange={(e) => setEmail(e.target.value)}
+ placeholder="user@university.edu"
+ autoComplete="off"
+ required
+ />
+ </div>
+ <div className="grid grid-cols-2 gap-3">
+ <div className="space-y-2">
+ <Label htmlFor="add-user-first">First name</Label>
+ <Input
+ id="add-user-first"
+ value={firstName}
+ onChange={(e) => setFirstName(e.target.value)}
+ autoComplete="off"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="add-user-last">Last name</Label>
+ <Input
+ id="add-user-last"
+ value={lastName}
+ onChange={(e) => setLastName(e.target.value)}
+ autoComplete="off"
+ />
+ </div>
+ </div>
+ {!isAdmin ? (
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <Label htmlFor="add-user-username">Username</Label>
+ <span className="text-xs text-muted-foreground">Optional</span>
+ </div>
+ <Input
+ id="add-user-username"
+ value={username}
+ onChange={(e) => setUsername(e.target.value)}
+ placeholder="Leave empty to auto-generate"
+ autoComplete="off"
+ spellCheck={false}
+ />
+ <p className="text-xs text-muted-foreground">
+ Generated from the name when left empty, the same way ingested accounts get
+ theirs.
+ </p>
+ </div>
+ ) : null}
+ {!isAdmin ? (
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <Label>Allocation</Label>
+ <span className="text-xs text-muted-foreground">Optional</span>
+ </div>
+ <Select
+ value={allocationId}
+ onValueChange={(value) => setAllocationId(value ?? NO_ALLOCATION)}
+ >
+ <SelectTrigger aria-label="Allocation" className="w-full">
+ <SelectValue>
+ {(value: string) =>
+ value === NO_ALLOCATION
+ ? "No allocation, assign later"
+ : (allocations.find((a) => a.id === value)?.name ?? value)
+ }
+ </SelectValue>
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value={NO_ALLOCATION}>No allocation, assign later</SelectItem>
+ {allocations.map((allocation) => (
+ <SelectItem key={allocation.id} value={allocation.id}>
+ {allocation.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <p className="text-xs text-muted-foreground">
+ Can be assigned later, but needed before they can run jobs.
+ </p>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <Label>Admin access</Label>
+ </div>
+ <div className="rounded-md border">
+ <label className="flex cursor-pointer items-start gap-2.5 p-3">
+ <input
+ type="checkbox"
+ checked={portalAdmin}
+ onChange={(e) => setPortalAdmin(e.target.checked)}
+ className="mt-0.5 size-4 rounded border-input accent-[color:var(--brand)]"
+ />
+ <span>
+ <span className="block text-sm font-medium">Portal admin</span>
+ <span className="block text-xs text-muted-foreground">
+ Manage users, allocations, and settings in this portal.
+ </span>
+ </span>
+ </label>
+ <label className="flex cursor-pointer items-start gap-2.5 border-t p-3">
+ <input
+ type="checkbox"
+ checked={clusterAdmin}
+ onChange={(e) => setClusterAdmin(e.target.checked)}
+ className="mt-0.5 size-4 rounded border-input accent-[color:var(--brand)]"
+ />
+ <span>
+ <span className="block text-sm font-medium">Cluster admin</span>
+ <span className="block text-xs text-muted-foreground">
+ Admin access on the cluster itself, with a login account.
+ </span>
+ </span>
+ </label>
+ </div>
+ <div className="flex items-start gap-2.5 rounded-md border border-[color:var(--tone-warn-fg)]/30 bg-[color:var(--tone-warn-bg)] p-3 text-xs">
+ <ShieldCheck className="mt-0.5 size-4 shrink-0 text-[color:var(--tone-warn-fg)]" />
+ <span>
+ <span className="block text-sm font-medium">{adminNotice.title}</span>
+ <span className="text-muted-foreground">{adminNotice.detail}</span>
+ </span>
+ </div>
+ </div>
+ )}
+ {error ? <p className="text-sm text-[color:var(--custos-red-700)]">{error}</p> : null}
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+ Cancel
+ </Button>
+ <Button type="submit" variant="brand" disabled={isPending || noAdminAccess}>
+ {isPending ? "Adding…" : isAdmin ? "Add admin user" : "Add user"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+function TypeOption({
+ name,
+ description,
+ selected,
+ onSelect,
+ disabled,
+ disabledReason,
+}: {
+ name: string;
+ description: string;
+ selected: boolean;
+ onSelect: () => void;
+ disabled?: boolean;
+ disabledReason?: string;
+}) {
+ return (
+ <label
+ title={disabled ? disabledReason : undefined}
+ className={cn(
+ "rounded-md border p-3 transition-colors",
+ selected ? "border-[color:var(--brand)] bg-[color:var(--brand-tint)]/40" : "hover:bg-muted",
+ disabled ? "cursor-not-allowed opacity-50 hover:bg-transparent" : "cursor-pointer",
+ )}
+ >
+ <input
+ type="radio"
+ name="add-user-type"
+ checked={selected}
+ onChange={onSelect}
+ disabled={disabled}
+ className="sr-only"
+ />
+ <span className="block text-sm font-medium">{name}</span>
+ <span className="block text-xs text-muted-foreground">{description}</span>
+ </label>
+ );
+}
diff --git a/web/src/app/(portal)/admin/users/management/UsersTable.tsx b/web/src/app/(portal)/admin/users/management/UsersTable.tsx
index 0eafd4365..ae76013d3 100644
--- a/web/src/app/(portal)/admin/users/management/UsersTable.tsx
+++ b/web/src/app/(portal)/admin/users/management/UsersTable.tsx
@@ -25,6 +25,7 @@ import {
} from "@/shared/hooks/useShallowSearchParams";
import { DataTable, type DataTableColumn } from "@/shared/ui/DataTable";
import { Input } from "@/shared/ui/input";
+import { StatusBadge } from "@/shared/ui/StatusBadge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import { ChevronRight } from "lucide-react";
import * as React from "react";
@@ -38,6 +39,25 @@ function fullNameFor(user: UserManagementRow): string {
return name || user.email;
}
+// PENDING means the account exists but nobody has signed in as it yet. The
+// status flips on first sign-in, not when cluster provisioning finishes.
+function UserStatusBadge({ status }: { status: string | undefined }) {
+ switch (status) {
+ case "ACTIVE":
+ return <StatusBadge variant="active" />;
+ case "PENDING":
+ return <StatusBadge variant="pending" label="Awaiting sign-in" />;
+ case "SUSPENDED":
+ return <StatusBadge variant="warning" label="Suspended" />;
+ case "INACTIVE":
+ return <StatusBadge variant="inactive" />;
+ case "MERGED":
+ return <StatusBadge variant="inactive" label="Merged" />;
+ default:
+ return <span className="text-muted-foreground">—</span>;
+ }
+}
+
function useExpandableRow() {
const [expandedId, setExpandedId] = React.useState<string | null>(null);
return {
@@ -59,6 +79,7 @@ export function UsersTable({
pageSize,
total,
onPageChange,
+ toolbarAction,
}: {
users: UserManagementRow[];
rolesCatalog: Role[];
@@ -69,6 +90,7 @@ export function UsersTable({
pageSize: number;
total: number;
onPageChange: (page: number) => void;
+ toolbarAction?: React.ReactNode;
}) {
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const expandedRow = useExpandableRow();
@@ -177,6 +199,12 @@ export function UsersTable({
}
columns.push(
+ {
+ key: "status",
+ header: "Status",
+ width: "180px",
+ cell: (row) => <UserStatusBadge status={row.status} />,
+ },
{
key: "identities",
header: "External Identities",
@@ -242,6 +270,7 @@ export function UsersTable({
))}
</SelectContent>
</Select>
+ {toolbarAction}
</div>
{filtersActive ? (
diff --git a/web/src/app/(portal)/admin/users/management/UsersTableContainer.tsx b/web/src/app/(portal)/admin/users/management/UsersTableContainer.tsx
index 16bc4ebf7..8776224e2 100644
--- a/web/src/app/(portal)/admin/users/management/UsersTableContainer.tsx
+++ b/web/src/app/(portal)/admin/users/management/UsersTableContainer.tsx
@@ -18,11 +18,16 @@
"use client";
import * as React from "react";
+import { toast } from "sonner";
+import { useAllocations } from "@/features/core/allocations/queries";
import { useCurrentUser } from "@/features/core/identity/queries";
-import { useRolesCatalog, useUserPageDetails, useUsers } from "@/features/core/users/queries";
+import { useCreateUser, useRolesCatalog, useUserPageDetails, useUsers } from "@/features/core/users/queries";
+import type { CreateUserPayload } from "@/features/core/users/schemas";
import { useAbility } from "@/shared/casl/AbilityProvider";
+import { Button } from "@/shared/ui/button";
import { ErrorState } from "@/shared/ui/ErrorState";
import { TableSkeleton } from "@/shared/ui/Loading";
+import { AddUserDialog } from "./AddUserDialog";
import { UsersTable } from "./UsersTable";
const PAGE_SIZE = 25;
@@ -32,8 +37,24 @@ export function UsersTableContainer() {
const canReadUsers = ability.can("read", "User");
const canManageRoles = ability.can("manage", "Role");
const canReadDirectPrivileges = ability.can("manage", "PrivilegeGrant");
+ const canCreateUsers = ability.can("manage", "User");
const { user: currentUser } = useCurrentUser();
const [page, setPage] = React.useState(1);
+ const [addDialogOpen, setAddDialogOpen] = React.useState(false);
+ const [addError, setAddError] = React.useState<string | null>(null);
+ const createMutation = useCreateUser();
+ const allocationsQuery = useAllocations({ status: "ACTIVE" });
+
+ async function handleAddUser(payload: CreateUserPayload) {
+ setAddError(null);
+ try {
+ await createMutation.mutateAsync(payload);
+ toast.success(payload.portal_admin ? "Admin user added" : "User added");
+ setAddDialogOpen(false);
+ } catch (err) {
+ setAddError(err instanceof Error ? err.message : "Failed to add user");
+ }
+ }
const usersQuery = useUsers(
{ limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE },
{ enabled: canReadUsers },
@@ -68,16 +89,42 @@ export function UsersTableContainer() {
}
return (
- <UsersTable
- users={hydratedRows}
- rolesCatalog={rolesQuery.data ?? []}
- currentUserEmail={currentUser?.email}
- canManageRoles={canManageRoles}
- canReadDirectPrivileges={canReadDirectPrivileges}
- page={page}
- pageSize={PAGE_SIZE}
- total={usersQuery.data?.total ?? 0}
- onPageChange={setPage}
- />
+ <>
+ <UsersTable
+ users={hydratedRows}
+ rolesCatalog={rolesQuery.data ?? []}
+ currentUserEmail={currentUser?.email}
+ canManageRoles={canManageRoles}
+ canReadDirectPrivileges={canReadDirectPrivileges}
+ page={page}
+ pageSize={PAGE_SIZE}
+ total={usersQuery.data?.total ?? 0}
+ onPageChange={setPage}
+ toolbarAction={
+ canCreateUsers ? (
+ <Button
+ className="sm:ml-auto"
+ onClick={() => {
+ setAddError(null);
+ setAddDialogOpen(true);
+ }}
+ >
+ + Add user
+ </Button>
+ ) : null
+ }
+ />
+ {canCreateUsers ? (
+ <AddUserDialog
+ open={addDialogOpen}
+ onOpenChange={setAddDialogOpen}
+ onSubmit={handleAddUser}
+ isPending={createMutation.isPending}
+ error={addError}
+ canCreateAdmins={canManageRoles}
+ allocations={allocationsQuery.data?.items ?? []}
+ />
+ ) : null}
+ </>
);
}
diff --git a/web/src/app/(portal)/admin/users/management/__tests__/AddUserDialog.test.tsx b/web/src/app/(portal)/admin/users/management/__tests__/AddUserDialog.test.tsx
new file mode 100644
index 000000000..49d1d1728
--- /dev/null
+++ b/web/src/app/(portal)/admin/users/management/__tests__/AddUserDialog.test.tsx
@@ -0,0 +1,167 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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.
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { AddUserDialog } from "../AddUserDialog";
+
+function renderDialog(overrides: Partial<Parameters<typeof AddUserDialog>[0]> = {}) {
+ const onSubmit = vi.fn();
+ render(
+ <AddUserDialog
+ open
+ onOpenChange={() => {}}
+ onSubmit={onSubmit}
+ isPending={false}
+ canCreateAdmins
+ allocations={[
+ {
+ id: "alloc-1",
+ project_id: "proj-1",
+ name: "CHM-240013",
+ status: "ACTIVE",
+ compute_cluster_id: "cluster-1",
+ initial_su_amount: 1000,
+ start_time: "2026-01-01T00:00:00Z",
+ end_time: "2027-01-01T00:00:00Z",
+ },
+ ]}
+ {...overrides}
+ />,
+ );
+ return { onSubmit };
+}
+
+describe("AddUserDialog", () => {
+ it("submits a researcher without a username so the backend generates one", () => {
+ const { onSubmit } = renderDialog();
+ fireEvent.change(screen.getByLabelText("Email"), {
+ target: { value: "jkellett@ncsa.illinois.edu" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Add user" }));
+ expect(onSubmit).toHaveBeenCalledWith({
+ email: "jkellett@ncsa.illinois.edu",
+ first_name: "",
+ last_name: "",
+ });
+ });
+
+ it("sends a typed username as is", () => {
+ const { onSubmit } = renderDialog();
+ fireEvent.change(screen.getByLabelText("Email"), {
+ target: { value: "jkellett@ncsa.illinois.edu" },
+ });
+ fireEvent.change(screen.getByLabelText("Username"), { target: { value: "jkellett" } });
+ fireEvent.click(screen.getByRole("button", { name: "Add user" }));
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({ username: "jkellett" }),
+ );
+ });
+
+ it("submits a portal admin without a cluster account", () => {
+ const { onSubmit } = renderDialog();
+ fireEvent.click(screen.getByRole("radio", { name: /Admin/ }));
+ fireEvent.change(screen.getByLabelText("Email"), {
+ target: { value: "sandrade@sdsc.edu" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Add admin user" }));
+ expect(onSubmit).toHaveBeenCalledWith({
+ email: "sandrade@sdsc.edu",
+ first_name: "",
+ last_name: "",
+ portal_admin: true,
+ });
+ });
+
+ it("disables the admin type for non super admins", () => {
+ renderDialog({ canCreateAdmins: false });
+ expect(screen.getByRole("radio", { name: /Admin/ })).toBeDisabled();
+ });
+
+ it("sends cluster_admin when cluster admin is ticked", () => {
+ const { onSubmit } = renderDialog();
+ fireEvent.click(screen.getByRole("radio", { name: /Admin/ }));
+
+ const clusterAdmin = screen
+ .getByText(/Admin access on the cluster itself/)
+ .closest("label")
+ ?.querySelector("input");
+ expect(clusterAdmin).toBeEnabled();
+
+ fireEvent.change(screen.getByLabelText("Email"), {
+ target: { value: "sandrade@sdsc.edu" },
+ });
+ fireEvent.click(clusterAdmin as HTMLInputElement);
+ fireEvent.click(screen.getByRole("button", { name: "Add admin user" }));
+
+ expect(onSubmit).toHaveBeenCalledWith({
+ email: "sandrade@sdsc.edu",
+ first_name: "",
+ last_name: "",
+ portal_admin: true,
+ cluster_admin: true,
+ });
+ });
+
+ it("submits a cluster admin without portal admin", () => {
+ const { onSubmit } = renderDialog();
+ fireEvent.click(screen.getByRole("radio", { name: /Admin/ }));
+
+ const box = (text: RegExp) =>
+ screen.getByText(text).closest("label")?.querySelector("input") as HTMLInputElement;
+ fireEvent.click(box(/Admin access on the cluster itself/));
+ fireEvent.click(box(/Manage users, allocations, and settings/));
+
+ fireEvent.change(screen.getByLabelText("Email"), {
+ target: { value: "sandrade@sdsc.edu" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Add admin user" }));
+
+ expect(onSubmit).toHaveBeenCalledWith({
+ email: "sandrade@sdsc.edu",
+ first_name: "",
+ last_name: "",
+ cluster_admin: true,
+ });
+ });
+
+ it("blocks submit when neither kind of admin access is picked", () => {
+ const { onSubmit } = renderDialog();
+ fireEvent.click(screen.getByRole("radio", { name: /Admin/ }));
+ fireEvent.click(
+ screen
+ .getByText(/Manage users, allocations, and settings/)
+ .closest("label")
+ ?.querySelector("input") as HTMLInputElement,
+ );
+ expect(screen.getByRole("button", { name: "Add admin user" })).toBeDisabled();
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it("leaves cluster_admin off when the box is not ticked", () => {
+ const { onSubmit } = renderDialog();
+ fireEvent.click(screen.getByRole("radio", { name: /Admin/ }));
+ fireEvent.change(screen.getByLabelText("Email"), {
+ target: { value: "sandrade@sdsc.edu" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Add admin user" }));
+
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.not.objectContaining({ cluster_admin: expect.anything() }),
+ );
+ });
+});
diff --git a/web/src/features/core/users/api.ts b/web/src/features/core/users/api.ts
index 28d64d0ed..8a66c5245 100644
--- a/web/src/features/core/users/api.ts
+++ b/web/src/features/core/users/api.ts
@@ -17,6 +17,7 @@
import { apiFetch } from "@/shared/api/client";
import {
+ createUserPayloadSchema,
grantRoleResponseSchema,
roleDetailResponseSchema,
rolesResponseSchema,
@@ -24,10 +25,13 @@ import {
userListResponseSchema,
userPrivilegesResponseSchema,
userRolesResponseSchema,
+ userSchema,
} from "./schemas";
import type {
+ CreateUserPayload,
Role,
RoleDetail,
+ User,
UserIdentity,
UserListResponse,
UserPrivilege,
@@ -35,6 +39,11 @@ import type {
} from "./schemas";
import type { UserListParams } from "./types";
+export async function createUser(payload: CreateUserPayload): Promise<User> {
+ const body = createUserPayloadSchema.parse(payload);
+ return userSchema.parse(await apiFetch("/users", { method: "POST", body }));
+}
+
export async function listUsers(params: UserListParams = {}): Promise<UserListResponse> {
const search = new URLSearchParams();
if (typeof params.limit === "number") search.set("limit", String(params.limit));
diff --git a/web/src/features/core/users/queries.ts b/web/src/features/core/users/queries.ts
index 7b4022963..81b4ceac7 100644
--- a/web/src/features/core/users/queries.ts
+++ b/web/src/features/core/users/queries.ts
@@ -21,6 +21,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/rea
import { identityKeys } from "@/features/core/identity/queries";
import {
assignUserRole,
+ createUser,
getRoleDetail,
listDirectPrivileges,
listRolesCatalog,
@@ -29,7 +30,7 @@ import {
listUsers,
removeUserRole,
} from "./api";
-import type { Role, User, UserRole } from "./schemas";
+import type { CreateUserPayload, Role, User, UserRole } from "./schemas";
import type {
RoleWithPrivileges,
UpdateUserRolesInput,
@@ -118,6 +119,14 @@ export async function applyUserRoleChanges(
}
}
+export function useCreateUser() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (payload: CreateUserPayload) => createUser(payload),
+ onSuccess: () => client.invalidateQueries({ queryKey: userKeys.all }),
+ });
+}
+
export function useUsers(params: UserListParams = {}, options: { enabled?: boolean } = {}) {
return useQuery({
queryKey: userKeys.list(params),
diff --git a/web/src/features/core/users/schemas.ts b/web/src/features/core/users/schemas.ts
index 7fdc821ad..6e16253ca 100644
--- a/web/src/features/core/users/schemas.ts
+++ b/web/src/features/core/users/schemas.ts
@@ -16,7 +16,7 @@
// under the License.
import { z } from "zod";
-import { zRole, zUser, zUserIdentity } from "@/generated/core/zod.gen";
+import { zCreateUserRequest, zRole, zUser, zUserIdentity } from "@/generated/core/zod.gen";
import {
privilegeKeySchema,
userPrivilegeSchema as baseUserPrivilegeSchema,
@@ -70,6 +70,8 @@ export const roleDetailResponseSchema = z.object({
export const grantRoleResponseSchema = userRoleSchema;
+export const createUserPayloadSchema = zCreateUserRequest.required({ email: true });
+
export type User = z.infer<typeof userSchema>;
export type Role = z.infer<typeof roleSchema>;
export type UserIdentity = z.infer<typeof userIdentitySchema>;
@@ -77,3 +79,4 @@ export type UserRole = z.infer<typeof userRoleSchema>;
export type UserPrivilege = z.infer<typeof userPrivilegeSchema>;
export type UserListResponse = z.infer<typeof userListResponseSchema>;
export type RoleDetail = z.infer<typeof roleDetailResponseSchema>;
+export type CreateUserPayload = z.infer<typeof createUserPayloadSchema>;
diff --git a/web/src/generated/core/index.ts b/web/src/generated/core/index.ts
index 379e6c28b..609c7fbee 100644
--- a/web/src/generated/core/index.ts
+++ b/web/src/generated/core/index.ts
@@ -1,4 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts
export { deleteComputeAllocationChangeRequestEventsById, deleteComputeAllocationChangeRequestsById, deleteComputeAllocationDiffsById, deleteComputeAllocationMembershipResourceOverridesById, deleteComputeAllocationMembershipsById, deleteComputeAllocationsByIdResourcesByResourceId, deleteComputeAllocationUsagesById, deleteComputeClusterUsersById, deleteRolesById, deleteRolesByIdPrivilegesByKey, deleteUserIdentitiesById, deleteUsersByIdPrivilegesByKey, deleteUsersByIdRolesByRoleId, getAudit [...]
-export type { AllocationMembershipResponse, AllocationStatus, AllocationSuTotalResponse, AttachResourceRequest, CallerProfileResponse, CallerRoleGrant, ClientOptions, ComputeAllocation, ComputeAllocationChangeRequest, ComputeAllocationChangeRequestEvent, ComputeAllocationDiff, ComputeAllocationListResponse, ComputeAllocationMembership, ComputeAllocationMembershipResourceOverride, ComputeAllocationResource, ComputeAllocationResourceMapping, ComputeAllocationResourceRate, ComputeAllocation [...]
+export type { AllocationMembershipResponse, AllocationStatus, AllocationSuTotalResponse, AttachResourceRequest, CallerProfileResponse, CallerRoleGrant, ClientOptions, ClusterAccessLevel, ComputeAllocation, ComputeAllocationChangeRequest, ComputeAllocationChangeRequestEvent, ComputeAllocationDiff, ComputeAllocationListResponse, ComputeAllocationMembership, ComputeAllocationMembershipResourceOverride, ComputeAllocationResource, ComputeAllocationResourceMapping, ComputeAllocationResourceRat [...]
diff --git a/web/src/generated/core/sdk.gen.ts b/web/src/generated/core/sdk.gen.ts
index 149b028f8..b92ba3a9e 100644
--- a/web/src/generated/core/sdk.gen.ts
+++ b/web/src/generated/core/sdk.gen.ts
@@ -969,6 +969,8 @@ export const getUsers = <ThrowOnError extends boolean = false>(options?: Options
/**
* Create a user
+ *
+ * Creates a researcher (both admin flags false) or an admin.
*/
export const postUsers = <ThrowOnError extends boolean = false>(options: Options<PostUsersData, ThrowOnError>): RequestResult<PostUsersResponses, PostUsersErrors, ThrowOnError> => (options.client ?? client).post<PostUsersResponses, PostUsersErrors, ThrowOnError>({
security: [{ name: 'Authorization', type: 'apiKey' }],
diff --git a/web/src/generated/core/types.gen.ts b/web/src/generated/core/types.gen.ts
index 58c9f2310..ab9efd8b8 100644
--- a/web/src/generated/core/types.gen.ts
+++ b/web/src/generated/core/types.gen.ts
@@ -38,6 +38,8 @@ export type CallerRoleGrant = {
role?: Role;
};
+export type ClusterAccessLevel = 'USER' | 'ADMIN';
+
export type ComputeAllocation = {
/**
* The ID of the compute cluster where the allocation is provisioned.
@@ -277,12 +279,17 @@ export type ComputeCluster = {
};
export type ComputeClusterUser = {
+ access_level?: ClusterAccessLevel;
compute_cluster_id?: string;
id?: string;
/**
* The username of the user on the compute cluster, which may be different from their Airavata Custos username.
*/
local_username?: string;
+ /**
+ * When the account was provisioned into the registry; nil until then.
+ */
+ provisioned_at?: string;
user_id?: string;
};
@@ -479,11 +486,29 @@ export type AttachResourceRequest = {
resource_time?: number;
};
+export type CreateComputeClusterUserRequest = {
+ compute_cluster_id?: string;
+ local_username?: string;
+ user_id?: string;
+};
+
export type CreateRoleRequest = {
description?: string;
name?: string;
};
+export type CreateUserRequest = {
+ allocation_id?: string;
+ cluster_admin?: boolean;
+ compute_cluster_id?: string;
+ email?: string;
+ first_name?: string;
+ last_name?: string;
+ organization_id?: string;
+ portal_admin?: boolean;
+ username?: string;
+};
+
export type GrantPrivilegeRequest = {
privilege?: PrivilegeKey;
reason?: string;
@@ -2388,7 +2413,7 @@ export type PostComputeClusterUsersData = {
/**
* Cluster user payload
*/
- body: ComputeClusterUser;
+ body: CreateComputeClusterUserRequest;
path?: never;
query?: never;
url: '/compute-cluster-users';
@@ -2480,7 +2505,7 @@ export type PutComputeClusterUsersByIdData = {
/**
* Cluster user payload
*/
- body: ComputeClusterUser;
+ body: CreateComputeClusterUserRequest;
path: {
/**
* Compute cluster user ID
@@ -3619,7 +3644,7 @@ export type PostUsersData = {
/**
* User payload
*/
- body: User;
+ body: CreateUserRequest;
path?: never;
query?: never;
url: '/users';
@@ -3632,6 +3657,12 @@ export type PostUsersErrors = {
400: {
error?: string;
};
+ /**
+ * Conflict
+ */
+ 409: {
+ error?: string;
+ };
};
export type PostUsersError = PostUsersErrors[keyof PostUsersErrors];
diff --git a/web/src/generated/core/zod.gen.ts b/web/src/generated/core/zod.gen.ts
index 959dbf89d..9f409e1c7 100644
--- a/web/src/generated/core/zod.gen.ts
+++ b/web/src/generated/core/zod.gen.ts
@@ -31,6 +31,10 @@ export const zAllocationMembershipResponse = z.object({
export type allocationMembershipResponseZodType = z.infer<typeof zAllocationMembershipResponse>;
+export const zClusterAccessLevel = z.enum(['USER', 'ADMIN']);
+
+export type clusterAccessLevelZodType = z.infer<typeof zClusterAccessLevel>;
+
export const zComputeAllocation = z.object({
compute_cluster_id: z.string().optional(),
end_time: z.string().optional(),
@@ -173,9 +177,11 @@ export const zComputeCluster = z.object({
export type computeClusterZodType = z.infer<typeof zComputeCluster>;
export const zComputeClusterUser = z.object({
+ access_level: zClusterAccessLevel.optional(),
compute_cluster_id: z.string().optional(),
id: z.string().optional(),
local_username: z.string().optional(),
+ provisioned_at: z.string().optional(),
user_id: z.string().optional()
});
@@ -432,6 +438,14 @@ export const zAttachResourceRequest = z.object({
export type attachResourceRequestZodType = z.infer<typeof zAttachResourceRequest>;
+export const zCreateComputeClusterUserRequest = z.object({
+ compute_cluster_id: z.string().optional(),
+ local_username: z.string().optional(),
+ user_id: z.string().optional()
+});
+
+export type createComputeClusterUserRequestZodType = z.infer<typeof zCreateComputeClusterUserRequest>;
+
export const zCreateRoleRequest = z.object({
description: z.string().optional(),
name: z.string().optional()
@@ -439,6 +453,20 @@ export const zCreateRoleRequest = z.object({
export type createRoleRequestZodType = z.infer<typeof zCreateRoleRequest>;
+export const zCreateUserRequest = z.object({
+ allocation_id: z.string().optional(),
+ cluster_admin: z.boolean().optional(),
+ compute_cluster_id: z.string().optional(),
+ email: z.string().optional(),
+ first_name: z.string().optional(),
+ last_name: z.string().optional(),
+ organization_id: z.string().optional(),
+ portal_admin: z.boolean().optional(),
+ username: z.string().optional()
+});
+
+export type createUserRequestZodType = z.infer<typeof zCreateUserRequest>;
+
export const zGrantPrivilegeRequest = z.object({
privilege: zPrivilegeKey.optional(),
reason: z.string().optional()
@@ -1005,7 +1033,7 @@ export const zGetComputeAllocationsByIdUsersByUserIdUsagesTotalResponse = zUserA
/**
* Cluster user payload
*/
-export const zPostComputeClusterUsersBody = zComputeClusterUser;
+export const zPostComputeClusterUsersBody = zCreateComputeClusterUserRequest;
/**
* Created
@@ -1028,7 +1056,7 @@ export const zGetComputeClusterUsersByIdResponse = zComputeClusterUser;
/**
* Cluster user payload
*/
-export const zPutComputeClusterUsersByIdBody = zComputeClusterUser;
+export const zPutComputeClusterUsersByIdBody = zCreateComputeClusterUserRequest;
export const zPutComputeClusterUsersByIdPath = z.object({
id: z.string()
@@ -1359,7 +1387,7 @@ export const zGetUsersResponse = zUserListResponse;
/**
* User payload
*/
-export const zPostUsersBody = zUser;
+export const zPostUsersBody = zCreateUserRequest;
/**
* Created
diff --git a/web/src/mocks/handlers/users.ts b/web/src/mocks/handlers/users.ts
index b34608fbb..eaff47b7f 100644
--- a/web/src/mocks/handlers/users.ts
+++ b/web/src/mocks/handlers/users.ts
@@ -31,6 +31,26 @@ export const usersHandlers = [
total: users.length,
});
}),
+ http.post("*/api/v1/users", async ({ request }) => {
+ const body = (await request.json()) as {
+ email?: string;
+ first_name?: string;
+ last_name?: string;
+ portal_admin?: boolean;
+ };
+ return HttpResponse.json(
+ {
+ id: "user-created-1",
+ organization_id: "system",
+ email: body.email,
+ first_name: body.first_name ?? "",
+ last_name: body.last_name ?? "",
+ status: "PENDING",
+ type: body.portal_admin ? "SYSTEM" : "CLUSTER_LOCAL",
+ },
+ { status: 201 },
+ );
+ }),
http.get("*/api/v1/roles", () => HttpResponse.json(roles)),
http.post("*/api/v1/users/:id/roles", async ({ params, request }) => {
const body = (await request.json()) as { role_id?: string };
diff --git a/web/src/shared/ui/StatusBadge.tsx b/web/src/shared/ui/StatusBadge.tsx
index 98fbf0e00..7e5368085 100644
--- a/web/src/shared/ui/StatusBadge.tsx
+++ b/web/src/shared/ui/StatusBadge.tsx
@@ -61,7 +61,7 @@ export function StatusBadge({ variant, label, className }: StatusBadgeProps) {
return (
<span
className={cn(
- "inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium",
+ "inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-medium",
variantStyles[variant],
className,
)}