add initial setup for user

This commit is contained in:
chark1es 2025-03-13 03:26:43 -07:00
parent 48996f4d84
commit 3bda85b7bc
7 changed files with 1780 additions and 735 deletions

View file

@ -35,12 +35,6 @@ export default function UserProfileSettings({
// Use environment variables or props (fallback)
const logtoApiEndpoint = envLogtoApiEndpoint || propLogtoApiEndpoint;
// Parse the majors list from the text file and sort alphabetically
const majorsList = allMajors
.split('\n')
.filter(major => major.trim() !== '')
.sort((a, b) => a.localeCompare(b));
useEffect(() => {
const loadUserData = async () => {
try {
@ -83,6 +77,7 @@ export default function UserProfileSettings({
// Extract username from Logto data or email if not set
const defaultUsername = logtoUser.data?.username || currentUser.email?.split('@')[0] || '';
// Remove all the major matching logic and just use the server value directly
setUser(currentUser);
setFormData({
name: currentUser.name || '',
@ -334,11 +329,23 @@ export default function UserProfileSettings({
className="select select-bordered w-full"
>
<option value="">Select a major</option>
{majorsList.map((major, index) => (
{(() => {
const standardMajors = allMajors
.split('\n')
.filter(major => major.trim() !== '')
.sort((a, b) => a.localeCompare(b));
if (formData.major && !standardMajors.includes(formData.major)) {
standardMajors.push(formData.major);
standardMajors.sort((a, b) => a.localeCompare(b));
}
return standardMajors.map((major, index) => (
<option key={index} value={major}>
{major}
</option>
))}
));
})()}
</select>
</div>

View file

@ -0,0 +1,67 @@
import { useState, useEffect } from "react";
import type { ReactNode } from "react";
import { Authentication } from "../../../scripts/pocketbase/Authentication";
import type { User } from "../../../schemas/pocketbase/schema";
import FirstTimeLoginPopup from "./FirstTimeLoginPopup";
interface DashboardWrapperProps {
children: ReactNode;
logtoApiEndpoint?: string;
}
const DashboardWrapper = ({ children, logtoApiEndpoint }: DashboardWrapperProps) => {
const [showOnboarding, setShowOnboarding] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const checkUserStatus = async () => {
try {
const auth = Authentication.getInstance();
if (!auth.isAuthenticated()) {
// Not logged in, so don't show onboarding
setIsLoading(false);
return;
}
const userData = auth.getCurrentUser() as User | null;
if (userData) {
// If signed_up is explicitly false, show onboarding
setShowOnboarding(userData.signed_up === false);
}
} catch (error) {
console.error("Error checking user status:", error);
} finally {
setIsLoading(false);
}
};
checkUserStatus();
}, []);
const handleOnboardingComplete = () => {
setShowOnboarding(false);
};
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-screen">
<span className="loading loading-spinner loading-lg text-primary"></span>
</div>
);
}
return (
<>
{showOnboarding && (
<FirstTimeLoginPopup
logtoApiEndpoint={logtoApiEndpoint}
onComplete={handleOnboardingComplete}
/>
)}
{children}
</>
);
};
export default DashboardWrapper;

View file

@ -0,0 +1,56 @@
import { useState, useEffect } from "react";
import { Authentication } from "../../../scripts/pocketbase/Authentication";
import type { User } from "../../../schemas/pocketbase/schema";
import FirstTimeLoginPopup from "./FirstTimeLoginPopup";
interface FirstTimeLoginManagerProps {
logtoApiEndpoint?: string;
}
const FirstTimeLoginManager = ({ logtoApiEndpoint }: FirstTimeLoginManagerProps) => {
const [showOnboarding, setShowOnboarding] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const checkUserStatus = async () => {
try {
const auth = Authentication.getInstance();
if (!auth.isAuthenticated()) {
// Not logged in, so don't show onboarding
setIsLoading(false);
return;
}
// Using the new method to check if user has signed up
const isSignedUp = auth.isUserSignedUp();
console.log("User signed up status:", isSignedUp);
// If not signed up, show onboarding
setShowOnboarding(!isSignedUp);
} catch (error) {
console.error("Error checking user status:", error);
} finally {
setIsLoading(false);
}
};
checkUserStatus();
}, []);
const handleOnboardingComplete = () => {
setShowOnboarding(false);
};
if (isLoading || !showOnboarding) {
return null;
}
return (
<FirstTimeLoginPopup
logtoApiEndpoint={logtoApiEndpoint}
onComplete={handleOnboardingComplete}
/>
);
};
export default FirstTimeLoginManager;

View file

@ -0,0 +1,701 @@
import { useState, useEffect } from "react";
import { Icon } from "@iconify/react";
import { Update } from "../../../scripts/pocketbase/Update";
import { Authentication } from "../../../scripts/pocketbase/Authentication";
import type { User } from "../../../schemas/pocketbase/schema";
import CustomAlert from "./CustomAlert";
import { motion, AnimatePresence } from "framer-motion";
interface FirstTimeLoginPopupProps {
logtoApiEndpoint?: string;
onComplete?: () => void;
}
const ucsdMajors = [
"Aerospace Engineering",
"Aerospace Engineering Aerothermodynamics",
"Aerospace Engineering Astrodynamics and Space Applications",
"Aerospace Engineering Flight Dynamics and Controls",
"Anthropology",
"Art History/Criticism",
"Astronomy & Astrophysics",
"Biochemistry",
"Biochemistry and Cell Biology",
"Biology with Specialization in Bioinformatics",
"Bioengineering",
"Business Economics",
"Business Psychology",
"Chemical Engineering",
"Chemistry",
"Chinese Studies",
"Cinematic Arts",
"Classical Studies",
"Cognitive Science",
"Cognitive Science Clinical Aspects of Cognition",
"Cognitive Science Design and Interaction",
"Cognitive Science Language and Culture",
"Cognitive Science Machine Learning and Neural Computation",
"Cognitive Science Neuroscience",
"Cognitive and Behavioral Neuroscience",
"Communication",
"Computer Engineering",
"Computer Science",
"Computer Science Bioinformatics",
"Critical Gender Studies",
"Dance",
"Data Science",
"Ecology, Behavior and Evolution",
"Economics",
"Economics and Mathematics Joint Major",
"Economics-Public Policy",
"Education Sciences",
"Electrical Engineering",
"Electrical Engineering and Society",
"Engineering Physics",
"Environmental Chemistry",
"Environmental Systems (Earth Sciences)",
"Environmental Systems (Ecology, Behavior & Evolution)",
"Environmental Systems (Environmental Chemistry)",
"Environmental Systems (Environmental Policy)",
"Ethnic Studies",
"General Biology",
"General Physics",
"General Physics/Secondary Education",
"Geosciences",
"German Studies",
"Global Health",
"Global South Studies",
"History",
"Human Biology",
"Human Developmental Sciences",
"Human Developmental Sciences Equity and Diversity",
"Human Developmental Sciences Healthy Aging",
"Interdisciplinary Computing and the Arts",
"International Studies Anthropology",
"International Studies Economics",
"International Studies Economics (Joint BA/MIA)",
"International Studies History",
"International Studies International Business",
"International Studies International Business (Joint BA/MIA)",
"International Studies Linguistics",
"International Studies Literature",
"International Studies Philosophy",
"International Studies Political Science",
"International Studies Political Science (Joint BA/MIA)",
"International Studies Sociology",
"Italian Studies",
"Japanese Studies",
"Jewish Studies",
"Latin American Studies",
"Latin American Studies Mexico",
"Latin American Studies Migration and Border Studies",
"Linguistics",
"Linguistics Cognition and Language",
"Linguistics Language and Society",
"Linguistics Speech and Language Sciences",
"Linguistics: Language Studies",
"Literary Arts",
"Literatures in English",
"Marine Biology",
"Mathematical Biology",
"Mathematics",
"Mathematics Applied Science",
"Mathematics Computer Science",
"Mathematics Secondary Education",
"Mathematics (Applied)",
"Mechanical Engineering",
"Mechanical Engineering Controls and Robotics",
"Mechanical Engineering Fluid Mechanics and Thermal Systems",
"Mechanical Engineering Materials Science and Engineering",
"Mechanical Engineering Mechanics of Materials",
"Mechanical Engineering Renewable Energy and Environmental Flows",
"Media",
"Media Industries and Communication",
"Microbiology",
"Molecular Synthesis",
"Molecular and Cell Biology",
"Music",
"Music Humanities",
"NanoEngineering",
"Neurobiology / Physiology and Neuroscience",
"Oceanic and Atmospheric Sciences",
"Pharmacological Chemistry",
"Philosophy",
"Physics",
"Physics Astrophysics",
"Physics Biophysics",
"Physics Computational Physics",
"Physics Earth Sciences",
"Physics Materials Physics",
"Political Science",
"Political Science American Politics",
"Political Science Comparative Politics",
"Political Science Data Analytics",
"Political Science International Affairs",
"Political Science International Relations",
"Political Science Political Theory",
"Political Science Public Law",
"Political Science Public Policy",
"Political Science Race, Ethnicity, and Politics",
"Probability and Statistics",
"Psychology",
"Psychology Clinical Psychology",
"Psychology Cognitive Psychology",
"Psychology Developmental Psychology",
"Psychology Human Health",
"Psychology Sensation and Perception",
"Psychology Social Psychology",
"Public Health",
"Public Health Biostatistics",
"Public Health Climate and Environmental Sciences",
"Public Health Community Health Sciences",
"Public Health Epidemiology",
"Public Health Health Policy and Management Sciences",
"Public Health Medicine Sciences",
"Real Estate and Development",
"Russian, East European & Eurasian Studies",
"Sociology",
"Sociology American Studies",
"Sociology Culture and Communication",
"Sociology Economy and Society",
"Sociology International Studies",
"Sociology Law and Society",
"Sociology Science and Medicine",
"Sociology Social Inequality",
"Spanish Literature",
"Speculative Design",
"Structural Engineering",
"Structural Engineering Aerospace Structures",
"Structural Engineering Civil Structures",
"Structural Engineering Geotechnical Engineering",
"Structural Engineering Structural Health Monitoring/Non-Destructive Evaluation",
"Studio",
"Study of Religion",
"Theatre",
"Undeclared Humanities/Arts",
"Undeclared Physical Sciences",
"Undeclared Social Sciences",
"Urban Studies and Planning",
"World Literature and Culture",
"Other"
].sort(); // Ensure alphabetical order
// Animation variants
const overlayVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.3 } }
};
const popupVariants = {
hidden: { opacity: 0, scale: 0.9, y: 20 },
visible: {
opacity: 1,
scale: 1,
y: 0,
transition: {
type: "spring",
damping: 25,
stiffness: 300,
duration: 0.4
}
},
exit: {
opacity: 0,
scale: 0.95,
y: -10,
transition: {
duration: 0.2
}
}
};
const formItemVariants = {
hidden: { opacity: 0, y: 10 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: {
delay: i * 0.1,
duration: 0.3
}
})
};
const FirstTimeLoginPopup = ({ logtoApiEndpoint, onComplete }: FirstTimeLoginPopupProps) => {
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [successMessage, setSuccessMessage] = useState("");
// Form state
const [formData, setFormData] = useState({
name: "",
username: "",
pid: "",
member_id: "", // Optional
graduation_year: new Date().getFullYear() + 4, // Default to 4 years from now
major: ""
});
// Validation state
const [isValid, setIsValid] = useState({
name: false,
username: false,
pid: false,
graduation_year: true,
major: false
});
// Get current user data
useEffect(() => {
const fetchUserData = async () => {
try {
const auth = Authentication.getInstance();
const userData = auth.getCurrentUser() as User | null;
if (userData) {
setFormData(prev => ({
...prev,
name: userData.name || "",
username: userData.username || "",
pid: userData.pid || "",
member_id: userData.member_id || "",
graduation_year: userData.graduation_year || new Date().getFullYear() + 4,
major: userData.major || ""
}));
// Update validation state based on existing data
setIsValid({
name: !!userData.name,
username: !!userData.username,
pid: !!userData.pid,
graduation_year: true,
major: !!userData.major
});
}
} catch (error) {
console.error("Error fetching user data:", error);
setErrorMessage("Failed to load your profile information. Please try again.");
} finally {
setIsLoading(false);
}
};
fetchUserData();
}, []);
// Validate form
useEffect(() => {
setIsValid({
name: formData.name.trim().length > 0,
username: /^[a-z0-9_]{3,20}$/.test(formData.username),
pid: /^[A-Za-z]\d{8}$/.test(formData.pid),
graduation_year:
!isNaN(parseInt(formData.graduation_year.toString())) &&
parseInt(formData.graduation_year.toString()) >= new Date().getFullYear(),
major: formData.major !== "" // Check if a major is selected
});
}, [formData]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: name === "graduation_year" ? parseInt(value) : value
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMessage("");
setSuccessMessage("");
const allRequiredValid = isValid.name && isValid.username && isValid.pid && isValid.graduation_year && isValid.major;
if (!allRequiredValid) {
setErrorMessage("Please fill in all required fields with valid information.");
return;
}
setIsSaving(true);
try {
// Update PocketBase user
const auth = Authentication.getInstance();
const userId = auth.getUserId();
if (!userId) {
throw new Error("No user ID found. Please log in again.");
}
const updateInstance = Update.getInstance();
await updateInstance.updateFields("users", userId, {
name: formData.name,
username: formData.username,
pid: formData.pid,
member_id: formData.member_id || undefined,
graduation_year: formData.graduation_year,
major: formData.major,
signed_up: true // Set signed_up to true after completing onboarding
});
console.log("Saving first-time user data with signed_up=true");
// Update Logto user if endpoint is provided
if (logtoApiEndpoint) {
const accessToken = localStorage.getItem("access_token");
if (accessToken) {
const response = await fetch("/api/update-logto-user", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: formData.name,
custom_data: {
username: formData.username,
pid: formData.pid,
member_id: formData.member_id || "",
graduation_year: formData.graduation_year,
major: formData.major,
signed_up: true
}
}),
});
if (!response.ok) {
throw new Error("Failed to update Logto user data");
}
}
}
console.log("Successfully updated PocketBase user with signed_up=true");
if (logtoApiEndpoint) {
console.log("Successfully updated Logto user profile with signed_up=true");
}
setSuccessMessage("Profile information saved successfully!");
// Call onComplete callback if provided
if (onComplete) {
setTimeout(() => {
onComplete();
}, 1500); // Show success message briefly before completing
}
} catch (error) {
console.error("Error saving user data:", error);
// Check if the error might be related to username uniqueness
const errorMsg = error instanceof Error ? error.message : "Unknown error";
if (errorMsg.toLowerCase().includes("username") || errorMsg.toLowerCase().includes("unique")) {
setErrorMessage("Failed to save your profile information. The username you chose might already be taken. Please try a different username.");
} else {
setErrorMessage("Failed to save your profile information. Please try again.");
}
} finally {
setIsSaving(false);
}
};
// Check if form can be submitted (all required fields valid)
const canSubmit = isValid.name && isValid.username && isValid.pid && isValid.graduation_year && isValid.major;
return (
<AnimatePresence>
<motion.div
className="fixed inset-0 z-50 overflow-y-auto bg-black bg-opacity-50 flex items-center justify-center p-4"
initial="hidden"
animate="visible"
exit="hidden"
variants={overlayVariants}
>
<motion.div
className="bg-base-100 shadow-xl rounded-xl max-w-2xl w-full"
variants={popupVariants}
initial="hidden"
animate="visible"
exit="exit"
>
<div className="p-6">
<div className="mb-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">Complete Your Profile</h2>
<div className="badge badge-primary p-3">
<Icon icon="heroicons:user" className="h-5 w-5" />
</div>
</div>
<p className="opacity-70 mt-2">
Welcome to IEEE UCSD! Please complete your profile to continue.
</p>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<CustomAlert
type="info"
title="Profile Setup Required"
message="You need to complete this information before you can access the dashboard. All fields marked with * are required."
className="mt-4"
/>
</motion.div>
</div>
{isLoading ? (
<div className="flex justify-center py-8">
<motion.span
className="loading loading-spinner loading-lg text-primary"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
/>
</div>
) : (
<form onSubmit={handleSubmit}>
<div className="space-y-6">
{/* Name Field */}
<motion.div
className="form-control"
custom={0}
variants={formItemVariants}
initial="hidden"
animate="visible"
>
<label className="label">
<span className="label-text font-medium">Full Name <span className="text-error">*</span></span>
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleInputChange}
placeholder="Enter your full name"
className={`input input-bordered w-full ${!isValid.name && formData.name ? 'input-error' : ''}`}
required
/>
{!isValid.name && formData.name && (
<label className="label">
<span className="label-text-alt text-error">Please enter your full name</span>
</label>
)}
</motion.div>
{/* Username Field */}
<motion.div
className="form-control"
custom={1}
variants={formItemVariants}
initial="hidden"
animate="visible"
>
<label className="label">
<span className="label-text font-medium">Username <span className="text-error">*</span></span>
</label>
<div className="relative">
<input
type="text"
name="username"
value={formData.username}
onChange={handleInputChange}
placeholder="your_username"
className={`input input-bordered w-full ${!isValid.username && formData.username ? 'input-error' : ''}`}
required
/>
</div>
{!isValid.username && formData.username && (
<label className="label">
<span className="label-text-alt text-error">Username must be 3-20 characters, lowercase letters, numbers, and underscores only</span>
</label>
)}
<label className="label">
<span className="label-text-alt opacity-70">Choose a unique username for your IEEEUCSD SSO account. This only impacts your SSO login</span>
</label>
</motion.div>
{/* PID Field */}
<motion.div
className="form-control"
custom={2}
variants={formItemVariants}
initial="hidden"
animate="visible"
>
<label className="label">
<span className="label-text font-medium">PID <span className="text-error">*</span></span>
</label>
<input
type="text"
name="pid"
value={formData.pid}
onChange={handleInputChange}
placeholder="A12345678"
className={`input input-bordered w-full ${!isValid.pid && formData.pid ? 'input-error' : ''}`}
required
/>
{!isValid.pid && formData.pid && (
<label className="label">
<span className="label-text-alt text-error">PID must be in format A12345678</span>
</label>
)}
</motion.div>
{/* Member ID Field (Optional) */}
<motion.div
className="form-control"
custom={3}
variants={formItemVariants}
initial="hidden"
animate="visible"
>
<label className="label">
<span className="label-text font-medium">IEEE Member ID <span className="text-opacity-50">(optional)</span></span>
</label>
<input
type="text"
name="member_id"
value={formData.member_id}
onChange={handleInputChange}
placeholder="Your IEEE member ID (if you have one)"
className="input input-bordered w-full"
/>
</motion.div>
{/* Graduation Year Field */}
<motion.div
className="form-control"
custom={4}
variants={formItemVariants}
initial="hidden"
animate="visible"
>
<label className="label">
<span className="label-text font-medium">Expected Graduation Year <span className="text-error">*</span></span>
</label>
<select
name="graduation_year"
value={formData.graduation_year}
onChange={handleInputChange}
className={`select select-bordered w-full ${!isValid.graduation_year ? 'select-error' : ''}`}
required
>
{Array.from({ length: 10 }, (_, i) => {
const year = new Date().getFullYear() + i;
return (
<option key={year} value={year}>
{year}
</option>
);
})}
</select>
{!isValid.graduation_year && (
<label className="label">
<span className="label-text-alt text-error">Please select a valid graduation year</span>
</label>
)}
</motion.div>
{/* Major Field */}
<motion.div
className="form-control"
custom={5}
variants={formItemVariants}
initial="hidden"
animate="visible"
>
<label className="label">
<span className="label-text font-medium">Major <span className="text-error">*</span></span>
</label>
<select
name="major"
value={formData.major}
onChange={handleInputChange}
className={`select select-bordered w-full ${!isValid.major && formData.major ? 'select-error' : ''}`}
required
>
<option value="" disabled>Select your major</option>
{ucsdMajors.map(major => (
<option key={major} value={major}>
{major}
</option>
))}
</select>
{!isValid.major && formData.major && (
<label className="label">
<span className="label-text-alt text-error">Please select your major</span>
</label>
)}
</motion.div>
{/* Error/Success Messages */}
<AnimatePresence>
{errorMessage && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<CustomAlert
type="error"
title="Error Saving Profile"
message={errorMessage}
icon="heroicons:exclamation-circle"
className="mt-4"
/>
</motion.div>
)}
{successMessage && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<CustomAlert
type="success"
title="Profile Saved"
message={successMessage}
icon="heroicons:check-circle"
className="mt-4"
/>
</motion.div>
)}
</AnimatePresence>
{/* Submit Button */}
<motion.div
className="form-control mt-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6, duration: 0.3 }}
>
<button
type="submit"
className="btn btn-primary"
disabled={!canSubmit || isSaving}
>
{isSaving ? (
<>
<span className="loading loading-spinner loading-sm"></span>
Saving...
</>
) : (
"Complete Profile"
)}
</button>
<p className="text-xs text-center mt-3 opacity-70">
<span className="text-error">*</span> Required fields
</p>
</motion.div>
</div>
</form>
)}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
};
export default FirstTimeLoginPopup;

