Features

์ธ์ฆ

์ด ์„น์…˜์—์„œ๋Š” ๋ ˆ์ด์•„์›ƒ๊ณผ ๊ฐ€๋“œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊ฒฝ๋กœ๋ฅผ ๋ณดํ˜ธํ•˜๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•ด ์•Œ์•„๋ด…๋‹ˆ๋‹ค.

Supaplate๋Š” Supabase์˜ ์ธ์ฆ ๊ธฐ๋Šฅ์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.

์ด๋ฉ”์ผ/๋น„๋ฐ€๋ฒˆํ˜ธ ์ธ์ฆ, ์†Œ์…œ ๋กœ๊ทธ์ธ, ๋งค์ง๋งํฌ ์ธ์ฆ, OTP ์ธ์ฆ์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.

Supabase ์ธ์ฆ

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 ์—ด์„ ์—…๋ฐ์ดํŠธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Previous
Prettier
Next
Users