diff --git a/src/app/create-account/complete-profile/page.tsx b/src/app/create-account/complete-profile/page.tsx new file mode 100644 index 000000000..ea99a150f --- /dev/null +++ b/src/app/create-account/complete-profile/page.tsx @@ -0,0 +1,669 @@ +"use client" + +import { useUser } from "@clerk/nextjs" +import { zodResolver } from "@hookform/resolvers/zod" +import { track } from "@vercel/analytics/react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { useEffect, useState } from "react" +import { useForm } from "react-hook-form" +import { siDiscord } from "simple-icons" +import * as z from "zod" + +// import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar" +// import GithubHeatmap from "../_components/github-heatmap" +import OnlinePaymentForm from "~/components/payment/online" +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert" +import { Button } from "~/components/ui/button" +import { Checkbox } from "~/components/ui/checkbox" +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form" +import { Input } from "~/components/ui/input" +import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs" +import { toast } from "~/components/ui/use-toast" + +import { PRONOUNS, UNIVERSITIES } from "~/lib/constants" +import { cn, getIsMembershipOpen } from "~/lib/utils" +import { type User } from "~/server/db/types" +import { api } from "~/trpc/react" + +import DetailsBlock from "../details" +import PaymentBlock from "../payment" + +type ActiveView = "form" | "payment" + +const formSchema = z + .object({ + name: z.string().min(2, { + message: "Name is required", + }), + preferred_name: z.string().min(2, { + message: "Preferred name is required", + }), + email: z + .string() + .email({ + message: "Invalid email address", + }) + .min(2, { + message: "Email is required", + }), + pronouns: z.string().min(2, { + message: "Pronouns are required", + }), + isUWA: z.boolean(), + student_number: z.string().optional(), + uni: z.string().optional(), + github: z.string().optional(), + discord: z.string().optional(), + subscribe: z.boolean(), + }) + .refine(({ isUWA, student_number }) => !Boolean(isUWA) || student_number, { + message: "Student number is required", + path: ["student_number"], + }) + .refine(({ isUWA, student_number = "" }) => !Boolean(isUWA) || /^\d{8}$/.test(student_number), { + message: "Student number must be 8 digits", + path: ["student_number"], + }) + .refine(({ isUWA, uni = "" }) => Boolean(isUWA) || uni || uni === "other", { + message: "University is required", + path: ["uni"], + }) + +type FormSchema = z.infer + +const defaultValues = { + name: "", + preferred_name: "", + pronouns: PRONOUNS[0].value, + isUWA: true, + student_number: "", + uni: UNIVERSITIES[0].value, + github: "", + discord: "", + subscribe: true, +} + +export default function CompleteProfile() { + const [activeView, setActiveView] = useState("form") + const [loadingSkipPayment, setLoadingSkipPayment] = useState(false) + const [user, setUser] = useState() + const router = useRouter() + + const { isLoaded, user: clerkUser } = useUser() + const [step, setStep] = useState<"submitForm" | "verifying">("submitForm") + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + ...defaultValues, + }, + }) + useEffect(() => { + const updateForm = async () => { + if (!clerkUser) return null + const email = clerkUser?.primaryEmailAddress?.emailAddress ?? "" + const name = clerkUser?.firstName ? `${clerkUser.firstName} ${clerkUser.lastName ?? ""}`.trim() : "" + const user = await Promise.resolve({ + ...defaultValues, + name: name, + email: email, + }) + + form.reset(user) + } + + updateForm().catch(console.error) + }, [clerkUser]) + if (!isLoaded) return null + + const clerk_id = clerkUser?.id + + const { getValues, setError } = form + + const utils = api.useUtils() + const createUser = api.users.create.useMutation({ + onError: (error) => { + toast({ + variant: "destructive", + title: "Failed to create database user", + description: error.message, + }) + }, + }) + const { data: cards } = api.payments.getCards.useQuery(undefined, { + enabled: !!user, + staleTime: Infinity, // this is ok because this will be the first time ever the user will fetch cards, no risk of it being out of date + }) + + const onSubmit = async (values: FormSchema) => { + if (!isLoaded) return + + if (values.github !== "") { + const { status: githubStatus } = await fetch(`https://api.github.com/users/${values.github}`) + + if (githubStatus !== 200) { + toast({ + variant: "destructive", + title: "Github username not found", + description: "The Github username not found. Please try again.", + }) + setError("github", { + type: "custom", + message: "Github username not found", + }) + return + } + } + window.scrollTo({ + top: 0, + behavior: "smooth", // smooth scrolling + }) + setStep("verifying") + if (process.env.NEXT_PUBLIC_VERCEL_ENV === "production") track("created-account") + + const userData: Omit = { + name: values.name, + preferred_name: values.preferred_name, + email: values.email, + pronouns: values.pronouns, + github: values.github, + discord: values.discord, + subscribe: values.subscribe, + } + + if (values.isUWA) { + userData.student_number = values.student_number + userData.uni = "UWA" + } else { + userData.uni = values.uni + } + + try { + if (clerkUser) + await clerkUser.update({ + firstName: values.name, // Use full name as first name + unsafeMetadata: { + preferred_name: values.preferred_name, + pronouns: values.pronouns, + github: values.github, + discord: values.discord, + subscribe: values.subscribe, + university: values.uni, + student_number: values.student_number, + }, + }) + + const user = await createUser.mutateAsync({ + clerk_id: clerk_id ?? "", + ...userData, + }) + setUser(user) + + setActiveView("payment") + } catch (error) { + console.error("Signup error", error) + toast({ + variant: "destructive", + title: "Failed to create user", + description: `An error occurred while trying to create user. ${(error as { message?: string })?.message ?? ""}`, + }) + } + } + + const handleAfterOnlinePayment = async (paymentID: string) => { + if (!user) { + toast({ + variant: "destructive", + title: "Unable to verify user", + description: "We were unable to verify if the user was created.", + }) + return + } + + user.role = "member" + try { + utils.users.getCurrent.setData(undefined, user) + await utils.users.getCurrent.invalidate() + router.push("/dashboard") + } catch (error) { + console.error(error) + toast({ + variant: "destructive", + title: "Unable to update user", + description: `We were unable to update the user. ${(error as { message?: string })?.message ?? ""}`, + }) + } + } + + const handleSkipPayment = async () => { + if (user) { + setLoadingSkipPayment(true) + try { + utils.users.getCurrent.setData(undefined, user) + await utils.users.getCurrent.invalidate() + router.push("/dashboard") + } catch (error) { + console.error(error) + toast({ + variant: "destructive", + title: "Unable to skip payment", + description: `Error occurred when trying to skip payment.${(error as { message?: string })?.message ?? ""}`, + }) + } finally { + setLoadingSkipPayment(false) + } + } + } + + return ( +
+ + mail + {activeView === "form" ? ( + step === "submitForm" ? ( + <> + New user detected! + + We couldn't find an account with that email address so you can create a new account here. If you + think it was a mistake,{" "} + + + + ) : ( + <> + Creating your account... + Thanks for your patience! We are creating your account. + + ) + ) : ( + <> + User created! + + Now you can proceed to payment or skip for now and complete later from your dashboard. + + + )} + +
+ {activeView === "form" ? ( + +
+

Personal details

+

Fields marked with * are required.

+
+ ( + + +

Email address

+

*

+
+ + + + +
+ )} + /> + ( + + +

Full name

*

+
+ + + + + We use your full name for internal committee records and official correspondence + + +
+ )} + /> + ( + + +

Preferred name

+

*

+
+ + + + This is how we normally refer to you + +
+ )} + /> + ( + + +

Pronouns

+

*

+
+ + + {PRONOUNS.map(({ label, value }) => ( + + + + + {label} + + ))} + + + + + {Boolean(PRONOUNS.find(({ value: val }) => val === field.value)) ? ( + Other + ) : ( + + + + )} + + + + +
+ )} + /> +
+ ( + + + + + I'm a UWA student + + + )} + /> + ( + + +

UWA student number

+

*

+
+ + + + +
+ )} + /> + ( + + University + + + {UNIVERSITIES.map(({ label, value }) => ( + + + + + {label} + + ))} + + + + + {Boolean(UNIVERSITIES.find(({ value: val }) => val === field.value)) ? ( + Other university + ) : ( + + + + )} + + + + + + )} + /> +
+
+
+

