基于 SolidJS + Supabase 构建用户管理应用:Magic Link 登录、PostgreSQL RLS 与头像上传实战
2026/9/7 20:09:23 网站建设 项目流程

基于 SolidJS + Supabase 构建用户管理应用:Magic Link 登录、PostgreSQL RLS 与头像上传实战

【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase

导读

本文围绕 Supabase 官方仓库examples/user-management/solid-user-management这一示例应用展开,系统讲解如何用SolidJS + Vite从零搭建一个完整的用户管理与资料编辑功能:包括数据库profiles表与行级安全策略(RLS)的建立、基于邮箱 Magic Link 的免密登录、用户资料的新增/更新,以及头像图片上传与回显。读完本文,你既能拿到可直接复制运行的工程级代码,也能理解 Supabase Auth、Postgres 行级安全与 Storage 三个核心能力在真实 CRUD 场景中如何协同工作。

示例本身只是一个“最小可用但闭环完整”的 demo:没有引入复杂状态管理库,全部业务逻辑仅由App / Auth / Account / Avatar四个组件加一个客户端封装文件完成,非常适合作为学习 Supabase 集成模式的脚手架。完整示例代码与配置见 solid-user-management,仓库同级目录下还提供了 react-user-management、nextjs-user-management、svelte-user-management、vue3-user-management 等多框架版本,便于横向对照同一套 Supabase API 在不同前端框架下的写法。

一、项目结构与工程骨架

该示例是一个由 Vite 脚手架起来的 SolidJS + TypeScript 单页应用,根目录结构如下:

examples/user-management/solid-user-management/ ├── .env.example # 环境变量模板 ├── index.html ├── package.json ├── tsconfig.json ├── vite.config.ts # Vite 配置(dev server 端口 3000) └── src/ ├── Account.tsx # 资料读取与更新(profiles 表 CRUD) ├── App.tsx # 会话状态驱动 Auth / Account 切换 ├── Auth.tsx # 邮箱 Magic Link 登录 ├── Avatar.tsx # Storage 头像上传与下载 ├── index.css ├── index.tsx # Solid 渲染入口 ├── schema.ts # profiles 表 TypeScript 类型(作为 createClient 泛型) └── supabaseClient.tsx # supabase-js 客户端实例

从 package.json 可以看到运行时依赖非常精简:solid-js(UI 框架)、@supabase/supabase-jsv2(官方 JS 客户端);开发依赖为vite+vite-plugin-solid+typescript+prettier,没有任何 UI 组件库,所有表单样式都来自index.css中的极简类名。

二、快速开始:安装依赖与本地运行

示例在 README 中提供了一套可直接执行的脚本。进入项目目录后先安装依赖:

$ npm install

随后可使用以下脚本(与 package.json 的scripts字段一一对应):

命令对应实现作用
npm dev/npm startvite启动开发服务器,默认端口3000(见 vite.config.ts 中server.port),支持热更新
npm run buildvite buildesnext为编译目标打包生产版本,产物输出到dist目录,文件名带内容哈希、已做压缩优化
npm run servevite preview本地预览生产构建产物
npm run formatprettier自动格式化src目录下的 JS/TS/TSX 源码

运行时在浏览器打开http://localhost:3000即可看到应用。需要说明的是:此时页面上的“Send magic link”与登录流程尚无法真正工作,因为应用还缺少指向你后端实例的配置——这正是下面“从零搭建”要解决的。

三、从零搭建:数据库端四步准备

如果不想基于云端项目,也可以参考 docker 或 docker/dev/docker-compose.dev.yml 在本地拉起一套 Supabase 自托管环境。云端与自托管的操作逻辑基本一致:创建项目 → 建表与授权 → 拿到 URL 与 Key → 写入环境变量。

1. 创建 Supabase 项目

登录 Supabase 控制台新建一个项目,等待数据库启动完成。新项目创建时 Supabase 会自动向该 Postgres 实例注入auth模式与若干辅助函数——这是后续 RLS 策略能直接调用auth.uid()的前提。

2. 执行 “User Management Starter” 建表 SQL

数据库就绪后,进入项目的SQL Editor,滚动到User Management Starter这条示例查询(其描述为“Sets up a public Profiles table which you can access with your API”),点击后执行 RUN。脚本会创建一张profiles表,并顺带完成行级安全策略、Realtime 发布与 Storage 头像桶的初始化。执行完毕后,切到Table Editor即可看到这张空表。

