入门

使用 RedwoodJS 构建用户管理应用


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

Supabase User Management example

关于 RedwoodJS#

Redwood 应用分为两个部分:前端和后端。这在单个 monorepo 中的两个 node 项目中体现。

前端项目称为 web,后端项目称为 api。为了清晰起见,我们将它们在正文中称为 “侧面”,即 web sideapi side。它们是独立的,因为 web side 上的代码最终将在用户的浏览器中运行,而 api side 上的代码将在服务器的某个地方运行。

api side 是 GraphQL API 的实现。业务逻辑组织成“服务”,这些服务代表它们自己的内部 API,可以从外部 GraphQL 请求和其他内部服务调用。

web side 使用 React 构建。Redwood 的路由器使将 URL 路径映射到 React “Page” 组件变得简单(并自动为每个路由对您的应用进行代码拆分)。页面可能包含一个“Layout”组件来包装内容。它们还包含“Cells”和常规 React 组件。Cells 允许您声明性地管理获取和显示数据的组件的生命周期。

为了与其它框架教程保持一致,我们将以与平常略有不同的方式构建此应用。我们不会使用 Prisma 连接到 Supabase Postgres 数据库或 Prisma 迁移,就像通常在 Redwood 应用中那样。相反,我们将依赖 Supabase 客户端来完成 web 侧的一些工作,并在 api 侧再次使用客户端来执行数据获取。

这意味着您应该避免运行任何 yarn rw prisma migrate 命令,并且还要仔细检查部署命令,以确保 Prisma 不会重置您的数据库。Prisma 目前不支持跨模式外键,因此由于您的 Supabase public 模式引用了 auth.users,导致模式内省失败。

项目设置#

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

创建项目#

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

设置数据库 schema#

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

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

获取 API 详细信息#

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

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

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

构建应用#

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

确保您已安装 yarn,因为 RedwoodJS 依赖它来 使用工作区管理其包,用于其 webapi “侧面”。

初始化 RedwoodJS 应用#

我们可以使用 Create Redwood App 命令来初始化一个名为 supabase-redwoodjs 的应用

1
yarn create redwood-app supabase-redwoodjs
2
cd supabase-redwoodjs

在应用安装过程中,您应该会看到

1
Creating Redwood app
2
Checking node and yarn compatibility
3
Creating directory 'supabase-redwoodjs'
4
Installing packages
5
Running 'yarn install'... (This could take a while)
6
Convert TypeScript files to JavaScript
7
Generating types
8
9
Thanks for trying out Redwood!

然后让我们安装唯一的附加依赖项 supabase-js,通过运行 setup auth 命令

1
yarn redwood setup auth supabase

当被提示时

覆盖现有的 /api/src/lib/auth.[jt]s?

说,,它将设置您的应用中的 Supabase 客户端,并提供与 Supabase 身份验证一起使用的钩子。

1
Generating auth lib...
2
Successfully wrote file `./api/src/lib/auth.js`
3
Adding auth config to web...
4
Adding auth config to GraphQL API...
5
Adding required web packages...
6
Installing packages...
7
One more thing...
8
9
You will need to add your Supabase URL (SUPABASE_URL), public API KEY,
10
and JWT SECRET (SUPABASE_KEY, and SUPABASE_JWT_SECRET) to your .env file.

接下来,我们想将环境变量保存在一个 .env 文件中。我们需要 API URL 以及您之前复制的密钥和 jwt_secret

1
SUPABASE_URL=YOUR_SUPABASE_URL
2
SUPABASE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEY
3
SUPABASE_JWT_SECRET=YOUR_SUPABASE_JWT_SECRET

最后,您还需要将 web side 环境变量保存到 redwood.toml 文件中。

1
[web]
2
title = "Supabase Redwood Tutorial"
3
port = 8910
4
apiProxyPath = "/.redwood/functions"
5
includeEnvironmentVariables = ["SUPABASE_URL", "SUPABASE_KEY"]
6
[api]
7
port = 8911
8
[browser]
9
open = true

这些变量将在浏览器中公开,这完全没问题。它们允许您的 web 应用使用我们的公共匿名密钥初始化 Supabase 客户端,因为我们启用了 行级别安全 数据库。

您将在 web/src/App.js 中看到这些被用来配置您的 Supabase 客户端

