> supabase를 이용해 인증을 구현해보자
npm install @supabase/ssr @supabase/supabase-js 설치
>supa-client.ts
import {
createBrowserClient,
createServerClient,
parseCookieHeader,
serializeCookieHeader,
} from "@supabase/ssr";
import type { MergeDeep, SetNonNullable, SetFieldType } from "type-fest";
import type { Database as SupabaseDatabase } from "database.types";
export type Database = MergeDeep<
SupabaseDatabase,
{
public: {
Views: {
community_post_list_view: {
Row: SetFieldType<
SetNonNullable<
SupabaseDatabase["public"]["Views"]["community_post_list_view"]["Row"]
>,
"author_avatar",
string | null
>;
};
product_overview_view: {
Row: SetNonNullable<
SupabaseDatabase["public"]["Views"]["product_overview_view"]["Row"]
>;
};
community_post_detail: {
Row: SetNonNullable<
SupabaseDatabase["public"]["Views"]["community_post_detail"]["Row"]
>;
};
gpt_ideas_view: {
Row: SetNonNullable<
SupabaseDatabase["public"]["Views"]["gpt_ideas_view"]["Row"]
>;
};
};
};
}
>;
export const browserClient = createBrowserClient<Database>(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!
);
export const makeSSRClient = (request: Request) => {
const headers = new Headers();
const serverSideClient = createServerClient<Database>(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return parseCookieHeader(request.headers.get("Cookie") ?? "");
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
headers.append(
"Set-Cookie",
serializeCookieHeader(name, value, options)
);
});
},
},
}
);
return {
client: serverSideClient,
headers,
};
};
브라우저에서 쿠키를 가져온 후, 해당 쿠키를 supabase 서버로 보내주려면 위 makeSSRClient 함수와 같이 세팅 (현재 버전은 위와 같이 하면 타입에러 나오니,, 버전을 이전 버전으로 낮추던가 새로운 버전에 맞게 수정해야함/ 흐름만 이해)
그리고 해당 함수를 적용하기 위해 리팩토링을 진행하자.
리팩토링 해야하는 페이지수가 많기 때문에 대표적으로 몇개면 설명 한다.
- home-page.tsx
export const loader = async ({ request }: Route.LoaderArgs) => {
const { client, headers } = makeSSRClient(request);
const products = await getProductsByDateRange(client, {
startDate: DateTime.now().startOf("day"),
endDate: DateTime.now().endOf("day"),
limit: 7,
});
const posts = await getPosts(client, {
limit: 7,
sorting: "newest",
});
const ideas = await getGptIdeas(client, { limit: 7 });
const jobs = await getJobs(client, { limit: 11 });
const teams = await getTeams(client, { limit: 7 });
return { products, posts, ideas, jobs, teams };
};
loader부분에서 request값을 이전에 선언한 makeSSRClient함수로 보내준다. 해당 값으로부터 client값과 header값을 가져온다.
그리고 client 값을 api값 인자로 보내준다.
- products/queriest.ts
export const getProductsByDateRange = async (
client: SupabaseClient<Database>,
{
startDate,
endDate,
limit,
page = 1,
}: {
startDate: DateTime;
endDate: DateTime;
limit: number;
page?: number;
}
) => {
const { data, error } = await client
.from("products")
.select(productListSelect)
.order("stats->>upvotes", { ascending: false })
.gte("created_at", startDate.toISO())
.lte("created_at", endDate.toISO())
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
if (error) throw error;
return data;
};
위와 같이 쿼리값을 세팅한다. (1번째 인자값으로 client값을 가져옴). 기존에는 바로 supa-client에서 가져오던걸 위와 같이 한 이유는 헤더값(쿠키)를 가져오기 위함. 다음 수업에 이어서 다룰 예정.
> Action Function 활용 (로그인 페이지 form)
- root.tsx
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLocation,
useNavigation,
} from "react-router";
import type { Route } from "./+types/root";
import stylesheet from "./app.css?url";
import Navigation from "./common/components/navigation";
import { Settings } from "luxon";
import { cn } from "./lib/utils";
import { makeSSRClient } from "./supa-client";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
{ rel: "stylesheet", href: stylesheet },
];
export function Layout({ children }: { children: React.ReactNode }) {
Settings.defaultLocale = "ko";
Settings.defaultZone = "Asia/Seoul";
return (
<html lang="en" className="">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<main>{children}</main>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export const loader = async ({ request }: Route.LoaderArgs) => {
const { client } = makeSSRClient(request);
const {
data: { user },
} = await client.auth.getUser();
return { user };
};
export default function App({ loaderData }: Route.ComponentProps) {
const { pathname } = useLocation();
const navigation = useNavigation();
const isLoading = navigation.state === "loading";
const isLoggedIn = loaderData.user !== null;
return (
<div
className={cn({
"py-28 px-5 md:px-20": !pathname.includes("/auth/"),
"transition-opacity animate-pulse": isLoading,
})}
>
{pathname.includes("/auth") ? null : (
<Navigation
isLoggedIn={isLoggedIn}
hasNotifications={false}
hasMessages={false}
/>
)}
<Outlet />
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}
loader부분에 user 로그인 여부에 대해 설정함
-login-page.tsx
import { Button } from "~/common/components/ui/button";
import type { Route } from "./+types/login-page";
import { Form, Link, useNavigation } from "react-router";
import InputPair from "~/common/components/input-pair";
import AuthButtons from "../components/auth-buttons";
import { LoaderCircle } from "lucide-react";
export const meta: Route.MetaFunction = () => {
return [{ title: "Login | wemake" }];
};
export const action = async ({ request }: Route.ActionArgs) => {
await new Promise((resolve) => setTimeout(resolve, 4000));
const formData = await request.formData();
const email = formData.get("email");
const password = formData.get("password");
return {
message: "Error wrong password",
};
};
export default function LoginPage({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<div className="flex flex-col relative items-center justify-center h-full">
<Button variant={"ghost"} asChild className="absolute right-8 top-8 ">
<Link to="/auth/join">Join</Link>
</Button>
<div className="flex items-center flex-col justify-center w-full max-w-md gap-10">
<h1 className="text-2xl font-semibold">Log in to your account</h1>
<Form className="w-full space-y-4" method="post">
<InputPair
label="Email"
description="Enter your email address"
name="email"
id="email"
required
type="email"
placeholder="i.e wemake@example.com"
/>
<InputPair
id="password"
label="Password"
description="Enter your password"
name="password"
required
type="password"
placeholder="i.e wemake@example.com"
/>
<Button className="w-full" type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<LoaderCircle className="animate-spin" />
) : (
"Log in"
)}
</Button>
{actionData?.message && (
<p className="text-sm text-red-500">{actionData.message}</p>
)}
</Form>
<AuthButtons />
</div>
</div>
);
}
react router에서 제공하는 Form은 기본 html form태그와 거의 유사하지만 다른점은 Form은 새로고침이 자동으로 안된다는 것이다. 그리고 Action function을 활용하여 method가 post이후 특정 기능을 할 수 있게 해줄 수 있다. useNavigation을 활용하여 Form의 loading 여부를 확인 할수도 있다.
> 회원가입 및 로그인 페이지
-join-page.tsx
import { Button } from "~/common/components/ui/button";
import { Form, Link, redirect, useNavigation } from "react-router";
import type { Route } from "./+types/join-page";
import InputPair from "~/common/components/input-pair";
import AuthButtons from "../components/auth-buttons";
import { makeSSRClient } from "~/supa-client";
import { z } from "zod";
import { checkUsernameExists } from "../queries";
import { LoaderCircle } from "lucide-react";
export const meta: Route.MetaFunction = () => {
return [{ title: "Join | wemake" }];
};
const formSchema = z.object({
name: z.string().min(3),
username: z.string().min(3),
email: z.string().email(),
password: z.string().min(8),
});
export const action = async ({ request }: Route.ActionArgs) => {
const formData = await request.formData();
const { success, error, data } = formSchema.safeParse(
Object.fromEntries(formData)
);
if (!success) {
return {
formErrors: error.flatten().fieldErrors,
};
}
const usernameExists = await checkUsernameExists(request, {
username: data.username,
});
if (usernameExists) {
return {
formErrors: { username: ["Username already exists"] },
};
}
const { client, headers } = makeSSRClient(request);
const { error: signUpError } = await client.auth.signUp({
email: data.email,
password: data.password,
options: {
data: {
name: data.name,
username: data.username,
},
},
});
if (signUpError) {
return {
signUpError: signUpError.message,
};
}
return redirect("/", { headers });
};
export default function JoinPage({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const isSubmitting =
navigation.state === "submitting" || navigation.state === "loading";
return (
<div className="flex flex-col relative items-center justify-center h-full">
<Button variant={"ghost"} asChild className="absolute right-8 top-8 ">
<Link to="/auth/login">Login</Link>
</Button>
<div className="flex items-center flex-col justify-center w-full max-w-md gap-10">
<h1 className="text-2xl font-semibold">Create an account</h1>
<Form className="w-full space-y-4" method="post">
<InputPair
label="Name"
description="Enter your name"
name="name"
id="name"
required
type="text"
placeholder="Enter your name"
/>
{actionData && "formErrors" in actionData && (
<p className="text-red-500">{actionData?.formErrors?.name}</p>
)}
<InputPair
id="username"
label="Username"
description="Enter your username"
name="username"
required
type="text"
placeholder="i.e wemake"
/>
{actionData && "formErrors" in actionData && (
<p className="text-red-500">{actionData?.formErrors?.username}</p>
)}
<InputPair
id="email"
label="Email"
description="Enter your email address"
name="email"
required
type="email"
placeholder="i.e wemake@example.com"
/>
{actionData && "formErrors" in actionData && (
<p className="text-red-500">{actionData?.formErrors?.email}</p>
)}
<InputPair
id="password"
label="Password"
description="Enter your password"
name="password"
required
type="password"
placeholder="Enter your password"
/>
{actionData && "formErrors" in actionData && (
<p className="text-red-500">{actionData?.formErrors?.password}</p>
)}
<Button className="w-full" type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<LoaderCircle className="animate-spin" />
) : (
"Create account"
)}
</Button>
{actionData && "signUpError" in actionData && (
<p className="text-red-500">{actionData.signUpError}</p>
)}
</Form>
<AuthButtons />
</div>
</div>
);
}
-login-page.tsx
import { Button } from "~/common/components/ui/button";
import type { Route } from "./+types/login-page";
import { Form, Link, redirect, useNavigation } from "react-router";
import InputPair from "~/common/components/input-pair";
import AuthButtons from "../components/auth-buttons";
import { LoaderCircle } from "lucide-react";
import { z } from "zod";
import { makeSSRClient } from "~/supa-client";
export const meta: Route.MetaFunction = () => {
return [{ title: "Login | wemake" }];
};
const formSchema = z.object({
email: z
.string({
required_error: "Email is required",
invalid_type_error: "Email should be a string",
})
.email("Invalid email address"),
password: z
.string({
required_error: "Password is required",
})
.min(8, {
message: "Password must be at least 8 characters",
}),
});
export const action = async ({ request }: Route.ActionArgs) => {
const formData = await request.formData();
const { success, data, error } = formSchema.safeParse(
Object.fromEntries(formData)
);
if (!success) {
return {
loginError: null,
formErrors: error.flatten().fieldErrors,
};
}
const { email, password } = data;
const { client, headers } = makeSSRClient(request);
const { error: loginError } = await client.auth.signInWithPassword({
email,
password,
});
if (loginError) {
return {
formErrors: null,
loginError: loginError.message,
};
}
return redirect("/", { headers });
};
export default function LoginPage({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const isSubmitting =
navigation.state === "submitting" || navigation.state === "loading";
return (
<div className="flex flex-col relative items-center justify-center h-full">
<Button variant={"ghost"} asChild className="absolute right-8 top-8 ">
<Link to="/auth/join">Join</Link>
</Button>
<div className="flex items-center flex-col justify-center w-full max-w-md gap-10">
<h1 className="text-2xl font-semibold">Log in to your account</h1>
<Form className="w-full space-y-4" method="post">
<InputPair
label="Email"
description="Enter your email address"
name="email"
id="email"
required
type="email"
placeholder="i.e wemake@example.com"
/>
{actionData && "formErrors" in actionData && (
<p className="text-sm text-red-500">
{actionData?.formErrors?.email?.join(", ")}
</p>
)}
<InputPair
id="password"
label="Password"
description="Enter your password"
name="password"
required
type="password"
placeholder="i.e wemake@example.com"
/>
{actionData && "formErrors" in actionData && (
<p className="text-sm text-red-500">
{actionData?.formErrors?.password?.join(", ")}
</p>
)}
<Button className="w-full" type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<LoaderCircle className="animate-spin" />
) : (
"Log in"
)}
</Button>
{actionData && "loginError" in actionData && (
<p className="text-sm text-red-500">{actionData.loginError}</p>
)}
</Form>
<AuthButtons />
</div>
</div>
);
}
-logout-page.tsx
import { makeSSRClient } from "~/supa-client";
import type { Route } from "./+types/logout-page";
import { redirect } from "react-router";
export const loader = async ({ request }: Route.LoaderArgs) => {
const { client, headers } = makeSSRClient(request);
await client.auth.signOut();
return redirect("/", { headers });
};
-queries.ts
import { makeSSRClient } from "~/supa-client";
export const checkUsernameExists = async (
request: Request,
{ username }: { username: string }
) => {
const { client } = makeSSRClient(request);
const { error } = await client
.from("profiles")
.select("profile_id")
.eq("username", username)
.single();
if (error) {
return false;
}
return true;
};
- user_to_profile_trigger.sql
create function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
if new.raw_app_meta_data is not null then
if new.raw_app_meta_data ? 'provider' AND new.raw_app_meta_data ->> 'provider' = 'email' then
if new.raw_user_meta_data ? 'name' and new.raw_user_meta_data ? 'username' then
insert into public.profiles (profile_id, name, username, role)
values (new.id, new.raw_user_meta_data ->> 'name', new.raw_user_meta_data ->> 'username', 'developer');
else
insert into public.profiles (profile_id, name, username, role)
values (new.id, 'Anonymous', 'mr.' || substr(md5(random()::text), 1, 8), 'developer');
end if;
end if;
end if;
return new;
end;
$$;
create trigger user_to_profile_trigger
after insert on auth.users
for each row execute function public.handle_new_user();
유저 생성 시, 프로필 테이블에 name과 username을 생성해줄 트리거 부분을 추가 해주었다.
sql 에디터 사용시 기존 트리거를 삭제 하기 위해서는 drop function if exists handle_new_user() CASCADE; 명령어를 먼저 선언해주자.
- users/queries.ts
export const getUserById = async (
client: SupabaseClient<Database>,
{ id }: { id: string }
) => {
const { data, error } = await client
.from("profiles")
.select(
`
profile_id,
name,
username,
avatar
`
)
.eq("profile_id", id)
.single();
if (error) {
throw error;
}
return data;
};
home page 부분에 프로필 정보를 확인하기 위해 위 쿼리 추가
- root.tsx
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLocation,
useNavigation,
} from "react-router";
import type { Route } from "./+types/root";
import stylesheet from "./app.css?url";
import Navigation from "./common/components/navigation";
import { Settings } from "luxon";
import { cn } from "./lib/utils";
import { makeSSRClient } from "./supa-client";
import { getUserById } from "./features/users/queries";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
{ rel: "stylesheet", href: stylesheet },
];
export function Layout({ children }: { children: React.ReactNode }) {
Settings.defaultLocale = "ko";
Settings.defaultZone = "Asia/Seoul";
return (
<html lang="en" className="">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<main>{children}</main>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export const loader = async ({ request }: Route.LoaderArgs) => {
const { client } = makeSSRClient(request);
const {
data: { user },
} = await client.auth.getUser();
if (user) {
const profile = await getUserById(client, { id: user?.id });
return { user, profile };
}
return { user: null, profile: null };
};
export default function App({ loaderData }: Route.ComponentProps) {
const { pathname } = useLocation();
const navigation = useNavigation();
const isLoading = navigation.state === "loading";
const isLoggedIn = loaderData.user !== null;
return (
<div
className={cn({
"py-28 px-5 md:px-20": !pathname.includes("/auth/"),
"transition-opacity animate-pulse": isLoading,
})}
>
{pathname.includes("/auth") ? null : (
<Navigation
isLoggedIn={isLoggedIn}
username={loaderData.profile?.username}
avatar={loaderData.profile?.avatar}
name={loaderData.profile?.name}
hasNotifications={false}
hasMessages={false}
/>
)}
<Outlet />
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}
네비게이션 컴포넌트에 값 전달
- navigation.tsx
import { Link } from "react-router";
import { Separator } from "~/common/components/ui/separator";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "./ui/navigation-menu";
import { cn } from "~/lib/utils";
import { Button } from "./ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import {
BarChart3Icon,
BellIcon,
LogOutIcon,
MessageCircleIcon,
SettingsIcon,
UserIcon,
} from "lucide-react";
const menus = [
{
name: "Products",
to: "/products",
items: [
{
name: "Leaderboards",
description: "See the top performers in your community",
to: "/products/leaderboards",
},
{
name: "Categories",
description: "See the top categories in your community",
to: "/products/categories",
},
{
name: "Search",
description: "Search for a product",
to: "/products/search",
},
{
name: "Submit a Product",
description: "Submit a product to our community",
to: "/products/submit",
},
{
name: "Promote",
description: "Promote a product to our community",
to: "/products/promote",
},
],
},
{
name: "Jobs",
to: "/jobs",
items: [
{
name: "Remote Jobs",
description: "Find a remote job in our community",
to: "/jobs?location=remote",
},
{
name: "Full-Time Jobs",
description: "Find a full-time job in our community",
to: "/jobs?type=full-time",
},
{
name: "Freelance Jobs",
description: "Find a freelance job in our community",
to: "/jobs?type=freelance",
},
{
name: "Internships",
description: "Find an internship in our community",
to: "/jobs?type=internship",
},
{
name: "Post a Job",
description: "Post a job to our community",
to: "/jobs/submit",
},
],
},
{
name: "Community",
to: "/community",
items: [
{
name: "All Posts",
description: "See all posts in our community",
to: "/community",
},
{
name: "Top Posts",
description: "See the top posts in our community",
to: "/community?sort=top",
},
{
name: "New Posts",
description: "See the new posts in our community",
to: "/community?sort=new",
},
{
name: "Create a Post",
description: "Create a post in our community",
to: "/community/create",
},
],
},
{
name: "IdeasGPT",
to: "/ideas",
},
{
name: "Teams",
to: "/teams",
items: [
{
name: "All Teams",
description: "See all teams in our community",
to: "/teams",
},
{
name: "Create a Team",
description: "Create a team in our community",
to: "/teams/create",
},
],
},
];
export default function Navigation({
isLoggedIn,
hasNotifications,
hasMessages,
username,
avatar,
name,
}: {
isLoggedIn: boolean;
hasNotifications: boolean;
hasMessages: boolean;
username?: string;
avatar?: string | null;
name?: string;
}) {
return (
<nav className="flex px-20 h-16 items-center justify-between backdrop-blur fixed top-0 left-0 right-0 z-50 bg-background/50">
<div className="flex items-center">
<Link to="/" className="font-bold tracking-tighter text-lg">
wemake
</Link>
<Separator orientation="vertical" className="h-6 mx-4" />
<NavigationMenu>
<NavigationMenuList>
{menus.map((menu) => (
<NavigationMenuItem key={menu.name}>
{menu.items ? (
<>
<Link to={menu.to}>
<NavigationMenuTrigger>{menu.name}</NavigationMenuTrigger>
</Link>
<NavigationMenuContent>
<ul className="grid w-[600px] font-light gap-3 p-4 grid-cols-2">
{menu.items?.map((item) => (
<NavigationMenuItem
key={item.name}
className={cn([
"select-none rounded-md transition-colors focus:bg-accent hover:bg-accent",
(item.to === "/products/promote" ||
item.to === "/jobs/submit") &&
"col-span-2 bg-primary/10 hover:bg-primary/20 focus:bg-primary/20",
])}
>
<NavigationMenuLink asChild>
<Link
className="p-3 space-y-1 block leading-none no-underline outline-none"
to={item.to}
>
<span className="text-sm font-medium leading-none">
{item.name}
</span>
<p className="text-sm leading-snug text-muted-foreground">
{item.description}
</p>
</Link>
</NavigationMenuLink>
</NavigationMenuItem>
))}
</ul>
</NavigationMenuContent>
</>
) : (
<Link className={navigationMenuTriggerStyle()} to={menu.to}>
{menu.name}
</Link>
)}
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
</div>
{isLoggedIn ? (
<div className="flex items-center gap-4">
<Button size="icon" variant="ghost" asChild className="relative">
<Link to="/my/notifications">
<BellIcon className="size-4" />
{hasNotifications && (
<div className="absolute top-1.5 right-1.5 size-2 bg-red-500 rounded-full" />
)}
</Link>
</Button>
<Button size="icon" variant="ghost" asChild className="relative">
<Link to="/my/messages">
<MessageCircleIcon className="size-4" />
{hasMessages && (
<div className="absolute top-1.5 right-1.5 size-2 bg-red-500 rounded-full" />
)}
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar>
{avatar ? (
<AvatarImage src={avatar} />
) : (
<AvatarFallback>{name?.[0]}</AvatarFallback>
)}
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel className="flex flex-col">
<span className="font-medium">{name}</span>
<span className="text-xs text-muted-foreground">
@{username}
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/my/dashboard">
<BarChart3Icon className="size-4 mr-2" />
Dashboard
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/my/profile">
<UserIcon className="size-4 mr-2" />
Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/my/settings">
<SettingsIcon className="size-4 mr-2" />
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/auth/logout">
<LogOutIcon className="size-4 mr-2" />
Logout
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
<div className="flex items-center gap-4">
<Button asChild variant="secondary">
<Link to="/auth/login">Login</Link>
</Button>
<Button asChild>
<Link to="/auth/join">Join</Link>
</Button>
</div>
)}
</nav>
);
}
- my-profile-page.tsx
import { redirect } from "react-router";
import type { Route } from "./+types/my-profile-page";
import { makeSSRClient } from "~/supa-client";
import { getUserById } from "../queries";
export async function loader({ request }: Route.LoaderArgs) {
const { client } = makeSSRClient(request);
const {
data: { user },
} = await client.auth.getUser();
if (user) {
const profile = await getUserById(client, { id: user.id });
return redirect(`/users/${profile.username}`);
}
return redirect("/auth/login");
}
네비게이션 내 프로필 정보 누르면 위와 같이 실행
> 소셜 로그인을 해보자!
먼저 github로 진행해보자
Supabase > Authentication > Sign in / Up 에서 github를 클릭 하면 아래 화면이 나온다.

https://github.com/settings/applications/new
GitHub · Build and ship software on a single, collaborative platform
Join the world's most widely adopted, AI-powered developer platform where millions of developers, businesses, and the largest open source community build software that advances humanity.
github.com
위 화면에서 아래와 같이 입력 후 등록
등록 한 후 클라이언트 ID와 클라이언트 secrets를 supabase 화면에 입력한다.
카카오도 같은 방법으로 진행하자.
3) 카카오 로그인 - 보안 탭에서 Client Secret 생성 및 복붙 + 활성화
4) 카카오 로그인 탭 - Redirect URI에 supabase Callback URL 값 넣기 + 활성화
5) 카카오 로그인 - 동의항목 탭에서 닉네임과 프로필 사진은 활성화 시켰는데, 카카오계정(이메일)도 활성화 시키려면 비즈니스 탭에서 사업자 정보를 등록해야 함 (사업자 번호가 없어도 개발자 계정으로 가능). 그 후 이메일 활성화
- social-start-page.tsx
import { z } from "zod";
import type { Route } from "./+types/social-start-page";
import { redirect } from "react-router";
import { makeSSRClient } from "~/supa-client";
const paramsSchema = z.object({
provider: z.enum(["github", "kakao"]),
});
export const loader = async ({ params, request }: Route.LoaderArgs) => {
const { success, data } = paramsSchema.safeParse(params);
if (!success) {
return redirect("/auth/login");
}
const { provider } = data;
const redirectTo = `http://localhost:5173/auth/social/${provider}/complete`;
const { client, headers } = makeSSRClient(request);
const {
data: { url },
error,
} = await client.auth.signInWithOAuth({
provider,
options: {
redirectTo,
},
});
if (url) {
return redirect(url, { headers });
}
if (error) {
throw error;
}
};
params를 확인 한 후, github인지 kakao인지 확인한다. 그리고 해당 provider값을 가지고 온 후 signInWithOAuth함수를 사용해서 provider값 및 리다이렉트 url 값을 넣어준다.
플로우는 아래와 같다. 최종적으로 로그인이 성공하면 아래 url로 이동하게 된다.
const redirectTo = `http://localhost:5173/auth/social/${provider}/complete`;
- social-complete-page.tsx
import { z } from "zod";
import type { Route } from "./+types/social-start-page";
import { redirect } from "react-router";
import { makeSSRClient } from "~/supa-client";
const paramsSchema = z.object({
provider: z.enum(["github", "kakao"]),
});
export const loader = async ({ params, request }: Route.LoaderArgs) => {
const { success, data } = paramsSchema.safeParse(params);
if (!success) {
return redirect("/auth/login");
}
const url = new URL(request.url);
const code = url.searchParams.get("code");
if (!code) {
return redirect("/auth/login");
}
const { client, headers } = makeSSRClient(request);
const { error } = await client.auth.exchangeCodeForSession(code);
if (error) {
throw error;
}
return redirect("/", { headers });
};
complete페이지에 code값을 파라미터값으로 받게 되는데, 해당 code값을 exchangeCodeForSession에 넣어주고 최종적으로 헤더값(쿠키 넣어주기 위해)과 함께 리디렉트 해준다.
이렇게 하면, 아래와 같이 db에 기존 이메일 주소가 있는 경우(카카오)에는 로그인에 문제가 안되는데, 기존 이메일이 없는 경우(깃헙)에는 에러가 뜬다 . 이 문제를 해결해보자.
위와 같은 문제가 생긴 이유는, root.tsx 파일에서 getUserById 함수에서 아래와 같이 프로필 정보를 가져와야 하는데, 그 값이 없어서이다. 따라서, 트리거로 추가해주자.
export const getUserById = async (
client: SupabaseClient<Database>,
{ id }: { id: string }
) => {
const { data, error } = await client
.from("profiles")
.select(
`
profile_id,
name,
username,
avatar
`
)
.eq("profile_id", id)
.single();
if (error) {
throw error;
}
return data;
};
- user_to_profile_trigger.sql
create function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
if new.raw_app_meta_data is not null then
if new.raw_app_meta_data ? 'provider' AND new.raw_app_meta_data ->> 'provider' = 'email' then
if new.raw_user_meta_data ? 'name' and new.raw_user_meta_data ? 'username' then
insert into public.profiles (profile_id, name, username, role)
values (new.id, new.raw_user_meta_data ->> 'name', new.raw_user_meta_data ->> 'username', 'developer');
else
insert into public.profiles (profile_id, name, username, role)
values (new.id, 'Anonymous', 'mr.' || substr(md5(random()::text), 1, 8), 'developer');
end if;
end if;
if new.raw_app_meta_data ? 'provider' AND new.raw_app_meta_data ->> 'provider' = 'kakao' then
insert into public.profiles (profile_id, name, username, role, avatar)
values (new.id, new.raw_user_meta_data ->> 'name', new.raw_user_meta_data ->> 'preferred_username' || substr(md5(random()::text), 1, 5), 'developer', new.raw_user_meta_data ->> 'avatar_url');
end if;
if new.raw_app_meta_data ? 'provider' AND new.raw_app_meta_data ->> 'provider' = 'github' then
insert into public.profiles (profile_id, name, username, role, avatar)
values (new.id, new.raw_user_meta_data ->> 'full_name', new.raw_user_meta_data ->> 'user_name' || substr(md5(random()::text), 1, 5), 'developer', new.raw_user_meta_data ->> 'avatar_url');
end if;
end if;
return new;
end;
$$;
create trigger user_to_profile_trigger
after insert on auth.users
for each row execute function public.handle_new_user();
소셜 로그인 할 때, 기존 다른 유저와의 username 중복방지를 위해 랜덤 문자를 추가했다.
> email OTP 또는 핸드폰번호 OTP 으로 로그인 하기
강의 #7.9, #7.10 참고
'코딩강의 > Maker 마스터클래스(노마드코더)' 카테고리의 다른 글
#9 Fetchers (0) | 2025.05.14 |
---|---|
#8 Private Pages (0) | 2025.05.02 |
#6 Public Pages (0) | 2025.04.17 |
#5 Data Loading Strategies (0) | 2025.04.17 |
#4 Supabase & Drizzle Database (3) | 2025.04.08 |