该 SQL 的完整内容会在本文第四、五节逐一拆解,因为它是整个授权模型与文件上传能力的核心。

3. 获取项目 URL 与 anon Key

打开Project Settings(齿轮图标)→API标签页,可以看到项目的 API URL 与anon(在较新控制台中显示为 publishable 发布密钥)key

两者用途差异必须分清:

  • anon/ publishable key 是面向客户端的公钥:它允许应用在用户尚未登录时以“匿名”身份访问数据库,用户登录后,请求会自动携带用户自己的登录令牌(JWT)。正是这种“匿名态”与“登录态”的切换,让数据访问真正落到行级安全策略上。
  • secret(服务端密钥)拥有数据的完全访问权,会绕过一切安全策略。它只能放在服务端环境中使用,绝不能出现在浏览器或客户端代码里。本文示例的前端环境变量文件中只写入 publishable key,也正是出于这一安全边界。

4. 配置环境变量文件

复制环境变量模板并填入刚才拿到的 URL 与 Key:

cp .env.example .env.local

.env.example 的内容为:

VITE_SUPABASE_URL=https://your-project-ref.supabase.co VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-key

注意两个变量的命名都必须以VITE_开头,这是 Vite 暴露环境变量到客户端(import.meta.env)的硬性约定。supabaseClient.tsx正是通过import.meta.env.VITE_SUPABASE_URLimport.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY读取它们(见 supabaseClient.tsx)。因此.env.local中的键名必须与上述两个完全一致,否则客户端拿到undefined会直接创建失败。

5. 运行应用

npm run dev

浏览器打开http://localhost:3000/,此时邮箱登录与资料编辑即可真正跑通。

四、读懂建表 SQL:profiles、RLS 策略、Realtime 与 Storage

这是整个示例最重要的一段代码。README 中贴出的是一份“经过精简但完整可用”的 SQL,将其整理并加注释后可读版本如下:

-- 为 Public Profiles 建表 create table profiles ( id uuid references auth.users not null, -- 外键指向 auth 模式的用户表 updated_at timestamp with time zone, username text unique, -- 用户名全局唯一 avatar_url text, -- 头像文件路径(存于 avatars 桶) website text, primary key (id), -- 主键即用户 UUID unique (username), constraint username_length check (char_length(username) >= 3) -- 用户名至少 3 个字符 ); -- 开启行级安全 alter table profiles enable row level security; -- 所有人的资料都可公开查看 create policy "Public profiles are viewable by everyone." on profiles for select using (true); -- 用户只能插入自己的资料 create policy "Users can insert their own profile." on profiles for insert with check ((select auth.uid()) = id); -- 用户只能更新自己的资料 create policy "Users can update own profile." on profiles for update using ((select auth.uid()) = id); -- 开启 Realtime:将 profiles 表加入实时订阅发布 begin; drop publication if exists supabase_realtime; create publication supabase_realtime; commit; alter publication supabase_realtime add table profiles; -- 创建 Storage 头像桶 insert into storage.buckets (id, name) values ('avatars', 'avatars'); -- 允许任何人公开下载头像对象 create policy "Avatar images are publicly accessible." on storage.objects for select using (bucket_id = 'avatars' and storage.allow_any_operation(array['object.get_authenticated_info', 'object.get_authenticated'])); -- 允许任何人向 avatars 桶上传头像 create policy "Anyone can upload an avatar." on storage.objects for insert with check (bucket_id = 'avatars');

4.1 表结构设计的三个关键点

  1. 主键即用户标识id uuid references auth.usersprofiles与 Supabase Auth 的用户一一对应,且primary key (id)意味着“一个用户只有一行资料”。示例代码后续会利用这一点直接对id做 upsert(存在则更新、不存在则插入)。
  2. 用户名约束下放到数据库usernameunique约束保证全局唯一,username_length检查约束强制char_length(username) >= 3。这类“写进数据库的约束”比前端校验更可靠——即使绕过 UI 直接发请求也会被 Postgres 拒绝。
  3. updated_at由应用维护:示例没有用触发器自动维护时间戳,而是由客户端在写入时显式传入new Date().toISOString(),保持整段逻辑最小化、便于理解。

4.2 RLS:把授权逻辑写进数据库层

这是整个示例授权的核心。当 Supabase 项目创建时,Postgres 中已经就绪auth模式与辅助函数。用户登录后,客户端请求携带的 JWT 会包含角色authenticated与用户的 UUID;数据库层的 PostgREST 网关把这些 claim 注入查询,RLS 策略据此过滤每一行数据。

示例为profiles定义了三条非常直观的策略,恰好覆盖一张“个人资料表”的典型授权语义:

  • 任何人都能 SELECTusing (true),即登录前也能浏览公开资料;
  • 只能 INSERT 自己的资料with check ((select auth.uid()) = id),即新插入行的id必须等于当前登录用户的 UUID;
  • 只能 UPDATE 自己的资料using ((select auth.uid()) = id),即被修改的行必须属于当前用户。

对比对应的客户端行为:当用户通过邮箱链接登录后,App 拿到其 UUID,Account 组件以该 UUID 为条件查询/写入——凡是尝试读取或篡改他人资料的请求,都会在数据库层被 RLS 拦下。这也解释了为何前端可以大胆使用 publishable key:行级安全并不依赖“密钥是否保密”,而是依赖每个请求携带的 JWT 身份

4.3 Realtime 与 Storage 的两段初始化

SQL 后段完成了两件“锦上添花”的初始化:

  • Realtime:以事务方式重建supabase_realtime发布,并把profiles加入其中。此后客户端可以通过 supabase-js 的 channel 订阅该表的变更(示例应用本身未展示订阅 UI,属预留能力,便于后续扩展在线同步)。
  • Storage:向storage.buckets插入名为avatars的公开桶,并为其打上两条对象级策略——select策略允许任何人下载头像,insert策略允许任何人上传。与表级 RLS 不同,这里的授权对象是storage.objects。示例调用storage.allow_any_operation(...)来放行受认证信息读取操作,是 Supabase Storage 提供的辅助授权函数。

如果跳过这段 SQL 只建表,那么头像组件supabase.storage.from('avatars')的一切读写都会因为桶或策略不存在而失败——这也从侧面说明“一份完整的初始化脚本”对于可复现部署的价值。

五、客户端接入:supabase-js 实例与类型安全

supabaseClient.tsx 是全局唯一与后端建立连接的文件,全量代码如下:

import { createClient } from '@supabase/supabase-js' import { Database } from './schema' const supabaseUrl = import.meta.env.VITE_SUPABASE_URL const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY export const supabase = createClient(supabaseUrl, supabasePublishableKey)

两个值得说明的实现细节:

  1. 泛型类型参数createClient<Database>(...)中传入的Database类型来自同目录下的 schema.ts。它把profiles表结构拆成了Row / Insert / Update三套形状(分别对应查询返回、插入参数与更新参数),并把Views / Functions / Enums声明为空索引类型。好处是:后续supabase.from('profiles').select(...).upsert(...)的字段名与类型都会得到 TypeScript 的编译期检查,表名字典序错误、字段拼错等低级 bug 在开发期即被拦截。生产项目中该文件通常由 Supabase CLI(supabase gen types)从真实数据库生成,以保持与迁移同步。
  2. 模块级单例:客户端在模块加载时一次性创建并被App / Auth / Account / Avatar四个组件共享,这是官方推荐的用法——auth 会话状态由该实例统一维护,避免多实例导致状态不同步。

六、登录:基于邮箱的 Magic Link 免密认证

Auth.tsx 提供了应用的第一屏——一个极简的邮箱输入表单。提交时调用:

const { error } = await supabase.auth.signInWithOtp({ email: email() }) if (error) throw error alert('Check your email for the login link!')

这是 supabase-js v2 的Otp(一次性密码/魔术链接)登录:用户只输入邮箱,Supabase Auth 会向该邮箱发送一封包含登录链接的邮件,点击链接即完成认证并建立会话。实现上需要注意:

  • 表单onSubmit里必须e.preventDefault(),否则页面会刷新丢失状态;
  • 用 Solid 的createSignal管理emailloading,并通过e.currentTarget.value受控更新;
  • 登录成功与否不在这里判断——因为 Magic Link 流程是“把用户带离当前页面”去收邮件,所以成功分支只弹出提示,真正的会话建立发生在用户点击邮件链接回到站点之后。

