Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
85 changes: 67 additions & 18 deletions app/api/users/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,22 @@
* It includes handlers for fetching all users and creating new users.
*/

'use server'
"use server";

import { NextResponse} from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getUsers, createUser } from "@/services/user";

// Define a Zod schema for validating new user creation requests
import { auth } from "@/auth";
import { createUser, getUserByEmail, getUsers } from "@/services/user";

// Validation for new-user creation. ucsdId is optional because Google OAuth
// can't give us a real PID — users can fill it in later from their profile.
const userCreateSchema = z.object({
ucsdId: z.string().min(1),
email: z.string().email().regex(/@ucsd\.edu$/, "Must be a UCSD email"),
ucsdId: z.string().min(1).optional(),
email: z
.string()
.email()
.regex(/@ucsd\.edu$/, "Must be a UCSD email"),
name: z.object({
first: z.string().min(1),
last: z.string().min(1),
Expand All @@ -27,32 +33,75 @@ const userCreateSchema = z.object({
* Fetch all users
* @return response with user data
*/
export async function GET(){
export async function GET() {
try {
const users = await getUsers();
return NextResponse.json(users, { status: 200 });
}
catch (err){
} catch (err) {
console.error(err);
return NextResponse.json({ message: "Internal server error" }, {status: 500});
return NextResponse.json(
{ message: "Internal server error" },
{ status: 500 },
);
}
}

/**
* Create a new user
* @param request request object
* @return response after creating new user
* Create a new user.
*
* Used by the onboarding flow after Google sign-in: takes the Google email
* from the authenticated session, the role the user selected from the enum
* (PI / LAB_MANAGER / RESEARCHER / VIEWER), and the name (pre-filled from
* Google but editable).
*/
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.email) {
return NextResponse.json(
{ message: "Not authenticated" },
{ status: 401 },
);
}

const parsed = userCreateSchema.safeParse(await request.json());
if (parsed.success === false){
return NextResponse.json({ message: "Invalid data "}, {status: 400});
if (parsed.success === false) {
return NextResponse.json(
{ message: "Invalid data", issues: parsed.error.flatten() },
{ status: 400 },
);
}
const newUser = await createUser(parsed.data);

// Users can only create an account for themselves
if (
parsed.data.email.toLowerCase() !==
session.user.email.toLowerCase()
) {
return NextResponse.json(
{ message: "Email does not match signed-in user" },
{ status: 403 },
);
}

// Prevent duplicate user docs if onboarding is submitted twice
const existing = await getUserByEmail(parsed.data.email);
if (existing) {
return NextResponse.json(
{ message: "User already exists" },
{ status: 409 },
);
}

const newUser = await createUser({
...parsed.data,
lastLoginAt: new Date(),
});
return NextResponse.json(newUser, { status: 201 });
} catch (err) {
console.error(err);
return NextResponse.json({ message: "Internal server error " }, {status: 500});
return NextResponse.json(
{ message: "Internal server error" },
{ status: 500 },
);
}
}
}
3 changes: 2 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import localFont from "next/font/local";

import Providers from "@/components/Providers";
import "./globals.css";

const geistSans = localFont({
Expand All @@ -27,7 +28,7 @@ export default function RootLayout({
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{children}
<Providers>{children}</Providers>
</body>
</html>
);
Expand Down
2 changes: 1 addition & 1 deletion app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export default function SignInPage() {
{/* SSO Sign In Button */}
<button
type="button"
onClick = {() => signIn("google", { callbackUrl: "/"})}
onClick = {() => signIn("google", { callbackUrl: "/onboarding"})}
className="w-full bg-[#5d8cb9] hover:bg-[#4f7ca6] text-white font-medium py-2.5 rounded-md border border-[#3b5e7d] transition-colors shadow-sm"
>
Sign in with UCSD SSO
Expand Down
234 changes: 234 additions & 0 deletions app/onboarding/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
"use client";

import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { signOut, useSession } from "next-auth/react";

import type { Role } from "@/models/User";

const ROLES: { value: Role; label: string }[] = [
{ value: "PI", label: "Principal Investigator (PI)" },
{ value: "LAB_MANAGER", label: "Lab Manager" },
{ value: "RESEARCHER", label: "Researcher" },
{ value: "VIEWER", label: "Viewer" },
];

/**
* Split a Google "name" string into first/last. Falls back to using the whole
* thing as `first` if there's only one word.
*/
function splitName(full: string): { first: string; last: string } {
const trimmed = full.trim();
if (!trimmed) return { first: "", last: "" };
const parts = trimmed.split(/\s+/);
if (parts.length === 1) return { first: parts[0], last: "" };
return {
first: parts[0],
last: parts.slice(1).join(" "),
};
}

export default function OnboardingPage() {
const router = useRouter();
const { data: session, status, update } = useSession();

const initialNames = useMemo(() => {
const source = session?.user?.googleName ?? session?.user?.name ?? "";
return splitName(source);
}, [session?.user?.googleName, session?.user?.name]);

const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [role, setRole] = useState<Role | "">("");
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);

// Warm up the marketplace route so the post-submit navigation feels instant
useEffect(() => {
router.prefetch("/marketplace");
}, [router]);

// Seed the name fields once the session is available
useEffect(() => {
if (status === "authenticated") {
setFirstName((prev) => prev || initialNames.first);
setLastName((prev) => prev || initialNames.last);
}
}, [status, initialNames.first, initialNames.last]);

// Redirect already-onboarded users out of this page
useEffect(() => {
if (status === "unauthenticated") {
router.replace("/login");
return;
}
if (
status === "authenticated" &&
session?.user?.needsOnboarding === false
) {
router.replace("/");
}
}, [status, session?.user?.needsOnboarding, router]);

if (status === "loading") {
return (
<main className="min-h-screen flex items-center justify-center bg-[#f8f9fa]">
<p className="text-gray-500">Loading…</p>
</main>
);
}

const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);

if (!session?.user?.email) {
setError("No session found. Please sign in again.");
return;
}
if (!role) {
setError("Please select your role.");
return;
}
if (!firstName.trim() || !lastName.trim()) {
setError("First and last name are required.");
return;
}

setSubmitting(true);
try {
const response = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: session.user.email,
name: {
first: firstName.trim(),
last: lastName.trim(),
},
role,
}),
});

if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data?.message ?? "Failed to create account.");
}

// Refresh the session so needsOnboarding flips to false.
await update();

// Show the welcome interstitial briefly, then do a full-page
// navigation so middleware sees the updated cookie.
setSuccess(true);
setTimeout(() => {
window.location.assign("/marketplace");
}, 700);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
setSubmitting(false);
}
};

