From 7cd44d07d0ae2806c6aba32a435b6f8b85340f92 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Thu, 25 Jun 2026 03:05:56 -0700 Subject: [PATCH] feat(onboarding): explicit role selection with role-aware steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the email-domain heuristic dialog with an explicit role picker (current member / alumni / guest) on the welcome step, and tailor the rest of the flow to the chosen role. Frontend: - Welcome step now gates progression on a chosen role. The "Are you a current student?" dialog is gone. - Step list is derived from the role: guests skip the academic step, alumni and guests get a new occupation step (job title + company) inserted before team gear. - SAE registration number is shown only for current members; the field is zeroed in the submit payload for other roles. - Credentials step blocks progression with a clearer dialog when member uses a non-ucsb.edu email, OR when alumni use a ucsb.edu email (those accounts expire after graduation). - Past-graduation-year check is gated on role === "member" instead of email domain. - Review step hides academic for guests and SAE for non-members, shows occupation for alumni/guests. Backend: - Add user.occupation_title and user.occupation_company columns (picked up by GORM AutoMigrate on next boot). - Forward the new fields through discord onboarding consume. - Match the frontend's role/email rules in the consume handler for defense in depth: reject member + non-ucsb.edu and alumni + ucsb.edu submissions, and gate the past-grad-year check on role. - Final username availability check before entity creation narrows the race with concurrent signups (DB unique index remains the authoritative guard, but losing the race after entity creation would orphan the entity). - Trim mentor/sponsor from validInitialRoles and DiscordRolesForInitialRole; the frontend no longer sends those values (guest → other). --- core/model/user.go | 2 + discord/api/onboarding_token.go | 43 ++++- discord/service/guild.go | 2 +- discord/service/onboarding_token.go | 24 +++ web/src/pages/onboarding/OnboardingPage.tsx | 181 +++++++++--------- .../pages/onboarding/steps/OccupationStep.tsx | 46 +++++ web/src/pages/onboarding/steps/ReviewStep.tsx | 23 ++- web/src/pages/onboarding/steps/TeamStep.tsx | 25 ++- .../pages/onboarding/steps/WelcomeStep.tsx | 77 +++++++- web/src/pages/onboarding/types.ts | 8 + 10 files changed, 307 insertions(+), 124 deletions(-) create mode 100644 web/src/pages/onboarding/steps/OccupationStep.tsx diff --git a/core/model/user.go b/core/model/user.go index 12a7ebb..9ee9eed 100644 --- a/core/model/user.go +++ b/core/model/user.go @@ -23,6 +23,8 @@ type User struct { ShirtSize string `json:"shirt_size"` JacketSize string `json:"jacket_size"` SAERegistrationNumber string `json:"sae_registration_number"` + OccupationTitle string `json:"occupation_title"` + OccupationCompany string `json:"occupation_company"` AvatarURL string `json:"avatar_url"` InitialRole string `json:"initial_role"` Groups []string `json:"groups" gorm:"-"` diff --git a/discord/api/onboarding_token.go b/discord/api/onboarding_token.go index cb232c5..2f721e2 100644 --- a/discord/api/onboarding_token.go +++ b/discord/api/onboarding_token.go @@ -58,17 +58,19 @@ type consumeRequest struct { ShirtSize string `json:"shirt_size" binding:"required"` JacketSize string `json:"jacket_size" binding:"required"` SAERegistrationNumber string `json:"sae_registration_number"` + OccupationTitle string `json:"occupation_title"` + OccupationCompany string `json:"occupation_company"` InitialRole string `json:"initial_role" binding:"required"` } var validInitialRoles = map[string]bool{ - "member": true, - "alumni": true, - "mentor": true, - "sponsor": true, - "other": true, + "member": true, + "alumni": true, + "other": true, } +const ucsbEmailDomain = "ucsb.edu" + func ConsumeOnboardingToken(c *gin.Context) { c.Header("Cache-Control", "no-store") id := c.Param("id") @@ -84,11 +86,29 @@ func ConsumeOnboardingToken(c *gin.Context) { return } - if req.GraduationYear > 0 && req.GraduationYear < time.Now().Year() { - parts := strings.SplitN(req.Email, "@", 2) - if len(parts) == 2 && strings.EqualFold(parts[1], "ucsb.edu") { + emailDomain := "" + if parts := strings.SplitN(req.Email, "@", 2); len(parts) == 2 { + emailDomain = strings.ToLower(parts[1]) + } + + switch req.InitialRole { + case "member": + if emailDomain != ucsbEmailDomain { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "current members must sign up with their @ucsb.edu email", + }) + return + } + if req.GraduationYear > 0 && req.GraduationYear < time.Now().Year() { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "current members can't have a graduation year in the past", + }) + return + } + case "alumni": + if emailDomain == ucsbEmailDomain { c.JSON(http.StatusBadRequest, gin.H{ - "error": "UCSB emails expire after graduation. Update your graduation year or use a personal email.", + "error": "alumni must sign up with a personal email since @ucsb.edu addresses expire after graduation", }) return } @@ -109,6 +129,8 @@ func ConsumeOnboardingToken(c *gin.Context) { ShirtSize: req.ShirtSize, JacketSize: req.JacketSize, SAERegistrationNumber: req.SAERegistrationNumber, + OccupationTitle: req.OccupationTitle, + OccupationCompany: req.OccupationCompany, InitialRole: req.InitialRole, }) @@ -119,6 +141,9 @@ func ConsumeOnboardingToken(c *gin.Context) { case errors.Is(err, service.ErrOnboardingTokenInvalid): c.JSON(http.StatusGone, gin.H{"error": err.Error()}) return + case errors.Is(err, service.ErrUsernameTaken): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return case err != nil: logger.SugarLogger.Errorf("Failed to consume onboarding token %s: %v", id, err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/discord/service/guild.go b/discord/service/guild.go index f485f9d..0a62683 100644 --- a/discord/service/guild.go +++ b/discord/service/guild.go @@ -51,7 +51,7 @@ func DiscordRolesForInitialRole(initialRole string) []string { return []string{config.MembersDiscordRoleID} case "alumni": return []string{config.AlumniDiscordRoleID} - case "mentor", "sponsor", "other": + case "other": return []string{config.GuestDiscordRoleID} default: return nil diff --git a/discord/service/onboarding_token.go b/discord/service/onboarding_token.go index 2edb195..6fd5247 100644 --- a/discord/service/onboarding_token.go +++ b/discord/service/onboarding_token.go @@ -3,6 +3,7 @@ package service import ( "errors" "fmt" + "net/url" "time" "github.com/gaucho-racing/sentinel/discord/config" @@ -17,6 +18,7 @@ import ( var ( ErrOnboardingTokenNotFound = errors.New("onboarding token not found") ErrOnboardingTokenInvalid = errors.New("onboarding token expired or already used") + ErrUsernameTaken = errors.New("username is already taken") ) // CreateOnboardingTokenForDiscordUser invalidates any unused tokens for the @@ -78,6 +80,8 @@ type OnboardingConsumePayload struct { ShirtSize string JacketSize string SAERegistrationNumber string + OccupationTitle string + OccupationCompany string InitialRole string } @@ -96,6 +100,24 @@ func ConsumeOnboardingToken(id string, p OnboardingConsumePayload) (string, erro return "", fmt.Errorf("invalid birthday format (want YYYY-MM-DD): %w", err) } + // Final username availability check before we create the entity. + // Narrows the race window between the live check on the form and + // the DB unique constraint on `user.username` — the constraint + // remains the authoritative safeguard, but losing the race after + // the entity exists would orphan the entity. + var usernameCheck struct { + Available bool `json:"available"` + } + if err := sentinel.Get( + fmt.Sprintf("/api/users/check-username?username=%s", url.QueryEscape(p.Username)), + &usernameCheck, + ); err != nil { + return "", fmt.Errorf("check username: %w", err) + } + if !usernameCheck.Available { + return "", ErrUsernameTaken + } + var entityResp struct { ID string `json:"id"` } @@ -118,6 +140,8 @@ func ConsumeOnboardingToken(id string, p OnboardingConsumePayload) (string, erro "shirt_size": p.ShirtSize, "jacket_size": p.JacketSize, "sae_registration_number": p.SAERegistrationNumber, + "occupation_title": p.OccupationTitle, + "occupation_company": p.OccupationCompany, "avatar_url": token.DiscordAvatarURL, "initial_role": p.InitialRole, } diff --git a/web/src/pages/onboarding/OnboardingPage.tsx b/web/src/pages/onboarding/OnboardingPage.tsx index 0bf12c0..4a147af 100644 --- a/web/src/pages/onboarding/OnboardingPage.tsx +++ b/web/src/pages/onboarding/OnboardingPage.tsx @@ -21,6 +21,7 @@ import { OnboardingProgress } from "@/pages/onboarding/OnboardingProgress" import { AcademicStep } from "@/pages/onboarding/steps/AcademicStep" import { CredentialsStep } from "@/pages/onboarding/steps/CredentialsStep" import { IdentityStep, type UsernameAvailability } from "@/pages/onboarding/steps/IdentityStep" +import { OccupationStep } from "@/pages/onboarding/steps/OccupationStep" import { PersonalStep } from "@/pages/onboarding/steps/PersonalStep" import { ReviewStep } from "@/pages/onboarding/steps/ReviewStep" import { TeamStep } from "@/pages/onboarding/steps/TeamStep" @@ -38,7 +39,6 @@ const HOLD_MS = 250 const STEP_SLIDE_PX = 24 const STUDENT_DOMAIN = "ucsb.edu" -const NON_STUDENT_ROLES = ["Alumni", "Mentor", "Sponsor", "Other"] const stepVariants: Variants = { enter: (dir: "forward" | "back") => ({ @@ -66,22 +66,28 @@ type TokenInfoResponse = { discord_avatar_url: string } -type StepId = "welcome" | "credentials" | "identity" | "personal" | "academic" | "team" | "review" - -const STEPS: StepId[] = [ - "welcome", - "credentials", - "identity", - "personal", - "academic", - "team", - "review", -] +type StepId = + | "welcome" + | "credentials" + | "identity" + | "personal" + | "academic" + | "occupation" + | "team" + | "review" + +function stepsForRole(role: OnboardingData["role"]): StepId[] { + const steps: StepId[] = ["welcome", "credentials", "identity", "personal"] + if (role !== "guest") steps.push("academic") + if (role === "alumni" || role === "guest") steps.push("occupation") + steps.push("team", "review") + return steps +} function isStepValid(step: StepId, data: OnboardingData): boolean { switch (step) { case "welcome": - return true + return data.role.length > 0 case "credentials": return ( data.email.trim().length > 0 && @@ -105,6 +111,10 @@ function isStepValid(step: StepId, data: OnboardingData): boolean { data.graduationYear.length > 0 && data.major.trim().length > 0 ) + case "occupation": + return ( + data.occupationTitle.trim().length > 0 && data.occupationCompany.trim().length > 0 + ) case "team": return data.shirtSize.length > 0 && data.jacketSize.length > 0 case "review": @@ -122,11 +132,7 @@ export default function OnboardingPage() { const [data, setData] = useState(EMPTY_ONBOARDING_DATA) const [submitting, setSubmitting] = useState(false) const [transitioning, setTransitioning] = useState(false) - const [confirmedNonStudentDomain, setConfirmedNonStudentDomain] = useState< - string | null - >(null) - const [nonStudentRole, setNonStudentRole] = useState(null) - const [studentDialogOpen, setStudentDialogOpen] = useState(false) + const [emailRoleDialogOpen, setEmailRoleDialogOpen] = useState(false) const [tokenState, setTokenState] = useState({ status: "loading" }) const [usernameAvailability, setUsernameAvailability] = useState("idle") @@ -158,17 +164,20 @@ export default function OnboardingPage() { } }, [token]) - const currentStep = STEPS[stepIndex] - const isLast = stepIndex === STEPS.length - 1 + const steps = useMemo(() => stepsForRole(data.role), [data.role]) + const currentStep = steps[Math.min(stepIndex, steps.length - 1)] + const isLast = stepIndex === steps.length - 1 const isFirst = stepIndex === 0 const canProceed = useMemo(() => isStepValid(currentStep, data), [currentStep, data]) const emailDomain = data.email.split("@")[1]?.toLowerCase() ?? "" - const needsStudentConfirm = - currentStep === "credentials" && - emailDomain.includes(".") && - emailDomain !== STUDENT_DOMAIN && - confirmedNonStudentDomain !== emailDomain + const hasFullDomain = emailDomain.includes(".") + const memberNeedsUcsbEmail = + data.role === "member" && hasFullDomain && emailDomain !== STUDENT_DOMAIN + const alumniRejectsUcsbEmail = + data.role === "alumni" && emailDomain === STUDENT_DOMAIN + const emailRoleMismatch = + currentStep === "credentials" && (memberNeedsUcsbEmail || alumniRejectsUcsbEmail) function update(patch: Partial) { setData((prev) => ({ ...prev, ...patch })) @@ -179,13 +188,6 @@ export default function OnboardingPage() { setStepIndex((i) => i + 1) } - function handleConfirmNonStudent(role: string) { - setNonStudentRole(role) - setConfirmedNonStudentDomain(emailDomain) - setStudentDialogOpen(false) - advance() - } - async function handleNext() { if (!canProceed || submitting) return @@ -202,24 +204,19 @@ export default function OnboardingPage() { return } - if (currentStep === "academic") { + if (currentStep === "academic" && data.role === "member") { const gradYear = parseInt(data.graduationYear, 10) const currentYear = new Date().getFullYear() - if ( - Number.isFinite(gradYear) && - gradYear > 0 && - gradYear < currentYear && - emailDomain === STUDENT_DOMAIN - ) { + if (Number.isFinite(gradYear) && gradYear > 0 && gradYear < currentYear) { toast.error( - "UCSB emails expire after graduation. Update your graduation year or use a personal email.", + "Current members can't have a graduation year in the past. Update your graduation year or pick Alumni on the first step.", ) return } } - if (needsStudentConfirm) { - setStudentDialogOpen(true) + if (emailRoleMismatch) { + setEmailRoleDialogOpen(true) return } @@ -228,10 +225,7 @@ export default function OnboardingPage() { return } - const initialRole = - confirmedNonStudentDomain === emailDomain && nonStudentRole - ? nonStudentRole.toLowerCase() - : "member" + const initialRole = data.role === "guest" ? "other" : data.role || "member" const payload = { email: data.email, @@ -242,12 +236,19 @@ export default function OnboardingPage() { gender: data.gender, birthday: data.birthday, phone_number: data.phoneNumber, - graduate_level: data.graduateLevel, - graduation_year: data.graduationYear ? parseInt(data.graduationYear, 10) : 0, - major: data.major, + graduate_level: data.role === "guest" ? "none" : data.graduateLevel, + graduation_year: + data.role === "guest" + ? 0 + : data.graduationYear + ? parseInt(data.graduationYear, 10) + : 0, + major: data.role === "guest" ? "" : data.major, shirt_size: data.shirtSize, jacket_size: data.jacketSize, - sae_registration_number: data.saeRegistrationNumber, + sae_registration_number: data.role === "member" ? data.saeRegistrationNumber : "", + occupation_title: data.role === "member" ? "" : data.occupationTitle, + occupation_company: data.role === "member" ? "" : data.occupationCompany, initial_role: initialRole, } @@ -306,12 +307,12 @@ export default function OnboardingPage() {

