* 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>
545 lines
13 KiB
Plaintext
545 lines
13 KiB
Plaintext
---
|
|
title: 'Build a User Management App with Ionic React'
|
|
description: 'Learn how to use Supabase in your Ionic React 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/mhartington/supabase-ionic-react).
|
|
|
|
</Admonition>
|
|
|
|
<$Partial path="project_setup.mdx" />
|
|
|
|
## Building the app
|
|
|
|
Let's start building the React app from scratch.
|
|
|
|
### Initialize an Ionic React app
|
|
|
|
We can use the [Ionic CLI](https://ionicframework.com/docs/cli) to initialize
|
|
an app called `supabase-ionic-react`:
|
|
|
|
```bash
|
|
npm install -g @ionic/cli
|
|
ionic start supabase-ionic-react blank --type react
|
|
cd supabase-ionic-react
|
|
```
|
|
|
|
Then let's install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js)
|
|
|
|
```bash
|
|
npm install @supabase/supabase-js
|
|
```
|
|
|
|
And finally we want to save the environment variables in a `.env`.
|
|
All we need are the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
|
|
|
|
<$CodeTabs>
|
|
|
|
```bash name=.env
|
|
VITE_SUPABASE_URL=YOUR_SUPABASE_URL
|
|
VITE_SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEY
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
Now that we have the API credentials in place, let's create a helper file to initialize the Supabase client. These variables will be exposed
|
|
on the browser, and that's completely fine since we have [Row Level Security](/docs/guides/auth#row-level-security) enabled on our Database.
|
|
|
|
<$CodeTabs>
|
|
|
|
```js name=src/supabaseClient.ts
|
|
import { createClient } from '@supabase/supabase-js'
|
|
|
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || ''
|
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY || ''
|
|
|
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### Set up a login route
|
|
|
|
Let's set up a React component to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords.
|
|
|
|
<$CodeTabs>
|
|
|
|
```jsx name=/src/pages/Login.tsx
|
|
import { useState } from 'react';
|
|
import {
|
|
IonButton,
|
|
IonContent,
|
|
IonHeader,
|
|
IonInput,
|
|
IonItem,
|
|
IonLabel,
|
|
IonList,
|
|
IonPage,
|
|
IonTitle,
|
|
IonToolbar,
|
|
useIonToast,
|
|
useIonLoading,
|
|
} from '@ionic/react';
|
|
|
|
import {supabase} from '../supabaseClient'
|
|
|
|
export function LoginPage() {
|
|
const [email, setEmail] = useState('');
|
|
|
|
const [showLoading, hideLoading] = useIonLoading();
|
|
const [showToast ] = useIonToast();
|
|
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
console.log()
|
|
e.preventDefault();
|
|
await showLoading();
|
|
try {
|
|
await supabase.auth.signInWithOtp({
|
|
"email": email
|
|
});
|
|
await showToast({ message: 'Check your email for the login link!' });
|
|
} catch (e: any) {
|
|
await showToast({ message: e.error_description || e.message , duration: 5000});
|
|
} finally {
|
|
await hideLoading();
|
|
}
|
|
};
|
|
return (
|
|
<IonPage>
|
|
<IonHeader>
|
|
<IonToolbar>
|
|
<IonTitle>Login</IonTitle>
|
|
</IonToolbar>
|
|
</IonHeader>
|
|
|
|
<IonContent>
|
|
<div className="ion-padding">
|
|
<h1>Supabase + Ionic React</h1>
|
|
<p>Sign in via magic link with your email below</p>
|
|
</div>
|
|
<IonList inset={true}>
|
|
<form onSubmit={handleLogin}>
|
|
<IonItem>
|
|
<IonLabel position="stacked">Email</IonLabel>
|
|
<IonInput
|
|
value={email}
|
|
name="email"
|
|
onIonChange={(e) => setEmail(e.detail.value ?? '')}
|
|
type="email"
|
|
></IonInput>
|
|
</IonItem>
|
|
<div className="ion-text-center">
|
|
<IonButton type="submit" fill="clear">
|
|
Login
|
|
</IonButton>
|
|
</div>
|
|
</form>
|
|
</IonList>
|
|
</IonContent>
|
|
</IonPage>
|
|
);
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### Account page
|
|
|
|
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.tsx`.
|
|
|
|
<$CodeTabs>
|
|
|
|
```jsx name=src/pages/Account.tsx
|
|
import {
|
|
IonButton,
|
|
IonContent,
|
|
IonHeader,
|
|
IonInput,
|
|
IonItem,
|
|
IonLabel,
|
|
IonPage,
|
|
IonTitle,
|
|
IonToolbar,
|
|
useIonLoading,
|
|
useIonToast,
|
|
useIonRouter
|
|
} from '@ionic/react';
|
|
import { useEffect, useState } from 'react';
|
|
import { supabase } from '../supabaseClient';
|
|
import { Session } from '@supabase/supabase-js';
|
|
|
|
export function AccountPage() {
|
|
const [showLoading, hideLoading] = useIonLoading();
|
|
const [showToast] = useIonToast();
|
|
const [session, setSession] = useState<Session | null>(null)
|
|
const router = useIonRouter();
|
|
const [profile, setProfile] = useState({
|
|
username: '',
|
|
website: '',
|
|
avatar_url: '',
|
|
});
|
|
|
|
useEffect(() => {
|
|
const getSession = async () => {
|
|
setSession(await supabase.auth.getSession().then((res) => res.data.session))
|
|
}
|
|
getSession()
|
|
supabase.auth.onAuthStateChange((_event, session) => {
|
|
setSession(session)
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
getProfile();
|
|
}, [session]);
|
|
const getProfile = async () => {
|
|
console.log('get');
|
|
await showLoading();
|
|
try {
|
|
const user = await supabase.auth.getUser();
|
|
const { data, error, status } = await supabase
|
|
.from('profiles')
|
|
.select(`username, website, avatar_url`)
|
|
.eq('id', user!.data.user?.id)
|
|
.single();
|
|
|
|
if (error && status !== 406) {
|
|
throw error;
|
|
}
|
|
|
|
if (data) {
|
|
setProfile({
|
|
username: data.username,
|
|
website: data.website,
|
|
avatar_url: data.avatar_url,
|
|
});
|
|
}
|
|
} catch (error: any) {
|
|
showToast({ message: error.message, duration: 5000 });
|
|
} finally {
|
|
await hideLoading();
|
|
}
|
|
};
|
|
const signOut = async () => {
|
|
await supabase.auth.signOut();
|
|
router.push('/', 'forward', 'replace');
|
|
}
|
|
const updateProfile = async (e?: any, avatar_url: string = '') => {
|
|
e?.preventDefault();
|
|
|
|
console.log('update ');
|
|
await showLoading();
|
|
|
|
try {
|
|
const user = await supabase.auth.getUser();
|
|
|
|
const updates = {
|
|
id: user!.data.user?.id,
|
|
...profile,
|
|
avatar_url: avatar_url,
|
|
updated_at: new Date(),
|
|
};
|
|
|
|
const { error } = await supabase.from('profiles').upsert(updates);
|
|
|
|
if (error) {
|
|
throw error;
|
|
}
|
|
} catch (error: any) {
|
|
showToast({ message: error.message, duration: 5000 });
|
|
} finally {
|
|
await hideLoading();
|
|
}
|
|
};
|
|
return (
|
|
<IonPage>
|
|
<IonHeader>
|
|
<IonToolbar>
|
|
<IonTitle>Account</IonTitle>
|
|
</IonToolbar>
|
|
</IonHeader>
|
|
|
|
<IonContent>
|
|
<form onSubmit={updateProfile}>
|
|
<IonItem>
|
|
<IonLabel>
|
|
<p>Email</p>
|
|
<p>{session?.user?.email}</p>
|
|
</IonLabel>
|
|
</IonItem>
|
|
|
|
<IonItem>
|
|
<IonLabel position="stacked">Name</IonLabel>
|
|
<IonInput
|
|
type="text"
|
|
name="username"
|
|
value={profile.username}
|
|
onIonChange={(e) =>
|
|
setProfile({ ...profile, username: e.detail.value ?? '' })
|
|
}
|
|
></IonInput>
|
|
</IonItem>
|
|
|
|
<IonItem>
|
|
<IonLabel position="stacked">Website</IonLabel>
|
|
<IonInput
|
|
type="url"
|
|
name="website"
|
|
value={profile.website}
|
|
onIonChange={(e) =>
|
|
setProfile({ ...profile, website: e.detail.value ?? '' })
|
|
}
|
|
></IonInput>
|
|
</IonItem>
|
|
<div className="ion-text-center">
|
|
<IonButton fill="clear" type="submit">
|
|
Update Profile
|
|
</IonButton>
|
|
</div>
|
|
</form>
|
|
|
|
<div className="ion-text-center">
|
|
<IonButton fill="clear" onClick={signOut}>
|
|
Log Out
|
|
</IonButton>
|
|
</div>
|
|
</IonContent>
|
|
</IonPage>
|
|
);
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### Launch!
|
|
|
|
Now that we have all the components in place, let's update `App.tsx`:
|
|
|
|
<$CodeTabs>
|
|
|
|
```jsx name=src/App.tsx
|
|
import { Redirect, Route } from 'react-router-dom'
|
|
import { IonApp, IonRouterOutlet, setupIonicReact } from '@ionic/react'
|
|
import { IonReactRouter } from '@ionic/react-router'
|
|
import { supabase } from './supabaseClient'
|
|
|
|
import '@ionic/react/css/ionic.bundle.css'
|
|
|
|
/* Theme variables */
|
|
import './theme/variables.css'
|
|
import { LoginPage } from './pages/Login'
|
|
import { AccountPage } from './pages/Account'
|
|
import { useEffect, useState } from 'react'
|
|
import { Session } from '@supabase/supabase-js'
|
|
|
|
setupIonicReact()
|
|
|
|
const App: React.FC = () => {
|
|
const [session, setSession] = useState<Session | null>(null)
|
|
useEffect(() => {
|
|
const getSession = async () => {
|
|
setSession(await supabase.auth.getSession().then((res) => res.data.session))
|
|
}
|
|
getSession()
|
|
supabase.auth.onAuthStateChange((_event, session) => {
|
|
setSession(session)
|
|
})
|
|
}, [])
|
|
return (
|
|
<IonApp>
|
|
<IonReactRouter>
|
|
<IonRouterOutlet>
|
|
<Route
|
|
exact
|
|
path="/"
|
|
render={() => {
|
|
return session ? <Redirect to="/account" /> : <LoginPage />
|
|
}}
|
|
/>
|
|
<Route exact path="/account">
|
|
<AccountPage />
|
|
</Route>
|
|
</IonRouterOutlet>
|
|
</IonReactRouter>
|
|
</IonApp>
|
|
)
|
|
}
|
|
|
|
export default App
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
Once that's done, run this in a terminal window:
|
|
|
|
```bash
|
|
ionic serve
|
|
```
|
|
|
|
And then open the browser to [localhost:3000](http://localhost:3000) 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
|
|
|
|
First install two packages in order to interact with the user's camera.
|
|
|
|
```bash
|
|
npm install @ionic/pwa-elements @capacitor/camera
|
|
```
|
|
|
|
[Capacitor](https://capacitorjs.com) is a cross platform native runtime from Ionic that enables web apps to be deployed through the app store and provides access to native device API.
|
|
|
|
Ionic PWA elements is a companion package that will polyfill certain browser APIs that provide no user interface with custom Ionic UI.
|
|
|
|
With those packages installed we can update our `index.tsx` to include an additional bootstrapping call for the Ionic PWA Elements.
|
|
|
|
<$CodeTabs>
|
|
|
|
```ts name=src/index.tsx
|
|
import React from 'react'
|
|
import ReactDOM from 'react-dom'
|
|
import App from './App'
|
|
import * as serviceWorkerRegistration from './serviceWorkerRegistration'
|
|
import reportWebVitals from './reportWebVitals'
|
|
|
|
import { defineCustomElements } from '@ionic/pwa-elements/loader'
|
|
defineCustomElements(window)
|
|
|
|
ReactDOM.render(
|
|
<React.StrictMode>
|
|
<App />
|
|
</React.StrictMode>,
|
|
document.getElementById('root')
|
|
)
|
|
|
|
serviceWorkerRegistration.unregister()
|
|
reportWebVitals()
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
Then create an `AvatarComponent`.
|
|
|
|
<$CodeTabs>
|
|
|
|
```jsx name=src/components/Avatar.tsx
|
|
import { IonIcon } from '@ionic/react';
|
|
import { person } from 'ionicons/icons';
|
|
import { Camera, CameraResultType } from '@capacitor/camera';
|
|
import { useEffect, useState } from 'react';
|
|
import { supabase } from '../supabaseClient';
|
|
import './Avatar.css'
|
|
export function Avatar({
|
|
url,
|
|
onUpload,
|
|
}: {
|
|
url: string;
|
|
onUpload: (e: any, file: string) => Promise<void>;
|
|
}) {
|
|
const [avatarUrl, setAvatarUrl] = useState<string | undefined>();
|
|
|
|
useEffect(() => {
|
|
if (url) {
|
|
downloadImage(url);
|
|
}
|
|
}, [url]);
|
|
const uploadAvatar = async () => {
|
|
try {
|
|
const photo = await Camera.getPhoto({
|
|
resultType: CameraResultType.DataUrl,
|
|
});
|
|
|
|
const file = await fetch(photo.dataUrl!)
|
|
.then((res) => res.blob())
|
|
.then(
|
|
(blob) =>
|
|
new File([blob], 'my-file', { type: `image/${photo.format}` })
|
|
);
|
|
|
|
const fileName = `${Math.random()}-${new Date().getTime()}.${
|
|
photo.format
|
|
}`;
|
|
const { error: uploadError } = await supabase.storage
|
|
.from('avatars')
|
|
.upload(fileName, file);
|
|
if (uploadError) {
|
|
throw uploadError;
|
|
}
|
|
onUpload(null, fileName);
|
|
} catch (error) {
|
|
console.log(error);
|
|
}
|
|
};
|
|
|
|
const downloadImage = async (path: string) => {
|
|
try {
|
|
const { data, error } = await supabase.storage
|
|
.from('avatars')
|
|
.download(path);
|
|
if (error) {
|
|
throw error;
|
|
}
|
|
const url = URL.createObjectURL(data!);
|
|
setAvatarUrl(url);
|
|
} catch (error: any) {
|
|
console.log('Error downloading image: ', error.message);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="avatar">
|
|
<div className="avatar_wrapper" onClick={uploadAvatar}>
|
|
{avatarUrl ? (
|
|
<img src={avatarUrl} />
|
|
) : (
|
|
<IonIcon icon={person} className="no-avatar" />
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### Add the new widget
|
|
|
|
And then we can add the widget to the Account page:
|
|
|
|
<$CodeTabs>
|
|
|
|
```jsx name=src/pages/Account.tsx
|
|
// Import the new component
|
|
|
|
import { Avatar } from '../components/Avatar';
|
|
|
|
// ...
|
|
return (
|
|
<IonPage>
|
|
<IonHeader>
|
|
<IonToolbar>
|
|
<IonTitle>Account</IonTitle>
|
|
</IonToolbar>
|
|
</IonHeader>
|
|
|
|
<IonContent>
|
|
<Avatar url={profile.avatar_url} onUpload={updateProfile}></Avatar>
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
At this stage you have a fully functional application!
|