入门

使用 Refine 构建用户管理应用


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

Supabase User Management example

关于 Refine#

Refine 是一个基于 React 的框架,用于快速构建数据密集型应用程序,例如管理面板、仪表盘、商店和任何类型的 CRUD 应用。它将应用程序的关注点分离成独立的层,每一层都由一个 React context 和相应的 provider 对象支持。例如,auth 层表示由一组特定的 authProvider 方法提供的 context,这些方法执行身份验证和授权操作,例如登录、注销、获取角色数据等。 类似地,数据层提供了另一个抽象层,配备了 dataProvider 方法,以在适当的后端 API 端点处理 CRUD 操作。

Refine 通过其补充的 @refinedev/supabase 包,提供了与 Supabase 后端的无忧集成。它在项目初始化时生成 authProviderdataProvider 方法,因此我们无需花费太多精力来定义它们。我们只需要在创建应用程序时将 Supabase 选择为我们的后端服务即可,使用 create refine-app

项目设置#

在开始构建之前,您需要设置数据库和 API。您可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“schema”来完成此操作。

创建项目#

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

设置数据库 schema#

现在设置数据库 schema。您可以使用 SQL 编辑器中的“用户管理 Starter”快速入门,或者您可以复制/粘贴下面的 SQL 并运行它。

  1. 转到控制面板中的 SQL 编辑器 页面。
  2. 点击 社区 > 快速入门 选项卡下的 用户管理 Starter
  3. 点击 运行

获取 API 详细信息#

现在您已经创建了一些数据库表,您可以使用自动生成的 API 插入数据。

为此,您需要从 项目的 连接 对话框 获取项目 URL 和密钥。

阅读 API 密钥文档 以全面了解所有密钥类型及其用途。

构建应用#

让我们从头开始构建 Refine 应用。

初始化 Refine 应用#

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

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

在上面的命令中,我们使用了 refine-supabase 预设,它为我们的应用程序选择了 Supabase 补充包。我们没有使用任何 UI 框架,因此我们将拥有一个无头 UI,使用纯 React 和 CSS 样式。

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
cd app-name
2
npm run dev

我们应该在 https://:5173 处有一个正在运行的应用程序实例,并显示一个欢迎页面。

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

Refine supabaseClient#

create refine-app 为我们在 src/utility/supabaseClient.ts 文件中生成了一个 Supabase 客户端。它有两个常量:SUPABASE_URLSUPABASE_KEY。我们希望将它们分别替换为 supabaseUrlsupabasePublishableKey,并分配我们自己的 Supabase 服务器的值。

我们将使用 Vite 管理的环境变量进行更新

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

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

1
VITE_SUPABASE_URL=YOUR_SUPABASE_URL
2
VITE_SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEY

supabaseClient 将在我们的应用程序从 Supabase 端点进行获取调用的过程中使用。 正如我们将在下面看到的那样,该客户端对于使用 Refine 的 auth provider 方法和适当的数据 provider 方法实现身份验证和 CRUD 操作至关重要。

一个可选步骤是更新 CSS 文件 src/App.css 以使应用程序看起来不错。您可以在 此处 找到此文件的完整内容。

为了在这个应用程序中添加登录和用户配置文件页面,我们必须调整 App.tsx 内部的 <Refine /> 组件。

<Refine /> 组件#

App.tsx 文件最初如下所示

1
import { Refine, WelcomePage } from '@refinedev/core'
2
import { RefineKbar, RefineKbarProvider } from '@refinedev/kbar'
3
import routerProvider, {
4
DocumentTitleHandler,
5
UnsavedChangesNotifier,
6
} from '@refinedev/react-router'
7
import { dataProvider, liveProvider } from '@refinedev/supabase'
8
import { BrowserRouter, Route, Routes } from 'react-router'
9
import './App.css'
10
import authProvider from './authProvider'
11
import { supabaseClient } from './utility'
12
13
function App() {
14
return (
15
<BrowserRouter>
16
<RefineKbarProvider>
17
<Refine
18
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
}
38
39
export 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
1
login: async ({ email }) => {
2
try {
3
const { error } = await supabaseClient.auth.signInWithOtp({ email });
4
5
if (!error) {
6
alert("Check your email for the login link!");
7
return {
8
success: true,
9
};
10
};
11
12
throw error;
13
} catch (e: any) {
14
alert(e.message);
15
return {
16
success: false,
17
e,
18
};
19
}
20
},

我们还希望删除 registerupdatePasswordforgotPasswordgetPermissions 属性,它们是可选的类型成员,并且对于我们的应用程序来说也不是必需的。最终的 authProvider 对象如下所示

1
import { AuthProvider } from '@refinedev/core'
2
3
import { supabaseClient } from './utility'
4
5
const authProvider: AuthProvider = {
6
login: async ({ email }) => {
7
try {
8
const { error } = await supabaseClient.auth.signInWithOtp({ email })
9
10
if (!error) {
11
alert('Check your email for the login link!')
12
return {
13
success: true,
14
}
15
}
16
17
throw error
18
} 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()
28
29
if (error) {
30
return {
31
success: false,
32
error,
33
}
34
}
35
36
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 } = data
49
50
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
}
72
73
return {
74
authenticated: true,
75
}
76
},
77
getIdentity: async () => {
78
const { data } = await supabaseClient.auth.getUser()
79
80
if (data?.user) {
81
return {
82
...data.user,
83
name: data.user.email,
84
}
85
}
86
87
return null
88
},
89
}
90
91
export default authProvider

