Features
์ธ์ฆ
์ด ์น์ ์์๋ ๋ ์ด์์๊ณผ ๊ฐ๋๋ฅผ ์ฌ์ฉํ์ฌ ๊ฒฝ๋ก๋ฅผ ๋ณดํธํ๋ ๋ฐฉ๋ฒ์ ๋ํด ์์๋ด ๋๋ค.
Supaplate๋ Supabase์ ์ธ์ฆ ๊ธฐ๋ฅ์ ์ง์ํฉ๋๋ค.
์ด๋ฉ์ผ/๋น๋ฐ๋ฒํธ ์ธ์ฆ, ์์ ๋ก๊ทธ์ธ, ๋งค์ง๋งํฌ ์ธ์ฆ, OTP ์ธ์ฆ์ ์ง์ํฉ๋๋ค.
Custom SMTP
Supabase์ ์ด๋ฉ์ผ ์ ๊ณต์ ์ฒด ์ ํ์ ๊ฑธ๋ฆฌ์ง ์๋๋ก ์ปค์คํ SMTP ์๋ฒ๋ฅผ ์ค์ ํ๋์ง ํ์ธํ์ธ์.
Setup
Supabase์ ์ธ์ฆ ๊ฐ์ด๋๋ฅผ ๋ฐ๋ผ ์ฌ์ฉํ๋ ค๋ ์ธ์ฆ ์ ๊ณต์ ์ฒด๋ฅผ ํ์ฑํํ์ธ์.
Supabase๋ ๋ก๊ทธ์ธ ๋ฐ ํ์๊ฐ์ ํ์ด์ง์ ์นด์นด์ค์ ๊นํ๋ธ ๋ ๊ฐ์ง ์์ ์ธ์ฆ ๋ฒํผ์ด ์์ฑ๋์ด ์์ง๋ง ๋ฐ๋ก ์ฌ์ฉํ ์ ์๋ ๊ฒ์ ์๋๋ฏ๋ก, ๋ณ๋๋ก ์ค์ ํด์ผ ํฉ๋๋ค.
Layouts
Supaplate์๋ ๋น ๋ฅด๊ฒ ๊ฒฝ๋ก๋ฅผ ๋ณดํธํ๋ ๋ฐฉ๋ฒ์ ์ ๊ณตํ๊ธฐ ์ํด ๋ฐ๋ก ์ฌ์ฉํ ์ ์๋ ๋ ๊ฐ์ ๋ ์ด์์ ํ์ผ์ด ํฌํจ๋์ด ์์ต๋๋ค: './app/core/layouts/private.layout.tsx'์ './app/core/layouts/public.layout.tsx'์ ๋๋ค.
์ ๋ ๊ฐ์ง ๋ ์ด์์์ ์ถ๊ฐ UI ์์ด, ์ ์ ์ ์ธ์ฆ ์ํ๋ฅผ ํ์ธํ๋ ๋ก๋๋ง ์์ต๋๋ค.
private.layout.tsx
Supabase ํด๋ผ์ด์ธํธ๋ก๋ถํฐ ์ ์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ค๊ณ , ์ ๋ณด๊ฐ null์ธ ๊ฒฝ์ฐ ์ฆ, ์ ์ ๊ฐ ๋ก๊ทธ์ธํ์ง ์์ ๊ฒฝ์ฐ ๋ก๊ทธ์ธ ํ์ด์ง๋ก ๋ฆฌ๋๋ ์ ํฉ๋๋ค.
์ด๋ฌํ ๋ฐฉ์์ ๋ก๊ทธ์ธํ ์ ์ ๋ง ์ ๊ทผํ ์ ์๋๋ก ๊ฒฝ๋ก๋ฅผ ๋ณดํธํ๋ ๋ฐ ์ ํฉํฉ๋๋ค.
public.layout.tsx
Supabase ํด๋ผ์ด์ธํธ๋ก๋ถํฐ ์ ์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ค๊ณ , ์ ๋ณด๊ฐ null์ด ์๋ ๊ฒฝ์ฐ, ์ฆ ์ ์ ๊ฐ ๋ก๊ทธ์ธํ ๊ฒฝ์ฐ /dashboard
๊ฒฝ๋ก๋ก ๋ฆฌ๋๋ ์
ํฉ๋๋ค.
์ด๋ฌํ ๋ฐฉ์์ ๋ก๊ทธ์ธ ๋ฐ ํ์๊ฐ์ ํ์ด์ง์ ๊ฐ์ด ๋ก๊ทธ์์ํ ์ ์ ๋ง ์ ๊ทผ ๊ฐ๋ฅํ ๊ฒฝ๋ก์ ์ ํฉํฉ๋๋ค.
๋ ์ด์์ ์ฌ์ฉ ๋ฐฉ๋ฒ
๋ ์ด์์์ ์ฌ์ฉํ๋ ค๋ฉด routes.ts
ํ์ผ์์ ๊ฒฝ๋ก๋ฅผ ๋ ์ด์์์ ๋ํํด์ผ ํฉ๋๋ค.
// example routes.ts
import {
type RouteConfig,
index,
layout,
prefix,
route,
} from '@react-router/dev/routes'
export default [
index('home.tsx'),
layout('core/layouts/private.layout.tsx', [
route('/dashboard', 'dashboard.tsx'),
]),
layout('core/layouts/public.layout.tsx', [
route('/login', 'login.tsx'),
route('/register', 'register.tsx'),
]),
] satisfies RouteConfig
๊ฒฝ๋ก์ ๊ตฌ์กฐ์ ๋ค๋ฅธ ๊ฒฝ๋ก์ ๋์ผํ ๋ ์ด์์์ ๋ค์ ์ฌ์ฉํด์ผ ํ๋ ๊ฒฝ์ฐ๊ฐ ์๋๋ฐ, ์ด ๊ฒฝ์ฐ React ๋ผ์ฐํฐ๊ฐ ์ค๋ฅ๋ฅผ ๋ฐ์์ํค์ง ์๋๋ก id
๋ฅผ ์ถ๊ฐํด์ผ ํฉ๋๋ค.
// example routes.ts
import {
type RouteConfig,
index,
layout,
prefix,
route,
} from '@react-router/dev/routes'
export default [
index('home.tsx'),
layout('core/layouts/private.layout.tsx', [
route('/dashboard', 'dashboard.tsx'),
]),
layout('core/layouts/public.layout.tsx', [
route('/login', 'login.tsx'),
route('/register', 'register.tsx'),
]),
...prefix('blog', [
layout('core/layouts/private.layout.tsx', { id: 'blog-private' }, [
route('/members', 'members.tsx'),
]),
layout('core/layouts/public.layout.tsx', { id: 'blog-public' }, [
route('/public', 'public.tsx'),
]),
]),
] satisfies RouteConfig
API Route ์ธ์ฆ
๋ ์ด์์์ ์ฌ์ฉํ์ฌ API ๊ฒฝ๋ก๋ฅผ ๋ณดํธํ ์๋ ์์ง๋ง, API ๊ฒฝ๋ก๋ฅผ ๊ณต๊ฐ๋ก ์ค์ ํ๊ณ './app/core/guards.server.ts'์ ๊ฐ๋๋ฅผ ์ฌ์ฉํ์ฌ ๊ฒฝ๋ก๋ฅผ ๋ณดํธํ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.
// ./app/core/guards.server.ts
import { data, unstable_RouterContextProvider } from 'react-router'
import { userContext } from './contexts.server'
export function requireAuthentication(context: unstable_RouterContextProvider) {
const user = context.get(userContext)
if (!user) {
throw data(null, { status: 401 })
}
}
export function requireMethod(method: string) {
return (request: Request) => {
if (request.method !== method) {
throw data(null, { status: 405 })
}
}
}
requireAuthentication
๊ณผ requireMethod
. ๋ผ๋ ๋ ๊ฐ์ง ๊ฐ๋๊ฐ ์์ต๋๋ค.
์ ์ ๊ฐ ์ธ์ฆ๋์ง ์์ ๊ฒฝ์ฐ requireAuthentication
๊ฐ๋๋ 401 ์๋ฌ๋ฅผ ๋ฐ์์ํต๋๋ค.
requireMethod
๊ฐ๋๋ ์์ฒญ์ด ๋ค์ด์ค๋ฉด, ํด๋น ๋ฉ์๋๊ฐ ๋ฏธ๋ฆฌ ์ง์ ๋ ํน์ ๋ฉ์๋์ธ์ง ํ์ธํฉ๋๋ค. ์๋์์ 405 ์๋ฌ๋ฅผ ๋ฐ์์ํต๋๋ค.
๋ค์๊ณผ ๊ฐ์ ๋ฐฉ์์ผ๋ก API ๊ฒฝ๋ก๋ฅผ ๋ณดํธํ ์ ์์ต๋๋ค:
// example ./app/features/users/api/get-user.ts
import { requireAuthentication, requireMethod } from '~/core/guards.server'
export async function loader({ context }: Route.LoaderArgs) {
const [client] = makeServerClient(request)
await requireAuthentication(client)
// your loader code here
}
export async function action({ context }: Route.ActionArgs) {
requireMethod('POST')(request)
const [client] = makeServerClient(request)
await requireAuthentication(client)
// your action code here
}
์์ ๋ก๊ทธ์ธ ํธ๋ฆฌ๊ฑฐ
์ค์ ์น์
์ผ๋ก ๋์๊ฐ์, npm db:migrate
๋ฅผ ์ฒ์ ์คํํ ๋ handle_sign_up
ํจ์์ auth.users
ํ
์ด๋ธ์ ํธ๋ฆฌ๊ฑฐ๋ฅผ ๋ง๋์ธ์.
CREATE OR REPLACE FUNCTION handle_sign_up()
RETURNS TRIGGER
LANGUAGE PLPGSQL
SECURITY DEFINER
SET SEARCH_PATH = ''
AS $$
BEGIN
IF new.raw_app_meta_data IS NOT NULL AND new.raw_app_meta_data ? 'provider' THEN
IF new.raw_app_meta_data ->> 'provider' = 'email' OR new.raw_app_meta_data ->> 'provider' = 'phone' THEN
IF new.raw_user_meta_data ? 'name' THEN
INSERT INTO public.profiles (profile_id, name, marketing_consent)
VALUES (new.id, new.raw_user_meta_data ->> 'name', (new.raw_user_meta_data ->> 'marketing_consent')::boolean);
ELSE
INSERT INTO public.profiles (profile_id, name, marketing_consent)
VALUES (new.id, 'Anonymous', TRUE);
END IF;
ELSE
INSERT INTO public.profiles (profile_id, name, avatar_url, marketing_consent)
VALUES (new.id, new.raw_user_meta_data ->> 'full_name', new.raw_user_meta_data ->> 'avatar_url', TRUE);
END IF;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER handle_sign_up
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION handle_sign_up();
์ด ํธ๋ฆฌ๊ฑฐ์ ํจ์๋ ์ ์ ๊ฐ ๊ฐ์
ํ ๋ profiles
ํ
์ด๋ธ์ ํ๋กํ์ ๋ง๋๋ ๋ฐ ์ฌ์ฉ๋ฉ๋๋ค.
ํธ๋ฆฌ๊ฑฐ๋ auth.users
ํ
์ด๋ธ์ ํ์ด ์ฝ์
๋ ํ ์คํ๋๋๋ก ์ค์ ํฉ๋๋ค
์ ์ ๊ฐ ์ ์ ์์ด๋์ ๋น๋ฐ๋ฒํธ๋ก ๊ณ์ ์ ๋ง๋ค ๋ ๋ง์ผํ
์ด๋ฉ์ผ ์์ ์ ๋์ํ ์ ์์ต๋๋ค(profiles
ํ
์ด๋ธ์ marketing_consent
์ด).
์์
์ธ์ฆ ์ ๊ณต์
์ฒด์ ๊ฐ์
ํ ๋ marketing_consent
์ด์ ๊ธฐ๋ณธ์ ์ผ๋ก 'TRUE'๋ก ์ค์ ๋๋ฉฐ, 'handle_sign_up' ํจ์์์ ์ด๋ฅผ ๋ณ๊ฒฝํ ์ ์์ต๋๋ค.
๋ํ ์ ์ ๋ /account/edit
ํ์ด์ง์์ marketing_consent
์ด์ ์
๋ฐ์ดํธํ ์ ์์ต๋๋ค.