入门

使用 SvelteKit 构建用户管理应用


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

Supabase User Management example

项目设置#

在开始构建之前,您需要设置数据库和 API。您可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“schema”来完成此操作。

创建项目#

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

设置数据库 schema#

现在设置数据库 schema。您可以使用 SQL 编辑器中的“用户管理 Starter”快速入门,或者您可以复制/粘贴下面的 SQL 并运行它。

  1. 转到控制面板中的 SQL 编辑器 页面。
  2. 点击 社区 > 快速入门 选项卡下的 用户管理 Starter
  3. 点击 运行

获取 API 详细信息#

现在您已经创建了一些数据库表,您可以使用自动生成的 API 插入数据。

为此,您需要从 项目 Connect 对话框中获取项目 URL 和密钥。

阅读 API 密钥文档 以全面了解所有密钥类型及其用途。

构建应用#

开始从头构建 Svelte 应用。

初始化 Svelte 应用#

使用 SvelteKit 骨架项目初始化一个名为 supabase-sveltekit 的应用(对于本教程,选择“SvelteKit minimal”并使用 TypeScript)

1
npx sv create supabase-sveltekit
2
cd supabase-sveltekit
3
npm install

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

1
npm install @supabase/supabase-js

最后,将环境变量保存到 .env 文件中。您只需要 PUBLIC_SUPABASE_URL 和您之前复制的密钥 此处

1
PUBLIC_SUPABASE_URL="YOUR_SUPABASE_URL"
2
PUBLIC_SUPABASE_PUBLISHABLE_KEY="YOUR_SUPABASE_PUBLISHABLE_KEY"

应用样式 (可选)#

一个可选步骤是更新 CSS 文件 src/styles.css 以使应用看起来更美观。您可以在 示例仓库中找到此文件的完整内容。

为 SSR 创建 Supabase 客户端#

ssr 包配置 Supabase 以使用 Cookie,这对于服务器端语言和框架是必需的。

安装 SSR 包

1
npm install @supabase/ssr

使用 ssr 包创建 Supabase 客户端会自动配置它以使用 Cookie。这意味着用户会话在整个 SvelteKit 堆栈中可用 - 页面、布局、服务器和钩子。

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

1
// src/hooks.server.ts
2
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY } from '$env/static/public'
3
import { createServerClient } from '@supabase/ssr'
4
import type { Handle } from '@sveltejs/kit'
5
6
export const handle: Handle = async ({ event, resolve }) => {
7
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, {
8
cookies: {
9
getAll: () => event.cookies.getAll(),
10
/**
11
* Note: You have to add the `path` variable to the
12
* set and remove method due to sveltekit's cookie API
13
* requiring this to be set, setting the path to `/`
14
* will replicate previous/standard behaviour (https://kit.svelte.net.cn/docs/types#public-types-cookies)
15
*/
16
setAll: (cookiesToSet) => {
17
cookiesToSet.forEach(({ name, value, options }) => {
18
event.cookies.set(name, value, { ...options, path: '/' })
19
})
20
},
21
},
22
})
23
24
/**
25
* Unlike `supabase.auth.getSession`, which is unsafe on the server because it
26
* doesn't validate the JWT, this function validates the JWT by first calling
27
* `getUser` and aborts early if the JWT signature is invalid.
28
*/
29
event.locals.safeGetSession = async () => {
30
const {
31
data: { user },
32
error,
33
} = await event.locals.supabase.auth.getUser()
34
if (error) {
35
return { session: null, user: null }
36
}
37
38
const {
39
data: { session },
40
} = await event.locals.supabase.auth.getSession()
41
return { session, user }
42
}
43
44
return resolve(event, {
45
filterSerializedResponseHeaders(name: string) {
46
return name === 'content-range' || name === 'x-supabase-api-version'
47
},
48
})
49
}
查看源代码

由于本教程使用 TypeScript,编译器会抱怨 event.locals.supabaseevent.locals.safeGetSession,您可以通过使用以下内容更新 src/app.d.ts 来修复此问题

1
import { , } from '@supabase/supabase-js'
2
// See https://kit.svelte.net.cn/docs/types#app
3
// for information about these interfaces
4
declare {
5
namespace {
6
// interface Error {}
7
interface {
8
:
9
(): <{ : | null; ?: ["user"] | null }>
10
}
11
interface {
12
: | null
13
?: ["user"] | null
14
}
15
// interface PageState {}
16
// interface Platform {}
17
}
18
}
19
20
export {};
查看源代码

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

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

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

