Skip to content

Commit d598fc1

Browse files
authored
Make sure we query for pending invites in the create tenant page (#2883)
* WIP: pending invites onboarding * check for invites on create tenant onboarding * add cypress tests
1 parent 708f8da commit d598fc1

File tree

14 files changed

+323
-23
lines changed

14 files changed

+323
-23
lines changed
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.

frontend/app/cypress/e2e/auth/tenant-invite-accept.cy.ts renamed to frontend/app/cypress/e2e/auth/05-tenant-invite-accept.cy.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,18 @@ describe('Tenant Invite: accept', () => {
101101
'/onboarding/invites',
102102
);
103103

104-
// Verify exactly one invite is displayed
104+
// Find the specific invite and accept it
105105
cy.contains(`You got an invitation to join ${tenant2Name}`).should(
106106
'be.visible',
107107
);
108-
cy.get('button').contains('Accept').should('have.length', 1);
109108

110-
// Step 4: Accept the invite
109+
// Step 4: Accept the invite - register intercept before clicking
111110
cy.intercept('POST', '/api/v1/users/invites/accept').as('acceptInvite');
112-
cy.contains('button', 'Accept').click();
111+
cy.contains(`You got an invitation to join ${tenant2Name}`)
112+
.parent()
113+
.contains('button', 'Accept')
114+
.should('be.visible')
115+
.click();
113116

114117
// Wait for the accept API call to complete
115118
cy.wait('@acceptInvite').its('response.statusCode').should('eq', 200);

frontend/app/cypress/e2e/auth/tenant-switching.cy.ts renamed to frontend/app/cypress/e2e/auth/06-tenant-switching.cy.ts

File renamed without changes.
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { seededUsers } from '../../support/seeded-users.generated';
2+
3+
describe('Create Tenant: redirect to invites', () => {
4+
it('should redirect to invites page when user has pending invites', () => {
5+
const ts = Date.now();
6+
const tenantName = `InviteRedirectTenant${ts}`;
7+
const tenantSlug = `invite-redirect-tenant-${ts}`;
8+
9+
// Step 1: Login as owner and create a tenant
10+
cy.visit('/auth/login');
11+
cy.get('input#email').type(seededUsers.owner.email);
12+
cy.get('input#password').type(seededUsers.owner.password);
13+
cy.get('form')
14+
.filter(':visible')
15+
.first()
16+
.within(() => {
17+
cy.contains('button', /^Sign In$/)
18+
.should('be.enabled')
19+
.click();
20+
});
21+
cy.location('pathname', { timeout: 30000 }).should(
22+
'match',
23+
/\/tenants\/.+/,
24+
);
25+
26+
// Create a new tenant for the invite
27+
cy.request({
28+
method: 'POST',
29+
url: '/api/v1/tenants',
30+
body: {
31+
name: tenantName,
32+
slug: tenantSlug,
33+
environment: 'development',
34+
},
35+
})
36+
.its('status')
37+
.should('eq', 200);
38+
39+
// Refresh to get the new tenant
40+
cy.visit('/');
41+
cy.location('pathname', { timeout: 30000 }).should(
42+
'match',
43+
/\/tenants\/.+/,
44+
);
45+
46+
// Switch to the new tenant
47+
cy.get('button[aria-label="Select a tenant"]')
48+
.filter(':visible')
49+
.first()
50+
.click({ force: true });
51+
cy.get('[data-cy="tenant-switcher-list"]').should('be.visible');
52+
cy.get(`[data-cy="tenant-switcher-item-${tenantSlug}"]`)
53+
.should('exist')
54+
.scrollIntoView()
55+
.click({ force: true });
56+
57+
// Get tenant ID from URL
58+
cy.location('pathname', { timeout: 30000 })
59+
.should('match', /\/tenants\/([^/]+)/)
60+
.then((pathname) => {
61+
const match = pathname.match(/\/tenants\/([^/]+)/);
62+
const tenantId = match![1];
63+
64+
// Step 2: Create an invite for the member user
65+
cy.request({
66+
method: 'POST',
67+
url: `/api/v1/tenants/${tenantId}/invites`,
68+
body: {
69+
email: seededUsers.member.email,
70+
role: 'MEMBER',
71+
},
72+
}).then((response) => {
73+
expect(response.status).to.eq(201);
74+
});
75+
});
76+
77+
// Step 3: Logout
78+
cy.get('button[aria-label="User Menu"]')
79+
.filter(':visible')
80+
.should('be.visible')
81+
.first()
82+
.click();
83+
cy.contains('[role="menuitem"]', 'Log out').filter(':visible').click();
84+
cy.location('pathname').should('include', '/auth/login');
85+
86+
// Step 4: Login as member (who has pending invite)
87+
cy.get('input#email').type(seededUsers.member.email);
88+
cy.get('input#password').type(seededUsers.member.password);
89+
cy.get('form')
90+
.filter(':visible')
91+
.first()
92+
.within(() => {
93+
cy.contains('button', /^Sign In$/)
94+
.should('be.enabled')
95+
.click();
96+
});
97+
98+
// Step 5: Try to navigate to create-tenant page
99+
// The user should be redirected to invites page
100+
cy.visit('/onboarding/create-tenant', { failOnStatusCode: false });
101+
102+
// Step 6: Verify redirect to invites page
103+
cy.location('pathname', { timeout: 10000 }).should(
104+
'eq',
105+
'/onboarding/invites',
106+
);
107+
108+
// Verify the invite is displayed
109+
cy.contains(`You got an invitation to join ${tenantName}`).should(
110+
'be.visible',
111+
);
112+
113+
// Step 7: Accept the invite to clean up (prevent affecting other tests)
114+
cy.intercept('POST', '/api/v1/users/invites/accept').as('acceptInvite');
115+
cy.contains(`You got an invitation to join ${tenantName}`)
116+
.parent()
117+
.contains('button', 'Accept')
118+
.should('be.visible')
119+
.click();
120+
121+
cy.wait('@acceptInvite').its('response.statusCode').should('eq', 200);
122+
123+
// Verify redirect to tenant page
124+
cy.location('pathname', { timeout: 10000 }).should(
125+
'match',
126+
/\/tenants\/[^/]+/,
127+
);
128+
});
129+
});
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { seededUsers } from '../../support/seeded-users.generated';
2+
3+
describe('Tenant Invite: decline', () => {
4+
it('should redirect away from invites page after declining invite', () => {
5+
const ts = Date.now();
6+
const tenantName = `DeclineTenant${ts}`;
7+
const tenantSlug = `decline-tenant-${ts}`;
8+
9+
// Step 1: Login as owner and create a tenant
10+
cy.visit('/auth/login');
11+
cy.get('input#email').type(seededUsers.owner.email);
12+
cy.get('input#password').type(seededUsers.owner.password);
13+
cy.get('form')
14+
.filter(':visible')
15+
.first()
16+
.within(() => {
17+
cy.contains('button', /^Sign In$/)
18+
.should('be.enabled')
19+
.click();
20+
});
21+
cy.location('pathname', { timeout: 30000 }).should(
22+
'match',
23+
/\/tenants\/.+/,
24+
);
25+
26+
// Create a new tenant for the invite
27+
cy.request({
28+
method: 'POST',
29+
url: '/api/v1/tenants',
30+
body: {
31+
name: tenantName,
32+
slug: tenantSlug,
33+
environment: 'development',
34+
},
35+
})
36+
.its('status')
37+
.should('eq', 200);
38+
39+
// Refresh to get the new tenant
40+
cy.visit('/');
41+
cy.location('pathname', { timeout: 30000 }).should(
42+
'match',
43+
/\/tenants\/.+/,
44+
);
45+
46+
// Switch to the new tenant
47+
cy.get('button[aria-label="Select a tenant"]')
48+
.filter(':visible')
49+
.first()
50+
.click({ force: true });
51+
cy.get('[data-cy="tenant-switcher-list"]').should('be.visible');
52+
cy.get(`[data-cy="tenant-switcher-item-${tenantSlug}"]`)
53+
.should('exist')
54+
.scrollIntoView()
55+
.click({ force: true });
56+
57+
// Get tenant ID from URL
58+
cy.location('pathname', { timeout: 30000 })
59+
.should('match', /\/tenants\/([^/]+)/)
60+
.then((pathname) => {
61+
const match = pathname.match(/\/tenants\/([^/]+)/);
62+
const tenantId = match![1];
63+
64+
// Step 2: Create an invite for the member user
65+
cy.request({
66+
method: 'POST',
67+
url: `/api/v1/tenants/${tenantId}/invites`,
68+
body: {
69+
email: seededUsers.member.email,
70+
role: 'MEMBER',
71+
},
72+
}).then((response) => {
73+
expect(response.status).to.eq(201);
74+
});
75+
});
76+
77+
// Step 3: Logout
78+
cy.get('button[aria-label="User Menu"]')
79+
.filter(':visible')
80+
.should('be.visible')
81+
.first()
82+
.click();
83+
cy.contains('[role="menuitem"]', 'Log out').filter(':visible').click();
84+
cy.location('pathname').should('include', '/auth/login');
85+
86+
// Step 4: Login as member (who has pending invite)
87+
cy.get('input#email').type(seededUsers.member.email);
88+
cy.get('input#password').type(seededUsers.member.password);
89+
cy.get('form')
90+
.filter(':visible')
91+
.first()
92+
.within(() => {
93+
cy.contains('button', /^Sign In$/)
94+
.should('be.enabled')
95+
.click();
96+
});
97+
98+
// Should be redirected to invites page
99+
cy.location('pathname', { timeout: 5000 }).should(
100+
'eq',
101+
'/onboarding/invites',
102+
);
103+
104+
// Verify the invite is displayed
105+
cy.contains(`You got an invitation to join ${tenantName}`).should(
106+
'be.visible',
107+
);
108+
109+
// Step 5: Decline the invite - register intercept before clicking
110+
cy.intercept('POST', '/api/v1/users/invites/reject').as('rejectInvite');
111+
cy.contains(`You got an invitation to join ${tenantName}`)
112+
.parent()
113+
.contains('button', 'Decline')
114+
.should('be.visible')
115+
.click();
116+
117+
// Wait for the reject API call to complete
118+
cy.wait('@rejectInvite').its('response.statusCode').should('eq', 200);
119+
120+
// Step 6: Verify redirect away from invites page
121+
// User should be redirected to authenticated route (which may further redirect)
122+
cy.location('pathname', { timeout: 10000 }).should(
123+
'not.eq',
124+
'/onboarding/invites',
125+
);
126+
});
127+
});

frontend/app/cypress/support/flows/auth.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ export function loginSession(
2929
`expected redirect to land on tenant shell or onboarding, got ${pathname}`,
3030
).to.satisfy(
3131
(p: string) =>
32-
p.includes('/tenants/') || p.includes('/onboarding/create-tenant'),
32+
p.includes('/tenants/') ||
33+
p.includes('/onboarding/create-tenant') ||
34+
p.includes('/onboarding/invites'),
3335
);
3436
});
3537

