Skip to content
Getting Started

用 RedwoodJS 构建一个用户管理应用

本教程演示了如何构建一个基本的用户管理应用。该应用可以进行用户认证和识别,将用户的个人资料信息存储在数据库中,并允许用户登录、更新他们的个人资料信息以及上传头像。该应用使用了:

🌐 This tutorial demonstrates how to build a basic user management app. The app authenticates and identifies the user, stores their profile information in the database, and allows the user to log in, update their profile details, and upload a profile photo. The app uses:

Supabase User Management example

关于 RedwoodJS #

🌐 About RedwoodJS

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

🌐 A Redwood application is split into two parts: a frontend and a backend. This is represented as two node projects within a single monorepo.

前端项目叫 web,后端项目叫 api。为了便于说明,我们在文章中会称它们为 “端””,也就是 web sideapi side。它们是分开的项目,因为 web side 上的代码最终会在用户的浏览器中运行,而 api side 上的代码会在某个服务器上运行。

🌐 The frontend project is called web and the backend project is called api. For clarity, we will refer to these in prose as "sides," that is, the web side and the api side. They are separate projects because code on the web side will end up running in the user's browser while code on the api side will run on a server somewhere.

api side 是一个 GraphQL API 的实现。业务逻辑被组织成“服务”,这些服务有自己的内部 API,既可以被外部的 GraphQL 请求调用,也可以被其他内部服务调用。

🌐 The api side is an implementation of a GraphQL API. The business logic is organized into "services" that represent their own internal API and can be called both from external GraphQL requests and other internal services.

web side 是用 React 构建的。Redwood 的路由让你可以将 URL 路径映射到 React “页面”组件(并在每条路由上自动进行代码拆分)。 页面可以包含一个“布局”组件来封装内容。它们还包含“Cells”和普通的 React 组件。 Cells 让你可以以声明式的方式管理获取和显示数据的组件的生命周期。

🌐 The web side is built with React. Redwood's router lets you map URL paths to React "Page" components (and automatically code-split your app on each route). Pages may contain a "Layout" component to wrap content. They also contain "Cells" and regular React components. Cells allow you to declaratively manage the lifecycle of a component that fetches and displays data.

为了与其他框架教程保持一致,我们这次会以稍微不同的方式来构建这个应用。 我们不会像在 Redwood 应用中通常那样使用 Prisma 来连接 Supabase Postgres 数据库或使用 Prisma 迁移 。 相反,我们会依赖 Supabase 客户端在 web 一侧完成部分工作,并在 api 一侧再次使用客户端进行数据获取。

🌐 For the sake of consistency with the other framework tutorials, we'll build this app a little differently than normal. We won't use Prisma to connect to the Supabase Postgres database or Prisma migrations as one typically might in a Redwood app. Instead, we'll rely on the Supabase client to do some of the work on the web side and use the client again on the api side to do data fetching as well.

这意味着你会想要避免运行任何 yarn rw prisma migrate 命令,并且在部署时仔细检查你的构建命令,以确保 Prisma 不会重置你的数据库。Prisma 目前不支持跨 schema 外键,所以由于你的 Supabase public schema 引用了 auth.users,在 introspect schema 时会失败。

🌐 That means you will want to refrain from running any yarn rw prisma migrate commands and also double check your build commands on deployment to ensure Prisma won't reset your database. Prisma currently doesn't support cross-schema foreign keys, so introspecting the schema fails due to how your Supabase public schema references the auth.users.

项目设置 #

🌐 Project setup

在你开始构建之前,你需要先设置数据库和 API。你可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“架构”来完成这一步。

🌐 Before you start building you need to set up the Database and API. You can do this by starting a new Project in Supabase and then creating a "schema" inside the database.

创建一个项目 #

🌐 Create a project

  1. 在 Supabase 仪表板中创建一个新项目
  2. 输入你的项目详情。
  3. 等新数据库上线。

设置数据库模式 #

🌐 Set up the database schema

现在设置数据库模式。你可以在 SQL 编辑器中使用“用户管理入门”快速开始,也可以复制粘贴下面的 SQL 并运行。

🌐 Now set up the database schema. You can use the "User Management Starter" quickstart in the SQL Editor, or you can copy/paste the SQL from below and run it.

  1. 在仪表板中转到SQL 编辑器页面。
  2. 点击 社区 > 快速入门 标签下的 用户管理入门
  3. 点击运行

获取 API 详情 #

🌐 Get API details

要与数据库表中的数据进行交互,你可以使用封装了自动生成的数据 API 端点的客户端库,并使用来自项目 Connect 对话框的项目 URL 和密钥进行认证。

🌐 To interact with data in database tables, you use the client libraries that wrap the auto-generated Data API endpoints, authenticating using the Project URL and key from the project Connect dialog.

Project URL
Publishable key

正在构建应用 #

🌐 Building the app

从零开始搭建 RedwoodJS 应用。

