基于 SolidJS + Supabase 构建用户管理应用:Magic Link 登录、PostgreSQL RLS 与头像上传实战
发布时间:2026/9/7 20:09:35 作者:尧图编辑部 阅读量:1,286

基于 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-jsUI 框架、supabase/supabase-jsv2官方 JS 客户端开发依赖为vitevite-plugin-solidtypescriptprettier没有任何 UI 组件库所有表单样式都来自index.css中的极简类名。二、快速开始安装依赖与本地运行示例在 README 中提供了一套可直接执行的脚本。进入项目目录后先安装依赖$ npm install随后可使用以下脚本与 package.json 的scripts字段一一对应命令对应实现作用npm dev/npm startvite启动开发服务器默认端口3000见 vite.config.ts 中server.port支持热更新npm run buildvite build以esnext为编译目标打包生产版本产物输出到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 与 Keycp .env.example .env.local.env.example 的内容为VITE_SUPABASE_URLhttps://your-project-ref.supabase.co VITE_SUPABASE_PUBLISHABLE_KEYyour-publishable-key注意两个变量的命名都必须以VITE_开头这是 Vite 暴露环境变量到客户端import.meta.env的硬性约定。supabaseClient.tsx正是通过import.meta.env.VITE_SUPABASE_URL与import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY读取它们见 supabaseClient.tsx。因此.env.local中的键名必须与上述两个完全一致否则客户端拿到undefined会直接创建失败。5. 运行应用npm run dev浏览器打开http://localhost:3000/此时邮箱登录与资料编辑即可真正跑通。四、读懂建表 SQLprofiles、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 表结构设计的三个关键点主键即用户标识id uuid references auth.users让profiles与 Supabase Auth 的用户一一对应且primary key (id)意味着“一个用户只有一行资料”。示例代码后续会利用这一点直接对id做 upsert存在则更新、不存在则插入。用户名约束下放到数据库username用unique约束保证全局唯一username_length检查约束强制char_length(username) 3。这类“写进数据库的约束”比前端校验更可靠——即使绕过 UI 直接发请求也会被 Postgres 拒绝。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 拿到其 UUIDAccount 组件以该 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)两个值得说明的实现细节泛型类型参数createClientDatabase(...)中传入的Database类型来自同目录下的 schema.ts。它把profiles表结构拆成了Row / Insert / Update三套形状分别对应查询返回、插入参数与更新参数并把Views / Functions / Enums声明为空索引类型。好处是后续supabase.from(profiles).select(...).upsert(...)的字段名与类型都会得到 TypeScript 的编译期检查表名字典序错误、字段拼错等低级 bug 在开发期即被拦截。生产项目中该文件通常由 Supabase CLIsupabase gen types从真实数据库生成以保持与迁移同步。模块级单例客户端在模块加载时一次性创建并被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管理email与loading并通过e.currentTarget.value受控更新登录成功与否不在这里判断——因为 Magic Link 流程是“把用户带离当前页面”去收邮件所以成功分支只弹出提示真正的会话建立发生在用户点击邮件链接回到站点之后。对比仓库中其他语言版本如 sveltekit-user-management服务端框架的版本还会额外配置回调跳转地址而本示例是纯 SPAauth 事件由App.tsx统一监听即可见下一节。若产品需要更强安全性也可以把该流程扩展为带 PKCE 的重定向认证signInWithOtp配合emailRedirectTo示例为保持最小化未包含该参数。七、会话驱动视图切换Auth 与 Account 的联动App.tsx 是应用的状态中枢它把“有没有登录”翻译成“渲染哪个组件”const [userId, setUserId] createSignalstring | null(null) const [userEmail, setUserEmail] createSignalstring | 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 的底层机制getClaims()读取 JWT 声明登录后本地会持有用户 JWT其中sub即用户 UUID数据库端与auth.users.id对应email为用户邮箱。示例直接把这两个 claim 写入 signal作为“是否登录”与“资料归属”的依据。onAuthStateChange保持同步注册监听器后无论登录、登出、令牌刷新回调都会触发并重新拉取 claims从而自动完成Auth ⇄ Account的切换。Solid 的createEffect在此保证首次挂载时就执行一次初始同步用户若已登录则跳过登录页直接进入资料页。值得一提的细节示例选择用 JWT 的claims.sub而非在onAuthStateChange里读session.user.id本质上二者都来自同一份会话令牌只是取值入口不同。该写法让“身份来源”更直接也便于后续扩展自定义 claims。八、资料页查询、更新与 upsert 语义Account.tsx 承担了profiles表的读取与写入。8.1 读取资料getProfilelet { 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 状态码为 406PGRST116 类型的“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 的unique与username_length会返回错误代码经catch分支弹出错误信息无需前端预校验。表单提交按钮的disabled{loading()}与文案在 “Saving ... / Update profile” 间切换属于 Solid 响应式的典型 loading 模式页面底部同时提供“Sign Out”按钮直接调用supabase.auth.signOut()结束会话随后App.tsx的onAuthStateChange会触发视图切回登录页。九、头像上传Storage 桶的读写闭环Avatar.tsx 是完整度最高的一个组件它演示了 Storage 的“下载回显 上传”两条链路。9.1 下载回显私有流式下载转 Blob URLcreateEffect(() { 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 的props与createEffect建立“外部传入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)上传逻辑的关键点文件名使用Math.random()前缀直接把用户原始文件名当对象名会造成同名覆盖与目录注入风险随机化后可视为“每次上传都是新对象”结合 4.3 的 insert 策略任何登录用户都可上传到avatars桶这是示例在“无鉴权服务端”约束下能给出的最简安全策略。更严格的方案会把路径按用户userId/组织并在策略中校验前缀读者可自行扩展隐藏的input typefile acceptimage/*用 label 触发文件选择onChange里取files[0]上传上传中禁用输入并显示 “Uploading ...”上传成功并不代表资料已保存Avatar只负责“把文件放进 Storage 并拿到对象路径”真正的数据库持久化通过回调props.onUpload(event, filePath)交由父组件完成。回看 Account.tsx父组件收到路径后先setAvatarUrl(filePath)再调用updateProfile从而把avatar_url字段 upsert 进profiles表。至此整个数据流形成闭环文件 → Storageavatars桶 → 对象路径存进profiles.avatar_url→ 下次进页面时download()该路径回显头像。表、桶、策略三者缺一不可这正是一份完整 SQL 初始化脚本的真正价值。十、安全边界与最佳实践小结publishableanonkey 可以出现在前端secret key 绝不能本应用所有请求都经由带 RLS 的表与带策略的桶完成鉴权公钥泄露不构成数据风险而 secret key 一旦被带到浏览器就等同于把数据库完全敞开这是示例刻意不在.env中存放 secret key 的原因。生产项目还应把 publishable key 视为“可轮换的公钥”定期管理。所有数据授权以数据库为准读profiles、写profiles、读storage.objects、写storage.objects四类动作的安全判断都落在 PostgreSQL 策略层前端代码只需“按用户身份发起请求”无需复制一份授权逻辑避免前后端规则漂移。以最小的工程代价获得完整的类型体验通过createClientDatabase(...) 手工/生成的Database类型schema.ts一个小型 Vite 应用就能享受到对表名、字段、Insert/Update 形状的编译期校验若数据库后续变更只需重新生成该类型文件。从 demo 到生产还差几步本示例为教学把登录裁剪成“发链接”的极简形态也未包含密码/第三方登录、邮箱验证回调页emailRedirectTo、头像路径按用户隔离等。在这些方向上examples/user-management 目录下的其他框架版本与仓库内的 user-management 相关 SQL 迁移 可作为继续深入的起点。结语solid-user-management是一个麻雀虽小、五脏俱全的官方参考实现它用不足两百行组件代码把 Supabase 的AuthMagic Link、DatabasePostgres RLS、Realtimepublication与 Storagebucket 对象策略四块能力完整串联起来。对照本文的建表 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),仅供参考