refactor: update session handling across components to use new session structure and improve loading states

This commit is contained in:
Marco Pedone 2026-04-09 11:09:25 +02:00
parent 621d227760
commit 3f477ca3b8
18 changed files with 226 additions and 132 deletions

View file

@ -16,7 +16,6 @@ import {
useSession, useSession,
} from "~/providers/SessionProvider"; } from "~/providers/SessionProvider";
import type { UsersId } from "~/schemas/public/Users"; import type { UsersId } from "~/schemas/public/Users";
import type { ValidSession } from "~/server/api/trpc";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { UserViewHeader } from "./area-riservata/userViewHeader"; import { UserViewHeader } from "./area-riservata/userViewHeader";
@ -73,9 +72,9 @@ export const AreaRiservataLayout = ({
const { data: bannerData } = api.banners.getBannerData.useQuery({ const { data: bannerData } = api.banners.getBannerData.useQuery({
area_riservata: true, area_riservata: true,
}); });
const session = useSession(); const { status, user } = useSession();
if (!ignoreSessionCheck) { if (!ignoreSessionCheck) {
if (session.status === "pending") if (status === "LOADING")
return ( return (
<div <div
className={cn( className={cn(
@ -97,7 +96,7 @@ export const AreaRiservataLayout = ({
</main> </main>
</div> </div>
); );
if (session.status !== "success" || !session.user) { if (status !== "AUTHENTICATED") {
window.location.reload(); window.location.reload();
return ( return (
<div <div
@ -122,6 +121,27 @@ export const AreaRiservataLayout = ({
); );
} }
} }
if (status !== "AUTHENTICATED") {
window.location.reload();
return (
<div
className={cn(
"flex h-full min-h-screen flex-col text-clip",
bodyClassName,
)}
>
<SiteHeader />
<main
className={cn("flex h-full flex-1 overflow-auto", containerClassName)}
>
<div className="flex h-auto w-full flex-col md:flex-row">
<LoadingPage />
</div>
</main>
</div>
);
}
return ( return (
<div <div
@ -143,10 +163,10 @@ export const AreaRiservataLayout = ({
{noSidebar ? null : ( {noSidebar ? null : (
<Sidebar <Sidebar
defaultOpen={getCookie(SIDEBAR_COOKIE_NAME) === "true"} defaultOpen={getCookie(SIDEBAR_COOKIE_NAME) === "true"}
isAdmin={session.user?.isAdmin || false} isAdmin={user?.isAdmin || false}
/> />
)} )}
<EnforcedSessionContext.Provider value={session as ValidSession}> <EnforcedSessionContext.Provider value={{ user, status }}>
{children} {children}
</EnforcedSessionContext.Provider> </EnforcedSessionContext.Provider>
</div> </div>
@ -160,12 +180,12 @@ export const AreaRiservataLayoutUserView = ({
}: { }: {
children: ReactNode & { props: { userId: UsersId } }; children: ReactNode & { props: { userId: UsersId } };
}) => { }) => {
const session = useSession(); const { status, user } = useSession();
const props = children.props as { userId: UsersId }; const props = children.props as { userId: UsersId };
if (session.status === "pending") { if (status === "LOADING") {
return <LoadingPage />; return <LoadingPage />;
} }
if (session.status === "error" || !session.user) { if (status !== "AUTHENTICATED") {
window.location.reload(); window.location.reload();
return <LoadingPage />; return <LoadingPage />;
} }
@ -174,7 +194,7 @@ export const AreaRiservataLayoutUserView = ({
<SiteHeader /> <SiteHeader />
<main className="flex h-full flex-1 overflow-auto"> <main className="flex h-full flex-1 overflow-auto">
<UserViewProvider userId={props.userId}> <UserViewProvider userId={props.userId}>
<EnforcedSessionContext.Provider value={session as ValidSession}> <EnforcedSessionContext.Provider value={{ user, status }}>
<Head> <Head>
<title>User View - Infoalloggi.it</title> <title>User View - Infoalloggi.it</title>
</Head> </Head>

View file

@ -7,20 +7,16 @@ import { Button } from "~/components/ui/button";
import { useAnnuncio } from "~/providers/AnnuncioProvider"; import { useAnnuncio } from "~/providers/AnnuncioProvider";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
import { useServizio } from "~/providers/ServizioProvider"; import { useServizio } from "~/providers/ServizioProvider";
import type { SessionContextType } from "~/providers/SessionProvider"; import { useSession } from "~/providers/SessionProvider";
import type { AnnunciId } from "~/schemas/public/Annunci"; import type { AnnunciId } from "~/schemas/public/Annunci";
import type { UsersId } from "~/schemas/public/Users"; import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
export const AnnuncioInteractions = ({ export const AnnuncioInteractions = ({ disabled }: { disabled?: boolean }) => {
session, const { user } = useSession();
disabled,
}: {
session: SessionContextType;
disabled?: boolean;
}) => {
const { id, tipo } = useAnnuncio(); const { id, tipo } = useAnnuncio();
const { locale } = useTranslation(); const { locale } = useTranslation();
if (!user) return null;
if (disabled) { if (disabled) {
return ( return (
<div className="w-full rounded-md bg-red-500 p-2 text-center font-semibold text-base text-white"> <div className="w-full rounded-md bg-red-500 p-2 text-center font-semibold text-base text-white">
@ -32,12 +28,12 @@ export const AnnuncioInteractions = ({
} }
return ( return (
<div className="flex w-full flex-col justify-around gap-3 md:gap-5"> <div className="flex w-full flex-col justify-around gap-3 md:gap-5">
{session.user ? ( {user ? (
tipo ? ( tipo ? (
<ServizioInteraction <ServizioInteraction
annuncioId={id} annuncioId={id}
tipologia={tipo} tipologia={tipo}
userId={session.user.id} userId={user.id}
/> />
) : ( ) : (
<span>{locale === "it" ? "Errore" : "Error"}</span> <span>{locale === "it" ? "Errore" : "Error"}</span>

View file

@ -1,4 +1,3 @@
"use client";
import { LogIn, LogOut } from "lucide-react"; import { LogIn, LogOut } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
@ -35,7 +34,7 @@ export const UserHeaderSection = ({
inSidebar, inSidebar,
}: LoginButtonProps) => { }: LoginButtonProps) => {
const router = useRouter(); const router = useRouter();
const session = useSession(); const { status, user } = useSession();
const { t } = useTranslation(); const { t } = useTranslation();
const path = usePathname(); const path = usePathname();
const utils = api.useUtils(); const utils = api.useUtils();
@ -51,7 +50,7 @@ export const UserHeaderSection = ({
}, },
}); });
if (session.status === "pending") { if (status === "LOADING") {
return ( return (
<div className="flex items-center px-3 py-2"> <div className="flex items-center px-3 py-2">
<LoadingSpinner className="size-5" /> <LoadingSpinner className="size-5" />
@ -61,7 +60,7 @@ export const UserHeaderSection = ({
return ( return (
<div className="flex items-center"> <div className="flex items-center">
{!session || !session.user ? ( {status !== "AUTHENTICATED" ? (
<Button <Button
aria-label="Login Button" aria-label="Login Button"
className="flex h-11.5 w-full items-center justify-center gap-x-1 rounded-lg p-2" className="flex h-11.5 w-full items-center justify-center gap-x-1 rounded-lg p-2"
@ -89,25 +88,23 @@ export const UserHeaderSection = ({
> >
<UserAvatar <UserAvatar
className="size-10" className="size-10"
userId={session.user.id} userId={user.id}
username={session.user.username} username={user.username}
/> />
{inSidebar ? ( {inSidebar ? (
<span>{session.user.username}</span> <span>{user.username}</span>
) : ( ) : (
<span className="navqry:block hidden"> <span className="navqry:block hidden">{user.username}</span>
{session.user.username}
</span>
)} )}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56" forceMount> <DropdownMenuContent align="end" className="w-56" forceMount>
{session.user.isAdmin ? ( {user.isAdmin ? (
<> <>
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
<p className="font-medium text-sm leading-none"> <p className="font-medium text-sm leading-none">
{session.user.username} {user.username}
</p> </p>
<p className="text-muted-foreground text-xs leading-none"> <p className="text-muted-foreground text-xs leading-none">
Amministratore Amministratore
@ -136,10 +133,10 @@ export const UserHeaderSection = ({
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
<p className="font-medium text-sm leading-none"> <p className="font-medium text-sm leading-none">
{session.user.username} {user.username}
</p> </p>
<p className="text-muted-foreground text-xs leading-none"> <p className="text-muted-foreground text-xs leading-none">
{session.user.email} {user.email}
</p> </p>
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>

View file

@ -121,14 +121,14 @@ const AnnuncioDettaglio: NextPage<AnnuncioProps> = ({
}; };
const AnnuncioView = ({ data, flag }: AnnuncioProps) => { const AnnuncioView = ({ data, flag }: AnnuncioProps) => {
const session = useSession(); const { status, user } = useSession();
useStaleImageReload(); useStaleImageReload();
const isDesktop = useMediaQuery("(min-width: 768px)"); const isDesktop = useMediaQuery("(min-width: 768px)");
if ( if (
!data || !data ||
session.status === "error" || status !== "AUTHENTICATED" ||
(data && data.stato === "Sospeso") (data && data.stato === "Sospeso")
) { ) {
return <FailedAnnuncioLoading />; return <FailedAnnuncioLoading />;
@ -150,7 +150,7 @@ const AnnuncioView = ({ data, flag }: AnnuncioProps) => {
<div className="mb-6 flex flex-col md:mb-0 md:w-1/3"> <div className="mb-6 flex flex-col md:mb-0 md:w-1/3">
<div className="flex w-full flex-col flex-nowrap items-center justify-center gap-6"> <div className="flex w-full flex-col flex-nowrap items-center justify-center gap-6">
<CardInfos /> <CardInfos />
{session.user?.isAdmin ? ( {user?.isAdmin ? (
<> <>
<Link <Link
className="w-full" className="w-full"
@ -175,10 +175,7 @@ const AnnuncioView = ({ data, flag }: AnnuncioProps) => {
</Link> </Link>
</> </>
) : ( ) : (
<AnnuncioInteractions <AnnuncioInteractions disabled={flag === "true"} />
disabled={flag === "true"}
session={session}
/>
)} )}
<ComeFunziona /> <ComeFunziona />
{data.tipo && {data.tipo &&

View file

@ -9,6 +9,9 @@ export default createNextApiHandler({
createContext: createTRPCContext, createContext: createTRPCContext,
onError: ({ path, error }) => { onError: ({ path, error }) => {
if (error.message.startsWith("SESSION_EXPIRED")) {
return;
}
if (env.NODE_ENV === "development") { if (env.NODE_ENV === "development") {
console.error( console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}, msg: ${error.message}`, `❌ tRPC failed on ${path ?? "<no-path>"}, msg: ${error.message}`,

View file

@ -15,7 +15,7 @@ import { api } from "~/utils/api";
* Pagina di gestione chat per admin: /area-riservata/admin/chats * Pagina di gestione chat per admin: /area-riservata/admin/chats
*/ */
const AdminChats: NextPageWithLayout = () => { const AdminChats: NextPageWithLayout = () => {
const session = useSession(); const { status } = useSession();
const { data: activeChats, isLoading: isLoadingActiveChats } = const { data: activeChats, isLoading: isLoadingActiveChats } =
api.chat.getActiveChats.useQuery(); api.chat.getActiveChats.useQuery();
@ -23,14 +23,10 @@ const AdminChats: NextPageWithLayout = () => {
const { data: inactiveUsers, isLoading: isLoadingInactiveUsers } = const { data: inactiveUsers, isLoading: isLoadingInactiveUsers } =
api.users.getActiveUsersWithoutChat.useQuery(); api.users.getActiveUsersWithoutChat.useQuery();
if ( if (status === "LOADING" || isLoadingActiveChats || isLoadingInactiveUsers)
session.status === "pending" ||
isLoadingActiveChats ||
isLoadingInactiveUsers
)
return <LoadingPage />; return <LoadingPage />;
if (session.status !== "success" || !session.user) { if (status !== "AUTHENTICATED") {
window.location.reload(); window.location.reload();
return <LoadingPage />; return <LoadingPage />;
} }

View file

@ -11,11 +11,11 @@ import { useSession } from "~/providers/SessionProvider";
*/ */
const AggiornaPasswordPage: NextPage = () => { const AggiornaPasswordPage: NextPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const session = useSession(); const { status } = useSession();
if (session.status === "pending") { if (status === "LOADING") {
return <LoadingPage />; return <LoadingPage />;
} }
if (session.status === "error" || !session.user) { if (status !== "AUTHENTICATED") {
window.location.reload(); window.location.reload();
return <Status500 />; return <Status500 />;
} }

View file

@ -6,17 +6,25 @@ import {
useContext, useContext,
useEffect, useEffect,
} from "react"; } from "react";
import type { Session, ValidSession } from "~/server/api/trpc"; import { LoadingPage } from "~/components/loading";
import type { Session, SessionStatus } from "~/server/api/trpc";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
type SessionStatus = "pending" | "success" | "error"; export type ValidSession = {
export type SessionContextType = { user: Session;
user: Session | null; status: Extract<SessionStatus, "AUTHENTICATED">;
status: SessionStatus;
}; };
type InvalidStatus = Exclude<SessionStatus, "AUTHENTICATED"> | "LOADING";
export type InvalidSession = {
status: InvalidStatus;
user: null;
};
export type SessionContextType = ValidSession | InvalidSession;
const SessionContext = createContext<SessionContextType>({ const SessionContext = createContext<SessionContextType>({
status: "pending", status: "LOADING",
user: null, user: null,
}); });
@ -24,24 +32,20 @@ export const SessionProvider: FC<{ children: ReactNode }> = ({ children }) => {
const router = useRouter(); const router = useRouter();
const utils = api.useUtils(); const utils = api.useUtils();
const { const { data, isLoading } = api.auth.getSession.useQuery(undefined, {
data: user,
status,
isLoading,
isError,
error,
} = api.auth.getSession.useQuery(undefined, {
refetchInterval: 30000, refetchInterval: 30000,
refetchOnMount: true, //refetchOnMount: true,
refetchOnReconnect: true, //refetchOnReconnect: true,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
staleTime: 0, staleTime: Infinity,
retry: false,
meta: {
isSessionQuery: true,
},
}); });
if (isError && error) { const session = data?.session ?? null;
console.error("Session fetch error: ", error); const sessionStatus = data?.sessionStatus ?? "LOADING";
window.location.reload();
}
// Invalidate session on route change // Invalidate session on route change
useEffect(() => { useEffect(() => {
@ -58,17 +62,28 @@ export const SessionProvider: FC<{ children: ReactNode }> = ({ children }) => {
}, [router, utils]); }, [router, utils]);
// Invalidate session if user is null // Invalidate session if user is null
useEffect(() => { // useEffect(() => {
if (!user) { // if (!session && status === "success") {
utils.auth.getSession.invalidate().catch((error: Error) => { // utils.auth.getSession.invalidate().catch((error: Error) => {
console.error("Error during session invalidation:", error); // console.error("Error during session invalidation:", error);
}); // });
// }
// }, [session, status]);
const sessionValue: SessionContextType =
session && sessionStatus === "AUTHENTICATED"
? {
user: session, // TypeScript knows user is non-null here
status: "AUTHENTICATED", // matches ValidSession
} }
}, [user]); : {
user: null, // matches InvalidSession
status: sessionStatus as InvalidStatus, // TypeScript knows this is not "AUTHENTICATED"
};
return ( return (
<SessionContext.Provider value={{ status, user: user || null }}> <SessionContext.Provider value={sessionValue}>
{children} {isLoading ? <LoadingPage /> : children}
</SessionContext.Provider> </SessionContext.Provider>
); );
}; };

View file

@ -33,8 +33,8 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
return new NextResponse("Unauthorized", { status: 401 }); return new NextResponse("Unauthorized", { status: 401 });
} }
const payload = await verifyToken(accessToken); const { expired, session } = await verifyToken(accessToken);
if (!payload) { if (!session || expired) {
console.log("Invalid token, referrer:", req.referrer, " url:", req.url); console.log("Invalid token, referrer:", req.referrer, " url:", req.url);
return new NextResponse("Unauthorized", { status: 401 }); return new NextResponse("Unauthorized", { status: 401 });
} }

View file

@ -50,8 +50,12 @@ export const authProxy: ProxyFn = async (req: NextRequest) => {
} }
// If access token is present, check if it's valid // If access token is present, check if it's valid
const accessTokenPayload = await verifyToken(accessToken); const { expired, session } = await verifyToken(accessToken);
if (!accessTokenPayload) { if (expired && refreshToken) {
// If access token is expired but refresh token is present, attempt to refresh
return await refreshAuthToken(req, refreshToken, false);
}
if (!session) {
// If access token is invalid, delete it and redirect to login // If access token is invalid, delete it and redirect to login
const destination = new URL("/login", req.nextUrl); const destination = new URL("/login", req.nextUrl);
destination.searchParams.set("redirect", path); destination.searchParams.set("redirect", path);
@ -61,7 +65,7 @@ export const authProxy: ProxyFn = async (req: NextRequest) => {
} }
if (path.startsWith("/area-riservata/admin")) { if (path.startsWith("/area-riservata/admin")) {
if (!accessTokenPayload.isAdmin) { if (!session.isAdmin) {
return NextResponse.redirect( return NextResponse.redirect(
new URL("/area-riservata/dashboard", req.nextUrl), new URL("/area-riservata/dashboard", req.nextUrl),
); );
@ -87,9 +91,12 @@ export const authProxy: ProxyFn = async (req: NextRequest) => {
} }
// If no redirect present, redirect to dashboard // If no redirect present, redirect to dashboard
const payload = await verifyToken(accessToken); const { expired, session } = await verifyToken(accessToken);
if (payload) { if (expired && refreshToken) {
if (payload.isAdmin) { return await refreshAuthToken(req, refreshToken, true);
}
if (session && !expired) {
if (session.isAdmin) {
return NextResponse.redirect( return NextResponse.redirect(
new URL("/area-riservata/dashboard", req.nextUrl), new URL("/area-riservata/dashboard", req.nextUrl),
); );
@ -118,8 +125,8 @@ export const authProxy: ProxyFn = async (req: NextRequest) => {
return await refreshAuthToken(req, refreshToken, false); return await refreshAuthToken(req, refreshToken, false);
} }
} else { } else {
const payload = await verifyToken(accessToken); const { expired, session } = await verifyToken(accessToken);
if (!payload) { if (!session || expired) {
if (refreshToken) { if (refreshToken) {
return await refreshAuthToken(req, refreshToken, false); return await refreshAuthToken(req, refreshToken, false);
} }
@ -138,9 +145,10 @@ const refreshAuthToken = async (
refreshToken: string, refreshToken: string,
onSuccessDashboard: boolean, onSuccessDashboard: boolean,
) => { ) => {
//console.log("Attempting to refresh access token")
// If refresh token is present, verify it // If refresh token is present, verify it
const refreshTokenPayload = await verifyToken(refreshToken); const { expired, session } = await verifyToken(refreshToken);
if (!refreshTokenPayload) { if (!session || expired) {
// If refresh token is invalid, delete it and redirect to login // If refresh token is invalid, delete it and redirect to login
const destination = new URL("/login", req.nextUrl); const destination = new URL("/login", req.nextUrl);
destination.searchParams.set("redirect", req.nextUrl.pathname); destination.searchParams.set("redirect", req.nextUrl.pathname);
@ -156,7 +164,7 @@ const refreshAuthToken = async (
const response = NextResponse.redirect(new URL(destination, req.nextUrl)); const response = NextResponse.redirect(new URL(destination, req.nextUrl));
const newAccessToken = await signToken( const newAccessToken = await signToken(
refreshTokenPayload, session,
TOKEN_CONFIG.ACCESS_TOKEN_EXPIRY, TOKEN_CONFIG.ACCESS_TOKEN_EXPIRY,
"accessToken", "accessToken",
); );

View file

@ -16,11 +16,11 @@ export const pswChangeProxy: ProxyFn = async (req: NextRequest) => {
return; return;
} }
const accessTokenPayload = await verifyToken(accessToken); const { expired, session } = await verifyToken(accessToken);
if (!accessTokenPayload) { if (!session || expired) {
return; return;
} }
if (accessTokenPayload.mustChangePassword) { if (session.mustChangePassword) {
return NextResponse.redirect( return NextResponse.redirect(
new URL("/auth/aggiorna-password", req.nextUrl), new URL("/auth/aggiorna-password", req.nextUrl),
); );

View file

@ -73,7 +73,7 @@ export const authRouter = createTRPCRouter({
getSession: publicProcedure.query(async ({ ctx }) => { getSession: publicProcedure.query(async ({ ctx }) => {
if (ctx.session?.isBlocked) return null; if (ctx.session?.isBlocked) return null;
return ctx.session; return { session: ctx.session, sessionStatus: ctx.sessionStatus };
}), }),
loginUser: publicProcedure loginUser: publicProcedure
.input( .input(
@ -90,7 +90,7 @@ export const authRouter = createTRPCRouter({
}), }),
logoutUser: protectedProcedure.mutation(async ({ ctx }) => { logoutUser: protectedProcedure.mutation(async ({ ctx }) => {
await logOut({ ctx: ctx }); await logOut({ req: ctx.req, res: ctx.res });
return "logout success"; return "logout success";
}), }),

View file

@ -9,7 +9,6 @@
import { initTRPC, TRPCError } from "@trpc/server"; import { initTRPC, TRPCError } from "@trpc/server";
import type { CreateNextContextOptions } from "@trpc/server/adapters/next"; import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
import type { NextApiRequest, NextApiResponse } from "next";
import superjson from "superjson"; import superjson from "superjson";
import z, { ZodError } from "zod/v4"; import z, { ZodError } from "zod/v4";
import { env } from "~/env"; import { env } from "~/env";
@ -36,23 +35,40 @@ export const sessionSchema = z.object({
export type Session = z.infer<typeof sessionSchema>; export type Session = z.infer<typeof sessionSchema>;
export type ValidSession = { export type SessionStatus =
user: Session; | "AUTHENTICATED"
status: "success"; | "EXPIRED"
}; | "INVALID"
| "NO_SESSION";
interface CreateInnerContextOptions extends Partial<CreateNextContextOptions> { interface CreateInnerContextOptions extends Partial<CreateNextContextOptions> {
session: Session | null; accessToken?: string;
res?: NextApiResponse; }
req?: NextApiRequest; export const createInnerTRPCContext = async ({
accessToken,
...opts
}: CreateInnerContextOptions) => {
let sessionData: Session | null = null;
let sessionStatus: SessionStatus = "NO_SESSION";
if (accessToken) {
const { expired, session } = await verifyToken(accessToken);
if (expired) {
sessionStatus = "EXPIRED";
} else if (session) {
sessionData = session;
sessionStatus = "AUTHENTICATED";
} else {
sessionStatus = "INVALID";
}
} }
export function createInnerTRPCContext(opts: CreateInnerContextOptions) {
return { return {
...opts, ...opts,
session: opts.session, session: sessionData,
sessionStatus,
};
}; };
}
export const createTRPCContext = async ({ export const createTRPCContext = async ({
req, req,
@ -63,19 +79,11 @@ export const createTRPCContext = async ({
"You are missing `req` or `res` in your call to createTRPCContext.", "You are missing `req` or `res` in your call to createTRPCContext.",
); );
} }
const accessToken = req.cookies[TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME]; const accessToken = req.cookies[TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME];
// Get the session from the server using the getServerSession wrapper function
const session = accessToken ? await verifyToken(accessToken) : null; return await createInnerTRPCContext({ accessToken, req, res });
const contextInner = createInnerTRPCContext({ session });
return {
...contextInner,
req,
res,
}; };
}; export type Context = Awaited<ReturnType<typeof createTRPCContext>>;
export type Context = Awaited<ReturnType<typeof createInnerTRPCContext>>;
export const addApiAuthToContext = (ctx: Context) => { export const addApiAuthToContext = (ctx: Context) => {
if (!ctx.req || !ctx.res) { if (!ctx.req || !ctx.res) {
throw new Error( throw new Error(
@ -146,6 +154,12 @@ export const publicProcedure = t.procedure;
/** Reusable middleware that enforces users are logged in before running the procedure. */ /** Reusable middleware that enforces users are logged in before running the procedure. */
const enforceUserIsAuthed = t.middleware(({ ctx, next, path }) => { const enforceUserIsAuthed = t.middleware(({ ctx, next, path }) => {
if (ctx.sessionStatus === "EXPIRED") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: `SESSION_EXPIRED, error in path: ${path} [${ctx.req?.url}]`,
});
}
if (!ctx.session) { if (!ctx.session) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@ -202,6 +216,12 @@ export const overridableProcedure = t.procedure.use(
/** Reusable middleware that enforces users are logged in before running the procedure. */ /** Reusable middleware that enforces users are logged in before running the procedure. */
const enforceUserIsAdmin = t.middleware(({ ctx, next, path }) => { const enforceUserIsAdmin = t.middleware(({ ctx, next, path }) => {
if (ctx.sessionStatus === "EXPIRED") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: `SESSION_EXPIRED, error in path: ${path} [${ctx.req?.url}]`,
});
}
if (!ctx.session || !ctx.session.isAdmin) { if (!ctx.session || !ctx.session.isAdmin) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",

View file

@ -2,9 +2,9 @@ export const TOKEN_CONFIG = {
ACCESS_TOKEN_COOKIE_NAME: "access_token", ACCESS_TOKEN_COOKIE_NAME: "access_token",
REFRESH_TOKEN_COOKIE_NAME: "refresh_token", REFRESH_TOKEN_COOKIE_NAME: "refresh_token",
OVERRIDE_TOKEN_COOKIE_NAME: "override_token", OVERRIDE_TOKEN_COOKIE_NAME: "override_token",
ACCESS_TOKEN_EXPIRY: "1h", ACCESS_TOKEN_EXPIRY: "2h",
REFRESH_TOKEN_EXPIRY: "7d", REFRESH_TOKEN_EXPIRY: "7d",
ACCESS_TOKEN_COOKIE_EXPIRY: 1, ACCESS_TOKEN_COOKIE_EXPIRY: 2,
REFRESH_TOKEN_COOKIE_EXPIRY: 7, REFRESH_TOKEN_COOKIE_EXPIRY: 7,
COOKIE_OPTIONS: { COOKIE_OPTIONS: {
httpOnly: true, httpOnly: true,

View file

@ -1,5 +1,6 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { jwtVerify, SignJWT } from "jose"; import { jwtVerify, SignJWT } from "jose";
import { JWTExpired } from "jose/errors";
import { env } from "~/env"; import { env } from "~/env";
import { type Session, sessionSchema } from "~/server/api/trpc"; import { type Session, sessionSchema } from "~/server/api/trpc";
@ -24,17 +25,40 @@ export async function signToken(
} }
} }
export async function verifyToken(token: string): Promise<Session | null> { type VerifySuccess = {
expired: false;
session: Session;
};
type VerifyExpired = {
expired: true;
session: null;
};
type VerifyFailure = {
expired: boolean;
session: null;
};
type VerifyResult = VerifySuccess | VerifyExpired | VerifyFailure;
export async function verifyToken(token: string): Promise<VerifyResult> {
try { try {
const { payload } = await jwtVerify(token, secret); const { payload } = await jwtVerify(token, secret, {
requiredClaims: ["exp"],
});
const parse = sessionSchema.safeParse(payload.payload); const parse = sessionSchema.safeParse(payload.payload);
if (!parse.success) { if (!parse.success) {
//console.error("JWT payload invalid:", parse.error); //console.error("JWT payload invalid:", parse.error);
return null; return { expired: false, session: null };
} }
return parse.data; return { expired: false, session: parse.data };
} catch (e) { } catch (e) {
if (e instanceof JWTExpired) {
return { expired: true, session: null };
}
console.error("JWT verification Internal Error:", (e as Error).message); console.error("JWT verification Internal Error:", (e as Error).message);
return null; return { expired: false, session: null };
} }
} }

View file

@ -1,6 +1,7 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { deleteCookie, setCookie } from "cookies-next"; import { deleteCookie, setCookie } from "cookies-next";
import { add } from "date-fns"; import { add } from "date-fns";
import type { NextApiRequest, NextApiResponse } from "next";
import type { UsersId } from "~/schemas/public/Users"; import type { UsersId } from "~/schemas/public/Users";
import type { Context } from "~/server/api/trpc"; import type { Context } from "~/server/api/trpc";
import { signToken } from "~/server/auth/jwt"; import { signToken } from "~/server/auth/jwt";
@ -226,7 +227,13 @@ export const swapAuth = async ({
} }
}; };
export const logOut = async ({ ctx: { req, res } }: { ctx: Context }) => { export const logOut = async ({
req,
res,
}: {
req?: NextApiRequest;
res?: NextApiResponse;
}) => {
checkCtx({ req, res }); checkCtx({ req, res });
try { try {
await deleteCookie(TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME, { req, res }); await deleteCookie(TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME, { req, res });

View file

@ -6,14 +6,14 @@ import { verifyToken } from "~/server/auth/jwt";
export const generateSSGHelper = () => export const generateSSGHelper = () =>
createServerSideHelpers({ createServerSideHelpers({
ctx: { session: null }, ctx: { session: null, sessionStatus: "NO_SESSION" },
router: appRouter, router: appRouter,
transformer: superjson, // optional - adds superjson serialization transformer: superjson, // optional - adds superjson serialization
}); });
const generateHelperWSession = (session: Session) => const generateHelperWSession = (session: Session) =>
createServerSideHelpers({ createServerSideHelpers({
ctx: { session: session }, ctx: { session: session, sessionStatus: "AUTHENTICATED" },
router: appRouter, router: appRouter,
transformer: superjson, // optional - adds superjson serialization transformer: superjson, // optional - adds superjson serialization
}); });
@ -24,9 +24,9 @@ export const TrpcAuthedFetchingIstance = async ({
access_token: string | undefined; access_token: string | undefined;
}) => { }) => {
if (access_token) { if (access_token) {
const session = await verifyToken(access_token); const { expired, session } = await verifyToken(access_token);
if (session) { if (!expired && session) {
const trpc = generateHelperWSession(session); const trpc = generateHelperWSession(session);
return { session, trpc }; return { session, trpc };

View file

@ -27,6 +27,11 @@ const errorLink: TRPCLink<AppRouter> = () => {
const unsubscribe = next(op).subscribe({ const unsubscribe = next(op).subscribe({
complete: observer.complete, complete: observer.complete,
error(err) { error(err) {
if (err.message.startsWith("SESSION_EXPIRED")) {
// Silently reload — the auth proxy will refresh the token
window.location.reload();
return;
}
if ( if (
(err instanceof TRPCClientError && (err instanceof TRPCClientError &&
err.data?.code === "UNAUTHORIZED") || err.data?.code === "UNAUTHORIZED") ||
@ -34,6 +39,10 @@ const errorLink: TRPCLink<AppRouter> = () => {
err.data?.code === "CONFLICT" || err.data?.code === "CONFLICT" ||
err.data?.code === "NOT_FOUND" err.data?.code === "NOT_FOUND"
) { ) {
console.warn(
`Received ${err.data?.code} error from tRPC API, reloading the page to trigger auth flow or show error page.`,
err,
);
window.location.reload(); window.location.reload();
} }
observer.error(err); observer.error(err);
@ -54,7 +63,9 @@ export const api = createTRPCNext<AppRouter>({
enabled: (opts) => enabled: (opts) =>
(process.env.NODE_ENV === "development" && (process.env.NODE_ENV === "development" &&
typeof window !== "undefined") || typeof window !== "undefined") ||
(opts.direction === "down" && opts.result instanceof Error), (opts.direction === "down" &&
opts.result instanceof Error &&
!opts.result.message.startsWith("SESSION_EXPIRED")),
}), }),
splitLink({ splitLink({