You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by la...@apache.org on 2026/09/08 01:51:42 UTC

(airavata-custos) 04/04: Implemented the user creation dialog in the `Users and Permissions`

This is an automated email from the ASF dual-hosted git repository.

lahirujayathilake pushed a commit to branch early-user-onboard
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git

commit 9021925a751c0eca18e7934bd0225dc700a86de1
Author: lahiruj <la...@gmail.com>
AuthorDate: Mon Sep 7 21:51:01 2026 -0400

    Implemented the user creation dialog in the `Users and Permissions`
---
 internal/server/user.go                            |   2 +-
 .../admin/users/management/AddUserDialog.tsx       | 303 +++++++++++++++++++++
 .../(portal)/admin/users/management/UsersTable.tsx |  29 ++
 .../admin/users/management/UsersTableContainer.tsx |  71 ++++-
 .../management/__tests__/AddUserDialog.test.tsx    | 105 +++++++
 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 +-
 14 files changed, 609 insertions(+), 23 deletions(-)

diff --git a/internal/server/user.go b/internal/server/user.go
index 690802b03..b52afcb5b 100644
--- a/internal/server/user.go
+++ b/internal/server/user.go
@@ -38,7 +38,7 @@ type createUserRequest struct {
 }
 
 // @Summary	Create a user
-// @Description	Creates a researcher (both admins flag false) or an admin.
+// @Description	Creates a researcher (both admin flags false) or an admin.
 // @Tags	Users
 // @Security	BearerAuth
 // @Accept	json
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..ffa32ebae
--- /dev/null
+++ b/web/src/app/(portal)/admin/users/management/AddUserDialog.tsx
@@ -0,0 +1,303 @@
+// 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);
+
+  React.useEffect(() => {
+    if (!open) {
+      setUserType("researcher");
+      setEmail("");
+      setFirstName("");
+      setLastName("");
+      setUsername("");
+      setAllocationId(NO_ALLOCATION);
+    }
+  }, [open]);
+
+  const isAdmin = userType === "admin";
+
+  function handleSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    if (isPending) return;
+    const payload: CreateUserPayload = {
+      email: email.trim(),
+      first_name: firstName.trim(),
+      last_name: lastName.trim(),
+    };
+    if (isAdmin) {
+      payload.portal_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
+                    readOnly
+                    className="mt-0.5 size-4 rounded border-input accent-[color:var(--brand)]"
+                  />
+                  {/* TODO: cluster admin is not wired up yet, so admins are portal-only. */}
+                  <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-not-allowed items-start gap-2.5 border-t p-3 opacity-60">
+                  <input type="checkbox" disabled className="mt-0.5 size-4 rounded border-input" />
+                  <span>
+                    <span className="block text-sm font-medium">
+                      Cluster admin{" "}
+                      <span className="ml-1 rounded bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
+                        Coming soon
+                      </span>
+                    </span>
+                    <span className="block text-xs text-muted-foreground">
+                      Admin access on the cluster itself. Available in an upcoming release.
+                    </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">This user will be a portal admin</span>
+                  <span className="text-muted-foreground">Admin access to the portal.</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}>
+              {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..c055956b5
--- /dev/null
+++ b/web/src/app/(portal)/admin/users/management/__tests__/AddUserDialog.test.tsx
@@ -0,0 +1,105 @@
+// 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("shows cluster admin as coming soon and disabled", () => {
+    renderDialog();
+    fireEvent.click(screen.getByRole("radio", { name: /Admin/ }));
+    expect(screen.getByText("Coming soon")).toBeInTheDocument();
+    const clusterAdmin = screen
+      .getByText(/Admin access on the cluster itself/)
+      .closest("label")
+      ?.querySelector("input");
+    expect(clusterAdmin).toBeDisabled();
+  });
+});
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,
       )}