mirror of
https://github.com/supabase/supabase.git
synced 2026-07-04 16:24:25 +08:00
* initial app without storage * code tab typo * format --------- Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com>
622 lines
15 KiB
Plaintext
622 lines
15 KiB
Plaintext
---
|
|
title: 'Build a User Management App with Ionic Vue'
|
|
description: 'Learn how to use Supabase in your Ionic Vue 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-vue).
|
|
|
|
</Admonition>
|
|
|
|
<$Partial path="project_setup.mdx" />
|
|
|
|
## Building the app
|
|
|
|
Let's start building the Vue app from scratch.
|
|
|
|
### Initialize an Ionic Vue app
|
|
|
|
We can use the [Ionic CLI](https://ionicframework.com/docs/cli) to initialize an app called `supabase-ionic-vue`:
|
|
|
|
```bash
|
|
npm install -g @ionic/cli
|
|
ionic start supabase-ionic-vue blank --type vue
|
|
cd supabase-ionic-vue
|
|
```
|
|
|
|
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_ANON_KEY=YOUR_SUPABASE_ANON_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/supabase.ts
|
|
import { createClient } from '@supabase/supabase-js';
|
|
|
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string;
|
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string;
|
|
|
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### Set up a login route
|
|
|
|
Let's set up a Vue component to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords.
|
|
|
|
<$CodeTabs>
|
|
|
|
```html name=/src/views/Login.vue
|
|
<template>
|
|
<ion-page>
|
|
<ion-header>
|
|
<ion-toolbar>
|
|
<ion-title>Login</ion-title>
|
|
</ion-toolbar>
|
|
</ion-header>
|
|
|
|
<ion-content>
|
|
<div class="ion-padding">
|
|
<h1>Supabase + Ionic Vue</h1>
|
|
<p>Sign in via magic link with your email below</p>
|
|
</div>
|
|
<ion-list inset="true">
|
|
<form @submit.prevent="handleLogin">
|
|
<ion-item>
|
|
<ion-label position="stacked">Email</ion-label>
|
|
<ion-input v-model="email" name="email" autocomplete type="email"></ion-input>
|
|
</ion-item>
|
|
<div class="ion-text-center">
|
|
<ion-button type="submit" fill="clear">Login</ion-button>
|
|
</div>
|
|
</form>
|
|
</ion-list>
|
|
<p>{{ email }}</p>
|
|
</ion-content>
|
|
</ion-page>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { supabase } from '../supabase'
|
|
import {
|
|
IonContent,
|
|
IonHeader,
|
|
IonPage,
|
|
IonTitle,
|
|
IonToolbar,
|
|
IonList,
|
|
IonItem,
|
|
IonLabel,
|
|
IonInput,
|
|
IonButton,
|
|
toastController,
|
|
loadingController,
|
|
} from '@ionic/vue'
|
|
import { defineComponent, ref } from 'vue'
|
|
|
|
export default defineComponent({
|
|
name: 'LoginPage',
|
|
components: {
|
|
IonContent,
|
|
IonHeader,
|
|
IonPage,
|
|
IonTitle,
|
|
IonToolbar,
|
|
IonList,
|
|
IonItem,
|
|
IonLabel,
|
|
IonInput,
|
|
IonButton,
|
|
},
|
|
setup() {
|
|
const email = ref('')
|
|
const handleLogin = async () => {
|
|
const loader = await loadingController.create({})
|
|
const toast = await toastController.create({ duration: 5000 })
|
|
|
|
try {
|
|
await loader.present()
|
|
const { error } = await supabase.auth.signInWithOtp({ email: email.value })
|
|
|
|
if (error) throw error
|
|
|
|
toast.message = 'Check your email for the login link!'
|
|
await toast.present()
|
|
} catch (error: any) {
|
|
toast.message = error.error_description || error.message
|
|
await toast.present()
|
|
} finally {
|
|
await loader.dismiss()
|
|
}
|
|
}
|
|
return { handleLogin, email }
|
|
},
|
|
})
|
|
</script>
|
|
```
|
|
|
|
</$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.vue`.
|
|
|
|
<$CodeTabs>
|
|
|
|
```html name=src/views/Account.vue
|
|
<template>
|
|
<ion-page>
|
|
<ion-header>
|
|
<ion-toolbar>
|
|
<ion-title>Account</ion-title>
|
|
</ion-toolbar>
|
|
</ion-header>
|
|
|
|
<ion-content>
|
|
<form @submit.prevent="updateProfile">
|
|
<ion-item>
|
|
<ion-label>
|
|
<p>Email</p>
|
|
<p>{{ user?.email }}</p>
|
|
</ion-label>
|
|
</ion-item>
|
|
|
|
<ion-item>
|
|
<ion-label position="stacked">Name</ion-label>
|
|
<ion-input type="text" v-model="profile.username" />
|
|
</ion-item>
|
|
|
|
<ion-item>
|
|
<ion-label position="stacked">Website</ion-label>
|
|
<ion-input type="url" v-model="profile.website" />
|
|
</ion-item>
|
|
|
|
<div class="ion-text-center">
|
|
<ion-button type="submit" fill="clear">Update Profile</ion-button>
|
|
</div>
|
|
</form>
|
|
|
|
<div class="ion-text-center">
|
|
<ion-button fill="clear" @click="signOut">Log Out</ion-button>
|
|
</div>
|
|
</ion-content>
|
|
</ion-page>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import {
|
|
IonPage,
|
|
IonHeader,
|
|
IonToolbar,
|
|
IonTitle,
|
|
IonContent,
|
|
IonItem,
|
|
IonLabel,
|
|
IonInput,
|
|
IonButton,
|
|
toastController,
|
|
loadingController,
|
|
} from '@ionic/vue'
|
|
import { defineComponent, onMounted, ref } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { supabase } from '@/supabase'
|
|
import type { User } from '@supabase/supabase-js'
|
|
|
|
export default defineComponent({
|
|
name: 'AccountPage',
|
|
components: {
|
|
IonPage,
|
|
IonHeader,
|
|
IonToolbar,
|
|
IonTitle,
|
|
IonContent,
|
|
IonItem,
|
|
IonLabel,
|
|
IonInput,
|
|
IonButton,
|
|
},
|
|
setup() {
|
|
const router = useRouter()
|
|
const user = ref<User | null>(null)
|
|
|
|
const profile = ref({
|
|
username: '',
|
|
website: '',
|
|
avatar_url: '',
|
|
})
|
|
|
|
const getProfile = async () => {
|
|
const loader = await loadingController.create()
|
|
const toast = await toastController.create({ duration: 5000 })
|
|
await loader.present()
|
|
|
|
try {
|
|
const { data, error, status } = await supabase
|
|
.from('profiles')
|
|
.select('username, website, avatar_url')
|
|
.eq('id', user.value?.id)
|
|
.single()
|
|
|
|
if (error && status !== 406) throw error
|
|
|
|
if (data) {
|
|
profile.value = {
|
|
username: data.username,
|
|
website: data.website,
|
|
avatar_url: data.avatar_url,
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
toast.message = error.message
|
|
await toast.present()
|
|
} finally {
|
|
await loader.dismiss()
|
|
}
|
|
}
|
|
|
|
const updateProfile = async () => {
|
|
const loader = await loadingController.create()
|
|
const toast = await toastController.create({ duration: 5000 })
|
|
await loader.present()
|
|
|
|
try {
|
|
const updates = {
|
|
id: user.value?.id,
|
|
...profile.value,
|
|
updated_at: new Date(),
|
|
}
|
|
|
|
const { error } = await supabase.from('profiles').upsert(updates, {
|
|
returning: 'minimal',
|
|
})
|
|
|
|
if (error) throw error
|
|
} catch (error: any) {
|
|
toast.message = error.message
|
|
await toast.present()
|
|
} finally {
|
|
await loader.dismiss()
|
|
}
|
|
}
|
|
|
|
const signOut = async () => {
|
|
const loader = await loadingController.create()
|
|
const toast = await toastController.create({ duration: 5000 })
|
|
await loader.present()
|
|
|
|
try {
|
|
const { error } = await supabase.auth.signOut()
|
|
if (error) throw error
|
|
router.push('/')
|
|
} catch (error: any) {
|
|
toast.message = error.message
|
|
await toast.present()
|
|
} finally {
|
|
await loader.dismiss()
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
const loader = await loadingController.create()
|
|
await loader.present()
|
|
|
|
const { data } = await supabase.auth.getSession()
|
|
user.value = data.session?.user ?? null
|
|
|
|
if (!user.value) {
|
|
router.push('/')
|
|
} else {
|
|
await getProfile()
|
|
}
|
|
|
|
await loader.dismiss()
|
|
})
|
|
|
|
return {
|
|
user,
|
|
profile,
|
|
updateProfile,
|
|
signOut,
|
|
}
|
|
},
|
|
})
|
|
</script>
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### Launch!
|
|
|
|
Now that we have all the components in place, let's update `App.vue` and our routes:
|
|
|
|
<$CodeTabs>
|
|
|
|
```ts name=src/router.index.ts
|
|
import { createRouter, createWebHistory } from '@ionic/vue-router'
|
|
import { RouteRecordRaw } from 'vue-router'
|
|
import LoginPage from '../views/Login.vue'
|
|
import AccountPage from '../views/Account.vue'
|
|
const routes: Array<RouteRecordRaw> = [
|
|
{
|
|
path: '/',
|
|
name: 'Login',
|
|
component: LoginPage,
|
|
},
|
|
{
|
|
path: '/account',
|
|
name: 'Account',
|
|
component: AccountPage,
|
|
},
|
|
]
|
|
|
|
const router = createRouter({
|
|
history: createWebHistory(import.meta.env.BASE_URL),
|
|
routes,
|
|
})
|
|
|
|
export default router
|
|
```
|
|
|
|
```html name=src/App.vue
|
|
<template>
|
|
<ion-app>
|
|
<ion-router-outlet />
|
|
</ion-app>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { IonApp, IonRouterOutlet, useIonRouter } from '@ionic/vue'
|
|
import { defineComponent, ref, onMounted } from 'vue'
|
|
import { supabase } from './supabase'
|
|
|
|
export default defineComponent({
|
|
name: 'App',
|
|
components: {
|
|
IonApp,
|
|
IonRouterOutlet,
|
|
},
|
|
setup() {
|
|
const router = useIonRouter()
|
|
const user = ref(null)
|
|
|
|
onMounted(() => {
|
|
supabase.auth
|
|
.getSession()
|
|
.then((resp) => {
|
|
user.value = resp.data.session?.user ?? null
|
|
})
|
|
.catch((err) => {
|
|
console.log('Error fetching session', err)
|
|
})
|
|
|
|
supabase.auth.onAuthStateChange((_event, session) => {
|
|
user.value = session?.user ?? null
|
|
})
|
|
})
|
|
|
|
return { user }
|
|
},
|
|
})
|
|
</script>
|
|
```
|
|
|
|
</$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 `main.ts` to include an additional bootstrapping call for the Ionic PWA Elements.
|
|
|
|
<$CodeTabs>
|
|
|
|
```ts name=src/main.tsx
|
|
import { createApp } from 'vue'
|
|
import App from './App.vue'
|
|
import router from './router'
|
|
|
|
import { IonicVue } from '@ionic/vue'
|
|
/* Core CSS required for Ionic components to work properly */
|
|
import '@ionic/vue/css/ionic.bundle.css'
|
|
|
|
/* Theme variables */
|
|
import './theme/variables.css'
|
|
|
|
import { defineCustomElements } from '@ionic/pwa-elements/loader'
|
|
defineCustomElements(window)
|
|
const app = createApp(App).use(IonicVue).use(router)
|
|
|
|
router.isReady().then(() => {
|
|
app.mount('#app')
|
|
})
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
Then create an `AvatarComponent`.
|
|
|
|
<$CodeTabs>
|
|
|
|
```html name=src/components/Avatar.vue
|
|
<template>
|
|
<div class="avatar">
|
|
<div class="avatar_wrapper" @click="uploadAvatar">
|
|
<img v-if="avatarUrl" :src="avatarUrl" />
|
|
<ion-icon v-else name="person" class="no-avatar"></ion-icon>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { ref, toRefs, watch, defineComponent } from 'vue'
|
|
import { supabase } from '../supabase'
|
|
import { Camera, CameraResultType } from '@capacitor/camera'
|
|
import { IonIcon } from '@ionic/vue'
|
|
import { person } from 'ionicons/icons'
|
|
export default defineComponent({
|
|
name: 'AppAvatar',
|
|
props: { path: String },
|
|
emits: ['upload', 'update:path'],
|
|
components: { IonIcon },
|
|
setup(prop, { emit }) {
|
|
const { path } = toRefs(prop)
|
|
const avatarUrl = ref('')
|
|
|
|
const downloadImage = async () => {
|
|
try {
|
|
const { data, error } = await supabase.storage.from('avatars').download(path.value)
|
|
if (error) throw error
|
|
avatarUrl.value = URL.createObjectURL(data!)
|
|
} catch (error: any) {
|
|
console.error('Error downloading image: ', error.message)
|
|
}
|
|
}
|
|
|
|
const uploadAvatar = async () => {
|
|
try {
|
|
const photo = await Camera.getPhoto({
|
|
resultType: CameraResultType.DataUrl,
|
|
})
|
|
if (photo.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
|
|
}
|
|
emit('update:path', fileName)
|
|
emit('upload')
|
|
}
|
|
} catch (error) {
|
|
console.log(error)
|
|
}
|
|
}
|
|
|
|
watch(path, () => {
|
|
if (path.value) downloadImage()
|
|
})
|
|
|
|
return { avatarUrl, uploadAvatar, person }
|
|
},
|
|
})
|
|
</script>
|
|
<style>
|
|
.avatar {
|
|
display: block;
|
|
margin: auto;
|
|
min-height: 150px;
|
|
}
|
|
.avatar .avatar_wrapper {
|
|
margin: 16px auto 16px;
|
|
border-radius: 50%;
|
|
overflow: hidden;
|
|
height: 150px;
|
|
aspect-ratio: 1;
|
|
background: var(--ion-color-step-50);
|
|
border: thick solid var(--ion-color-step-200);
|
|
}
|
|
.avatar .avatar_wrapper:hover {
|
|
cursor: pointer;
|
|
}
|
|
.avatar .avatar_wrapper ion-icon.no-avatar {
|
|
width: 100%;
|
|
height: 115%;
|
|
}
|
|
.avatar img {
|
|
display: block;
|
|
object-fit: cover;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### Add the new widget
|
|
|
|
And then we can add the widget to the Account page:
|
|
|
|
<$CodeTabs>
|
|
|
|
```html name=src/views/Account.vue
|
|
<template>
|
|
<ion-page>
|
|
<ion-header>
|
|
<ion-toolbar>
|
|
<ion-title>Account</ion-title>
|
|
</ion-toolbar>
|
|
</ion-header>
|
|
|
|
<ion-content>
|
|
<avatar v-model:path="profile.avatar_url" @upload="updateProfile"></avatar>
|
|
...
|
|
</template>
|
|
<script lang="ts">
|
|
import Avatar from '../components/Avatar.vue';
|
|
export default defineComponent({
|
|
name: 'AccountPage',
|
|
components: {
|
|
Avatar,
|
|
....
|
|
}
|
|
|
|
</script>
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
At this stage you have a fully functional application!
|