mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
152
apps/web-react/app/features/auth/core/DiscordStrategy.server.ts
Normal file
152
apps/web-react/app/features/auth/core/DiscordStrategy.server.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { add } from "date-fns";
|
||||
import { OAuth2Strategy } from "remix-auth-oauth2";
|
||||
import { z } from "zod";
|
||||
import { Config } from "~/config";
|
||||
import { ServerConfig } from "~/config.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
|
||||
let discordApiCooldownUntil: number | null = null;
|
||||
|
||||
const partialDiscordUserSchema = z.object({
|
||||
avatar: z.string().nullish(),
|
||||
discriminator: z.string(),
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
global_name: z.string().nullish(),
|
||||
verified: z.boolean().nullish(),
|
||||
});
|
||||
const partialDiscordConnectionsSchema = z.array(
|
||||
z.object({
|
||||
visibility: z.number(),
|
||||
verified: z.boolean(),
|
||||
name: z.string(),
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
}),
|
||||
);
|
||||
const discordUserDetailsSchema = z.tuple([
|
||||
partialDiscordUserSchema,
|
||||
partialDiscordConnectionsSchema,
|
||||
]);
|
||||
const discordRateLimitSchema = z.object({
|
||||
retry_after: z.number(),
|
||||
});
|
||||
|
||||
export const DiscordStrategy = () => {
|
||||
const jsonIfOk = async (res: Response) => {
|
||||
if (res.status === 429) {
|
||||
const body = discordRateLimitSchema.safeParse(await res.clone().json());
|
||||
const retryAfterSeconds = body.success ? body.data.retry_after : 60;
|
||||
discordApiCooldownUntil = add(new Date(), {
|
||||
seconds: retryAfterSeconds,
|
||||
}).getTime();
|
||||
logger.warn(
|
||||
`Discord API rate limited, cooldown for ${retryAfterSeconds}s${body.success ? "" : " (failed to parse retry_after)"}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Auth related call failed with status code ${res.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
};
|
||||
|
||||
const fetchProfileViaDiscordApi = (token: string) => {
|
||||
if (discordApiCooldownUntil && Date.now() < discordApiCooldownUntil) {
|
||||
throw new Error("Discord API is rate limited");
|
||||
}
|
||||
|
||||
const authHeader: [string, string] = ["Authorization", `Bearer ${token}`];
|
||||
|
||||
return Promise.all([
|
||||
fetch("https://discord.com/api/users/@me", {
|
||||
headers: [authHeader],
|
||||
}).then(jsonIfOk),
|
||||
fetch("https://discord.com/api/users/@me/connections", {
|
||||
headers: [authHeader],
|
||||
}).then(jsonIfOk),
|
||||
]);
|
||||
};
|
||||
|
||||
return new OAuth2Strategy(
|
||||
{
|
||||
clientId: ServerConfig.discord.clientId,
|
||||
clientSecret: ServerConfig.discord.clientSecret,
|
||||
|
||||
authorizationEndpoint: "https://discord.com/api/oauth2/authorize",
|
||||
tokenEndpoint: "https://discord.com/api/oauth2/token",
|
||||
redirectURI: new URL("/auth/callback", Config.siteDomain).toString(),
|
||||
|
||||
scopes: ["identify", "connections", "email"],
|
||||
},
|
||||
async ({ tokens }) => {
|
||||
try {
|
||||
const discordResponses = await fetchProfileViaDiscordApi(
|
||||
tokens.accessToken(),
|
||||
);
|
||||
|
||||
const [user, connections] =
|
||||
discordUserDetailsSchema.parse(discordResponses);
|
||||
|
||||
const isAlreadyRegistered = Boolean(
|
||||
await UserRepository.findIdByIdentifier(user.id),
|
||||
);
|
||||
|
||||
if (!isAlreadyRegistered && !user.verified) {
|
||||
logger.info(`User is not verified with id: ${user.id}`);
|
||||
throw new Error("Unverified user");
|
||||
}
|
||||
|
||||
const userFromDb = await UserRepository.upsert({
|
||||
discordAvatar: user.avatar ?? null,
|
||||
discordId: user.id,
|
||||
discordName: user.global_name ?? user.username,
|
||||
discordUniqueName: user.global_name ? user.username : null,
|
||||
...parseConnections(connections),
|
||||
});
|
||||
|
||||
return userFromDb.id;
|
||||
} catch (e) {
|
||||
logger.error("Failed to finish authentication:\n", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
function parseConnections(
|
||||
connections: z.infer<typeof partialDiscordConnectionsSchema>,
|
||||
) {
|
||||
if (!connections) throw new Error("No connections");
|
||||
|
||||
const result: {
|
||||
twitch: string | null;
|
||||
youtubeId: string | null;
|
||||
bsky: string | null;
|
||||
} = {
|
||||
twitch: null,
|
||||
youtubeId: null,
|
||||
bsky: null,
|
||||
};
|
||||
|
||||
for (const connection of connections) {
|
||||
if (connection.visibility !== 1 || !connection.verified) continue;
|
||||
|
||||
switch (connection.type) {
|
||||
case "twitch":
|
||||
result.twitch = connection.name;
|
||||
break;
|
||||
case "youtube":
|
||||
result.youtubeId = connection.id;
|
||||
break;
|
||||
case "bluesky":
|
||||
result.bsky = connection.name;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Authenticator } from "remix-auth";
|
||||
import { DiscordStrategy } from "./DiscordStrategy.server";
|
||||
|
||||
export const SESSION_KEY = "user";
|
||||
export const IMPERSONATED_SESSION_KEY = "impersonated_user";
|
||||
|
||||
export const authenticator = new Authenticator<number>();
|
||||
|
||||
authenticator.use(DiscordStrategy(), "discord");
|
||||
6
apps/web-react/app/features/auth/core/errors.ts
Normal file
6
apps/web-react/app/features/auth/core/errors.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type AuthErrorCode =
|
||||
| "aborted"
|
||||
| "discordOverloaded"
|
||||
| "unverifiedEmail"
|
||||
| "browserPrivacy"
|
||||
| "unknown";
|
||||
265
apps/web-react/app/features/auth/core/routes.server.ts
Normal file
265
apps/web-react/app/features/auth/core/routes.server.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { isbot } from "isbot";
|
||||
import type { ActionFunction, LoaderFunction } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { isAdmin, isStaff } from "~/modules/permissions/utils";
|
||||
import { logger } from "~/utils/logger";
|
||||
import {
|
||||
canAccessLohiEndpoint,
|
||||
errorToastRedirect,
|
||||
} from "~/utils/remix.server";
|
||||
import { ADMIN_PAGE, authErrorUrl } from "~/utils/urls";
|
||||
import * as LogInLinkRepository from "../LogInLinkRepository.server";
|
||||
import {
|
||||
authenticator,
|
||||
IMPERSONATED_SESSION_KEY,
|
||||
SESSION_KEY,
|
||||
} from "./authenticator.server";
|
||||
import type { AuthErrorCode } from "./errors";
|
||||
import { authSessionStorage } from "./session.server";
|
||||
import { getUser } from "./user.server";
|
||||
|
||||
export const callbackLoader: LoaderFunction = async ({ request, url }) => {
|
||||
// biome-ignore lint/plugin: OAuth callback param, its name and values defined by the provider
|
||||
if (url.searchParams.get("error") === "access_denied") {
|
||||
// The user denied the authentication request
|
||||
// https://www.oauth.com/oauth2-servers/server-side-apps/possible-errors/
|
||||
|
||||
throw redirect(authErrorUrl("aborted"));
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = await authenticator.authenticate("discord", request);
|
||||
|
||||
const session = await authSessionStorage.getSession(
|
||||
request.headers.get(SESSION_KEY),
|
||||
);
|
||||
|
||||
session.set(SESSION_KEY, userId);
|
||||
|
||||
return redirect("/", {
|
||||
headers: {
|
||||
"Set-Cookie": await authSessionStorage.commitSession(session),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error(
|
||||
`Error during authentication (${classifyAuthError(error)}):`,
|
||||
error,
|
||||
);
|
||||
throw redirect(authErrorUrl(classifyAuthError(error)));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const logOutAction: ActionFunction = async ({ request }) => {
|
||||
const session = await authSessionStorage.getSession(
|
||||
request.headers.get(SESSION_KEY),
|
||||
);
|
||||
return redirect("/", {
|
||||
headers: { "Set-Cookie": await authSessionStorage.destroySession(session) },
|
||||
});
|
||||
};
|
||||
|
||||
export const logInAction: ActionFunction = async ({ request }) => {
|
||||
return await authenticator.authenticate("discord", request);
|
||||
};
|
||||
|
||||
export const impersonateAction: ActionFunction = async ({ request, url }) => {
|
||||
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
|
||||
const user = requireUser();
|
||||
if (!user.roles.includes("ADMIN") && !user.roles.includes("DEV")) {
|
||||
throw new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
if (user.roles.includes("DEV") && !user.roles.includes("ADMIN")) {
|
||||
// biome-ignore lint/plugin: a missing or malformed `id` must 400, not fall back to a default
|
||||
const targetId = Number(url.searchParams.get("id"));
|
||||
if (isAdmin({ id: targetId }) || isStaff({ id: targetId })) {
|
||||
throw new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const returnTo = await safeReturnTo(request);
|
||||
|
||||
const session = await authSessionStorage.getSession(
|
||||
request.headers.get("Cookie"),
|
||||
);
|
||||
|
||||
const realUserId = session.get(SESSION_KEY);
|
||||
|
||||
// biome-ignore-start lint/plugin: a missing or malformed `id` must 400, not fall back to a default
|
||||
const rawId = url.searchParams.get("id");
|
||||
|
||||
const userId = Number(url.searchParams.get("id"));
|
||||
// biome-ignore-end lint/plugin: a missing or malformed `id` must 400, not fall back to a default
|
||||
if (!rawId || Number.isNaN(userId)) throw new Response(null, { status: 400 });
|
||||
|
||||
logger.info(
|
||||
`Impersonation: user ${realUserId} started impersonating user ${userId}`,
|
||||
);
|
||||
|
||||
session.set(IMPERSONATED_SESSION_KEY, userId);
|
||||
|
||||
throw redirect(returnTo ?? ADMIN_PAGE, {
|
||||
headers: { "Set-Cookie": await authSessionStorage.commitSession(session) },
|
||||
});
|
||||
};
|
||||
|
||||
export const stopImpersonatingAction: ActionFunction = async ({ request }) => {
|
||||
const returnTo = await safeReturnTo(request);
|
||||
|
||||
const session = await authSessionStorage.getSession(
|
||||
request.headers.get("Cookie"),
|
||||
);
|
||||
|
||||
const realUserId = session.get(SESSION_KEY);
|
||||
const impersonatedUserId = session.get(IMPERSONATED_SESSION_KEY);
|
||||
|
||||
logger.info(
|
||||
`Impersonation: user ${realUserId} stopped impersonating user ${impersonatedUserId}`,
|
||||
);
|
||||
|
||||
session.unset(IMPERSONATED_SESSION_KEY);
|
||||
|
||||
throw redirect(returnTo ?? ADMIN_PAGE, {
|
||||
headers: { "Set-Cookie": await authSessionStorage.commitSession(session) },
|
||||
});
|
||||
};
|
||||
|
||||
async function safeReturnTo(request: Request): Promise<string | null> {
|
||||
if (!request.headers.get("Content-Type")?.includes("form")) return null;
|
||||
|
||||
const value = (await request.formData()).get("returnTo");
|
||||
if (typeof value !== "string") return null;
|
||||
if (!value.startsWith("/") || value.startsWith("//")) return null;
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// below is alternative log-in flow that is operated via the Lohi Discord bot
|
||||
// this is intended primarily as a workaround when website is having problems communicating
|
||||
// with the Discord due to rate limits or other reasons
|
||||
|
||||
// only light validation here as we generally trust Lohi
|
||||
// auth flow params are infrastructure conventions and intentionally do not go
|
||||
// through app/modules/search-params/
|
||||
function parseSearchParams<T extends z.ZodTypeAny>({
|
||||
request,
|
||||
schema,
|
||||
}: {
|
||||
request: Request;
|
||||
schema: T;
|
||||
}): z.infer<T> {
|
||||
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
|
||||
|
||||
try {
|
||||
return schema.parse(searchParams);
|
||||
} catch (e) {
|
||||
logger.error("Error parsing search params", e);
|
||||
|
||||
throw errorToastRedirect("Validation failed");
|
||||
}
|
||||
}
|
||||
|
||||
const createLogInLinkActionSchema = z.object({
|
||||
discordId: z.string(),
|
||||
discordAvatar: z.string().nullish(),
|
||||
discordName: z.string(),
|
||||
discordUniqueName: z.string(),
|
||||
updateOnly: z.enum(["true", "false"]),
|
||||
});
|
||||
|
||||
export const createLogInLinkAction: ActionFunction = async ({ request }) => {
|
||||
const data = parseSearchParams({
|
||||
request,
|
||||
schema: createLogInLinkActionSchema,
|
||||
});
|
||||
|
||||
if (!canAccessLohiEndpoint(request)) {
|
||||
throw new Response(null, { status: 403 });
|
||||
}
|
||||
|
||||
const user = await UserRepository.upsert({
|
||||
discordAvatar: data.discordAvatar ?? null,
|
||||
discordId: data.discordId,
|
||||
discordName: data.discordName,
|
||||
discordUniqueName: data.discordUniqueName,
|
||||
});
|
||||
|
||||
if (data.updateOnly === "true") return null;
|
||||
|
||||
const createdLink = await LogInLinkRepository.insert(user.id);
|
||||
|
||||
return {
|
||||
code: createdLink.code,
|
||||
};
|
||||
};
|
||||
|
||||
const logInViaLinkActionSchema = z.object({
|
||||
code: z.string(),
|
||||
});
|
||||
|
||||
export const logInViaLinkLoader: LoaderFunction = async ({ request }) => {
|
||||
// make sure Discord link preview doesn't consume the login link
|
||||
const userAgent = request.headers.get("user-agent");
|
||||
if (userAgent && isbot(userAgent)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = parseSearchParams({
|
||||
request,
|
||||
schema: logInViaLinkActionSchema,
|
||||
});
|
||||
const user = getUser();
|
||||
|
||||
if (user) {
|
||||
throw redirect("/");
|
||||
}
|
||||
|
||||
const result = await LogInLinkRepository.findValidByCode(data.code);
|
||||
if (!result) {
|
||||
throw new Response("Invalid log in link", { status: 400 });
|
||||
}
|
||||
const userId = result.userId;
|
||||
|
||||
const session = await authSessionStorage.getSession(
|
||||
request.headers.get("Cookie"),
|
||||
);
|
||||
|
||||
session.set(SESSION_KEY, userId);
|
||||
|
||||
await LogInLinkRepository.deleteByCode(data.code);
|
||||
|
||||
throw redirect("/", {
|
||||
headers: { "Set-Cookie": await authSessionStorage.commitSession(session) },
|
||||
});
|
||||
};
|
||||
|
||||
function classifyAuthError(error: Error): AuthErrorCode {
|
||||
const message = error.message;
|
||||
|
||||
if (
|
||||
message.includes("rate limited") ||
|
||||
("status" in error && error.status === 429)
|
||||
) {
|
||||
return "discordOverloaded";
|
||||
}
|
||||
|
||||
if (message === "Unverified user") {
|
||||
return "unverifiedEmail";
|
||||
}
|
||||
|
||||
if (message.includes("Missing state")) {
|
||||
return "browserPrivacy";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
20
apps/web-react/app/features/auth/core/session.server.ts
Normal file
20
apps/web-react/app/features/auth/core/session.server.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createCookieSessionStorage } from "react-router";
|
||||
import { ServerConfig } from "~/config.server";
|
||||
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
|
||||
|
||||
const ONE_YEAR_IN_SECONDS = 31_536_000;
|
||||
|
||||
export const authSessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: "__session",
|
||||
sameSite: "lax",
|
||||
// need to specify domain so that sub-domains can access it
|
||||
domain:
|
||||
ServerConfig.isProduction && !IS_E2E_TEST_RUN ? "sendou.ink" : undefined,
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secrets: [ServerConfig.sessionSecret],
|
||||
secure: ServerConfig.isProduction && !IS_E2E_TEST_RUN,
|
||||
maxAge: ONE_YEAR_IN_SECONDS,
|
||||
},
|
||||
});
|
||||
51
apps/web-react/app/features/auth/core/user-context.server.ts
Normal file
51
apps/web-react/app/features/auth/core/user-context.server.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { redirect } from "react-router";
|
||||
import { userIsBanned } from "~/features/ban/core/banned.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { SUSPENDED_PAGE } from "~/utils/urls";
|
||||
import { IMPERSONATED_SESSION_KEY, SESSION_KEY } from "./authenticator.server";
|
||||
import { authSessionStorage } from "./session.server";
|
||||
|
||||
export type AuthenticatedUser = NonNullable<
|
||||
Awaited<ReturnType<typeof UserRepository.findLeanById>>
|
||||
>;
|
||||
|
||||
interface UserContext {
|
||||
user: AuthenticatedUser | undefined;
|
||||
}
|
||||
|
||||
export const userAsyncLocalStorage = new AsyncLocalStorage<UserContext>();
|
||||
|
||||
export function getUserContext(): UserContext {
|
||||
const context = userAsyncLocalStorage.getStore();
|
||||
if (!context) {
|
||||
throw new Error("getUserContext called outside of user middleware context");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function getUserFromRequest(
|
||||
request: Request,
|
||||
url: URL,
|
||||
): Promise<AuthenticatedUser | undefined> {
|
||||
const session = await authSessionStorage.getSession(
|
||||
request.headers.get("Cookie"),
|
||||
);
|
||||
|
||||
const userId =
|
||||
session.get(IMPERSONATED_SESSION_KEY) ?? session.get(SESSION_KEY);
|
||||
|
||||
if (!userId) return undefined;
|
||||
|
||||
if (userIsBanned(userId)) {
|
||||
const isExemptPath =
|
||||
url.pathname === SUSPENDED_PAGE ||
|
||||
// needed for ban E2E tests
|
||||
url.pathname.startsWith("/auth/impersonate");
|
||||
if (!isExemptPath) {
|
||||
throw redirect(SUSPENDED_PAGE);
|
||||
}
|
||||
}
|
||||
|
||||
return UserRepository.findLeanById(userId);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
getUserFromRequest,
|
||||
userAsyncLocalStorage,
|
||||
} from "./user-context.server";
|
||||
|
||||
type MiddlewareArgs = {
|
||||
request: Request;
|
||||
url: URL;
|
||||
context: unknown;
|
||||
};
|
||||
|
||||
type MiddlewareFn = (
|
||||
args: MiddlewareArgs,
|
||||
next: () => Promise<Response>,
|
||||
) => Promise<Response>;
|
||||
|
||||
export const userMiddleware: MiddlewareFn = async ({ request, url }, next) => {
|
||||
const user = await getUserFromRequest(request, url);
|
||||
|
||||
return userAsyncLocalStorage.run({ user }, () => next());
|
||||
};
|
||||
62
apps/web-react/app/features/auth/core/user.server.ts
Normal file
62
apps/web-react/app/features/auth/core/user.server.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { IMPERSONATED_SESSION_KEY, SESSION_KEY } from "./authenticator.server";
|
||||
import { authSessionStorage } from "./session.server";
|
||||
import {
|
||||
type AuthenticatedUser,
|
||||
getUserContext,
|
||||
userAsyncLocalStorage,
|
||||
} from "./user-context.server";
|
||||
|
||||
export type { AuthenticatedUser };
|
||||
|
||||
export function getUser(): AuthenticatedUser | undefined {
|
||||
const context = getUserContext();
|
||||
return context.user;
|
||||
}
|
||||
|
||||
export function requireUser(): AuthenticatedUser {
|
||||
const user = getUser();
|
||||
|
||||
if (!user) throw new Response(null, { status: 401 });
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Id of the acting user, from request context. Throws an Error if there is no
|
||||
* authenticated user (e.g. called outside a request) — repositories rely on a
|
||||
* bouncer having already enforced auth, so absence here is a bug, not a 401. */
|
||||
export function actorId(): number {
|
||||
const id = actorIdOrNull();
|
||||
if (id === null) throw new Error("No acting user in context");
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Id of the acting user, or null when unauthenticated. Use for reads that
|
||||
* also serve anonymous visitors, where the actor only scopes the result. */
|
||||
export function actorIdOrNull(): number | null {
|
||||
return getUser()?.id ?? null;
|
||||
}
|
||||
|
||||
/** Id of the acting user, or null when there is no actor *or* no request
|
||||
* context at all (e.g. cron routines). Never throws, unlike actorIdOrNull —
|
||||
* use for ambient side effects that may also run outside of a request. */
|
||||
export function actorIdOrNullSafe(): number | null {
|
||||
return userAsyncLocalStorage.getStore()?.user?.id ?? null;
|
||||
}
|
||||
|
||||
export async function isImpersonating(request: Request) {
|
||||
const session = await authSessionStorage.getSession(
|
||||
request.headers.get("Cookie"),
|
||||
);
|
||||
|
||||
return Boolean(session.get(IMPERSONATED_SESSION_KEY));
|
||||
}
|
||||
|
||||
export async function getRealUserId(
|
||||
request: Request,
|
||||
): Promise<number | undefined> {
|
||||
const session = await authSessionStorage.getSession(
|
||||
request.headers.get("Cookie"),
|
||||
);
|
||||
|
||||
return session.get(SESSION_KEY) as number | undefined;
|
||||
}
|
||||
8
apps/web-react/app/features/auth/core/user.ts
Normal file
8
apps/web-react/app/features/auth/core/user.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useMatches } from "react-router";
|
||||
import type { RootLoaderData } from "~/root";
|
||||
|
||||
export function useUser() {
|
||||
const [root] = useMatches();
|
||||
|
||||
return (root.loaderData as RootLoaderData | undefined)?.user;
|
||||
}
|
||||
Reference in New Issue
Block a user