@@ -49,6 +51,15 @@ export function loginSession(
4951
cy.contains('button', 'Create Tenant').click();
5052
cy.wait('@createTenant').its('response.statusCode').should('eq', 200);
5153
}
54+
55+
// If the user has pending invites, accept the first one to proceed
56+
if (pathname.includes('/onboarding/invites')) {
57+
cy.intercept('POST', '/api/v1/users/invites/accept').as(
58+
'acceptInvite',
59+
);
60+
cy.contains('button', 'Accept').first().click();
61+
cy.wait('@acceptInvite').its('response.statusCode').should('eq', 200);
62+
}
5263
});
5364

5465
cy.location('pathname', { timeout: 30000 }).should(

frontend/app/src/hooks/use-pending-invites.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import useCloud from '@/pages/auth/hooks/use-cloud';
44
import { useQuery } from '@tanstack/react-query';
55

66
export const usePendingInvites = () => {
7-
const { isCloudEnabled } = useCloud();
7+
const { isCloudEnabled, isCloudLoading } = useCloud();
88

99
const query = useQuery({
10-
queryKey: ['pending-invites'],
10+
queryKey: ['pending-invites', isCloudEnabled],
1111
queryFn: async () => {
1212
const [tenantInvites, orgInvites] = await Promise.allSettled([
1313
api.userListTenantInvites(),
@@ -27,11 +27,11 @@ export const usePendingInvites = () => {
2727

2828
return tenantCount + orgCount;
2929
},
30-
refetchInterval: 30000, // Refetch every 30 seconds
31-
enabled: true,
30+
refetchInterval: 30000,
3231
});
3332

3433
return {
3534
pendingInvitesQuery: query,
35+
isLoading: isCloudLoading || query.isLoading,
3636
};
3737
};

0 commit comments

Comments
 (0)