Next.js 16, React 19.2, Identities page, Invitations identities step, PNPM Catalogs (#381)

* Upgraded to Next.js 16
* Refactored code to comply with React 19.2 ESLint rules
* Refactored some useEffect usages with the new useEffectEvent
* Added Identities page and added second step to set up an identity after accepting an invitation
* Updated all dependencies
* Introduced PNPM catalogs for some frequently updated dependencies
* Bugs fixing and improvements
This commit is contained in:
Giancarlo Buomprisco
2025-10-22 11:47:47 +09:00
committed by GitHub
parent ea0c1dde80
commit 2c0d0bf7a1
98 changed files with 4812 additions and 4394 deletions

View File

@@ -34,17 +34,17 @@
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.58.0",
"@tanstack/react-query": "5.90.2",
"@types/react": "19.1.16",
"@types/react-dom": "19.1.9",
"lucide-react": "^0.544.0",
"next": "15.5.5",
"@supabase/supabase-js": "2.76.1",
"@tanstack/react-query": "5.90.5",
"@types/react": "catalog:",
"@types/react-dom": "19.2.2",
"lucide-react": "^0.546.0",
"next": "16.0.0",
"next-themes": "0.4.6",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-hook-form": "^7.63.0",
"react-i18next": "^16.0.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-hook-form": "^7.65.0",
"react-i18next": "^16.1.4",
"zod": "^3.25.74"
},
"prettier": "@kit/prettier-config",

View File