1
// src/routes/+layout.ts
2
import { PUBLIC_SUPABASE_PUBLISHABLE_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'
3
import type { LayoutLoad } from './$types'
4
import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr'
5
6
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
7
depends('supabase:auth')
8
9
const supabase = isBrowser()
10
? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, {
11
global: {
12
fetch,
13
},
14
})
15
: createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, {
16
global: {
17
fetch,
18
},
19
cookies: {
20
getAll() {
21
return data.cookies
22
},
23
},
24
})
25
26
/**
27
* It's fine to use `getSession` here, because on the client, `getSession` is
28
* safe, and on the server, it reads `session` from the `LayoutData`, which
29
* safely checked the session using `safeGetSession`.
30
*/
31
const {
32
data: { session },
33
} = await supabase.auth.getSession()
34
35
return { supabase, session }
36
}
查看源代码

创建 src/routes/+layout.svelte

1
<!-- src/routes/+layout.svelte -->
2
<script lang="ts">
3
import '../styles.css'
4
import { invalidate } from '$app/navigation'
5
import { onMount } from 'svelte'
6
7
let { data, children } = $props()
8
let { supabase, session } = $derived(data)
9
10
onMount(() => {
11
const { data } = supabase.auth.onAuthStateChange((event, _session) => {
12
if (_session?.expires_at !== session?.expires_at) {
13
invalidate('supabase:auth')
14
}
15
})
16
17
return () => data.subscription.unsubscribe()
18
})
19
</script>
20
21
<svelte:head>
22
<title>User Management</title>
23
</svelte:head>
24
25
<div class="container" style="padding: 50px 0 100px 0">
26
{@render children()}
27
</div>
查看源代码

设置登录页面#

通过更新 routes/+page.svelte 文件来为您的应用程序创建一个魔法链接登录/注册页面

