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:
Giancarlo Buomprisco
2025-11-05 11:39:08 +07:00
committed by GitHub
parent ae404d8366
commit fa2fa9a15c
23 changed files with 1005 additions and 154 deletions

View File

@@ -5,7 +5,7 @@ model: sonnet
color: red
---
You are an elite code quality reviewer specializing in TypeScript, React, Next.js 15, and Supabase architectures. You have deep expertise in the Makerkit SaaS framework and its specific patterns, conventions, and best practices. Your mission is to ensure code meets the highest standards of quality, security, and maintainability while adhering to project-specific requirements.
You are an elite code quality reviewer specializing in TypeScript, React, Next.js 16, and Supabase architectures. You have deep expertise in the Makerkit SaaS framework and its specific patterns, conventions, and best practices. Your mission is to ensure code meets the highest standards of quality, security, and maintainability while adhering to project-specific requirements.
**Your Review Process:**
@@ -20,7 +20,7 @@ You will analyze recently written or modified code against these critical criter
- Ensure 'server-only' is added to exclusively server-side code
- Verify no mixing of client and server imports from the same file or package
**React & Next.js 15 Compliance:**
**React & Next.js 16 Compliance:**
- Confirm only functional components are used with proper 'use client' directives
- Check that repeated code blocks are encapsulated into reusable local components
- Flag any useEffect usage as a code smell requiring justification

View File