1
// ... Redwood imports
2
import { AuthProvider } from '@redwoodjs/auth'
3
import { createClient } from '@supabase/supabase-js'
4
5
// ...
6
7
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY)
8
9
const App = () => (
10
<FatalErrorBoundary page={FatalErrorPage}>
11
<RedwoodProvider titleTemplate="%PageTitle | %AppTitle">
12
<AuthProvider client={supabase} type="supabase">
13
<RedwoodApolloProvider>
14
<Routes />
15
</RedwoodApolloProvider>
16
</AuthProvider>
17
</RedwoodProvider>
18
</FatalErrorBoundary>
19
)
20
21
export default App

应用样式 (可选)#

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

启动 RedwoodJS 和您的第一个页面#

让我们通过启动应用来测试我们的设置

1
yarn rw dev

您应该会看到一个“欢迎使用 RedwoodJS”页面和一个关于您没有页面消息。

所以,让我们创建一个“home”页面

1
yarn rw generate page home /
2
3
Generating page files...
4
Successfully wrote file `./web/src/pages/HomePage/HomePage.stories.js`
5
Successfully wrote file `./web/src/pages/HomePage/HomePage.test.js`
6
Successfully wrote file `./web/src/pages/HomePage/HomePage.js`
7
Updating routes file...
8
Generating types ...

如果您想停止 dev 服务器,可以这样做;要查看您的更改,请确保再次运行 yarn rw dev

您应该在 web/src/Routes.js 中看到 Home 页面路由

1
import { Router, Route } from '@redwoodjs/router'
2
3
const Routes = () => {
4
return (
5
<Router>
6
<Route path="/" page={HomePage} name="home" />
7
<Route notfound page={NotFoundPage} />
8
</Router>
9
)
10
}
11
12
export default Routes

设置登录组件#

让我们设置一个 Redwood 组件来管理登录和注册。我们将使用 Magic Links,以便用户可以使用他们的电子邮件地址进行登录,而无需使用密码。

1
yarn rw g component auth
2
3
Generating component files...
4
Successfully wrote file `./web/src/components/Auth/Auth.test.js`
5
Successfully wrote file `./web/src/components/Auth/Auth.stories.js`
6
Successfully wrote file `./web/src/components/Auth/Auth.js`

现在,更新 Auth.js 组件以包含

1
import { useState } from 'react'
2
import { useAuth } from '@redwoodjs/auth'
3
4
const Auth = () => {
5
const { logIn } = useAuth()
6
const [loading, setLoading] = useState(false)
7
const [email, setEmail] = useState('')
8
9
const handleLogin = async (email) => {
10
try {
11
setLoading(true)
12
const { error } = await logIn({ email })
13
if (error) throw error
14
alert('Check your email for the login link!')
15
} catch (error) {
16
alert(error.error_description || error.message)
17
} finally {
18
setLoading(false)
19
}
20
}
21
22
return (
23
<div className="row flex-center flex">
24
<div className="col-6 form-widget">
25
<h1 className="header">Supabase + RedwoodJS</h1>
26
<p className="description">Sign in via magic link with your email below</p>
27
<div>
28
<input
29
className="inputField"
30
type="email"
31
placeholder="Your email"
32
value={email}
33
onChange={(e) => setEmail(e.target.value)}
34
/>
35
</div>
36
<div>
37
<button
38
onClick={(e) => {
39
e.preventDefault()
40
handleLogin(email)
41
}}
42
className={'button block'}
43
disabled={loading}
44
>
45
{loading ? <span>Loading</span> : <span>Send magic link</span>}
46
</button>
47
</div>
48
</div>
49
</div>
50
)
51
}
52
53
export default Auth

设置一个帐户组件#

用户登录后,我们可以允许他们编辑他们的个人资料详细信息并管理他们的帐户。

让我们创建一个名为 Account.js 的新组件。

1
yarn rw g component account
2
3
Generating component files...
4
Successfully wrote file `./web/src/components/Account/Account.test.js`
5
Successfully wrote file `./web/src/components/Account/Account.stories.js`
6
Successfully wrote file `./web/src/components/Account/Account.js`

然后更新文件以包含

