快速开始

使用refine构建用户管理应用


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

Supabase 用户管理示例

关于 refine

refine 是一个基于 React 的框架,用于快速构建数据密集型应用,如管理后台、仪表盘、电商前台和各种 CRUD 应用。它将应用关注点分离到独立的层,每层都由一个 React 上下文和相应的 provider 对象支持。例如,认证层由一组特定的 authProvider 方法支持,这些方法执行认证和授权操作,如登录、登出、获取角色数据等。同样,数据层提供了另一层抽象,配备了 dataProvider 方法来处理后端 API 端点的 CRUD 操作。

refine 通过其附加包 @refinedev/supabase 提供了与 Supabase 后端的无缝集成。它在项目初始化时就会生成 authProviderdataProvider 方法,因此我们无需花费太多精力自行定义这些方法。我们只需要在使用 create refine-app 创建应用时选择 Supabase 作为后端服务。

项目设置

在开始构建之前,我们需要设置数据库和 API。这就像在 Supabase 中创建一个新项目,然后在数据库中创建一个 “模式” 一样简单。

创建项目

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

设置数据库模式

现在我们要设置数据库模式。我们可以在 SQL 编辑器中使用 “用户管理入门” 快速启动模板,或者您也可以直接复制/粘贴下面的 SQL 并自行运行。

  1. 转到仪表板中的SQL 编辑器页面。
  2. 点击 用户管理入门
  3. 点击 运行
1
2
3
4
supabase link --project-ref <project-id># 您可以从项目的仪表板 URL 获取 <project-id>:https://supabase.com/dashboard/project/<project-id>supabase db pull

获取 API 密钥

既然您已经创建了一些数据库表,就可以使用自动生成的 API 插入数据了。 我们只需要从 API 设置中获取项目 URL 和 anon 密钥。

  1. 前往仪表板中的 API 设置 页面。
  2. 在该页面找到您的项目 URLanonservice_role 密钥。

构建应用

让我们从零开始构建 refine 应用。

初始化 refine 应用

我们可以使用 create refine-app 命令来初始化应用。在终端中运行以下命令:

1
npm create refine-app@latest -- --preset refine-supabase

在上述命令中,我们使用了 refine-supabase 预设,这会为我们的应用选择 Supabase 补充包。我们没有使用任何 UI 框架,因此将获得一个基于纯 React 和 CSS 样式的无头 UI。

refine-supabase 预设安装了 @refinedev/supabase 包,该包开箱即用包含了 Supabase 依赖:supabase-js

我们还需要安装 @refinedev/react-hook-formreact-hook-form 包,这些包允许我们在 refine 应用中使用 React Hook Form。运行:

1
npm install @refinedev/react-hook-form react-hook-form

应用初始化和包安装完成后,在开始讨论 refine 概念之前,让我们先尝试运行应用:

1
2
cd app-namenpm run dev

我们应该能在 http://localhost:5173 看到一个运行中的应用实例,其中包含欢迎页面。

现在让我们继续了解生成的代码。

优化 supabaseClient

create refine-app 命令在 src/utility/supabaseClient.ts 文件中为我们生成了一个 Supabase 客户端。它包含两个常量:SUPABASE_URLSUPABASE_KEY。我们需要将它们分别替换为 supabaseUrlsupabaseAnonKey,并赋值为我们自己的 Supabase 服务器配置。

我们将使用 Vite 管理的环境变量来更新它:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { createClient } from '@refinedev/supabase'const supabaseUrl = import.meta.env.VITE_SUPABASE_URLconst supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEYexport const supabaseClient = createClient(supabaseUrl, supabaseAnonKey, { db: { schema: 'public', }, auth: { persistSession: true, },})

然后,我们需要将环境变量保存在 .env.local 文件中。您只需要之前复制的 API URL 和 anon 密钥。

1
2
VITE_SUPABASE_URL=您的_SUPABASE_URLVITE_SUPABASE_ANON_KEY=您的_SUPABASE_ANON_KEY

