Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c645220
Basic user application forms
fallow64 Apr 10, 2026
158dba7
Move list page to /forms/user-applications
fallow64 Apr 10, 2026
03cf306
Fix redirect link
fallow64 Apr 10, 2026
042e9cf
Remove PI autocomplete
fallow64 Apr 10, 2026
08da5f9
Add questionate to user application creation
fallow64 Apr 10, 2026
49c6c50
Move empty value to null
fallow64 Apr 10, 2026
f6da107
Simplify trims
fallow64 Apr 13, 2026
b641b7a
Add trailing backslash to user hrefs
fallow64 Apr 13, 2026
438d775
Add @chtc/web-components theme
fallow64 Apr 13, 2026
5a3303c
Add trailing slashes to all URLs
fallow64 Apr 13, 2026
bd9659a
Remove trailing slash from /api/login
fallow64 Apr 13, 2026
7855c29
Add forms to navbar
fallow64 Apr 13, 2026
8c3f6e6
Update edit form to handle content field
fallow64 Apr 13, 2026
d862684
Update header to only show admin links when user is admin
fallow64 Apr 13, 2026
caf272e
Add some UX pieces
CannonLock Apr 15, 2026
d114d50
Clean up the UI imports into components
CannonLock Apr 15, 2026
0b6663a
Make the entrypoint for unauthenticated users nicer
CannonLock Apr 15, 2026
8a70b4f
Clean things up
CannonLock Apr 16, 2026
110d4ae
Add adminView handles with auto true to the users page
CannonLock Apr 16, 2026
6126df8
Add in state checks for the approval flow
CannonLock Apr 16, 2026
83a2259
Pages between steps of the user request form
fallow64 Apr 17, 2026
a2567ed
Correct situation where users come in without emails
CannonLock Apr 20, 2026
97c39b1
Correct user/me page
CannonLock Apr 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"analyze": "ANALYZE=true next build"
},
"dependencies": {
"@chtc/web-components": "^1.0.20",
"@chtc/web-components": "^1.0.21",
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions src/app/email/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Add } from "@mui/icons-material";
import { Box, Button, Link, Typography } from "@mui/material";
import { Box, Link, Typography } from "@mui/material";

export default function RootLayout({
children,
Expand All @@ -9,7 +8,7 @@ export default function RootLayout({
return (
<>
<Typography variant={"h3"} component="h1" sx={{ mb: 2, display: "flex", justifyContent: "space-between" }}>
<Link href="/email" style={{ textDecoration: "none", color: "inherit" }}>
<Link href="/email/" style={{ textDecoration: "none", color: "inherit" }}>
Email List Generator
</Link>
</Typography>
Expand Down
128 changes: 128 additions & 0 deletions src/app/forms/user-applications/create/_components/LandingPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import React from 'react';
import {
Box,
Button,
Container,
Link as MuiLink,
Paper,
Stack,
Typography,
} from '@mui/material';
import EmailOutlinedIcon from '@mui/icons-material/EmailOutlined';
import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined';
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
import PersonAddOutlinedIcon from '@mui/icons-material/PersonAddOutlined';

const steps = [
{
icon: <EmailOutlinedIcon fontSize="medium" />,
text: (
<>
You will receive an automated email from{' '}
<MuiLink href="mailto:chtc@cs.wisc.edu">chtc@cs.wisc.edu</MuiLink>{' '}
within a few hours of completing the form.
</>
),
},
{
icon: <AccessTimeOutlinedIcon fontSize="medium" />,
text: (
<>
Wait 2–3 business days for a follow-up from CHTC Facilitation Staff. Contact us at{' '}
<MuiLink href="mailto:chtc@cs.wisc.edu">chtc@cs.wisc.edu</MuiLink>{' '}
if you don&apos;t hear from us after 3 business days.
</>
),
},
{
icon: <GroupsOutlinedIcon fontSize="medium" />,
text: (
<>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.5 }}>New Research Group</Typography>
We will first schedule a meeting with you to understand your computational needs before creating your account.
</>
),
},
{
icon: <PersonAddOutlinedIcon fontSize="medium" />,
text: (
<>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.5 }}>Existing Research Group</Typography>
We will create a new account. We don&apos;t require a meeting, but highly recommend meeting with us, especially if you are new to research computing!
</>
),
},
];

export default function LandingPage() {
return (
<Container maxWidth="sm" sx={{ py: 8 }}>
{/* Header */}
<Box sx={{ mb: 5, textAlign: 'center' }}>
<Typography
variant="h4"
component="h1"
sx={{ fontWeight: 700, mb: 1 }}
>
Before you apply
</Typography>
<Typography variant="body1" color="text.secondary">
Here&apos;s what to expect after submitting your account request.
</Typography>
</Box>

{/* Info cards */}
<Stack spacing={2} sx={{ mb: 5 }}>
{steps.map((step, i) => (
<Paper
key={i}
elevation={0}
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 2,
p: 2.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
transition: 'box-shadow 0.2s',
'&:hover': { boxShadow: 3 },
}}
>
<Box
sx={{
flexShrink: 0,
width: 40,
height: 40,
borderRadius: 2,
bgcolor: 'primary.main',
color: 'primary.contrastText',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{step.icon}
</Box>
<Typography variant="body2" sx={{ pt: 0.5, lineHeight: 1.6 }}>
{step.text}
</Typography>
</Paper>
))}
</Stack>

{/* CTA */}
<Box sx={{ textAlign: 'center' }}>
<Button
variant="contained"
size="large"
component={MuiLink}
href="/api/login?next=/forms/user-applications/create/"
sx={{ px: 5, textTransform: 'none' }}
>
Login to Apply
</Button>
</Box>
</Container>
);
}
77 changes: 77 additions & 0 deletions src/app/forms/user-applications/create/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"use client";

import LandingPage from "@/src/app/forms/user-applications/create/_components/LandingPage";
import { apiFetch, useAuthClient } from "@/src/components/AuthProvider";
import UserApplicationCreateForm from "@/src/components/Forms/UserApplicationForm/UserApplicationCreateForm";
import { UserFormPost } from "@/types";
import { Alert, Box, Skeleton } from "@mui/material";
import { useState } from "react";

function CreateUserFormPage() {
const { isAuthenticated, currentUser, loading } = useAuthClient();
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitSuccess, setSubmitSuccess] = useState(false);

const handleSubmit = async (payload: UserFormPost) => {
setSubmitError(null);
setSubmitSuccess(false);
setIsSubmitting(true);

try {
const response = await apiFetch("/forms/user-applications", {
method: "POST",
body: JSON.stringify(payload),
});

if (!response.ok) {
let message = `Failed to submit form: ${response.statusText}`;
try {
const errorData = (await response.json()) as { detail?: string };
if (typeof errorData.detail === "string" && errorData.detail.length > 0) {
message = errorData.detail;
}
} catch {
// Keep the fallback status text message.
}
setSubmitError(message);
return;
}

setSubmitSuccess(true);
} catch (error: unknown) {
setSubmitError(error instanceof Error ? error.message : "Failed to submit form.");
} finally {
setIsSubmitting(false);
}
};

if (loading) {
return (
<Box>
<Skeleton variant={"rounded"} height={"800px"} sx={{ minHeight: "80vh" }} />
</Box>
);
}

if (!isAuthenticated) {
return <LandingPage />;
}

if (currentUser?.active) {
return <Alert severity="info">Your account is already active. No need to submit another application.</Alert>;
}

return (
<Box sx={{ p: 3, maxWidth: 800, mx: "auto" }}>
<UserApplicationCreateForm
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
error={submitError}
submitSuccess={submitSuccess}
/>
</Box>
);
}

export default CreateUserFormPage;
103 changes: 103 additions & 0 deletions src/app/forms/user-applications/edit/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"use client";

import { AuthGuard } from "@/src/components/AuthGuard";
import {apiFetch} from "@/src/components/AuthProvider";
import UserApplicationEditForm from "@/src/components/Forms/UserApplicationForm/UserApplicationEditForm";
import type { ApiError } from "@/src/components/Forms/UserForm/UserForm";
import type { UserForm, UserFormPatch } from "@/types";
import { Box, Skeleton, Typography } from "@mui/material";
import {useRouter, useSearchParams} from "next/navigation";
import { Suspense, useState } from "react";
import useSWR from "swr";

const userApplicationFetcher = async (id: number | null) => {
if (!id) return null;

// todo: use the /forms/user-applications/{id} endpoint once it's implemented
const response = await apiFetch(`/forms/user-applications?id=eq.${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch user application with id ${id}: ${response.statusText}`);
}

const data = (await response.json()) as UserForm[];
return data[0] ?? null;
};

function Page() {
return (
<AuthGuard message="You must be logged in to edit a user application.">
<Suspense fallback={<Skeleton variant="rectangular" height="100" />}>
<UserApplicationPage />
</Suspense>
</AuthGuard>
);
}

function UserApplicationPage() {
const searchParams = useSearchParams();
const id = Number.parseInt(searchParams.get("id") || "", 10) || null;

if (!id) {
return <Typography color="error">No user application ID provided.</Typography>;
}

return (
<Box sx={{ p: 3, width: "100%" }}>
<Typography variant="h4" sx={{ mb: 3 }}>
Edit User Application
</Typography>
<Suspense fallback={<Skeleton variant="rectangular" height="400" />}>
<UserApplicationFormSuspense id={id} />
</Suspense>
</Box>
);
}

function UserApplicationFormSuspense({ id }: { id: number }) {

const { data, mutate } = useSWR([`/forms/user-applications`, id], () => userApplicationFetcher(id), {
suspense: true,
});
const [error, setError] = useState<string | ApiError | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const router = useRouter();

if (!data) {
return <Typography color="error">User application not found.</Typography>;
}

const handleSubmit = async (payload: UserFormPatch) => {
setError(null);
setIsSubmitting(true);
try {
const response = await apiFetch(`/forms/user-applications/${id}`, {
method: "PATCH",
body: JSON.stringify(payload),
});

if (!response.ok) {
try {
const errorData = await response.json();
setError(errorData as ApiError);
} catch {
setError(`Failed to update user application: ${response.statusText}`);
}
return;
}

await mutate();
router.push("/forms/user-applications");

} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to update user application");
} finally {
setIsSubmitting(false);
}
};

return (
<UserApplicationEditForm initialValues={data} onSubmit={handleSubmit} isSubmitting={isSubmitting} error={error} />
);
}

export default Page;
Loading
Loading