From 3556b1425fb21d9632fca0012143ca864653e44a Mon Sep 17 00:00:00 2001 From: Hardik Date: Sat, 16 May 2026 16:09:30 +0530 Subject: [PATCH] feat(profile): user profile page, manager signature, and SuperUser access requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema (migration: 20260516103515_user_profile_signature): - User.signatureKey String? — storage key for the manager's approval signature - New RequestStatus enum (PENDING / APPROVED / DENIED) - New SuperUserRequest model — tracks access-escalation requests from users Profile page (/profile) — all roles: - Account info panel (name, email, employee ID, role — read-only) - Change password form (validates current password, bcrypt hash on save) - Signature uploader (MANAGER / SUPERUSER only) — PNG/JPG/WebP up to 2 MB; previews before save; can remove existing signature - SuperUser access request form — textarea for reason, shows current request status (pending / approved / denied) after submission Signature gate on approval page (/approvals/[id]): - Server checks if the current manager has uploaded a signatureKey - If missing: shows an amber warning banner with a deep-link to /profile instead of rendering the approval action buttons; managers cannot approve, reject, or request edits without a signature on file PDF and XLSX exports: - Fetches the approver's signature image from storage after identifying the approval action - PDF: embeds as base64 data URI above the approver name in the left signature block - XLSX: inserts the image into the sig-row cells via ExcelJS addImage; adds a name row below the image for legibility SuperUser requests admin page (/admin/superuser-requests): - Pending requests listed with user info, role, reason, and Approve/Deny buttons - Approve: sets user.role = SUPERUSER and closes the request - Deny: marks request DENIED, user role unchanged - Resolved history table below Admin user management updates (/admin/users): - "SuperUser" button (ShieldCheck icon) on every non-superuser, non-admin row - Directly grants SUPERUSER role and auto-closes any open request for that user lib/storage.ts: - buildSignatureKey(userId, ext) helper - uploadBuffer(key, buffer, contentType) — server-side write to dev-uploads or R2 - downloadBuffer(key) — server-side read from dev-uploads or R2 presigned URL Sidebar: - "My Profile" link (UserCircle) visible to all roles - "SuperUser Requests" link (ShieldCheck) in admin section Co-Authored-By: Claude Sonnet 4.6 --- .../admin/superuser-requests/actions.ts | 71 +++++++++ .../admin/superuser-requests/page.tsx | 135 ++++++++++++++++++ .../superuser-requests/request-actions.tsx | 43 ++++++ .../admin/users/grant-superuser-button.tsx | 36 +++++ .../app/(portal)/admin/users/page.tsx | 6 +- .../app/(portal)/approvals/[id]/page.tsx | 27 +++- .../app/(portal)/profile/actions.ts | 125 ++++++++++++++++ .../(portal)/profile/change-password-form.tsx | 96 +++++++++++++ .../app/(portal)/profile/page.tsx | 120 ++++++++++++++++ .../(portal)/profile/signature-uploader.tsx | 128 +++++++++++++++++ .../profile/superuser-request-form.tsx | 79 ++++++++++ .../app/api/po/[id]/export/route.ts | 49 ++++++- .../components/layout/sidebar.tsx | 4 + App/pelagia-portal/lib/storage.ts | 60 ++++++++ .../migration.sql | 24 ++++ App/pelagia-portal/prisma/schema.prisma | 29 +++- 16 files changed, 1022 insertions(+), 10 deletions(-) create mode 100644 App/pelagia-portal/app/(portal)/admin/superuser-requests/actions.ts create mode 100644 App/pelagia-portal/app/(portal)/admin/superuser-requests/page.tsx create mode 100644 App/pelagia-portal/app/(portal)/admin/superuser-requests/request-actions.tsx create mode 100644 App/pelagia-portal/app/(portal)/admin/users/grant-superuser-button.tsx create mode 100644 App/pelagia-portal/app/(portal)/profile/actions.ts create mode 100644 App/pelagia-portal/app/(portal)/profile/change-password-form.tsx create mode 100644 App/pelagia-portal/app/(portal)/profile/page.tsx create mode 100644 App/pelagia-portal/app/(portal)/profile/signature-uploader.tsx create mode 100644 App/pelagia-portal/app/(portal)/profile/superuser-request-form.tsx create mode 100644 App/pelagia-portal/prisma/migrations/20260516103515_user_profile_signature/migration.sql diff --git a/App/pelagia-portal/app/(portal)/admin/superuser-requests/actions.ts b/App/pelagia-portal/app/(portal)/admin/superuser-requests/actions.ts new file mode 100644 index 0000000..7ddbc82 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/superuser-requests/actions.ts @@ -0,0 +1,71 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; +import { revalidatePath } from "next/cache"; + +type Result = { ok: true } | { error: string }; + +export async function resolveRequest( + requestId: string, + decision: "APPROVED" | "DENIED" +): Promise { + const session = await auth(); + if (!session?.user || !hasPermission(session.user.role, "manage_users")) { + return { error: "Unauthorized" }; + } + + const request = await db.superUserRequest.findUnique({ + where: { id: requestId }, + include: { user: true }, + }); + if (!request) return { error: "Request not found" }; + if (request.status !== "PENDING") return { error: "Request has already been resolved" }; + + await db.$transaction(async (tx) => { + await tx.superUserRequest.update({ + where: { id: requestId }, + data: { + status: decision, + resolvedAt: new Date(), + resolvedById: session.user.id, + }, + }); + + if (decision === "APPROVED") { + await tx.user.update({ + where: { id: request.userId }, + data: { role: "SUPERUSER" }, + }); + } + }); + + revalidatePath("/admin/superuser-requests"); + revalidatePath("/admin/users"); + return { ok: true }; +} + +export async function grantSuperUser(userId: string): Promise { + const session = await auth(); + if (!session?.user || !hasPermission(session.user.role, "manage_users")) { + return { error: "Unauthorized" }; + } + + const user = await db.user.findUnique({ where: { id: userId }, select: { role: true, name: true } }); + if (!user) return { error: "User not found" }; + if (user.role === "SUPERUSER") return { error: "User is already a SuperUser" }; + if (user.role === "ADMIN") return { error: "Cannot change Admin role" }; + + await db.user.update({ where: { id: userId }, data: { role: "SUPERUSER" } }); + + // Auto-close any pending request for this user + await db.superUserRequest.updateMany({ + where: { userId, status: "PENDING" }, + data: { status: "APPROVED", resolvedAt: new Date(), resolvedById: session.user.id }, + }); + + revalidatePath("/admin/users"); + revalidatePath("/admin/superuser-requests"); + return { ok: true }; +} diff --git a/App/pelagia-portal/app/(portal)/admin/superuser-requests/page.tsx b/App/pelagia-portal/app/(portal)/admin/superuser-requests/page.tsx new file mode 100644 index 0000000..23251ff --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/superuser-requests/page.tsx @@ -0,0 +1,135 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; +import { redirect } from "next/navigation"; +import { formatDate } from "@/lib/utils"; +import { RequestActions } from "./request-actions"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "SuperUser Requests" }; + +const STATUS_STYLES: Record = { + PENDING: "bg-warning-50 text-warning-700", + APPROVED: "bg-success-50 text-success-700", + DENIED: "bg-danger-50 text-danger-700", +}; + +const ROLE_LABELS: Record = { + TECHNICAL: "Technical", + MANNING: "Manning", + ACCOUNTS: "Accounts", + MANAGER: "Manager", + SUPERUSER: "SuperUser", + AUDITOR: "Auditor", + ADMIN: "Admin", +}; + +export default async function SuperUserRequestsPage() { + const session = await auth(); + if (!session?.user) redirect("/login"); + if (!hasPermission(session.user.role, "manage_users")) redirect("/dashboard"); + + const requests = await db.superUserRequest.findMany({ + include: { + user: { select: { id: true, name: true, email: true, employeeId: true, role: true } }, + resolvedBy: { select: { name: true } }, + }, + orderBy: [{ status: "asc" }, { createdAt: "desc" }], + }); + + const pending = requests.filter((r) => r.status === "PENDING"); + const resolved = requests.filter((r) => r.status !== "PENDING"); + + return ( +
+
+
+

SuperUser Access Requests

+

+ {pending.length} pending · {resolved.length} resolved +

+
+
+ + {requests.length === 0 && ( +
+ No SuperUser access requests. +
+ )} + + {pending.length > 0 && ( +
+

Pending

+
+ {pending.map((req) => ( +
+
+
+
+ {req.user.name} + {req.user.employeeId} + + {ROLE_LABELS[req.user.role] ?? req.user.role} + +
+

{req.user.email}

+ {req.reason && ( +

+ “{req.reason}” +

+ )} +

+ Requested {formatDate(req.createdAt)} +

+
+ +
+
+ ))} +
+
+ )} + + {resolved.length > 0 && ( +
+

Resolved

+
+ + + + + + + + + + + + {resolved.map((req) => ( + + + + + + + + ))} + +
UserCurrent RoleStatusResolved ByResolved
+
{req.user.name}
+
{req.user.email}
+
+ {ROLE_LABELS[req.user.role] ?? req.user.role} + + + {req.status.charAt(0) + req.status.slice(1).toLowerCase()} + + {req.resolvedBy?.name ?? "—"} + {req.resolvedAt ? formatDate(req.resolvedAt) : "—"} +
+
+
+ )} +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/superuser-requests/request-actions.tsx b/App/pelagia-portal/app/(portal)/admin/superuser-requests/request-actions.tsx new file mode 100644 index 0000000..0d94df8 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/superuser-requests/request-actions.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { resolveRequest } from "./actions"; +import { Check, X } from "lucide-react"; + +export function RequestActions({ requestId }: { requestId: string }) { + const router = useRouter(); + const [pending, setPending] = useState<"approve" | "deny" | null>(null); + const [error, setError] = useState(""); + + async function handle(decision: "APPROVED" | "DENIED") { + setPending(decision === "APPROVED" ? "approve" : "deny"); + setError(""); + const result = await resolveRequest(requestId, decision); + setPending(null); + if ("error" in result) setError(result.error); + else router.refresh(); + } + + return ( +
+ {error && {error}} + + +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/users/grant-superuser-button.tsx b/App/pelagia-portal/app/(portal)/admin/users/grant-superuser-button.tsx new file mode 100644 index 0000000..4ea025e --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/users/grant-superuser-button.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { grantSuperUser } from "../superuser-requests/actions"; +import { ShieldCheck } from "lucide-react"; + +export function GrantSuperUserButton({ userId }: { userId: string }) { + const router = useRouter(); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + + async function handle() { + if (!confirm("Grant this user SuperUser access?")) return; + setPending(true); + setError(""); + const result = await grantSuperUser(userId); + setPending(false); + if ("error" in result) setError(result.error); + else router.refresh(); + } + + return ( + + + + ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/users/page.tsx b/App/pelagia-portal/app/(portal)/admin/users/page.tsx index 2666c3a..6bf7da3 100644 --- a/App/pelagia-portal/app/(portal)/admin/users/page.tsx +++ b/App/pelagia-portal/app/(portal)/admin/users/page.tsx @@ -5,6 +5,7 @@ import { redirect } from "next/navigation"; import { formatDate } from "@/lib/utils"; import { AddUserButton, EditUserButton } from "./user-form"; import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button"; +import { GrantSuperUserButton } from "./grant-superuser-button"; import { deleteUser } from "./actions"; import type { Metadata } from "next"; @@ -68,7 +69,10 @@ export default async function AdminUsersPage() { {formatDate(user.createdAt)} - + + {user.role !== "SUPERUSER" && user.role !== "ADMIN" && ( + + )}
- + {hasSignature ? ( + + ) : ( +
+ +
+

Signature required to approve POs

+

+ You must upload your approval signature before you can approve, reject, or request edits on purchase orders. +

+ + Go to Profile to upload your signature → + +
+
+ )}
); diff --git a/App/pelagia-portal/app/(portal)/profile/actions.ts b/App/pelagia-portal/app/(portal)/profile/actions.ts new file mode 100644 index 0000000..c34c0f3 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/profile/actions.ts @@ -0,0 +1,125 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { buildSignatureKey, uploadBuffer } from "@/lib/storage"; +import { revalidatePath } from "next/cache"; +import bcrypt from "bcryptjs"; +import { z } from "zod"; + +type Result = { ok: true } | { error: string }; + +// ── Change password ─────────────────────────────────────────────────────────── + +const changePasswordSchema = z.object({ + currentPassword: z.string().min(1, "Current password is required"), + newPassword: z.string().min(8, "New password must be at least 8 characters"), +}); + +export async function changePassword(formData: FormData): Promise { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + const parsed = changePasswordSchema.safeParse({ + currentPassword: formData.get("currentPassword"), + newPassword: formData.get("newPassword"), + }); + if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; + + const user = await db.user.findUnique({ + where: { id: session.user.id }, + select: { passwordHash: true }, + }); + if (!user) return { error: "User not found" }; + + const valid = await bcrypt.compare(parsed.data.currentPassword, user.passwordHash); + if (!valid) return { error: "Current password is incorrect" }; + + const newHash = await bcrypt.hash(parsed.data.newPassword, 12); + await db.user.update({ + where: { id: session.user.id }, + data: { passwordHash: newHash }, + }); + + return { ok: true }; +} + +// ── Upload signature ────────────────────────────────────────────────────────── + +export async function saveSignature(formData: FormData): Promise { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + if (session.user.role !== "MANAGER" && session.user.role !== "SUPERUSER") { + return { error: "Only managers and superusers can upload a signature" }; + } + + const file = formData.get("signature") as File | null; + if (!file || file.size === 0) return { error: "No file provided" }; + if (file.size > 2 * 1024 * 1024) return { error: "Signature must be under 2 MB" }; + + const allowed = ["image/png", "image/jpeg", "image/jpg", "image/webp"]; + if (!allowed.includes(file.type)) { + return { error: "Signature must be a PNG, JPG, or WebP image" }; + } + + const ext = file.type === "image/png" ? "png" : file.type === "image/webp" ? "webp" : "jpg"; + const key = buildSignatureKey(session.user.id, ext); + const buffer = Buffer.from(await file.arrayBuffer()); + + await uploadBuffer(key, buffer, file.type); + + await db.user.update({ + where: { id: session.user.id }, + data: { signatureKey: key }, + }); + + revalidatePath("/profile"); + revalidatePath("/approvals"); + return { ok: true }; +} + +// ── Remove signature ────────────────────────────────────────────────────────── + +export async function removeSignature(): Promise { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + await db.user.update({ + where: { id: session.user.id }, + data: { signatureKey: null }, + }); + + revalidatePath("/profile"); + revalidatePath("/approvals"); + return { ok: true }; +} + +// ── Request SuperUser access ────────────────────────────────────────────────── + +export async function requestSuperUser(formData: FormData): Promise { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + if (session.user.role === "SUPERUSER" || session.user.role === "ADMIN") { + return { error: "You already have elevated access" }; + } + + const reason = (formData.get("reason") as string | null)?.trim() ?? ""; + + // Check for an existing pending request + const existing = await db.superUserRequest.findFirst({ + where: { userId: session.user.id, status: "PENDING" }, + }); + if (existing) return { error: "You already have a pending SuperUser request" }; + + await db.superUserRequest.create({ + data: { + userId: session.user.id, + reason: reason || null, + }, + }); + + revalidatePath("/profile"); + return { ok: true }; +} diff --git a/App/pelagia-portal/app/(portal)/profile/change-password-form.tsx b/App/pelagia-portal/app/(portal)/profile/change-password-form.tsx new file mode 100644 index 0000000..f15dd52 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/profile/change-password-form.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState } from "react"; +import { changePassword } from "./actions"; + +export function ChangePasswordForm() { + const [success, setSuccess] = useState(false); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const form = e.currentTarget; + const fd = new FormData(form); + + const newPw = fd.get("newPassword") as string; + const confirmPw = fd.get("confirmPassword") as string; + if (newPw !== confirmPw) { + setError("New passwords do not match"); + return; + } + + setPending(true); + setSuccess(false); + setError(""); + const result = await changePassword(fd); + setPending(false); + + if ("error" in result) { + setError(result.error); + } else { + setSuccess(true); + form.reset(); + } + } + + return ( +
+
+ + +
+
+ + +
+
+ + +
+ + {error && ( +

{error}

+ )} + {success && ( +

+ Password changed successfully. +

+ )} + + +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/profile/page.tsx b/App/pelagia-portal/app/(portal)/profile/page.tsx new file mode 100644 index 0000000..c82ee1d --- /dev/null +++ b/App/pelagia-portal/app/(portal)/profile/page.tsx @@ -0,0 +1,120 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { redirect } from "next/navigation"; +import { generateDownloadUrl } from "@/lib/storage"; +import { ChangePasswordForm } from "./change-password-form"; +import { SignatureUploader } from "./signature-uploader"; +import { SuperUserRequestForm } from "./superuser-request-form"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "My Profile" }; + +const ROLE_LABELS: Record = { + TECHNICAL: "Technical", + MANNING: "Manning", + ACCOUNTS: "Accounts", + MANAGER: "Manager", + SUPERUSER: "SuperUser", + AUDITOR: "Auditor", + ADMIN: "Admin", +}; + +export default async function ProfilePage() { + const session = await auth(); + if (!session?.user) redirect("/login"); + + const user = await db.user.findUnique({ + where: { id: session.user.id }, + select: { + id: true, + name: true, + email: true, + employeeId: true, + role: true, + signatureKey: true, + superUserRequests: { + orderBy: { createdAt: "desc" }, + take: 1, + select: { status: true, createdAt: true }, + }, + }, + }); + if (!user) redirect("/login"); + + const canHaveSignature = user.role === "MANAGER" || user.role === "SUPERUSER"; + const canRequestSuperUser = user.role !== "SUPERUSER" && user.role !== "ADMIN"; + + const signatureUrl = user.signatureKey + ? await generateDownloadUrl(user.signatureKey) + : null; + + const latestRequest = user.superUserRequests[0] ?? null; + + return ( +
+
+

My Profile

+

Manage your account settings

+
+ + {/* Account Info */} +
+

Account Information

+
+
+
Name
+
{user.name}
+
+
+
Email
+
{user.email}
+
+
+
Employee ID
+
{user.employeeId}
+
+
+
Role
+
+ + {ROLE_LABELS[user.role] ?? user.role} + +
+
+
+
+ + {/* Change Password */} +
+

Change Password

+ +
+ + {/* Signature (managers & superusers) */} + {canHaveSignature && ( +
+
+

Approval Signature

+

+ Your signature is embedded in approved PO documents (PDF and XLSX). + {!user.signatureKey && ( + + A signature is required to approve purchase orders. + + )} +

+
+ +
+ )} + + {/* SuperUser access request */} + {canRequestSuperUser && ( +
+

SuperUser Access

+ +
+ )} +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/profile/signature-uploader.tsx b/App/pelagia-portal/app/(portal)/profile/signature-uploader.tsx new file mode 100644 index 0000000..d8dfa48 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/profile/signature-uploader.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useRef, useState } from "react"; +import { saveSignature, removeSignature } from "./actions"; +import { Upload, X } from "lucide-react"; + +interface Props { + currentSignatureUrl: string | null; +} + +export function SignatureUploader({ currentSignatureUrl }: Props) { + const inputRef = useRef(null); + const [preview, setPreview] = useState(null); + const [pending, setPending] = useState(false); + const [removing, setRemoving] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setError(""); + setSuccess(""); + setPreview(URL.createObjectURL(file)); + } + + async function handleUpload(e: React.FormEvent) { + e.preventDefault(); + const file = inputRef.current?.files?.[0]; + if (!file) { setError("Please select a file first"); return; } + + const fd = new FormData(); + fd.append("signature", file); + + setPending(true); + setError(""); + setSuccess(""); + const result = await saveSignature(fd); + setPending(false); + + if ("error" in result) { + setError(result.error); + } else { + setSuccess("Signature saved successfully."); + setPreview(null); + if (inputRef.current) inputRef.current.value = ""; + } + } + + async function handleRemove() { + setRemoving(true); + setError(""); + setSuccess(""); + const result = await removeSignature(); + setRemoving(false); + if ("error" in result) setError(result.error); + else setSuccess("Signature removed."); + } + + const displayUrl = preview ?? currentSignatureUrl; + + return ( +
+ {displayUrl && ( +
+

+ {preview ? "Preview" : "Current signature"} +

+ {/* eslint-disable-next-line @next/next/no-img-element */} + Signature +
+ )} + +
+
inputRef.current?.click()} + > + +

+ Click to select signature image +

+

PNG, JPG or WebP — max 2 MB

+ +
+ + {error && ( +

{error}

+ )} + {success && ( +

{success}

+ )} + +
+ + {currentSignatureUrl && !preview && ( + + )} +
+
+
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/profile/superuser-request-form.tsx b/App/pelagia-portal/app/(portal)/profile/superuser-request-form.tsx new file mode 100644 index 0000000..65f920d --- /dev/null +++ b/App/pelagia-portal/app/(portal)/profile/superuser-request-form.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useState } from "react"; +import { requestSuperUser } from "./actions"; + +interface Props { + pendingRequest: { createdAt: Date; status: string } | null; +} + +export function SuperUserRequestForm({ pendingRequest }: Props) { + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + if (pendingRequest) { + const isPending = pendingRequest.status === "PENDING"; + const isApproved = pendingRequest.status === "APPROVED"; + return ( +
+ {isPending && "Your SuperUser access request is pending admin review."} + {isApproved && "Your SuperUser access request was approved."} + {!isPending && !isApproved && "Your SuperUser access request was not approved."} +
+ ); + } + + if (submitted) { + return ( +
+ Your request has been submitted. An admin will review it shortly. +
+ ); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setPending(true); + setError(""); + const result = await requestSuperUser(new FormData(e.currentTarget)); + setPending(false); + if ("error" in result) setError(result.error); + else setSubmitted(true); + } + + return ( +
+

+ SuperUser access grants additional cross-team permissions. Describe why you need elevated access and an admin will review your request. +

+
+ +