supabaseClient 将用于从我们的应用程序向 Supabase 端点发起 fetch 请求。如下所示,该客户端对于使用 Refine 的 auth provider 方法实现身份验证以及使用适当的数据 provider 方法执行 CRUD 操作至关重要。

一个可选步骤是更新 src/App.css 文件来美化应用界面。您可以在此处找到该文件的完整内容。

为了在这个应用中添加登录和用户资料页面,我们需要调整 App.tsx 文件中的 <Refine /> 组件。

<Refine /> 组件

App.tsx 文件的初始内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import { Refine, WelcomePage } from '@refinedev/core'import { RefineKbar, RefineKbarProvider } from '@refinedev/kbar'import routerBindings, { DocumentTitleHandler, UnsavedChangesNotifier,} from '@refinedev/react-router-v6'import { dataProvider, liveProvider } from '@refinedev/supabase'import { BrowserRouter, Route, Routes } from 'react-router-dom'import './App.css'import authProvider from './authProvider'import { supabaseClient } from './utility'function App() { return ( <BrowserRouter> <RefineKbarProvider> <Refine dataProvider={dataProvider(supabaseClient)} liveProvider={liveProvider(supabaseClient)} authProvider={authProvider} routerProvider={routerBindings} options={{ syncWithLocation: true, warnWhenUnsavedChanges: true, }} > <Routes> <Route index element={<WelcomePage />} /> </Routes> <RefineKbar /> <UnsavedChangesNotifier /> <DocumentTitleHandler /> </Refine> </RefineKbarProvider> </BrowserRouter> )}export default App

我们将重点关注 <Refine /> 组件,它接收了多个 props。请注意 dataProvider 这个 prop,它使用 dataProvider() 函数并传入 supabaseClient 作为参数来生成数据提供者对象。authProvider 对象也在其方法实现中使用了 supabaseClient,您可以在 src/authProvider.ts 文件中查看具体实现。

自定义 authProvider

如果您检查 authProvider 对象,会发现它有一个实现了 OAuth 和邮箱/密码认证策略的 login 方法。不过我们将移除这些策略,改用 Magic Links(魔法链接)让用户无需密码即可通过邮箱登录。

我们希望在 authProvider.login 方法中使用 supabaseClientsignInWithOtp 认证方法:

src/authProvider.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
login: async ({ email }) => { try { const { error } = await supabaseClient.auth.signInWithOtp({ email }); if (!error) { alert("请检查您的邮箱获取登录链接!"); return { success: true, }; }; throw error; } catch (e: any) { alert(e.message); return { success: false, e, }; }},

我们还需要移除 registerupdatePasswordforgotPasswordgetPermissions 属性,这些是可选类型成员,对我们的应用也不是必需的。最终的 authProvider 对象如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import { AuthBindings } from '@refinedev/core'import { supabaseClient } from './utility'const authProvider: AuthBindings = { login: async ({ email }) => { try { const { error } = await supabaseClient.auth.signInWithOtp({ email }) if (!error) { alert('请检查您的邮箱获取登录链接!') return { success: true, } } throw error } catch (e: any) { alert(e.message) return { success: false, e, } } }, logout: async () => { const { error } = await supabaseClient.auth.signOut() if (error) { return { success: false, error, } } return { success: true, redirectTo: '/', } }, onError: async (error) => { console.error(error) return { error } }, check: async () => { try { const { data } = await supabaseClient.auth.getSession() const { session } = data if (!session) { return { authenticated: false, error: { message: '检查失败', name: '未找到会话', }, logout: true, redirectTo: '/login', } } } catch (error: any) { return { authenticated: false, error: error || { message: '检查失败', name: '未认证', }, logout: true, redirectTo: '/login', } } return { authenticated: true, } }, getIdentity: async () => { const { data } = await supabaseClient.auth.getUser() if (data?.user) { return { ...data.user, name: data.user.email, } } return null },}export default authProvider

设置登录组件

我们选择使用不包含任何UI框架支持的无头refine核心包。因此,让我们设置一个普通的React组件来管理登录和注册功能。

