refactor: update session handling across components to use new session structure and improve loading states
This commit is contained in:
parent
621d227760
commit
3f477ca3b8
18 changed files with 226 additions and 132 deletions
|
|
@ -16,7 +16,6 @@ import {
|
|||
useSession,
|
||||
} from "~/providers/SessionProvider";
|
||||
import type { UsersId } from "~/schemas/public/Users";
|
||||
import type { ValidSession } from "~/server/api/trpc";
|
||||
import { api } from "~/utils/api";
|
||||
import { UserViewHeader } from "./area-riservata/userViewHeader";
|
||||
|
||||
|
|
@ -73,9 +72,9 @@ export const AreaRiservataLayout = ({
|
|||
const { data: bannerData } = api.banners.getBannerData.useQuery({
|
||||
area_riservata: true,
|
||||
});
|
||||
const session = useSession();
|
||||
const { status, user } = useSession();
|
||||
if (!ignoreSessionCheck) {
|
||||
if (session.status === "pending")
|
||||
if (status === "LOADING")
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -97,7 +96,7 @@ export const AreaRiservataLayout = ({
|
|||
</main>
|
||||
</div>
|
||||
);
|
||||
if (session.status !== "success" || !session.user) {
|
||||
if (status !== "AUTHENTICATED") {
|
||||
window.location.reload();
|
||||
return (
|
||||
<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 (
|
||||
<div
|
||||
|
|
@ -143,10 +163,10 @@ export const AreaRiservataLayout = ({
|
|||
{noSidebar ? null : (
|
||||
<Sidebar
|
||||
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}
|
||||
</EnforcedSessionContext.Provider>
|
||||
</div>
|
||||
|
|
@ -160,12 +180,12 @@ export const AreaRiservataLayoutUserView = ({
|
|||
}: {
|
||||
children: ReactNode & { props: { userId: UsersId } };
|
||||
}) => {
|
||||
const session = useSession();
|
||||
const { status, user } = useSession();
|
||||
const props = children.props as { userId: UsersId };
|
||||
if (session.status === "pending") {
|
||||
if (status === "LOADING") {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
if (session.status === "error" || !session.user) {
|
||||
if (status !== "AUTHENTICATED") {
|
||||
window.location.reload();
|
||||
return <LoadingPage />;
|
||||
}
|
||||
|
|
@ -174,7 +194,7 @@ export const AreaRiservataLayoutUserView = ({
|
|||
<SiteHeader />
|
||||
<main className="flex h-full flex-1 overflow-auto">
|
||||
<UserViewProvider userId={props.userId}>
|
||||
<EnforcedSessionContext.Provider value={session as ValidSession}>
|
||||
<EnforcedSessionContext.Provider value={{ user, status }}>
|
||||
<Head>
|
||||
<title>User View - Infoalloggi.it</title>
|
||||
</Head>
|
||||
|
|
|
|||
|
|
@ -7,20 +7,16 @@ import { Button } from "~/components/ui/button";
|
|||
import { useAnnuncio } from "~/providers/AnnuncioProvider";
|
||||
import { useTranslation } from "~/providers/I18nProvider";
|
||||
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 { UsersId } from "~/schemas/public/Users";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export const AnnuncioInteractions = ({
|
||||
session,
|
||||
disabled,
|
||||
}: {
|
||||
session: SessionContextType;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
export const AnnuncioInteractions = ({ disabled }: { disabled?: boolean }) => {
|
||||
const { user } = useSession();
|
||||
const { id, tipo } = useAnnuncio();
|
||||
const { locale } = useTranslation();
|
||||
if (!user) return null;
|
||||
if (disabled) {
|
||||
return (
|
||||
<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 (
|
||||
<div className="flex w-full flex-col justify-around gap-3 md:gap-5">
|
||||
{session.user ? (
|
||||
{user ? (
|
||||
tipo ? (
|
||||
<ServizioInteraction
|
||||
annuncioId={id}
|
||||
tipologia={tipo}
|
||||
userId={session.user.id}
|
||||
userId={user.id}
|
||||
/>
|
||||
) : (
|
||||
<span>{locale === "it" ? "Errore" : "Error"}</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
"use client";
|
||||
import { LogIn, LogOut } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
|
@ -35,7 +34,7 @@ export const UserHeaderSection = ({
|
|||
inSidebar,
|
||||
}: LoginButtonProps) => {
|
||||
const router = useRouter();
|
||||
const session = useSession();
|
||||
const { status, user } = useSession();
|
||||
const { t } = useTranslation();
|
||||
const path = usePathname();
|
||||
const utils = api.useUtils();
|
||||
|
|
@ -51,7 +50,7 @@ export const UserHeaderSection = ({
|
|||
},
|
||||
});
|
||||
|
||||
if (session.status === "pending") {
|
||||
if (status === "LOADING") {
|
||||
return (
|
||||
<div className="flex items-center px-3 py-2">
|
||||
<LoadingSpinner className="size-5" />
|
||||
|
|
@ -61,7 +60,7 @@ export const UserHeaderSection = ({
|
|||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{!session || !session.user ? (
|
||||
{status !== "AUTHENTICATED" ? (
|
||||
<Button
|
||||
aria-label="Login Button"
|
||||
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
|
||||
className="size-10"
|
||||
userId={session.user.id}
|
||||
username={session.user.username}
|
||||
userId={user.id}
|
||||
username={user.username}
|
||||
/>
|
||||
{inSidebar ? (
|
||||
<span>{session.user.username}</span>
|
||||
<span>{user.username}</span>
|
||||
) : (
|
||||
<span className="navqry:block hidden">
|
||||
{session.user.username}
|
||||
</span>
|
||||
<span className="navqry:block hidden">{user.username}</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56" forceMount>
|
||||
{session.user.isAdmin ? (
|
||||
{user.isAdmin ? (
|
||||
<>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="font-medium text-sm leading-none">
|
||||
{session.user.username}
|
||||
{user.username}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-none">
|
||||
Amministratore
|
||||
|
|
@ -136,10 +133,10 @@ export const UserHeaderSection = ({
|
|||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="font-medium text-sm leading-none">
|
||||
{session.user.username}
|
||||
{user.username}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-none">
|
||||
{session.user.email}
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
|
|
|||
|
|
@ -121,14 +121,14 @@ const AnnuncioDettaglio: NextPage<AnnuncioProps> = ({
|
|||
};
|
||||
|
||||
const AnnuncioView = ({ data, flag }: AnnuncioProps) => {
|
||||
const session = useSession();
|
||||
const { status, user } = useSession();
|
||||
useStaleImageReload();
|
||||
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
if (
|
||||
!data ||
|
||||
session.status === "error" ||
|
||||
status !== "AUTHENTICATED" ||
|
||||
(data && data.stato === "Sospeso")
|
||||
) {
|
||||
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="flex w-full flex-col flex-nowrap items-center justify-center gap-6">
|
||||
<CardInfos />
|
||||
{session.user?.isAdmin ? (
|
||||
{user?.isAdmin ? (
|
||||
<>
|
||||
<Link
|
||||
className="w-full"
|
||||
|
|
@ -175,10 +175,7 @@ const AnnuncioView = ({ data, flag }: AnnuncioProps) => {
|
|||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<AnnuncioInteractions
|
||||
disabled={flag === "true"}
|
||||
session={session}
|
||||
/>
|
||||
<AnnuncioInteractions disabled={flag === "true"} />
|
||||
)}
|
||||
<ComeFunziona />
|
||||
{data.tipo &&
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ export default createNextApiHandler({
|
|||
createContext: createTRPCContext,
|
||||
|
||||
onError: ({ path, error }) => {
|
||||
if (error.message.startsWith("SESSION_EXPIRED")) {
|
||||
return;
|
||||
}
|
||||
if (env.NODE_ENV === "development") {
|
||||
console.error(
|
||||
`❌ tRPC failed on ${path ?? "<no-path>"}, msg: ${error.message}`,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { api } from "~/utils/api";
|
|||
* Pagina di gestione chat per admin: /area-riservata/admin/chats
|
||||
*/
|
||||
const AdminChats: NextPageWithLayout = () => {
|
||||
const session = useSession();
|
||||
const { status } = useSession();
|
||||
|
||||
const { data: activeChats, isLoading: isLoadingActiveChats } =
|
||||
api.chat.getActiveChats.useQuery();
|
||||
|
|
@ -23,14 +23,10 @@ const AdminChats: NextPageWithLayout = () => {
|
|||
const { data: inactiveUsers, isLoading: isLoadingInactiveUsers } =
|
||||
api.users.getActiveUsersWithoutChat.useQuery();
|
||||
|
||||
if (
|
||||
session.status === "pending" ||
|
||||
isLoadingActiveChats ||
|
||||
isLoadingInactiveUsers
|
||||
)
|
||||
if (status === "LOADING" || isLoadingActiveChats || isLoadingInactiveUsers)
|
||||
return <LoadingPage />;
|
||||
|
||||
if (session.status !== "success" || !session.user) {
|
||||
if (status !== "AUTHENTICATED") {
|
||||
window.location.reload();
|
||||
return <LoadingPage />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@ import { useSession } from "~/providers/SessionProvider";
|
|||
*/
|
||||
const AggiornaPasswordPage: NextPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
if (session.status === "pending") {
|
||||
const { status } = useSession();
|
||||
if (status === "LOADING") {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
if (session.status === "error" || !session.user) {
|
||||
if (status !== "AUTHENTICATED") {
|
||||
window.location.reload();
|
||||
return <Status500 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,17 +6,25 @@ import {
|
|||
useContext,
|
||||
useEffect,
|
||||
} 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";
|
||||
|
||||
type SessionStatus = "pending" | "success" | "error";
|
||||
export type SessionContextType = {
|
||||
user: Session | null;
|
||||
status: SessionStatus;
|
||||
export type ValidSession = {
|
||||
user: Session;
|
||||
status: Extract<SessionStatus, "AUTHENTICATED">;
|
||||
};
|
||||
|
||||
type InvalidStatus = Exclude<SessionStatus, "AUTHENTICATED"> | "LOADING";
|
||||
export type InvalidSession = {
|
||||
status: InvalidStatus;
|
||||
user: null;
|
||||
};
|
||||
|
||||
export type SessionContextType = ValidSession | InvalidSession;
|
||||
|
||||
const SessionContext = createContext<SessionContextType>({
|
||||
status: "pending",
|
||||
status: "LOADING",
|
||||
user: null,
|
||||
});
|
||||
|
||||
|
|
@ -24,24 +32,20 @@ export const SessionProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
|||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const {
|
||||
data: user,
|
||||
status,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = api.auth.getSession.useQuery(undefined, {
|
||||
const { data, isLoading } = api.auth.getSession.useQuery(undefined, {
|
||||
refetchInterval: 30000,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
//refetchOnMount: true,
|
||||
//refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 0,
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
meta: {
|
||||
isSessionQuery: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (isError && error) {
|
||||
console.error("Session fetch error: ", error);
|
||||
window.location.reload();
|
||||
}
|
||||
const session = data?.session ?? null;
|
||||
const sessionStatus = data?.sessionStatus ?? "LOADING";
|
||||
|
||||
// Invalidate session on route change
|
||||
useEffect(() => {
|
||||
|
|
@ -58,17 +62,28 @@ export const SessionProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
|||
}, [router, utils]);
|
||||
|
||||
// Invalidate session if user is null
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
utils.auth.getSession.invalidate().catch((error: Error) => {
|
||||
console.error("Error during session invalidation:", error);
|
||||
});
|
||||
// useEffect(() => {
|
||||
// if (!session && status === "success") {
|
||||
// utils.auth.getSession.invalidate().catch((error: 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 (
|
||||
<SessionContext.Provider value={{ status, user: user || null }}>
|
||||
{children}
|
||||
<SessionContext.Provider value={sessionValue}>
|
||||
{isLoading ? <LoadingPage /> : children}
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
|
|||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await verifyToken(accessToken);
|
||||
if (!payload) {
|
||||
const { expired, session } = await verifyToken(accessToken);
|
||||
if (!session || expired) {
|
||||
console.log("Invalid token, referrer:", req.referrer, " url:", req.url);
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,8 +50,12 @@ export const authProxy: ProxyFn = async (req: NextRequest) => {
|
|||
}
|
||||
|
||||
// If access token is present, check if it's valid
|
||||
const accessTokenPayload = await verifyToken(accessToken);
|
||||
if (!accessTokenPayload) {
|
||||
const { expired, session } = await verifyToken(accessToken);
|
||||
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
|
||||
const destination = new URL("/login", req.nextUrl);
|
||||
destination.searchParams.set("redirect", path);
|
||||
|
|
@ -61,7 +65,7 @@ export const authProxy: ProxyFn = async (req: NextRequest) => {
|
|||
}
|
||||
|
||||
if (path.startsWith("/area-riservata/admin")) {
|
||||
if (!accessTokenPayload.isAdmin) {
|
||||
if (!session.isAdmin) {
|
||||
return NextResponse.redirect(
|
||||
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
|
||||
const payload = await verifyToken(accessToken);
|
||||
if (payload) {
|
||||
if (payload.isAdmin) {
|
||||
const { expired, session } = await verifyToken(accessToken);
|
||||
if (expired && refreshToken) {
|
||||
return await refreshAuthToken(req, refreshToken, true);
|
||||
}
|
||||
if (session && !expired) {
|
||||
if (session.isAdmin) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/area-riservata/dashboard", req.nextUrl),
|
||||
);
|
||||
|
|
@ -118,8 +125,8 @@ export const authProxy: ProxyFn = async (req: NextRequest) => {
|
|||
return await refreshAuthToken(req, refreshToken, false);
|
||||
}
|
||||
} else {
|
||||
const payload = await verifyToken(accessToken);
|
||||
if (!payload) {
|
||||
const { expired, session } = await verifyToken(accessToken);
|
||||
if (!session || expired) {
|
||||
if (refreshToken) {
|
||||
return await refreshAuthToken(req, refreshToken, false);
|
||||
}
|
||||
|
|
@ -138,9 +145,10 @@ const refreshAuthToken = async (
|
|||
refreshToken: string,
|
||||
onSuccessDashboard: boolean,
|
||||
) => {
|
||||
//console.log("Attempting to refresh access token")
|
||||
// If refresh token is present, verify it
|
||||
const refreshTokenPayload = await verifyToken(refreshToken);
|
||||
if (!refreshTokenPayload) {
|
||||
const { expired, session } = await verifyToken(refreshToken);
|
||||
if (!session || expired) {
|
||||
// If refresh token is invalid, delete it and redirect to login
|
||||
const destination = new URL("/login", req.nextUrl);
|
||||
destination.searchParams.set("redirect", req.nextUrl.pathname);
|
||||
|
|
@ -156,7 +164,7 @@ const refreshAuthToken = async (
|
|||
const response = NextResponse.redirect(new URL(destination, req.nextUrl));
|
||||
|
||||
const newAccessToken = await signToken(
|
||||
refreshTokenPayload,
|
||||
session,
|
||||
TOKEN_CONFIG.ACCESS_TOKEN_EXPIRY,
|
||||
"accessToken",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ export const pswChangeProxy: ProxyFn = async (req: NextRequest) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const accessTokenPayload = await verifyToken(accessToken);
|
||||
if (!accessTokenPayload) {
|
||||
const { expired, session } = await verifyToken(accessToken);
|
||||
if (!session || expired) {
|
||||
return;
|
||||
}
|
||||
if (accessTokenPayload.mustChangePassword) {
|
||||
if (session.mustChangePassword) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/auth/aggiorna-password", req.nextUrl),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ export const authRouter = createTRPCRouter({
|
|||
|
||||
getSession: publicProcedure.query(async ({ ctx }) => {
|
||||
if (ctx.session?.isBlocked) return null;
|
||||
return ctx.session;
|
||||
return { session: ctx.session, sessionStatus: ctx.sessionStatus };
|
||||
}),
|
||||
loginUser: publicProcedure
|
||||
.input(
|
||||
|
|
@ -90,7 +90,7 @@ export const authRouter = createTRPCRouter({
|
|||
}),
|
||||
|
||||
logoutUser: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
await logOut({ ctx: ctx });
|
||||
await logOut({ req: ctx.req, res: ctx.res });
|
||||
return "logout success";
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import superjson from "superjson";
|
||||
import z, { ZodError } from "zod/v4";
|
||||
import { env } from "~/env";
|
||||
|
|
@ -36,23 +35,40 @@ export const sessionSchema = z.object({
|
|||
|
||||
export type Session = z.infer<typeof sessionSchema>;
|
||||
|
||||
export type ValidSession = {
|
||||
user: Session;
|
||||
status: "success";
|
||||
};
|
||||
export type SessionStatus =
|
||||
| "AUTHENTICATED"
|
||||
| "EXPIRED"
|
||||
| "INVALID"
|
||||
| "NO_SESSION";
|
||||
|
||||
interface CreateInnerContextOptions extends Partial<CreateNextContextOptions> {
|
||||
session: Session | null;
|
||||
res?: NextApiResponse;
|
||||
req?: NextApiRequest;
|
||||
accessToken?: string;
|
||||
}
|
||||
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 {
|
||||
...opts,
|
||||
session: opts.session,
|
||||
session: sessionData,
|
||||
sessionStatus,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const createTRPCContext = async ({
|
||||
req,
|
||||
|
|
@ -63,19 +79,11 @@ export const createTRPCContext = async ({
|
|||
"You are missing `req` or `res` in your call to createTRPCContext.",
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
const contextInner = createInnerTRPCContext({ session });
|
||||
return {
|
||||
...contextInner,
|
||||
req,
|
||||
res,
|
||||
return await createInnerTRPCContext({ accessToken, req, res });
|
||||
};
|
||||
};
|
||||
export type Context = Awaited<ReturnType<typeof createInnerTRPCContext>>;
|
||||
export type Context = Awaited<ReturnType<typeof createTRPCContext>>;
|
||||
export const addApiAuthToContext = (ctx: Context) => {
|
||||
if (!ctx.req || !ctx.res) {
|
||||
throw new Error(
|
||||
|
|
@ -146,6 +154,12 @@ export const publicProcedure = t.procedure;
|
|||
|
||||
/** Reusable middleware that enforces users are logged in before running the procedure. */
|
||||
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) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
|
|
@ -202,6 +216,12 @@ export const overridableProcedure = t.procedure.use(
|
|||
|
||||
/** Reusable middleware that enforces users are logged in before running the procedure. */
|
||||
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) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ export const TOKEN_CONFIG = {
|
|||
ACCESS_TOKEN_COOKIE_NAME: "access_token",
|
||||
REFRESH_TOKEN_COOKIE_NAME: "refresh_token",
|
||||
OVERRIDE_TOKEN_COOKIE_NAME: "override_token",
|
||||
ACCESS_TOKEN_EXPIRY: "1h",
|
||||
ACCESS_TOKEN_EXPIRY: "2h",
|
||||
REFRESH_TOKEN_EXPIRY: "7d",
|
||||
ACCESS_TOKEN_COOKIE_EXPIRY: 1,
|
||||
ACCESS_TOKEN_COOKIE_EXPIRY: 2,
|
||||
REFRESH_TOKEN_COOKIE_EXPIRY: 7,
|
||||
COOKIE_OPTIONS: {
|
||||
httpOnly: true,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { TRPCError } from "@trpc/server";
|
||||
import { jwtVerify, SignJWT } from "jose";
|
||||
import { JWTExpired } from "jose/errors";
|
||||
import { env } from "~/env";
|
||||
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 {
|
||||
const { payload } = await jwtVerify(token, secret);
|
||||
const { payload } = await jwtVerify(token, secret, {
|
||||
requiredClaims: ["exp"],
|
||||
});
|
||||
|
||||
const parse = sessionSchema.safeParse(payload.payload);
|
||||
if (!parse.success) {
|
||||
//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) {
|
||||
if (e instanceof JWTExpired) {
|
||||
return { expired: true, session: null };
|
||||
}
|
||||
console.error("JWT verification Internal Error:", (e as Error).message);
|
||||
return null;
|
||||
return { expired: false, session: null };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { TRPCError } from "@trpc/server";
|
||||
import { deleteCookie, setCookie } from "cookies-next";
|
||||
import { add } from "date-fns";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { UsersId } from "~/schemas/public/Users";
|
||||
import type { Context } from "~/server/api/trpc";
|
||||
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 });
|
||||
try {
|
||||
await deleteCookie(TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME, { req, res });
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ import { verifyToken } from "~/server/auth/jwt";
|
|||
|
||||
export const generateSSGHelper = () =>
|
||||
createServerSideHelpers({
|
||||
ctx: { session: null },
|
||||
ctx: { session: null, sessionStatus: "NO_SESSION" },
|
||||
router: appRouter,
|
||||
transformer: superjson, // optional - adds superjson serialization
|
||||
});
|
||||
|
||||
const generateHelperWSession = (session: Session) =>
|
||||
createServerSideHelpers({
|
||||
ctx: { session: session },
|
||||
ctx: { session: session, sessionStatus: "AUTHENTICATED" },
|
||||
router: appRouter,
|
||||
transformer: superjson, // optional - adds superjson serialization
|
||||
});
|
||||
|
|
@ -24,9 +24,9 @@ export const TrpcAuthedFetchingIstance = async ({
|
|||
access_token: string | undefined;
|
||||
}) => {
|
||||
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);
|
||||
|
||||
return { session, trpc };
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ const errorLink: TRPCLink<AppRouter> = () => {
|
|||
const unsubscribe = next(op).subscribe({
|
||||
complete: observer.complete,
|
||||
error(err) {
|
||||
if (err.message.startsWith("SESSION_EXPIRED")) {
|
||||
// Silently reload — the auth proxy will refresh the token
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(err instanceof TRPCClientError &&
|
||||
err.data?.code === "UNAUTHORIZED") ||
|
||||
|
|
@ -34,6 +39,10 @@ const errorLink: TRPCLink<AppRouter> = () => {
|
|||
err.data?.code === "CONFLICT" ||
|
||||
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();
|
||||
}
|
||||
observer.error(err);
|
||||
|
|
@ -54,7 +63,9 @@ export const api = createTRPCNext<AppRouter>({
|
|||
enabled: (opts) =>
|
||||
(process.env.NODE_ENV === "development" &&
|
||||
typeof window !== "undefined") ||
|
||||
(opts.direction === "down" && opts.result instanceof Error),
|
||||
(opts.direction === "down" &&
|
||||
opts.result instanceof Error &&
|
||||
!opts.result.message.startsWith("SESSION_EXPIRED")),
|
||||
}),
|
||||
|
||||
splitLink({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue