fix: remove RequireAuth render-time loading spinner causing SSR/hydration mismatch

The loading spinner in RequireAuth caused a hydration error: on SSR the session was available so children rendered, but during client hydration session.loading was true so the spinner rendered instead, causing DOM mismatch (null nextSibling).

Also includes role resolution priority fixes from previous session:
- prefer preferredRole when backendRole is JOB_SEEKER but preferredRole is not
- pass role via URL param to dashboard
- urlRoleLocked signal prevents auth effects from overriding URL role
This commit is contained in:
Tracewebstudio Dev 2026-04-21 23:56:19 +02:00
parent 159b051ac8
commit 1990b5c99d
4 changed files with 274 additions and 40 deletions

View file

@ -40,6 +40,10 @@ function normalizeRoleValue(value: unknown): string {
return String(value || '').trim().toUpperCase().replace(/\s+/g, '_'); return String(value || '').trim().toUpperCase().replace(/\s+/g, '_');
} }
function isJobSeekerRole(roleKey: string): boolean {
return normalizeRoleValue(roleKey) === 'JOB_SEEKER';
}
function getStoredPreferredRole(emailHint?: string): string | null { function getStoredPreferredRole(emailHint?: string): string | null {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
const keys = ['nxtgauge_signup_profile_v1', 'nxtgauge_auth_user', 'nxtgauge_user']; const keys = ['nxtgauge_signup_profile_v1', 'nxtgauge_auth_user', 'nxtgauge_user'];
@ -65,10 +69,13 @@ function getStoredPreferredRole(emailHint?: string): string | null {
function resolveActiveRole(rawBackendRole: unknown, emailHint?: string): string { function resolveActiveRole(rawBackendRole: unknown, emailHint?: string): string {
const backendRole = normalizeRoleValue(rawBackendRole); const backendRole = normalizeRoleValue(rawBackendRole);
if (backendRole) return backendRole;
const preferredRole = getStoredPreferredRole(emailHint); const preferredRole = getStoredPreferredRole(emailHint);
if (backendRole && preferredRole && isJobSeekerRole(backendRole) && !isJobSeekerRole(preferredRole)) {
return preferredRole;
}
if (backendRole) return backendRole;
if (preferredRole) return preferredRole; if (preferredRole) return preferredRole;
return preferredRole || 'JOB_SEEKER'; return '';
} }
async function fetchSession(): Promise<AuthUser | null> { async function fetchSession(): Promise<AuthUser | null> {
@ -144,14 +151,16 @@ export function useAuth() {
export function RequireAuth(props: ParentProps<{ fallback?: string }>) { export function RequireAuth(props: ParentProps<{ fallback?: string }>) {
const navigate = useNavigate(); const navigate = useNavigate();
const auth = useAuth(); const auth = useAuth();
const fallback = props.fallback || '/login';
if (auth.isLoading()) { createEffect(() => {
return <div class="flex min-h-screen items-center justify-center text-[#6B7280]">Loading...</div>; if (typeof window === 'undefined') return;
} if (!auth.isAuthenticated()) {
navigate(fallback, { replace: true });
}
});
if (!auth.isAuthenticated()) { if (!auth.isAuthenticated()) {
const fallback = props.fallback || '/login';
navigate(fallback, { replace: true });
return null; return null;
} }

View file

@ -9,7 +9,7 @@ import {
onMount, onMount,
} from "solid-js"; } from "solid-js";
import { useNavigate } from "@solidjs/router"; import { useNavigate } from "@solidjs/router";
import { useAuth, RequireAuth } from "~/lib/auth"; import { useAuth, RequireAuth, getToken } from "~/lib/auth";
import DashboardDesignPreview from "~/components/admin/DashboardDesignPreview"; import DashboardDesignPreview from "~/components/admin/DashboardDesignPreview";
import DashboardShell from "~/components/DashboardShell"; import DashboardShell from "~/components/DashboardShell";
import ProfilePage from "~/components/dashboard/ProfilePage"; import ProfilePage from "~/components/dashboard/ProfilePage";
@ -317,6 +317,25 @@ function getNameFromStorage(): string {
return "User"; return "User";
} }
function getEmailFromStorage(): string {
if (typeof window === "undefined") return "";
const keys = ["nxtgauge_signup_profile_v1", "nxtgauge_auth_user", "nxtgauge_user"];
for (const key of keys) {
try {
const raw = window.localStorage.getItem(key) || window.sessionStorage.getItem(key);
if (!raw) continue;
const parsed = JSON.parse(raw);
const email = String(parsed?.email || parsed?.user?.email || "")
.trim()
.toLowerCase();
if (email) return email;
} catch {
// ignore invalid payload
}
}
return "";
}
const EXPLORE_ROLES = [ const EXPLORE_ROLES = [
{ key: "PHOTOGRAPHER", name: "Photographer" }, { key: "PHOTOGRAPHER", name: "Photographer" },
{ key: "MAKEUP_ARTIST", name: "Makeup Artist" }, { key: "MAKEUP_ARTIST", name: "Makeup Artist" },
@ -369,6 +388,14 @@ function asStringArray(value: unknown): string[] {
return value.map((item) => String(item || "").trim()).filter(Boolean); return value.map((item) => String(item || "").trim()).filter(Boolean);
} }
function firstNonJobSeekerRole(roleKeys: string[]): RoleKey | null {
for (const roleKey of roleKeys) {
const normalized = normalizeRole(roleKey);
if (normalized !== "JOB_SEEKER") return normalized;
}
return null;
}
function getInitialRoleFromStorage(): RoleKey { function getInitialRoleFromStorage(): RoleKey {
if (typeof window === "undefined") return "JOB_SEEKER"; if (typeof window === "undefined") return "JOB_SEEKER";
const keys = ["nxtgauge_signup_profile_v1", "nxtgauge_auth_user", "nxtgauge_user"]; const keys = ["nxtgauge_signup_profile_v1", "nxtgauge_auth_user", "nxtgauge_user"];
@ -589,6 +616,7 @@ export default function RuntimeDashboardPage() {
const [userName, setUserName] = createSignal("User"); const [userName, setUserName] = createSignal("User");
const [userId, setUserId] = createSignal(""); const [userId, setUserId] = createSignal("");
const [urlRoleLocked, setUrlRoleLocked] = createSignal(false); const [urlRoleLocked, setUrlRoleLocked] = createSignal(false);
const [roleReconcileAttempted, setRoleReconcileAttempted] = createSignal(false);
onMount(() => { onMount(() => {
setHydrated(true); setHydrated(true);
@ -615,10 +643,101 @@ export default function RuntimeDashboardPage() {
if (u && !urlRoleLocked()) { if (u && !urlRoleLocked()) {
if (u.full_name && userName() === "User") setUserName(u.full_name); if (u.full_name && userName() === "User") setUserName(u.full_name);
if (u.id && !userId()) setUserId(u.id); if (u.id && !userId()) setUserId(u.id);
if (u.active_role) setRole(resolveRoleForDashboard(u.active_role)); if (u.active_role) {
const authRole = resolveRoleForDashboard(u.active_role);
const preferredRole = getInitialRoleFromStorage();
if (authRole === "JOB_SEEKER" && preferredRole !== "JOB_SEEKER") {
setRole(preferredRole);
} else {
setRole(authRole);
}
}
} }
}); });
createEffect(() => {
if (urlRoleLocked() || roleReconcileAttempted()) return;
if (role() !== "JOB_SEEKER") {
setRoleReconcileAttempted(true);
return;
}
setRoleReconcileAttempted(true);
void (async () => {
try {
const authEmail = String(auth.user()?.email || "")
.trim()
.toLowerCase();
const email = authEmail || getEmailFromStorage();
if (!email) return;
const checkRes = await fetch("/api/auth/check-email", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: "include",
body: JSON.stringify({ email }),
});
const checkPayload = await checkRes.json().catch(() => ({}));
if (!checkRes.ok || !checkPayload?.exists) return;
const discoveredRoles = asStringArray(checkPayload?.roles).map((item) =>
normalizeRole(String(item))
);
const discoveredActive = normalizeRole(
String(checkPayload?.active_role || checkPayload?.role || "")
);
const targetRole =
discoveredActive !== "JOB_SEEKER"
? discoveredActive
: firstNonJobSeekerRole(discoveredRoles);
if (!targetRole || targetRole === "JOB_SEEKER") return;
const token = getToken();
if (token) {
const switchRes = await fetch("/api/auth/switch-role", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
credentials: "include",
body: JSON.stringify({ role_key: targetRole }),
});
const switchPayload = await switchRes.json().catch(() => ({}));
const switchedToken = String(switchPayload?.access_token || "").trim();
if (switchRes.ok && switchedToken && typeof window !== "undefined") {
window.sessionStorage.setItem("nxtgauge_access_token", switchedToken);
window.sessionStorage.setItem("nxtgauge_frontend_access_token", switchedToken);
}
}
if (typeof window !== "undefined") {
const storageKeys = ["nxtgauge_signup_profile_v1", "nxtgauge_auth_user", "nxtgauge_user"];
for (const key of storageKeys) {
const raw = window.localStorage.getItem(key);
if (!raw) continue;
try {
const parsed = JSON.parse(raw);
parsed.active_role = targetRole;
parsed.selectedProfessionalRole = targetRole;
parsed.role = String(targetRole).toLowerCase();
parsed.roleKey = String(targetRole).toLowerCase();
window.localStorage.setItem(key, JSON.stringify(parsed));
} catch {
// ignore malformed storage payloads
}
}
}
setRole(targetRole);
navigate(`/dashboard?role=${encodeURIComponent(targetRole)}`, { replace: true });
} catch {
// best-effort reconciliation only
}
})();
});
const [bundle] = createResource(() => role(), loadRoleBundle); const [bundle] = createResource(() => role(), loadRoleBundle);
const activeSidebarKey = createMemo(() => normalizeSidebarKey(activeSidebar())); const activeSidebarKey = createMemo(() => normalizeSidebarKey(activeSidebar()));

View file

@ -1 +1,3 @@
export { default } from "../dashboard"; import RuntimeDashboardPage from "../dashboard";
export default RuntimeDashboardPage;

View file

@ -15,6 +15,32 @@ function normalizeRoleValue(value: unknown): string {
.replace(/\s+/g, "_"); .replace(/\s+/g, "_");
} }
function extractRoleKey(value: unknown): string {
const normalized = normalizeRoleValue(value);
if (normalized && normalized !== "[OBJECT_OBJECT]") return normalized;
if (!value || typeof value !== "object") return normalized;
const maybe = value as Record<string, unknown>;
return normalizeRoleValue(
maybe.key ?? maybe.role_key ?? maybe.roleKey ?? maybe.name ?? maybe.role ?? maybe.id
);
}
function normalizeRoleKeysList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => extractRoleKey(item)).filter(Boolean);
}
function isJobSeekerRole(roleKey: string): boolean {
return normalizeRoleValue(roleKey) === "JOB_SEEKER";
}
function firstNonJobSeekerRole(roleKeys: string[]): string | null {
for (const roleKey of roleKeys) {
if (!isJobSeekerRole(roleKey)) return roleKey;
}
return null;
}
function getStoredPreferredRole(emailHint?: string): string | null { function getStoredPreferredRole(emailHint?: string): string | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
const keys = ["nxtgauge_signup_profile_v1", "nxtgauge_auth_user", "nxtgauge_user"]; const keys = ["nxtgauge_signup_profile_v1", "nxtgauge_auth_user", "nxtgauge_user"];
@ -47,7 +73,7 @@ function resolveActiveRole(rawBackendRole: unknown, emailHint?: string): string
if (backendRole) return backendRole; if (backendRole) return backendRole;
const preferredRole = getStoredPreferredRole(emailHint); const preferredRole = getStoredPreferredRole(emailHint);
if (preferredRole) return preferredRole; if (preferredRole) return preferredRole;
return preferredRole || "JOB_SEEKER"; return "";
} }
function makeCaptcha() { function makeCaptcha() {
@ -160,9 +186,17 @@ export default function LoginRoute() {
const fullName = String(user?.full_name || user?.fullName || "").trim(); const fullName = String(user?.full_name || user?.fullName || "").trim();
const [firstName, ...rest] = fullName.split(" "); const [firstName, ...rest] = fullName.split(" ");
const lastName = rest.join(" "); const lastName = rest.join(" ");
// Trust the passed-in role — don't re-resolve from storage (which can be stale) // Trust the passed-in role and never force a JOB_SEEKER fallback.
const normalizedRole = normalizeRoleValue(user?.active_role || user?.role || "JOB_SEEKER"); const preferredRole = getStoredPreferredRole(String(user?.email || email()));
const storedRole = normalizedRole.toLowerCase(); const normalizedRole = normalizeRoleValue(
user?.active_role || user?.role || preferredRole || ""
);
const storedRole = normalizedRole ? normalizedRole.toLowerCase() : "";
const selectedRoleForStorage =
isJobSeekerRole(normalizedRole) && preferredRole && !isJobSeekerRole(preferredRole)
? preferredRole
: normalizedRole;
const payload = { const payload = {
firstName: firstName || "", firstName: firstName || "",
lastName: lastName || "", lastName: lastName || "",
@ -175,7 +209,7 @@ export default function LoginRoute() {
roleKey: storedRole, roleKey: storedRole,
role: storedRole, role: storedRole,
active_role: normalizedRole, active_role: normalizedRole,
selectedProfessionalRole: normalizedRole, selectedProfessionalRole: selectedRoleForStorage,
user, user,
}; };
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@ -226,36 +260,106 @@ export default function LoginRoute() {
} }
const accessToken = String(data?.access_token || "").trim(); const accessToken = String(data?.access_token || "").trim();
if (typeof window !== "undefined" && accessToken) { const normalizedEmail = email().trim().toLowerCase();
window.sessionStorage.setItem("nxtgauge_access_token", accessToken); const userEmail = String(data?.user?.email || normalizedEmail).trim().toLowerCase();
window.sessionStorage.setItem("nxtgauge_frontend_access_token", accessToken); const availableRoleKeys = normalizeRoleKeysList(data?.user?.roles || data?.roles);
} const backendActiveRoleKey =
const user_roles = data?.user?.roles || []; extractRoleKey(
const selectedRole = getStoredPreferredRole(data?.user?.email || email().trim().toLowerCase()); data?.user?.active_role ||
const backendActiveRole = data?.user?.active_role; data?.active_role ||
data?.role ||
data?.role_code
) ||
firstNonJobSeekerRole(availableRoleKeys) ||
"";
const preferredRoleKey = getStoredPreferredRole(userEmail);
let discoveredRoleKeys = [...availableRoleKeys];
let discoveredActiveRole = backendActiveRoleKey;
// Use selected role from storage if available, otherwise use backend role if (discoveredRoleKeys.length === 0 || isJobSeekerRole(discoveredActiveRole)) {
// If backend returns JOB_SEEKER but user has professional roles, pick the first professional role try {
let resolvedActiveRole = backendActiveRole; const checkRes = await fetch("/api/auth/check-email", {
if (normalizeRoleValue(backendActiveRole) === "JOB_SEEKER" && user_roles.length > 0) { method: "POST",
// Find first non-JOB_SEEKER role from backend's roles array headers: { "Content-Type": "application/json", Accept: "application/json" },
for (const role of user_roles) { credentials: "include",
if (normalizeRoleValue(role) !== "JOB_SEEKER") { body: JSON.stringify({ email: userEmail }),
resolvedActiveRole = role; });
break; const checkPayload = await checkRes.json().catch(() => ({}));
if (checkRes.ok && checkPayload?.exists) {
const checkRoles = normalizeRoleKeysList(checkPayload?.roles);
if (checkRoles.length > 0) {
discoveredRoleKeys = Array.from(new Set([...discoveredRoleKeys, ...checkRoles]));
}
const checkActive = extractRoleKey(checkPayload?.active_role || checkPayload?.role);
if (checkActive) {
discoveredActiveRole = checkActive;
}
} }
} catch {
// Ignore check-email failures; continue with login payload roles.
} }
} }
if (!resolvedActiveRole) {
resolvedActiveRole = selectedRole || backendActiveRole || "JOB_SEEKER"; // Choose the role we *want* to activate:
// 1) Prefer the stored role selection (from signup/switch services) when it isn't JOB_SEEKER.
// If backend doesn't return roles, still attempt to activate it via switch-role.
// 2) Otherwise, if backend returns JOB_SEEKER but other roles exist, pick the first non-JOB_SEEKER role.
let requestedRoleKey: string | null = null;
if (
preferredRoleKey &&
!isJobSeekerRole(preferredRoleKey) &&
(discoveredRoleKeys.length === 0 || discoveredRoleKeys.includes(preferredRoleKey))
) {
requestedRoleKey = preferredRoleKey;
} else if (isJobSeekerRole(discoveredActiveRole)) {
requestedRoleKey = firstNonJobSeekerRole(discoveredRoleKeys);
} }
const normalizedEmail = email().trim().toLowerCase(); let finalAccessToken = accessToken;
const finalRole = normalizeRoleValue(resolvedActiveRole); let desiredRoleKey = discoveredActiveRole;
if (finalAccessToken && requestedRoleKey && requestedRoleKey !== discoveredActiveRole) {
try {
const switchRes = await fetch("/api/auth/switch-role", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${finalAccessToken}`,
},
credentials: "include",
body: JSON.stringify({ role_key: requestedRoleKey }),
});
const switchPayload = await switchRes.json().catch(() => ({}));
const switchedToken = String(switchPayload?.access_token || "").trim();
if (switchRes.ok && switchedToken) {
finalAccessToken = switchedToken;
desiredRoleKey = requestedRoleKey;
}
} catch {
// Ignore switch failures; fall back to backend active role token.
}
}
if (typeof window !== "undefined" && finalAccessToken) {
window.sessionStorage.setItem("nxtgauge_access_token", finalAccessToken);
window.sessionStorage.setItem("nxtgauge_frontend_access_token", finalAccessToken);
}
const finalRole = normalizeRoleValue(
desiredRoleKey ||
backendActiveRoleKey ||
preferredRoleKey ||
firstNonJobSeekerRole(discoveredRoleKeys) ||
""
);
if (!finalRole) {
setError("No active role is assigned to this account. Please contact support.");
return;
}
const userPayload = { const userPayload = {
id: String(data?.user?.id || ""), id: String(data?.user?.id || ""),
email: String(data?.user?.email || normalizedEmail), email: userEmail,
full_name: String(data?.user?.full_name || ""), full_name: String(data?.user?.full_name || data?.user?.name || data?.name || ""),
active_role: finalRole, active_role: finalRole,
email_verified: Boolean(data?.user?.email_verified ?? true), email_verified: Boolean(data?.user?.email_verified ?? true),
}; };
@ -263,7 +367,7 @@ export default function LoginRoute() {
if (auth.refreshUser) { if (auth.refreshUser) {
auth.refreshUser(userPayload); auth.refreshUser(userPayload);
} }
navigate(`/dashboard?role=${encodeURIComponent(normalizeRoleValue(resolvedActiveRole))}`, { replace: true }); navigate(`/dashboard?role=${encodeURIComponent(finalRole)}`, { replace: true });
} catch { } catch {
setError("Network error during login. Please try again."); setError("Network error during login. Please try again.");
} finally { } finally {