对比仓库中其他语言版本(如 sveltekit-user-management),服务端框架的版本还会额外配置回调跳转地址;而本示例是纯 SPA,auth 事件由App.tsx统一监听即可(见下一节)。若产品需要更强安全性,也可以把该流程扩展为带 PKCE 的重定向认证(signInWithOtp配合emailRedirectTo),示例为保持最小化未包含该参数。

七、会话驱动视图切换:Auth 与 Account 的联动

App.tsx 是应用的状态中枢,它把“有没有登录”翻译成“渲染哪个组件”:

const [userId, setUserId] = createSignal<string | null>(null) const [userEmail, setUserEmail] = createSignal<string | null>(null) const syncClaims = async () => { const { data } = await supabase.auth.getClaims() setUserId((data?.claims.sub as string) ?? null) setUserEmail((data?.claims.email as string) ?? null) } createEffect(() => { syncClaims() supabase.auth.onAuthStateChange(() => { syncClaims() }) }) // ... {!userId() ? <Auth /> : <Account userId={userId()!} userEmail={userEmail()} />}

这段逻辑体现了 Solid 典型的响应式写法,也透露出两条 Supabase 的底层机制:

  1. getClaims()读取 JWT 声明:登录后本地会持有用户 JWT,其中sub即用户 UUID(数据库端与auth.users.id对应),email为用户邮箱。示例直接把这两个 claim 写入 signal,作为“是否登录”与“资料归属”的依据。
  2. onAuthStateChange保持同步:注册监听器后,无论登录、登出、令牌刷新,回调都会触发并重新拉取 claims,从而自动完成Auth ⇄ Account的切换。Solid 的createEffect在此保证首次挂载时就执行一次初始同步,用户若已登录则跳过登录页直接进入资料页。

值得一提的细节:示例选择用 JWT 的claims.sub而非在onAuthStateChange里读session.user.id,本质上二者都来自同一份会话令牌,只是取值入口不同。该写法让“身份来源”更直接,也便于后续扩展自定义 claims。

八、资料页:查询、更新与 upsert 语义

Account.tsx 承担了profiles表的读取与写入。

8.1 读取资料(getProfile)

let { data, error, status } = await supabase .from('profiles') .select(`username, website, avatar_url`) .eq('id', userId) .single() if (error && status !== 406) { throw error }

要点:

  • .select(...)只取三个业务字段,不整行拉取,减少网络开销;
  • .eq('id', userId).single()表达“取当前用户唯一一行”;
  • status !== 406的特判值得注意.single()在“表里没有匹配行”时返回的 HTTP 状态码为 406(PGRST116 类型的“not found”)。对于刚注册、尚未创建过资料的用户,这属于正常情况而非错误,因此代码将其静默放行,让页面停留在“空资料可编辑”状态。

8.2 写入资料(updateProfile)与 upsert 语义

const updates = { id: userId, username: username(), website: website(), avatar_url: avatarUrl(), updated_at: new Date().toISOString(), } let { error } = await supabase.from('profiles').upsert(updates)

这里的核心 API 是upsert:当主键id在表中已存在时执行更新,不存在则插入。它与前端常见“先查询再决定 insert/update”的两步写法相比,既省一次往返,又天然避免竞态。结合第五节 SQL 可以看到它和数据库端的配合非常严谨:

  • insert 分支:新用户首次保存时,RLS 的 insert 策略要求auth.uid() = id;示例恰好把登录用户的 UUID 放进updates.id,满足策略;
  • update 分支:后续编辑时,RLS 的 update 策略只放行“属于自己的行”,而.eq条件又限定在本人——数据库层的双重约束使任何越权写都被拒绝;
  • 约束兜底:若用户名重复或短于 3 字符,Postgres 的uniqueusername_length会返回错误,代码经catch分支弹出错误信息,无需前端预校验。

表单提交按钮的disabled={loading()}与文案在 “Saving ... / Update profile” 间切换,属于 Solid 响应式的典型 loading 模式;页面底部同时提供“Sign Out”按钮,直接调用supabase.auth.signOut()结束会话,随后App.tsxonAuthStateChange会触发视图切回登录页。

九、头像上传:Storage 桶的读写闭环

Avatar.tsx 是完整度最高的一个组件,它演示了 Storage 的“下载回显 + 上传”两条链路。

9.1 下载回显:私有流式下载转 Blob URL

