diff --git a/apps/e2e/playwright.config.ts b/apps/e2e/playwright.config.ts index 7f18f4e96..7298059ef 100644 --- a/apps/e2e/playwright.config.ts +++ b/apps/e2e/playwright.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 3 : 1, /* Limit parallel tests on CI. */ - workers: process.env.CI ? 2 : undefined, + workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ @@ -32,6 +32,13 @@ export default defineConfig({ trace: 'on-first-retry', }, + // test timeout set to 2 minutes + timeout: 2 * 60 * 1000, + expect: { + // expect timeout set to 10 seconds + timeout: 10 * 1000 + }, + /* Configure projects for major browsers */ projects: [ { diff --git a/apps/e2e/tests/authentication/auth.po.ts b/apps/e2e/tests/authentication/auth.po.ts index 09e92a7cb..6392ef654 100644 --- a/apps/e2e/tests/authentication/auth.po.ts +++ b/apps/e2e/tests/authentication/auth.po.ts @@ -27,7 +27,7 @@ export class AuthPageObject { email: string, password: string }) { - await this.page.waitForTimeout(100); + await this.page.waitForTimeout(1000); await this.page.fill('input[name="email"]', params.email); await this.page.fill('input[name="password"]', params.password); @@ -39,7 +39,7 @@ export class AuthPageObject { password: string, repeatPassword: string }) { - await this.page.waitForTimeout(100); + await this.page.waitForTimeout(1000); await this.page.fill('input[name="email"]', params.email); await this.page.fill('input[name="password"]', params.password); @@ -47,7 +47,9 @@ export class AuthPageObject { await this.page.click('button[type="submit"]'); } - async visitConfirmEmailLink(email: string) { + async visitConfirmEmailLink(email: string, params?: { + deleteAfter: boolean + }) { return expect(async() => { const res = await this.mailbox.visitMailbox(email); diff --git a/apps/e2e/tests/invitations/invitations.po.ts b/apps/e2e/tests/invitations/invitations.po.ts index 0d0a33ecb..cc73257ea 100644 --- a/apps/e2e/tests/invitations/invitations.po.ts +++ b/apps/e2e/tests/invitations/invitations.po.ts @@ -1,4 +1,4 @@ -import { Page } from '@playwright/test'; +import { expect, Page } from '@playwright/test'; import { AuthPageObject } from '../authentication/auth.po'; import { TeamAccountsPageObject } from '../team-accounts/team-accounts.po'; @@ -44,7 +44,7 @@ export class InvitationsPageObject { await form.locator('button[type="submit"]').click(); } - public navigateToMembers() { + navigateToMembers() { return this.page.locator('a', { hasText: 'Members', }).click(); @@ -54,7 +54,47 @@ export class InvitationsPageObject { await this.page.locator('[data-test="invite-members-form-trigger"]').click(); } + async getInvitations() { + return this.page.locator('[data-test="invitation-email"]'); + } + private getInviteForm() { return this.page.locator('[data-test="invite-members-form"]'); } + + async deleteInvitation(email: string) { + const actions = this.getInvitationRow(email).getByRole('button'); + + await actions.click(); + + await this.page.locator('[data-test="remove-invitation-trigger"]').click(); + + await this.page.click('[data-test="delete-invitation-form"] button[type="submit"]'); + } + + getInvitationRow(email: string) { + return this.page.getByRole('row', { name: email }); + } + + async updateInvitation(email: string, role: string) { + const row = this.getInvitationRow(email); + const actions = row.getByRole('button'); + + await actions.click(); + + await this.page.locator('[data-test="update-invitation-trigger"]').click(); + + await this.page.click(`[data-test="role-selector-trigger"]`); + await this.page.click(`[data-test="role-option-${role}"]`); + + await this.page.click('[data-test="update-invitation-form"] button[type="submit"]'); + } + + async acceptInvitation() { + await this.page.locator('[data-test="join-team-form"] button[type="submit"]').click(); + + await this.page.waitForResponse(response => { + return response.url().includes('/join') && response.request().method() === 'POST'; + }) + } } \ No newline at end of file diff --git a/apps/e2e/tests/invitations/invitations.spec.ts b/apps/e2e/tests/invitations/invitations.spec.ts index dc2202258..3fc150473 100644 --- a/apps/e2e/tests/invitations/invitations.spec.ts +++ b/apps/e2e/tests/invitations/invitations.spec.ts @@ -1,4 +1,4 @@ -import { Page, test } from '@playwright/test'; +import { expect, Page, test } from '@playwright/test'; import { InvitationsPageObject } from './invitations.po'; test.describe('Invitations', () => { @@ -12,7 +12,7 @@ test.describe('Invitations', () => { await invitations.setup(); }); - test('user invite users', async ({page}) => { + test('Full invite flow', async ({page}) => { await page.waitForLoadState('networkidle'); await invitations.navigateToMembers(); @@ -30,5 +30,80 @@ test.describe('Invitations', () => { ]; await invitations.inviteMembers(invites); + + const firstEmail = invites[0]!.email; + + await expect(await invitations.getInvitations()).toHaveCount(2) + + // sign out and sign in with the first email + await invitations.auth.signOut(); + + await invitations.auth.visitConfirmEmailLink(invites[0]!.email, { + deleteAfter: true + }); + + await invitations.auth.signUp({ + email: firstEmail, + password: 'password', + repeatPassword: 'password' + }); + + await invitations.auth.visitConfirmEmailLink(firstEmail); + + await invitations.acceptInvitation(); + + await invitations.teamAccounts.openAccountsSelector(); + + await expect(await invitations.teamAccounts.getTeams()).toHaveCount(1); + }); + + test('users can delete invites', async ({page}) => { + await page.waitForLoadState('networkidle'); + + await invitations.navigateToMembers(); + await invitations.openInviteForm(); + + const email = invitations.auth.createRandomEmail(); + + const invites = [ + { + email, + role: 'member' + }, + ]; + + await invitations.inviteMembers(invites); + + await expect(await invitations.getInvitations()).toHaveCount(1); + + await invitations.deleteInvitation(email); + + await expect(await invitations.getInvitations()).toHaveCount(0); + }); + + test('users can update invites', async ({page}) => { + await page.waitForLoadState('networkidle'); + + await invitations.navigateToMembers(); + await invitations.openInviteForm(); + + const email = invitations.auth.createRandomEmail(); + + const invites = [ + { + email, + role: 'member' + }, + ]; + + await invitations.inviteMembers(invites); + + await expect(await invitations.getInvitations()).toHaveCount(1); + + await invitations.updateInvitation(email, 'owner'); + + const row = invitations.getInvitationRow(email); + + await expect(row.locator('[data-test="member-role-badge"]')).toHaveText('owner'); }); }); diff --git a/apps/e2e/tests/team-accounts/team-accounts.po.ts b/apps/e2e/tests/team-accounts/team-accounts.po.ts index e494eda22..4e51d33bc 100644 --- a/apps/e2e/tests/team-accounts/team-accounts.po.ts +++ b/apps/e2e/tests/team-accounts/team-accounts.po.ts @@ -18,7 +18,17 @@ export class TeamAccountsPageObject { async getTeamFromSelector(teamSlug: string) { await this.openAccountsSelector(); - return this.page.locator(`[data-test="account-selector-team-${teamSlug}"]`); + return this.page.locator(`[data-test="account-selector-team"][data-value="${teamSlug}"]`); + } + + async selectAccount(teamName: string) { + await this.page.click(`[data-test="account-selector-team"][data-name="${teamName}"]`); + } + + async getTeams() { + await this.openAccountsSelector(); + + return this.page.locator('[data-test="account-selector-team"]'); } async goToSettings() { diff --git a/apps/e2e/tests/utils/mailbox.ts b/apps/e2e/tests/utils/mailbox.ts index 342cec84b..b7a250902 100644 --- a/apps/e2e/tests/utils/mailbox.ts +++ b/apps/e2e/tests/utils/mailbox.ts @@ -7,7 +7,9 @@ export class Mailbox { ) { } - async visitMailbox(email: string) { + async visitMailbox(email: string, params?: { + deleteAfter: boolean + }) { const mailbox = email.split('@')[0]; console.log(`Visiting mailbox ${mailbox} ...`) @@ -16,7 +18,7 @@ export class Mailbox { throw new Error('Invalid email'); } - const json = await this.getInviteEmail(mailbox); + const json = await this.getInviteEmail(mailbox, params); if (!json.body) { throw new Error('Email body was not found'); diff --git a/apps/web/supabase/migrations/20221215192558_schema.sql b/apps/web/supabase/migrations/20221215192558_schema.sql index 815b773b9..e0e43f54d 100644 --- a/apps/web/supabase/migrations/20221215192558_schema.sql +++ b/apps/web/supabase/migrations/20221215192558_schema.sql @@ -126,6 +126,14 @@ create type public.subscription_item_type as ENUM( 'metered' ); +/* +* Invitation Type +- We create the invitation type for the Supabase MakerKit. These types are used to manage the type of the invitation +*/ +create type public.invitation as ( + email text, + role varchar( 50)); + /* * ------------------------------------------------------- * Section: App Configuration @@ -1949,13 +1957,9 @@ language plpgsql; grant execute on function public.get_account_invitations(text) to authenticated, service_role; -create type kit.invitation as ( - email text, - role varchar( 50)); - create or replace function public.add_invitations_to_account(account_slug text, invitations - kit.invitation[]) + public.invitation[]) returns public.invitations[] as $$ declare @@ -1999,7 +2003,7 @@ $$ language plpgsql; grant execute on function public.add_invitations_to_account(text, - kit.invitation[]) to authenticated, service_role; + public.invitation[]) to authenticated, service_role; -- Storage -- Account Image diff --git a/packages/features/accounts/src/components/account-selector.tsx b/packages/features/accounts/src/components/account-selector.tsx index 85e64b38b..0591dffde 100644 --- a/packages/features/accounts/src/components/account-selector.tsx +++ b/packages/features/accounts/src/components/account-selector.tsx @@ -188,7 +188,9 @@ export function AccountSelector({ > {(accounts ?? []).map((account) => (
-
+ - +
); } @@ -87,7 +91,10 @@ function useGetColumns(permissions: { const email = member.email; return ( - + @@ -166,19 +173,28 @@ function ActionsDropdown({ - setIsUpdatingRole(true)}> + setIsUpdatingRole(true)} + > - setIsRenewingInvite(true)}> + setIsRenewingInvite(true)} + > - setIsDeletingInvite(true)}> + setIsDeletingInvite(true)} + > diff --git a/packages/features/team-accounts/src/components/invitations/delete-invitation-dialog.tsx b/packages/features/team-accounts/src/components/invitations/delete-invitation-dialog.tsx index 919780792..0f734e854 100644 --- a/packages/features/team-accounts/src/components/invitations/delete-invitation-dialog.tsx +++ b/packages/features/team-accounts/src/components/invitations/delete-invitation-dialog.tsx @@ -66,7 +66,7 @@ function DeleteInvitationForm({ }; return ( - +

@@ -82,7 +82,7 @@ function DeleteInvitationForm({ diff --git a/packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts b/packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts index 46d06bacd..74a956fd7 100644 --- a/packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts +++ b/packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts @@ -17,7 +17,7 @@ export class AccountPerSeatBillingService { logger.info( ctx, - `Getting per-seat subscription item for account ${accountId}...`, + `Retrieving per-seat subscription item for account ${accountId}...`, ); const { data, error } = await this.client @@ -34,7 +34,7 @@ export class AccountPerSeatBillingService { `, ) .eq('account_id', accountId) - .eq('subscription_items.type', 'per-seat') + .eq('subscription_items.type', 'per_seat') .maybeSingle(); if (error) { @@ -52,7 +52,7 @@ export class AccountPerSeatBillingService { if (!data?.subscription_items) { logger.info( ctx, - `No per-seat subscription item found for account ${accountId}. Exiting...`, + `Account is not subscribed to a per-seat subscription. Exiting...`, ); return; diff --git a/packages/ui/src/shadcn/data-table.tsx b/packages/ui/src/shadcn/data-table.tsx index 41b88f175..b5af19e7f 100644 --- a/packages/ui/src/shadcn/data-table.tsx +++ b/packages/ui/src/shadcn/data-table.tsx @@ -59,6 +59,7 @@ export function DataTable({ table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => (