From 4d288c7468c8c9139934ca5e386bc38bc32c3675 Mon Sep 17 00:00:00 2001 From: Michael Dausmann Date: Sun, 9 Apr 2023 00:41:46 +1000 Subject: [PATCH] team join with link and member admin --- README.md | 5 ++- components/AppHeader.vue | 3 +- lib/services/account.service.ts | 53 ++++++++++++++++++++++++++- lib/services/auth.service.ts | 6 +++ nuxt.config.ts | 1 + package-lock.json | 43 ++++++++++++++++++++++ package.json | 1 + pages/account.vue | 27 ++++++++++---- pages/join/[join_password].vue | 44 ++++++++++++++++++++++ prisma/schema.prisma | 2 + server/trpc/context.ts | 7 ---- server/trpc/routers/account.router.ts | 40 +++++++++++++++++--- server/trpc/routers/auth.router.ts | 4 +- stores/account.store.ts | 31 ++++++++++++++-- stores/auth.store.ts | 5 ++- 15 files changed, 238 insertions(+), 34 deletions(-) create mode 100644 pages/join/[join_password].vue diff --git a/README.md b/README.md index 8e05f4e..7798933 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,9 @@ Please don't hitch your wagon to this star just yet... I'm coding this in the op - [x] Allow users to upgrade their accounts fron individual accounts to multi-user accounts (Teams). - [x] Allow users to switch between Teams and view/edit data from the selected Team. - [x] All features, billing and limits is controlled at the Account (Team) level (not the user level) -- [ ] Team administrators and owners can administer the permissions (roles) of other team members on the Accounts page -- [ ] Gen/Regen an invite link to allow users to join a team +- [x] Gen/Regen an invite link to allow users to join a team +- [x] Team administrators and owners can accept pending invites +- [x] Team administrators and owners can administer the permissions (roles) of other team members on the Accounts page ### Plans and Pricing - [x] Manage multiple Plans each with specific Feature flags and Plan limits diff --git a/components/AppHeader.vue b/components/AppHeader.vue index 60d9f1b..3d7316b 100644 --- a/components/AppHeader.vue +++ b/components/AppHeader.vue @@ -37,8 +37,9 @@

Switch Account.. -

