Main auth and account flows now tested and working

This commit is contained in:
Michael Dausmann
2023-04-25 15:01:23 +10:00
parent 577e79478e
commit 5d21a5731b
13 changed files with 192 additions and 50 deletions

14
assets/images/avatar.svg Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns:serif="http://www.serif.com/"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1291.116 1638.771"
enable-background="new 0 0 1291.116 1638.771" xml:space="preserve">
<g id="status">
<path id="path3940-5" d="M959.98,769.382C873.16,846.125,770.543,880.92,645.557,880.92s-228.083-35.757-314.903-112.5
c-136.539,44.711-299.819,158.492-300,357.211l-0.48,370.673c-0.089,68.183,54.892,123.081,123.077,123.081h984.614
c68.185,0,123.077-54.894,123.077-123.081v-369.23c0-170.673-135.669-314.558-300.961-357.692L959.98,769.382L959.98,769.382z"/>
<path id="path3942-6" d="M1014.788,388.615c0,203.92-165.31,369.23-369.23,369.23s-369.23-165.311-369.23-369.23
s165.31-369.23,369.23-369.23S1014.788,184.696,1014.788,388.615z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 971 B

View File

@@ -45,8 +45,8 @@
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-circle avatar"> <label tabindex="0" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full"> <div class="w-10 rounded-full">
<img v-if="user.user_metadata.avatar_url" :src="user.user_metadata.avatar_url" />
<img :src="user.user_metadata.avatar_url" /> <img v-else src="~/assets/images/avatar.svg"/>
</div> </div>
</label> </label>
<ul tabindex="0" class="mt-3 p-2 shadow menu menu-compact dropdown-content bg-base-100 rounded-box w-52"> <ul tabindex="0" class="mt-3 p-2 shadow menu menu-compact dropdown-content bg-base-100 rounded-box w-52">

View File