创建并编辑 src/components/auth.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { useState } from 'react'import { useLogin } from '@refinedev/core'export default function Auth() { const [email, setEmail] = useState('') const { isLoading, mutate: login } = useLogin() const handleLogin = async (event: { preventDefault: () => void }) => { event.preventDefault() login({ email }) } return ( <div className="row flex flex-center container"> <div className="col-6 form-widget"> <h1 className="header">Supabase + refine</h1> <p className="description">通过下方邮箱接收魔法链接登录</p> <form className="form-widget" onSubmit={handleLogin}> <div> <input className="inputField" type="email" placeholder="您的邮箱" value={email} required={true} onChange={(e) => setEmail(e.target.value)} /> </div> <div> <button className={'button block'} disabled={isLoading}> {isLoading ? <span>加载中</span> : <span>发送魔法链接</span>} </button> </div> </form> </div> </div> )}

注意我们使用了refine的useLogin()认证钩子来获取mutate: login方法用于handleLogin()函数内部,以及isLoading状态用于表单提交。useLogin()钩子方便地为我们提供了访问authProvider.login方法的能力,用于通过OTP认证用户。

账户页面

用户登录后,我们可以允许他们编辑个人资料信息和管理账户。

让我们在 src/components/account.tsx 中创建一个新组件来实现这个功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import { BaseKey, useGetIdentity, useLogout } from '@refinedev/core'import { useForm } from '@refinedev/react-hook-form'interface IUserIdentity { id?: BaseKey username: string name: string}export interface IProfile { id?: string username?: string website?: string avatar_url?: string}export default function Account() { const { data: userIdentity } = useGetIdentity<IUserIdentity>() const { mutate: logOut } = useLogout() const { refineCore: { formLoading, queryResult, onFinish }, register, control, handleSubmit, } = useForm<IProfile>({ refineCoreProps: { resource: 'profiles', action: 'edit', id: userIdentity?.id, redirect: false, onMutationError: (data) => alert(data?.message), }, }) return ( <div className="container" style={{ padding: '50px 0 100px 0' }}> <form onSubmit={handleSubmit(onFinish)} className="form-widget"> <div> <label htmlFor="email">Email</label> <input id="email" name="email" type="text" value={userIdentity?.name} disabled /> </div> <div> <label htmlFor="username">Name</label> <input id="username" type="text" {...register('username')} /> </div> <div> <label htmlFor="website">Website</label> <input id="website" type="url" {...register('website')} /> </div> <div> <button className="button block primary" type="submit" disabled={formLoading}> {formLoading ? '加载中...' : '更新'} </button> </div> <div> <button className="button block" type="button" onClick={() => logOut()}> 退出登录 </button> </div> </form> </div> )}

请注意上面我们使用了三个 Refine 钩子:useGetIdentity()useLogOut()useForm()

useGetIdentity() 是一个认证钩子,用于获取已认证用户的身份信息。它通过调用底层的 authProvider.getIdentity 方法来获取当前用户。

useLogOut() 也是一个认证钩子。它调用 authProvider.logout 方法来结束会话。

相比之下,useForm() 是一个数据钩子,它暴露了一系列用于编辑表单的有用对象。例如,我们获取 onFinish 函数来通过 handleSubmit 事件处理器提交表单。我们还使用 formLoading 属性来呈现表单提交时的状态变化。

useForm() 钩子是构建在 Refine 核心 useForm() 钩子之上的高级钩子。它完全支持使用 React Hook Form 进行表单状态管理、字段验证和提交。在幕后,它会调用 dataProvider.getOne 方法从我们的 Supabase /profiles 端点获取用户资料数据,并在调用 onFinish() 时调用 dataProvider.update 方法。

启动!

现在我们已经准备好了所有组件,接下来定义这些组件应该渲染的页面路由。