Socials

+

+ These fields are optional but are required if you plan on applying for projects during the winter and + summer breaks. +

+ + + {siDiscord.title} + + + Join our Discord! + + You can join our Discord server at{" "} + + + +
+ ( + + Github username + + + + + Sign up at{" "} + + + + + )} + /> + ( + + Discord username + + + + + Sign up at{" "} + + + + + )} + /> +
+ ( + + + + + I wish to receive emails about future CFC events + + + )} + /> + + + ) : ( + + )} + + {activeView === "payment" ? ( +
+
+

Payment

+
+

+ Become a paying member of Coders for Causes for just $5 a year (ends on 31st Dec{" "} + {new Date().getFullYear()}). There are many benefits to becoming a member which include: +

+
    +
  • discounts to paid events such as industry nights
  • +
  • the ability to vote and run for committee positions
  • +
  • the ability to join our projects run during the winter and summer breaks.
  • +
+
+
+ {getIsMembershipOpen() ? ( + <> + + + + Online + + + In-person + + + +

+ Our online payment system is handled by{" "} + + . We do not store your card details but we do record the information Square provides us after + confirming your card. +

+ +
+ +

+ We accept cash and card payments in-person. We use{" "} + {" "} + Point-of-Sale terminals to accept card payments. Reach out to a committee member via our Discord or + a CFC event to pay in-person. A committee member will update your status as a member on payment + confirmation. +

+ +
+
+
+
+ +
+
+ Or +
+
+
+

Skipping payment

+
+

+ You can skip payment for now but you will miss out on the benefits mentioned above until you do. You + can always pay later by going to your account dashboard. +

+
+
+ + + ) : ( +

+ Memberships are temporarily closed for the new year. Please check back later. +

+ )} +
+ ) : ( + + )} + {/*
+
+
+ + + CN + +
+
+
+
+

{getValues().name}

+

+ {getValues().email} +

+
+ +
+
*/} +
+ ) +} diff --git a/src/app/create-account/page.tsx b/src/app/create-account/page.tsx index 8ccaa6cac..a71f1295a 100644 --- a/src/app/create-account/page.tsx +++ b/src/app/create-account/page.tsx @@ -180,7 +180,7 @@ export default function CreateAccount() { setStep("enterCode") window.scrollTo({ top: 0, - behavior: "smooth", // smooth scrolling + behavior: "smooth", }) } catch (error) { console.error("Error sending OTP", error) @@ -194,7 +194,10 @@ export default function CreateAccount() { const onSubmit = async (values: FormSchema) => { if (!isLoaded) return - + window.scrollTo({ + top: 0, + behavior: "smooth", + }) setStep("verifying") if (process.env.NEXT_PUBLIC_VERCEL_ENV === "production") track("created-account") @@ -244,7 +247,7 @@ export default function CreateAccount() { toast({ variant: "destructive", title: "Failed to create user", - description: `An error occurred while trying to create user. ${(error as { message?: string })?.message ?? ""}.`, + description: `An error occurred while trying to create user. ${(error as { message?: string })?.message ?? ""}`, }) setStep("enterCode") } diff --git a/src/app/join/page.tsx b/src/app/join/page.tsx index 7fdd94216..2e41c2bc1 100644 --- a/src/app/join/page.tsx +++ b/src/app/join/page.tsx @@ -1,11 +1,14 @@ "use client" import { useSignIn } from "@clerk/nextjs" +import { SignIn } from "@clerk/nextjs" +import { type EmailLinkFactor } from "@clerk/types" import { zodResolver } from "@hookform/resolvers/zod" import { track } from "@vercel/analytics/react" import { useRouter } from "next/navigation" import { useEffect, useState } from "react" import { useForm } from "react-hook-form" +import { siGoogle } from "simple-icons" import * as z from "zod" import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert" @@ -17,6 +20,7 @@ import { toast } from "~/components/ui/use-toast" import type { ClerkError } from "~/lib/types" import { api } from "~/trpc/react" +const googleIcon = { path: siGoogle.path, title: siGoogle.title } const formSchema = z.object({ email: z .string() @@ -127,6 +131,25 @@ export default function Join() { setStep("email") } } + const handleGoogleSignIn = async () => { + if (!isLoaded) return + + try { + // Start the Google OAuth flow + await signIn.authenticateWithRedirect({ + strategy: "oauth_google", + redirectUrl: `${window.location.origin}/sso-callback`, + redirectUrlComplete: `/dashboard`, // after everything succeeds + }) + } catch (error) { + console.error("Google sign-in failed", error) + toast({ + variant: "destructive", + title: "Google sign-in failed", + description: `${(error as { message?: string })?.message ?? ""}`, + }) + } + } return (
@@ -156,24 +179,33 @@ export default function Join() { )} {step === "initial" ? ( -
- ( - - Email address - - - - - - )} - /> - + + - + ) : (
Enter one-time code from your email diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index b9e4ffda1..71e7996bd 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -10,7 +10,7 @@ import { Button } from "~/components/ui/button" export default function NotFound() { return ( -
+
Not Found | Coders for Causes
¯\_(ツ)_/¯

404: Not Found

diff --git a/src/app/profile/[id]/profile/page.tsx b/src/app/profile/[id]/page-content.tsx similarity index 97% rename from src/app/profile/[id]/profile/page.tsx rename to src/app/profile/[id]/page-content.tsx index 280acbdd2..dca52fb45 100644 --- a/src/app/profile/[id]/profile/page.tsx +++ b/src/app/profile/[id]/page-content.tsx @@ -18,7 +18,7 @@ interface ProfilePageProps { currentUser: RouterOutputs["users"]["getCurrent"] } -const ProfilePage = ({ id, currentUser }: ProfilePageProps) => { +const ProfilePageContent = ({ id, currentUser }: ProfilePageProps) => { const [isEditing, setIsEditing] = useState(false) const { data: user, refetch } = api.users.get.useQuery(id) @@ -128,4 +128,4 @@ const ProfilePage = ({ id, currentUser }: ProfilePageProps) => { return } -export default ProfilePage +export default ProfilePageContent diff --git a/src/app/profile/[id]/page.tsx b/src/app/profile/[id]/page.tsx index 9612759d5..9e23c3e56 100644 --- a/src/app/profile/[id]/page.tsx +++ b/src/app/profile/[id]/page.tsx @@ -1,13 +1,13 @@ import NotFound from "~/app/not-found" import { api } from "~/trpc/server" -import ProfilePage from "./profile/page" +import ProfilePageContent from "./page-content" const Profile = async ({ params: { id } }: { params: { id: string } }) => { const currentUser = await api.users.getCurrent.query() if (currentUser) { - return + return } return diff --git a/src/app/sso-callback/page.tsx b/src/app/sso-callback/page.tsx new file mode 100644 index 000000000..69872352c --- /dev/null +++ b/src/app/sso-callback/page.tsx @@ -0,0 +1,10 @@ +import { AuthenticateWithRedirectCallback } from "@clerk/nextjs" + +export default function Page() { + return ( + <> +
Signing you in...
+ + + ) +} diff --git a/src/middleware.ts b/src/middleware.ts index a3b66b9ad..e65162f75 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -7,30 +7,34 @@ import { User } from "./server/db/schema" const adminRoles = ["admin", "committee"] const isAdminPage = createRouteMatcher(["/dashboard/admin(.*)"]) -const isProtectedPage = createRouteMatcher(["/dashboard(.*)", "/profile/settings(.*)"]) -const isAuthPage = createRouteMatcher(["/join(.*)"]) +const isProtectedPage = createRouteMatcher(["/dashboard(.*)", "/profile(.*)"]) +const isAuthPage = createRouteMatcher(["/join(.*)", "/sso-callback(.*)"]) export default clerkMiddleware(async (auth, req) => { - const clerkId = auth().userId + const { userId, redirectToSignIn } = auth() - if (isAdminPage(req) && clerkId) { - const user = await db.query.User.findFirst({ - where: eq(User.clerk_id, clerkId), - }) - - if (!adminRoles.includes(user?.role ?? "")) { - // non-existent clerk role so we go to 404 page cleanly - auth().protect({ - role: "lmfaooo", + if (isAdminPage(req)) { + if (userId) { + const user = await db.query.User.findFirst({ + where: eq(User.clerk_id, userId), }) + + if (!adminRoles.includes(user?.role ?? "")) { + // non-existent clerk role so we go to 404 page cleanly + auth().protect({ + role: "lmfaooo", + }) + } + } else { + return redirectToSignIn() } } - if (isProtectedPage(req)) { - auth().protect() + if (isProtectedPage(req) && !userId) { + return redirectToSignIn() } - if (isAuthPage(req) && clerkId) { + if (isAuthPage(req) && userId) { return Response.redirect(new URL("/dashboard", req.url)) } }) diff --git a/src/server/api/routers/users/get.ts b/src/server/api/routers/users/get.ts index de0a28cc3..e9ce04903 100644 --- a/src/server/api/routers/users/get.ts +++ b/src/server/api/routers/users/get.ts @@ -19,3 +19,17 @@ export const get = publicRatedProcedure(Ratelimit.fixedWindow(4, "30s")) return user }) + +export const getByEmail = publicRatedProcedure(Ratelimit.fixedWindow(4, "30s")) + .input(z.string().email()) + .query(async ({ ctx, input }) => { + const user = await ctx.db.query.User.findFirst({ + where: eq(User.email, input), + }) + + if (!user) { + throw new TRPCError({ code: "NOT_FOUND", message: `User with email: ${input} does not exist` }) + } + + return user + }) diff --git a/src/server/api/routers/users/index.ts b/src/server/api/routers/users/index.ts index 27d864f8f..e535a13a0 100644 --- a/src/server/api/routers/users/index.ts +++ b/src/server/api/routers/users/index.ts @@ -4,7 +4,7 @@ import { env } from "~/env" import { createTRPCRouter } from "~/server/api/trpc" import { create } from "./create" -import { get } from "./get" +import { get, getByEmail } from "./get" import { getCurrent } from "./get-current" import { update } from "./update" import { updateSocials } from "./update-socials" @@ -15,4 +15,5 @@ export const usersRouter = createTRPCRouter({ get, update, updateSocials, + getByEmail, })