@@ -181,7 +181,7 @@ export default class AccountService {
// User must already be an ADMIN for the Account // User must already be an ADMIN for the Account
// Existing OWNER memberships are downgraded to ADMIN // Existing OWNER memberships are downgraded to ADMIN
// In future, some sort of Billing/Stripe tie in here e.g. changing email details on the Account, not sure. // In future, some sort of Billing/Stripe tie in here e.g. changing email details on the Account, not sure.
async claimOwnershipOfAccount(user_id: number, account_id: number) { async claimOwnershipOfAccount(user_id: number, account_id: number): Promise<MembershipWithUser[]> {
const membership = await prisma_client.membership.findUniqueOrThrow({ const membership = await prisma_client.membership.findUniqueOrThrow({
where: { where: {
user_id_account_id: { user_id_account_id: {
@@ -192,7 +192,7 @@ export default class AccountService {
}); });
if (membership.access === ACCOUNT_ACCESS.OWNER) { if (membership.access === ACCOUNT_ACCESS.OWNER) {
return; // already owner throw new Error('BADREQUEST: user is already owner');
} else if (membership.access !== ACCOUNT_ACCESS.ADMIN) { } else if (membership.access !== ACCOUNT_ACCESS.ADMIN) {
throw new Error('UNAUTHORISED: only Admins can claim ownership'); throw new Error('UNAUTHORISED: only Admins can claim ownership');
} }
@@ -219,7 +219,7 @@ export default class AccountService {
} }
// finally update the ADMIN member to OWNER // finally update the ADMIN member to OWNER
return prisma_client.membership.update({ await prisma_client.membership.update({
where: { where: {
user_id_account_id: { user_id_account_id: {
user_id: user_id, user_id: user_id,
@@ -229,9 +229,12 @@ export default class AccountService {
data: { data: {
access: ACCOUNT_ACCESS.OWNER, access: ACCOUNT_ACCESS.OWNER,
}, },
include: { });
account: true
} // return the full membership list because 2 members have changed.
return prisma_client.membership.findMany({
where: { account_id },
...membershipWithUser
}); });
} }

View File

@@ -60,9 +60,19 @@
<span>{{ activeMembership?.account.max_notes }}</span> <span>{{ activeMembership?.account.max_notes }}</span>
</div> </div>
<div class="flex gap-4 items-center">
<span class="font-bold w-32">Maximum Members:</span>
<span>{{ activeMembership?.account.max_members }}</span>
</div>
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
<span class="font-bold w-32">Access Level:</span> <span class="font-bold w-32">Access Level:</span>
<span class="bg-green-500 text-white font-semibold py-1 px-2 rounded-full">{{ activeMembership?.access }}</span> <span class="bg-green-500 text-white font-semibold py-1 px-2 rounded-full">{{ activeMembership?.access }}</span>
<button @click.prevent="accountStore.claimOwnershipOfAccount()"
v-if="activeMembership && activeMembership.access === ACCOUNT_ACCESS.ADMIN "
class="bg-orange-500 hover:bg-orange-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue">
Claim Ownership
</button>
</div> </div>
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">

View File

@@ -1,10 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { ACCOUNT_ACCESS } from '@prisma/client';
definePageMeta({ definePageMeta({
middleware: ['auth'], middleware: ['auth'],
}); });
const accountStore = useAccountStore();
const { activeMembership } = storeToRefs(accountStore)
const notesStore = useNotesStore(); const notesStore = useNotesStore();
const { notes } = storeToRefs(notesStore); // ensure the notes list is reactive const { notes } = storeToRefs(notesStore); // ensure the notes list is reactive
const newNoteText = ref('') const newNoteText = ref('')
@@ -15,6 +18,7 @@
} }
onMounted(async () => { onMounted(async () => {
await accountStore.init();
await notesStore.fetchNotesForCurrentUser(); await notesStore.fetchNotesForCurrentUser();
}); });
</script> </script>
@@ -25,9 +29,10 @@
</div> </div>
<div class="w-full max-w-md mb-8"> <div class="w-full max-w-md mb-8">
<div class="flex flex-row"> <div v-if="activeMembership && (activeMembership.access === ACCOUNT_ACCESS.READ_WRITE || activeMembership.access === ACCOUNT_ACCESS.ADMIN || activeMembership.access === ACCOUNT_ACCESS.OWNER)" class="flex flex-row">
<input v-model="newNoteText" type="text" class="w-full rounded-l-md py-2 px-4 border-gray-400 border-2 focus:outline-none focus:border-blue-500" placeholder="Add a note..."> <input v-model="newNoteText" type="text" class="w-full rounded-l-md py-2 px-4 border-gray-400 border-2 focus:outline-none focus:border-blue-500" placeholder="Add a note...">
<button @click.prevent="addNote()" type="button" class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-r-md focus:outline-none focus:shadow-outline-blue"> <button @click.prevent="addNote()" type="button"
class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-r-md focus:outline-none focus:shadow-outline-blue">
Add Add
</button> </button>
</div> </div>
@@ -36,7 +41,9 @@
<div class="w-full max-w-md"> <div class="w-full max-w-md">
<div v-for="note in notes" class="bg-white rounded-lg shadow-lg text-center px-6 py-8 md:mx-4 md:my-4"> <div v-for="note in notes" class="bg-white rounded-lg shadow-lg text-center px-6 py-8 md:mx-4 md:my-4">
<p class="text-gray-600 mb-4">{{ note.note_text }}</p> <p class="text-gray-600 mb-4">{{ note.note_text }}</p>
<button @click.prevent="notesStore.deleteNote(note.id)" class="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue"> <button @click.prevent="notesStore.deleteNote(note.id)"
v-if="activeMembership && (activeMembership.access === ACCOUNT_ACCESS.ADMIN || activeMembership.access === ACCOUNT_ACCESS.OWNER)"
class="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue">
Delete Delete
</button> </button>
</div> </div>

View File

@@ -6,12 +6,18 @@
const email = ref('') const email = ref('')
const password = ref('') const password = ref('')
const confirmPassword = ref('') const confirmPassword = ref('')
const signUpOk = ref(false)
const handleStandardSignup = async () => { const handleStandardSignup = async () => {
try { try {
loading.value = true loading.value = true
const { data, error } = await supabase.auth.signUp({ email: email.value, password: password.value }) const { data, error } = await supabase.auth.signUp({ email: email.value, password: password.value })
if (error) throw error if (error) {
throw error
}
else {
signUpOk.value = true;
}
} catch (error) { } catch (error) {
alert(error) alert(error)
} finally { } finally {
@@ -47,6 +53,8 @@
</div> </div>
<button :disabled="loading || password === '' || (confirmPassword !== password)" type="submit" <button :disabled="loading || password === '' || (confirmPassword !== password)" type="submit"
class="w-full py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700">Sign up</button> class="w-full py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700">Sign up</button>
<p v-if="signUpOk" class="mt-4 text-lg text-center">You have successfully signed up. Please check your email for a link to confirm your email address and proceed.</p>
</form> </form>
<p class="text-center">or</p> <p class="text-center">or</p>
<button @click="supabase.auth.signInWithOAuth({ provider: 'google' })" <button @click="supabase.auth.signInWithOAuth({ provider: 'google' })"

View File

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

View File

@@ -105,9 +105,9 @@ export const accountRouter = router({
claimOwnershipOfAccount: adminProcedure claimOwnershipOfAccount: adminProcedure
.mutation(async ({ ctx }) => { .mutation(async ({ ctx }) => {
const accountService = new AccountService(); const accountService = new AccountService();
const membership = await accountService.claimOwnershipOfAccount(ctx.dbUser!.id, ctx.activeAccountId!); const memberships = await accountService.claimOwnershipOfAccount(ctx.dbUser!.id, ctx.activeAccountId!);
return { return {
membership, memberships,
} }
}), }),
getAccountMembers: adminProcedure getAccountMembers: adminProcedure

View File

@@ -1,9 +1,9 @@
import NotesService from '~~/lib/services/notes.service'; import NotesService from '~~/lib/services/notes.service';
import { memberProcedure, protectedProcedure, publicProcedure, router } from '../trpc'; import { adminProcedure, memberProcedure, protectedProcedure, publicProcedure, readWriteProcedure, router } from '../trpc';
import { z } from 'zod'; import { z } from 'zod';
export const notesRouter = router({ export const notesRouter = router({
getForCurrentUser: memberProcedure getForActiveAccount: memberProcedure
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const notesService = new NotesService(); const notesService = new NotesService();
const notes = (ctx.activeAccountId)?await notesService.getNotesForAccountId(ctx.activeAccountId):[]; const notes = (ctx.activeAccountId)?await notesService.getNotesForAccountId(ctx.activeAccountId):[];
@@ -20,7 +20,7 @@ export const notesRouter = router({
note, note,
} }
}), }),
createNote: memberProcedure createNote: readWriteProcedure
.input(z.object({ note_text: z.string() })) .input(z.object({ note_text: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const notesService = new NotesService(); const notesService = new NotesService();
@@ -29,7 +29,7 @@ export const notesRouter = router({
note, note,
} }
}), }),
deleteNote: memberProcedure deleteNote: adminProcedure
.input(z.object({ note_id: z.number() })) .input(z.object({ note_id: z.number() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const notesService = new NotesService(); const notesService = new NotesService();

View File

@@ -30,37 +30,82 @@ const isAuthed = t.middleware(({ next, ctx }) => {
}); });
}); });
const isMemberForInputAccountId = t.middleware(({ next, rawInput, ctx }) => { // Yes, these functions do look very repetitive and could be refactored. If only I was smart enough to understand https://trpc.io/docs/server/procedures#reusable-base-procedures
const isMemberForActiveAccountId = t.middleware(({ next, ctx }) => {
if (!ctx.dbUser || !ctx.activeAccountId) { if (!ctx.dbUser || !ctx.activeAccountId) {
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
const activeMembership = ctx.dbUser.memberships.find(membership => membership.account_id == ctx.activeAccountId); const activeMembership = ctx.dbUser.memberships.find(membership => membership.account_id == ctx.activeAccountId);
if(!activeMembership || activeMembership.pending) {
if(!activeMembership) {
throw new TRPCError({ code: 'UNAUTHORIZED', message:`user is not a member of the active account` });
}
if(activeMembership.pending) {
throw new TRPCError({ code: 'UNAUTHORIZED', message:`membership ${activeMembership?.id} is not active` }); throw new TRPCError({ code: 'UNAUTHORIZED', message:`membership ${activeMembership?.id} is not active` });
} }
return next({ ctx }); return next({ ctx });
}); });
const isAdminForInputAccountId = t.middleware(({ next, rawInput, ctx }) => { const isReadWriteForActiveAccountId = t.middleware(({ next, ctx }) => {
if (!ctx.dbUser || !ctx.activeAccountId) { if (!ctx.dbUser || !ctx.activeAccountId) {
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
const activeMembership = ctx.dbUser.memberships.find(membership => membership.account_id == ctx.activeAccountId); const activeMembership = ctx.dbUser.memberships.find(membership => membership.account_id == ctx.activeAccountId);
if(!activeMembership || (activeMembership?.access !== ACCOUNT_ACCESS.ADMIN && activeMembership?.access !== ACCOUNT_ACCESS.OWNER)) {
throw new TRPCError({ code: 'UNAUTHORIZED', message:`activeMembership ${activeMembership?.id} is only ${activeMembership?.access}` }); if(!activeMembership) {
throw new TRPCError({ code: 'UNAUTHORIZED', message:`user is not a member of the active account` });
}
if(activeMembership.pending) {
throw new TRPCError({ code: 'UNAUTHORIZED', message:`membership ${activeMembership?.id} is not active` });
}
if(activeMembership?.access !== ACCOUNT_ACCESS.READ_WRITE && activeMembership?.access !== ACCOUNT_ACCESS.ADMIN && activeMembership?.access !== ACCOUNT_ACCESS.OWNER) {
throw new TRPCError({ code: 'UNAUTHORIZED', message:`activeMembership ${activeMembership?.id} has insufficient access (${activeMembership?.access})` });
} }
return next({ ctx }); return next({ ctx });
}); });
const isOwnerForInputAccountId = t.middleware(({ next, rawInput, ctx }) => { const isAdminForActiveAccountId = t.middleware(({ next, ctx }) => {
if (!ctx.dbUser || !ctx.activeAccountId) { if (!ctx.dbUser || !ctx.activeAccountId) {
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
const activeMembership = ctx.dbUser.memberships.find(membership => membership.account_id == ctx.activeAccountId); const activeMembership = ctx.dbUser.memberships.find(membership => membership.account_id == ctx.activeAccountId);
if(!activeMembership || activeMembership?.access !== ACCOUNT_ACCESS.OWNER) {
throw new TRPCError({ code: 'UNAUTHORIZED', message:`activeMembership ${activeMembership?.id} is only ${activeMembership?.access}` }); if(!activeMembership) {
throw new TRPCError({ code: 'UNAUTHORIZED', message:`user is not a member of the active account` });
}
if(activeMembership.pending) {
throw new TRPCError({ code: 'UNAUTHORIZED', message:`membership ${activeMembership?.id} is not active` });
}
if(activeMembership?.access !== ACCOUNT_ACCESS.ADMIN && activeMembership?.access !== ACCOUNT_ACCESS.OWNER) {
throw new TRPCError({ code: 'UNAUTHORIZED', message:`activeMembership ${activeMembership?.id} has insufficient access (${activeMembership?.access})` });
}
return next({ ctx });
});
const isOwnerForActiveAccountId = t.middleware(({ next, ctx }) => {
if (!ctx.dbUser || !ctx.activeAccountId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const activeMembership = ctx.dbUser.memberships.find(membership => membership.account_id == ctx.activeAccountId);
if(!activeMembership) {
throw new TRPCError({ code: 'UNAUTHORIZED', message:`user is not a member of the active account` });
}
if(activeMembership.pending) {
throw new TRPCError({ code: 'UNAUTHORIZED', message:`membership ${activeMembership?.id} is not active` });
}
if(activeMembership?.access !== ACCOUNT_ACCESS.OWNER) {
throw new TRPCError({ code: 'UNAUTHORIZED', message:`activeMembership ${activeMembership?.id} has insufficient access (${activeMembership?.access})` });
} }
return next({ ctx }); return next({ ctx });
@@ -71,8 +116,9 @@ const isOwnerForInputAccountId = t.middleware(({ next, rawInput, ctx }) => {
**/ **/
export const publicProcedure = t.procedure; export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed); export const protectedProcedure = t.procedure.use(isAuthed);
export const memberProcedure = protectedProcedure.use(isMemberForInputAccountId); export const memberProcedure = protectedProcedure.use(isMemberForActiveAccountId);
export const adminProcedure = protectedProcedure.use(isAdminForInputAccountId); export const readWriteProcedure = protectedProcedure.use(isReadWriteForActiveAccountId);
export const ownerProcedure = protectedProcedure.use(isOwnerForInputAccountId); export const adminProcedure = protectedProcedure.use(isAdminForActiveAccountId);
export const ownerProcedure = protectedProcedure.use(isOwnerForActiveAccountId);
export const router = t.router; export const router = t.router;
export const middleware = t.middleware; export const middleware = t.middleware;

View File

@@ -140,17 +140,10 @@ export const useAccountStore = defineStore('account', {
}, },
async claimOwnershipOfAccount(){ async claimOwnershipOfAccount(){
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { membership } = await $client.account.claimOwnershipOfAccount.mutate(); const { memberships } = await $client.account.claimOwnershipOfAccount.mutate();
if(membership){ if(memberships){
if(this.activeMembership){ this.activeAccountMembers = memberships;
this.activeMembership.access = membership.access; this.activeMembership!.access = ACCOUNT_ACCESS.OWNER
}
for(const m of this.activeAccountMembers){
if(m.id === membership.id){
m.access = membership.access;
}
}
} }
} }
} }

View File

@@ -20,7 +20,7 @@ export const useNotesStore = defineStore('notes', () => {
async function fetchNotesForCurrentUser() { async function fetchNotesForCurrentUser() {
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { notes } = await $client.notes.getForCurrentUser.query(); const { notes } = await $client.notes.getForActiveAccount.query();
if(notes){ if(notes){
_notes.value = notes; _notes.value = notes;
} }

View File

@@ -1,10 +1,71 @@
# Manual test for Admin Functions Scenario # Manual test for Admin Functions Scenario
Pre-condition ## Pre-req
User 3 (encumbent id=3) - Owner of own single user account. Admin of Team account - Site configured for free plan
User 4 (noob id = 4) - Owner of own single user account. - Neither User1 or User2 are present in DB
## Main Flow (Happy Path)
This scenario covers most of the Site Auth and Account admin functions.
User 3... (User 1)
- joins user 4 to team account (expect user is a read only member of team account) - Front page - Get Started
- upgrades user 4 to owner (should fail) - Signup with google - should drop to dashboard
- upgrades user 4 to admin - Check account page via nav
- claims ownership of team account - Go to pricing page via nav
- Click on 'Subscribe' button under team account
- Fill in Credit card details and sub in Stripe - Should come back to Dashboard page (comes to success page but no customer info??)
- Add a Note or 2 in the Dashboard page - make it clear user1 has entered
- Check Account view - Should be OWNER of this account, max members should be updated to 10
- Update Team account Name using button
- Copy Join Link
- Signout
(User 2)
- Open Join Link - Should prompt for signup to new account name
- Signup with email/password - should drop to dashboard (some fucking bullshit error with signin + no avatar link + how the fuck to deal with non confirmed emails)
- Open join link again - should prompt to Join (Note, doing navigateTo and saving a returnURL seems to be difficult in Nuxt)
- Click Join - should redirect to dashboard
- Check 'Switch to' accounts, team account should be (pending) and not clickable
- Sign out
(User 1)
- Front Page - Sign in - Note Signin page subtly different to signup page, no password conf and no 'if you proceed' warning
- Sign in with google - Should drop to dashboard page
- navigate to Account page
- Look at members, should show User 2 as 'Pending' with approve/reject buttons
- Click approve, should update user item in list and display 'Upgrade to read/write' and 'Delete'
(User 2)
- Signin -> Dashboard should now show notes but no 'Delete' or 'Add' buttons
(User 1)
- Signin -> Dashboard
- Navigate to Account Page
- Click 'Upgrade to read/write' - Should update user and now show 'Upgrade to Admin' button
- sign out
(User 2)
- Signin -> Dashboard - should see 'Add' button now
- Add a Note
- Sign Out
(User 1)
- Signin -> Dashboard
- Navigate to Account Page
- Click 'Upgrade to Admin' - Should see just the 'Delete' button now
- sign out
(User 2)
- Signin -> Dashboard - should now see 'Delete' button on notes
- Click on 'Delete' for an existing Note
- Go to Account Page - should now see 'Claim ownership' button next to access
- Click on 'Claim Ownership' - Button should dissappear and member list should be updated - Delete button should be visible against
User 1
- Click 'Delete' for user 1
- You are now king of the world
- navigate to Notes, verify you can see/crud notes on dashboard.
## Unchecked things
- Admin can approve pending membership
## Alternate Flow (Pricing First)
- Front Page - Pricing
- get started for free (TODO - should be button to go to signup under free plan)