View file

@ -10,9 +10,13 @@ import { hasAccess, type OfficerStatus } from "../utils/roleAccess";
import { OfficerTypes } from "../schemas/pocketbase/schema";
import { initAuthSync } from "../scripts/database/initAuthSync";
import ToastProvider from "../components/dashboard/universal/ToastProvider";
import FirstTimeLoginManager from "../components/dashboard/universal/FirstTimeLoginManager";
const title = "Dashboard";
// Load environment variables
const logtoApiEndpoint = import.meta.env.LOGTO_API_ENDPOINT || "";
// Load and parse dashboard config
const configPath = path.join(process.cwd(), "src", "config", "dashboard.yaml");
const dashboardConfig = yaml.load(fs.readFileSync(configPath, "utf8")) as any;
@ -28,8 +32,8 @@ const components = Object.fromEntries(
);
// console.log(`Loaded component: ${section.component}`); // Debug log
return [section.component, component.default];
}),
),
})
)
);
// console.log("Available components:", Object.keys(components)); // Debug log
@ -47,6 +51,12 @@ const components = Object.fromEntries(
></script>
</head>
<body class="bg-base-200">
<!-- First Time Login Manager - This handles the onboarding popup for new users -->
<FirstTimeLoginManager
client:load
logtoApiEndpoint={logtoApiEndpoint}
/>
<div class="flex h-screen">
<!-- Sidebar -->
<aside
@ -65,19 +75,33 @@ const components = Object.fromEntries(
<!-- User Profile -->
<div class="p-6 border-b border-base-200">
<!-- Loading State -->
<div id="userProfileSkeleton" class="flex items-center gap-4">
<div
id="userProfileSkeleton"
class="flex items-center gap-4"
>
<div class="avatar flex items-center justify-center">
<div class="w-12 h-12 rounded-xl bg-base-300 animate-pulse"></div>
<div
class="w-12 h-12 rounded-xl bg-base-300 animate-pulse"
>
</div>
</div>
<div class="flex-1">
<div class="h-6 w-32 bg-base-300 animate-pulse rounded mb-2">
<div
class="h-6 w-32 bg-base-300 animate-pulse rounded mb-2"
>
</div>
<div
class="h-5 w-20 bg-base-300 animate-pulse rounded"
>
</div>
<div class="h-5 w-20 bg-base-300 animate-pulse rounded"></div>
</div>
</div>
<!-- Signed Out State -->
<div id="userProfileSignedOut" class="flex items-center gap-4 hidden">
<div
id="userProfileSignedOut"
class="flex items-center gap-4 hidden"
>
<div class="avatar flex items-center justify-center">
<div
class="w-12 h-12 rounded-xl bg-base-300 text-base-content/30 flex items-center justify-center"
@ -86,15 +110,22 @@ const components = Object.fromEntries(
</div>
</div>
<div>
<h3 class="font-medium text-lg text-base-content/70">
<h3
class="font-medium text-lg text-base-content/70"
>
Signed Out
</h3>
<div class="badge badge-outline mt-1 opacity-50">Guest</div>
<div class="badge badge-outline mt-1 opacity-50">
Guest
</div>
</div>
</div>
<!-- Actual Profile -->
<div id="userProfileSummary" class="flex items-center gap-4 hidden">
<div
id="userProfileSummary"
class="flex items-center gap-4 hidden"
>
<div class="avatar flex items-center justify-center">
<div
class="w-12 h-12 rounded-xl bg-[#06659d] text-white ring ring-base-200 ring-offset-base-100 ring-offset-2 inline-flex items-center justify-center"
@ -106,7 +137,9 @@ const components = Object.fromEntries(
</div>
</div>
<div>
<h3 class="font-medium text-lg" id="userName">Loading...</h3>
<h3 class="font-medium text-lg" id="userName">
Loading...
</h3>
<div
class="badge badge-outline mt-1 border-[#06659d] text-[#06659d]"
id="userRole"
@ -145,41 +178,64 @@ const components = Object.fromEntries(
<div id="actualMenu" class="hidden">
{
Object.entries(dashboardConfig.categories).map(
([categoryKey, category]: [string, any]) => (
([categoryKey, category]: [
string,
any,
]) => (
<>
<li
class={`menu-title font-medium opacity-70 ${
category.role && category.role !== "none"
category.role &&
category.role !== "none"
? "hidden"
: ""
}`}
data-role-required={category.role || "none"}
data-role-required={
category.role || "none"
}
>
<span>{category.title}</span>
</li>
{category.sections.map((sectionKey: string) => {
const section = dashboardConfig.sections[sectionKey];
{category.sections.map(
(sectionKey: string) => {
const section =
dashboardConfig
.sections[
sectionKey
];
return (
<li
class={
section.role && section.role !== "none"
section.role &&
section.role !==
"none"
? "hidden"
: ""
}
data-role-required={section.role}
data-role-required={
section.role
}
>
<button
class={`dashboard-nav-btn gap-4 transition-all duration-200 outline-none focus:outline-none hover:bg-opacity-5 ${section.class || ""}`}
data-section={sectionKey}
data-section={
sectionKey
}
>
<Icon name={section.icon} class="h-5 w-5" />
<Icon
name={
section.icon
}
class="h-5 w-5"
/>
{section.title}
</button>
</li>
);
})}
}
)}
</>
),
)
)
}
</div>
@ -192,10 +248,15 @@ const components = Object.fromEntries(
class="flex-1 overflow-x-hidden overflow-y-auto bg-base-200 w-full xl:w-[calc(100%-20rem)]"
>
<!-- Mobile Header -->
<header class="bg-base-100 p-4 shadow-md xl:hidden sticky top-0 z-40">
<header
class="bg-base-100 p-4 shadow-md xl:hidden sticky top-0 z-40"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<button id="mobileSidebarToggle" class="btn btn-square btn-ghost">
<button
id="mobileSidebarToggle"
class="btn btn-square btn-ghost"
>
<Icon name="heroicons:bars-3" class="h-6 w-6" />
</button>
<h1 class="text-xl font-bold">IEEE UCSD</h1>
@ -207,8 +268,11 @@ const components = Object.fromEntries(
<div class="p-4 md:p-6 max-w-[1600px] mx-auto">
<!-- Loading State -->
<div id="pageLoadingState" class="w-full">
<div class="flex flex-col items-center justify-center p-4 sm:p-8">
<div class="loading loading-spinner loading-lg"></div>
<div
class="flex flex-col items-center justify-center p-4 sm:p-8"
>
<div class="loading loading-spinner loading-lg">
</div>
<p class="mt-4 opacity-70">Loading dashboard...</p>
</div>
</div>
@ -234,7 +298,9 @@ const components = Object.fromEntries(
<!-- Not Authenticated State -->
<div id="notAuthenticatedState" class="hidden w-full">
<div class="card bg-base-100 shadow-xl mx-2 sm:mx-0">
<div class="card-body items-center text-center p-4 sm:p-8">
<div
class="card-body items-center text-center p-4 sm:p-8"
>
<div class="mb-4 sm:mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -251,9 +317,11 @@ const components = Object.fromEntries(
<h2 class="card-title text-xl sm:text-2xl mb-2">
Sign in to Access Dashboard
</h2>
<p class="opacity-70 mb-4 sm:mb-6 text-sm sm:text-base">
Please sign in with your IEEE UCSD account to access the
dashboard.
<p
class="opacity-70 mb-4 sm:mb-6 text-sm sm:text-base"
>
Please sign in with your IEEE UCSD account
to access the dashboard.
</p>
<button
class="login-button btn btn-primary btn-lg gap-2 w-full sm:w-auto"
@ -285,12 +353,14 @@ const components = Object.fromEntries(
// Skip if no component is defined
if (!section.component) return null;
const Component = components[section.component];
const Component =
components[section.component];
return (
<div
id={`${sectionKey}Section`}
class={`dashboard-section hidden ${
section.role && section.role !== "none"
section.role &&
section.role !== "none"
? "role-restricted"
: ""
}`}
@ -299,7 +369,7 @@ const components = Object.fromEntries(
<Component />
</div>
);
},
}
)
}
</div>
@ -310,6 +380,24 @@ const components = Object.fromEntries(
<!-- Centralized Toast Provider -->
<ToastProvider client:load />
<!-- Only include in development mode -->
{
import.meta.env.DEV && (
<script>
import {(checkUserSignUpStatus, resetUserSignUpStatus)} from
'../scripts/checkFirstTimeUsers'; // Expose testing
utilities to window for development testing
window.checkUserSignUpStatus = checkUserSignUpStatus;
window.resetUserSignUpStatus = resetUserSignUpStatus;
console.log("First-time login test utilities loaded.");
console.log("Use window.checkUserSignUpStatus() to check the
current signed_up status"); console.log("Use
window.resetUserSignUpStatus() to reset the signed_up status
to false for testing");
</script>
)
}
<script>
import { Authentication } from "../scripts/pocketbase/Authentication";
import { Get } from "../scripts/pocketbase/Get";
@ -328,10 +416,11 @@ const components = Object.fromEntries(
}
// Initialize page state
const pageLoadingState = document.getElementById("pageLoadingState");
const pageLoadingState =
document.getElementById("pageLoadingState");
const pageErrorState = document.getElementById("pageErrorState");
const notAuthenticatedState = document.getElementById(
"notAuthenticatedState",
"notAuthenticatedState"
);
const mainContent = document.getElementById("mainContent");
const sidebar = document.querySelector("aside");
@ -362,9 +451,11 @@ const components = Object.fromEntries(
}
// For non-sponsor roles, handle normally
document.querySelectorAll("[data-role-required]").forEach((element) => {
document
.querySelectorAll("[data-role-required]")
.forEach((element) => {
const requiredRole = element.getAttribute(
"data-role-required",
"data-role-required"
) as OfficerStatus;
// Skip elements that don't have a role requirement
@ -374,7 +465,10 @@ const components = Object.fromEntries(
}
// Check if user has permission for this role
const hasPermission = hasAccess(officerStatus, requiredRole);
const hasPermission = hasAccess(
officerStatus,
requiredRole
);
// Only show elements if user has permission
element.classList.toggle("hidden", !hasPermission);
@ -383,8 +477,10 @@ const components = Object.fromEntries(
// Handle navigation
const handleNavigation = () => {
const navButtons = document.querySelectorAll(".dashboard-nav-btn");
const sections = document.querySelectorAll(".dashboard-section");
const navButtons =
document.querySelectorAll(".dashboard-nav-btn");
const sections =
document.querySelectorAll(".dashboard-section");
const mainContentDiv = document.getElementById("mainContent");
// Ensure mainContent is visible
@ -418,7 +514,8 @@ const components = Object.fromEntries(
// Show selected section
const sectionId = `${sectionKey}Section`;
const targetSection = document.getElementById(sectionId);
const targetSection =
document.getElementById(sectionId);
if (targetSection) {
targetSection.classList.remove("hidden");
// console.log(`Showing section: ${sectionId}`); // Debug log
@ -428,7 +525,8 @@ const components = Object.fromEntries(
if (window.innerWidth < 1024 && sidebar) {
sidebar.classList.add("-translate-x-full");
document.body.classList.remove("overflow-hidden");
const overlay = document.getElementById("sidebarOverlay");
const overlay =
document.getElementById("sidebarOverlay");
overlay?.remove();
}
});
@ -452,6 +550,7 @@ const components = Object.fromEntries(
"member_id",
"graduation_year",
"major",
"signed_up",
],
});
@ -472,7 +571,7 @@ const components = Object.fromEntries(
"",
{
fields: ["id", "type", "role"],
},
}
);
if (officerRecords && officerRecords.items.length > 0) {
@ -514,7 +613,7 @@ const components = Object.fromEntries(
"",
{
fields: ["id", "company"],
},
}
);
if (sponsorRecords && sponsorRecords.items.length > 0) {
@ -548,7 +647,8 @@ const components = Object.fromEntries(
if (userName) userName.textContent = fallbackValues.name;
if (userRole) userRole.textContent = fallbackValues.role;
if (userInitials) userInitials.textContent = fallbackValues.initials;
if (userInitials)
userInitials.textContent = fallbackValues.initials;
updateSectionVisibility("" as OfficerStatus);
}
@ -556,16 +656,18 @@ const components = Object.fromEntries(
// Mobile sidebar toggle
const mobileSidebarToggle = document.getElementById(
"mobileSidebarToggle",
"mobileSidebarToggle"
);
if (mobileSidebarToggle && sidebar) {
const toggleSidebar = () => {
const isOpen = !sidebar.classList.contains("-translate-x-full");
const isOpen =
!sidebar.classList.contains("-translate-x-full");
if (isOpen) {
sidebar.classList.add("-translate-x-full");
document.body.classList.remove("overflow-hidden");
const overlay = document.getElementById("sidebarOverlay");
const overlay =
document.getElementById("sidebarOverlay");
overlay?.remove();
} else {
sidebar.classList.remove("-translate-x-full");
@ -600,7 +702,8 @@ const components = Object.fromEntries(
window.toast = originalToast;
// console.log("User not authenticated");
if (pageLoadingState) pageLoadingState.classList.add("hidden");
if (pageLoadingState)
pageLoadingState.classList.add("hidden");
if (notAuthenticatedState)
notAuthenticatedState.classList.remove("hidden");
return;
@ -609,28 +712,30 @@ const components = Object.fromEntries(
// Initialize auth sync for IndexedDB (for authenticated users)
await initAuthSync();
if (pageLoadingState) pageLoadingState.classList.remove("hidden");
if (pageLoadingState)
pageLoadingState.classList.remove("hidden");
if (pageErrorState) pageErrorState.classList.add("hidden");
if (notAuthenticatedState)
notAuthenticatedState.classList.add("hidden");
// Show loading states
const userProfileSkeleton = document.getElementById(
"userProfileSkeleton",
"userProfileSkeleton"
);
const userProfileSignedOut = document.getElementById(
"userProfileSignedOut",
"userProfileSignedOut"
);
const userProfileSummary =
document.getElementById("userProfileSummary");
const menuLoadingSkeleton = document.getElementById(
"menuLoadingSkeleton",
"menuLoadingSkeleton"
);
const actualMenu = document.getElementById("actualMenu");
if (userProfileSkeleton)
userProfileSkeleton.classList.remove("hidden");
if (userProfileSummary) userProfileSummary.classList.add("hidden");
if (userProfileSummary)
userProfileSummary.classList.add("hidden");
if (userProfileSignedOut)
userProfileSignedOut.classList.add("hidden");
if (menuLoadingSkeleton)
@ -641,11 +746,15 @@ const components = Object.fromEntries(
await updateUserProfile(user);
// Show actual profile and hide skeleton
if (userProfileSkeleton) userProfileSkeleton.classList.add("hidden");
if (userProfileSummary) userProfileSummary.classList.remove("hidden");
if (userProfileSkeleton)
userProfileSkeleton.classList.add("hidden");
if (userProfileSummary)
userProfileSummary.classList.remove("hidden");
// Hide all sections first
document.querySelectorAll(".dashboard-section").forEach((section) => {
document
.querySelectorAll(".dashboard-section")
.forEach((section) => {
section.classList.add("hidden");
});
@ -662,7 +771,7 @@ const components = Object.fromEntries(
"",
{
fields: ["id", "type", "role"],
},
}
);
if (officerRecords && officerRecords.items.length > 0) {
@ -700,17 +809,23 @@ const components = Object.fromEntries(
"",
{
fields: ["id", "company"],
},
}
);
if (sponsorRecords && sponsorRecords.items.length > 0) {
if (
sponsorRecords &&
sponsorRecords.items.length > 0
) {
officerStatus = "sponsor";
} else {
officerStatus = "none";
}
}
} catch (error) {
console.error("Error determining officer status:", error);
console.error(
"Error determining officer status:",
error
);
officerStatus = "none";
}
@ -721,14 +836,19 @@ const components = Object.fromEntries(
// Only sponsors get a different default view
if (officerStatus === "sponsor") {
// For sponsors, show the sponsor dashboard
defaultSection = document.getElementById("sponsorDashboardSection");
defaultSection = document.getElementById(
"sponsorDashboardSection"
);
defaultButton = document.querySelector(
'[data-section="sponsorDashboard"]',
'[data-section="sponsorDashboard"]'
);
} else {
// For all other users (including administrators), show the profile section
defaultSection = document.getElementById("profileSection");
defaultButton = document.querySelector('[data-section="profile"]');
defaultSection =
document.getElementById("profileSection");
defaultButton = document.querySelector(
'[data-section="profile"]'
);
// Log the default section for debugging
// console.log(`Setting default section to profile for user with role: ${officerStatus}`);
@ -745,16 +865,20 @@ const components = Object.fromEntries(
handleNavigation();
// Show actual menu and hide skeleton
if (menuLoadingSkeleton) menuLoadingSkeleton.classList.add("hidden");
if (menuLoadingSkeleton)
menuLoadingSkeleton.classList.add("hidden");
if (actualMenu) actualMenu.classList.remove("hidden");
// Show main content and hide loading
if (mainContent) mainContent.classList.remove("hidden");
if (pageLoadingState) pageLoadingState.classList.add("hidden");
if (pageLoadingState)
pageLoadingState.classList.add("hidden");
} catch (error) {
console.error("Error initializing dashboard:", error);
if (pageLoadingState) pageLoadingState.classList.add("hidden");
if (pageErrorState) pageErrorState.classList.remove("hidden");
if (pageLoadingState)
pageLoadingState.classList.add("hidden");
if (pageErrorState)
pageErrorState.classList.remove("hidden");
}
};
@ -766,19 +890,24 @@ const components = Object.fromEntries(
.querySelector(".login-button")
?.addEventListener("click", async () => {
try {
if (pageLoadingState) pageLoadingState.classList.remove("hidden");
if (pageLoadingState)
pageLoadingState.classList.remove("hidden");
if (notAuthenticatedState)
notAuthenticatedState.classList.add("hidden");
await auth.login();
} catch (error) {
console.error("Login error:", error);
if (pageLoadingState) pageLoadingState.classList.add("hidden");
if (pageErrorState) pageErrorState.classList.remove("hidden");
if (pageLoadingState)
pageLoadingState.classList.add("hidden");
if (pageErrorState)
pageErrorState.classList.remove("hidden");
}
});
// Handle logout button click
document.getElementById("logoutButton")?.addEventListener("click", () => {
document
.getElementById("logoutButton")
?.addEventListener("click", () => {
auth.logout();
window.location.reload();
});
@ -791,7 +920,8 @@ const components = Object.fromEntries(
window.addEventListener("resize", () => {
if (window.innerWidth >= 1024) {
const overlay = document.getElementById("sidebarOverlay");
const overlay =
document.getElementById("sidebarOverlay");
if (overlay) {
overlay.remove();
document.body.classList.remove("overflow-hidden");

View file

@ -0,0 +1,70 @@
/**
* Utility script to check and set the signed_up field for users
* This can be run manually to test the first-time login functionality
*/
import { Get } from './pocketbase/Get';
import { Update } from './pocketbase/Update';
import { Authentication } from './pocketbase/Authentication';
// This script can be imported and executed in the browser console
// to manually test the first-time login functionality
export async function checkUserSignUpStatus() {
const auth = Authentication.getInstance();
if (!auth.isAuthenticated()) {
console.log("User is not authenticated");
return false;
}
const user = auth.getCurrentUser();
if (!user) {
console.log("No current user found");
return false;
}
console.log("Current user:", {
id: user.id,
name: user.name || 'Not set',
signed_up: user.signed_up
});
return user.signed_up;
}
export async function resetUserSignUpStatus() {
const auth = Authentication.getInstance();
if (!auth.isAuthenticated()) {
console.log("User is not authenticated");
return false;
}
const user = auth.getCurrentUser();
if (!user) {
console.log("No current user found");
return false;
}
try {
const update = Update.getInstance();
await update.updateFields("users", user.id, {
signed_up: false
});
console.log("User signed_up status reset to false");
console.log("Refresh the page to see the onboarding popup");
return true;
} catch (error) {
console.error("Error resetting user signed_up status:", error);
return false;
}
}
// Export for use in browser console
if (typeof window !== 'undefined') {
(window as any).checkUserSignUpStatus = checkUserSignUpStatus;
(window as any).resetUserSignUpStatus = resetUserSignUpStatus;
}

View file

@ -202,4 +202,18 @@ export class Authentication {
console.error("Failed to initialize AuthSyncService:", error);
}
}
/**
* Check if the current user has completed the initial sign-up process
* This is used to determine if the onboarding process should be shown
* @returns boolean indicating if the user has signed up or not
*/
public isUserSignedUp(): boolean {
const user = this.getCurrentUser();
if (!user) return false;
// If the signed_up field is explicitly set to false, return false
// Otherwise, if it's undefined or true, return true
return user.signed_up !== false;
}
}