1
import { useState, useEffect } from 'react'
2
import { useAuth } from '@redwoodjs/auth'
3
4
const Account = () => {
5
const { client: supabase, currentUser, logOut } = useAuth()
6
const [loading, setLoading] = useState(true)
7
const [username, setUsername] = useState(null)
8
const [website, setWebsite] = useState(null)
9
const [avatar_url, setAvatarUrl] = useState(null)
10
11
useEffect(() => {
12
getProfile()
13
}, [supabase.auth.session])
14
15
async function getProfile() {
16
try {
17
setLoading(true)
18
const user = supabase.auth.user()
19
20
const { data, error, status } = await supabase
21
.from('profiles')
22
.select(`username, website, avatar_url`)
23
.eq('id', user.id)
24
.single()
25
26
if (error && status !== 406) {
27
throw error
28
}
29
30
if (data) {
31
setUsername(data.username)
32
setWebsite(data.website)
33
setAvatarUrl(data.avatar_url)
34
}
35
} catch (error) {
36
alert(error.message)
37
} finally {
38
setLoading(false)
39
}
40
}
41
42
async function updateProfile({ username, website, avatar_url }) {
43
try {
44
setLoading(true)
45
const user = supabase.auth.user()
46
47
const updates = {
48
id: user.id,
49
username,
50
website,
51
avatar_url,
52
updated_at: new Date(),
53
}
54
55
const { error } = await supabase.from('profiles').upsert(updates, {
56
returning: 'minimal', // Don't return the value after inserting
57
})
58
59
if (error) {
60
throw error
61
}
62
63
alert('Updated profile!')
64
} catch (error) {
65
alert(error.message)
66
} finally {
67
setLoading(false)
68
}
69
}
70
71
return (
72
<div className="row flex-center flex">
73
<div className="col-6 form-widget">
74
<h1 className="header">Supabase + RedwoodJS</h1>
75
<p className="description">Your profile</p>
76
<div className="form-widget">
77
<div>
78
<label htmlFor="email">Email</label>
79
<input id="email" type="text" value={currentUser.email} disabled />
80
</div>
81
<div>
82
<label htmlFor="username">Name</label>
83
<input
84
id="username"
85
type="text"
86
value={username || ''}
87
onChange={(e) => setUsername(e.target.value)}
88
/>
89
</div>
90
<div>
91
<label htmlFor="website">Website</label>
92
<input
93
id="website"
94
type="url"
95
value={website || ''}
96
onChange={(e) => setWebsite(e.target.value)}
97
/>
98
</div>
99
100
<div>
101
<button
102
className="button primary block"
103
onClick={() => updateProfile({ username, website, avatar_url })}
104
disabled={loading}
105
>
106
{loading ? 'Loading ...' : 'Update'}
107
</button>
108
</div>
109
110
<div>
111
<button className="button block" onClick={() => logOut()}>
112
Sign Out
113
</button>
114
</div>
115
</div>
116
</div>
117
</div>
118
)
119
}
120
121
export default Account

您会看到 useAuth() 被多次使用。Redwood 的 useAuth 钩子提供了方便的方法来访问 logInlogOutcurrentUser,以及访问 supabase 身份验证客户端。我们将使用它来获取 Supabase 客户端的实例,以与您的 API 交互。

更新主页#

现在我们已经准备好所有组件,让我们更新您的 HomePage 页面以使用它们

1
import { useAuth } from '@redwoodjs/auth'
2
import { MetaTags } from '@redwoodjs/web'
3
4
import Account from 'src/components/Account'
5
import Auth from 'src/components/Auth'
6
7
const HomePage = () => {
8
const { isAuthenticated } = useAuth()
9
10
return (
11
<>
12
<MetaTags title="Welcome" />
13
{!isAuthenticated ? <Auth /> : <Account />}
14
</>
15
)
16
}
17
18
export default HomePage

启动!#

完成后,在终端窗口中运行此命令以启动 dev 服务器

1
yarn rw dev

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

Supabase RedwoodJS

奖励:个人资料照片#

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

创建一个上传小部件#

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

1
yarn rw g component avatar
2
Generating component files...
3
Successfully wrote file `./web/src/components/Avatar/Avatar.test.js`
4
Successfully wrote file `./web/src/components/Avatar/Avatar.stories.js`
5
Successfully wrote file `./web/src/components/Avatar/Avatar.js`

现在,更新您的 Avatar 组件以包含以下小部件

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

添加新的小部件#

然后我们可以将小部件添加到 Account 组件

1
// Import the new component
2
import Avatar from 'src/components/Avatar'
3
4
// ...
5
6
return (
7
<div className="form-widget">
8
{/* Add to the body */}
9
<Avatar
10
url={avatar_url}
11
size={150}
12
onUpload={(url) => {
13
setAvatarUrl(url)
14
updateProfile({ username, website, avatar_url: url })
15
}}
16
/>
17
{/* ... */}
18
</div>
19
)

此时,您已经拥有一个功能齐全的应用!

另请参阅#