Next.js Supabase V3 (#463)

Version 3 of the kit:
- Radix UI replaced with Base UI (using the Shadcn UI patterns)
- next-intl replaces react-i18next
- enhanceAction deprecated; usage moved to next-safe-action
- main layout now wrapped with [locale] path segment
- Teams only mode
- Layout updates
- Zod v4
- Next.js 16.2
- Typescript 6
- All other dependencies updated
- Removed deprecated Edge CSRF
- Dynamic Github Action runner
This commit is contained in:
Giancarlo Buomprisco
2026-03-24 13:40:38 +08:00
committed by GitHub
parent 4912e402a3
commit 7ebff31475
840 changed files with 71395 additions and 20095 deletions

View File

@@ -2,7 +2,13 @@ import Link from 'next/link';
import { cn } from '@kit/ui/utils';
function LogoImage({
/**
* App Logo Image - modify this with your own logo
* @param className - The class name to apply to the logo
* @param width - The width of the logo
* @returns
*/
export function LogoImage({
className,
width = 105,
}: {
@@ -12,7 +18,7 @@ function LogoImage({
return (
<svg
width={width}
className={cn(`w-[80px] lg:w-[95px]`, className)}
className={cn(`w-20 lg:w-[95px]`, className)}
viewBox="0 0 733 140"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@@ -40,7 +46,12 @@ export function AppLogo({
}
return (
<Link aria-label={label ?? 'Home Page'} href={href ?? '/'} prefetch={true}>
<Link
aria-label={label ?? 'Home Page'}
href={href ?? '/'}
prefetch={true}
className="mx-auto md:mx-0"
>
<LogoImage className={className} />
</Link>
);

View File

@@ -13,8 +13,8 @@ export function ErrorPageContent({
subtitle,
reset,
backLink = '/',
backLabel = 'common:backToHomePage',
contactLabel = 'common:contactUs',
backLabel = 'common.backToHomePage',
contactLabel = 'common.contactUs',
}: {
statusCode: string;
heading: string;
@@ -67,20 +67,27 @@ export function ErrorPageContent({
<Trans i18nKey={backLabel} />
</Button>
) : (
<Button asChild>
<Link href={backLink}>
<ArrowLeft className={'mr-1 h-4 w-4'} />
<Trans i18nKey={backLabel} />
</Link>
</Button>
<Button
nativeButton={false}
render={
<Link href={backLink}>
<ArrowLeft className={'mr-1 h-4 w-4'} />
<Trans i18nKey={backLabel} />
</Link>
}
/>
)}
<Button asChild variant={'ghost'}>
<Link href={'/contact'}>
<MessageCircleQuestion className={'mr-1 h-4 w-4'} />
<Trans i18nKey={contactLabel} />
</Link>
</Button>
<Button
nativeButton={false}
render={
<Link href={'/contact'}>
<MessageCircleQuestion className={'mr-1 h-4 w-4'} />
<Trans i18nKey={contactLabel} />
</Link>
}
variant={'ghost'}
/>
</div>
</div>
</div>

View File

@@ -8,10 +8,6 @@ import { JWTUserData } from '@kit/supabase/types';
import featuresFlagConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
const paths = {
home: pathsConfig.app.home,
};
const features = {
enableThemeToggle: featuresFlagConfig.enableThemeToggle,
};
@@ -19,6 +15,7 @@ const features = {
export function ProfileAccountDropdownContainer(props: {
user?: JWTUserData | null;
showProfileName?: boolean;
accountSlug?: string;
account?: {
id: string | null;
@@ -34,10 +31,15 @@ export function ProfileAccountDropdownContainer(props: {
return null;
}
const homePath =
featuresFlagConfig.enableTeamsOnly && props.accountSlug
? pathsConfig.app.accountHome.replace('[account]', props.accountSlug)
: pathsConfig.app.home;
return (
<PersonalAccountDropdown
className={'w-full'}
paths={paths}
paths={{ home: homePath }}
features={features}
user={userData}
account={props.account}

View File

@@ -1,12 +1,12 @@
'use client';
import { useMemo } from 'react';
import type { AbstractIntlMessages } from 'next-intl';
import { ThemeProvider } from 'next-themes';
import { I18nProvider } from '@kit/i18n/provider';
import { I18nClientProvider } from '@kit/i18n/provider';
import { MonitoringProvider } from '@kit/monitoring/components';
import { AppEventsProvider } from '@kit/shared/events';
import { CSPProvider } from '@kit/ui/csp-provider';
import { If } from '@kit/ui/if';
import { VersionUpdater } from '@kit/ui/version-updater';
@@ -14,52 +14,52 @@ import { AnalyticsProvider } from '~/components/analytics-provider';
import { AuthProvider } from '~/components/auth-provider';
import appConfig from '~/config/app.config';
import featuresFlagConfig from '~/config/feature-flags.config';
import { i18nResolver } from '~/lib/i18n/i18n.resolver';
import { getI18nSettings } from '~/lib/i18n/i18n.settings';
import { ReactQueryProvider } from './react-query-provider';
type RootProvidersProps = React.PropsWithChildren<{
// The language to use for the app (optional)
lang?: string;
locale?: string;
// The theme (light or dark or system) (optional)
theme?: string;
// The CSP nonce to pass to scripts (optional)
nonce?: string;
messages: AbstractIntlMessages;
}>;
export function RootProviders({
lang,
locale = 'en',
messages,
theme = appConfig.theme,
nonce,
children,
}: RootProvidersProps) {
const i18nSettings = useMemo(() => getI18nSettings(lang), [lang]);
return (
<MonitoringProvider>
<AppEventsProvider>
<AnalyticsProvider>
<ReactQueryProvider>
<I18nProvider settings={i18nSettings} resolver={i18nResolver}>
<AuthProvider>
<ThemeProvider
attribute="class"
enableSystem
disableTransitionOnChange
defaultTheme={theme}
enableColorScheme={false}
nonce={nonce}
>
{children}
</ThemeProvider>
</AuthProvider>
<CSPProvider nonce={nonce}>
<ReactQueryProvider>
<I18nClientProvider locale={locale!} messages={messages}>
<AuthProvider>
<ThemeProvider
attribute="class"
enableSystem
disableTransitionOnChange
defaultTheme={theme}
enableColorScheme={false}
nonce={nonce}
>
{children}
</ThemeProvider>
</AuthProvider>
<If condition={featuresFlagConfig.enableVersionUpdater}>
<VersionUpdater />
</If>
</I18nProvider>
</ReactQueryProvider>
<If condition={featuresFlagConfig.enableVersionUpdater}>
<VersionUpdater />
</If>
</I18nClientProvider>
</ReactQueryProvider>
</CSPProvider>
</AnalyticsProvider>
</AppEventsProvider>
</MonitoringProvider>

View File

@@ -0,0 +1,381 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
Check,
ChevronsUpDown,
LogOut,
MessageCircleQuestion,
Plus,
Settings,
Shield,
User,
Users,
} from 'lucide-react';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { JWTUserData } from '@kit/supabase/types';
import { CreateTeamAccountDialog } from '@kit/team-accounts/components';
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
import { Button } from '@kit/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { If } from '@kit/ui/if';
import { SubMenuModeToggle } from '@kit/ui/mode-toggle';
import { ProfileAvatar } from '@kit/ui/profile-avatar';
import { useSidebar } from '@kit/ui/sidebar';
import { Trans } from '@kit/ui/trans';
import featuresFlagConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
export type AccountModel = {
label: string | null;
value: string | null;
image: string | null;
};
interface WorkspaceDropdownProps {
user: JWTUserData;
accounts: AccountModel[];
selectedAccount?: string;
workspace?: {
id: string | null;
name: string | null;
picture_url: string | null;
};
}
export function WorkspaceDropdown({
user,
accounts,
selectedAccount,
workspace,
}: WorkspaceDropdownProps) {
const router = useRouter();
const { open: isSidebarOpen } = useSidebar();
const signOutMutation = useSignOut();
const [isCreatingTeam, setIsCreatingTeam] = useState(false);
const collapsed = !isSidebarOpen;
const isTeamContext = !!selectedAccount;
const { data: personalAccountData } = usePersonalAccountData(
user.id,
workspace,
);
const displayName = personalAccountData?.name ?? user.email ?? '';
const userEmail = user.email ?? '';
const isSuperAdmin =
user.app_metadata.role === 'super-admin' && user.aal === 'aal2';
const currentTeam = accounts.find((a) => a.value === selectedAccount);
const currentLabel = isTeamContext
? (currentTeam?.label ?? selectedAccount)
: displayName;
const currentAvatar = isTeamContext
? (currentTeam?.image ?? null)
: (personalAccountData?.picture_url ?? null);
const settingsPath = selectedAccount
? pathsConfig.app.accountSettings.replace('[account]', selectedAccount)
: pathsConfig.app.personalAccountSettings;
const switchToPersonal = () => {
if (!featuresFlagConfig.enableTeamsOnly) {
router.replace(pathsConfig.app.home);
}
};
const switchToTeam = (slug: string) => {
router.replace(pathsConfig.app.accountHome.replace('[account]', slug));
};
return (
<div className="min-w-0 flex-1">
<DropdownMenu>
{collapsed ? (
<div className="flex flex-col items-center justify-center">
<DropdownMenuTrigger
render={
<Button
data-test="workspace-dropdown-trigger"
variant="secondary"
size="icon"
className="border-border hover:shadow"
>
<Avatar className="size-8">
<AvatarImage
className="rounded-md!"
src={currentAvatar ?? undefined}
alt={currentLabel ?? ''}
/>
<AvatarFallback>
{isTeamContext ? (
(currentLabel ?? '').charAt(0).toUpperCase()
) : (
<User className="text-secondary-foreground size-4" />
)}
</AvatarFallback>
</Avatar>
</Button>
}
/>
</div>
) : (
<DropdownMenuTrigger
render={
<Button
data-test="workspace-dropdown-trigger"
variant="ghost"
className="hover:bg-accent/40 active:bg-accent border-border/50! hover:border-border h-11 w-full justify-start gap-x-1 rounded-md border px-1 transition-colors hover:shadow-xs"
>
<span className="flex aspect-square size-8 items-center justify-center">
<Avatar className="size-6">
<AvatarImage
src={currentAvatar ?? undefined}
alt={currentLabel ?? ''}
/>
<AvatarFallback>
{isTeamContext ? (
(currentLabel ?? '').charAt(0).toUpperCase()
) : (
<User className="size-4" />
)}
</AvatarFallback>
</Avatar>
</span>
<span className="grid flex-1 text-left text-sm leading-tight">
<span className="max-w-md truncate">{currentLabel}</span>
</span>
<ChevronsUpDown className="ml-auto size-4 transition-opacity duration-300" />
</Button>
}
/>
)}
<DropdownMenuContent
className="min-w-60!"
align="center"
side={isSidebarOpen ? 'bottom' : 'inline-end'}
sideOffset={4}
alignOffset={8}
>
<div className="flex items-center justify-start gap-2 py-1.5">
<div className="w-2/12">
<ProfileAvatar
className="size-6"
displayName={displayName}
pictureUrl={personalAccountData?.picture_url}
/>
</div>
<div className="flex w-10/12 flex-col text-left text-sm">
<span
className="max-w-max truncate font-medium"
data-test="account-dropdown-display-name"
>
{displayName}
</span>
<span className="text-muted-foreground max-w-max truncate text-xs">
{userEmail}
</span>
</div>
</div>
<DropdownMenuSeparator />
<If condition={featuresFlagConfig.enableTeamAccounts}>
<DropdownMenuSub>
<DropdownMenuSubTrigger data-test="workspace-switch-submenu">
<Users className="size-4" />
<span>
<Trans i18nKey={'teams.switchWorkspace'} />
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="max-h-[50vh] overflow-y-auto"
data-test="workspace-switch-content"
>
<If condition={!featuresFlagConfig.enableTeamsOnly}>
<DropdownMenuItem
data-test="personal-workspace-item"
className="flex gap-2"
onClick={switchToPersonal}
>
<div className="flex size-8 items-center justify-center rounded-sm border">
<User className="size-4" />
</div>
<span className="flex-1">
<Trans i18nKey={'teams.personalAccount'} />
</span>
{!isTeamContext && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
</If>
{accounts.length > 0 && (
<>
<If condition={!featuresFlagConfig.enableTeamsOnly}>
<DropdownMenuSeparator />
</If>
{accounts.map((account) => (
<DropdownMenuItem
key={account.value}
data-test="workspace-team-item"
data-name={account.label}
data-slug={account.value}
className="flex gap-2"
onClick={() => {
if (
account.value &&
account.value !== selectedAccount
) {
switchToTeam(account.value);
}
}}
>
<Avatar className="size-8">
<AvatarImage
className="rounded-md!"
src={account.image ?? undefined}
alt={account.label ?? ''}
/>
<AvatarFallback className="rounded-md! text-xs">
{(account.label ?? '').charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="font-medium">{account.label}</div>
</div>
{selectedAccount === account.value && (
<Check className="ml-auto size-4" />
)}
</DropdownMenuItem>
))}
</>
)}
<If condition={featuresFlagConfig.enableTeamCreation}>
<DropdownMenuItem
onClick={() => setIsCreatingTeam(true)}
data-test="create-team-trigger"
className="bg-background/50 sticky bottom-0 mt-1 flex h-10 w-full gap-2 border backdrop-blur-lg"
>
<Plus className="size-4" />
<span>
<Trans i18nKey={'teams.createTeam'} />
</span>
</DropdownMenuItem>
</If>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
</If>
<DropdownMenuItem
render={
<Link
className="flex items-center gap-x-2"
href={settingsPath}
data-test="workspace-settings-link"
>
<Settings className="size-4" />
<span>
<Trans i18nKey={'common.routes.settings'} />
</span>
</Link>
}
/>
<If condition={isSuperAdmin}>
<DropdownMenuItem
render={
<Link
className="flex items-center gap-x-2 text-yellow-700 hover:text-yellow-600 dark:text-yellow-500"
href="/admin"
data-test="workspace-admin-link"
>
<Shield className="size-4" />
<span>Super Admin</span>
</Link>
}
/>
</If>
<DropdownMenuSeparator />
<DropdownMenuItem
render={
<Link className="flex items-center gap-x-2" href="/docs">
<MessageCircleQuestion className="size-4" />
<span>
<Trans i18nKey={'common.documentation'} />
</span>
</Link>
}
/>
<DropdownMenuSeparator />
<If condition={featuresFlagConfig.enableThemeToggle}>
<SubMenuModeToggle />
<DropdownMenuSeparator />
</If>
<DropdownMenuItem
disabled={signOutMutation.isPending}
className="flex items-center gap-x-2"
data-test="workspace-sign-out"
onClick={() => signOutMutation.mutate()}
>
<LogOut className="size-4" />
<span>
<Trans i18nKey={'auth.signOut'} />
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<If condition={featuresFlagConfig.enableTeamCreation}>
<CreateTeamAccountDialog
isOpen={isCreatingTeam}
setIsOpen={setIsCreatingTeam}
/>
</If>
</div>
);
}