chore: improve invitation flow, update project dependencies and documentation for Next.js 16 (#408)
* chore: update project dependencies and documentation for Next.js 16 - Upgraded Next.js from version 15 to 16 across various documentation files and components. - Updated references to Next.js 16 in AGENTS.md and CLAUDE.md for consistency. - Incremented application version to 2.21.0 in package.json. - Refactored identity setup components to improve user experience and added confirmation dialogs for authentication methods. - Enhanced invitation flow with new logic for handling user redirection and token generation. * refactor: streamline invitation flow in e2e tests - Simplified the invitation flow test by using a predefined email instead of generating a random one. - Removed unnecessary steps such as clearing cookies and reloading the page before user sign-up. - Enhanced clarity by eliminating commented-out code related to identity verification and user membership checks. * refactor: improve code readability in IdentitiesPage and UpdatePasswordForm components - Enhanced formatting of JSX elements in IdentitiesPage and UpdatePasswordForm for better readability. - Adjusted indentation and line breaks to maintain consistent coding style across components. * refactor: enhance LinkAccountsList component with user redirection logic - Updated the LinkAccountsList component to include a redirectToPath option in the useLinkIdentityWithProvider hook for improved user experience. - Removed redundant user hook declaration to streamline the code structure. * refactor: update account setup logic in JoinTeamAccountPage - Introduced a check for email-only authentication support to streamline account setup requirements. - Adjusted the conditions for determining if a new account should set up additional authentication methods, enhancing user experience for new users.
This commit is contained in:
committed by
GitHub
parent
ae404d8366
commit
fa2fa9a15c
@@ -56,7 +56,7 @@ export class AccountPageObject {
|
||||
password,
|
||||
);
|
||||
|
||||
await this.page.click('[data-test="account-password-form"] button');
|
||||
await this.page.click('[data-test="identity-form"] button');
|
||||
}
|
||||
|
||||
async deleteAccount(email: string) {
|
||||
|
||||
@@ -132,16 +132,32 @@ export class InvitationsPageObject {
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
// skip authentication setup
|
||||
const skipIdentitiesButton = this.page.locator(
|
||||
'[data-test="skip-identities-button"]',
|
||||
const continueButton = this.page.locator(
|
||||
'[data-test="continue-button"]',
|
||||
);
|
||||
|
||||
if (
|
||||
await skipIdentitiesButton.isVisible({
|
||||
await continueButton.isVisible({
|
||||
timeout: 1000,
|
||||
})
|
||||
) {
|
||||
await skipIdentitiesButton.click();
|
||||
await continueButton.click();
|
||||
|
||||
// Handle confirmation dialog that appears when skipping without adding auth
|
||||
const confirmationDialog = this.page.locator(
|
||||
'[data-test="no-auth-method-dialog"]',
|
||||
);
|
||||
|
||||
if (
|
||||
await confirmationDialog.isVisible({
|
||||
timeout: 2000,
|
||||
})
|
||||
) {
|
||||
console.log('Confirmation dialog appeared, clicking Continue...');
|
||||
await this.page
|
||||
.locator('[data-test="no-auth-dialog-continue"]')
|
||||
.click();
|
||||
}
|
||||
}
|
||||
|
||||
// wait for redirect to account home
|
||||
|
||||
@@ -128,4 +128,529 @@ test.describe('Full Invitation Flow', () => {
|
||||
|
||||
await expect(invitations.teamAccounts.getTeams()).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('new users should be redirected to /identities to set up identity', async ({
|
||||
page,
|
||||
}) => {
|
||||
const invitations = new InvitationsPageObject(page);
|
||||
await invitations.setup();
|
||||
|
||||
await invitations.navigateToMembers();
|
||||
|
||||
const newUserEmail = invitations.auth.createRandomEmail();
|
||||
|
||||
const invites = [
|
||||
{
|
||||
email: newUserEmail,
|
||||
role: 'member',
|
||||
},
|
||||
];
|
||||
|
||||
await invitations.openInviteForm();
|
||||
await invitations.inviteMembers(invites);
|
||||
|
||||
await expect(invitations.getInvitations()).toHaveCount(1);
|
||||
|
||||
// Sign out current user
|
||||
await page.context().clearCookies();
|
||||
await page.reload();
|
||||
|
||||
console.log(`Finding invitation email for new user: ${newUserEmail}`);
|
||||
|
||||
// Click invitation link from email
|
||||
await invitations.auth.visitConfirmEmailLink(newUserEmail);
|
||||
|
||||
console.log(`New user authenticated, should land on /join page`);
|
||||
|
||||
// Verify user lands on /join page
|
||||
await page.waitForURL('**/join?**');
|
||||
|
||||
// Click accept invitation button
|
||||
const acceptButton = page.locator(
|
||||
'[data-test="join-team-form"] button[type="submit"]',
|
||||
);
|
||||
await acceptButton.click();
|
||||
|
||||
console.log(`Checking if new user is redirected to /identities...`);
|
||||
|
||||
// NEW USERS should be redirected to /identities to set up auth method
|
||||
await page.waitForURL('**/identities?**', { timeout: 5000 });
|
||||
|
||||
console.log(`✓ New user correctly redirected to /identities`);
|
||||
|
||||
// Verify continue button exists (user can skip and set up later)
|
||||
const continueButton = page.locator('[data-test="continue-button"]');
|
||||
await expect(continueButton).toBeVisible();
|
||||
|
||||
console.log(`Skipping identity setup...`);
|
||||
|
||||
// Skip identity setup for now
|
||||
await continueButton.click();
|
||||
|
||||
// Handle confirmation dialog that appears when skipping without adding auth
|
||||
const confirmationDialog = page.locator(
|
||||
'[data-test="no-auth-method-dialog"]',
|
||||
);
|
||||
|
||||
if (await confirmationDialog.isVisible({ timeout: 2000 })) {
|
||||
console.log('Confirmation dialog appeared, clicking Continue...');
|
||||
await page.locator('[data-test="no-auth-dialog-continue"]').click();
|
||||
}
|
||||
|
||||
// Should redirect to team home after skipping
|
||||
await page.waitForURL(new RegExp('/home/[a-z0-9-]+'));
|
||||
|
||||
console.log(`✓ New user successfully joined team after identity setup`);
|
||||
|
||||
// Verify user is now a member
|
||||
await invitations.teamAccounts.openAccountsSelector();
|
||||
await expect(invitations.teamAccounts.getTeams()).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('existing users should skip /identities and go directly to team', async ({
|
||||
page,
|
||||
}) => {
|
||||
const invitations = new InvitationsPageObject(page);
|
||||
|
||||
// First, create a user account by signing up
|
||||
const existingUserEmail = 'test@makerkit.dev';
|
||||
|
||||
await invitations.setup();
|
||||
await invitations.navigateToMembers();
|
||||
|
||||
const invites = [
|
||||
{
|
||||
email: existingUserEmail,
|
||||
role: 'member',
|
||||
},
|
||||
];
|
||||
|
||||
console.log(`Sending invitation to existing user...`);
|
||||
|
||||
await invitations.openInviteForm();
|
||||
await invitations.inviteMembers(invites);
|
||||
|
||||
await expect(invitations.getInvitations()).toHaveCount(1);
|
||||
|
||||
// Sign out and click invitation as existing user
|
||||
await page.context().clearCookies();
|
||||
await page.reload();
|
||||
|
||||
console.log(`Existing user clicking invitation link...`);
|
||||
|
||||
// Click invitation link from email
|
||||
await invitations.auth.visitConfirmEmailLink(existingUserEmail, {
|
||||
deleteAfter: true,
|
||||
});
|
||||
|
||||
// Verify user lands on /join page
|
||||
await page.waitForURL('**/join?**');
|
||||
|
||||
// Click accept invitation button
|
||||
const acceptButton = page.locator(
|
||||
'[data-test="join-team-form"] button[type="submit"]',
|
||||
);
|
||||
|
||||
await acceptButton.click();
|
||||
|
||||
console.log(`Verifying existing user skips /identities...`);
|
||||
|
||||
// EXISTING USERS should skip /identities and go directly to team home
|
||||
await page.waitForURL(new RegExp('/home/[a-z0-9-]+'), { timeout: 5000 });
|
||||
|
||||
console.log(
|
||||
`✓ Existing user correctly skipped /identities and went directly to team`,
|
||||
);
|
||||
});
|
||||
|
||||
test('invitation links should work for 7 days (on-the-fly generation)', async ({
|
||||
page,
|
||||
}) => {
|
||||
const invitations = new InvitationsPageObject(page);
|
||||
await invitations.setup();
|
||||
|
||||
await invitations.navigateToMembers();
|
||||
|
||||
const newUserEmail = invitations.auth.createRandomEmail();
|
||||
|
||||
const invites = [
|
||||
{
|
||||
email: newUserEmail,
|
||||
role: 'member',
|
||||
},
|
||||
];
|
||||
|
||||
await invitations.openInviteForm();
|
||||
await invitations.inviteMembers(invites);
|
||||
|
||||
await expect(invitations.getInvitations()).toHaveCount(1);
|
||||
|
||||
// Get the invitation link from email
|
||||
console.log(`Getting invitation link from email...`);
|
||||
|
||||
// Sign out to access mailbox
|
||||
await page.context().clearCookies();
|
||||
await page.reload();
|
||||
|
||||
// Visit the invitation link
|
||||
await invitations.auth.visitConfirmEmailLink(newUserEmail, {
|
||||
deleteAfter: false, // Keep email for multiple clicks
|
||||
});
|
||||
|
||||
console.log(`✓ First click successful - user authenticated`);
|
||||
|
||||
// Verify we're on the join page
|
||||
await page.waitForURL('**/join?**');
|
||||
|
||||
// Don't accept yet - just verify the link works
|
||||
|
||||
console.log(`Simulating clicking link again (second time)...`);
|
||||
|
||||
// Clear session and click link again
|
||||
await page.context().clearCookies();
|
||||
|
||||
// Visit link again (simulating user clicking expired link)
|
||||
await invitations.auth.visitConfirmEmailLink(newUserEmail, {
|
||||
deleteAfter: false,
|
||||
});
|
||||
|
||||
console.log(`✓ Second click successful - link still works!`);
|
||||
|
||||
// Should still work and land on join page
|
||||
await page.waitForURL('**/join?**');
|
||||
|
||||
console.log(
|
||||
`✓ Invitation link works multiple times (on-the-fly token generation)`,
|
||||
);
|
||||
|
||||
// Now accept the invitation
|
||||
await invitations.acceptInvitation();
|
||||
|
||||
// Verify successful
|
||||
await invitations.teamAccounts.openAccountsSelector();
|
||||
await expect(invitations.teamAccounts.getTeams()).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Identity Setup Confirmation Dialog', () => {
|
||||
test('should show confirmation dialog when skipping without adding auth method', async ({
|
||||
page,
|
||||
}) => {
|
||||
const invitations = new InvitationsPageObject(page);
|
||||
await invitations.setup();
|
||||
|
||||
await invitations.navigateToMembers();
|
||||
|
||||
const newUserEmail = invitations.auth.createRandomEmail();
|
||||
|
||||
const invites = [
|
||||
{
|
||||
email: newUserEmail,
|
||||
role: 'member',
|
||||
},
|
||||
];
|
||||
|
||||
await invitations.openInviteForm();
|
||||
await invitations.inviteMembers(invites);
|
||||
|
||||
await expect(invitations.getInvitations()).toHaveCount(1);
|
||||
|
||||
// Sign out and accept invitation as new user
|
||||
await page.context().clearCookies();
|
||||
await page.reload();
|
||||
|
||||
console.log(`New user accepting invitation: ${newUserEmail}`);
|
||||
|
||||
await invitations.auth.visitConfirmEmailLink(newUserEmail);
|
||||
await page.waitForURL('**/join?**');
|
||||
|
||||
// Click accept invitation
|
||||
const acceptButton = page.locator(
|
||||
'[data-test="join-team-form"] button[type="submit"]',
|
||||
);
|
||||
await acceptButton.click();
|
||||
|
||||
// Should redirect to /identities
|
||||
await page.waitForURL('**/identities?**', { timeout: 5000 });
|
||||
|
||||
console.log(`✓ Redirected to /identities page`);
|
||||
|
||||
// Try to continue WITHOUT adding any auth method
|
||||
const continueButton = page.locator('[data-test="continue-button"]');
|
||||
await continueButton.click();
|
||||
|
||||
console.log(`Clicked continue without adding auth method...`);
|
||||
|
||||
// Confirmation dialog should appear
|
||||
const confirmDialog = page.locator('[data-test="no-auth-method-dialog"]');
|
||||
await expect(confirmDialog).toBeVisible({ timeout: 2000 });
|
||||
|
||||
console.log(`✓ Confirmation dialog appeared`);
|
||||
|
||||
// Verify dialog content
|
||||
await expect(
|
||||
page.locator('[data-test="no-auth-dialog-title"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.locator('[data-test="no-auth-dialog-description"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// Verify dialog has cancel and continue buttons
|
||||
const cancelButton = page.locator('[data-test="no-auth-dialog-cancel"]');
|
||||
const proceedButton = page.locator('[data-test="no-auth-dialog-continue"]');
|
||||
|
||||
await expect(cancelButton).toBeVisible();
|
||||
await expect(proceedButton).toBeVisible();
|
||||
|
||||
console.log(`✓ Dialog has correct content and buttons`);
|
||||
|
||||
// Click proceed to continue without auth
|
||||
await proceedButton.click();
|
||||
|
||||
// Should now redirect to team home
|
||||
await page.waitForURL(new RegExp('/home/[a-z0-9-]+'), { timeout: 5000 });
|
||||
|
||||
console.log(`✓ User successfully continued without adding auth method`);
|
||||
|
||||
// Verify user joined team
|
||||
await invitations.teamAccounts.openAccountsSelector();
|
||||
await expect(invitations.teamAccounts.getTeams()).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('should NOT show confirmation when user adds password', async ({
|
||||
page,
|
||||
}) => {
|
||||
const invitations = new InvitationsPageObject(page);
|
||||
await invitations.setup();
|
||||
|
||||
await invitations.navigateToMembers();
|
||||
|
||||
const newUserEmail = invitations.auth.createRandomEmail();
|
||||
|
||||
const invites = [
|
||||
{
|
||||
email: newUserEmail,
|
||||
role: 'member',
|
||||
},
|
||||
];
|
||||
|
||||
await invitations.openInviteForm();
|
||||
await invitations.inviteMembers(invites);
|
||||
|
||||
// Sign out and accept invitation
|
||||
await page.context().clearCookies();
|
||||
await page.reload();
|
||||
|
||||
console.log(`New user accepting invitation: ${newUserEmail}`);
|
||||
|
||||
await invitations.auth.visitConfirmEmailLink(newUserEmail);
|
||||
await page.waitForURL('**/join?**');
|
||||
|
||||
const acceptButton = page.locator(
|
||||
'[data-test="join-team-form"] button[type="submit"]',
|
||||
);
|
||||
await acceptButton.click();
|
||||
|
||||
// Should redirect to /identities
|
||||
await page.waitForURL('**/identities?**', { timeout: 5000 });
|
||||
|
||||
console.log(`Setting up password authentication...`);
|
||||
|
||||
// Click to open password dialog
|
||||
const passwordDialogTrigger = page.locator(
|
||||
'[data-test="open-password-dialog-trigger"]',
|
||||
);
|
||||
await passwordDialogTrigger.click();
|
||||
|
||||
// Wait for dialog to open
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Add password authentication
|
||||
const passwordInput = page.locator(
|
||||
'[data-test="account-password-form-password-input"]',
|
||||
);
|
||||
const confirmPasswordInput = page.locator(
|
||||
'[data-test="account-password-form-repeat-password-input"]',
|
||||
);
|
||||
|
||||
await passwordInput.fill('SecurePassword123!');
|
||||
await confirmPasswordInput.fill('SecurePassword123!');
|
||||
|
||||
const submitPasswordButton = page.locator(
|
||||
'[data-test="identity-form-submit"]',
|
||||
);
|
||||
await submitPasswordButton.click();
|
||||
|
||||
// Wait for password to be set
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
console.log(`✓ Password added`);
|
||||
|
||||
// Now click continue
|
||||
const continueButton = page.locator('[data-test="continue-button"]');
|
||||
await continueButton.click();
|
||||
|
||||
console.log(`Clicked continue after adding password...`);
|
||||
|
||||
// Confirmation dialog should NOT appear - should go directly to team
|
||||
await page.waitForURL(new RegExp('/home/[a-z0-9-]+'), { timeout: 5000 });
|
||||
|
||||
// Verify no dialog appeared
|
||||
const confirmDialog = page.locator('[data-test="no-auth-method-dialog"]');
|
||||
await expect(confirmDialog).not.toBeVisible();
|
||||
|
||||
console.log(
|
||||
`✓ No confirmation dialog shown - user added authentication method`,
|
||||
);
|
||||
|
||||
// Verify user joined team
|
||||
await invitations.teamAccounts.openAccountsSelector();
|
||||
await expect(invitations.teamAccounts.getTeams()).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('user can cancel confirmation dialog and return to add auth', async ({
|
||||
page,
|
||||
}) => {
|
||||
const invitations = new InvitationsPageObject(page);
|
||||
await invitations.setup();
|
||||
|
||||
await invitations.navigateToMembers();
|
||||
|
||||
const newUserEmail = invitations.auth.createRandomEmail();
|
||||
|
||||
const invites = [
|
||||
{
|
||||
email: newUserEmail,
|
||||
role: 'member',
|
||||
},
|
||||
];
|
||||
|
||||
await invitations.openInviteForm();
|
||||
await invitations.inviteMembers(invites);
|
||||
|
||||
// Sign out and accept invitation
|
||||
await page.context().clearCookies();
|
||||
await page.reload();
|
||||
|
||||
await invitations.auth.visitConfirmEmailLink(newUserEmail);
|
||||
await page.waitForURL('**/join?**');
|
||||
|
||||
const acceptButton = page.locator(
|
||||
'[data-test="join-team-form"] button[type="submit"]',
|
||||
);
|
||||
await acceptButton.click();
|
||||
|
||||
await page.waitForURL('**/identities?**');
|
||||
|
||||
console.log(`Trying to continue without adding auth...`);
|
||||
|
||||
// Try to continue without adding auth
|
||||
const continueButton = page.locator('[data-test="continue-button"]');
|
||||
await continueButton.click();
|
||||
|
||||
// Dialog should appear
|
||||
const confirmDialog = page.locator('[data-test="no-auth-method-dialog"]');
|
||||
await expect(confirmDialog).toBeVisible();
|
||||
|
||||
console.log(`✓ Confirmation dialog appeared`);
|
||||
|
||||
// Click cancel
|
||||
const cancelButton = page.locator('[data-test="no-auth-dialog-cancel"]');
|
||||
await cancelButton.click();
|
||||
|
||||
console.log(`Clicked cancel button...`);
|
||||
|
||||
// Dialog should close and stay on /identities page
|
||||
await expect(confirmDialog).not.toBeVisible();
|
||||
await expect(page).toHaveURL(/\/identities/);
|
||||
|
||||
console.log(`✓ Dialog closed, still on /identities page`);
|
||||
|
||||
// User can now add password - verify the password dialog trigger is available
|
||||
const passwordDialogTrigger = page.locator(
|
||||
'[data-test="open-password-dialog-trigger"]',
|
||||
);
|
||||
await expect(passwordDialogTrigger).toBeVisible();
|
||||
|
||||
console.log(`✓ User can continue to set up authentication`);
|
||||
});
|
||||
|
||||
test('should NOT show confirmation with email-only authentication', async ({
|
||||
page,
|
||||
}) => {
|
||||
// This test assumes email-only auth is configured
|
||||
// In that case, no confirmation dialog should appear even without adding methods
|
||||
|
||||
const invitations = new InvitationsPageObject(page);
|
||||
await invitations.setup();
|
||||
|
||||
await invitations.navigateToMembers();
|
||||
|
||||
const newUserEmail = invitations.auth.createRandomEmail();
|
||||
|
||||
const invites = [
|
||||
{
|
||||
email: newUserEmail,
|
||||
role: 'member',
|
||||
},
|
||||
];
|
||||
|
||||
await invitations.openInviteForm();
|
||||
await invitations.inviteMembers(invites);
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.reload();
|
||||
|
||||
await invitations.auth.visitConfirmEmailLink(newUserEmail);
|
||||
await page.waitForURL('**/join?**');
|
||||
|
||||
const acceptButton = page.locator(
|
||||
'[data-test="join-team-form"] button[type="submit"]',
|
||||
);
|
||||
await acceptButton.click();
|
||||
|
||||
// Check if redirected to /identities
|
||||
const urlAfterAccept = page.url();
|
||||
|
||||
if (urlAfterAccept.includes('/identities')) {
|
||||
console.log(
|
||||
`Redirected to /identities - checking for password dialog trigger...`,
|
||||
);
|
||||
|
||||
// If password dialog trigger is NOT available, this is email-only mode
|
||||
const passwordDialogTrigger = page.locator(
|
||||
'[data-test="open-password-dialog-trigger"]',
|
||||
);
|
||||
const isPasswordAvailable = await passwordDialogTrigger
|
||||
.isVisible({
|
||||
timeout: 1000,
|
||||
})
|
||||
.catch(() => false);
|
||||
|
||||
if (!isPasswordAvailable) {
|
||||
console.log(`✓ Email-only mode detected`);
|
||||
|
||||
// Try to continue
|
||||
const continueButton = page.locator('[data-test="continue-button"]');
|
||||
|
||||
if (await continueButton.isVisible({ timeout: 1000 })) {
|
||||
await continueButton.click();
|
||||
|
||||
// No confirmation dialog should appear in email-only mode
|
||||
const confirmDialog = page.locator(
|
||||
'[data-test="no-auth-method-dialog"]',
|
||||
);
|
||||
await expect(confirmDialog).not.toBeVisible({ timeout: 2000 });
|
||||
|
||||
console.log(
|
||||
`✓ No confirmation dialog in email-only mode - continuing directly`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify user can complete flow regardless
|
||||
console.log(`✓ User successfully completed invitation flow`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ The `[account]` parameter is the `accounts.slug` property, not the ID
|
||||
|
||||
## React Server Components - Async Pattern
|
||||
|
||||
**CRITICAL**: In Next.js 15, always await params directly in async server components:
|
||||
**CRITICAL**: In Next.js 16, always await params directly in async server components:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Don't use React.use() in async functions
|
||||
@@ -48,12 +48,12 @@ async function Page({ params }: Props) {
|
||||
const { account } = use(params);
|
||||
}
|
||||
|
||||
// ✅ CORRECT - await params directly in Next.js 15
|
||||
// ✅ CORRECT - await params directly in Next.js 16
|
||||
async function Page({ params }: Props) {
|
||||
const { account } = await params; // ✅ Server component pattern
|
||||
}
|
||||
|
||||
// ✅ CORRECT - "use" in non-async functions in Next.js 15
|
||||
// ✅ CORRECT - "use" in non-async functions in Next.js 16
|
||||
function Page({ params }: Props) {
|
||||
const { account } = use(params); // ✅ Server component pattern
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ The `[account]` parameter is the `accounts.slug` property, not the ID
|
||||
|
||||
## React Server Components - Async Pattern
|
||||
|
||||
**CRITICAL**: In Next.js 15, always await params directly in async server components:
|
||||
**CRITICAL**: In Next.js 16, always await params directly in async server components:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Don't use React.use() in async functions
|
||||
@@ -48,12 +48,12 @@ async function Page({ params }: Props) {
|
||||
const { account } = use(params);
|
||||
}
|
||||
|
||||
// ✅ CORRECT - await params directly in Next.js 15
|
||||
// ✅ CORRECT - await params directly in Next.js 16
|
||||
async function Page({ params }: Props) {
|
||||
const { account } = await params; // ✅ Server component pattern
|
||||
}
|
||||
|
||||
// ✅ CORRECT - "use" in non-async functions in Next.js 15
|
||||
// ✅ CORRECT - "use" in non-async functions in Next.js 16
|
||||
function Page({ params }: Props) {
|
||||
const { account } = use(params); // ✅ Server component pattern
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export default AdminGuard(AdminPageComponent);
|
||||
### Async Server Component Pattern
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Next.js 15 pattern
|
||||
// ✅ CORRECT - Next.js 16 pattern
|
||||
async function AdminPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params; // ✅ await params directly
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export default AdminGuard(AdminPageComponent);
|
||||
### Async Server Component Pattern
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Next.js 15 pattern
|
||||
// ✅ CORRECT - Next.js 16 pattern
|
||||
async function AdminPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params; // ✅ await params directly
|
||||
|
||||
|
||||
156
apps/web/app/identities/_components/identities-step-wrapper.tsx
Normal file
156
apps/web/app/identities/_components/identities-step-wrapper.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
|
||||
import { LinkAccountsList } from '@kit/accounts/personal-account-settings';
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
import { useUserIdentities } from '@kit/supabase/hooks/use-user-identities';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
interface IdentitiesStepWrapperProps {
|
||||
nextPath: string;
|
||||
showPasswordOption: boolean;
|
||||
showEmailOption: boolean;
|
||||
enableIdentityLinking: boolean;
|
||||
oAuthProviders: Provider[];
|
||||
requiresConfirmation: boolean;
|
||||
}
|
||||
|
||||
export function IdentitiesStepWrapper(props: IdentitiesStepWrapperProps) {
|
||||
const user = useUser();
|
||||
const { identities } = useUserIdentities();
|
||||
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [hasSetPassword, setHasSetPassword] = useState(false);
|
||||
const [hasLinkedProvider, setHasLinkedProvider] = useState(false);
|
||||
|
||||
const initialCountRef = useRef<number | null>(null);
|
||||
const initialHasPasswordRef = useRef<boolean | null>(null);
|
||||
|
||||
// Capture initial state once when data becomes available
|
||||
// Using refs to avoid re-renders and useEffect to avoid accessing refs during render
|
||||
useEffect(() => {
|
||||
if (initialCountRef.current === null && identities.length > 0) {
|
||||
const nonEmailIdentities = identities.filter(
|
||||
(identity) => identity.provider !== 'email',
|
||||
);
|
||||
|
||||
initialCountRef.current = nonEmailIdentities.length;
|
||||
}
|
||||
}, [identities]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialHasPasswordRef.current === null && user.data) {
|
||||
const amr = user.data.amr || [];
|
||||
|
||||
const hasPassword = amr.some(
|
||||
(item: { method: string }) => item.method === 'password',
|
||||
);
|
||||
|
||||
initialHasPasswordRef.current = hasPassword;
|
||||
}
|
||||
}, [user.data]);
|
||||
|
||||
const handleContinueClick = (e: React.MouseEvent) => {
|
||||
// Only show confirmation if password or oauth is enabled (requiresConfirmation)
|
||||
if (!props.requiresConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentNonEmailIdentities = identities.filter(
|
||||
(identity) => identity.provider !== 'email',
|
||||
);
|
||||
|
||||
const hasAddedNewIdentity =
|
||||
currentNonEmailIdentities.length > (initialCountRef.current ?? 0);
|
||||
|
||||
// Check if password was added
|
||||
const amr = user.data?.amr || [];
|
||||
|
||||
const currentHasPassword = amr.some(
|
||||
(item: { method: string }) => item.method === 'password',
|
||||
);
|
||||
|
||||
const hasAddedPassword =
|
||||
currentHasPassword && !initialHasPasswordRef.current;
|
||||
|
||||
// If no new identity was added AND no password was set AND no provider linked, show confirmation dialog
|
||||
if (
|
||||
!hasAddedNewIdentity &&
|
||||
!hasAddedPassword &&
|
||||
!hasSetPassword &&
|
||||
!hasLinkedProvider
|
||||
) {
|
||||
e.preventDefault();
|
||||
setShowConfirmDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
'animate-in fade-in slide-in-from-bottom-4 mx-auto flex w-full max-w-md flex-col space-y-4 duration-500'
|
||||
}
|
||||
data-test="join-step-two"
|
||||
>
|
||||
<LinkAccountsList
|
||||
providers={props.oAuthProviders}
|
||||
showPasswordOption={props.showPasswordOption}
|
||||
showEmailOption={props.showEmailOption}
|
||||
redirectTo={props.nextPath}
|
||||
enabled={props.enableIdentityLinking}
|
||||
onPasswordSet={() => setHasSetPassword(true)}
|
||||
onProviderLinked={() => setHasLinkedProvider(true)}
|
||||
/>
|
||||
|
||||
<Button asChild data-test="continue-button">
|
||||
<Link href={props.nextPath} onClick={handleContinueClick}>
|
||||
<Trans i18nKey={'common:continueKey'} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent data-test="no-auth-method-dialog">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle data-test="no-auth-dialog-title">
|
||||
<Trans i18nKey={'auth:noIdentityLinkedTitle'} />
|
||||
</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription data-test="no-auth-dialog-description">
|
||||
<Trans i18nKey={'auth:noIdentityLinkedDescription'} />
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel data-test="no-auth-dialog-cancel">
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction asChild data-test="no-auth-dialog-continue">
|
||||
<Link href={props.nextPath}>
|
||||
<Trans i18nKey={'common:continueKey'} />
|
||||
</Link>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,10 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
|
||||
import { LinkAccountsList } from '@kit/accounts/personal-account-settings';
|
||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
@@ -19,6 +14,8 @@ import pathsConfig from '~/config/paths.config';
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { IdentitiesStepWrapper } from './_components/identities-step-wrapper';
|
||||
|
||||
export const meta = async (): Promise<Metadata> => {
|
||||
const i18n = await createI18nServerInstance();
|
||||
|
||||
@@ -42,6 +39,7 @@ async function IdentitiesPage(props: IdentitiesPageProps) {
|
||||
showEmailOption,
|
||||
oAuthProviders,
|
||||
enableIdentityLinking,
|
||||
requiresConfirmation,
|
||||
} = await fetchData(props);
|
||||
|
||||
return (
|
||||
@@ -55,24 +53,30 @@ async function IdentitiesPage(props: IdentitiesPageProps) {
|
||||
}
|
||||
>
|
||||
<div className={'flex flex-col items-center gap-1'}>
|
||||
<Heading level={4} className="text-center">
|
||||
<Heading
|
||||
level={4}
|
||||
className="text-center"
|
||||
data-test="identities-page-heading"
|
||||
>
|
||||
<Trans i18nKey={'auth:linkAccountToSignIn'} />
|
||||
</Heading>
|
||||
|
||||
<Heading
|
||||
level={6}
|
||||
className={'text-muted-foreground text-center text-sm'}
|
||||
data-test="identities-page-description"
|
||||
>
|
||||
<Trans i18nKey={'auth:linkAccountToSignInDescription'} />
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<IdentitiesStep
|
||||
<IdentitiesStepWrapper
|
||||
nextPath={nextPath}
|
||||
showPasswordOption={showPasswordOption}
|
||||
showEmailOption={showEmailOption}
|
||||
oAuthProviders={oAuthProviders}
|
||||
enableIdentityLinking={enableIdentityLinking}
|
||||
requiresConfirmation={requiresConfirmation}
|
||||
/>
|
||||
</div>
|
||||
</AuthLayoutShell>
|
||||
@@ -81,42 +85,6 @@ async function IdentitiesPage(props: IdentitiesPageProps) {
|
||||
|
||||
export default withI18n(IdentitiesPage);
|
||||
|
||||
/**
|
||||
* @name IdentitiesStep
|
||||
* @description Displays linked accounts and available authentication methods.
|
||||
* LinkAccountsList component handles all authentication options including OAuth and Email/Password.
|
||||
*/
|
||||
function IdentitiesStep(props: {
|
||||
nextPath: string;
|
||||
showPasswordOption: boolean;
|
||||
showEmailOption: boolean;
|
||||
enableIdentityLinking: boolean;
|
||||
oAuthProviders: Provider[];
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'animate-in fade-in slide-in-from-bottom-4 mx-auto flex w-full max-w-md flex-col space-y-4 duration-500'
|
||||
}
|
||||
data-test="join-step-two"
|
||||
>
|
||||
<LinkAccountsList
|
||||
providers={props.oAuthProviders}
|
||||
showPasswordOption={props.showPasswordOption}
|
||||
showEmailOption={props.showEmailOption}
|
||||
redirectTo={props.nextPath}
|
||||
enabled={props.enableIdentityLinking}
|
||||
/>
|
||||
|
||||
<Button asChild data-test="skip-identities-button">
|
||||
<Link href={props.nextPath}>
|
||||
<Trans i18nKey={'common:continueKey'} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchData(props: IdentitiesPageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const client = getSupabaseServerClient();
|
||||
@@ -142,11 +110,16 @@ async function fetchData(props: IdentitiesPageProps) {
|
||||
const oAuthProviders = authConfig.providers.oAuth;
|
||||
const enableIdentityLinking = authConfig.enableIdentityLinking;
|
||||
|
||||
// Only require confirmation if password or oauth providers are enabled
|
||||
const requiresConfirmation =
|
||||
authConfig.providers.password || oAuthProviders.length > 0;
|
||||
|
||||
return {
|
||||
nextPath,
|
||||
showPasswordOption,
|
||||
showEmailOption,
|
||||
oAuthProviders,
|
||||
enableIdentityLinking,
|
||||
requiresConfirmation,
|
||||
};
|
||||
}
|
||||
|
||||
192
apps/web/app/join/accept/route.ts
Normal file
192
apps/web/app/join/accept/route.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { Database } from '~/lib/database.types';
|
||||
|
||||
/**
|
||||
* @name GET
|
||||
* @description Middleware route that validates team invitation and generates fresh auth link on-demand.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User clicks email link: /join/accept?invite_token=xxx
|
||||
* 2. Validate invitation exists and not expired (7-day window)
|
||||
* 3. Generate fresh Supabase auth link (new 24-hour token)
|
||||
* 4. Redirect to /auth/confirm with fresh token
|
||||
* 5. User authenticated immediately (token consumed right away)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const logger = await getLogger();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const inviteToken = searchParams.get('invite_token');
|
||||
|
||||
const ctx = {
|
||||
name: 'join.accept',
|
||||
inviteToken,
|
||||
};
|
||||
|
||||
// Validate invite token is provided
|
||||
if (!inviteToken) {
|
||||
logger.warn(ctx, 'Missing invite_token parameter');
|
||||
|
||||
return redirectToError('Invalid invitation link');
|
||||
}
|
||||
|
||||
try {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// Query invitation from database
|
||||
const { data: invitation, error: invitationError } = await adminClient
|
||||
.from('invitations')
|
||||
.select('*')
|
||||
.eq('invite_token', inviteToken)
|
||||
.gte('expires_at', new Date().toISOString())
|
||||
.single();
|
||||
|
||||
// Handle invitation not found or expired
|
||||
if (invitationError || !invitation) {
|
||||
logger.warn(
|
||||
{
|
||||
...ctx,
|
||||
error: invitationError,
|
||||
},
|
||||
'Invitation not found or expired',
|
||||
);
|
||||
|
||||
return redirectToError('Invitation not found or expired');
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
...ctx,
|
||||
invitationId: invitation.id,
|
||||
email: invitation.email,
|
||||
},
|
||||
'Valid invitation found. Generating auth link...',
|
||||
);
|
||||
|
||||
// Determine email link type based on user existence
|
||||
// 'invite' for new users (creates account + authenticates)
|
||||
// 'magiclink' for existing users (authenticates only)
|
||||
const emailLinkType = await determineEmailLinkType(
|
||||
adminClient,
|
||||
invitation.email,
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
...ctx,
|
||||
emailLinkType,
|
||||
email: invitation.email,
|
||||
},
|
||||
'Determined email link type for invitation',
|
||||
);
|
||||
|
||||
// Generate fresh Supabase auth link
|
||||
const generateLinkResponse = await adminClient.auth.admin.generateLink({
|
||||
email: invitation.email,
|
||||
type: emailLinkType,
|
||||
});
|
||||
|
||||
if (generateLinkResponse.error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error: generateLinkResponse.error,
|
||||
},
|
||||
'Failed to generate auth link',
|
||||
);
|
||||
|
||||
throw generateLinkResponse.error;
|
||||
}
|
||||
|
||||
// Extract token from generated link
|
||||
const verifyLink = generateLinkResponse.data.properties?.action_link;
|
||||
const token = new URL(verifyLink).searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
logger.error(ctx, 'Token not found in generated link');
|
||||
throw new Error('Token in verify link from Supabase Auth was not found');
|
||||
}
|
||||
|
||||
// Build redirect URL to auth confirmation with fresh token
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
|
||||
const authCallbackUrl = new URL('/auth/confirm', siteUrl);
|
||||
|
||||
// Add auth parameters
|
||||
authCallbackUrl.searchParams.set('token_hash', token);
|
||||
authCallbackUrl.searchParams.set('type', emailLinkType);
|
||||
|
||||
// Add next parameter to redirect to join page after auth
|
||||
const joinUrl = new URL(pathsConfig.app.joinTeam, siteUrl);
|
||||
joinUrl.searchParams.set('invite_token', inviteToken);
|
||||
|
||||
// Mark if this is a new user so /join page can redirect to /identities
|
||||
if (emailLinkType === 'invite') {
|
||||
joinUrl.searchParams.set('is_new_user', 'true');
|
||||
}
|
||||
|
||||
authCallbackUrl.searchParams.set('next', joinUrl.href);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
...ctx,
|
||||
redirectUrl: authCallbackUrl.pathname,
|
||||
},
|
||||
'Redirecting to auth confirmation with fresh token',
|
||||
);
|
||||
|
||||
// Redirect to auth confirmation
|
||||
return NextResponse.redirect(authCallbackUrl);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error,
|
||||
},
|
||||
'Failed to process invitation acceptance',
|
||||
);
|
||||
|
||||
return redirectToError('An error occurred processing your invitation');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name determineEmailLinkType
|
||||
* @description Determines whether to use 'invite' or 'magiclink' based on user existence
|
||||
*/
|
||||
async function determineEmailLinkType(
|
||||
adminClient: SupabaseClient<Database>,
|
||||
email: string,
|
||||
): Promise<'invite' | 'magiclink'> {
|
||||
const user = await adminClient
|
||||
.from('accounts')
|
||||
.select('*')
|
||||
.eq('email', email)
|
||||
.single();
|
||||
|
||||
// If user not found, return 'invite' type (allows registration)
|
||||
if (user.error || !user.data) {
|
||||
return 'invite';
|
||||
}
|
||||
|
||||
// If user exists, return 'magiclink' type (sign in)
|
||||
return 'magiclink';
|
||||
}
|
||||
|
||||
/**
|
||||
* @name redirectToError
|
||||
* @description Redirects to join page with error message
|
||||
*/
|
||||
function redirectToError(message: string): NextResponse {
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
|
||||
const errorUrl = new URL(pathsConfig.app.joinTeam, siteUrl);
|
||||
|
||||
errorUrl.searchParams.set('error', message);
|
||||
|
||||
return NextResponse.redirect(errorUrl);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ interface JoinTeamAccountPageProps {
|
||||
invite_token?: string;
|
||||
type?: 'invite' | 'magic-link';
|
||||
email?: string;
|
||||
is_new_user?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -131,16 +132,18 @@ async function JoinTeamAccountPage(props: JoinTeamAccountPageProps) {
|
||||
|
||||
// Determine if we should show the account setup step (Step 2)
|
||||
// Decision logic:
|
||||
// 1. Only show for new accounts (linkType === 'invite')
|
||||
// 2. Only if we have auth options available (password OR OAuth)
|
||||
// 1. Only show for new accounts (is_new_user === 'true' or linkType === 'invite')
|
||||
// 2. Only if we don't support email only auth (magic link or OTP)
|
||||
// 3. Users can always skip and set up auth later in account settings
|
||||
const linkType = searchParams.type;
|
||||
const supportsPasswordSignUp = authConfig.providers.password;
|
||||
const supportsOAuthProviders = authConfig.providers.oAuth.length > 0;
|
||||
const isNewAccount = linkType === 'invite';
|
||||
const isNewUserParam = searchParams.is_new_user === 'true';
|
||||
|
||||
const shouldSetupAccount =
|
||||
isNewAccount && (supportsPasswordSignUp || supportsOAuthProviders);
|
||||
// if the app supports email only auth, we don't need to setup any other auth methods. In all other cases (passowrd, oauth), we need to setup at least one of them.
|
||||
const supportsEmailOnlyAuth =
|
||||
authConfig.providers.magicLink || authConfig.providers.otp;
|
||||
|
||||
const isNewAccount = isNewUserParam || linkType === 'invite';
|
||||
const shouldSetupAccount = isNewAccount && !supportsEmailOnlyAuth;
|
||||
|
||||
// Determine redirect destination after joining:
|
||||
// - If shouldSetupAccount: redirect to /identities with next param (Step 2)
|
||||
|
||||
@@ -80,6 +80,8 @@
|
||||
"existingAccountHint": "You previously signed in with <method>{{method}}</method>. <signInLink>Already have an account?</signInLink>",
|
||||
"linkAccountToSignIn": "Link account to sign in",
|
||||
"linkAccountToSignInDescription": "Add one or more sign-in methods to your account",
|
||||
"noIdentityLinkedTitle": "No authentication method added",
|
||||
"noIdentityLinkedDescription": "You haven't added any authentication methods yet. Are you sure you want to continue? You can set up sign-in methods later in your personal account settings.",
|
||||
"errors": {
|
||||
"Invalid login credentials": "The credentials entered are invalid",
|
||||
"User already registered": "This credential is already in use. Please try with another one.",
|
||||
|
||||
Reference in New Issue
Block a user