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, '_');
}
function isJobSeekerRole(roleKey: string): boolean {
return normalizeRoleValue(roleKey) === 'JOB_SEEKER';
}
function getStoredPreferredRole(emailHint?: string): string | null {
if (typeof window === 'undefined') return null;
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 {
const backendRole = normalizeRoleValue(rawBackendRole);
if (backendRole) return backendRole;
const preferredRole = getStoredPreferredRole(emailHint);
if (backendRole && preferredRole && isJobSeekerRole(backendRole) && !isJobSeekerRole(preferredRole)) {
return preferredRole;
}
if (backendRole) return backendRole;
if (preferredRole) return preferredRole;
return preferredRole || 'JOB_SEEKER';
return '';
}
async function fetchSession(): Promise<AuthUser | null> {
@ -144,14 +151,16 @@ export function useAuth() {
export function RequireAuth(props: ParentProps<{ fallback?: string }>) {
const navigate = useNavigate();
const auth = useAuth();
const fallback = props.fallback || '/login';
if (auth.isLoading()) {
return <div class="flex min-h-screen items-center justify-center text-[#6B7280]">Loading...</div>;
}
createEffect(() => {
if (typeof window === 'undefined') return;
if (!auth.isAuthenticated()) {
navigate(fallback, { replace: true });
}
});
if (!auth.isAuthenticated()) {
const fallback = props.fallback || '/login';
navigate(fallback, { replace: true });
return null;
}

View file

@ -9,7 +9,7 @@ import {
onMount,
} from "solid-js";
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 DashboardShell from "~/components/DashboardShell";
import ProfilePage from "~/components/dashboard/ProfilePage";
@ -317,6 +317,25 @@ function getNameFromStorage(): string {
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 = [
{ key: "PHOTOGRAPHER", name: "Photographer" },
{ key: "MAKEUP_ARTIST", name: "Makeup Artist" },
@ -369,6 +388,14 @@ function asStringArray(value: unknown): string[] {
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 {
if (typeof window === "undefined") return "JOB_SEEKER";
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 [userId, setUserId] = createSignal("");
const [urlRoleLocked, setUrlRoleLocked] = createSignal(false);
const [roleReconcileAttempted, setRoleReconcileAttempted] = createSignal(false);
onMount(() => {
setHydrated(true);
@ -615,10 +643,101 @@ export default function RuntimeDashboardPage() {
if (u && !urlRoleLocked()) {
if (u.full_name && userName() === "User") setUserName(u.full_name);
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 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, "_");
}
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 {
if (typeof window === "undefined") return null;
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;
const preferredRole = getStoredPreferredRole(emailHint);
if (preferredRole) return preferredRole;
return preferredRole || "JOB_SEEKER";
return "";
}
function makeCaptcha() {
@ -160,9 +186,17 @@ export default function LoginRoute() {
const fullName = String(user?.full_name || user?.fullName || "").trim();
const [firstName, ...rest] = fullName.split(" ");
const lastName = rest.join(" ");
// Trust the passed-in role — don't re-resolve from storage (which can be stale)
const normalizedRole = normalizeRoleValue(user?.active_role || user?.role || "JOB_SEEKER");
const storedRole = normalizedRole.toLowerCase();
// Trust the passed-in role and never force a JOB_SEEKER fallback.
const preferredRole = getStoredPreferredRole(String(user?.email || email()));
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 = {
firstName: firstName || "",
lastName: lastName || "",
@ -175,7 +209,7 @@ export default function LoginRoute() {
roleKey: storedRole,
role: storedRole,
active_role: normalizedRole,
selectedProfessionalRole: normalizedRole,
selectedProfessionalRole: selectedRoleForStorage,
user,
};
if (typeof window !== "undefined") {
@ -226,36 +260,106 @@ export default function LoginRoute() {
}
const accessToken = String(data?.access_token || "").trim();
if (typeof window !== "undefined" && accessToken) {
window.sessionStorage.setItem("nxtgauge_access_token", accessToken);
window.sessionStorage.setItem("nxtgauge_frontend_access_token", accessToken);
}
const user_roles = data?.user?.roles || [];
const selectedRole = getStoredPreferredRole(data?.user?.email || email().trim().toLowerCase());
const backendActiveRole = data?.user?.active_role;
// Use selected role from storage if available, otherwise use backend role
// If backend returns JOB_SEEKER but user has professional roles, pick the first professional role
let resolvedActiveRole = backendActiveRole;
if (normalizeRoleValue(backendActiveRole) === "JOB_SEEKER" && user_roles.length > 0) {
// Find first non-JOB_SEEKER role from backend's roles array
for (const role of user_roles) {
if (normalizeRoleValue(role) !== "JOB_SEEKER") {
resolvedActiveRole = role;
break;
const normalizedEmail = email().trim().toLowerCase();
const userEmail = String(data?.user?.email || normalizedEmail).trim().toLowerCase();
const availableRoleKeys = normalizeRoleKeysList(data?.user?.roles || data?.roles);
const backendActiveRoleKey =
extractRoleKey(
data?.user?.active_role ||
data?.active_role ||
data?.role ||
data?.role_code
) ||
firstNonJobSeekerRole(availableRoleKeys) ||
"";
const preferredRoleKey = getStoredPreferredRole(userEmail);
let discoveredRoleKeys = [...availableRoleKeys];
let discoveredActiveRole = backendActiveRoleKey;
if (discoveredRoleKeys.length === 0 || isJobSeekerRole(discoveredActiveRole)) {
try {
const checkRes = await fetch("/api/auth/check-email", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: "include",
body: JSON.stringify({ email: userEmail }),
});
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);
}
let finalAccessToken = accessToken;
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 normalizedEmail = email().trim().toLowerCase();
const finalRole = normalizeRoleValue(resolvedActiveRole);
const userPayload = {
id: String(data?.user?.id || ""),
email: String(data?.user?.email || normalizedEmail),
full_name: String(data?.user?.full_name || ""),
email: userEmail,
full_name: String(data?.user?.full_name || data?.user?.name || data?.name || ""),
active_role: finalRole,
email_verified: Boolean(data?.user?.email_verified ?? true),
};
@ -263,7 +367,7 @@ export default function LoginRoute() {
if (auth.refreshUser) {
auth.refreshUser(userPayload);
}
navigate(`/dashboard?role=${encodeURIComponent(normalizeRoleValue(resolvedActiveRole))}`, { replace: true });
navigate(`/dashboard?role=${encodeURIComponent(finalRole)}`, { replace: true });
} catch {
setError("Network error during login. Please try again.");
} finally {