Set up your account

- Step {stepIndex + 1} of {STEPS.length} + Step {stepIndex + 1} of {steps.length}

- + - {currentStep === "welcome" && } + {currentStep === "welcome" && ( + + )} {currentStep === "credentials" && ( )} @@ -339,17 +342,15 @@ export default function OnboardingPage() { + )} + {currentStep === "occupation" && ( + )} {currentStep === "team" && } @@ -388,46 +389,42 @@ export default function OnboardingPage() { )} - +
- Are you a current student? - - You're using{" "} - @{emailDomain}{" "} - instead of @ucsb.edu. Current students should use their UCSB email so we - can verify enrollment. - + {data.role === "alumni" ? ( + <> + Use a personal email + + UCSB emails expire after graduation. Sign up with a personal email + so you keep access after your{" "} + @ucsb.edu{" "} + account is deactivated. + + + ) : ( + <> + UCSB email required + + Current members must sign up with their{" "} + @ucsb.edu email + so we can verify enrollment. You're using{" "} + @{emailDomain}. + + + )}
setStudentDialogOpen(false)} + onClick={() => setEmailRoleDialogOpen(false)} > - I'll use my UCSB email + {data.role === "alumni" ? "Use a different email" : "Use my UCSB email"} - -
-

- Or, if you're not a student, I'm a/an: -