1
<!-- src/routes/+page.svelte -->
2
<script lang="ts">
3
import { enhance } from '$app/forms'
4
import type { ActionData, SubmitFunction } from './$types.js'
5
6
interface Props {
7
form: ActionData
8
}
9
let { form }: Props = $props()
10
11
let loading = $state(false)
12
13
const handleSubmit: SubmitFunction = () => {
14
loading = true
15
return async ({ update }) => {
16
update()
17
loading = false
18
}
19
}
20
</script>
21
22
<svelte:head>
23
<title>User Management</title>
24
</svelte:head>
25
26
<form class="row flex flex-center" method="POST" use:enhance={handleSubmit}>
27
<div class="col-6 form-widget">
28
<h1 class="header">Supabase + SvelteKit</h1>
29
<p class="description">Sign in via magic link with your email below</p>
30
{#if form?.message !== undefined}
31
<div class="success {form?.success ? '' : 'fail'}">
32
{form?.message}
33
</div>
34
{/if}
35
<div>
36
<label for="email">Email address</label>
37
<input
38
id="email"
39
name="email"
40
class="inputField"
41
type="email"
42
placeholder="Your email"
43
value={form?.email ?? ''}
44
/>
45
</div>
46
{#if form?.errors?.email}
47
<span class="flex items-center text-sm error">
48
{form?.errors?.email}
49
</span>
50
{/if}
51
<div>
52
<button class="button primary block">
53
{ loading ? 'Loading' : 'Send magic link' }
54
</button>
55
</div>
56
</div>
57
</form>
查看源代码

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

1
// src/routes/+page.server.ts
2
import { fail, redirect } from '@sveltejs/kit'
3
import type { Actions, PageServerLoad } from './$types'
4
5
export const load: PageServerLoad = async ({ url, locals: { safeGetSession } }) => {
6
const { session } = await safeGetSession()
7
8
// if the user is already logged in return them to the account page
9
if (session) {
10
redirect(303, '/account')
11
}
12
13
return { url: url.origin }
14
}
15
16
export const actions: Actions = {
17
default: async (event) => {
18
const {
19
url,
20
request,
21
locals: { supabase }
22
} = event
23
const formData = await request.formData()
24
const email = formData.get('email') as string
25
const validEmail = /^[\w-\.+]+@([\w-]+\.)+[\w-]{2,8}$/.test(email)
26
27
if (!validEmail) {
28
return fail(400, { errors: { email: "Please enter a valid email address" }, email })
29
}
30
31
const { error } = await supabase.auth.signInWithOtp({ email })
32
33
if (error) {
34
return fail(400, {
35
success: false,
36
email,
37
message: `There was an issue, Please contact support.`
38
})
39
}
40
41
return {
42
success: true,
43
message: 'Please check your email for a magic link to log into the website.'
44
}
45
}
46
}
查看源代码

电子邮件模板#

更改电子邮件模板以支持服务器端身份验证流程。

在继续之前,让我们更改电子邮件模板以支持发送令牌哈希

  • 转到项目仪表板中的 Auth > Emails 页面。
  • 选择 Confirm signup 模板。
  • {{ .ConfirmationURL }} 更改为 {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email
  • Magic link 模板重复上一步。

确认端点#

由于这是一个服务器端渲染 (SSR) 环境,您需要创建一个服务器端点来负责用 token_hash 交换会话。

以下代码片段执行以下步骤

  • 从 Supabase Auth 服务器使用 token_hash 查询参数检索发送回的 token_hash
  • 将此 token_hash 交换为会话,您将其存储在存储中(在本例中为 Cookie)。
  • 最后,将用户重定向到 account 页面或 error 页面。
1
// src/routes/auth/confirm/+server.js
2
import type { EmailOtpType } from '@supabase/supabase-js'
3
import { redirect } from '@sveltejs/kit'
4
5
import type { RequestHandler } from './$types'
6
7
export const GET: RequestHandler = async ({ url, locals: { supabase } }) => {
8
const token_hash = url.searchParams.get('token_hash')
9
const type = url.searchParams.get('type') as EmailOtpType | null
10
const next = url.searchParams.get('next') ?? '/account'
11
12
/**
13
* Clean up the redirect URL by deleting the Auth flow parameters.
14
*
15
* `next` is preserved for now, because it's needed in the error case.
16
*/
17
const redirectTo = new URL(url)
18
redirectTo.pathname = next
19
redirectTo.searchParams.delete('token_hash')
20
redirectTo.searchParams.delete('type')
21
22
if (token_hash && type) {
23
const { error } = await supabase.auth.verifyOtp({ type, token_hash })
24
if (!error) {
25
redirectTo.searchParams.delete('next')
26
redirect(303, redirectTo)
27
}
28
}
29
30
redirectTo.pathname = '/auth/error'
31
redirect(303, redirectTo)
32
}
查看源代码

身份验证错误页面#

如果确认令牌出现错误,请将用户重定向到错误页面。

1
<p>Login error</p>
查看源代码

账户页面#

用户登录后,他们需要能够编辑他们的个人资料详细信息页面。使用以下内容创建一个新的 src/routes/account/+page.svelte 文件。

1
<script lang="ts">
2
import { enhance } from '$app/forms';
3
import type { SubmitFunction } from '@sveltejs/kit';
4
5
// ...
6
7
let { data, form } = $props()
8
let { session, supabase, profile } = $derived(data)
9
let profileForm: HTMLFormElement
10
let loading = $state(false)
11
let fullName: string = profile?.full_name ?? ''
12
let username: string = profile?.username ?? ''
13
let website: string = profile?.website ?? ''
14
15
// ...
16
17
const handleSubmit: SubmitFunction = () => {
18
loading = true
19
return async () => {
20
loading = false
21
}
22
}
23
24
const handleSignOut: SubmitFunction = () => {
25
loading = true
26
return async ({ update }) => {
27
loading = false
28
update()
29
}
30
}
31
</script>
32
33
<div class="form-widget">
34
<form
35
class="form-widget"
36
method="post"
37
action="?/update"
38
use:enhance={handleSubmit}
39
bind:this={profileForm}
40
>
41
42
// ...
43
44
<div>
45
<label for="email">Email</label>
46
<input id="email" type="text" value={session.user.email} disabled />
47
</div>
48
49
<div>
50
<label for="fullName">Full Name</label>
51
<input id="fullName" name="fullName" type="text" value={form?.fullName ?? fullName} />
52
</div>
53
54
<div>
55
<label for="username">Username</label>
56
<input id="username" name="username" type="text" value={form?.username ?? username} />
57
</div>
58
59
<div>
60
<label for="website">Website</label>
61
<input id="website" name="website" type="url" value={form?.website ?? website} />
62
</div>
63
64
<div>
65
<input
66
type="submit"
67
class="button block primary"
68
value={loading ? 'Loading...' : 'Update'}
69
disabled={loading}
70
/>
71
</div>
72
</form>
73
74
<form method="post" action="?/signout" use:enhance={handleSignOut}>
75
<div>
76
<button class="button block" disabled={loading}>Sign Out</button>
77
</div>
78
</form>
79
</div>
查看源代码

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

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

启动!#

准备好所有页面后,在终端中运行此命令

1
npm run dev

然后打开浏览器到 localhost:5173,您应该会看到完成的应用。

Supabase Svelte

奖励:个人资料照片#

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

创建一个上传小部件#

创建一个用户头像,以便他们可以上传个人资料照片。首先在 src/routes/account 目录中创建一个名为 Avatar.svelte 的新组件

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

添加新的小部件#

将小部件添加到 Account 页面

1
<script lang="ts">
2
3
// ...
4
5
import Avatar from './Avatar.svelte'
6
7
// ...
8
9
<div class="form-widget">
10
11
// ...
12
13
<Avatar
14
{supabase}
15
bind:url={avatarUrl}
16
size={10}
17
onupload={() => {
18
profileForm.requestSubmit();
19
}}
20
/>
21
22
// ...
23
24
</div>
查看源代码

此时,您已经拥有一个功能齐全的应用!