🌐 Build the RedwoodJS app from scratch.

确保你已经安装了 yarn,因为 RedwoodJS 依赖它来在工作区管理其包,用于其 webapi “端”。

🌐 Make sure you have installed yarn since RedwoodJS relies on it to manage its packages in workspaces for its web and api "sides."

初始化一个 RedwoodJS 应用 #

🌐 Initialize a RedwoodJS app

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

🌐 We can use Create Redwood App command to initialize an app called supabase-redwoodjs:

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

在应用安装的时候,你应该会看到:

🌐 While the app is installing, you should see:

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!

然后通过运行 setup auth 命令来安装唯一的额外依赖 supabase-js

🌐 Then install the only additional dependency supabase-js by running the setup auth command:

1
yarn redwood setup auth supabase

当被提示时:

🌐 When prompted:

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

,它会在你的应用中设置 Supabase 客户端,并且提供用于 Supabase 认证的钩子。

🌐 Say, yes and it will setup the Supabase client in your app and also provide hooks used with Supabase authentication.

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

🌐 Next, we want to save the environment variables in a .env. We need the API URL as well as the key and jwt_secret that you copied earlier.

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

🌐 And finally, you will also need to save only the web side environment variables to the 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

这些变量会在浏览器上暴露,这完全没问题。它们允许你的网页应用使用你的可公开密钥初始化 Supabase 客户端,因为我们的数据库启用了 行级安全

🌐 These variables will be exposed on the browser, and that's completely fine. They allow your web app to initialize the Supabase client with your publishable key since we have Row Level Security enabled on our Database.

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

🌐 You'll see these being used to configure your Supabase client in web/src/App.js:

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

应用风格(可选) #

🌐 App styling (optional)

一个可选步骤是更新 CSS 文件 web/src/index.css,让应用看起来更好。你可以在示例仓库中找到这个文件的完整内容。

🌐 An optional step is to update the CSS file web/src/index.css to make the app look better. You can find the full contents of this file in the example repository.

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

🌐 Start RedwoodJS and your first page

通过启动应用来测试你的设置:

🌐 Test your setup by starting the app:

1
yarn rw dev

你应该会看到一个“欢迎来到 RedwoodJS”的页面,以及一条关于还没有任何页面的消息。

🌐 You should see a "Welcome to RedwoodJS" page and a message about not having any pages yet.

创建一个“主页”:

🌐 Create a "home" page:

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

🌐 You can stop the dev server if you want; to see your changes, run yarn rw dev again.

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

🌐 You should see the Home page route in web/src/Routes.js:

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

设置一个登录组件 #

🌐 Set up a login component

设置一个 Redwood 组件来管理登录和注册。我们将使用 Magic Links,这样用户可以用邮箱登录而不用密码。

🌐 Set up a Redwood component to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords.

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 组件,使其包含:

🌐 Now, update the Auth.js component to contain:

1
import { useAuth } from '@redwoodjs/auth'
2
import { useState } from 'react'
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

设置一个账户组件 #

🌐 Set up an account component

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

🌐 After a user is signed in we can allow them to edit their profile details and manage their account.

创建一个叫做 Account.js 的新组件。

🌐 Create a new component called 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`

然后更新文件以包含:

🌐 And then update the file to contain:

1
import { useAuth } from '@redwoodjs/auth'
2
import { useEffect, useState } from 'react'
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 交互。

🌐 You'll see the use of useAuth() several times. Redwood's useAuth hook provides convenient ways to access logIn, logOut, currentUser, and access the supabase authenticate client. We'll use it to get an instance of the Supabase client to interact with your API.

更新首页 #

🌐 Update home page

在所有组件就位后,更新你的 HomePage 页面来使用它们:

🌐 With all the components in place, update your HomePage page to use them:

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

头像照片 #

🌐 Profile photos

接下来,添加一个让用户上传个人资料照片的方式。Supabase 会为每个项目配置 Storage,用于管理像照片和视频这样的大文件。

🌐 Next, add a way for users to upload a profile photo. Supabase configures every project with Storage for managing large files like photos and videos.

创建一个上传小工具 #

🌐 Create an upload widget

创建一个头像,这样用户就可以上传个人照片。首先创建一个新组件:

🌐 Create an avatar so the user can upload a profile photo. Start by creating a new component:

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 组件,使其包含以下小部件:

🌐 Now, update your Avatar component to contain the following widget:

1
import { useAuth } from '@redwoodjs/auth'
2
import { useEffect, useState } from 'react'
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

触发! #

🌐 Launch!

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

🌐 Once that's done, run this in a terminal window to launch the dev server:

1
yarn rw dev

然后打开浏览器访问 localhost:8910,你应该能看到完成的应用。

🌐 And then open the browser to localhost:8910 and you should see the completed app.

Supabase RedwoodJS

在这个阶段,你已经有了一个完全可用的应用!

🌐 At this stage you have a fully functional application!

另请参阅 #

🌐 See also