设置登录组件#

我们选择使用无头 Refine 核心包,该包不带任何受支持的 UI 框架。因此,让我们设置一个纯 React 组件来管理登录和注册。

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

1
import { useState } from 'react'
2
import { useLogin } from '@refinedev/core'
3
4
export default function Auth() {
5
const [email, setEmail] = useState('')
6
const { isPending, mutate: login } = useLogin()
7
8
const handleLogin = async (event: { preventDefault: () => void }) => {
9
event.preventDefault()
10
login({ email })
11
}
12
13
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
<input
21
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 中创建一个新的组件。

1
import { BaseKey, useGetIdentity, useLogout } from '@refinedev/core'
2
import { useForm } from '@refinedev/react-hook-form'
3
4
interface IUserIdentity {
5
id?: BaseKey
6
username: string
7
name: string
8
}
9
10
export interface IProfile {
11
id?: string
12
username?: string
13
website?: string
14
avatar_url?: string
15
}
16
17
export default function Account() {
18
const { data: userIdentity } = useGetIdentity<IUserIdentity>()
19
20
const { mutate: logOut } = useLogout()
21
22
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
})
36
37
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>
52
53
<div>
54
<button className="button block primary" type="submit" disabled={formLoading}>
55
{formLoading ? 'Loading ...' : 'Update'}
56
</button>
57
</div>
58
59
<div>
60
<button className="button block" type="button" onClick={() => logOut()}>
61
Sign Out
62
</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

1
import { Authenticated, Refine } from '@refinedev/core'
2
import { RefineKbar, RefineKbarProvider } from '@refinedev/kbar'
3
import routerProvider, {
4
CatchAllNavigate,
5
DocumentTitleHandler,
6
UnsavedChangesNotifier,
7
} from '@refinedev/react-router'
8
import { dataProvider, liveProvider } from '@refinedev/supabase'
9
import { BrowserRouter, Outlet, Route, Routes } from 'react-router'
10
11
import './App.css'
12
import authProvider from './authProvider'
13
import { supabaseClient } from './utility'
14
import Account from './components/account'
15
import Auth from './components/auth'
16
17
function App() {
18
return (
19
<BrowserRouter>
20
<RefineKbarProvider>
21
<Refine
22
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
<Route
33
element={
34
<Authenticated
35
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
}
56
57
export default App

让我们再次运行服务器来测试应用程序

1
npm run dev

然后打开浏览器到 localhost:5173,您应该会看到完成的应用。

Supabase Refine

奖励:个人资料照片#

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

创建一个上传小部件#

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

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

1
import { useEffect, useState } from 'react'
2
import { supabaseClient } from '../utility/supabaseClient'
3
4
type TAvatarProps = {
5
url?: string
6
size: number
7
onUpload: (filePath: string) => void
8
}
9
10
export default function Avatar({ url, size, onUpload }: TAvatarProps) {
11
const [avatarUrl, setAvatarUrl] = useState('')
12
const [uploading, setUploading] = useState(false)
13
14
useEffect(() => {
15
if (url) downloadImage(url)
16
}, [url])
17
18
async function downloadImage(path: string) {
19
try {
20
const { data, error } = await supabaseClient.storage.from('avatars').download(path)
21
if (error) {
22
throw error
23
}
24
const url = URL.createObjectURL(data)
25
setAvatarUrl(url)
26
} catch (error: any) {
27
console.log('Error downloading image: ', error?.message)
28
}
29
}
30
31
async function uploadAvatar(event: React.ChangeEvent<HTMLInputElement>) {
32
try {
33
setUploading(true)
34
35
if (!event.target.files || event.target.files.length === 0) {
36
throw new Error('You must select an image to upload.')
37
}
38
39
const file = event.target.files[0]
40
const fileExt = file.name.split('.').pop()
41
const fileName = `${Math.random()}.${fileExt}`
42
const filePath = `${fileName}`
43
44
const { error: uploadError } = await supabaseClient.storage
45
.from('avatars')
46
.upload(filePath, file)
47
48
if (uploadError) {
49
throw uploadError
50
}
51
onUpload(filePath)
52
} catch (error: any) {
53
alert(error.message)
54
} finally {
55
setUploading(false)
56
}
57
}
58
59
return (
60
<div>
61
{avatarUrl ? (
62
<img
63
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
<input
76
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 components
2
import { Controller } from 'react-hook-form'
3
import Avatar from './avatar'
4
5
// ...
6
7
return (
8
<div className="container" style={{ padding: '50px 0 100px 0' }}>
9
<form onSubmit={handleSubmit} className="form-widget">
10
<Controller
11
control={control}
12
name="avatar_url"
13
render={({ field }) => {
14
return (
15
<Avatar
16
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
)

在这个阶段,你已经拥有了一个功能齐全的应用程序!