@@ -68,27 +68,9 @@ export function AccountSelector({
return selectedAccount ?? PERSONAL_ACCOUNT_SLUG;
}, [selectedAccount]);
const Icon = (props: { item: string }) => {
return (
<CheckCircle
className={cn(
'ml-auto h-4 w-4',
value === props.item ? 'opacity-100' : 'opacity-0',
)}
/>
);
};
const selected = accounts.find((account) => account.value === value);
const pictureUrl = personalData.data?.picture_url;
const PersonalAccountAvatar = () =>
pictureUrl ? (
<UserAvatar pictureUrl={pictureUrl} />
) : (
<PersonIcon className="h-5 w-5" />
);
return (
<>
<Popover open={open} onOpenChange={setOpen}>
@@ -117,7 +99,7 @@ export function AccountSelector({
'gap-x-2': !collapsed,
})}
>
<PersonalAccountAvatar />
<PersonalAccountAvatar pictureUrl={pictureUrl} />
<span
className={cn('truncate', {
@@ -136,7 +118,7 @@ export function AccountSelector({
'gap-x-2': !collapsed,
})}
>
<Avatar className={'rounded-xs h-6 w-6'}>
<Avatar className={'h-6 w-6 rounded-xs'}>
<AvatarImage src={account.image ?? undefined} />
<AvatarFallback
@@ -176,6 +158,7 @@ export function AccountSelector({
<CommandList>
<CommandGroup>
<CommandItem
className="shadow-none"
onSelect={() => onAccountChange(undefined)}
value={PERSONAL_ACCOUNT_SLUG}
>
@@ -185,7 +168,7 @@ export function AccountSelector({
<Trans i18nKey={'teams:personalAccount'} />
</span>
<Icon item={PERSONAL_ACCOUNT_SLUG} />
<Icon selected={value === PERSONAL_ACCOUNT_SLUG} />
</CommandItem>
</CommandGroup>
@@ -206,7 +189,7 @@ export function AccountSelector({
data-name={account.label}
data-slug={account.value}
className={cn(
'group my-1 flex justify-between transition-colors',
'group my-1 flex justify-between shadow-none transition-colors',
{
['bg-muted']: value === account.value,
},
@@ -222,7 +205,7 @@ export function AccountSelector({
}}
>
<div className={'flex items-center'}>
<Avatar className={'rounded-xs mr-2 h-6 w-6'}>
<Avatar className={'mr-2 h-6 w-6 rounded-xs'}>
<AvatarImage src={account.image ?? undefined} />
<AvatarFallback
@@ -241,7 +224,7 @@ export function AccountSelector({
</span>
</div>
<Icon item={account.value ?? ''} />
<Icon selected={(account.value ?? '') === value} />
</CommandItem>
))}
</CommandGroup>
@@ -286,8 +269,24 @@ export function AccountSelector({
function UserAvatar(props: { pictureUrl?: string }) {
return (
<Avatar className={'rounded-xs h-6 w-6'}>
<Avatar className={'h-6 w-6 rounded-xs'}>
<AvatarImage src={props.pictureUrl} />
</Avatar>
);
}
function Icon({ selected }: { selected: boolean }) {
return (
<CheckCircle
className={cn('ml-auto h-4 w-4', selected ? 'opacity-100' : 'opacity-0')}
/>
);
}
function PersonalAccountAvatar({ pictureUrl }: { pictureUrl?: string | null }) {
return pictureUrl ? (
<UserAvatar pictureUrl={pictureUrl} />
) : (
<PersonIcon className="h-5 w-5" />
);
}

View File

@@ -156,23 +156,26 @@ export function PersonalAccountSettingsContainer(
</CardContent>
</Card>
<If condition={props.features.enableAccountLinking}>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:linkedAccounts'} />
</CardTitle>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:linkedAccounts'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:linkedAccountsDescription'} />
</CardDescription>
</CardHeader>
<CardDescription>
<Trans i18nKey={'account:linkedAccountsDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<LinkAccountsList providers={props.providers} />
</CardContent>
</Card>
</If>
<CardContent>
<LinkAccountsList
providers={props.providers}
enabled={props.features.enableAccountLinking}
showEmailOption
showPasswordOption
/>
</CardContent>
</Card>
<If condition={props.features.enableAccountDeletion}>
<Card className={'border-destructive'}>

View File

@@ -27,26 +27,46 @@ import { Trans } from '@kit/ui/trans';
import { UpdateEmailSchema } from '../../../schema/update-email.schema';
function createEmailResolver(currentEmail: string, errorMessage: string) {
return zodResolver(
UpdateEmailSchema.withTranslation(errorMessage).refine((schema) => {
return schema.email !== currentEmail;
}),
);
function createEmailResolver(
currentEmail: string | null,
emailsNotMatchingMessage: string,
emailNotChangedMessage: string,
) {
const schema = UpdateEmailSchema.withTranslation(emailsNotMatchingMessage);
// If there's a current email, ensure the new email is different
if (currentEmail) {
return zodResolver(
schema.refine(
(data) => {
return data.email !== currentEmail;
},
{
path: ['email'],
message: emailNotChangedMessage,
},
),
);
}
// If no current email, just validate the schema
return zodResolver(schema);
}
export function UpdateEmailForm({
email,
callbackPath,
onSuccess,
}: {
email: string;
email?: string | null;
callbackPath: string;
onSuccess?: () => void;
}) {
const { t } = useTranslation('account');
const updateUserMutation = useUpdateUser();
const isSettingEmail = !email;
const updateEmail = ({ email }: { email: string }) => {
// then, we update the user's email address
const promise = async () => {
const redirectTo = new URL(
callbackPath,
@@ -54,17 +74,25 @@ export function UpdateEmailForm({
).toString();
await updateUserMutation.mutateAsync({ email, redirectTo });
if (onSuccess) {
onSuccess();
}
};
toast.promise(promise, {
success: t(`updateEmailSuccess`),
loading: t(`updateEmailLoading`),
error: t(`updateEmailError`),
success: t(isSettingEmail ? 'setEmailSuccess' : 'updateEmailSuccess'),
loading: t(isSettingEmail ? 'setEmailLoading' : 'updateEmailLoading'),
error: t(isSettingEmail ? 'setEmailError' : 'updateEmailError'),
});
};
const form = useForm({
resolver: createEmailResolver(email, t('emailNotMatching')),
resolver: createEmailResolver(
email ?? null,
t('emailNotMatching'),
t('emailNotChanged'),
),
defaultValues: {
email: '',
repeatEmail: '',
@@ -83,11 +111,23 @@ export function UpdateEmailForm({
<CheckIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:updateEmailSuccess'} />
<Trans
i18nKey={
isSettingEmail
? 'account:setEmailSuccess'
: 'account:updateEmailSuccess'
}
/>
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:updateEmailSuccessMessage'} />
<Trans
i18nKey={
isSettingEmail
? 'account:setEmailSuccessMessage'
: 'account:updateEmailSuccessMessage'
}
/>
</AlertDescription>
</Alert>
</If>
@@ -107,7 +147,11 @@ export function UpdateEmailForm({
data-test={'account-email-form-email-input'}
required
type={'email'}
placeholder={t('account:newEmail')}
placeholder={t(
isSettingEmail
? 'account:emailAddress'
: 'account:newEmail',
)}
{...field}
/>
</InputGroup>
@@ -147,7 +191,13 @@ export function UpdateEmailForm({
<div>
<Button disabled={updateUserMutation.isPending}>
<Trans i18nKey={'account:updateEmailSubmitLabel'} />
<Trans
i18nKey={
isSettingEmail
? 'account:setEmailAddress'
: 'account:updateEmailSubmitLabel'
}
/>
</Button>
</div>
</div>

View File

@@ -1,11 +1,14 @@
'use client';
import { Suspense, useState } from 'react';
import { usePathname } from 'next/navigation';
import type { Provider, UserIdentity } from '@supabase/supabase-js';
import { CheckCircle } from 'lucide-react';
import { useLinkIdentityWithProvider } from '@kit/supabase/hooks/use-link-identity-with-provider';
import { useUnlinkUserIdentity } from '@kit/supabase/hooks/use-unlink-user-identity';
import { useUser } from '@kit/supabase/hooks/use-user';
import { useUserIdentities } from '@kit/supabase/hooks/use-user-identities';
import {
AlertDialog,
@@ -19,6 +22,14 @@ import {
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import { If } from '@kit/ui/if';
import {
Item,
@@ -35,9 +46,21 @@ import { toast } from '@kit/ui/sonner';
import { Spinner } from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans';
export function LinkAccountsList(props: { providers: Provider[] }) {
import { UpdateEmailForm } from '../email/update-email-form';
import { UpdatePasswordForm } from '../password/update-password-form';
interface LinkAccountsListProps {
providers: Provider[];
showPasswordOption?: boolean;
showEmailOption?: boolean;
enabled?: boolean;
redirectTo?: string;
}
export function LinkAccountsList(props: LinkAccountsListProps) {
const unlinkMutation = useUnlinkUserIdentity();
const linkMutation = useLinkIdentityWithProvider();
const pathname = usePathname();
const {
identities,
@@ -46,14 +69,40 @@ export function LinkAccountsList(props: { providers: Provider[] }) {
isLoading: isLoadingIdentities,
} = useUserIdentities();
// Only show providers from the allowed list that aren't already connected
const availableProviders = props.providers.filter(
(provider) => !isProviderConnected(provider),
// Get user email from email identity
const emailIdentity = identities.find(
(identity) => identity.provider === 'email',
);
const userEmail = (emailIdentity?.identity_data?.email as string) || '';
// If enabled, display available providers
const availableProviders = props.enabled
? props.providers.filter((provider) => !isProviderConnected(provider))
: [];
const user = useUser();
const amr = user.data ? user.data.amr : [];
const isConnectedWithPassword = amr.some(
(item: { method: string }) => item.method === 'password',
);
// Show all connected identities, even if their provider isn't in the allowed providers list
const connectedIdentities = identities;
const canLinkEmailAccount = !emailIdentity && props.showEmailOption;
const canLinkPassword =
emailIdentity && props.showPasswordOption && !isConnectedWithPassword;
const shouldDisplayAvailableAccountsSection =
canLinkEmailAccount || canLinkPassword || availableProviders.length;
/**
* @name handleUnlinkAccount
* @param identity
*/
const handleUnlinkAccount = (identity: UserIdentity) => {
const promise = unlinkMutation.mutateAsync(identity);
@@ -64,6 +113,10 @@ export function LinkAccountsList(props: { providers: Provider[] }) {
});
};
/**
* @name handleLinkAccount
* @param provider
*/
const handleLinkAccount = (provider: Provider) => {
const promise = linkMutation.mutateAsync(provider);
@@ -83,33 +136,32 @@ export function LinkAccountsList(props: { providers: Provider[] }) {
}
return (
<div className="space-y-6">
{/* Linked Accounts Section */}
<div className="space-y-4">
<If condition={connectedIdentities.length > 0}>
<div className="space-y-3">
<div className="space-y-2.5">
<div>
<h3 className="text-foreground text-sm font-medium">
<Trans i18nKey={'account:linkedAccounts'} />
<Trans i18nKey={'account:linkedMethods'} />
</h3>
<p className="text-muted-foreground text-xs">
<Trans i18nKey={'account:alreadyLinkedAccountsDescription'} />
<Trans i18nKey={'account:alreadyLinkedMethodsDescription'} />
</p>
</div>
<div className="flex flex-col space-y-2">
{connectedIdentities.map((identity) => (
<Item key={identity.id} variant="outline">
<Item key={identity.id} variant="muted">
<ItemMedia>
<OauthProviderLogoImage providerId={identity.provider} />
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center">
<OauthProviderLogoImage providerId={identity.provider} />
</div>
</ItemMedia>
<ItemContent>
<ItemHeader className="flex items-center gap-3">
<ItemHeader>
<div className="flex flex-col">
<ItemTitle className="flex items-center gap-x-2 text-sm font-medium capitalize">
<CheckCircle className="h-3 w-3 text-green-500" />
<ItemTitle className="text-sm font-medium capitalize">
<span>{identity.provider}</span>
</ItemTitle>
@@ -174,22 +226,35 @@ export function LinkAccountsList(props: { providers: Provider[] }) {
</div>
</If>
{/* Available Accounts Section */}
<If condition={availableProviders.length > 0}>
<If
condition={shouldDisplayAvailableAccountsSection}
fallback={<NoAccountsAvailable />}
>
<Separator />
<div className="space-y-3">
<div className="space-y-2.5">
<div>
<h3 className="text-foreground text-sm font-medium">
<Trans i18nKey={'account:availableAccounts'} />
<Trans i18nKey={'account:availableMethods'} />
</h3>
<p className="text-muted-foreground text-xs">
<Trans i18nKey={'account:availableAccountsDescription'} />
<Trans i18nKey={'account:availableMethodsDescription'} />
</p>
</div>
<div className="flex flex-col space-y-2">
<If condition={canLinkEmailAccount}>
<UpdateEmailDialog redirectTo={pathname} />
</If>
<If condition={canLinkPassword}>
<UpdatePasswordDialog
userEmail={userEmail}
redirectTo={props.redirectTo || '/home'}
/>
</If>
{availableProviders.map((provider) => (
<Item
key={provider}
@@ -217,16 +282,134 @@ export function LinkAccountsList(props: { providers: Provider[] }) {
</div>
</div>
</If>
<If
condition={
connectedIdentities.length === 0 && availableProviders.length === 0
}
>
<div className="text-muted-foreground py-8 text-center">
<Trans i18nKey={'account:noAccountsAvailable'} />
</div>
</If>
</div>
);
}
function NoAccountsAvailable() {
return (
<div>
<span className="text-muted-foreground text-xs">
<Trans i18nKey={'account:noAccountsAvailable'} />
</span>
</div>
);
}
function UpdateEmailDialog(props: { redirectTo: string }) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<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">
<OauthProviderLogoImage providerId={'email'} />
</div>
</ItemMedia>
<ItemContent>
<ItemHeader>
<div className="flex flex-col">
<ItemTitle className="text-sm font-medium">
<Trans i18nKey={'account:setEmailAddress'} />
</ItemTitle>
<ItemDescription>
<Trans i18nKey={'account:setEmailDescription'} />
</ItemDescription>
</div>
</ItemHeader>
</ItemContent>
</Item>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'account:setEmailAddress'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'account:setEmailDescription'} />
</DialogDescription>
</DialogHeader>
<Suspense
fallback={
<div className="flex items-center justify-center">
<Spinner />
</div>
}
>
<UpdateEmailForm
callbackPath={props.redirectTo}
onSuccess={() => {
setOpen(false);
}}
/>
</Suspense>
</DialogContent>
</Dialog>
);
}
function UpdatePasswordDialog(props: {
redirectTo: string;
userEmail: string;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<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">
<OauthProviderLogoImage providerId={'password'} />
</div>
</ItemMedia>
<ItemContent>
<ItemHeader>
<div className="flex flex-col">
<ItemTitle className="text-sm font-medium">
<Trans i18nKey={'account:linkEmailPassword'} />
</ItemTitle>
<ItemDescription>
<Trans i18nKey={'account:updatePasswordDescription'} />
</ItemDescription>
</div>
</ItemHeader>
</ItemContent>
</Item>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'account:linkEmailPassword'} />
</DialogTitle>
</DialogHeader>
<Suspense
fallback={
<div className="flex items-center justify-center">
<Spinner />
</div>
}
>
<UpdatePasswordForm
callbackPath={props.redirectTo}
email={props.userEmail}
onSuccess={() => {
setOpen(false);
}}
/>
</Suspense>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,9 +2,11 @@
import { useState } from 'react';
import type { PostgrestError } from '@supabase/supabase-js';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { Check, Lock } from 'lucide-react';
import { Check, Lock, XIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@@ -33,9 +35,11 @@ import { PasswordUpdateSchema } from '../../../schema/update-password.schema';
export const UpdatePasswordForm = ({
email,
callbackPath,
onSuccess,
}: {
email: string;
callbackPath: string;
onSuccess?: () => void;
}) => {
const { t } = useTranslation('account');
const updateUserMutation = useUpdateUser();
@@ -46,6 +50,7 @@ export const UpdatePasswordForm = ({
const promise = updateUserMutation
.mutateAsync({ password, redirectTo })
.then(onSuccess)
.catch((error) => {
if (
typeof error === 'string' &&
@@ -57,11 +62,13 @@ export const UpdatePasswordForm = ({
}
});
toast.promise(() => promise, {
success: t(`updatePasswordSuccess`),
error: t(`updatePasswordError`),
loading: t(`updatePasswordLoading`),
});
toast
.promise(() => promise, {
success: t(`updatePasswordSuccess`),
error: t(`updatePasswordError`),
loading: t(`updatePasswordLoading`),
})
.unwrap();
};
const updatePasswordCallback = async ({
@@ -99,6 +106,10 @@ export const UpdatePasswordForm = ({
<SuccessAlert />
</If>
<If condition={updateUserMutation.error}>
{(error) => <ErrorAlert error={error as PostgrestError} />}
</If>
<If condition={needsReauthentication}>
<NeedsReauthenticationAlert />
</If>
@@ -177,6 +188,27 @@ export const UpdatePasswordForm = ({
);
};
function ErrorAlert({ error }: { error: { code: string } }) {
const { t } = useTranslation();
return (
<Alert variant={'destructive'}>
<XIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:updatePasswordError'} />
</AlertTitle>
<AlertDescription>
<Trans
i18nKey={`auth:errors.${error.code}`}
defaults={t('auth:resetPasswordError')}
/>
</AlertDescription>
</Alert>
);
}
function SuccessAlert() {
return (
<Alert variant={'success'}>