快速开始

使用 SvelteKit 构建用户管理应用


本教程展示如何构建一个基本的用户管理应用程序。该应用程序对用户进行身份验证和识别,将其个人资料信息存储在数据库中,并允许用户登录、更新个人资料详细信息以及上传个人资料照片。该应用程序使用:

Supabase 用户管理示例

项目设置

在开始构建之前,我们需要设置数据库和 API。这就像在 Supabase 中创建一个新项目,然后在数据库中创建一个 “模式” 一样简单。

创建项目

  1. 在 Supabase 仪表板中创建一个新项目
  2. 输入项目详细信息。
  3. 等待新数据库启动。

设置数据库模式

现在我们要设置数据库模式。我们可以在 SQL 编辑器中使用 “用户管理入门” 快速启动模板,或者您也可以直接复制/粘贴下面的 SQL 并自行运行。

  1. 转到仪表板中的SQL 编辑器页面。
  2. 点击 用户管理入门
  3. 点击 运行
1
2
3
4
supabase link --project-ref <project-id># 您可以从项目的仪表板 URL 获取 <project-id>:https://supabase.com/dashboard/project/<project-id>supabase db pull

获取 API 密钥

既然您已经创建了一些数据库表,就可以使用自动生成的 API 插入数据了。 我们只需要从 API 设置中获取项目 URL 和 anon 密钥。

  1. 前往仪表板中的 API 设置 页面。
  2. 在该页面找到您的项目 URLanonservice_role 密钥。

构建应用

让我们从零开始构建这个Svelte应用。

初始化Svelte应用

我们可以使用SvelteKit骨架项目来初始化一个名为supabase-sveltekit的应用(本教程将使用TypeScript):

1
2
3
npm create svelte@latest supabase-sveltekitcd supabase-sveltekitnpm install

然后安装Supabase客户端库:supabase-js

1
npm install @supabase/supabase-js

最后我们需要将环境变量保存在.env文件中。 只需要之前复制SUPABASE_URLSUPABASE_KEY两个键值。

1
2
PUBLIC_SUPABASE_URL="YOUR_SUPABASE_URL"PUBLIC_SUPABASE_ANON_KEY="YOUR_SUPABASE_KEY"

可选操作:添加src/styles.css文件,包含示例中的CSS样式

为 SSR 创建 Supabase 客户端

ssr 包会将 Supabase 配置为使用 Cookies,这是服务器端语言和框架所必需的。

安装 Supabase 包:

1
npm install @supabase/ssr @supabase/supabase-js

使用 ssr 包创建 Supabase 客户端会自动将其配置为使用 Cookies。这意味着用户的会话在整个 SvelteKit 栈中都可用 - 页面、布局、服务器、钩子。

将以下代码添加到 src/hooks.server.ts 以在服务器上初始化客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// src/hooks.server.tsimport { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'import { createServerClient } from '@supabase/ssr'import type { Handle } from '@sveltejs/kit'export const handle: Handle = async ({ event, resolve }) => { event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { cookies: { getAll: () => event.cookies.getAll(), /** * SvelteKit 的 cookies API 要求在 cookie 选项中显式设置 `path`。 * 将 `path` 设置为 `/` 可复制之前的标准行为。 */ setAll: (cookiesToSet) => { cookiesToSet.forEach(({ name, value, options }) => { event.cookies.set(name, value, { ...options, path: '/' }) }) }, }, }) /** * 与 `supabase.auth.getSession()` 不同(它返回会话但_不_验证 JWT), * 此函数还会调用 `getUser()` 在返回会话前验证 JWT。 */ event.locals.safeGetSession = async () => { const { data: { session }, } = await event.locals.supabase.auth.getSession() if (!session) { return { session: null, user: null } } const { data: { user }, error, } = await event.locals.supabase.auth.getUser() if (error) { // JWT 验证失败 return { session: null, user: null } } return { session, user } } return resolve(event, { filterSerializedResponseHeaders(name) { return name === 'content-range' || name === 'x-supabase-api-version' }, })}