/login 路径添加使用 <Auth /> 组件的路由,为 index 路径添加使用 <Account /> 组件的路由。最终的 App.tsx 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { Authenticated, Refine } from '@refinedev/core'import { RefineKbar, RefineKbarProvider } from '@refinedev/kbar'import routerBindings, { CatchAllNavigate, DocumentTitleHandler, UnsavedChangesNotifier,} from '@refinedev/react-router-v6'import { dataProvider, liveProvider } from '@refinedev/supabase'import { BrowserRouter, Outlet, Route, Routes } from 'react-router-dom'import './App.css'import authProvider from './authProvider'import { supabaseClient } from './utility'import Account from './components/account'import Auth from './components/auth'function App() { return ( <BrowserRouter> <RefineKbarProvider> <Refine dataProvider={dataProvider(supabaseClient)} liveProvider={liveProvider(supabaseClient)} authProvider={authProvider} routerProvider={routerBindings} options={{ syncWithLocation: true, warnWhenUnsavedChanges: true, }} > <Routes> <Route element={ <Authenticated fallback={<CatchAllNavigate to="/login" />}> <Outlet /> </Authenticated> } > <Route index element={<Account />} /> </Route> <Route element={<Authenticated fallback={<Outlet />} />}> <Route path="/login" element={<Auth />} /> </Route> </Routes> <RefineKbar /> <UnsavedChangesNotifier /> <DocumentTitleHandler /> </Refine> </RefineKbarProvider> </BrowserRouter> )}export default App

让我们通过再次运行服务器来测试应用:

1
npm run dev

然后在浏览器中打开 localhost:5173,您应该能看到完整的应用。

Supabase refine

额外功能:个人资料照片

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

创建上传组件

让我们为用户创建一个头像上传组件,使他们能够上传个人资料照片。我们可以从创建一个新组件开始:

创建并编辑 src/components/avatar.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import { useEffect, useState } from 'react'import { supabaseClient } from '../utility/supabaseClient'type TAvatarProps = { url?: string size: number onUpload: (filePath: string) => void}export default function Avatar({ url, size, onUpload }: TAvatarProps) { const [avatarUrl, setAvatarUrl] = useState('') const [uploading, setUploading] = useState(false) useEffect(() => { if (url) downloadImage(url) }, [url]) async function downloadImage(path: string) { try { const { data, error } = await supabaseClient.storage.from('avatars').download(path) if (error) { throw error } const url = URL.createObjectURL(data) setAvatarUrl(url) } catch (error: any) { console.log('下载图片错误: ', error?.message) } } async function uploadAvatar(event: React.ChangeEvent<HTMLInputElement>) { try { setUploading(true) if (!event.target.files || event.target.files.length === 0) { throw new Error('请选择要上传的图片。') } const file = event.target.files[0] const fileExt = file.name.split('.').pop() const fileName = `${Math.random()}.${fileExt}` const filePath = `${fileName}` const { error: uploadError } = await supabaseClient.storage .from('avatars') .upload(filePath, file) if (uploadError) { throw uploadError } onUpload(filePath) } catch (error: any) { alert(error.message) } finally { setUploading(false) } } return ( <div> {avatarUrl ? ( <img src={avatarUrl} alt="头像" className="avatar image" style={{ height: size, width: size }} /> ) : ( <div className="avatar no-image" style={{ height: size, width: size }} /> )} <div style={{ width: size }}> <label className="button primary block" htmlFor="single"> {uploading ? '上传中...' : '上传'} </label> <input style={{ visibility: 'hidden', position: 'absolute', }} type="file" id="single" name="avatar_url" accept="image/*" onChange={uploadAvatar} disabled={uploading} /> </div> </div> )}

添加新组件

接下来我们可以将组件添加到账户页面 src/components/account.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 导入新组件import { Controller } from 'react-hook-form'import Avatar from './avatar'// ...return ( <div className="container" style={{ padding: '50px 0 100px 0' }}> <form onSubmit={handleSubmit} className="form-widget"> <Controller control={control} name="avatar_url" render={({ field }) => { return ( <Avatar url={field.value} size={150} onUpload={(filePath) => { onFinish({ ...queryResult?.data?.data, avatar_url: filePath, onMutationError: (data: { message: string }) => alert(data?.message), }) field.onChange({ target: { value: filePath, }, }) }} /> ) }} /> {/* ... */} </form> </div>)

至此,您已经拥有了一个功能完整的应用程序!