Signup flow + switch to serverSupabaseUser in context (fixes token refresh issue)+ lib upgrades for nuxt/supabase and nuxt/trpc

This commit is contained in:
Michael Dausmann
2023-04-01 16:53:47 +11:00
parent f08f02851e
commit 5b2fe2f6c9
13 changed files with 604 additions and 407 deletions

View File

@@ -31,6 +31,12 @@ pnpm install --shamefully-hoist
## Development Server ## Development Server
Start the Stripe thingy
```bash
stripe listen --forward-to localhost:3000/webhook
```
Start the development server on http://localhost:3000 Start the development server on http://localhost:3000
```bash ```bash
@@ -77,7 +83,7 @@ npm run dev -- -o
## Setup Supabase ## Setup Supabase
To setup supabase and middleware, loosely follow instructions from https://www.youtube.com/watch?v=IcaL1RfnU44 To setup supabase and middleware, loosely follow instructions from https://www.youtube.com/watch?v=IcaL1RfnU44
remember to update email template
Supabase - new account (free tier), used github oath for supabase account Supabase - new account (free tier), used github oath for supabase account
``` ```
@@ -139,8 +145,8 @@ I set up a Stripe account with a couple of 'Products' with a single price each t
-- add a pricing page....should be the default redirect from signup if the user has no active plan.. not sure whether to use a 'blank' plan or make plan nullable (basic pricing page is done - decided on 'no plan' plan) -- add a pricing page....should be the default redirect from signup if the user has no active plan.. not sure whether to use a 'blank' plan or make plan nullable (basic pricing page is done - decided on 'no plan' plan)
-- figure out what to do with Plan Name. Could add Plan Name to account record and copy over at time of account creation or updation. could pull from the Plan record for display.... but makes it difficult to change... should be loosely coupled, maybe use first approach (done) -- figure out what to do with Plan Name. Could add Plan Name to account record and copy over at time of account creation or updation. could pull from the Plan record for display.... but makes it difficult to change... should be loosely coupled, maybe use first approach (done)
-- figure out when/how plan changes.. is it triggered by webhook? (Done, webhook looks up product info on plan record and updates plan info) -- figure out when/how plan changes.. is it triggered by webhook? (Done, webhook looks up product info on plan record and updates plan info)
-- Plan info is all over the place... product id is on the plan record in the db, pricing id's are on the pricing page template. would it be too crazy to have an admin page to administer pricing and plan/product info? -- Plan info is all over the place... product id is on the plan record in the db, pricing id's are on the pricing page template. would it be too crazy to have an admin page to administer pricing and plan/product info? (scratch, current system works ok)
-- What to do with pricing page? should probably change depending on current account information i.e. buttons say 'upgrade' for plans > current and maybe 'downgrade' for plans < current? -- What to do with pricing page? should probably change depending on current account information i.e. buttons say 'upgrade' for plans > current and maybe 'downgrade' for plans < current? (Add an 'order' to the plan... basically an integer indicating how 'good' the plan is so that if your current account plan order (yes it's also copied onto the account), is lower than the plan on the pricing page, it says 'upgrade', otherwise 'downgrade'... on second thought, maybe just use plan name and if it's not == to your current plan, say 'switch to plan')
# Admin Functions Scenario (shitty test) # Admin Functions Scenario (shitty test)
Pre-condition Pre-condition
User 3 (encumbent id=3) - Owner of own single user account. Admin of Team account User 3 (encumbent id=3) - Owner of own single user account. Admin of Team account

View File

@@ -1,5 +1,7 @@
<template> <template>
<div> <div>
<h3>Nuxt 3 Boilerplate - AppFooter</h3> <hr/>
<span><NuxtLink to="/terms">Terms Of Service</NuxtLink></span>
<span>&nbsp;|&nbsp;<NuxtLink to="/privacy">Privacy</NuxtLink></span>
</div> </div>
</template> </template>

View File

@@ -23,15 +23,15 @@
<template> <template>
<div> <div>
<h3>Nuxt 3 Boilerplate - AppHeader</h3> <h3>Nuxt 3 Boilerplate - To the Moon!</h3>
<!-- logged in & sign out -->
<div v-if="user">logged in as: {{ user.email }}: <button @click="signout()">Sign Out</button></div>
<div v-if="!user">Not Logged in</div>
<div> <div>
<NuxtLink to="/">Boilerplate</NuxtLink>&nbsp; <span v-if="!user"><NuxtLink to="/">Nuxt 3 Boilerplate</NuxtLink>&nbsp;|&nbsp;</span>
|&nbsp;<NuxtLink to="/dashboard">Dashboard</NuxtLink>&nbsp; <span><NuxtLink to="/pricing">Pricing</NuxtLink></span>
|&nbsp;<NuxtLink to="/pricing">Pricing</NuxtLink>&nbsp; <span v-if="user">&nbsp;|&nbsp;<NuxtLink to="/dashboard">Dashboard</NuxtLink></span>
|&nbsp;<NuxtLink to="/account">Account</NuxtLink> <span v-if="user">&nbsp;|&nbsp;<NuxtLink to="/account">Account</NuxtLink></span>
<span v-if="!user">&nbsp;|&nbsp;<NuxtLink to="/signin">Sign In</NuxtLink></span>
<span v-if="user">&nbsp;|&nbsp;<a href="#" @click.prevent="signout()">Sign out</a></span>
<span v-if="user">&nbsp;|&nbsp;logged in as: {{ user.email }}</span>
</div> </div>
<!-- Account Switching --> <!-- Account Switching -->

View File

@@ -142,7 +142,7 @@ export default class UserAccountService {
return prisma_client.membership.create({ return prisma_client.membership.create({
data: { data: {
user_id: user_id, user_id: user_id,
account_id: account_id, account_id,
access: ACCOUNT_ACCESS.READ_ONLY access: ACCOUNT_ACCESS.READ_ONLY
}, },
include: { include: {

View File

@@ -12,4 +12,21 @@ export class UtilService {
if (error instanceof Error) return error.message if (error instanceof Error) return error.message
return String(error) return String(error)
} }
public static circleSafeStringify(obj: any) {
let cache: any[] = [];
let str = JSON.stringify(obj, function(key, value) {
if (typeof value === "object" && value !== null) {
if (cache.indexOf(value) !== -1) {
// Circular reference found, discard key
return;
}
// Store value in our collection
cache.push(value);
}
return value;
});
cache = []; // reset the cache
return str;
}
} }

824
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
"devDependencies": { "devDependencies": {
"@nuxtjs/supabase": "^0.3.0", "@nuxtjs/supabase": "^0.3.1",
"@prisma/client": "^4.9.0", "@prisma/client": "^4.9.0",
"nuxt": "^3.1.1", "nuxt": "^3.1.1",
"prisma": "^4.9.0" "prisma": "^4.9.0"
@@ -20,7 +20,7 @@
"pinia": "^2.0.30", "pinia": "^2.0.30",
"stripe": "^11.12.0", "stripe": "^11.12.0",
"superjson": "^1.12.2", "superjson": "^1.12.2",
"trpc-nuxt": "^0.5.0", "trpc-nuxt": "^0.8.0",
"zod": "^3.20.2" "zod": "^3.20.2"
}, },
"overrides": { "overrides": {

View File

@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
const user = useSupabaseUser() const user = useSupabaseUser()
const supabase = useSupabaseAuthClient();
watchEffect(() => { watchEffect(() => {
if (user.value) { if (user.value) {
navigateTo('/dashboard', {replace: true}) navigateTo('/dashboard', {replace: true})
@@ -10,6 +9,6 @@
<template> <template>
<div> <div>
<h3>Index</h3> <h3>Index</h3>
<button @click="supabase.auth.signInWithOAuth({provider: 'google'})">Connect with Google</button> Nuxt 3 (SAAS) Boilerplate is very nice, why don't you <NuxtLink to="/signup">Get Started</NuxtLink>
</div> </div>
</template> </template>

View File

@@ -9,25 +9,42 @@
<template> <template>
<div> <div>
<h3>Pricing</h3> <h3>Pricing</h3>
<p>Current Plan: {{ activeMembership?.account.plan_name }}</p>
<div> <div>
<label for="submit">Free Trial (1 Month)</label> <label for="submit">Free Trial (1 Month)</label>
<NuxtLink to="/dashboard">Continue to Dashboard</NuxtLink> <ul>
<li>10 Notes</li>
<li>Single User</li>
</ul>
<NuxtLink v-if="activeMembership && (activeMembership?.account.plan_name === 'Free Trial')" to="/dashboard">&nbsp;Continue to Dashboard</NuxtLink>
</div> </div>
<form action="/create-checkout-session" method="POST"> <form action="/create-checkout-session" method="POST">
<label for="submit">Individual Plan, Normal Price</label> <label for="submit">Individual Plan, Normal Price</label>
<ul>
<li>100 Notes</li>
<li>Single User</li>
</ul>
<input type="hidden" name="price_id" value="price_1MpOiwJfLn4RhYiLqfy6U8ZR" /> <input type="hidden" name="price_id" value="price_1MpOiwJfLn4RhYiLqfy6U8ZR" />
<input type="hidden" name="account_id" :value="activeMembership?.account_id" /> <input type="hidden" name="account_id" :value="activeMembership?.account_id" />
<button type="submit" :disabled="!activeMembership || (activeMembership.access !== ACCOUNT_ACCESS.OWNER && activeMembership.access !== ACCOUNT_ACCESS.ADMIN)">Checkout</button> <span v-if="!activeMembership">&nbsp;<NuxtLink to="/signup">Sign Up</NuxtLink>&nbsp;</span>
<button type="submit" v-if="activeMembership && (activeMembership.access === ACCOUNT_ACCESS.OWNER || activeMembership.access !== ACCOUNT_ACCESS.ADMIN) && (activeMembership?.account.plan_name !== 'Individual Plan')">Checkout</button>
</form> </form>
<form action="/create-checkout-session" method="POST"> <form action="/create-checkout-session" method="POST">
<label for="submit">Team Plan, Normal Price</label> <label for="submit">Team Plan, Normal Price</label>
<ul>
<li>200 Notes</li>
<li>Up to 10 Team Members</li>
</ul>
<input type="hidden" name="price_id" value="price_1MpOjtJfLn4RhYiLsjzAso90" /> <input type="hidden" name="price_id" value="price_1MpOjtJfLn4RhYiLsjzAso90" />
<input type="hidden" name="account_id" :value="activeMembership?.account_id" /> <input type="hidden" name="account_id" :value="activeMembership?.account_id" />
<button type="submit" :disabled="!activeMembership || (activeMembership.access !== ACCOUNT_ACCESS.OWNER && activeMembership.access !== ACCOUNT_ACCESS.ADMIN)">Checkout</button> <span v-if="!activeMembership">&nbsp;<NuxtLink to="/signup">Sign Up</NuxtLink>&nbsp;</span>
<button type="submit" v-if="activeMembership && (activeMembership.access === ACCOUNT_ACCESS.OWNER || activeMembership.access === ACCOUNT_ACCESS.ADMIN) && (activeMembership?.account.plan_name !== 'Team Plan')">Checkout</button>
</form> </form>
</div> </div>
</template> </template>

42
pages/signin.vue Normal file
View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
const user = useSupabaseUser()
const supabase = useSupabaseAuthClient();
const loading = ref(false)
const email = ref('')
const handleOtpLogin = async () => {
try {
loading.value = true
const { error } = await supabase.auth.signInWithOtp({ email: email.value })
if (error) throw error
alert('Check your email for the login link!')
} catch (error) {
alert(error)
} finally {
loading.value = false
}
}
watchEffect(() => {
if (user.value) {
navigateTo('/dashboard', {replace: true})
}
})
</script>
<template>
<div>
<h3>Sign In</h3>
<form @submit.prevent="handleOtpLogin">
<label for="email">Email:</label>
<input class="inputField" type="email" id="email" placeholder="Your email" v-model="email" />
<p>By signing in, I agree to the <NuxtLink to="/privacy">Privacy Statement</NuxtLink> and <NuxtLink to="/terms">Terms of Service</NuxtLink>.</p>
<button type="submit" :disabled="loading">Sign In using Magic Link</button>
</form>
<p>or sign in with</p>
<button @click="supabase.auth.signInWithOAuth({provider: 'google'})">Google</button>
</div>
</template>

42
pages/signup.vue Normal file
View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
const user = useSupabaseUser()
const supabase = useSupabaseAuthClient();
const loading = ref(false)
const email = ref('')
const handleOtpLogin = async () => {
try {
loading.value = true
const { error } = await supabase.auth.signInWithOtp({ email: email.value })
if (error) throw error
alert('Check your email for the login link!')
} catch (error) {
alert(error)
} finally {
loading.value = false
}
}
watchEffect(() => {
if (user.value) {
navigateTo('/dashboard', {replace: true})
}
})
</script>
<template>
<div>
<h3>Sign Up</h3>
<form @submit.prevent="handleOtpLogin">
<label for="email">Email:</label>
<input class="inputField" type="email" id="email" placeholder="Your email" v-model="email" />
<p>By proceeding, I agree to the <NuxtLink to="/privacy">Privacy Statement</NuxtLink> and <NuxtLink to="/terms">Terms of Service</NuxtLink>.</p>
<button type="submit" :disabled="loading">Sign Up using Magic Link</button>
</form>
<p>or sign up with</p>
<button @click="supabase.auth.signInWithOAuth({provider: 'google'})">Google</button>
</div>
</template>

View File

@@ -18,6 +18,6 @@ try{
<span v-if="!customer.deleted">We appreciate your business {{customer.name}}!</span> <span v-if="!customer.deleted">We appreciate your business {{customer.name}}!</span>
<span v-if="customer.deleted">It appears your stripe customer information has been deleted!</span> <span v-if="customer.deleted">It appears your stripe customer information has been deleted!</span>
</p> </p>
<p><NuxtLink to="/dashboard">To Your Dashboard</NuxtLink></p> <p>Checkout our reasonable <NuxtLink to="/pricing">Pricing</NuxtLink></p>
</div> </div>
</template> </template>

View File

@@ -1,36 +1,30 @@
import { inferAsyncReturnType, TRPCError } from '@trpc/server' import { inferAsyncReturnType, TRPCError } from '@trpc/server'
import { H3Event } from 'h3'; import { H3Event } from 'h3';
import { serverSupabaseClient } from '#supabase/server'; import { serverSupabaseUser } from '#supabase/server'
import SupabaseClient from '@supabase/supabase-js/dist/module/SupabaseClient';
import { User } from '@supabase/supabase-js'; import { User } from '@supabase/supabase-js';
import UserAccountService, { FullDBUser } from '~~/lib/services/user.account.service'; import UserAccountService, { FullDBUser } from '~~/lib/services/user.account.service';
let supabase: SupabaseClient | undefined
export async function createContext(event: H3Event){ export async function createContext(event: H3Event){
let user: User | null = null; let user: User | null = null;
let dbUser: FullDBUser | null = null; let dbUser: FullDBUser | null = null;
if (!supabase) {
supabase = serverSupabaseClient(event)
}
if (!user) { if (!user) {
({data: { user }} = await supabase.auth.getUser()); user = await serverSupabaseUser(event);
} }
if (!dbUser && user) { if (!dbUser && user) {
const userService = new UserAccountService(); const userService = new UserAccountService();
dbUser = await userService.getFullUserBySupabaseId(user.id); dbUser = await userService.getFullUserBySupabaseId(user.id);
if (!dbUser && user) { if (!dbUser && user) {
dbUser = await userService.createUser( user.id, user.user_metadata.full_name, user.email?user.email:"no@email.supplied" ); dbUser = await userService.createUser(user.id, user.user_metadata.full_name?user.user_metadata.full_name:"no name supplied", user.email?user.email:"no@email.supplied" );
console.log(`\n Created user \n ${JSON.stringify(dbUser)}\n`); console.log(`\n Created DB User \n ${JSON.stringify(dbUser)}\n`);
} }
} }
if(!user || !dbUser) { if(!user || !dbUser) {
throw new TRPCError({ throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR', code: 'INTERNAL_SERVER_ERROR',
message: `Unable to fetch user data, please try again later. Missing ->[supabase:${(!supabase)},user:${(!user)},dbUser:${(!dbUser)}]`, message: `Unable to fetch user data, please try again later. Missing ->[user:${(!user)},dbUser:${(!dbUser)}]`,
}); });
} }