如果使用 TypeScript,编译器可能会对 event.locals.supabaseevent.locals.safeGetSession 报错,可以通过更新 src/app.d.ts 来解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/app.d.tsimport { SupabaseClient, Session } from '@supabase/supabase-js'declare global { namespace App { interface Locals { supabase: SupabaseClient safeGetSession(): Promise<{ session: Session | null; user: User | null }> } interface PageData { session: Session | null user: User | null } // interface Error {} // interface Platform {} }}

创建一个新的 src/routes/+layout.server.ts 文件来处理服务器端会话:

1
2
3
4
5
6
7
8
9
10
11
12
// src/routes/+layout.server.tsimport type { LayoutServerLoad } from './$types'export const load: LayoutServerLoad = async ({ locals: { safeGetSession }, cookies }) => { const { session, user } = await safeGetSession() return { session, user, cookies: cookies.getAll(), }}

创建一个新的 src/routes/+layout.ts 文件来处理客户端会话和 supabase 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// src/routes/+layout.tsimport { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr'import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'import type { LayoutLoad } from './$types'export const load: LayoutLoad = async ({ fetch, data, depends }) => { depends('supabase:auth') const supabase = isBrowser() ? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { global: { fetch, }, }) : createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { global: { fetch, }, cookies: { getAll() { return data.cookies }, }, }) /** * 在此处使用 `getSession` 没有问题,因为在客户端 `getSession` 是安全的, * 而在服务器端,它会从 `LayoutData` 读取 `session`,后者已使用 `safeGetSession` 安全检查了会话。 */ const { data: { session }, } = await supabase.auth.getSession() return { supabase, session }}

更新你的 src/routes/+layout.svelte

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<!-- src/routes/+layout.svelte --><script lang="ts"> import '../styles.css' import { invalidate } from '$app/navigation' import { onMount } from 'svelte' export let data let { supabase, session } = data $: ({ supabase, session } = data) onMount(() => { const { data } = supabase.auth.onAuthStateChange((event, newSession) => { if (newSession?.expires_at !== session?.expires_at) { invalidate('supabase:auth') } }) return () => data.subscription.unsubscribe() })</script><svelte:head> <title>用户管理</title></svelte:head><div class="container" style="padding: 50px 0 100px 0"> <slot /></div>

设置登录页面

为您的应用创建一个魔法链接登录/注册页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<!-- src/routes/+page.svelte --><script lang="ts"> import { enhance } from '$app/forms' import type { ActionData, SubmitFunction } from './$types.js' export let form: ActionData; let loading = false const handleSubmit: SubmitFunction = () => { loading = true return async ({ update }) => { update() loading = false } }</script><svelte:head> <title>用户管理</title></svelte:head><form class="row flex flex-center" method="POST" use:enhance={handleSubmit}> <div class="col-6 form-widget"> <h1 class="header">Supabase + SvelteKit</h1> <p class="description">通过下方邮箱接收魔法链接登录</p> {#if form?.message !== undefined} <div class="success {form?.success ? '' : 'fail'}"> {form?.message} </div> {/if} <div> <label for="email">邮箱地址</label> <input id="email" name="email" class="inputField" type="email" placeholder="您的邮箱" value={form?.email ?? ''} /> </div> {#if form?.errors?.email} <span class="flex items-center text-sm error"> {form?.errors?.email} </span> {/if} <div> <button class="button primary block"> { loading ? '加载中' : '发送魔法链接' } </button> </div> </div></form>

创建一个 src/routes/+page.server.ts 文件来处理提交的魔法链接表单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// src/routes/+page.server.tsimport { fail, redirect } from '@sveltejs/kit'import type { Actions, PageServerLoad } from './$types'export const load: PageServerLoad = async ({ url, locals: { safeGetSession } }) => { const { session } = await safeGetSession() // 如果用户已登录,则重定向到账户页面 if (session) { redirect(303, '/account') } return { url: url.origin }}export const actions: Actions = { default: async (event) => { const { url, request, locals: { supabase }, } = event const formData = await request.formData() const email = formData.get('email') as string const validEmail = /^[\w-\.+]+@([\w-]+\.)+[\w-]{2,8}$/.test(email) if (!validEmail) { return fail(400, { errors: { email: '请输入有效的邮箱地址' }, email }) } const { error } = await supabase.auth.signInWithOtp({ email }) if (error) { return fail(400, { success: false, email, message: `遇到问题,请联系支持人员。`, }) } return { success: true, message: '请检查您的邮箱获取登录网站的魔法链接。', } },}

邮件模板

修改邮件模板以支持服务端认证流程。

在继续之前,我们需要修改邮件模板以支持发送令牌哈希:

  1. 前往仪表板的Auth模板页面
  2. 选择确认注册模板
  3. {{ .ConfirmationURL }}修改为{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email
  4. 魔法链接模板重复上述步骤

确认端点

由于我们处于服务器端渲染(SSR)环境中,需要创建一个服务器端点来负责将token_hash交换为会话。

在以下代码片段中,我们执行以下步骤:

  • 使用token_hash查询参数获取从Supabase Auth服务器返回的token_hash
  • 将此token_hash交换为会话,并将其存储在存储中(本例中使用cookies)
  • 最后将用户重定向到account页面或error页面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// src/routes/auth/confirm/+server.tsimport type { EmailOtpType } from '@supabase/supabase-js'import { redirect } from '@sveltejs/kit'import type { RequestHandler } from './$types'export const GET: RequestHandler = async ({ url, locals: { supabase } }) => { const token_hash = url.searchParams.get('token_hash') const type = url.searchParams.get('type') as EmailOtpType | null const next = url.searchParams.get('next') ?? '/account' /** * 通过删除Auth流程参数来清理重定向URL * * 暂时保留`next`参数,因为在错误情况下需要它 */ const redirectTo = new URL(url) redirectTo.pathname = next redirectTo.searchParams.delete('token_hash') redirectTo.searchParams.delete('type') if (token_hash && type) { const { error } = await supabase.auth.verifyOtp({ type, token_hash }) if (!error) { redirectTo.searchParams.delete('next') redirect(303, redirectTo) } } redirectTo.pathname = '/auth/error' redirect(303, redirectTo)}

认证错误页面

如果确认令牌时出现错误,您将被重定向到此错误页面。

1
<p>登录错误</p>

账户页面

用户登录后,需要能够编辑个人资料信息和管理账户。创建一个新的 src/routes/account/+page.svelte 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
<!-- src/routes/account/+page.svelte --><script lang="ts"> import { enhance } from '$app/forms'; import type { SubmitFunction } from '@sveltejs/kit'; export let data export let form let { session, supabase, profile } = data $: ({ session, supabase, profile } = data) let profileForm: HTMLFormElement let loading = false let fullName: string = profile?.full_name ?? '' let username: string = profile?.username ?? '' let website: string = profile?.website ?? '' let avatarUrl: string = profile?.avatar_url ?? '' const handleSubmit: SubmitFunction = () => { loading = true return async () => { loading = false } } const handleSignOut: SubmitFunction = () => { loading = true return async ({ update }) => { loading = false update() } }</script><div class="form-widget"> <form class="form-widget" method="post" action="?/update" use:enhance={handleSubmit} bind:this={profileForm} > <div> <label for="email">邮箱</label> <input id="email" type="text" value={session.user.email} disabled /> </div> <div> <label for="fullName">全名</label> <input id="fullName" name="fullName" type="text" value={form?.fullName ?? fullName} /> </div> <div> <label for="username">用户名</label> <input id="username" name="username" type="text" value={form?.username ?? username} /> </div> <div> <label for="website">网站</label> <input id="website" name="website" type="url" value={form?.website ?? website} /> </div> <div> <input type="submit" class="button block primary" value={loading ? '加载中...' : '更新'} disabled={loading} /> </div> </form> <form method="post" action="?/signout" use:enhance={handleSignOut}> <div> <button class="button block" disabled={loading}>退出登录</button> </div> </form></div>

现在创建对应的 src/routes/account/+page.server.ts 文件,该文件将通过 load 函数处理从服务器加载数据,并通过 actions 对象处理所有表单操作。

src/routes/account/+page.server.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import { fail, redirect } from '@sveltejs/kit'import type { Actions, PageServerLoad } from './$types'export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => { const { session } = await safeGetSession() if (!session) { redirect(303, '/') } const { data: profile } = await supabase .from('profiles') .select(`username, full_name, website, avatar_url`) .eq('id', session.user.id) .single() return { session, profile }}export const actions: Actions = { update: async ({ request, locals: { supabase, safeGetSession } }) => { const formData = await request.formData() const fullName = formData.get('fullName') as string const username = formData.get('username') as string const website = formData.get('website') as string const avatarUrl = formData.get('avatarUrl') as string const { session } = await safeGetSession() const { error } = await supabase.from('profiles').upsert({ id: session?.user.id, full_name: fullName, username, website, avatar_url: avatarUrl, updated_at: new Date(), }) if (error) { return fail(500, { fullName, username, website, avatarUrl, }) } return { fullName, username, website, avatarUrl, } }, signout: async ({ locals: { supabase, safeGetSession } }) => { const { session } = await safeGetSession() if (session) { await supabase.auth.signOut() redirect(303, '/') } },}

启动应用!

现在所有页面都已就绪,在终端窗口中运行以下命令:

1
npm run dev

然后在浏览器中打开 localhost:5173,您应该能看到完整的应用程序。

Supabase Svelte

额外功能:个人头像

每个 Supabase 项目都配置了存储功能,用于管理照片和视频等大型文件。

创建上传组件

让我们为用户创建一个头像上传功能,使他们能够上传个人资料照片。我们可以先在 src/routes/account 目录下创建一个名为 Avatar.svelte 的新组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
<!-- src/routes/account/Avatar.svelte --><script lang="ts"> import type { SupabaseClient } from '@supabase/supabase-js' import { createEventDispatcher } from 'svelte' export let size = 10 export let url: string export let supabase: SupabaseClient let avatarUrl: string | null = null let uploading = false let files: FileList const dispatch = createEventDispatcher() const downloadImage = async (path: string) => { try { const { data, error } = await supabase.storage.from('avatars').download(path) if (error) { throw error } const url = URL.createObjectURL(data) avatarUrl = url } catch (error) { if (error instanceof Error) { console.log('下载图片时出错: ', error.message) } } } const uploadAvatar = async () => { try { uploading = true if (!files || files.length === 0) { throw new Error('请选择要上传的图片') } const file = files[0] const fileExt = file.name.split('.').pop() const filePath = `${Math.random()}.${fileExt}` const { error } = await supabase.storage.from('avatars').upload(filePath, file) if (error) { throw error } url = filePath setTimeout(() => { dispatch('upload') }, 100) } catch (error) { if (error instanceof Error) { alert(error.message) } } finally { uploading = false } } $: if (url) downloadImage(url)</script><div> {#if avatarUrl} <img src={avatarUrl} alt={avatarUrl ? '头像' : '无图片'} class="avatar image" style="height: {size}em; width: {size}em;" /> {:else} <div class="avatar no-image" style="height: {size}em; width: {size}em;" /> {/if} <input type="hidden" name="avatarUrl" value={url} /> <div style="width: {size}em;"> <label class="button primary block" for="single"> {uploading ? '上传中...' : '上传'} </label> <input style="visibility: hidden; position:absolute;" type="file" id="single" accept="image/*" bind:files on:change={uploadAvatar} disabled={uploading} /> </div></div>

添加新组件

然后我们可以将这个组件添加到账户页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!-- src/routes/account/+page.svelte --><script lang="ts"> // 导入新组件 import Avatar from './Avatar.svelte'</script><div class="form-widget"> <form class="form-widget" method="post" action="?/update" use:enhance={handleSubmit} bind:this={profileForm} > <!-- 添加到主体部分 --> <Avatar {supabase} bind:url={avatarUrl} size={10} on:upload={() => { profileForm.requestSubmit(); }} /> <!-- 其他表单元素 --> </form></div>

至此,您已经拥有了一个功能完整的应用程序!