* docs: indicate publishable key instead of anon in many examples * replace your-anon-key to string indicating publishable or anon * fix your_... * apply suggestion from @ChrisChinchilla Co-authored-by: Chris Chinchilla <chris@chrischinchilla.com> * Update keys in code examples * Prettier fix * Update apps/docs/content/guides/functions/schedule-functions.mdx --------- Co-authored-by: Chris Chinchilla <chris@chrischinchilla.com>
652 lines
18 KiB
Plaintext
652 lines
18 KiB
Plaintext
---
|
|
title: 'Build a User Management App with RedwoodJS'
|
|
description: 'Learn how to use Supabase in your RedwoodJS App.'
|
|
---
|
|
|
|
<$Partial path="quickstart_intro.mdx" />
|
|
|
|

|
|
|
|
<Admonition type="note">
|
|
|
|
If you get stuck while working through this guide, refer to the [full example on GitHub](https://github.com/redwoodjs/redwoodjs-supabase-quickstart).
|
|
|
|
</Admonition>
|
|
|
|
## About RedwoodJS
|
|
|
|
A Redwood application is split into two parts: a frontend and a backend. This is represented as two node projects within a single monorepo.
|
|
|
|
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.
|
|
|
|
<Admonition type="note">
|
|
|
|
Important: When this guide refers to "API," that means the Supabase API and when it refers to `api side`, that means the RedwoodJS `api side`.
|
|
|
|
</Admonition>
|
|
|
|
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.
|
|
|
|
The **`web side`** is built with React. Redwood's router makes it simple to 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.
|
|
|
|
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](https://redwoodjs.com/docs/cli-commands#prisma-migrate) 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.
|
|
|
|
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`.
|
|
|
|
<$Partial path="project_setup.mdx" />
|
|
|
|
## Building the app
|
|
|
|
Let's start building the RedwoodJS app from scratch.
|
|
|
|
<Admonition type="note">
|
|
|
|
RedwoodJS requires Node.js `>= 14.x <= 16.x` and Yarn `>= 1.15`.
|
|
|
|
</Admonition>
|
|
|
|
Make sure you have installed yarn since RedwoodJS relies on it to [manage its packages in workspaces](https://classic.yarnpkg.com/lang/en/docs/workspaces/) for its `web` and `api` "sides."
|
|
|
|
### Initialize a RedwoodJS app
|
|
|
|
We can use [Create Redwood App](https://redwoodjs.com/docs/quick-start) command to initialize
|
|
an app called `supabase-redwoodjs`:
|
|
|
|
```bash
|
|
yarn create redwood-app supabase-redwoodjs
|
|
cd supabase-redwoodjs
|
|
```
|
|
|
|
While the app is installing, you should see:
|
|
|
|
```bash
|
|
✔ Creating Redwood app
|
|
✔ Checking node and yarn compatibility
|
|
✔ Creating directory 'supabase-redwoodjs'
|
|
✔ Installing packages
|
|
✔ Running 'yarn install'... (This could take a while)
|
|
✔ Convert TypeScript files to JavaScript
|
|
✔ Generating types
|
|
|
|
Thanks for trying out Redwood!
|
|
```
|
|
|
|
Then let's install the only additional dependency [supabase-js](https://github.com/supabase/supabase-js) by running the `setup auth` command:
|
|
|
|
```bash
|
|
yarn redwood setup auth supabase
|
|
```
|
|
|
|
When prompted:
|
|
|
|
> Overwrite existing /api/src/lib/auth.[jt]s?
|
|
|
|
Say, **yes** and it will setup the Supabase client in your app and also provide hooks used with Supabase authentication.
|
|
|
|
```bash
|
|
✔ Generating auth lib...
|
|
✔ Successfully wrote file `./api/src/lib/auth.js`
|
|
✔ Adding auth config to web...
|
|
✔ Adding auth config to GraphQL API...
|
|
✔ Adding required web packages...
|
|
✔ Installing packages...
|
|
✔ One more thing...
|
|
|
|
You will need to add your Supabase URL (SUPABASE_URL), public API KEY,
|
|
and JWT SECRET (SUPABASE_KEY, and SUPABASE_JWT_SECRET) to your .env file.
|
|
```
|
|
|
|
Next, we want to save the environment variables in a `.env`.
|
|
We need the `API URL` as well as the `anon` and `jwt_secret` keys that you copied [earlier](#get-the-api-keys).
|
|
|
|
<$CodeTabs>
|
|
|
|
```bash name=.env
|
|
SUPABASE_URL=YOUR_SUPABASE_URL
|
|
SUPABASE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEY
|
|
SUPABASE_JWT_SECRET=YOUR_SUPABASE_JWT_SECRET
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
And finally, you will also need to save **just** the `web side` environment variables to the `redwood.toml`.
|
|
|
|
<$CodeTabs>
|
|
|
|
```bash name=redwood.toml
|
|
[web]
|
|
title = "Supabase Redwood Tutorial"
|
|
port = 8910
|
|
apiProxyPath = "/.redwood/functions"
|
|
includeEnvironmentVariables = ["SUPABASE_URL", "SUPABASE_KEY"]
|
|
[api]
|
|
port = 8911
|
|
[browser]
|
|
open = true
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
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 public anon key
|
|
since we have [Row Level Security](/docs/guides/auth#row-level-security) enabled on our Database.
|
|
|
|
You'll see these being used to configure your Supabase client in `web/src/App.js`:
|
|
|
|
<$CodeTabs>
|
|
|
|
```js name=web/src/App.js
|
|
// ... Redwood imports
|
|
import { AuthProvider } from '@redwoodjs/auth'
|
|
import { createClient } from '@supabase/supabase-js'
|
|
|
|
// ...
|
|
|
|
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY)
|
|
|
|
const App = () => (
|
|
<FatalErrorBoundary page={FatalErrorPage}>
|
|
<RedwoodProvider titleTemplate="%PageTitle | %AppTitle">
|
|
<AuthProvider client={supabase} type="supabase">
|
|
<RedwoodApolloProvider>
|
|
<Routes />
|
|
</RedwoodApolloProvider>
|
|
</AuthProvider>
|
|
</RedwoodProvider>
|
|
</FatalErrorBoundary>
|
|
)
|
|
|
|
export default App
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### App styling (optional)
|
|
|
|
An optional step is to update the CSS file `web/src/index.css` to make the app look nice.
|
|
You can find the full contents of this file [here](https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/react-user-management/src/index.css).
|
|
|
|
### Start RedwoodJS and your first page
|
|
|
|
Let's test our setup at the moment by starting up the app:
|
|
|
|
```bash
|
|
yarn rw dev
|
|
```
|
|
|
|
<Admonition type="note">
|
|
|
|
`rw` is an alias for `redwood`, as in `yarn rw` to run Redwood CLI commands.
|
|
|
|
</Admonition>
|
|
|
|
You should see a "Welcome to RedwoodJS" page and a message about not having any pages yet.
|
|
|
|
So, let's create a "home" page:
|
|
|
|
```bash
|
|
yarn rw generate page home /
|
|
|
|
✔ Generating page files...
|
|
✔ Successfully wrote file `./web/src/pages/HomePage/HomePage.stories.js`
|
|
✔ Successfully wrote file `./web/src/pages/HomePage/HomePage.test.js`
|
|
✔ Successfully wrote file `./web/src/pages/HomePage/HomePage.js`
|
|
✔ Updating routes file...
|
|
✔ Generating types ...
|
|
```
|
|
|
|
<Admonition type="note">
|
|
|
|
The `/` is important here as it creates a root level route.
|
|
|
|
</Admonition>
|
|
|
|
You can stop the `dev` server if you want; to see your changes, just be sure to run `yarn rw dev` again.
|
|
|
|
You should see the `Home` page route in `web/src/Routes.js`:
|
|
|
|
<$CodeTabs>
|
|
|
|
```bash name=web/src/Routes.js
|
|
import { Router, Route } from '@redwoodjs/router'
|
|
|
|
const Routes = () => {
|
|
return (
|
|
<Router>
|
|
<Route path="/" page={HomePage} name="home" />
|
|
<Route notfound page={NotFoundPage} />
|
|
</Router>
|
|
)
|
|
}
|
|
|
|
export default Routes
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### Set up a login component
|
|
|
|
Let's 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.
|
|
|
|
```bash
|
|
yarn rw g component auth
|
|
|
|
✔ Generating component files...
|
|
✔ Successfully wrote file `./web/src/components/Auth/Auth.test.js`
|
|
✔ Successfully wrote file `./web/src/components/Auth/Auth.stories.js`
|
|
✔ Successfully wrote file `./web/src/components/Auth/Auth.js`
|
|
|
|
```
|
|
|
|
Now, update the `Auth.js` component to contain:
|
|
|
|
<$CodeTabs>
|
|
|
|
```jsx name=/web/src/components/Auth/Auth.js
|
|
import { useState } from 'react'
|
|
import { useAuth } from '@redwoodjs/auth'
|
|
|
|
const Auth = () => {
|
|
const { logIn } = useAuth()
|
|
const [loading, setLoading] = useState(false)
|
|
const [email, setEmail] = useState('')
|
|
|
|
const handleLogin = async (email) => {
|
|
try {
|
|
setLoading(true)
|
|
const { error } = await logIn({ email })
|
|
if (error) throw error
|
|
alert('Check your email for the login link!')
|
|
} catch (error) {
|
|
alert(error.error_description || error.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="row flex-center flex">
|
|
<div className="col-6 form-widget">
|
|
<h1 className="header">Supabase + RedwoodJS</h1>
|
|
<p className="description">Sign in via magic link with your email below</p>
|
|
<div>
|
|
<input
|
|
className="inputField"
|
|
type="email"
|
|
placeholder="Your email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<button
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
handleLogin(email)
|
|
}}
|
|
className={'button block'}
|
|
disabled={loading}
|
|
>
|
|
{loading ? <span>Loading</span> : <span>Send magic link</span>}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default Auth
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### Set up an account component
|
|
|
|
After a user is signed in we can allow them to edit their profile details and manage their account.
|
|
|
|
Let's create a new component for that called `Account.js`.
|
|
|
|
```bash
|
|
yarn rw g component account
|
|
|
|
✔ Generating component files...
|
|
✔ Successfully wrote file `./web/src/components/Account/Account.test.js`
|
|
✔ Successfully wrote file `./web/src/components/Account/Account.stories.js`
|
|
✔ Successfully wrote file `./web/src/components/Account/Account.js`
|
|
```
|
|
|
|
And then update the file to contain:
|
|
|
|
<$CodeTabs>
|
|
|
|
```jsx name=web/src/components/Account/Account.js
|
|
import { useState, useEffect } from 'react'
|
|
import { useAuth } from '@redwoodjs/auth'
|
|
|
|
const Account = () => {
|
|
const { client: supabase, currentUser, logOut } = useAuth()
|
|
const [loading, setLoading] = useState(true)
|
|
const [username, setUsername] = useState(null)
|
|
const [website, setWebsite] = useState(null)
|
|
const [avatar_url, setAvatarUrl] = useState(null)
|
|
|
|
useEffect(() => {
|
|
getProfile()
|
|
}, [supabase.auth.session])
|
|
|
|
async function getProfile() {
|
|
try {
|
|
setLoading(true)
|
|
const user = supabase.auth.user()
|
|
|
|
const { data, error, status } = await supabase
|
|
.from('profiles')
|
|
.select(`username, website, avatar_url`)
|
|
.eq('id', user.id)
|
|
.single()
|
|
|
|
if (error && status !== 406) {
|
|
throw error
|
|
}
|
|
|
|
if (data) {
|
|
setUsername(data.username)
|
|
setWebsite(data.website)
|
|
setAvatarUrl(data.avatar_url)
|
|
}
|
|
} catch (error) {
|
|
alert(error.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
async function updateProfile({ username, website, avatar_url }) {
|
|
try {
|
|
setLoading(true)
|
|
const user = supabase.auth.user()
|
|
|
|
const updates = {
|
|
id: user.id,
|
|
username,
|
|
website,
|
|
avatar_url,
|
|
updated_at: new Date(),
|
|
}
|
|
|
|
const { error } = await supabase.from('profiles').upsert(updates, {
|
|
returning: 'minimal', // Don't return the value after inserting
|
|
})
|
|
|
|
if (error) {
|
|
throw error
|
|
}
|
|
|
|
alert('Updated profile!')
|
|
} catch (error) {
|
|
alert(error.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="row flex-center flex">
|
|
<div className="col-6 form-widget">
|
|
<h1 className="header">Supabase + RedwoodJS</h1>
|
|
<p className="description">Your profile</p>
|
|
<div className="form-widget">
|
|
<div>
|
|
<label htmlFor="email">Email</label>
|
|
<input id="email" type="text" value={currentUser.email} disabled />
|
|
</div>
|
|
<div>
|
|
<label htmlFor="username">Name</label>
|
|
<input
|
|
id="username"
|
|
type="text"
|
|
value={username || ''}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="website">Website</label>
|
|
<input
|
|
id="website"
|
|
type="url"
|
|
value={website || ''}
|
|
onChange={(e) => setWebsite(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<button
|
|
className="button primary block"
|
|
onClick={() => updateProfile({ username, website, avatar_url })}
|
|
disabled={loading}
|
|
>
|
|
{loading ? 'Loading ...' : 'Update'}
|
|
</button>
|
|
</div>
|
|
|
|
<div>
|
|
<button className="button block" onClick={() => logOut()}>
|
|
Sign Out
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default Account
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
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
|
|
|
|
Now that we have all the components in place, let's update your `HomePage` page to use them:
|
|
|
|
<$CodeTabs>
|
|
|
|
```jsx name=web/src/pages/HomePage/HomePage.js
|
|
import { useAuth } from '@redwoodjs/auth'
|
|
import { MetaTags } from '@redwoodjs/web'
|
|
|
|
import Account from 'src/components/Account'
|
|
import Auth from 'src/components/Auth'
|
|
|
|
const HomePage = () => {
|
|
const { isAuthenticated } = useAuth()
|
|
|
|
return (
|
|
<>
|
|
<MetaTags title="Welcome" />
|
|
{!isAuthenticated ? <Auth /> : <Account />}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default HomePage
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
<Admonition type="note">
|
|
|
|
What we're doing here is showing the sign in form if you aren't logged in and your account profile if you are.
|
|
|
|
</Admonition>
|
|
|
|
### Launch!
|
|
|
|
Once that's done, run this in a terminal window to launch the `dev` server:
|
|
|
|
```bash
|
|
yarn rw dev
|
|
```
|
|
|
|
And then open the browser to [localhost:8910](http://localhost:8910) and you should see the completed app.
|
|
|
|

|
|
|
|
## Bonus: Profile photos
|
|
|
|
Every Supabase project is configured with [Storage](/docs/guides/storage) for managing large files like photos and videos.
|
|
|
|
### Create an upload widget
|
|
|
|
Let's create an avatar for the user so that they can upload a profile photo. We can start by creating a new component:
|
|
|
|
```bash
|
|
yarn rw g component avatar
|
|
✔ Generating component files...
|
|
✔ Successfully wrote file `./web/src/components/Avatar/Avatar.test.js`
|
|
✔ Successfully wrote file `./web/src/components/Avatar/Avatar.stories.js`
|
|
✔ Successfully wrote file `./web/src/components/Avatar/Avatar.js`
|
|
```
|
|
|
|
Now, update your Avatar component to contain the following widget:
|
|
|
|
<$CodeTabs>
|
|
|
|
```jsx name=web/src/components/Avatar/Avatar.js
|
|
import { useEffect, useState } from 'react'
|
|
import { useAuth } from '@redwoodjs/auth'
|
|
|
|
const Avatar = ({ url, size, onUpload }) => {
|
|
const { client: supabase } = useAuth()
|
|
|
|
const [avatarUrl, setAvatarUrl] = useState(null)
|
|
const [uploading, setUploading] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (url) downloadImage(url)
|
|
}, [url])
|
|
|
|
async function downloadImage(path) {
|
|
try {
|
|
const { data, error } = await supabase.storage.from('avatars').download(path)
|
|
if (error) {
|
|
throw error
|
|
}
|
|
const url = URL.createObjectURL(data)
|
|
setAvatarUrl(url)
|
|
} catch (error) {
|
|
console.log('Error downloading image: ', error.message)
|
|
}
|
|
}
|
|
|
|
async function uploadAvatar(event) {
|
|
try {
|
|
setUploading(true)
|
|
|
|
if (!event.target.files || event.target.files.length === 0) {
|
|
throw new Error('You must select an image to upload.')
|
|
}
|
|
|
|
const file = event.target.files[0]
|
|
const fileExt = file.name.split('.').pop()
|
|
const fileName = `${Math.random()}.${fileExt}`
|
|
const filePath = `${fileName}`
|
|
|
|
const { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file)
|
|
|
|
if (uploadError) {
|
|
throw uploadError
|
|
}
|
|
|
|
onUpload(filePath)
|
|
} catch (error) {
|
|
alert(error.message)
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{avatarUrl ? (
|
|
<img
|
|
src={avatarUrl}
|
|
alt="Avatar"
|
|
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 ? 'Uploading ...' : 'Upload'}
|
|
</label>
|
|
<input
|
|
style={{
|
|
visibility: 'hidden',
|
|
position: 'absolute',
|
|
}}
|
|
type="file"
|
|
id="single"
|
|
accept="image/*"
|
|
onChange={uploadAvatar}
|
|
disabled={uploading}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default Avatar
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### Add the new widget
|
|
|
|
And then we can add the widget to the Account component:
|
|
|
|
<$CodeTabs>
|
|
|
|
```jsx name=web/src/components/Account/Account.js
|
|
// Import the new component
|
|
import Avatar from 'src/components/Avatar'
|
|
|
|
// ...
|
|
|
|
return (
|
|
<div className="form-widget">
|
|
{/* Add to the body */}
|
|
<Avatar
|
|
url={avatar_url}
|
|
size={150}
|
|
onUpload={(url) => {
|
|
setAvatarUrl(url)
|
|
updateProfile({ username, website, avatar_url: url })
|
|
}}
|
|
/>
|
|
{/* ... */}
|
|
</div>
|
|
)
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
At this stage you have a fully functional application!
|
|
|
|
## See also
|
|
|
|
- Learn more about [RedwoodJS](https://redwoodjs.com)
|
|
- Visit the [RedwoodJS Discourse Community](https://community.redwoodjs.com)
|