Main auth and account flows now tested and working
This commit is contained in:
14
assets/images/avatar.svg
Normal file
14
assets/images/avatar.svg
Normal 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 |
@@ -45,8 +45,8 @@
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-circle avatar">
|
||||
<div class="w-10 rounded-full">
|
||||
|
||||
<img :src="user.user_metadata.avatar_url" />
|
||||
<img v-if="user.user_metadata.avatar_url" :src="user.user_metadata.avatar_url" />
|
||||
<img v-else src="~/assets/images/avatar.svg"/>
|
||||
</div>
|
||||
</label>
|
||||
<ul tabindex="0" class="mt-3 p-2 shadow menu menu-compact dropdown-content bg-base-100 rounded-box w-52">
|
||||
|
||||
@@ -181,7 +181,7 @@ export default class AccountService {
|
||||
// User must already be an ADMIN for the Account
|
||||
// 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.
|
||||
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({
|
||||
where: {
|
||||
user_id_account_id: {
|
||||
@@ -192,7 +192,7 @@ export default class AccountService {
|
||||
});
|
||||
|
||||
if (membership.access === ACCOUNT_ACCESS.OWNER) {
|
||||
return; // already owner
|
||||
throw new Error('BADREQUEST: user is already owner');
|
||||
} else if (membership.access !== ACCOUNT_ACCESS.ADMIN) {
|
||||
throw new Error('UNAUTHORISED: only Admins can claim ownership');
|
||||
}
|
||||
@@ -219,7 +219,7 @@ export default class AccountService {
|
||||
}
|
||||
|
||||
// finally update the ADMIN member to OWNER
|
||||
return prisma_client.membership.update({
|
||||
await prisma_client.membership.update({
|
||||
where: {
|
||||
user_id_account_id: {
|
||||
user_id: user_id,
|
||||
@@ -229,10 +229,13 @@ export default class AccountService {
|
||||
data: {
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
// Upgrade access of a membership. Cannot use this method to upgrade to or downgrade from OWNER access
|
||||
|
||||
@@ -60,9 +60,19 @@
|
||||
<span>{{ activeMembership?.account.max_notes }}</span>
|
||||
</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">
|
||||
<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>
|
||||
<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 class="flex gap-4 items-center">
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { ACCOUNT_ACCESS } from '@prisma/client';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth'],
|
||||
});
|
||||
|
||||
const accountStore = useAccountStore();
|
||||
const { activeMembership } = storeToRefs(accountStore)
|
||||
const notesStore = useNotesStore();
|
||||
const { notes } = storeToRefs(notesStore); // ensure the notes list is reactive
|
||||
const newNoteText = ref('')
|
||||
@@ -15,6 +18,7 @@
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await accountStore.init();
|
||||
await notesStore.fetchNotesForCurrentUser();
|
||||
});
|
||||
</script>
|
||||
@@ -25,9 +29,10 @@
|
||||
</div>
|
||||
|
||||
<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...">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@@ -36,7 +41,9 @@
|
||||
<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">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,12 +6,18 @@
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const signUpOk = ref(false)
|
||||
|
||||
const handleStandardSignup = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
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) {
|
||||
alert(error)
|
||||
} finally {
|
||||
@@ -47,6 +53,8 @@
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<p class="text-center">or</p>
|
||||
<button @click="supabase.auth.signInWithOAuth({ provider: 'google' })"
|
||||
|
||||
@@ -18,6 +18,6 @@ try{
|
||||
<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>
|
||||
</p>
|
||||
<p>Checkout our reasonable <NuxtLink to="/pricing">Pricing</NuxtLink></p>
|
||||
<p>Go to Your <NuxtLink to="/dashboard">Dashboard</NuxtLink></p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -105,9 +105,9 @@ export const accountRouter = router({
|
||||
claimOwnershipOfAccount: adminProcedure
|
||||
.mutation(async ({ ctx }) => {
|
||||
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 {
|
||||
membership,
|
||||
memberships,
|
||||
}
|
||||
}),
|
||||
getAccountMembers: adminProcedure
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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';
|
||||
|
||||
export const notesRouter = router({
|
||||
getForCurrentUser: memberProcedure
|
||||
getForActiveAccount: memberProcedure
|
||||
.query(async ({ ctx, input }) => {
|
||||
const notesService = new NotesService();
|
||||
const notes = (ctx.activeAccountId)?await notesService.getNotesForAccountId(ctx.activeAccountId):[];
|
||||
@@ -20,7 +20,7 @@ export const notesRouter = router({
|
||||
note,
|
||||
}
|
||||
}),
|
||||
createNote: memberProcedure
|
||||
createNote: readWriteProcedure
|
||||
.input(z.object({ note_text: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const notesService = new NotesService();
|
||||
@@ -29,7 +29,7 @@ export const notesRouter = router({
|
||||
note,
|
||||
}
|
||||
}),
|
||||
deleteNote: memberProcedure
|
||||
deleteNote: adminProcedure
|
||||
.input(z.object({ note_id: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const notesService = new NotesService();
|
||||
|
||||
@@ -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) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
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` });
|
||||
}
|
||||
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
const isAdminForInputAccountId = t.middleware(({ next, rawInput, ctx }) => {
|
||||
const isReadWriteForActiveAccountId = 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 || (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 });
|
||||
});
|
||||
|
||||
const isOwnerForInputAccountId = t.middleware(({ next, rawInput, ctx }) => {
|
||||
const isAdminForActiveAccountId = 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 || 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 });
|
||||
@@ -71,8 +116,9 @@ const isOwnerForInputAccountId = t.middleware(({ next, rawInput, ctx }) => {
|
||||
**/
|
||||
export const publicProcedure = t.procedure;
|
||||
export const protectedProcedure = t.procedure.use(isAuthed);
|
||||
export const memberProcedure = protectedProcedure.use(isMemberForInputAccountId);
|
||||
export const adminProcedure = protectedProcedure.use(isAdminForInputAccountId);
|
||||
export const ownerProcedure = protectedProcedure.use(isOwnerForInputAccountId);
|
||||
export const memberProcedure = protectedProcedure.use(isMemberForActiveAccountId);
|
||||
export const readWriteProcedure = protectedProcedure.use(isReadWriteForActiveAccountId);
|
||||
export const adminProcedure = protectedProcedure.use(isAdminForActiveAccountId);
|
||||
export const ownerProcedure = protectedProcedure.use(isOwnerForActiveAccountId);
|
||||
export const router = t.router;
|
||||
export const middleware = t.middleware;
|
||||
|
||||
@@ -140,17 +140,10 @@ export const useAccountStore = defineStore('account', {
|
||||
},
|
||||
async claimOwnershipOfAccount(){
|
||||
const { $client } = useNuxtApp();
|
||||
const { membership } = await $client.account.claimOwnershipOfAccount.mutate();
|
||||
if(membership){
|
||||
if(this.activeMembership){
|
||||
this.activeMembership.access = membership.access;
|
||||
}
|
||||
|
||||
for(const m of this.activeAccountMembers){
|
||||
if(m.id === membership.id){
|
||||
m.access = membership.access;
|
||||
}
|
||||
}
|
||||
const { memberships } = await $client.account.claimOwnershipOfAccount.mutate();
|
||||
if(memberships){
|
||||
this.activeAccountMembers = memberships;
|
||||
this.activeMembership!.access = ACCOUNT_ACCESS.OWNER
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
|
||||
async function fetchNotesForCurrentUser() {
|
||||
const { $client } = useNuxtApp();
|
||||
const { notes } = await $client.notes.getForCurrentUser.query();
|
||||
const { notes } = await $client.notes.getForActiveAccount.query();
|
||||
if(notes){
|
||||
_notes.value = notes;
|
||||
}
|
||||
|
||||
77
test/TEST.md
77
test/TEST.md
@@ -1,10 +1,71 @@
|
||||
# Manual test for Admin Functions Scenario
|
||||
Pre-condition
|
||||
User 3 (encumbent id=3) - Owner of own single user account. Admin of Team account
|
||||
User 4 (noob id = 4) - Owner of own single user account.
|
||||
## Pre-req
|
||||
- Site configured for free plan
|
||||
- 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...
|
||||
- joins user 4 to team account (expect user is a read only member of team account)
|
||||
- upgrades user 4 to owner (should fail)
|
||||
- upgrades user 4 to admin
|
||||
- claims ownership of team account
|
||||
(User 1)
|
||||
- Front page - Get Started
|
||||
- Signup with google - should drop to dashboard
|
||||
- Check account page via nav
|
||||
- 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)
|
||||
Reference in New Issue
Block a user