使用 Refine 构建用户管理应用
本教程演示如何构建一个基本的用户管理应用。该应用对用户进行身份验证和识别,将他们的个人资料信息存储在数据库中,并允许用户登录、更新他们的个人资料详细信息以及上传个人资料照片。该应用使用
- Supabase 数据库 - 一个用于存储用户数据的 Postgres 数据库,以及 行级别安全,以保护数据并确保用户只能访问他们自己的信息。
- Supabase Auth - 允许用户注册和登录。
- Supabase Storage - 允许用户上传个人资料照片。

如果在阅读本指南的过程中遇到问题,请参考 GitHub 上的完整示例。
关于 Refine#
Refine 是一个基于 React 的框架,用于快速构建数据密集型应用程序,例如管理面板、仪表盘、商店和任何类型的 CRUD 应用。它将应用程序的关注点分离成独立的层,每一层都由一个 React context 和相应的 provider 对象支持。例如,auth 层表示由一组特定的 authProvider 方法提供的 context,这些方法执行身份验证和授权操作,例如登录、注销、获取角色数据等。 类似地,数据层提供了另一个抽象层,配备了 dataProvider 方法,以在适当的后端 API 端点处理 CRUD 操作。
Refine 通过其补充的 @refinedev/supabase 包,提供了与 Supabase 后端的无忧集成。它在项目初始化时生成 authProvider 和 dataProvider 方法,因此我们无需花费太多精力来定义它们。我们只需要在创建应用程序时将 Supabase 选择为我们的后端服务即可,使用 create refine-app。
可以自定义 Supabase 的 authProvider,如我们将在下面看到的,可以从 src/authProvider.ts 文件中进行调整。 相比之下,Supabase dataProvider 是 node_modules 的一部分,因此不能进行修改。
项目设置#
在开始构建之前,您需要设置数据库和 API。您可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“schema”来完成此操作。
创建项目#
- 在 Supabase 控制面板中创建一个新项目。
- 输入您的项目详细信息。
- 等待新的数据库启动。
设置数据库 schema#
现在设置数据库 schema。您可以使用 SQL 编辑器中的“用户管理 Starter”快速入门,或者您可以复制/粘贴下面的 SQL 并运行它。
获取 API 详细信息#
现在您已经创建了一些数据库表,您可以使用自动生成的 API 插入数据。
为此,您需要从 项目的 连接 对话框 获取项目 URL 和密钥。
API 密钥的更改
Supabase 正在更改密钥的工作方式,以提高项目安全性和开发人员体验。您可以 阅读完整的公告,但在过渡期间,您可以使用当前的 anon 和 service_role 密钥以及新的可发布密钥,格式为 sb_publishable_xxx,它将取代旧的密钥。
在大多数情况下,您可以从 项目的 连接 对话框 获取正确的密钥,但如果您想要特定的密钥,可以在 项目的设置页面中的 API 密钥 部分 找到所有密钥
- 对于旧版密钥,从 旧版 API 密钥 选项卡中复制
anon密钥用于客户端操作,并复制service_role密钥用于服务器端操作。 - 对于新密钥,打开 API 密钥 选项卡,如果您还没有可发布密钥,请单击 创建新的 API 密钥,并复制 可发布密钥 部分中的值。
阅读 API 密钥文档 以全面了解所有密钥类型及其用途。
构建应用#
让我们从头开始构建 Refine 应用。
初始化 Refine 应用#
我们可以使用 create refine-app 命令来初始化一个应用。在终端中运行以下命令
1npm create refine-app@latest -- --preset refine-supabase在上面的命令中,我们使用了 refine-supabase 预设,它为我们的应用程序选择了 Supabase 补充包。我们没有使用任何 UI 框架,因此我们将拥有一个无头 UI,使用纯 React 和 CSS 样式。
refine-supabase 预设安装了 @refinedev/supabase 包,该包开箱即用地包含了 Supabase 依赖项:supabase-js。
我们还需要安装 @refinedev/react-hook-form 和 react-hook-form 包,这些包允许我们在 Refine 应用中使用 React Hook Form。
1npm install @refinedev/react-hook-form react-hook-form在应用程序初始化并安装了软件包后,在开始讨论 Refine 概念之前,让我们尝试运行该应用程序
1cd app-name2npm run dev我们应该在 https://:5173 处有一个正在运行的应用程序实例,并显示一个欢迎页面。
让我们继续了解生成的代码。
Refine supabaseClient#
create refine-app 为我们在 src/utility/supabaseClient.ts 文件中生成了一个 Supabase 客户端。它有两个常量:SUPABASE_URL 和 SUPABASE_KEY。我们希望将它们分别替换为 supabaseUrl 和 supabasePublishableKey,并分配我们自己的 Supabase 服务器的值。
我们将使用 Vite 管理的环境变量进行更新
1import { createClient } from '@refinedev/supabase'23const supabaseUrl = import.meta.env.VITE_SUPABASE_URL4const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY56export const supabaseClient = createClient(supabaseUrl, supabasePublishableKey, {7 db: {8 schema: 'public',9 },10 auth: {11 persistSession: true,12 },13})然后,我们希望在 .env.local 文件中保存环境变量。您只需要您之前复制的 API URL 和密钥 即可。
1VITE_SUPABASE_URL=YOUR_SUPABASE_URL2VITE_SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEYsupabaseClient 将在我们的应用程序从 Supabase 端点进行获取调用的过程中使用。 正如我们将在下面看到的那样,该客户端对于使用 Refine 的 auth provider 方法和适当的数据 provider 方法实现身份验证和 CRUD 操作至关重要。
一个可选步骤是更新 CSS 文件 src/App.css 以使应用程序看起来不错。您可以在 此处 找到此文件的完整内容。
为了在这个应用程序中添加登录和用户配置文件页面,我们必须调整 App.tsx 内部的 <Refine /> 组件。
<Refine /> 组件#
App.tsx 文件最初如下所示
1import { Refine, WelcomePage } from '@refinedev/core'2import { RefineKbar, RefineKbarProvider } from '@refinedev/kbar'3import routerProvider, {4 DocumentTitleHandler,5 UnsavedChangesNotifier,6} from '@refinedev/react-router'7import { dataProvider, liveProvider } from '@refinedev/supabase'8import { BrowserRouter, Route, Routes } from 'react-router'9import './App.css'10import authProvider from './authProvider'11import { supabaseClient } from './utility'1213function App() {14 return (15 <BrowserRouter>16 <RefineKbarProvider>17 <Refine18 dataProvider={dataProvider(supabaseClient)}19 liveProvider={liveProvider(supabaseClient)}20 authProvider={authProvider}21 routerProvider={routerProvider}22 options={{23 syncWithLocation: true,24 warnWhenUnsavedChanges: true,25 }}26 >27 <Routes>28 <Route index element={<WelcomePage />} />29 </Routes>30 <RefineKbar />31 <UnsavedChangesNotifier />32 <DocumentTitleHandler />33 </Refine>34 </RefineKbarProvider>35 </BrowserRouter>36 )37}3839export default App我们希望关注 <Refine /> 组件,它传递了几个 props 给它。 请注意 dataProvider prop。它使用一个将 supabaseClient 作为参数传递的 dataProvider() 函数来生成数据 provider 对象。 authProvider 对象也使用 supabaseClient 来实现其方法。您可以在 src/authProvider.ts 文件中查看它。
自定义 authProvider#
如果您检查 authProvider 对象,您会注意到它有一个 login 方法,该方法实现了 OAuth 和电子邮件/密码策略的身份验证。但是,我们将删除它们并使用 Magic Links 允许用户使用他们的电子邮件进行登录,而无需使用密码。
我们希望在 authProvider.login 方法内部使用 supabaseClient auth 的 signInWithOtp 方法
src/authProvider.ts
1login: async ({ email }) => {2 try {3 const { error } = await supabaseClient.auth.signInWithOtp({ email });45 if (!error) {6 alert("Check your email for the login link!");7 return {8 success: true,9 };10 };1112 throw error;13 } catch (e: any) {14 alert(e.message);15 return {16 success: false,17 e,18 };19 }20},我们还希望删除 register、updatePassword、forgotPassword 和 getPermissions 属性,它们是可选的类型成员,并且对于我们的应用程序来说也不是必需的。最终的 authProvider 对象如下所示
1import { AuthProvider } from '@refinedev/core'23import { supabaseClient } from './utility'45const authProvider: AuthProvider = {6 login: async ({ email }) => {7 try {8 const { error } = await supabaseClient.auth.signInWithOtp({ email })910 if (!error) {11 alert('Check your email for the login link!')12 return {13 success: true,14 }15 }1617 throw error18 } catch (e: any) {19 alert(e.message)20 return {21 success: false,22 e,23 }24 }25 },26 logout: async () => {27 const { error } = await supabaseClient.auth.signOut()2829 if (error) {30 return {31 success: false,32 error,33 }34 }3536 return {37 success: true,38 redirectTo: '/',39 }40 },41 onError: async (error) => {42 console.error(error)43 return { error }44 },45 check: async () => {46 try {47 const { data } = await supabaseClient.auth.getSession()48 const { session } = data4950 if (!session) {51 return {52 authenticated: false,53 error: {54 message: 'Check failed',55 name: 'Session not found',56 },57 logout: true,58 redirectTo: '/login',59 }60 }61 } catch (error: any) {62 return {63 authenticated: false,64 error: error || {65 message: 'Check failed',66 name: 'Not authenticated',67 },68 logout: true,69 redirectTo: '/login',70 }71 }7273 return {74 authenticated: true,75 }76 },77 getIdentity: async () => {78 const { data } = await supabaseClient.auth.getUser()7980 if (data?.user) {81 return {82 ...data.user,83 name: data.user.email,84 }85 }8687 return null88 },89}9091export default authProvider设置登录组件#
我们选择使用无头 Refine 核心包,该包不带任何受支持的 UI 框架。因此,让我们设置一个纯 React 组件来管理登录和注册。
创建并编辑 src/components/auth.tsx
1import { useState } from 'react'2import { useLogin } from '@refinedev/core'34export default function Auth() {5 const [email, setEmail] = useState('')6 const { isPending, mutate: login } = useLogin()78 const handleLogin = async (event: { preventDefault: () => void }) => {9 event.preventDefault()10 login({ email })11 }1213 return (14 <div className="row flex flex-center container">15 <div className="col-6 form-widget">16 <h1 className="header">Supabase + Refine</h1>17 <p className="description">Sign in via magic link with your email below</p>18 <form className="form-widget" onSubmit={handleLogin}>19 <div>20 <input21 className="inputField"22 type="email"23 placeholder="Your email"24 value={email}25 required={true}26 onChange={(e) => setEmail(e.target.value)}27 />28 </div>29 <div>30 <button className={'button block'} disabled={isPending}>31 {isPending ? <span>Loading</span> : <span>Send magic link</span>}32 </button>33 </div>34 </form>35 </div>36 </div>37 )38}请注意,我们正在使用 useLogin() Refine auth hook 来获取 mutate: login 方法,以便在 handleLogin() 函数中使用,以及用于表单提交的 isLoading 状态。
账户页面#
用户登录后,我们可以允许他们编辑他们的个人资料详细信息并管理他们的帐户。
让我们在 src/components/account.tsx 中创建一个新的组件。
1import { BaseKey, useGetIdentity, useLogout } from '@refinedev/core'2import { useForm } from '@refinedev/react-hook-form'34interface IUserIdentity {5 id?: BaseKey6 username: string7 name: string8}910export interface IProfile {11 id?: string12 username?: string13 website?: string14 avatar_url?: string15}1617export default function Account() {18 const { data: userIdentity } = useGetIdentity<IUserIdentity>()1920 const { mutate: logOut } = useLogout()2122 const {23 refineCore: { formLoading, query, onFinish },24 register,25 control,26 handleSubmit,27 } = useForm<IProfile>({28 refineCoreProps: {29 resource: 'profiles',30 action: 'edit',31 id: userIdentity?.id,32 redirect: false,33 onMutationError: (data) => alert(data?.message),34 },35 })3637 return (38 <div className="container" style={{ padding: '50px 0 100px 0' }}>39 <form onSubmit={handleSubmit(onFinish)} className="form-widget">40 <div>41 <label htmlFor="email">Email</label>42 <input id="email" name="email" type="text" value={userIdentity?.name} disabled />43 </div>44 <div>45 <label htmlFor="username">Name</label>46 <input id="username" type="text" {...register('username')} />47 </div>48 <div>49 <label htmlFor="website">Website</label>50 <input id="website" type="url" {...register('website')} />51 </div>5253 <div>54 <button className="button block primary" type="submit" disabled={formLoading}>55 {formLoading ? 'Loading ...' : 'Update'}56 </button>57 </div>5859 <div>60 <button className="button block" type="button" onClick={() => logOut()}>61 Sign Out62 </button>63 </div>64 </form>65 </div>66 )67}请注意,我们正在使用三个 Refine hook,即 useGetIdentity()、useLogOut() 和 useForm() hook。
useGetIdentity() 是一个 auth hook,用于获取经过身份验证的用户的身份。它通过在后台调用 authProvider.getIdentity 方法来获取当前用户。
useLogOut() 也是一个 auth hook。它调用 authProvider.logout 方法来结束会话。
useForm() 相反是一个数据 hook,它公开了一系列有用的对象,用于服务编辑表单。例如,我们正在获取 onFinish 函数,以便使用 handleSubmit 事件处理程序提交表单。我们还在使用 formLoading 属性来呈现已提交表单的状态变化。
useForm() hook 是构建在 Refine 的 useForm() 核心 hook 之上的高级 hook。它完全支持表单状态管理、字段验证和使用 React Hook Form 进行提交。在后台,它调用 dataProvider.getOne 方法从我们的 Supabase /profiles 端点获取用户配置文件数据,并在调用 onFinish() 时调用 dataProvider.update 方法。
启动!#
现在我们已经准备好所有组件,让我们定义页面渲染这些组件的路由。
为 /login 添加带有 <Auth /> 组件的路由,并为 index 路径添加带有 <Account /> 组件的路由。因此,最终的 App.tsx
1import { Authenticated, Refine } from '@refinedev/core'2import { RefineKbar, RefineKbarProvider } from '@refinedev/kbar'3import routerProvider, {4 CatchAllNavigate,5 DocumentTitleHandler,6 UnsavedChangesNotifier,7} from '@refinedev/react-router'8import { dataProvider, liveProvider } from '@refinedev/supabase'9import { BrowserRouter, Outlet, Route, Routes } from 'react-router'1011import './App.css'12import authProvider from './authProvider'13import { supabaseClient } from './utility'14import Account from './components/account'15import Auth from './components/auth'1617function App() {18 return (19 <BrowserRouter>20 <RefineKbarProvider>21 <Refine22 dataProvider={dataProvider(supabaseClient)}23 liveProvider={liveProvider(supabaseClient)}24 authProvider={authProvider}25 routerProvider={routerProvider}26 options={{27 syncWithLocation: true,28 warnWhenUnsavedChanges: true,29 }}30 >31 <Routes>32 <Route33 element={34 <Authenticated35 key="authenticated-routes"36 fallback={<CatchAllNavigate to="/login" />}37 >38 <Outlet />39 </Authenticated>40 }41 >42 <Route index element={<Account />} />43 </Route>44 <Route element={<Authenticated key="auth-pages" fallback={<Outlet />} />}>45 <Route path="/login" element={<Auth />} />46 </Route>47 </Routes>48 <RefineKbar />49 <UnsavedChangesNotifier />50 <DocumentTitleHandler />51 </Refine>52 </RefineKbarProvider>53 </BrowserRouter>54 )55}5657export default App让我们再次运行服务器来测试应用程序
1npm run dev然后打开浏览器到 localhost:5173,您应该会看到完成的应用。