diff --git a/lib/services/account.service.ts b/lib/services/account.service.ts index d663073..c7df7ce 100644 --- a/lib/services/account.service.ts +++ b/lib/services/account.service.ts @@ -1,6 +1,7 @@ import { ACCOUNT_ACCESS } from '@prisma/client'; import prisma_client from '~~/prisma/prisma.client'; import { accountWithMembers, AccountWithMembers, membershipWithAccount, MembershipWithAccount, membershipWithUser, MembershipWithUser } from './service.types'; +import generator from 'generate-password-ts'; export default class AccountService { async getAccountById(account_id: number): Promise { @@ -10,6 +11,13 @@ export default class AccountService { }); } + async getAccountByJoinPassword(join_password: string): Promise { + return prisma_client.account.findFirstOrThrow({ + where: { join_password }, + ...accountWithMembers + }); + } + async getAccountMembers(account_id: number): Promise { return prisma_client.membership.findMany({ where: { account_id }, @@ -62,7 +70,29 @@ export default class AccountService { } - async joinUserToAccount(user_id: number, account_id: number): Promise { + async acceptPendingMembership(account_id: number, membership_id: number): Promise { + const membership = prisma_client.membership.findFirstOrThrow({ + where: { + id: membership_id + } + }); + + if((await membership).account_id != account_id){ + throw new Error(`Membership does not belong to current account`); + } + + return await prisma_client.membership.update({ + where: { + id: membership_id, + }, + data: { + pending: false + }, + ...membershipWithAccount + }) + } + + async joinUserToAccount(user_id: number, account_id: number, pending: boolean ): Promise { const account = await prisma_client.account.findUnique({ where: { id: account_id, @@ -77,11 +107,20 @@ export default class AccountService { throw new Error(`Too Many Members, Account only permits ${account?.max_members} members.`); } + if(account?.members){ + for(const member of account.members){ + if(member.user_id === user_id){ + throw new Error(`User is already a member`); + } + } + } + return prisma_client.membership.create({ data: { user_id: user_id, account_id, - access: ACCOUNT_ACCESS.READ_ONLY + access: ACCOUNT_ACCESS.READ_ONLY, + pending }, ...membershipWithAccount }); @@ -108,6 +147,16 @@ export default class AccountService { }); } + async rotateJoinPassword(account_id: number) { + const join_password: string = generator.generate({ + length: 10, + numbers: true + }); + return prisma_client.account.update({ + where: { id: account_id}, + data: { join_password } + }); + } // Claim ownership of an account. // User must already be an ADMIN for the Account diff --git a/lib/services/auth.service.ts b/lib/services/auth.service.ts index ae25f27..c8c5f58 100644 --- a/lib/services/auth.service.ts +++ b/lib/services/auth.service.ts @@ -2,6 +2,7 @@ import { ACCOUNT_ACCESS } from '@prisma/client'; import prisma_client from '~~/prisma/prisma.client'; import { fullDBUser, FullDBUser } from './service.types'; import { UtilService } from './util.service'; +import generator from 'generate-password-ts'; const config = useRuntimeConfig(); @@ -22,6 +23,10 @@ export default class AuthService { async createUser( supabase_uid: string, display_name: string, email: string ): Promise { const trialPlan = await prisma_client.plan.findFirstOrThrow({ where: { name: config.initialPlanName}}); + const join_password: string = generator.generate({ + length: 10, + numbers: true + }); return prisma_client.user.create({ data:{ supabase_uid: supabase_uid, @@ -38,6 +43,7 @@ export default class AuthService { max_notes: trialPlan.max_notes, max_members: trialPlan.max_members, plan_name: trialPlan.name, + join_password: join_password, } }, access: ACCOUNT_ACCESS.OWNER diff --git a/nuxt.config.ts b/nuxt.config.ts index 1005212..62979fa 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -20,6 +20,7 @@ export default defineNuxtConfig({ initialPlanActiveMonths: 1, public: { debugMode: true, + siteRootUrl: 'http://localhost:3000', } } }) diff --git a/package-lock.json b/package-lock.json index 8a4feb4..b15ca37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "@pinia/nuxt": "^0.4.6", "@trpc/client": "^10.9.0", "@trpc/server": "^10.9.0", + "generate-password-ts": "^1.6.3", "pinia": "^2.0.30", "stripe": "^11.12.0", "superjson": "^1.12.2", @@ -3971,6 +3972,14 @@ "node": ">=8" } }, + "node_modules/generate-password-ts": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/generate-password-ts/-/generate-password-ts-1.6.3.tgz", + "integrity": "sha512-6piPG6qvW3B40NLKz6VfK9IgVl0MXRZgGt1SW3zUTZXln2DVno84cAwx0Z6OvA9uJKXRjRRvPz1CUDDsWXJZwA==", + "dependencies": { + "js-crypto-random": "1.0.2" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4631,6 +4640,19 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-crypto-env": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/js-crypto-env/-/js-crypto-env-1.0.4.tgz", + "integrity": "sha512-b7WdjaX4csatMPfZ/mQ94yb/XTKe3o6qt0jPBVbKmaiOH97e+FlmIANoFEMrhxQM1xxKfA2QYLjgqL/YtdMm9g==" + }, + "node_modules/js-crypto-random": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/js-crypto-random/-/js-crypto-random-1.0.2.tgz", + "integrity": "sha512-bJ31lo2YrxOJIRnT/XPzUu2O43/7uGWAzAtXmAi09Oq6Jmrd2uVy8wII/WH3thezhZvjdU85fVtySSeD1Y2/dQ==", + "dependencies": { + "js-crypto-env": "^1.0.2" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -11535,6 +11557,14 @@ } } }, + "generate-password-ts": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/generate-password-ts/-/generate-password-ts-1.6.3.tgz", + "integrity": "sha512-6piPG6qvW3B40NLKz6VfK9IgVl0MXRZgGt1SW3zUTZXln2DVno84cAwx0Z6OvA9uJKXRjRRvPz1CUDDsWXJZwA==", + "requires": { + "js-crypto-random": "1.0.2" + } + }, "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -12004,6 +12034,19 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.18.2.tgz", "integrity": "sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==" }, + "js-crypto-env": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/js-crypto-env/-/js-crypto-env-1.0.4.tgz", + "integrity": "sha512-b7WdjaX4csatMPfZ/mQ94yb/XTKe3o6qt0jPBVbKmaiOH97e+FlmIANoFEMrhxQM1xxKfA2QYLjgqL/YtdMm9g==" + }, + "js-crypto-random": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/js-crypto-random/-/js-crypto-random-1.0.2.tgz", + "integrity": "sha512-bJ31lo2YrxOJIRnT/XPzUu2O43/7uGWAzAtXmAi09Oq6Jmrd2uVy8wII/WH3thezhZvjdU85fVtySSeD1Y2/dQ==", + "requires": { + "js-crypto-env": "^1.0.2" + } + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index d7ca272..dd0f9ad 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@pinia/nuxt": "^0.4.6", "@trpc/client": "^10.9.0", "@trpc/server": "^10.9.0", + "generate-password-ts": "^1.6.3", "pinia": "^2.0.30", "stripe": "^11.12.0", "superjson": "^1.12.2", diff --git a/pages/account.vue b/pages/account.vue index 1e92881..ab574b1 100644 --- a/pages/account.vue +++ b/pages/account.vue @@ -24,21 +24,35 @@ if(!date){ return ""; } return new Intl.DateTimeFormat('default', {dateStyle: 'long'}).format(date); } + + function joinURL(){ + return `${config.public.siteRootUrl}/join/${activeMembership.value?.account.join_password}`; + } + diff --git a/pages/join/[join_password].vue b/pages/join/[join_password].vue new file mode 100644 index 0000000..7cb520f --- /dev/null +++ b/pages/join/[join_password].vue @@ -0,0 +1,44 @@ + + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a3d1537..49b4cb6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,6 +35,7 @@ model Membership { account Account @relation(fields: [account_id], references: [id]) user User @relation(fields: [user_id], references: [id]) access ACCOUNT_ACCESS @default(READ_ONLY) + pending Boolean @default(false) @@map("membership") @@unique([user_id, account_id]) @@ -54,6 +55,7 @@ model Account { stripe_subscription_id String? stripe_customer_id String? max_members Int @default(1) + join_password String @unique @@map("account") } diff --git a/server/trpc/context.ts b/server/trpc/context.ts index 94010fb..f36d0bf 100644 --- a/server/trpc/context.ts +++ b/server/trpc/context.ts @@ -22,13 +22,6 @@ export async function createContext(event: H3Event){ } } - if(!user || !dbUser) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: `Unable to fetch user data at this time. Missing ->[user:${(!user)},dbUser:${(!dbUser)}]`, - }); - } - return { user, dbUser, diff --git a/server/trpc/routers/account.router.ts b/server/trpc/routers/account.router.ts index 1474253..bdd63a1 100644 --- a/server/trpc/routers/account.router.ts +++ b/server/trpc/routers/account.router.ts @@ -1,4 +1,4 @@ -import { router, adminProcedure } from '../trpc' +import { router, adminProcedure, publicProcedure } from '../trpc' import { ACCOUNT_ACCESS } from '@prisma/client'; import { z } from 'zod'; import AccountService from '~~/lib/services/account.service'; @@ -15,11 +15,21 @@ export const accountRouter = router({ account, } }), - changeAccountPlan: adminProcedure - .input(z.object({ account_id: z.number(), plan_id: z.number() })) + rotateJoinPassword: adminProcedure + .input(z.object({ account_id: z.number() })) .query(async ({ ctx, input }) => { const accountService = new AccountService(); - const account = await accountService.changeAccountPlan(input.account_id, input.plan_id); + const account = await accountService.rotateJoinPassword(input.account_id); + + return { + account, + } + }), + getAccountByJoinPassword: publicProcedure + .input(z.object({ join_password: z.string() })) + .query(async ({ ctx, input }) => { + const accountService = new AccountService(); + const account = await accountService.getAccountByJoinPassword(input.join_password); return { account, @@ -29,7 +39,25 @@ export const accountRouter = router({ .input(z.object({ account_id: z.number(), user_id: z.number() })) .query(async ({ ctx, input }) => { const accountService = new AccountService(); - const membership: MembershipWithAccount| null = (ctx.dbUser?.id)?await accountService.joinUserToAccount(input.user_id, input.account_id):null; + const membership: MembershipWithAccount| null = (ctx.dbUser?.id)?await accountService.joinUserToAccount(input.user_id, input.account_id, false):null; + return { + membership, + } + }), + joinUserToAccountPending: publicProcedure + .input(z.object({ account_id: z.number(), user_id: z.number() })) + .query(async ({ ctx, input }) => { + const accountService = new AccountService(); + const membership: MembershipWithAccount| null = (ctx.dbUser?.id)?await accountService.joinUserToAccount(input.user_id, input.account_id, true):null; + return { + membership, + } + }), + acceptPendingMembership: adminProcedure + .input(z.object({ account_id: z.number(), membership_id: z.number() })) + .query(async ({ ctx, input }) => { + const accountService = new AccountService(); + const membership: MembershipWithAccount| null = (ctx.dbUser?.id)?await accountService.acceptPendingMembership(input.account_id, input.membership_id):null; return { membership, } @@ -48,7 +76,7 @@ export const accountRouter = router({ .input(z.object({ account_id: z.number() })) .query(async ({ ctx, input }) => { const accountService = new AccountService(); - const membership = await accountService.claimOwnershipOfAccount(ctx.dbUser.id, input.account_id); + const membership = await accountService.claimOwnershipOfAccount(ctx.dbUser!.id, input.account_id); // adminProcedure errors if ctx.dbUser is null so bang is ok here return { membership, diff --git a/server/trpc/routers/auth.router.ts b/server/trpc/routers/auth.router.ts index c8d8345..d49a4d6 100644 --- a/server/trpc/routers/auth.router.ts +++ b/server/trpc/routers/auth.router.ts @@ -1,7 +1,7 @@ -import { protectedProcedure, router } from '../trpc' +import { publicProcedure, router } from '../trpc' export const authRouter = router({ - getDBUser: protectedProcedure + getDBUser: publicProcedure .query(({ ctx }) => { return { dbUser: ctx.dbUser, diff --git a/stores/account.store.ts b/stores/account.store.ts index 8409831..0ced5a7 100644 --- a/stores/account.store.ts +++ b/stores/account.store.ts @@ -33,11 +33,25 @@ export const useAccountStore = defineStore('account', { authStore.activeMembership.account = account.value.account; } }, - async changeAccountPlan(plan_id: number){ + async acceptPendingMembership(membership_id: number){ const authStore = useAuthStore(); if(!authStore.activeMembership) { return; } const { $client } = useNuxtApp(); - const { data: account } = await $client.account.changeAccountPlan.useQuery({account_id: authStore.activeMembership.account_id, plan_id}); + const { data: membership } = await $client.account.acceptPendingMembership.useQuery({account_id: authStore.activeMembership.account_id, membership_id}); + + if(membership.value && membership.value.membership?.pending === false){ + for(const m of this.activeAccountMembers){ + if(m.id === membership_id){ + m.pending = false; + } + } + } + }, + async rotateJoinPassword(){ + const authStore = useAuthStore(); + if(!authStore.activeMembership) { return; } + const { $client } = useNuxtApp(); + const { data: account } = await $client.account.rotateJoinPassword.useQuery({account_id: authStore.activeMembership.account_id}); if(account.value?.account){ authStore.activeMembership.account = account.value.account; } @@ -57,7 +71,11 @@ export const useAccountStore = defineStore('account', { const { $client } = useNuxtApp(); const { data: membership } = await $client.account.changeUserAccessWithinAccount.useQuery({account_id: authStore.activeMembership.account_id, user_id, access}); if(membership.value?.membership){ - authStore.activeMembership = membership.value.membership; + for(const m of this.activeAccountMembers){ + if(m.id === membership.value?.membership.id){ + m.access = membership.value?.membership.access; + } + } } }, async claimOwnershipOfAccount(){ @@ -66,7 +84,12 @@ export const useAccountStore = defineStore('account', { const { $client } = useNuxtApp(); const { data: membership } = await $client.account.claimOwnershipOfAccount.useQuery({account_id: authStore.activeMembership.account_id}); if(membership.value?.membership){ - authStore.activeMembership = membership.value.membership; + authStore.activeMembership.access = membership.value.membership.access; + for(const m of this.activeAccountMembers){ + if(m.id === membership.value?.membership.id){ + m.access = membership.value?.membership.access; + } + } } } } diff --git a/stores/auth.store.ts b/stores/auth.store.ts index 626a9dd..38e5a32 100644 --- a/stores/auth.store.ts +++ b/stores/auth.store.ts @@ -20,8 +20,9 @@ export const useAuthStore = defineStore('auth', { if(dbUser){ this.dbUser = dbUser; - if(dbUser.memberships.length > 0){ - this.activeMembership = dbUser.memberships[0]; + const activeMemberships = dbUser.memberships.filter(m => !m.pending) + if(activeMemberships.length > 0){ + this.activeMembership = activeMemberships[0]; } } }