-
- {NON_STUDENT_ROLES.map((role) => ( - - ))} -
-
diff --git a/web/src/pages/onboarding/steps/OccupationStep.tsx b/web/src/pages/onboarding/steps/OccupationStep.tsx new file mode 100644 index 0000000..edc64cc --- /dev/null +++ b/web/src/pages/onboarding/steps/OccupationStep.tsx @@ -0,0 +1,46 @@ +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import type { StepProps } from "@/pages/onboarding/types" + +type Props = StepProps & { + isAlumni?: boolean +} + +export function OccupationStep({ data, update, isAlumni }: Props) { + return ( +
+
+

Current occupation

+

+ {isAlumni + ? "Tell us what you're up to now — we love seeing where alumni land." + : "Helps the team understand who's in our network."} +

+
+ +
+
+ + update({ occupationTitle: e.target.value })} + required + /> +
+ +
+ + update({ occupationCompany: e.target.value })} + required + /> +
+
+
+ ) +} diff --git a/web/src/pages/onboarding/steps/ReviewStep.tsx b/web/src/pages/onboarding/steps/ReviewStep.tsx index 45ce849..5e5f431 100644 --- a/web/src/pages/onboarding/steps/ReviewStep.tsx +++ b/web/src/pages/onboarding/steps/ReviewStep.tsx @@ -57,16 +57,27 @@ export function ReviewStep({ data }: { data: OnboardingData }) { -
- - - -
+ {data.role !== "guest" && ( +
+ + + +
+ )} + + {data.role !== "member" && ( +
+ + +
+ )}
- + {data.role === "member" && ( + + )}
diff --git a/web/src/pages/onboarding/steps/TeamStep.tsx b/web/src/pages/onboarding/steps/TeamStep.tsx index d126151..06b9000 100644 --- a/web/src/pages/onboarding/steps/TeamStep.tsx +++ b/web/src/pages/onboarding/steps/TeamStep.tsx @@ -48,12 +48,15 @@ function SizePicker({ } export function TeamStep({ data, update }: StepProps) { + const showSae = data.role === "member" return (

Team gear

- Sizes for team apparel. SAE registration is optional and can be added later. + {showSae + ? "Sizes for team apparel. SAE registration is optional and can be added later." + : "Sizes for team apparel."}

@@ -72,15 +75,17 @@ export function TeamStep({ data, update }: StepProps) { onChange={update} /> -
- - update({ saeRegistrationNumber: e.target.value })} - /> -
+ {showSae && ( +
+ + update({ saeRegistrationNumber: e.target.value })} + /> +
+ )}
) diff --git a/web/src/pages/onboarding/steps/WelcomeStep.tsx b/web/src/pages/onboarding/steps/WelcomeStep.tsx index ee24da1..94271e9 100644 --- a/web/src/pages/onboarding/steps/WelcomeStep.tsx +++ b/web/src/pages/onboarding/steps/WelcomeStep.tsx @@ -1,12 +1,39 @@ -import { ShieldCheck } from "lucide-react" +import { GraduationCap, ShieldCheck, UserPlus, Users } from "lucide-react" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" -import type { DiscordIdentity } from "@/pages/onboarding/types" +import { cn } from "@/lib/utils" +import type { DiscordIdentity, OnboardingRole, StepProps } from "@/pages/onboarding/types" -type WelcomeStepProps = { +type WelcomeStepProps = StepProps & { identity: DiscordIdentity } +const ROLE_OPTIONS: { + value: OnboardingRole + label: string + description: string + Icon: typeof Users +}[] = [ + { + value: "member", + label: "Current member", + description: "Active student on the team", + Icon: Users, + }, + { + value: "alumni", + label: "Alumni", + description: "Graduated from the team", + Icon: GraduationCap, + }, + { + value: "guest", + label: "Guest", + description: "Mentor, sponsor, or other", + Icon: UserPlus, + }, +] + function initials(name: string) { return name .split(" ") @@ -17,14 +44,14 @@ function initials(name: string) { .toUpperCase() } -export function WelcomeStep({ identity }: WelcomeStepProps) { +export function WelcomeStep({ identity, data, update }: WelcomeStepProps) { return (

Welcome to Gaucho Racing

- You verified through Discord. We'll set up your Sentinel account in a few short - steps so you can sign in to all the team's tools. + You verified through Discord. Tell us how you'll be joining the team so we can + tailor the rest of setup.

@@ -42,6 +69,44 @@ export function WelcomeStep({ identity }: WelcomeStepProps) { Verified
+ +
+

I'm joining as a…

+
+ {ROLE_OPTIONS.map(({ value, label, description, Icon }) => { + const selected = data.role === value + return ( + + ) + })} +
+
) } diff --git a/web/src/pages/onboarding/types.ts b/web/src/pages/onboarding/types.ts index 1bf73bf..c2f765a 100644 --- a/web/src/pages/onboarding/types.ts +++ b/web/src/pages/onboarding/types.ts @@ -1,4 +1,7 @@ +export type OnboardingRole = "member" | "alumni" | "guest" + export type OnboardingData = { + role: OnboardingRole | "" email: string password: string passwordConfirm: string @@ -14,6 +17,8 @@ export type OnboardingData = { shirtSize: string jacketSize: string saeRegistrationNumber: string + occupationTitle: string + occupationCompany: string } export type DiscordIdentity = { @@ -23,6 +28,7 @@ export type DiscordIdentity = { } export const EMPTY_ONBOARDING_DATA: OnboardingData = { + role: "", email: "", password: "", passwordConfirm: "", @@ -38,6 +44,8 @@ export const EMPTY_ONBOARDING_DATA: OnboardingData = { shirtSize: "", jacketSize: "", saeRegistrationNumber: "", + occupationTitle: "", + occupationCompany: "", } export type StepProps = {