createEffect(() => { if (props.url) downloadImage(props.url) }) const downloadImage = async (path: string) => { const { data, error } = await supabase.storage.from('avatars').download(path) if (error) throw error const url = URL.createObjectURL(data) setAvatarUrl(url) }

值得注意:示例用的是download()下载成 Blob 再以URL.createObjectURL生成本地预览地址,而不是直接拼一个{project}/storage/v1/object/public/avatars/xxx的公开 URL 赋给<img src>。这虽然在 SQL 层面已经放行了公开读(见 4.3 的公开访问策略),但download+ Blob URL 的写法意味着:即使日后把桶改为私有、策略收紧,前端代码也无需改动即可继续工作——访问控制完全交给 Storage 的授权策略判断,这是一处值得沿用的健壮性设计。

组件用 Solid 的propscreateEffect建立“外部传入avatar_url→ 自动下载 → 渲染<img>”的响应式链条,同时在downloadImage内部用本地 signalavatarUrl承接结果,避免把加载中间态泄漏到父组件。

9.2 上传:随机文件名 + 触发父级持久化

const file = target.files[0] const fileExt = file.name.split('.').pop() const fileName = `${Math.random()}.${fileExt}` const filePath = `${fileName}` let { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file) if (uploadError) throw uploadError props.onUpload(event, filePath)

上传逻辑的关键点:

  1. 文件名使用Math.random()前缀:直接把用户原始文件名当对象名会造成同名覆盖与目录注入风险,随机化后可视为“每次上传都是新对象”,结合 4.3 的 insert 策略(任何登录用户都可上传到avatars桶),这是示例在“无鉴权服务端”约束下能给出的最简安全策略。更严格的方案会把路径按用户userId/组织并在策略中校验前缀,读者可自行扩展;
  2. 隐藏的<input type="file" accept="image/*">:用 label 触发文件选择,onChange里取files[0]上传,上传中禁用输入并显示 “Uploading ...”;
  3. 上传成功并不代表资料已保存Avatar只负责“把文件放进 Storage 并拿到对象路径”,真正的数据库持久化通过回调props.onUpload(event, filePath)交由父组件完成。回看 Account.tsx,父组件收到路径后先setAvatarUrl(filePath)再调用updateProfile,从而把avatar_url字段 upsert 进profiles表。

至此,整个数据流形成闭环:文件 → Storageavatars桶 → 对象路径存进profiles.avatar_url→ 下次进页面时download()该路径回显头像。表、桶、策略三者缺一不可,这正是一份完整 SQL 初始化脚本的真正价值。

十、安全边界与最佳实践小结

  • publishable(anon)key 可以出现在前端,secret key 绝不能:本应用所有请求都经由带 RLS 的表与带策略的桶完成鉴权,公钥泄露不构成数据风险;而 secret key 一旦被带到浏览器就等同于把数据库完全敞开,这是示例刻意不在.env中存放 secret key 的原因。生产项目还应把 publishable key 视为“可轮换的公钥”定期管理。
  • 所有数据授权以数据库为准:读profiles、写profiles、读storage.objects、写storage.objects,四类动作的安全判断都落在 PostgreSQL 策略层,前端代码只需“按用户身份发起请求”,无需复制一份授权逻辑,避免前后端规则漂移。
  • 以最小的工程代价获得完整的类型体验:通过createClient<Database>(...)+ 手工/生成的Database类型(schema.ts),一个小型 Vite 应用就能享受到对表名、字段、Insert/Update 形状的编译期校验;若数据库后续变更,只需重新生成该类型文件。
  • 从 demo 到生产还差几步:本示例为教学把登录裁剪成“发链接”的极简形态,也未包含密码/第三方登录、邮箱验证回调页(emailRedirectTo)、头像路径按用户隔离等。在这些方向上,examples/user-management 目录下的其他框架版本与仓库内的 user-management 相关 SQL 迁移 可作为继续深入的起点。

结语

solid-user-management是一个麻雀虽小、五脏俱全的官方参考实现:它用不足两百行组件代码,把 Supabase 的Auth(Magic Link)、Database(Postgres + RLS)、Realtime(publication)与 Storage(bucket + 对象策略)四块能力完整串联起来。对照本文的建表 SQL 与源码逐段研读,再结合仓库中 solid-user-management 完整源码 动手运行一遍,你就能真正掌握“数据库层授权 + 客户端直连”这一 Supabase 开发范式的核心心智模型。

【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询