if (success) {
return (
<main className="min-h-screen bg-[#f8f9fa] flex flex-col items-center justify-center font-sans px-4 py-10">
<div className="bg-white w-full max-w-[480px] rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.06)] p-10 border border-gray-100 flex flex-col items-center text-center">
<div className="w-12 h-12 rounded-full border-2 border-[#ea7032]/30 border-t-[#ea7032] animate-spin mb-5" />
<h1 className="text-[22px] font-semibold text-gray-900 mb-2">
Welcome, {firstName.trim() || "there"}!
</h1>
<p className="text-[14px] text-gray-500">
Taking you to the marketplace…
</p>
</div>
</main>
);
}

return (
<main className="min-h-screen bg-[#f8f9fa] flex flex-col items-center justify-center font-sans px-4 py-10">
<div className="bg-white w-full max-w-[480px] rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.06)] p-8 border border-gray-100">
<h1 className="text-[24px] font-semibold text-center text-gray-900 mb-2">
Finish setting up your account
</h1>
<p className="text-center text-[14px] text-gray-500 mb-6">
{session?.user?.email
? `Signed in as ${session.user.email}`
: "Tell us a little about yourself"}
</p>

<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700">
First name
</label>
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
className="block w-full px-3 py-2.5 rounded-md bg-[#f3f4f6] text-gray-900 focus:bg-white focus:ring-2 focus:ring-[#ea7032]/50 outline-none transition-colors sm:text-sm"
/>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700">
Last name
</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
className="block w-full px-3 py-2.5 rounded-md bg-[#f3f4f6] text-gray-900 focus:bg-white focus:ring-2 focus:ring-[#ea7032]/50 outline-none transition-colors sm:text-sm"
/>
</div>
</div>

<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700">
Your role in the lab
</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as Role)}
required
className="block w-full px-3 py-2.5 rounded-md bg-[#f3f4f6] text-gray-900 focus:bg-white focus:ring-2 focus:ring-[#ea7032]/50 outline-none transition-colors sm:text-sm"
>
<option value="" disabled>
Select a role
</option>
{ROLES.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>

{error ? (
<p className="text-sm text-red-600">{error}</p>
) : null}

<button
type="submit"
disabled={submitting}
className="w-full bg-[#ea7032] hover:bg-[#d8642a] disabled:opacity-60 text-white font-medium py-2.5 rounded-md border border-[#c45a23] transition-colors shadow-sm"
>
{submitting ? "Creating account…" : "Create account"}
</button>

<button
type="button"
onClick={() => signOut({ callbackUrl: "/login" })}
className="w-full bg-white text-gray-600 font-medium py-2 rounded-md border border-gray-200 hover:bg-gray-50 transition-colors text-sm"
>
Cancel and sign out
</button>
</form>
</div>
</main>
);
}
2 changes: 1 addition & 1 deletion app/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default function SignUpPage() {
{/* sign in */}
<button
type="button"
onClick={() => signIn("google", { callbackUrl: "/" })}
onClick={() => signIn("google", { callbackUrl: "/onboarding" })}
className="w-full bg-[#5d8cb9] hover:bg-[#4f7ca6] text-white font-medium py-2.5 rounded-md border border-[#3b5e7d] transition-colors shadow-sm"
>
Sign up with UCSD SSO
Expand Down
Loading
Loading