@@ -1,13 +1,15 @@
# Makerkit Guidelines
## Project Stack
- Framework: Next.js 15 App Router, TypeScript, React, Node.js
- Framework: Next.js 16 App Router, TypeScript, React, Node.js
- Backend: Supabase with Postgres
- UI: Shadcn UI, Tailwind CSS
- Key libraries: React Hook Form, React Query, Zod, Lucide React
- Focus: Code clarity, Readability, Best practices, Maintainability
## Project Structure
```
/apps/web/
/app
@@ -26,6 +28,7 @@
## Core Principles
### Data Flow
1. Server Components
- Use Supabase Client directly via `getSupabaseServerClient`
- Handle errors with proper boundaries
@@ -50,13 +53,14 @@
queryFn: async () => {
const { data } = await fetch('/api/notes');
return data;
}
},
});
return { data, isLoading };
}
```
### Server Actions
- Name files as "server-actions.ts" in `_lib/server` folder
- Export with "Action" suffix
- Use `enhanceAction` with proper typing
@@ -74,7 +78,7 @@
{
auth: true,
schema: NoteSchema,
}
},
);
```
@@ -86,6 +90,7 @@
## Database & Security
### RLS Policies
- Strive to create a safe, robust, secure and consistent database schema
- Always consider the compromises you need to make and explain them so I can make an educated decision. Follow up with the considerations make and explain them.
- Enable RLS by default and propose the required RLS policies
@@ -97,6 +102,7 @@
## Forms Pattern
### 1. Schema Definition
```tsx
// schema/note.schema.ts
import { z } from 'zod';
@@ -109,6 +115,7 @@ export const NoteSchema = z.object({
```
### 2. Form Component
```tsx
'use client';
@@ -116,7 +123,7 @@ export function NoteForm() {
const [pending, startTransition] = useTransition();
const form = useForm({
resolver: zodResolver(NoteSchema),
defaultValues: { title: '', content: '', category: 'personal' }
defaultValues: { title: '', content: '', category: 'personal' },
});
const onSubmit = (data: z.infer<typeof NoteSchema>) => {
@@ -132,15 +139,18 @@ export function NoteForm() {
return (
<Form {...form}>
<FormField name="title" render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Other fields */}
</Form>
);
@@ -154,11 +164,12 @@ export function NoteForm() {
- Consider the unhappy path and handle errors appropriately
### Structured Logging
```tsx
const ctx = {
name: 'create-note',
userId: user.id,
noteId: note.id
noteId: note.id,
};
logger.info(ctx, 'Creating new note...');
@@ -177,6 +188,7 @@ try {
In client components, we can use the `useUserWorkspace` hook to access the user's workspace data.
### Personal Account
```tsx
'use client';
@@ -194,6 +206,7 @@ function PersonalDashboard() {
```
### Team Account
In client components, we can use the `useTeamAccountWorkspace` hook to access the team account's workspace data. It only works under the `/home/[account]` route.
```tsx
@@ -220,6 +233,7 @@ function TeamDashboard() {
## Creating Pages
When creating new pages ensure:
- The page is exported using `withI18n(Page)` to enable i18n.
- The page has the required and correct metadata using the `metadata` or `generateMetadata` function.
- Don't worry about authentication, it's handled in the middleware.

View File

@@ -2,7 +2,7 @@ This file provides guidance to Claude Code when working with code in this reposi
## Core Technologies
- **Next.js 15** with App Router
- **Next.js 16** with App Router
- **Supabase** for database, auth, and storage
- **React 19**
- **TypeScript**

View File

@@ -2,7 +2,7 @@ This file provides guidance to Claude Code when working with code in this reposi
## Core Technologies
- **Next.js 15** with App Router
- **Next.js 16** with App Router
- **Supabase** for database, auth, and storage
- **React 19**
- **TypeScript**

View File

@@ -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) {

View File

@@ -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

View File

@@ -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`);
});
});

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View 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>
</>
);
}

View File

@@ -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,
};
}

View 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);
}

View File

@@ -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)

View File

@@ -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.",

View File

@@ -1,6 +1,6 @@
{
"name": "next-supabase-saas-kit-turbo",
"version": "2.20.2",
"version": "2.21.0",
"private": true,
"sideEffects": false,
"engines": {

View File

@@ -55,12 +55,18 @@ interface LinkAccountsListProps {
showEmailOption?: boolean;
enabled?: boolean;
redirectTo?: string;
onPasswordSet?: () => void;
onProviderLinked?: () => void;
}
export function LinkAccountsList(props: LinkAccountsListProps) {
const unlinkMutation = useUnlinkUserIdentity();
const linkMutation = useLinkIdentityWithProvider();
const pathname = usePathname();
const user = useUser();
const linkMutation = useLinkIdentityWithProvider({
redirectToPath: props.redirectTo,
});
const {
identities,
@@ -81,7 +87,6 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
? props.providers.filter((provider) => !isProviderConnected(provider))
: [];
const user = useUser();
const amr = user.data ? user.data.amr : [];
const isConnectedWithPassword = amr.some(
@@ -118,7 +123,10 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
* @param provider
*/
const handleLinkAccount = (provider: Provider) => {
const promise = linkMutation.mutateAsync(provider);
const promise = linkMutation.mutateAsync(provider).then((result) => {
props.onProviderLinked?.();
return result;
});
toast.promise(promise, {
loading: <Trans i18nKey={'account:linkingAccount'} />,
@@ -252,6 +260,7 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
<UpdatePasswordDialog
userEmail={userEmail}
redirectTo={props.redirectTo || '/home'}
onPasswordSet={props.onPasswordSet}
/>
</If>
@@ -358,12 +367,13 @@ function UpdateEmailDialog(props: { redirectTo: string }) {
function UpdatePasswordDialog(props: {
redirectTo: string;
userEmail: string;
onPasswordSet?: () => void;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DialogTrigger asChild data-test="open-password-dialog-trigger">
<Item variant="outline" role="button" className="hover:bg-muted/50">
<ItemMedia>
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center">
@@ -406,6 +416,7 @@ function UpdatePasswordDialog(props: {
email={props.userEmail}
onSuccess={() => {
setOpen(false);
props.onPasswordSet?.();
}}
/>
</Suspense>

View File

@@ -98,7 +98,7 @@ export const UpdatePasswordForm = ({
return (
<Form {...form}>
<form
data-test={'account-password-form'}
data-test="identity-form"
onSubmit={form.handleSubmit(updatePasswordCallback)}
>
<div className={'flex flex-col space-y-4'}>
@@ -178,7 +178,10 @@ export const UpdatePasswordForm = ({
</div>
<div>
<Button disabled={updateUserMutation.isPending}>
<Button
disabled={updateUserMutation.isPending}
data-test="identity-form-submit"
>
<Trans i18nKey={'account:updatePasswordSubmitLabel'} />
</Button>
</div>

View File

@@ -151,6 +151,21 @@ class AccountInvitationsDispatchService {
return url.href;
}
/**
* @name getAcceptInvitationLink
* @description Generates an internal link that validates invitation and generates auth token on-demand.
* This solves the 24-hour Supabase auth token expiry issue by generating fresh tokens when clicked.
* @param token - The invitation token to use
*/
getAcceptInvitationLink(token: string) {
const siteUrl = env.siteURL;
const url = new URL('/join/accept', siteUrl);
url.searchParams.set('invite_token', token);
return url.href;
}
/**
* @name sendEmail
* @description Sends an invitation email to the invited user

View File

@@ -7,7 +7,6 @@ import { z } from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import type { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
import type { InviteMembersSchema } from '../../schema/invite-members.schema';
@@ -344,71 +343,13 @@ class AccountInvitationsService {
}
const logger = await getLogger();
const adminClient = getSupabaseServerAdminClient();
const service = createAccountInvitationsDispatchService(this.client);
const results = await Promise.allSettled(
invitations.map(async (invitation) => {
const joinTeamLink = service.getInvitationLink(invitation.invite_token);
const authCallbackUrl = service.getAuthCallbackUrl(joinTeamLink);
const getEmailLinkType = async () => {
const user = await adminClient
.from('accounts')
.select('*')
.eq('email', invitation.email)
.single();
// if the user is not found, return the invite type
// this link allows the user to register to the platform
if (user.error || !user.data) {
return 'invite';
}
// if the user is found, return the email link type to sign in
return 'magiclink';
};
const emailLinkType = await getEmailLinkType();
// generate an invitation link with Supabase admin client
// use the "redirectTo" parameter to redirect the user to the invitation page after the link is clicked
const generateLinkResponse = await adminClient.auth.admin.generateLink({
email: invitation.email,
type: emailLinkType,
});
// if the link generation fails, throw an error
if (generateLinkResponse.error) {
logger.error(
{
...ctx,
error: generateLinkResponse.error,
},
'Failed to generate link',
);
throw generateLinkResponse.error;
}
// get the link from the response
const verifyLink = generateLinkResponse.data.properties?.action_link;
// extract token
const token = new URL(verifyLink).searchParams.get('token');
if (!token) {
// return error
throw new Error(
'Token in verify link from Supabase Auth was not found',
);
}
// add search params to be consumed by /auth/confirm route
authCallbackUrl.searchParams.set('token_hash', token);
authCallbackUrl.searchParams.set('type', emailLinkType);
const link = authCallbackUrl.href;
// Generate internal link that will validate and generate auth token on-demand
// This solves the 24-hour auth token expiry issue
const link = service.getAcceptInvitationLink(invitation.invite_token);
// send the invitation email
const data = await service.sendInvitationEmail({

View File

@@ -75,7 +75,7 @@ export class PromptsManager {
- Proper error handling with try/catch and typed error objects
- Clean, clear, well-designed code without obvious comments
**React & Next.js 15 Best Practices:**
**React & Next.js 16 Best Practices:**
- Functional components only with 'use client' directive for client components
- Encapsulate repeated blocks of code into reusable local components
- Avoid useEffect (code smell) - justify if absolutely necessary