使用 SvelteKit 构建用户管理应用
本教程展示如何构建一个基本的用户管理应用程序。该应用程序对用户进行身份验证和识别,将其个人资料信息存储在数据库中,并允许用户登录、更新个人资料详细信息以及上传个人资料照片。该应用程序使用:
- Supabase数据库 - 一个Postgres数据库,用于存储用户数据,并通过行级安全性来保护数据,确保用户只能访问自己的信息。
- Supabase身份验证 - 允许用户注册和登录。
- Supabase存储 - 用户可以上传个人资料照片。
如果在学习本指南时遇到问题,请参考GitHub上的完整示例。
项目设置
在开始构建之前,我们需要设置数据库和 API。这就像在 Supabase 中创建一个新项目,然后在数据库中创建一个 “模式” 一样简单。
创建项目
- 在 Supabase 仪表板中创建一个新项目。
- 输入项目详细信息。
- 等待新数据库启动。
设置数据库模式
现在我们要设置数据库模式。我们可以在 SQL 编辑器中使用 “用户管理入门” 快速启动模板,或者您也可以直接复制/粘贴下面的 SQL 并自行运行。
获取 API 密钥
既然您已经创建了一些数据库表,就可以使用自动生成的 API 插入数据了。
我们只需要从 API 设置中获取项目 URL 和 anon
密钥。
- 前往仪表板中的 API 设置 页面。
- 在该页面找到您的项目
URL
、anon
和service_role
密钥。
构建应用
让我们从零开始构建这个Svelte应用。
初始化Svelte应用
我们可以使用SvelteKit骨架项目来初始化一个名为supabase-sveltekit
的应用(本教程将使用TypeScript):
123npm create svelte@latest supabase-sveltekitcd supabase-sveltekitnpm install
然后安装Supabase客户端库:supabase-js
1npm install @supabase/supabase-js
最后我们需要将环境变量保存在.env
文件中。
只需要之前复制的SUPABASE_URL
和SUPABASE_KEY
两个键值。
12PUBLIC_SUPABASE_URL="YOUR_SUPABASE_URL"PUBLIC_SUPABASE_ANON_KEY="YOUR_SUPABASE_KEY"
可选操作:添加src/styles.css
文件,包含示例中的CSS样式。
为 SSR 创建 Supabase 客户端
ssr
包会将 Supabase 配置为使用 Cookies,这是服务器端语言和框架所必需的。
安装 Supabase 包:
1npm install @supabase/ssr @supabase/supabase-js
使用 ssr
包创建 Supabase 客户端会自动将其配置为使用 Cookies。这意味着用户的会话在整个 SvelteKit 栈中都可用 - 页面、布局、服务器、钩子。
将以下代码添加到 src/hooks.server.ts
以在服务器上初始化客户端:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051// 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' }, })}
请注意,auth.getSession
会从本地存储介质中读取身份验证令牌和未编码的会话数据。除非本地会话过期,否则它不会向 Supabase 身份验证服务器发送请求。
如果您正在编写服务器代码,绝不要信任未编码的会话数据,因为它可能会被发送方篡改。如果您需要经过验证、可信的用户数据,请改用 auth.getUser
,它始终会向身份验证服务器发出请求以获取可信数据。
如果使用 TypeScript,编译器可能会对 event.locals.supabase
和 event.locals.safeGetSession
报错,可以通过更新 src/app.d.ts
来解决:
123456789101112131415161718// 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
文件来处理服务器端会话:
123456789101112// 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(), }}
启动开发服务器 (npm run dev
) 以生成我们在项目中引用的 ./$types
文件。
创建一个新的 src/routes/+layout.ts
文件来处理客户端会话和 supabase
对象:
1234567891011121314151617181920212223242526272829303132333435// 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
:
1234567891011121314151617181920212223242526272829<!-- 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>
设置登录页面
为您的应用创建一个魔法链接登录/注册页面:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354<!-- 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
文件来处理提交的魔法链接表单。
12345678910111213141516171819202122232425262728293031323334353637383940414243444546// 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: '请检查您的邮箱获取登录网站的魔法链接。', } },}
邮件模板
修改邮件模板以支持服务端认证流程。
在继续之前,我们需要修改邮件模板以支持发送令牌哈希:
- 前往仪表板的Auth模板页面
- 选择
确认注册
模板 - 将
{{ .ConfirmationURL }}
修改为{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email
- 对
魔法链接
模板重复上述步骤
您知道吗?您还可以自定义发送给新用户的邮件,包括邮件外观、内容和查询参数。查看您项目的设置。
确认端点
由于我们处于服务器端渲染(SSR)环境中,需要创建一个服务器端点来负责将token_hash
交换为会话。
在以下代码片段中,我们执行以下步骤:
- 使用
token_hash
查询参数获取从Supabase Auth服务器返回的token_hash
- 将此
token_hash
交换为会话,并将其存储在存储中(本例中使用cookies) - 最后将用户重定向到
account
页面或error
页面
1234567891011121314151617181920212223242526272829303132// 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
文件,内容如下:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778<!-- 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
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162import { 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, '/') } },}
启动应用!
现在所有页面都已就绪,在终端窗口中运行以下命令:
1npm run dev
然后在浏览器中打开 localhost:5173,您应该能看到完整的应用程序。
额外功能:个人头像
每个 Supabase 项目都配置了存储功能,用于管理照片和视频等大型文件。
创建上传组件
让我们为用户创建一个头像上传功能,使他们能够上传个人资料照片。我们可以先在 src/routes/account
目录下创建一个名为 Avatar.svelte
的新组件:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394<!-- 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>
添加新组件
然后我们可以将这个组件添加到账户页面:
123456789101112131415161718192021222324252627<!-- 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>
至此,您已经拥有了一个功能完整的应用程序!