Skip to content

Commit

Permalink
Merge pull request #132 from vuejs-jp/feature/invite-user-admin-setup…
Browse files Browse the repository at this point in the history
…-etc

feat: invite user, admin setup
  • Loading branch information
jiyuujin authored May 31, 2024
2 parents d870cfb + 735350d commit a855245
Show file tree
Hide file tree
Showing 22 changed files with 606 additions and 5 deletions.
2 changes: 2 additions & 0 deletions apps/web-docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ export default defineConfig({
nav: [
{ text: 'Top', link: '/' },
{ text: 'CSS', link: '/css/getting-started' },
{ text: 'Supabase', link: '/supabase/getting-started' },
],

sidebar: [
{
text: 'Examples',
items: [
{ text: 'CSS', link: '/css/getting-started' },
{ text: 'Supabase', link: '/supabase/getting-started' },
],
},
],
Expand Down
3 changes: 3 additions & 0 deletions apps/web-docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ hero:
- theme: brand
text: CSS
link: /css/getting-started
- theme: brand
text: Supabase
link: /supabase/getting-started
---
102 changes: 102 additions & 0 deletions apps/web-docs/supabase/getting-started.md
Original file line number Diff line number Diff line change
@@ -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)
}
})
```
2 changes: 2 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ NUXT_RECAPTCHA_WEBSITE_KEY=
SUPABASE_URL=
SUPABASE_KEY=
AVAILABLE_APPLY_SPONSOR=
ENABLE_INVITE_STAFF=
ENABLE_OPERATE_ADMIN=
ENABLE_SWITCH_LOCALE=
25 changes: 25 additions & 0 deletions apps/web/app/components/admin/List.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { AdminPage } from '@vuejs-jp/model'
interface ListProps {
page: AdminPage
}
const props = defineProps<ListProps>()
const pageText = props.page.replace(/^[a-z]/g, function (val) {
return val.toUpperCase()
})
</script>

<template>
<div class="tab-content">
<h2>{{ pageText }}</h2>
</div>
</template>

<style scoped>
.tab-content {
margin: 0 1.5%;
}
</style>
26 changes: 26 additions & 0 deletions apps/web/app/composables/useAuth.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
46 changes: 46 additions & 0 deletions apps/web/app/composables/useAuthSession.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 }
}
23 changes: 23 additions & 0 deletions apps/web/app/composables/useFormError.ts
Original file line number Diff line number Diff line change
@@ -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 = '名前を入力してください'
Expand All @@ -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 = '問い合わせ内容を入力してください'
Expand All @@ -33,11 +53,14 @@ export function useFormError() {
}

return {
idError,
nameError,
emailError,
detailError,
validateId,
validateName,
validateEmail,
validateAdminEmail,
validateDetail,
}
}
32 changes: 32 additions & 0 deletions apps/web/app/composables/useInvitation.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading

0 comments on commit a855245

Please sign in to comment.