diff --git a/apps/web-docs/.vitepress/config.mts b/apps/web-docs/.vitepress/config.mts index 8abe9552..900e3f67 100644 --- a/apps/web-docs/.vitepress/config.mts +++ b/apps/web-docs/.vitepress/config.mts @@ -9,6 +9,7 @@ export default defineConfig({ nav: [ { text: 'Top', link: '/' }, { text: 'CSS', link: '/css/getting-started' }, + { text: 'Supabase', link: '/supabase/getting-started' }, ], sidebar: [ @@ -16,6 +17,7 @@ export default defineConfig({ text: 'Examples', items: [ { text: 'CSS', link: '/css/getting-started' }, + { text: 'Supabase', link: '/supabase/getting-started' }, ], }, ], diff --git a/apps/web-docs/index.md b/apps/web-docs/index.md index 016708ec..d418567e 100644 --- a/apps/web-docs/index.md +++ b/apps/web-docs/index.md @@ -8,4 +8,7 @@ hero: - theme: brand text: CSS link: /css/getting-started + - theme: brand + text: Supabase + link: /supabase/getting-started --- diff --git a/apps/web-docs/supabase/getting-started.md b/apps/web-docs/supabase/getting-started.md new file mode 100644 index 00000000..e81a186b --- /dev/null +++ b/apps/web-docs/supabase/getting-started.md @@ -0,0 +1,102 @@ +# Supabase + +昨年に続いて [Supabase](https://supabase.com/) のお世話になります。今年は事前に **本番投入前チェックのひとつ** として、Supabase 公式より出されていた [Production Checklist](https://supabase.com/docs/guides/platform/going-into-prod) も一読しておくと良いように考えています。 + +## Supabase 環境を構築 + +ダッシュボードより各種変数を発行、手元の環境でそれらを使えるよう準備します。 + +データベースへの追加、更新する際は URL (SUPABASE_URL) と Anon Key (SUPABASE_KEY) が必要となります。 + +```.env +SUPABASE_URL= +SUPABASE_KEY= +``` + +[`useRuntimeConfig`](https://nuxt.com/docs/api/composables/use-runtime-config) を利用して、各種変数へアクセスできることを確認してください。 + +```ts +export default defineNuxtConfig({ + runtimeConfig: { + public: { + supabaseUrl: process.env.SUPABASE_URL, + supabaseKey: process.env.SUPABASE_KEY, + }, + }, +}) +``` + +なお、ここで supabase.redirect に `false` を設定しないと、強制的にログイン画面へ遷移されるようなります。 + +```ts +export default defineNuxtConfig({ + supabase: { + redirect: false, + }, +}) +``` + +### メールアドレスを使ってユーザーを招待 + +[`inviteUserByEmail`](https://supabase.com/docs/reference/javascript/auth-admin-inviteuserbyemail) のお世話になります。事前に Supabase の Auth Admin クライアントを作成する必要があり、直接 Web ブラウザからそれを操作することができません。 + +Service Role Key も発行しつつ、合わせてこちらも `useRuntimeConfig` を利用してアクセスできることを確認してください。 + +```ts +import { defineEventHandler, useRuntimeConfig } from '#imports' +import { createClient } from '@supabase/supabase-js' + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + const supabaseUrl = config.public.supabaseUrl + const serviceKey = config.public.serviceKey + + if (!supabaseUrl || !serviceKey) { + return event.node.res.end('No authentication') + } + + const supabase = createClient(supabaseUrl, serviceKey) + const { error } = await supabase.auth.admin.inviteUserByEmail(email) + + if (error) { + return event.node.res.end(error) + } +}) +``` + +raw_user_meta_data に `{ user_role: 'admin' }` を付与しつつ、それが追加された時に限って public.admin_users へデータが insert される設計を取りました。 + +```ts +const { error } = await supabase.auth.admin.inviteUserByEmail( + email, + { data: { user_role: 'admin' } }, +) +``` + +### API を利用してユーザーを削除 + +Supabase 管理画面よりユーザーを削除する操作を行えないため、API ([`deleteUser`](https://supabase.com/docs/reference/javascript/auth-admin-deleteuser)) のお世話になります。 + +ユーザーの招待時と同じく、事前に Supabase の Auth Admin クライアントを作成する必要があり、直接 Web ブラウザからそれを操作することができません。 + +```ts +import { defineEventHandler, useRuntimeConfig } from '#imports' +import { createClient } from '@supabase/supabase-js' + +export default defineEventHandler(async (event: H3Event) => { + const config = useRuntimeConfig() + const supabaseUrl = config.public.supabaseUrl + const serviceKey = config.public.serviceKey + + if (!supabaseUrl || !serviceKey) { + return event.node.res.end('No authentication') + } + + const supabase = createClient(supabaseUrl, serviceKey) + const { error } = await supabase.auth.admin.deleteUser(id) + + if (error) { + return event.node.res.end(error) + } +}) +``` diff --git a/apps/web/.env.example b/apps/web/.env.example index acfaeec9..4017e642 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -5,4 +5,6 @@ NUXT_RECAPTCHA_WEBSITE_KEY= SUPABASE_URL= SUPABASE_KEY= AVAILABLE_APPLY_SPONSOR= +ENABLE_INVITE_STAFF= +ENABLE_OPERATE_ADMIN= ENABLE_SWITCH_LOCALE= diff --git a/apps/web/app/components/admin/List.vue b/apps/web/app/components/admin/List.vue new file mode 100644 index 00000000..6b2b1c65 --- /dev/null +++ b/apps/web/app/components/admin/List.vue @@ -0,0 +1,25 @@ + + + + + {{ pageText }} + + + + diff --git a/apps/web/app/composables/useAuth.ts b/apps/web/app/composables/useAuth.ts new file mode 100644 index 00000000..9e4ebcf9 --- /dev/null +++ b/apps/web/app/composables/useAuth.ts @@ -0,0 +1,26 @@ +import { useSupabaseClient } from '#imports' +import type { AuthProvider, RedirectPath } from '@vuejs-jp/model' +import { REDIRECT_URL } from '~/utils/environment.constants' + +export function useAuth() { + const supabase = useSupabaseClient() + + async function signIn(provider: AuthProvider, path: RedirectPath) { + const { error } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: `${REDIRECT_URL}${path}`, + } + }) + if (error) console.log(error) + } + + async function signOut() { + const { error } = await supabase.auth.signOut() + if (error) { + throw new Error('can not signout') + } + } + + return { signIn, signOut } +} diff --git a/apps/web/app/composables/useAuthSession.ts b/apps/web/app/composables/useAuthSession.ts new file mode 100644 index 00000000..227bcf07 --- /dev/null +++ b/apps/web/app/composables/useAuthSession.ts @@ -0,0 +1,46 @@ +import { createClient, type AuthChangeEvent } from '@supabase/supabase-js' +import { match } from 'ts-pattern' +import { useRuntimeConfig } from '#imports' +import { computed, ref } from 'vue' + +export function useAuthSession() { + const config = useRuntimeConfig() + const supabaseUrl = config.public.supabaseUrl + const supabaseKey = config.public.supabaseKey + + let _onAuthChanged: (e: AuthChangeEvent) => void = () => {} + const onAuthChanged = (callback: (e: AuthChangeEvent) => void) => { + _onAuthChanged = callback + } + + if (!supabaseUrl || !supabaseKey) { + return { onAuthChanged } + } + const supabase = createClient(supabaseUrl, supabaseKey) + + const signedUserId = ref(null) + const setSignedUserId = (target: string | null) => signedUserId.value = target + + const hasAuth = computed(() => signedUserId.value !== null) + + supabase.auth.onAuthStateChange((e, session) => { + match(e) + .with('INITIAL_SESSION', 'SIGNED_IN', () => { + if (session?.user) setSignedUserId(session.user.id) + _onAuthChanged(e) + }) + .with('SIGNED_OUT', () => { + setSignedUserId(null) + }) + .with( + 'MFA_CHALLENGE_VERIFIED', + 'PASSWORD_RECOVERY', + 'TOKEN_REFRESHED', + 'USER_UPDATED', + () => {}, + ) + .exhaustive() + }) + + return { hasAuth, onAuthChanged } +} diff --git a/apps/web/app/composables/useFormError.ts b/apps/web/app/composables/useFormError.ts index 2462eae9..a7ac1676 100644 --- a/apps/web/app/composables/useFormError.ts +++ b/apps/web/app/composables/useFormError.ts @@ -1,10 +1,19 @@ import { ref } from 'vue' export function useFormError() { + const idError = ref('') const nameError = ref('') const emailError = ref('') const detailError = ref('') + function validateId(value: string) { + if (value === '') { + idError.value = 'IDを入力してください' + return + } + nameError.value = '' + } + function validateName(value: string) { if (value === '') { nameError.value = '名前を入力してください' @@ -24,6 +33,17 @@ export function useFormError() { emailError.value = '' } + function validateAdminEmail(value: string) { + if (!/[A-Za-z0-9._%+-]+\+supaadmin@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}/.test(value)) { + emailError.value = 'メールアドレスの形式を確認してください' + return + } + console.log('email: ', value) + + validateEmail(value) + console.log('emailError: ', emailError) + } + function validateDetail(value: string) { if (value === '') { detailError.value = '問い合わせ内容を入力してください' @@ -33,11 +53,14 @@ export function useFormError() { } return { + idError, nameError, emailError, detailError, + validateId, validateName, validateEmail, + validateAdminEmail, validateDetail, } } diff --git a/apps/web/app/composables/useInvitation.ts b/apps/web/app/composables/useInvitation.ts new file mode 100644 index 00000000..f96f9599 --- /dev/null +++ b/apps/web/app/composables/useInvitation.ts @@ -0,0 +1,32 @@ +import { computed, ref } from 'vue' +import { useFormError } from './useFormError' + +export function useInvitation() { + const email = ref('') + const id = ref('') + const { ...rest } = useFormError() + + const isSubmittingId = computed(() => { + if (!id.value) return false + return rest.idError.value === '' + }) + + const isSubmittingEmail = computed(() => { + if (!email.value) return false + return rest.emailError.value === '' + }) + + async function publish(type: 'invite' | 'delete', target: string) { + await $fetch(`/api/${type}-user`, { + method: 'post', + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + }, + body: JSON.stringify(type === 'invite' ? { email: target } : { id: target }), + }) + } + + return { publish, isSubmittingId, isSubmittingEmail, email, id, ...rest } +} diff --git a/apps/web/app/pages/staff/console.vue b/apps/web/app/pages/staff/console.vue new file mode 100644 index 00000000..29879d4e --- /dev/null +++ b/apps/web/app/pages/staff/console.vue @@ -0,0 +1,108 @@ + + + + + + + + Console + + + signIn('github', '/')" + > + Login + + + + + Logout + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/pages/staff/invite.vue b/apps/web/app/pages/staff/invite.vue new file mode 100644 index 00000000..f671e482 --- /dev/null +++ b/apps/web/app/pages/staff/invite.vue @@ -0,0 +1,94 @@ + + + + + + + Invite or Delete User + + + + Invite User + + + + Delete User + + + + + + diff --git a/apps/web/app/server/api/delete-user.ts b/apps/web/app/server/api/delete-user.ts new file mode 100644 index 00000000..8ec5d905 --- /dev/null +++ b/apps/web/app/server/api/delete-user.ts @@ -0,0 +1,27 @@ +import { useRuntimeConfig } from '#imports' +import { defineEventHandler, readBody, type H3Event } from 'h3' +import { createClient } from '@supabase/supabase-js' + +export default defineEventHandler(async (event: H3Event) => { + const body = await readBody(event) + const id: string = body.id.toString() + + if (!id) { + return event.node.res.end('No id') + } + + const config = useRuntimeConfig() + const supabaseUrl = config.public.supabaseUrl + const serviceKey = config.public.serviceKey + + if (!supabaseUrl || !serviceKey) { + return event.node.res.end('No authentication') + } + + const supabase = createClient(supabaseUrl, serviceKey) + const { error } = await supabase.auth.admin.deleteUser(id) + + if (error) { + return event.node.res.end(error) + } +}) diff --git a/apps/web/app/server/api/invite-user.ts b/apps/web/app/server/api/invite-user.ts new file mode 100644 index 00000000..ef50c7a8 --- /dev/null +++ b/apps/web/app/server/api/invite-user.ts @@ -0,0 +1,30 @@ +import { useRuntimeConfig } from '#imports' +import { defineEventHandler, readBody, type H3Event } from 'h3' +import { createClient } from '@supabase/supabase-js' + +export default defineEventHandler(async (event: H3Event) => { + const body = await readBody(event) + const email: string = body.email.toString() + + if (!email) { + return event.node.res.end('No email') + } + + const config = useRuntimeConfig() + const supabaseUrl = config.public.supabaseUrl + const serviceKey = config.public.serviceKey + + if (!supabaseUrl || !serviceKey) { + return event.node.res.end('No authentication') + } + + const supabase = createClient(supabaseUrl, serviceKey) + const { error } = await supabase.auth.admin.inviteUserByEmail( + email, + { data: { user_role: 'admin' } }, + ) + + if (error) { + return event.node.res.end(error) + } +}) diff --git a/apps/web/app/server/tsconfig.json b/apps/web/app/server/tsconfig.json index b9ed69c1..3c6e968e 100644 --- a/apps/web/app/server/tsconfig.json +++ b/apps/web/app/server/tsconfig.json @@ -1,3 +1,3 @@ { - "extends": "../.nuxt/tsconfig.server.json" + "extends": "../../.nuxt/tsconfig.server.json" } diff --git a/apps/web/app/utils/environment.constants.ts b/apps/web/app/utils/environment.constants.ts index 7da985a6..5dd5e7b1 100644 --- a/apps/web/app/utils/environment.constants.ts +++ b/apps/web/app/utils/environment.constants.ts @@ -1 +1,3 @@ export const isProd = process.env.NODE_ENV === 'production' + +export const REDIRECT_URL = isProd ? 'https://vuefes.jp/2024' : 'https://localhost:3000' diff --git a/apps/web/certificates/.gitkeep b/apps/web/certificates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts index 28f7fb90..8e8e93b4 100644 --- a/apps/web/nuxt.config.ts +++ b/apps/web/nuxt.config.ts @@ -84,6 +84,17 @@ export default defineNuxtConfig({ }), ], }, + nitro: { + prerender: { + crawlLinks: true, + routes: ['/'], + ignore: ['/api'], + }, + }, + serverMiddleware: [ + '~/api/invite-user.ts', + '~/api/delete-user.ts', + ], hooks: { async 'nitro:config'(nitroConfig) { if (nitroConfig.dev) { @@ -92,7 +103,8 @@ export default defineNuxtConfig({ const supabaseUrl = process.env.SUPABASE_URL const supabaseKey = process.env.SUPABASE_KEY - if (!supabaseUrl || !supabaseKey) return + const serviceKey = process.env.SERVICE_KEY + if (!supabaseUrl || !supabaseKey || serviceKey) return }, 'prerender:routes': (context) => { for (const path of [...context.routes]) { @@ -120,11 +132,21 @@ export default defineNuxtConfig({ newtFormUid: process.env.NUXT_NEWT_FORM_UID, reCaptchaWebsiteKey: process.env.NUXT_RECAPTCHA_WEBSITE_KEY, // supabase - supabaseProjectUrl: process.env.SUPABASE_URL, - supabaseApiKey: process.env.SUPABASE_KEY, + supabaseUrl: process.env.SUPABASE_URL, + supabaseKey: process.env.SUPABASE_KEY, + serviceKey: process.env.SERVICE_KEY, // feature availableApplySponsor: process.env.AVAILABLE_APPLY_SPONSOR, + enableInviteStaff: process.env.ENABLE_INVITE_STAFF, + enableOperateAdmin: process.env.ENABLE_OPERATE_ADMIN, enableSwitchLocale: process.env.ENABLE_SWITCH_LOCALE, }, }, + // for https on localhost + // devServer: { + // https: { + // key: './certificates/localhost-key.pem', + // cert: './certificates/localhost.pem', + // }, + // }, }) diff --git a/apps/web/package.json b/apps/web/package.json index 64a313a2..a82b0dc9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "nuxt dev", - "build": "nuxt generate && ./scripts/postbuild.sh", + "build": "nuxt build && ./scripts/postbuild.sh", "preview": "nuxt preview", "lint": "bun run eslint && bun run htmllint", "lint-fix": "bun run eslint-fix && bun run htmllint-fix", diff --git a/packages/model/index.ts b/packages/model/index.ts index fbba69ca..8eb7f89a 100644 --- a/packages/model/index.ts +++ b/packages/model/index.ts @@ -1,3 +1,5 @@ +export * from './lib/admin' +export * from './lib/auth' export * from './lib/color' export * from './lib/icon' export * from './lib/path' diff --git a/packages/model/lib/admin.ts b/packages/model/lib/admin.ts new file mode 100644 index 00000000..75584779 --- /dev/null +++ b/packages/model/lib/admin.ts @@ -0,0 +1,12 @@ +export const AdminPageMap = { + sponsor: 'sponsor', + speaker: 'speaker', + staff: 'staff', +} as const + +export const adminPageList = Object.values(AdminPageMap).map((value) => { + return value.replace(/^[a-z]/g, function (val) { + return val.toUpperCase() + }) +}) +export type AdminPage = typeof AdminPageMap[keyof typeof AdminPageMap] diff --git a/packages/model/lib/auth.ts b/packages/model/lib/auth.ts new file mode 100644 index 00000000..a0713340 --- /dev/null +++ b/packages/model/lib/auth.ts @@ -0,0 +1,3 @@ +export type AuthProvider = 'github' | 'google' + +export type RedirectPath = '/' diff --git a/supabase/schema.sql b/supabase/schema.sql index e69de29b..65dac1d8 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -0,0 +1,40 @@ +-- *** Table definitions *** + +create table public.admin_users ( + id uuid primary key references auth.users on delete cascade, + email text not null +); + +alter table public.admin_users + enable row level security; + +create policy "Allow select for all admin users." + on public.admin_users for select using ( + auth.role() = 'authenticated' + ); + +create policy "Allow update for users themselves." + on public.admin_users for update using ( + auth.uid() = id + ); + +-- *** Function definitions *** +create or replace function public.create_admin_user() + returns trigger as $$ +begin + -- If user_role is 'admin', insert data into admin_users table + if new.raw_user_meta_data ->> 'user_role' = 'admin' then + insert into public.admin_users (id, email) + values (new.id, new.email); + end if; + + return new; +end; +$$ language plpgsql security definer; + +-- *** Trigger definitions *** +drop trigger if exists on_admin_user_created on auth.users; +create trigger on_admin_user_created + after insert on auth.users + for each row + execute function public.create_admin_user();