奖励:个人资料照片#
每个 Supabase 项目都配置了 存储,用于管理大型文件,如照片和视频。
创建一个上传小部件#
让我们为用户创建一个头像,以便他们可以上传个人资料照片。我们可以从创建一个新组件开始
创建并编辑 src/components/avatar.tsx
1import { useEffect, useState } from 'react'2import { supabaseClient } from '../utility/supabaseClient'34type TAvatarProps = {5 url?: string6 size: number7 onUpload: (filePath: string) => void8}910export default function Avatar({ url, size, onUpload }: TAvatarProps) {11 const [avatarUrl, setAvatarUrl] = useState('')12 const [uploading, setUploading] = useState(false)1314 useEffect(() => {15 if (url) downloadImage(url)16 }, [url])1718 async function downloadImage(path: string) {19 try {20 const { data, error } = await supabaseClient.storage.from('avatars').download(path)21 if (error) {22 throw error23 }24 const url = URL.createObjectURL(data)25 setAvatarUrl(url)26 } catch (error: any) {27 console.log('Error downloading image: ', error?.message)28 }29 }3031 async function uploadAvatar(event: React.ChangeEvent<HTMLInputElement>) {32 try {33 setUploading(true)3435 if (!event.target.files || event.target.files.length === 0) {36 throw new Error('You must select an image to upload.')37 }3839 const file = event.target.files[0]40 const fileExt = file.name.split('.').pop()41 const fileName = `${Math.random()}.${fileExt}`42 const filePath = `${fileName}`4344 const { error: uploadError } = await supabaseClient.storage45 .from('avatars')46 .upload(filePath, file)4748 if (uploadError) {49 throw uploadError50 }51 onUpload(filePath)52 } catch (error: any) {53 alert(error.message)54 } finally {55 setUploading(false)56 }57 }5859 return (60 <div>61 {avatarUrl ? (62 <img63 src={avatarUrl}64 alt="Avatar"65 className="avatar image"66 style={{ height: size, width: size }}67 />68 ) : (69 <div className="avatar no-image" style={{ height: size, width: size }} />70 )}71 <div style={{ width: size }}>72 <label className="button primary block" htmlFor="single">73 {uploading ? 'Uploading ...' : 'Upload'}74 </label>75 <input76 style={{77 visibility: 'hidden',78 position: 'absolute',79 }}80 type="file"81 id="single"82 name="avatar_url"83 accept="image/*"84 onChange={uploadAvatar}85 disabled={uploading}86 />87 </div>88 </div>89 )90}添加新的小部件#
然后我们可以将小部件添加到 src/components/account.tsx 处的 Account 页面
1// Import the new components2import { Controller } from 'react-hook-form'3import Avatar from './avatar'45// ...67return (8 <div className="container" style={{ padding: '50px 0 100px 0' }}>9 <form onSubmit={handleSubmit} className="form-widget">10 <Controller11 control={control}12 name="avatar_url"13 render={({ field }) => {14 return (15 <Avatar16 url={field.value}17 size={150}18 onUpload={(filePath) => {19 onFinish({20 ...query?.data?.data,21 avatar_url: filePath,22 onMutationError: (data: { message: string }) => alert(data?.message),23 })24 field.onChange({25 target: {26 value: filePath,27 },28 })29 }}30 />31 )32 }}33 />34 {/* ... */}35 </form>36 </div>37)在这个阶段,你已经拥有了一个功能齐全的应用程序!