auth querying consolidation

This commit is contained in:
Michael Freno
2026-01-06 23:26:51 -05:00
parent 08a9ad35af
commit 445ab6d7de
10 changed files with 224 additions and 91 deletions

View File

@@ -14,6 +14,7 @@ import { MetaProvider } from "@solidjs/meta";
import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback"; import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback";
import { BarsProvider, useBars } from "./context/bars"; import { BarsProvider, useBars } from "./context/bars";
import { DarkModeProvider } from "./context/darkMode"; import { DarkModeProvider } from "./context/darkMode";
import { AuthProvider } from "./context/auth";
import { createWindowWidth, isMobile } from "~/lib/resize-utils"; import { createWindowWidth, isMobile } from "~/lib/resize-utils";
import { MOBILE_CONFIG } from "./config"; import { MOBILE_CONFIG } from "./config";
import CustomScrollbar from "./components/CustomScrollbar"; import CustomScrollbar from "./components/CustomScrollbar";
@@ -210,7 +211,13 @@ export default function App() {
> >
<DarkModeProvider> <DarkModeProvider>
<BarsProvider> <BarsProvider>
<Router root={(props) => <AppLayout>{props.children}</AppLayout>}> <Router
root={(props) => (
<AuthProvider>
<AppLayout>{props.children}</AppLayout>
</AuthProvider>
)}
>
<FileRoutes /> <FileRoutes />
</Router> </Router>
</BarsProvider> </BarsProvider>

View File

@@ -1,5 +1,7 @@
import { Typewriter } from "./Typewriter"; import { Typewriter } from "./Typewriter";
import { useBars } from "~/context/bars"; import { useBars } from "~/context/bars";
import { useAuth } from "~/context/auth";
import { revalidateAuth } from "~/lib/auth-query";
import { onMount, createSignal, Show, For, onCleanup } from "solid-js"; import { onMount, createSignal, Show, For, onCleanup } from "solid-js";
import { api } from "~/lib/api"; import { api } from "~/lib/api";
import { insertSoftHyphens, glitchText } from "~/lib/client-utils"; import { insertSoftHyphens, glitchText } from "~/lib/client-utils";
@@ -10,54 +12,8 @@ import { ActivityHeatmap } from "./ActivityHeatmap";
import { DarkModeToggle } from "./DarkModeToggle"; import { DarkModeToggle } from "./DarkModeToggle";
import { SkeletonBox, SkeletonText } from "./SkeletonLoader"; import { SkeletonBox, SkeletonText } from "./SkeletonLoader";
import { env } from "~/env/client"; import { env } from "~/env/client";
import { import { A, useNavigate, useLocation } from "@solidjs/router";
A,
useNavigate,
useLocation,
query,
createAsync,
revalidate
} from "@solidjs/router";
import { BREAKPOINTS } from "~/config"; import { BREAKPOINTS } from "~/config";
import { getRequestEvent } from "solid-js/web";
const getUserState = query(async () => {
"use server";
const { getPrivilegeLevel, getUserID } = await import("~/server/utils");
const { ConnectionFactory } = await import("~/server/utils");
const event = getRequestEvent()!;
const privilegeLevel = await getPrivilegeLevel(event.nativeEvent);
const userId = await getUserID(event.nativeEvent);
if (!userId) {
return {
isAuthenticated: false,
email: null,
privilegeLevel: "anonymous" as const
};
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT email FROM User WHERE id = ?",
args: [userId]
});
const email = res.rows[0] ? (res.rows[0].email as string | null) : null;
return {
isAuthenticated: true,
email,
privilegeLevel
};
}, "bars-user-state");
/**
* Call this function after login/logout to refresh the user state in the sidebar
*/
export function revalidateUserState() {
revalidate(getUserState.key);
}
function formatDomainName(url: string): string { function formatDomainName(url: string): string {
const domain = url.split("://")[1]?.split(":")[0] ?? url; const domain = url.split("://")[1]?.split(":")[0] ?? url;
@@ -247,7 +203,7 @@ export function RightBarContent() {
export function LeftBar() { export function LeftBar() {
const { leftBarVisible, setLeftBarVisible } = useBars(); const { leftBarVisible, setLeftBarVisible } = useBars();
const location = useLocation(); const location = useLocation();
const userState = createAsync(() => getUserState()); const { isAuthenticated, email, isAdmin } = useAuth();
let ref: HTMLDivElement | undefined; let ref: HTMLDivElement | undefined;
const [recentPosts, setRecentPosts] = createSignal<any[] | undefined>( const [recentPosts, setRecentPosts] = createSignal<any[] | undefined>(
@@ -277,6 +233,7 @@ export function LeftBar() {
setSignOutLoading(true); setSignOutLoading(true);
try { try {
await api.auth.signOut.mutate(); await api.auth.signOut.mutate();
revalidateAuth(); // Clear auth state immediately
window.location.href = "/"; window.location.href = "/";
} catch (error) { } catch (error) {
console.error("Sign out failed:", error); console.error("Sign out failed:", error);
@@ -540,9 +497,7 @@ export function LeftBar() {
Blog Blog
</a> </a>
</li> </li>
<Show <Show when={isMounted() && isAdmin()}>
when={isMounted() && userState()?.privilegeLevel === "admin"}
>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold"> <li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/analytics" onClick={handleLinkClick}> <a href="/analytics" onClick={handleLinkClick}>
Analytics Analytics
@@ -557,7 +512,7 @@ export function LeftBar() {
}} }}
> >
<Show <Show
when={isMounted() && userState()?.isAuthenticated} when={isMounted() && isAuthenticated()}
fallback={ fallback={
<a href="/login" onClick={handleLinkClick}> <a href="/login" onClick={handleLinkClick}>
Login Login
@@ -566,16 +521,16 @@ export function LeftBar() {
> >
<A href="/account" onClick={handleLinkClick}> <A href="/account" onClick={handleLinkClick}>
Account Account
<Show when={userState()?.email}> <Show when={email()}>
<span class="text-subtext0 text-sm font-normal"> <span class="text-subtext0 text-sm font-normal">
{" "} {" "}
({userState()!.email}) ({email()})
</span> </span>
</Show> </Show>
</A> </A>
</Show> </Show>
</li> </li>
<Show when={isMounted() && userState()?.isAuthenticated}> <Show when={isMounted() && isAuthenticated()}>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold"> <li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<button <button
onClick={handleSignOut} onClick={handleSignOut}

94
src/context/auth.tsx Normal file
View File

@@ -0,0 +1,94 @@
/**
* Auth Context Provider
* Provides convenient access to auth state throughout the app
*
* Security Note:
* - Context is for UI display only (showing/hiding buttons, user email, etc.)
* - Server endpoints ALWAYS validate independently from cookies
* - Never trust client-side state for authorization decisions
*/
import { createContext, useContext, Accessor, ParentComponent } from "solid-js";
import { createAsync } from "@solidjs/router";
import { getUserState, revalidateAuth, type UserState } from "~/lib/auth-query";
interface AuthContextType {
/** Current user state (for UI display) */
userState: Accessor<UserState | undefined>;
/** Is user authenticated (convenience) */
isAuthenticated: Accessor<boolean>;
/** User email (null if not authenticated) */
email: Accessor<string | null>;
/** User display name (null if not set) */
displayName: Accessor<string | null>;
/** User ID (null if not authenticated) */
userId: Accessor<string | null>;
/** Is user admin (for UI display only - server still validates) */
isAdmin: Accessor<boolean>;
/** Is email verified */
isEmailVerified: Accessor<boolean>;
/** Refresh auth state from server */
refreshAuth: () => void;
}
const AuthContext = createContext<AuthContextType>();
export const AuthProvider: ParentComponent = (props) => {
// Get server state via SolidStart query
const serverAuth = createAsync(() => getUserState());
// Convenience accessors with safe defaults
const isAuthenticated = () => serverAuth()?.isAuthenticated ?? false;
const email = () => serverAuth()?.email ?? null;
const displayName = () => serverAuth()?.displayName ?? null;
const userId = () => serverAuth()?.userId ?? null;
const isAdmin = () => serverAuth()?.privilegeLevel === "admin";
const isEmailVerified = () => serverAuth()?.emailVerified ?? false;
const value: AuthContextType = {
userState: serverAuth,
isAuthenticated,
email,
displayName,
userId,
isAdmin,
isEmailVerified,
refreshAuth: revalidateAuth
};
return (
<AuthContext.Provider value={value}>{props.children}</AuthContext.Provider>
);
};
/**
* Hook to access auth state anywhere in the app
*
* @example
* ```tsx
* function MyComponent() {
* const { isAuthenticated, email, refreshAuth } = useAuth();
*
* return (
* <Show when={isAuthenticated()}>
* <p>Welcome, {email()}!</p>
* <button onClick={() => refreshAuth()}>Refresh</button>
* </Show>
* );
* }
* ```
*/
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}

81
src/lib/auth-query.ts Normal file
View File

@@ -0,0 +1,81 @@
/**
* Shared Auth Query
* Single source of truth for authentication state across the app
*
* Security Model:
* - Server query reads from httpOnly cookies (secure)
* - Client context syncs from this query (UI convenience)
* - Server endpoints always validate independently (never trust client)
*/
import { query, revalidate as revalidateKey } from "@solidjs/router";
import { getRequestEvent } from "solid-js/web";
export interface UserState {
isAuthenticated: boolean;
userId: string | null;
email: string | null;
displayName: string | null;
emailVerified: boolean;
privilegeLevel: "admin" | "user" | "anonymous";
}
/**
* Global auth state query - single source of truth
* Called on server during SSR, cached by SolidStart router
*/
export const getUserState = query(async (): Promise<UserState> => {
"use server";
const { getPrivilegeLevel, getUserID, ConnectionFactory } =
await import("~/server/utils");
const event = getRequestEvent()!;
const privilegeLevel = await getPrivilegeLevel(event.nativeEvent);
const userId = await getUserID(event.nativeEvent);
if (!userId) {
return {
isAuthenticated: false,
userId: null,
email: null,
displayName: null,
emailVerified: false,
privilegeLevel: "anonymous"
};
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT email, display_name, email_verified FROM User WHERE id = ?",
args: [userId]
});
if (res.rows.length === 0) {
return {
isAuthenticated: false,
userId: null,
email: null,
displayName: null,
emailVerified: false,
privilegeLevel: "anonymous"
};
}
const user = res.rows[0] as any;
return {
isAuthenticated: true,
userId,
email: user.email ?? null,
displayName: user.display_name ?? null,
emailVerified: user.email_verified === 1,
privilegeLevel
};
}, "global-auth-state");
/**
* Revalidate auth state globally
* Call this after login, logout, token refresh, email verification
*/
export function revalidateAuth() {
revalidateKey(getUserState.key);
}

View File

@@ -6,6 +6,7 @@
import { api } from "~/lib/api"; import { api } from "~/lib/api";
import { getClientCookie } from "~/lib/cookies.client"; import { getClientCookie } from "~/lib/cookies.client";
import { getTimeUntilExpiry } from "~/lib/client-utils"; import { getTimeUntilExpiry } from "~/lib/client-utils";
import { revalidateAuth } from "~/lib/auth-query";
class TokenRefreshManager { class TokenRefreshManager {
private refreshTimer: ReturnType<typeof setTimeout> | null = null; private refreshTimer: ReturnType<typeof setTimeout> | null = null;
@@ -108,6 +109,7 @@ class TokenRefreshManager {
if (result.success) { if (result.success) {
console.log("[Token Refresh] Token refreshed successfully"); console.log("[Token Refresh] Token refreshed successfully");
revalidateAuth(); // Refresh auth state after token refresh
this.scheduleNextRefresh(); // Schedule next refresh this.scheduleNextRefresh(); // Schedule next refresh
return true; return true;
} else { } else {

View File

@@ -1,17 +1,14 @@
import { createSignal, Show, For, createEffect, ErrorBoundary } from "solid-js"; import { createSignal, Show, For, createEffect, ErrorBoundary } from "solid-js";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { redirect, query, createAsync, useNavigate } from "@solidjs/router"; import { redirect, query, createAsync, useNavigate } from "@solidjs/router";
import { getEvent } from "vinxi/http";
import { api } from "~/lib/api"; import { api } from "~/lib/api";
const checkAdmin = query(async (): Promise<boolean> => { const checkAdmin = query(async (): Promise<boolean> => {
"use server"; "use server";
const { getUserID } = await import("~/server/auth"); const { getUserState } = await import("~/lib/auth-query");
const { env } = await import("~/env/server"); const userState = await getUserState();
const event = getEvent()!;
const userId = await getUserID(event);
if (!userId || userId !== env.ADMIN_ID) { if (userState.privilegeLevel !== "admin") {
throw redirect("/"); throw redirect("/");
} }

View File

@@ -2,32 +2,30 @@ import { Show, lazy } from "solid-js";
import { query, redirect } from "@solidjs/router"; import { query, redirect } from "@solidjs/router";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { createAsync } from "@solidjs/router"; import { createAsync } from "@solidjs/router";
import { getEvent } from "vinxi/http"; import { getUserState } from "~/lib/auth-query";
import { Spinner } from "~/components/Spinner"; import { Spinner } from "~/components/Spinner";
import "../post.css"; import "../post.css";
const PostForm = lazy(() => import("~/components/blog/PostForm")); const PostForm = lazy(() => import("~/components/blog/PostForm"));
const getAuthState = query(async () => { const checkAdminAccess = query(async () => {
"use server"; "use server";
const { getPrivilegeLevel, getUserID } = await import("~/server/utils"); // Reuse shared auth query for consistency
const event = getEvent()!; const userState = await getUserState();
const privilegeLevel = await getPrivilegeLevel(event);
const userID = await getUserID(event);
if (privilegeLevel !== "admin") { if (userState.privilegeLevel !== "admin") {
throw redirect("/401"); throw redirect("/401");
} }
return { privilegeLevel, userID }; return { userID: userState.userId! };
}, "create-post-auth"); }, "create-post-admin-check");
export const route = { export const route = {
load: () => getAuthState() load: () => checkAdminAccess()
}; };
export default function CreatePost() { export default function CreatePost() {
const authState = createAsync(() => getAuthState()); const authState = createAsync(() => checkAdminAccess());
return ( return (
<> <>

View File

@@ -2,20 +2,17 @@ import { Show, lazy } from "solid-js";
import { useParams, query, redirect } from "@solidjs/router"; import { useParams, query, redirect } from "@solidjs/router";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { createAsync } from "@solidjs/router"; import { createAsync } from "@solidjs/router";
import { getEvent } from "vinxi/http";
import "../post.css"; import "../post.css";
const PostForm = lazy(() => import("~/components/blog/PostForm")); const PostForm = lazy(() => import("~/components/blog/PostForm"));
const getPostForEdit = query(async (id: string) => { const getPostForEdit = query(async (id: string) => {
"use server"; "use server";
const { getPrivilegeLevel, getUserID, ConnectionFactory } = const { getUserState } = await import("~/lib/auth-query");
await import("~/server/utils"); const { ConnectionFactory } = await import("~/server/utils");
const event = getEvent()!; const userState = await getUserState();
const privilegeLevel = await getPrivilegeLevel(event);
const userID = await getUserID(event);
if (privilegeLevel !== "admin") { if (userState.privilegeLevel !== "admin") {
throw redirect("/401"); throw redirect("/401");
} }
@@ -35,7 +32,12 @@ const getPostForEdit = query(async (id: string) => {
const post = results.rows[0]; const post = results.rows[0];
const tags = tagRes.rows; const tags = tagRes.rows;
return { post, tags, privilegeLevel, userID }; return {
post,
tags,
privilegeLevel: userState.privilegeLevel,
userID: userState.userId
};
}, "post-for-edit"); }, "post-for-edit");
export const route = { export const route = {

View File

@@ -7,7 +7,7 @@ import {
query query
} from "@solidjs/router"; } from "@solidjs/router";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { revalidateUserState } from "~/components/Bars"; import { revalidateAuth } from "~/lib/auth-query";
import { getEvent } from "vinxi/http"; import { getEvent } from "vinxi/http";
import GoogleLogo from "~/components/icons/GoogleLogo"; import GoogleLogo from "~/components/icons/GoogleLogo";
import GitHub from "~/components/icons/GitHub"; import GitHub from "~/components/icons/GitHub";
@@ -206,7 +206,7 @@ export default function LoginPage() {
if (response.ok && result.result?.data?.success) { if (response.ok && result.result?.data?.success) {
setShowPasswordSuccess(true); setShowPasswordSuccess(true);
revalidateUserState(); // Refresh user state in sidebar revalidateAuth(); // Refresh auth state globally
setTimeout(() => { setTimeout(() => {
navigate("/account", { replace: true }); navigate("/account", { replace: true });
}, 500); }, 500);

View File

@@ -1,16 +1,13 @@
import { createSignal, For, Show } from "solid-js"; import { createSignal, For, Show } from "solid-js";
import { query, createAsync } from "@solidjs/router"; import { query, createAsync } from "@solidjs/router";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { getRequestEvent } from "solid-js/web";
import { api } from "~/lib/api"; import { api } from "~/lib/api";
import { getUserState } from "~/lib/auth-query";
const getAuthState = query(async () => { const checkAdminAccess = query(async () => {
"use server"; "use server";
const { getPrivilegeLevel } = await import("~/server/utils"); const userState = await getUserState();
const event = getRequestEvent()!; return { privilegeLevel: userState.privilegeLevel };
const privilegeLevel = await getPrivilegeLevel(event.nativeEvent);
return { privilegeLevel };
}, "test-auth-state"); }, "test-auth-state");
type EndpointTest = { type EndpointTest = {
@@ -840,7 +837,7 @@ const routerSections: RouterSection[] = [
]; ];
export default function TestPage() { export default function TestPage() {
const authState = createAsync(() => getAuthState()); const authState = createAsync(() => checkAdminAccess());
const [expandedSections, setExpandedSections] = createSignal<Set<string>>( const [expandedSections, setExpandedSections] = createSignal<Set<string>>(
new Set() new Set()