Revert "Unify workspace dropdowns; Update layouts (#458)"

This reverts commit 4bc8448a1d.
This commit is contained in:
gbuomprisco
2026-03-11 14:47:47 +08:00
parent 4bc8448a1d
commit 4912e402a3
530 changed files with 11182 additions and 14382 deletions

View File

@@ -24,7 +24,6 @@
"@kit/billing-gateway": "workspace:*",
"@kit/email-templates": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/i18n": "workspace:*",
"@kit/mailers": "workspace:*",
"@kit/monitoring": "workspace:*",
"@kit/next": "workspace:*",
@@ -34,18 +33,18 @@
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "catalog:",
"@tanstack/react-query": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
"next-intl": "catalog:",
"next-safe-action": "catalog:",
"next-themes": "0.4.6",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "catalog:",
"react-i18next": "catalog:",
"zod": "catalog:"
},
"prettier": "@kit/prettier-config",

View File

@@ -1,9 +1,10 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { ChevronsUpDown, Plus, User } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { CaretSortIcon, PersonIcon } from '@radix-ui/react-icons';
import { CheckCircle, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
import { Button } from '@kit/ui/button';
@@ -39,7 +40,7 @@ interface AccountSelectorProps {
selectedAccount?: string;
collapsed?: boolean;
className?: string;
showPersonalAccount?: boolean;
collisionPadding?: number;
onAccountChange: (value: string | undefined) => void;
}
@@ -56,14 +57,16 @@ export function AccountSelector({
enableTeamCreation: true,
},
collapsed = false,
showPersonalAccount = true,
collisionPadding = 20,
}: React.PropsWithChildren<AccountSelectorProps>) {
const [open, setOpen] = useState<boolean>(false);
const [isCreatingAccount, setIsCreatingAccount] = useState<boolean>(false);
const t = useTranslations('teams');
const { t } = useTranslation('teams');
const personalData = usePersonalAccountData(userId);
const value = selectedAccount ?? PERSONAL_ACCOUNT_SLUG;
const value = useMemo(() => {
return selectedAccount ?? PERSONAL_ACCOUNT_SLUG;
}, [selectedAccount]);
const selected = accounts.find((account) => account.value === value);
const pictureUrl = personalData.data?.picture_url;
@@ -71,136 +74,128 @@ export function AccountSelector({
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
data-test={'account-selector-trigger'}
size={collapsed ? 'icon' : 'default'}
variant="ghost"
role="combobox"
aria-expanded={open}
className={cn(
'dark:shadow-primary/10 group mr-1 w-full min-w-0 px-2 lg:w-auto lg:max-w-[185px]',
{
'justify-start': !collapsed,
'm-auto justify-center px-2 lg:w-full': collapsed,
},
className,
)}
/>
}
>
<If
condition={selected}
fallback={
<span
className={cn('flex max-w-full items-center', {
'justify-center gap-x-0': collapsed,
'gap-x-2': !collapsed,
})}
>
<PersonalAccountAvatar pictureUrl={pictureUrl} />
<span
className={cn('truncate', {
hidden: collapsed,
})}
>
<Trans i18nKey={'teams.personalAccount'} />
</span>
</span>
}
>
{(account) => (
<span
className={cn('flex max-w-full items-center', {
'justify-center gap-x-0': collapsed,
'gap-x-2': !collapsed,
})}
>
<Avatar className={'h-6 w-6 rounded-xs'}>
<AvatarImage src={account.image ?? undefined} />
<AvatarFallback
className={'group-hover:bg-background rounded-xs'}
>
{account.label ? account.label[0] : ''}
</AvatarFallback>
</Avatar>
<span
className={cn('truncate', {
hidden: collapsed,
})}
>
{account.label}
</span>
</span>
<PopoverTrigger asChild>
<Button
data-test={'account-selector-trigger'}
size={collapsed ? 'icon' : 'default'}
variant="ghost"
role="combobox"
aria-expanded={open}
className={cn(
'dark:shadow-primary/10 group mr-1 w-full min-w-0 px-2 lg:w-auto lg:max-w-fit',
{
'justify-start': !collapsed,
'm-auto justify-center px-2 lg:w-full': collapsed,
},
className,
)}
</If>
>
<If
condition={selected}
fallback={
<span
className={cn('flex max-w-full items-center', {
'justify-center gap-x-0': collapsed,
'gap-x-2': !collapsed,
})}
>
<PersonalAccountAvatar pictureUrl={pictureUrl} />
<ChevronsUpDown
className={cn('ml-1 h-4 w-4 shrink-0 opacity-50', {
hidden: collapsed,
})}
/>
<span
className={cn('truncate', {
hidden: collapsed,
})}
>
<Trans i18nKey={'teams:personalAccount'} />
</span>
</span>
}
>
{(account) => (
<span
className={cn('flex max-w-full items-center', {
'justify-center gap-x-0': collapsed,
'gap-x-2': !collapsed,
})}
>
<Avatar className={'h-6 w-6 rounded-xs'}>
<AvatarImage src={account.image ?? undefined} />
<AvatarFallback
className={'group-hover:bg-background rounded-xs'}
>
{account.label ? account.label[0] : ''}
</AvatarFallback>
</Avatar>
<span
className={cn('truncate', {
hidden: collapsed,
})}
>
{account.label}
</span>
</span>
)}
</If>
<CaretSortIcon
className={cn('ml-1 h-4 w-4 shrink-0 opacity-50', {
hidden: collapsed,
})}
/>
</Button>
</PopoverTrigger>
<PopoverContent
data-test={'account-selector-content'}
className="w-full gap-0 p-0"
className="w-full p-0"
collisionPadding={collisionPadding}
>
<Command value={value}>
<Command>
<CommandInput placeholder={t('searchAccount')} className="h-9" />
<CommandList>
{showPersonalAccount && (
<>
<CommandGroup>
<CommandItem
tabIndex={0}
value={PERSONAL_ACCOUNT_SLUG}
onSelect={() => onAccountChange(undefined)}
className={cn('', {
'bg-muted': value === PERSONAL_ACCOUNT_SLUG,
'hover:bg-muted/50 data-selected:bg-transparent':
value !== PERSONAL_ACCOUNT_SLUG,
})}
>
<PersonalAccountAvatar />
<CommandGroup>
<CommandItem
className="shadow-none"
onSelect={() => onAccountChange(undefined)}
value={PERSONAL_ACCOUNT_SLUG}
>
<PersonalAccountAvatar />
<span className={'ml-2'}>
<Trans i18nKey={'teams.personalAccount'} />
</span>
</CommandItem>
</CommandGroup>
<span className={'ml-2'}>
<Trans i18nKey={'teams:personalAccount'} />
</span>
<CommandSeparator />
</>
)}
<Icon selected={value === PERSONAL_ACCOUNT_SLUG} />
</CommandItem>
</CommandGroup>
<CommandSeparator />
<If condition={accounts.length > 0}>
<CommandGroup
heading={
<Trans
i18nKey={'teams.yourTeams'}
i18nKey={'teams:yourTeams'}
values={{ teamsCount: accounts.length }}
/>
}
>
{(accounts ?? []).map((account) => (
<CommandItem
className={cn('', {
'bg-muted': value === account.value,
'hover:bg-muted/50 data-selected:bg-transparent':
value !== account.value,
})}
tabIndex={0}
data-test={'account-selector-team'}
data-name={account.label}
data-slug={account.value}
className={cn(
'group my-1 flex justify-between shadow-none transition-colors',
{
['bg-muted']: value === account.value,
},
)}
key={account.value}
value={account.value ?? undefined}
value={account.value ?? ''}
onSelect={(currentValue) => {
setOpen(false);
@@ -209,12 +204,13 @@ export function AccountSelector({
}
}}
>
<div className={'flex w-full items-center'}>
<div className={'flex items-center'}>
<Avatar className={'mr-2 h-6 w-6 rounded-xs'}>
<AvatarImage src={account.image ?? undefined} />
<AvatarFallback
className={cn('rounded-xs', {
['bg-background']: value === account.value,
['group-hover:bg-background']:
value !== account.value,
})}
@@ -223,10 +219,12 @@ export function AccountSelector({
</AvatarFallback>
</Avatar>
<span className={'max-w-[165px] truncate'}>
<span className={'mr-2 max-w-[165px] truncate'}>
{account.label}
</span>
</div>
<Icon selected={(account.value ?? '') === value} />
</CommandItem>
))}
</CommandGroup>
@@ -234,27 +232,26 @@ export function AccountSelector({
</CommandList>
</Command>
<Separator />
<If condition={features.enableTeamCreation}>
<div className="px-1">
<Separator />
<div className={'p-1'}>
<Button
data-test={'create-team-account-trigger'}
variant="ghost"
size={'sm'}
className="w-full justify-start text-sm font-normal"
onClick={() => {
setIsCreatingAccount(true);
setOpen(false);
}}
>
<Plus className="mr-3 h-4 w-4" />
<div className="py-1">
<Button
data-test={'create-team-account-trigger'}
variant="ghost"
className="w-full justify-start text-sm font-normal"
onClick={() => {
setIsCreatingAccount(true);
setOpen(false);
}}
>
<Plus className="mr-3 h-4 w-4" />
<span>
<Trans i18nKey={'teams.createTeam'} />
</span>
</Button>
</div>
<span>
<Trans i18nKey={'teams:createTeam'} />
</span>
</Button>
</div>
</If>
</PopoverContent>
@@ -278,10 +275,18 @@ function UserAvatar(props: { pictureUrl?: string }) {
);
}
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} />
) : (
<User className="h-5 w-5" />
<PersonIcon className="h-5 w-5" />
);
}

View File

@@ -10,7 +10,6 @@ import {
LogOut,
MessageCircleQuestion,
Shield,
User,
} from 'lucide-react';
import { JWTUserData } from '@kit/supabase/types';
@@ -50,7 +49,6 @@ export function PersonalAccountDropdown({
paths: {
home: string;
profileSettings: string;
};
features: {
@@ -89,10 +87,11 @@ export function PersonalAccountDropdown({
aria-label="Open your profile menu"
data-test={'account-dropdown-trigger'}
className={cn(
'group/trigger fade-in focus:outline-primary flex cursor-pointer items-center group-data-[collapsible=icon]:px-0',
'group/trigger fade-in focus:outline-primary flex cursor-pointer items-center group-data-[minimized=true]/sidebar:px-0',
className ?? '',
{
['active:bg-secondary/50 group-data-[collapsible=none]:hover:bg-secondary items-center gap-4 rounded-md border-dashed p-2 transition-colors group-data-[collapsible=none]:border']:
['active:bg-secondary/50 items-center gap-4 rounded-md' +
' hover:bg-secondary border border-dashed p-2 transition-colors']:
showProfileName,
},
)}
@@ -109,7 +108,7 @@ export function PersonalAccountDropdown({
<If condition={showProfileName}>
<div
className={
'fade-in flex w-full flex-col truncate text-left group-data-[collapsible=icon]:hidden'
'fade-in flex w-full flex-col truncate text-left group-data-[minimized=true]/sidebar:hidden'
}
>
<span
@@ -129,7 +128,7 @@ export function PersonalAccountDropdown({
<ChevronsUpDown
className={
'text-muted-foreground mr-1 h-8 group-data-[collapsible=icon]:hidden'
'text-muted-foreground mr-1 h-8 group-data-[minimized=true]/sidebar:hidden'
}
/>
</If>
@@ -141,7 +140,7 @@ export function PersonalAccountDropdown({
className={'flex flex-col justify-start truncate text-left text-xs'}
>
<div className={'text-muted-foreground'}>
<Trans i18nKey={'common.signedInAs'} />
<Trans i18nKey={'common:signedInAs'} />
</div>
<div>
@@ -152,69 +151,48 @@ export function PersonalAccountDropdown({
<DropdownMenuSeparator />
<DropdownMenuItem
render={
<Link
className={'s-full flex cursor-pointer items-center space-x-2'}
href={paths.home}
/>
}
>
<Home className={'h-5'} />
<DropdownMenuItem asChild>
<Link
className={'s-full flex cursor-pointer items-center space-x-2'}
href={paths.home}
>
<Home className={'h-5'} />
<span>
<Trans i18nKey={'common.routes.home'} />
</span>
</DropdownMenuItem>
<DropdownMenuItem
render={
<Link
className={'s-full flex cursor-pointer items-center space-x-2'}
href={paths.profileSettings}
/>
}
>
<User className={'h-5'} />
<span>
<Trans i18nKey={'common.routes.profile'} />
</span>
<span>
<Trans i18nKey={'common:routes.home'} />
</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
render={
<Link
className={'s-full flex cursor-pointer items-center space-x-2'}
href={'/docs'}
/>
}
>
<MessageCircleQuestion className={'h-5'} />
<DropdownMenuItem asChild>
<Link
className={'s-full flex cursor-pointer items-center space-x-2'}
href={'/docs'}
>
<MessageCircleQuestion className={'h-5'} />
<span>
<Trans i18nKey={'common.documentation'} />
</span>
<span>
<Trans i18nKey={'common:documentation'} />
</span>
</Link>
</DropdownMenuItem>
<If condition={isSuperAdmin}>
<DropdownMenuSeparator />
<DropdownMenuItem
render={
<Link
className={
's-full flex cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
}
href={'/admin'}
/>
}
>
<Shield className={'h-5'} />
<DropdownMenuItem asChild>
<Link
className={
's-full flex cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
}
href={'/admin'}
>
<Shield className={'h-5'} />
<span>Super Admin</span>
<span>Super Admin</span>
</Link>
</DropdownMenuItem>
</If>
@@ -236,7 +214,7 @@ export function PersonalAccountDropdown({
<LogOut className={'h-5'} />
<span>
<Trans i18nKey={'auth.signOut'} />
<Trans i18nKey={'auth:signOut'} />
</span>
</span>
</DropdownMenuItem>

View File

@@ -1,8 +1,9 @@
'use client';
import { useFormStatus } from 'react-dom';
import { zodResolver } from '@hookform/resolvers/zod';
import { TriangleAlert } from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useForm, useWatch } from 'react-hook-form';
import { ErrorBoundary } from '@kit/monitoring/components';
@@ -30,11 +31,11 @@ export function AccountDangerZone() {
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-1'}>
<span className={'text-sm font-medium'}>
<Trans i18nKey={'account.deleteAccount'} />
<Trans i18nKey={'account:deleteAccount'} />
</span>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'account.deleteAccountDescription'} />
<Trans i18nKey={'account:deleteAccountDescription'} />
</p>
</div>
@@ -54,18 +55,16 @@ function DeleteAccountModal() {
return (
<AlertDialog>
<AlertDialogTrigger
render={
<Button data-test={'delete-account-button'} variant={'destructive'}>
<Trans i18nKey={'account.deleteAccount'} />
</Button>
}
/>
<AlertDialogTrigger asChild>
<Button data-test={'delete-account-button'} variant={'destructive'}>
<Trans i18nKey={'account:deleteAccount'} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogContent onEscapeKeyDown={(e) => e.preventDefault()}>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'account.deleteAccount'} />
<Trans i18nKey={'account:deleteAccount'} />
</AlertDialogTitle>
</AlertDialogHeader>
@@ -78,8 +77,6 @@ function DeleteAccountModal() {
}
function DeleteAccountForm(props: { email: string }) {
const { execute, isPending } = useAction(deletePersonalAccountAction);
const form = useForm({
resolver: zodResolver(DeletePersonalAccountSchema),
defaultValues: {
@@ -97,7 +94,7 @@ function DeleteAccountForm(props: { email: string }) {
onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })}
CancelButton={
<AlertDialogCancel>
<Trans i18nKey={'common.cancel'} />
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
}
/>
@@ -108,12 +105,11 @@ function DeleteAccountForm(props: { email: string }) {
<Form {...form}>
<form
data-test={'delete-account-form'}
onSubmit={(e) => {
e.preventDefault();
execute({ otp });
}}
action={deletePersonalAccountAction}
className={'flex flex-col space-y-4'}
>
<input type="hidden" name="otp" value={otp} />
<div className={'flex flex-col space-y-6'}>
<div
className={
@@ -122,11 +118,11 @@ function DeleteAccountForm(props: { email: string }) {
>
<div className={'flex flex-col space-y-2'}>
<div>
<Trans i18nKey={'account.deleteAccountDescription'} />
<Trans i18nKey={'account:deleteAccountDescription'} />
</div>
<div>
<Trans i18nKey={'common.modalConfirmationQuestion'} />
<Trans i18nKey={'common:modalConfirmationQuestion'} />
</div>
</div>
</div>
@@ -134,28 +130,36 @@ function DeleteAccountForm(props: { email: string }) {
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common.cancel'} />
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<Button
data-test={'confirm-delete-account-button'}
type={'submit'}
disabled={isPending || !form.formState.isValid}
name={'action'}
variant={'destructive'}
>
{isPending ? (
<Trans i18nKey={'account.deletingAccount'} />
) : (
<Trans i18nKey={'account.deleteAccount'} />
)}
</Button>
<DeleteAccountSubmitButton disabled={!form.formState.isValid} />
</AlertDialogFooter>
</form>
</Form>
);
}
function DeleteAccountSubmitButton(props: { disabled: boolean }) {
const { pending } = useFormStatus();
return (
<Button
data-test={'confirm-delete-account-button'}
type={'submit'}
disabled={pending || props.disabled}
name={'action'}
variant={'destructive'}
>
{pending ? (
<Trans i18nKey={'account:deletingAccount'} />
) : (
<Trans i18nKey={'account:deleteAccount'} />
)}
</Button>
);
}
function DeleteAccountErrorContainer() {
return (
<div className="flex flex-col gap-y-4">
@@ -163,7 +167,7 @@ function DeleteAccountErrorContainer() {
<div>
<AlertDialogCancel>
<Trans i18nKey={'common.cancel'} />
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
</div>
</div>
@@ -173,14 +177,14 @@ function DeleteAccountErrorContainer() {
function DeleteAccountErrorAlert() {
return (
<Alert variant={'destructive'}>
<TriangleAlert className={'h-4'} />
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account.deleteAccountErrorHeading'} />
<Trans i18nKey={'account:deleteAccountErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common.genericError'} />
<Trans i18nKey={'common:genericError'} />
</AlertDescription>
</Alert>
);

View File

@@ -2,7 +2,8 @@
import type { Provider } from '@supabase/supabase-js';
import { routing } from '@kit/i18n';
import { useTranslation } from 'react-i18next';
import {
Card,
CardContent,
@@ -54,11 +55,11 @@ export function PersonalAccountSettingsContainer(
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account.accountImage'} />
<Trans i18nKey={'account:accountImage'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account.accountImageDescription'} />
<Trans i18nKey={'account:accountImageDescription'} />
</CardDescription>
</CardHeader>
@@ -75,11 +76,11 @@ export function PersonalAccountSettingsContainer(
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account.name'} />
<Trans i18nKey={'account:name'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account.nameDescription'} />
<Trans i18nKey={'account:nameDescription'} />
</CardDescription>
</CardHeader>
@@ -92,16 +93,16 @@ export function PersonalAccountSettingsContainer(
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account.language'} />
<Trans i18nKey={'account:language'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account.languageDescription'} />
<Trans i18nKey={'account:languageDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<LanguageSelector locales={routing.locales} />
<LanguageSelector />
</CardContent>
</Card>
</If>
@@ -109,11 +110,11 @@ export function PersonalAccountSettingsContainer(
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account.updateEmailCardTitle'} />
<Trans i18nKey={'account:updateEmailCardTitle'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account.updateEmailCardDescription'} />
<Trans i18nKey={'account:updateEmailCardDescription'} />
</CardDescription>
</CardHeader>
@@ -126,11 +127,11 @@ export function PersonalAccountSettingsContainer(
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account.updatePasswordCardTitle'} />
<Trans i18nKey={'account:updatePasswordCardTitle'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account.updatePasswordCardDescription'} />
<Trans i18nKey={'account:updatePasswordCardDescription'} />
</CardDescription>
</CardHeader>
@@ -143,11 +144,11 @@ export function PersonalAccountSettingsContainer(
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account.multiFactorAuth'} />
<Trans i18nKey={'account:multiFactorAuth'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account.multiFactorAuthDescription'} />
<Trans i18nKey={'account:multiFactorAuthDescription'} />
</CardDescription>
</CardHeader>
@@ -159,11 +160,11 @@ export function PersonalAccountSettingsContainer(
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account.linkedAccounts'} />
<Trans i18nKey={'account:linkedAccounts'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account.linkedAccountsDescription'} />
<Trans i18nKey={'account:linkedAccountsDescription'} />
</CardDescription>
</CardHeader>
@@ -182,11 +183,11 @@ export function PersonalAccountSettingsContainer(
<Card className={'border-destructive'}>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account.dangerZone'} />
<Trans i18nKey={'account:dangerZone'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account.dangerZoneDescription'} />
<Trans i18nKey={'account:dangerZoneDescription'} />
</CardDescription>
</CardHeader>
@@ -200,7 +201,10 @@ export function PersonalAccountSettingsContainer(
}
function useSupportMultiLanguage() {
const { locales } = routing;
const { i18n } = useTranslation();
const langs = (i18n?.options?.supportedLngs as string[]) ?? [];
return locales.length > 1;
const supportedLangs = langs.filter((lang) => lang !== 'cimode');
return supportedLangs.length > 1;
}

View File

@@ -1,9 +1,10 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Check, Mail } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { CheckIcon } from '@radix-ui/react-icons';
import { Mail } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -61,7 +62,7 @@ export function UpdateEmailForm({
callbackPath: string;
onSuccess?: () => void;
}) {
const t = useTranslations('account');
const { t } = useTranslation('account');
const updateUserMutation = useUpdateUser();
const isSettingEmail = !email;
@@ -107,14 +108,14 @@ export function UpdateEmailForm({
>
<If condition={updateUserMutation.data}>
<Alert variant={'success'}>
<Check className={'h-4'} />
<CheckIcon className={'h-4'} />
<AlertTitle>
<Trans
i18nKey={
isSettingEmail
? 'account.setEmailSuccess'
: 'account.updateEmailSuccess'
? 'account:setEmailSuccess'
: 'account:updateEmailSuccess'
}
/>
</AlertTitle>
@@ -123,8 +124,8 @@ export function UpdateEmailForm({
<Trans
i18nKey={
isSettingEmail
? 'account.setEmailSuccessMessage'
: 'account.updateEmailSuccessMessage'
? 'account:setEmailSuccessMessage'
: 'account:updateEmailSuccessMessage'
}
/>
</AlertDescription>
@@ -147,7 +148,9 @@ export function UpdateEmailForm({
required
type={'email'}
placeholder={t(
isSettingEmail ? 'emailAddress' : 'newEmail',
isSettingEmail
? 'account:emailAddress'
: 'account:newEmail',
)}
{...field}
/>
@@ -159,7 +162,7 @@ export function UpdateEmailForm({
)}
name={'email'}
/>
Perform
<FormField
render={({ field }) => (
<FormItem>
@@ -174,7 +177,7 @@ export function UpdateEmailForm({
data-test={'account-email-form-repeat-email-input'}
required
type={'email'}
placeholder={t('repeatEmail')}
placeholder={t('account:repeatEmail')}
/>
</InputGroup>
</FormControl>
@@ -187,12 +190,12 @@ export function UpdateEmailForm({
</div>
<div>
<Button type="submit" disabled={updateUserMutation.isPending}>
<Button disabled={updateUserMutation.isPending}>
<Trans
i18nKey={
isSettingEmail
? 'account.setEmailAddress'
: 'account.updateEmailSubmitLabel'
? 'account:setEmailAddress'
: 'account:updateEmailSubmitLabel'
}
/>
</Button>

View File

@@ -112,9 +112,9 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
const promise = unlinkMutation.mutateAsync(identity);
toast.promise(promise, {
loading: <Trans i18nKey={'account.unlinkingAccount'} />,
success: <Trans i18nKey={'account.accountUnlinked'} />,
error: <Trans i18nKey={'account.unlinkAccountError'} />,
loading: <Trans i18nKey={'account:unlinkingAccount'} />,
success: <Trans i18nKey={'account:accountUnlinked'} />,
error: <Trans i18nKey={'account:unlinkAccountError'} />,
});
};
@@ -129,9 +129,9 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
});
toast.promise(promise, {
loading: <Trans i18nKey={'account.linkingAccount'} />,
success: <Trans i18nKey={'account.accountLinked'} />,
error: <Trans i18nKey={'account.linkAccountError'} />,
loading: <Trans i18nKey={'account:linkingAccount'} />,
success: <Trans i18nKey={'account:accountLinked'} />,
error: <Trans i18nKey={'account:linkAccountError'} />,
});
};
@@ -149,11 +149,11 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
<div className="space-y-2.5">
<div>
<h3 className="text-foreground text-sm font-medium">
<Trans i18nKey={'account.linkedMethods'} />
<Trans i18nKey={'account:linkedMethods'} />
</h3>
<p className="text-muted-foreground text-xs">
<Trans i18nKey={'account.alreadyLinkedMethodsDescription'} />
<Trans i18nKey={'account:alreadyLinkedMethodsDescription'} />
</p>
</div>
@@ -185,30 +185,28 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
<ItemActions>
<If condition={hasMultipleIdentities}>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="outline"
size="sm"
disabled={unlinkMutation.isPending}
>
<If condition={unlinkMutation.isPending}>
<Spinner className="mr-2 h-3 w-3" />
</If>
<Trans i18nKey={'account.unlinkAccount'} />
</Button>
}
/>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={unlinkMutation.isPending}
>
<If condition={unlinkMutation.isPending}>
<Spinner className="mr-2 h-3 w-3" />
</If>
<Trans i18nKey={'account:unlinkAccount'} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'account.confirmUnlinkAccount'} />
<Trans i18nKey={'account:confirmUnlinkAccount'} />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans
i18nKey={'account.unlinkAccountConfirmation'}
i18nKey={'account:unlinkAccountConfirmation'}
values={{ provider: identity.provider }}
/>
</AlertDialogDescription>
@@ -216,14 +214,14 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common.cancel'} />
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleUnlinkAccount(identity)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<Trans i18nKey={'account.unlinkAccount'} />
<Trans i18nKey={'account:unlinkAccount'} />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -245,11 +243,11 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
<div className="space-y-2.5">
<div>
<h3 className="text-foreground text-sm font-medium">
<Trans i18nKey={'account.availableMethods'} />
<Trans i18nKey={'account:availableMethods'} />
</h3>
<p className="text-muted-foreground text-xs">
<Trans i18nKey={'account.availableMethodsDescription'} />
<Trans i18nKey={'account:availableMethodsDescription'} />
</p>
</div>
@@ -283,7 +281,7 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
<ItemDescription>
<Trans
i18nKey={'account.linkAccountDescription'}
i18nKey={'account:linkAccountDescription'}
values={{ provider }}
/>
</ItemDescription>
@@ -301,7 +299,7 @@ function NoAccountsAvailable() {
return (
<div>
<span className="text-muted-foreground text-xs">
<Trans i18nKey={'account.noAccountsAvailable'} />
<Trans i18nKey={'account:noAccountsAvailable'} />
</span>
</div>
);
@@ -312,41 +310,38 @@ function UpdateEmailDialog(props: { redirectTo: string }) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
nativeButton={false}
render={
<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'} />
<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>
</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>
}
/>
</ItemHeader>
</ItemContent>
</Item>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'account.setEmailAddress'} />
<Trans i18nKey={'account:setEmailAddress'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'account.setEmailDescription'} />
<Trans i18nKey={'account:setEmailDescription'} />
</DialogDescription>
</DialogHeader>
@@ -378,38 +373,34 @@ function UpdatePasswordDialog(props: {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
nativeButton={false}
data-test="open-password-dialog-trigger"
render={
<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'} />
<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">
<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>
</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>
}
/>
</ItemHeader>
</ItemContent>
</Item>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'account.linkEmailPassword'} />
<Trans i18nKey={'account:linkEmailPassword'} />
</DialogTitle>
</DialogHeader>

View File

@@ -4,9 +4,10 @@ import { useCallback, useState } from 'react';
import type { Factor } from '@supabase/supabase-js';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ShieldCheck, TriangleAlert, X } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { ShieldCheck, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
@@ -77,7 +78,7 @@ function FactorsTableContainer(props: { userId: string }) {
<Spinner />
<div>
<Trans i18nKey={'account.loadingFactors'} />
<Trans i18nKey={'account:loadingFactors'} />
</div>
</div>
);
@@ -87,14 +88,14 @@ function FactorsTableContainer(props: { userId: string }) {
return (
<div>
<Alert variant={'destructive'}>
<TriangleAlert className={'h-4'} />
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account.factorsListError'} />
<Trans i18nKey={'account:factorsListError'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account.factorsListErrorDescription'} />
<Trans i18nKey={'account:factorsListErrorDescription'} />
</AlertDescription>
</Alert>
</div>
@@ -113,11 +114,11 @@ function FactorsTableContainer(props: { userId: string }) {
<ItemContent>
<ItemTitle>
<Trans i18nKey={'account.multiFactorAuthHeading'} />
<Trans i18nKey={'account:multiFactorAuthHeading'} />
</ItemTitle>
<ItemDescription>
<Trans i18nKey={'account.multiFactorAuthDescription'} />
<Trans i18nKey={'account:multiFactorAuthDescription'} />
</ItemDescription>
</ItemContent>
</Item>
@@ -135,7 +136,7 @@ function ConfirmUnenrollFactorModal(
setIsModalOpen: (isOpen: boolean) => void;
}>,
) {
const t = useTranslations();
const { t } = useTranslation();
const unEnroll = useUnenrollFactor(props.userId);
const onUnenrollRequested = useCallback(
@@ -148,18 +149,15 @@ function ConfirmUnenrollFactorModal(
if (!response.success) {
const errorCode = response.data;
throw t(
`auth.errors.${errorCode}` as never,
{
defaultValue: t(`account.unenrollFactorError` as never),
} as never,
);
throw t(`auth:errors.${errorCode}`, {
defaultValue: t(`account:unenrollFactorError`),
});
}
});
toast.promise(promise, {
loading: t(`account.unenrollingFactor` as never),
success: t(`account.unenrollFactorSuccess` as never),
loading: t(`account:unenrollingFactor`),
success: t(`account:unenrollFactorSuccess`),
error: (error: string) => {
return error;
},
@@ -173,17 +171,17 @@ function ConfirmUnenrollFactorModal(
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'account.unenrollFactorModalHeading'} />
<Trans i18nKey={'account:unenrollFactorModalHeading'} />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey={'account.unenrollFactorModalDescription'} />
<Trans i18nKey={'account:unenrollFactorModalDescription'} />
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common.cancel'} />
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<AlertDialogAction
@@ -191,7 +189,7 @@ function ConfirmUnenrollFactorModal(
disabled={unEnroll.isPending}
onClick={() => onUnenrollRequested(props.factorId)}
>
<Trans i18nKey={'account.unenrollFactorModalButtonLabel'} />
<Trans i18nKey={'account:unenrollFactorModalButtonLabel'} />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -214,13 +212,13 @@ function FactorsTable({
<TableHeader>
<TableRow>
<TableHead>
<Trans i18nKey={'account.factorName'} />
<Trans i18nKey={'account:factorName'} />
</TableHead>
<TableHead>
<Trans i18nKey={'account.factorType'} />
<Trans i18nKey={'account:factorType'} />
</TableHead>
<TableHead>
<Trans i18nKey={'account.factorStatus'} />
<Trans i18nKey={'account:factorStatus'} />
</TableHead>
<TableHead />
@@ -252,20 +250,18 @@ function FactorsTable({
<td className={'flex justify-end'}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
variant={'ghost'}
size={'icon'}
onClick={() => setUnenrolling(factor.id)}
>
<X className={'h-4'} />
</Button>
}
/>
<TooltipTrigger asChild>
<Button
variant={'ghost'}
size={'icon'}
onClick={() => setUnenrolling(factor.id)}
>
<X className={'h-4'} />
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey={'account.unenrollTooltip'} />
<Trans i18nKey={'account:unenrollTooltip'} />
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@@ -3,11 +3,12 @@
import { useCallback, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeftIcon, TriangleAlert } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { ArrowLeftIcon } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key';
@@ -44,33 +45,34 @@ import { Trans } from '@kit/ui/trans';
import { refreshAuthSession } from '../../../server/personal-accounts-server-actions';
export function MultiFactorAuthSetupDialog(props: { userId: string }) {
const t = useTranslations();
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const onEnrollSuccess = useCallback(() => {
setIsOpen(false);
return toast.success(t(`account.multiFactorSetupSuccess` as never));
return toast.success(t(`account:multiFactorSetupSuccess`));
}, [t]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen} disablePointerDismissal>
<DialogTrigger
render={
<Button>
<Trans i18nKey={'account.setupMfaButtonLabel'} />
</Button>
}
/>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>
<Trans i18nKey={'account:setupMfaButtonLabel'} />
</Button>
</DialogTrigger>
<DialogContent>
<DialogContent
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'account.setupMfaButtonLabel'} />
<Trans i18nKey={'account:setupMfaButtonLabel'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'account.multiFactorAuthDescription'} />
<Trans i18nKey={'account:multiFactorAuthDescription'} />
</DialogDescription>
</DialogHeader>
@@ -208,7 +210,7 @@ function MultiFactorAuthSetupForm({
<FormDescription>
<Trans
i18nKey={'account.verifyActivationCodeDescription'}
i18nKey={'account:verifyActivationCodeDescription'}
/>
</FormDescription>
@@ -221,7 +223,7 @@ function MultiFactorAuthSetupForm({
<div className={'flex justify-end space-x-2'}>
<Button type={'button'} variant={'ghost'} onClick={onCancel}>
<Trans i18nKey={'common.cancel'} />
<Trans i18nKey={'common:cancel'} />
</Button>
<Button
@@ -231,9 +233,9 @@ function MultiFactorAuthSetupForm({
type={'submit'}
>
{state.loading ? (
<Trans i18nKey={'account.verifyingCode'} />
<Trans i18nKey={'account:verifyingCode'} />
) : (
<Trans i18nKey={'account.enableMfaFactor'} />
<Trans i18nKey={'account:enableMfaFactor'} />
)}
</Button>
</div>
@@ -255,7 +257,7 @@ function FactorQrCode({
onSetFactorId: (factorId: string) => void;
}>) {
const enrollFactorMutation = useEnrollFactor(userId);
const t = useTranslations();
const { t } = useTranslation();
const [error, setError] = useState<string>('');
const form = useForm({
@@ -277,16 +279,16 @@ function FactorQrCode({
return (
<div className={'flex w-full flex-col space-y-2'}>
<Alert variant={'destructive'}>
<TriangleAlert className={'h-4'} />
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account.qrCodeErrorHeading'} />
<Trans i18nKey={'account:qrCodeErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans
i18nKey={`auth.errors.${error}`}
defaults={t('account.qrCodeErrorDescription')}
i18nKey={`auth:errors.${error}`}
defaults={t('account:qrCodeErrorDescription')}
/>
</AlertDescription>
</Alert>
@@ -294,7 +296,7 @@ function FactorQrCode({
<div>
<Button variant={'outline'} onClick={onCancel}>
<ArrowLeftIcon className={'h-4'} />
<Trans i18nKey={`common.retry`} />
<Trans i18nKey={`common:retry`} />
</Button>
</div>
</div>
@@ -334,7 +336,7 @@ function FactorQrCode({
>
<p>
<span className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'account.multiFactorModalHeading'} />
<Trans i18nKey={'account:multiFactorModalHeading'} />
</span>
</p>
@@ -377,7 +379,7 @@ function FactorNameForm(
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'account.factorNameLabel'} />
<Trans i18nKey={'account:factorNameLabel'} />
</FormLabel>
<FormControl>
@@ -385,7 +387,7 @@ function FactorNameForm(
</FormControl>
<FormDescription>
<Trans i18nKey={'account.factorNameHint'} />
<Trans i18nKey={'account:factorNameHint'} />
</FormDescription>
<FormMessage />
@@ -396,11 +398,11 @@ function FactorNameForm(
<div className={'flex justify-end space-x-2'}>
<Button type={'button'} variant={'ghost'} onClick={props.onCancel}>
<Trans i18nKey={'common.cancel'} />
<Trans i18nKey={'common:cancel'} />
</Button>
<Button type={'submit'}>
<Trans i18nKey={'account.factorNameSubmitLabel'} />
<Trans i18nKey={'account:factorNameSubmitLabel'} />
</Button>
</div>
</div>
@@ -499,14 +501,14 @@ function useVerifyCodeMutation(userId: string) {
function ErrorAlert() {
return (
<Alert variant={'destructive'}>
<TriangleAlert className={'h-4'} />
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account.multiFactorSetupErrorHeading'} />
<Trans i18nKey={'account:multiFactorSetupErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account.multiFactorSetupErrorDescription'} />
<Trans i18nKey={'account:multiFactorSetupErrorDescription'} />
</AlertDescription>
</Alert>
);

View File

@@ -5,9 +5,10 @@ import { useState } from 'react';
import type { PostgrestError } from '@supabase/supabase-js';
import { zodResolver } from '@hookform/resolvers/zod';
import { Check, Lock, TriangleAlert, XIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { Check, Lock, XIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -40,7 +41,7 @@ export const UpdatePasswordForm = ({
callbackPath: string;
onSuccess?: () => void;
}) => {
const t = useTranslations('account');
const { t } = useTranslation('account');
const updateUserMutation = useUpdateUser();
const [needsReauthentication, setNeedsReauthentication] = useState(false);
@@ -130,7 +131,7 @@ export const UpdatePasswordForm = ({
autoComplete={'new-password'}
required
type={'password'}
placeholder={t('newPassword')}
placeholder={t('account:newPassword')}
{...field}
/>
</InputGroup>
@@ -159,14 +160,14 @@ export const UpdatePasswordForm = ({
}
required
type={'password'}
placeholder={t('repeatPassword')}
placeholder={t('account:repeatPassword')}
{...field}
/>
</InputGroup>
</FormControl>
<FormDescription>
<Trans i18nKey={'account.repeatPasswordDescription'} />
<Trans i18nKey={'account:repeatPasswordDescription'} />
</FormDescription>
<FormMessage />
@@ -178,11 +179,10 @@ export const UpdatePasswordForm = ({
<div>
<Button
type="submit"
disabled={updateUserMutation.isPending}
data-test="identity-form-submit"
>
<Trans i18nKey={'account.updatePasswordSubmitLabel'} />
<Trans i18nKey={'account:updatePasswordSubmitLabel'} />
</Button>
</div>
</div>
@@ -192,20 +192,20 @@ export const UpdatePasswordForm = ({
};
function ErrorAlert({ error }: { error: { code: string } }) {
const t = useTranslations();
const { t } = useTranslation();
return (
<Alert variant={'destructive'}>
<XIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account.updatePasswordError'} />
<Trans i18nKey={'account:updatePasswordError'} />
</AlertTitle>
<AlertDescription>
<Trans
i18nKey={`auth.errors.${error.code}`}
defaults={t('auth.resetPasswordError')}
i18nKey={`auth:errors.${error.code}`}
defaults={t('auth:resetPasswordError')}
/>
</AlertDescription>
</Alert>
@@ -218,11 +218,11 @@ function SuccessAlert() {
<Check className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account.updatePasswordSuccess'} />
<Trans i18nKey={'account:updatePasswordSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account.updatePasswordSuccessMessage'} />
<Trans i18nKey={'account:updatePasswordSuccessMessage'} />
</AlertDescription>
</Alert>
);
@@ -231,14 +231,14 @@ function SuccessAlert() {
function NeedsReauthenticationAlert() {
return (
<Alert variant={'warning'}>
<TriangleAlert className={'h-4'} />
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account.needsReauthentication'} />
<Trans i18nKey={'account:needsReauthentication'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account.needsReauthenticationDescription'} />
<Trans i18nKey={'account:needsReauthenticationDescription'} />
</AlertDescription>
</Alert>
);

View File

@@ -1,7 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { User } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Database } from '@kit/supabase/database';
import { Button } from '@kit/ui/button';
@@ -35,7 +35,7 @@ export function UpdateAccountDetailsForm({
onUpdate: (user: Partial<UpdateUserDataParams>) => void;
}) {
const updateAccountMutation = useUpdateAccountData(userId);
const t = useTranslations('account');
const { t } = useTranslation('account');
const form = useForm({
resolver: zodResolver(AccountDetailsSchema),
@@ -79,7 +79,7 @@ export function UpdateAccountDetailsForm({
<InputGroupInput
data-test={'account-display-name'}
minLength={2}
placeholder={t('name')}
placeholder={t('account:name')}
maxLength={100}
{...field}
/>
@@ -92,8 +92,8 @@ export function UpdateAccountDetailsForm({
/>
<div>
<Button type="submit" disabled={updateAccountMutation.isPending}>
<Trans i18nKey={'account.updateProfileSubmitLabel'} />
<Button disabled={updateAccountMutation.isPending}>
<Trans i18nKey={'account:updateProfileSubmitLabel'} />
</Button>
</div>
</form>

View File

@@ -4,7 +4,7 @@ import { useCallback } from 'react';
import type { SupabaseClient } from '@supabase/supabase-js';
import { useTranslations } from 'next-intl';
import { useTranslation } from 'react-i18next';
import { Database } from '@kit/supabase/database';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
@@ -41,7 +41,7 @@ function UploadProfileAvatarForm(props: {
onAvatarUpdated: () => void;
}) {
const client = useSupabase();
const t = useTranslations('account');
const { t } = useTranslation('account');
const createToaster = useCallback(
(promise: () => Promise<unknown>) => {
@@ -111,11 +111,11 @@ function UploadProfileAvatarForm(props: {
<ImageUploader value={props.pictureUrl} onValueChange={onValueChange}>
<div className={'flex flex-col space-y-1'}>
<span className={'text-sm'}>
<Trans i18nKey={'account.profilePictureHeading'} />
<Trans i18nKey={'account:profilePictureHeading'} />
</span>
<span className={'text-xs'}>
<Trans i18nKey={'account.profilePictureSubheading'} />
<Trans i18nKey={'account:profilePictureSubheading'} />
</span>
</div>
</ImageUploader>

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
export const AccountDetailsSchema = z.object({
displayName: z.string().min(2).max(100),

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
export const DeletePersonalAccountSchema = z.object({
otp: z.string().min(6),

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
export const LinkEmailPasswordSchema = z
.object({
@@ -8,5 +8,5 @@ export const LinkEmailPasswordSchema = z
})
.refine((values) => values.password === values.repeatPassword, {
path: ['repeatPassword'],
message: `account.passwordNotMatching`,
message: `account:passwordNotMatching`,
});

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
export const UpdateEmailSchema = {
withTranslation: (errorMessage: string) => {

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
export const PasswordUpdateSchema = {
withTranslation: (errorMessage: string) => {

View File

@@ -3,7 +3,7 @@
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { authActionClient } from '@kit/next/safe-action';
import { enhanceAction } from '@kit/next/actions';
import { createOtpApi } from '@kit/otp';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
@@ -23,17 +23,25 @@ export async function refreshAuthSession() {
return {};
}
export const deletePersonalAccountAction = authActionClient
.schema(DeletePersonalAccountSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
export const deletePersonalAccountAction = enhanceAction(
async (formData: FormData, user) => {
const logger = await getLogger();
// validate the form data
const { success } = DeletePersonalAccountSchema.safeParse(
Object.fromEntries(formData.entries()),
);
if (!success) {
throw new Error('Invalid form data');
}
const ctx = {
name: 'account.delete',
userId: user.id,
};
const otp = data.otp;
const otp = formData.get('otp') as string;
if (!otp) {
throw new Error('OTP is required');
@@ -93,4 +101,6 @@ export const deletePersonalAccountAction = authActionClient
// redirect to the home page
redirect('/');
});
},
{},
);

View File

@@ -2,7 +2,7 @@ import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import * as z from 'zod';
import { z } from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
@@ -133,12 +133,12 @@ class DeletePersonalAccountService {
.object({
productName: z
.string({
error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
})
.min(1),
fromEmail: z
.string({
error: 'EMAIL_SENDER is required',
required_error: 'EMAIL_SENDER is required',
})
.min(1),
})

View File

@@ -26,7 +26,6 @@
"@types/react": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "catalog:",

View File

@@ -7,7 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { ColumnDef } from '@tanstack/react-table';
import { EllipsisVertical } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { z } from 'zod';
import { Tables } from '@kit/supabase/database';
import { Button } from '@kit/ui/button';
@@ -77,7 +77,7 @@ export function AdminAccountsTable(
}
function AccountsTableFilters(props: {
filters: z.output<typeof FiltersSchema>;
filters: z.infer<typeof FiltersSchema>;
}) {
const form = useForm({
resolver: zodResolver(FiltersSchema),
@@ -92,7 +92,7 @@ function AccountsTableFilters(props: {
const router = useRouter();
const pathName = usePathname();
const onSubmit = ({ type, query }: z.output<typeof FiltersSchema>) => {
const onSubmit = ({ type, query }: z.infer<typeof FiltersSchema>) => {
const params = new URLSearchParams({
account_type: type,
query: query ?? '',
@@ -105,12 +105,6 @@ function AccountsTableFilters(props: {
const type = useWatch({ control: form.control, name: 'type' });
const options = {
all: 'All Accounts',
team: 'Team',
personal: 'Personal',
};
return (
<Form {...form}>
<form
@@ -122,7 +116,7 @@ function AccountsTableFilters(props: {
onValueChange={(value) => {
form.setValue(
'type',
value as z.output<typeof FiltersSchema>['type'],
value as z.infer<typeof FiltersSchema>['type'],
{
shouldValidate: true,
shouldDirty: true,
@@ -134,20 +128,16 @@ function AccountsTableFilters(props: {
}}
>
<SelectTrigger>
<SelectValue placeholder={'Account Type'}>
{(value: keyof typeof options) => options[value]}
</SelectValue>
<SelectValue placeholder={'Account Type'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Account Type</SelectLabel>
{Object.entries(options).map(([key, value]) => (
<SelectItem key={key} value={key}>
{value}
</SelectItem>
))}
<SelectItem value={'all'}>All accounts</SelectItem>
<SelectItem value={'team'}>Team</SelectItem>
<SelectItem value={'personal'}>Personal</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
@@ -167,8 +157,6 @@ function AccountsTableFilters(props: {
</FormItem>
)}
/>
<button type="submit" hidden />
</form>
</Form>
);
@@ -223,13 +211,11 @@ function getColumns(): ColumnDef<Account>[] {
return (
<div className={'flex justify-end'}>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant={'outline'} size={'icon'}>
<EllipsisVertical className={'h-4'} />
</Button>
}
/>
<DropdownMenuTrigger asChild>
<Button variant={'outline'} size={'icon'}>
<EllipsisVertical className={'h-4'} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={'end'}>
<DropdownMenuGroup>

View File

@@ -1,9 +1,8 @@
'use client';
import { useState } from 'react';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -42,7 +41,7 @@ export function AdminBanUserDialog(
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger render={props.children as React.ReactElement} />
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
@@ -61,9 +60,8 @@ export function AdminBanUserDialog(
}
function BanUserForm(props: { userId: string; onSuccess: () => void }) {
const { execute, isPending, hasErrored } = useAction(banUserAction, {
onSuccess: () => props.onSuccess(),
});
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
const form = useForm({
resolver: zodResolver(BanUserSchema),
@@ -78,9 +76,18 @@ function BanUserForm(props: { userId: string; onSuccess: () => void }) {
<form
data-test={'admin-ban-user-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => execute(data))}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
await banUserAction(data);
props.onSuccess();
} catch {
setError(true);
}
});
})}
>
<If condition={hasErrored}>
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
@@ -118,10 +125,10 @@ function BanUserForm(props: { userId: string; onSuccess: () => void }) {
/>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
<Button disabled={isPending} type={'submit'} variant={'destructive'}>
{isPending ? 'Banning...' : 'Ban User'}
<Button disabled={pending} type={'submit'} variant={'destructive'}>
{pending ? 'Banning...' : 'Ban User'}
</Button>
</AlertDialogFooter>
</form>

View File

@@ -1,9 +1,8 @@
'use client';
import { useState } from 'react';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -39,6 +38,8 @@ import {
} from '../lib/server/schema/create-user.schema';
export function AdminCreateUserDialog(props: React.PropsWithChildren) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const form = useForm({
@@ -51,19 +52,28 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
mode: 'onChange',
});
const { execute, isPending, result } = useAction(createUserAction, {
onSuccess: () => {
toast.success('User created successfully');
form.reset();
setOpen(false);
},
});
const onSubmit = (data: CreateUserSchemaType) => {
startTransition(async () => {
try {
const result = await createUserAction(data);
const error = result.serverError;
if (result.success) {
toast.success('User creates successfully');
form.reset();
setOpen(false);
}
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Error');
}
});
};
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger render={props.children as React.ReactElement} />
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
@@ -78,9 +88,7 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
<form
data-test={'admin-create-user-form'}
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit((data: CreateUserSchemaType) =>
execute(data),
)}
onSubmit={form.handleSubmit(onSubmit)}
>
<If condition={!!error}>
<Alert variant={'destructive'}>
@@ -158,8 +166,8 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button disabled={isPending} type={'submit'}>
{isPending ? 'Creating...' : 'Create User'}
<Button disabled={pending} type={'submit'}>
{pending ? 'Creating...' : 'Create User'}
</Button>
</AlertDialogFooter>
</form>

View File

@@ -1,7 +1,8 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -36,7 +37,8 @@ export function AdminDeleteAccountDialog(
accountId: string;
}>,
) {
const { execute, isPending, hasErrored } = useAction(deleteAccountAction);
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
const form = useForm({
resolver: zodResolver(DeleteAccountSchema),
@@ -48,7 +50,7 @@ export function AdminDeleteAccountDialog(
return (
<AlertDialog>
<AlertDialogTrigger render={props.children as React.ReactElement} />
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
@@ -63,11 +65,20 @@ export function AdminDeleteAccountDialog(
<Form {...form}>
<form
data-test={'admin-delete-account-form'}
data-form={'admin-delete-account-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => execute(data))}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
await deleteAccountAction(data);
setError(false);
} catch {
setError(true);
}
});
})}
>
<If condition={hasErrored}>
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
@@ -109,11 +120,11 @@ export function AdminDeleteAccountDialog(
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
disabled={isPending}
disabled={pending}
type={'submit'}
variant={'destructive'}
>
{isPending ? 'Deleting...' : 'Delete'}
{pending ? 'Deleting...' : 'Delete'}
</Button>
</AlertDialogFooter>
</form>

View File

@@ -1,7 +1,10 @@
'use client';
import { useState, useTransition } from 'react';
import { isRedirectError } from 'next/dist/client/components/redirect-error';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -36,7 +39,8 @@ export function AdminDeleteUserDialog(
userId: string;
}>,
) {
const { execute, isPending, hasErrored } = useAction(deleteUserAction);
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
const form = useForm({
resolver: zodResolver(DeleteUserSchema),
@@ -48,7 +52,7 @@ export function AdminDeleteUserDialog(
return (
<AlertDialog>
<AlertDialogTrigger render={props.children as React.ReactElement} />
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
@@ -65,9 +69,23 @@ export function AdminDeleteUserDialog(
<form
data-test={'admin-delete-user-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => execute(data))}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
await deleteUserAction(data);
setError(false);
} catch {
if (isRedirectError(error)) {
// Do nothing
} else {
setError(true);
}
}
});
})}
>
<If condition={hasErrored}>
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
@@ -109,11 +127,11 @@ export function AdminDeleteUserDialog(
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
disabled={isPending}
disabled={pending}
type={'submit'}
variant={'destructive'}
>
{isPending ? 'Deleting...' : 'Delete'}
{pending ? 'Deleting...' : 'Delete'}
</Button>
</AlertDialogFooter>
</form>

View File

@@ -1,10 +1,9 @@
'use client';
import { useState } from 'react';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
@@ -54,13 +53,8 @@ export function AdminImpersonateUserDialog(
refreshToken: string;
}>();
const { execute, isPending, hasErrored } = useAction(impersonateUserAction, {
onSuccess: ({ data }) => {
if (data) {
setTokens(data);
}
},
});
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<boolean | null>(null);
if (tokens) {
return (
@@ -74,7 +68,7 @@ export function AdminImpersonateUserDialog(
return (
<AlertDialog>
<AlertDialogTrigger render={props.children as React.ReactElement} />
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
@@ -97,9 +91,19 @@ export function AdminImpersonateUserDialog(
<form
data-test={'admin-impersonate-user-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => execute(data))}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
const result = await impersonateUserAction(data);
setTokens(result);
} catch {
setError(true);
}
});
})}
>
<If condition={hasErrored}>
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>

View File

@@ -1,9 +1,8 @@
'use client';
import { useState } from 'react';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -42,7 +41,7 @@ export function AdminReactivateUserDialog(
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger render={props.children as React.ReactElement} />
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
@@ -63,9 +62,8 @@ export function AdminReactivateUserDialog(
}
function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) {
const { execute, isPending, hasErrored } = useAction(reactivateUserAction, {
onSuccess: () => props.onSuccess(),
});
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
const form = useForm({
resolver: zodResolver(ReactivateUserSchema),
@@ -80,9 +78,18 @@ function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) {
<form
data-test={'admin-reactivate-user-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => execute(data))}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
await reactivateUserAction(data);
props.onSuccess();
} catch {
setError(true);
}
});
})}
>
<If condition={hasErrored}>
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
@@ -120,10 +127,10 @@ function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) {
/>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
<Button disabled={isPending} type={'submit'}>
{isPending ? 'Reactivating...' : 'Reactivate User'}
<Button disabled={pending} type={'submit'}>
{pending ? 'Reactivating...' : 'Reactivate User'}
</Button>
</AlertDialogFooter>
</form>

View File

@@ -1,9 +1,10 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { z } from 'zod';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
@@ -50,22 +51,33 @@ export function AdminResetPasswordDialog(
},
});
const { execute, isPending, hasErrored, hasSucceeded } = useAction(
resetPasswordAction,
{
onSuccess: () => {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const onSubmit = form.handleSubmit((data) => {
setError(null);
setSuccess(false);
startTransition(async () => {
try {
await resetPasswordAction(data);
setSuccess(true);
form.reset({ userId: props.userId, confirmation: '' });
toast.success('Password reset email successfully sent');
},
onError: () => {
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
toast.error('We hit an error. Please read the logs.');
},
},
);
}
});
});
return (
<AlertDialog>
<AlertDialogTrigger render={props.children as React.ReactElement} />
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
@@ -78,10 +90,7 @@ export function AdminResetPasswordDialog(
<div className="relative">
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="space-y-4"
>
<form onSubmit={onSubmit} className="space-y-4">
<FormField
control={form.control}
name="confirmation"
@@ -106,7 +115,7 @@ export function AdminResetPasswordDialog(
)}
/>
<If condition={hasErrored}>
<If condition={!!error}>
<Alert variant="destructive">
<AlertTitle>
We encountered an error while sending the email
@@ -118,7 +127,7 @@ export function AdminResetPasswordDialog(
</Alert>
</If>
<If condition={hasSucceeded}>
<If condition={success}>
<Alert>
<AlertTitle>
Password reset email sent successfully

View File

@@ -3,6 +3,7 @@
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -18,168 +19,212 @@ import { CreateUserSchema } from './schema/create-user.schema';
import { ResetPasswordSchema } from './schema/reset-password.schema';
import { createAdminAccountsService } from './services/admin-accounts.service';
import { createAdminAuthUserService } from './services/admin-auth-user.service';
import { adminActionClient } from './utils/admin-action-client';
import { adminAction } from './utils/admin-action';
/**
* @name banUserAction
* @description Ban a user from the system.
*/
export const banUserAction = adminActionClient
.schema(BanUserSchema)
.action(async ({ parsedInput: { userId } }) => {
const service = getAdminAuthService();
const logger = await getLogger();
export const banUserAction = adminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
const logger = await getLogger();
logger.info({ userId }, `Super Admin is banning user...`);
logger.info({ userId }, `Super Admin is banning user...`);
const { error } = await service.banUser(userId);
const { error } = await service.banUser(userId);
if (error) {
logger.error({ error }, `Error banning user`);
throw new Error('Error banning user');
}
if (error) {
logger.error({ error }, `Error banning user`);
revalidateAdmin();
return {
success: false,
};
}
logger.info({ userId }, `Super Admin has successfully banned user`);
});
revalidateAdmin();
logger.info({ userId }, `Super Admin has successfully banned user`);
},
{
schema: BanUserSchema,
},
),
);
/**
* @name reactivateUserAction
* @description Reactivate a user in the system.
*/
export const reactivateUserAction = adminActionClient
.schema(ReactivateUserSchema)
.action(async ({ parsedInput: { userId } }) => {
const service = getAdminAuthService();
const logger = await getLogger();
export const reactivateUserAction = adminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
const logger = await getLogger();
logger.info({ userId }, `Super Admin is reactivating user...`);
logger.info({ userId }, `Super Admin is reactivating user...`);
const { error } = await service.reactivateUser(userId);
const { error } = await service.reactivateUser(userId);
if (error) {
logger.error({ error }, `Error reactivating user`);
throw new Error('Error reactivating user');
}
if (error) {
logger.error({ error }, `Error reactivating user`);
revalidateAdmin();
return {
success: false,
};
}
logger.info({ userId }, `Super Admin has successfully reactivated user`);
});
revalidateAdmin();
logger.info({ userId }, `Super Admin has successfully reactivated user`);
},
{
schema: ReactivateUserSchema,
},
),
);
/**
* @name impersonateUserAction
* @description Impersonate a user in the system.
*/
export const impersonateUserAction = adminActionClient
.schema(ImpersonateUserSchema)
.action(async ({ parsedInput: { userId } }) => {
const service = getAdminAuthService();
const logger = await getLogger();
export const impersonateUserAction = adminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
const logger = await getLogger();
logger.info({ userId }, `Super Admin is impersonating user...`);
logger.info({ userId }, `Super Admin is impersonating user...`);
return await service.impersonateUser(userId);
});
return await service.impersonateUser(userId);
},
{
schema: ImpersonateUserSchema,
},
),
);
/**
* @name deleteUserAction
* @description Delete a user from the system.
*/
export const deleteUserAction = adminActionClient
.schema(DeleteUserSchema)
.action(async ({ parsedInput: { userId } }) => {
const service = getAdminAuthService();
const logger = await getLogger();
export const deleteUserAction = adminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
const logger = await getLogger();
logger.info({ userId }, `Super Admin is deleting user...`);
logger.info({ userId }, `Super Admin is deleting user...`);
await service.deleteUser(userId);
await service.deleteUser(userId);
logger.info({ userId }, `Super Admin has successfully deleted user`);
logger.info({ userId }, `Super Admin has successfully deleted user`);
redirect('/admin/accounts');
});
return redirect('/admin/accounts');
},
{
schema: DeleteUserSchema,
},
),
);
/**
* @name deleteAccountAction
* @description Delete an account from the system.
*/
export const deleteAccountAction = adminActionClient
.schema(DeleteAccountSchema)
.action(async ({ parsedInput: { accountId } }) => {
const service = getAdminAccountsService();
const logger = await getLogger();
export const deleteAccountAction = adminAction(
enhanceAction(
async ({ accountId }) => {
const service = getAdminAccountsService();
const logger = await getLogger();
logger.info({ accountId }, `Super Admin is deleting account...`);
logger.info({ accountId }, `Super Admin is deleting account...`);
await service.deleteAccount(accountId);
await service.deleteAccount(accountId);
revalidateAdmin();
revalidateAdmin();
logger.info({ accountId }, `Super Admin has successfully deleted account`);
logger.info(
{ accountId },
`Super Admin has successfully deleted account`,
);
redirect('/admin/accounts');
});
return redirect('/admin/accounts');
},
{
schema: DeleteAccountSchema,
},
),
);
/**
* @name createUserAction
* @description Create a new user in the system.
*/
export const createUserAction = adminActionClient
.schema(CreateUserSchema)
.action(async ({ parsedInput: { email, password, emailConfirm } }) => {
const adminClient = getSupabaseServerAdminClient();
const logger = await getLogger();
export const createUserAction = adminAction(
enhanceAction(
async ({ email, password, emailConfirm }) => {
const adminClient = getSupabaseServerAdminClient();
const logger = await getLogger();
logger.info({ email }, `Super Admin is creating a new user...`);
logger.info({ email }, `Super Admin is creating a new user...`);
const { data, error } = await adminClient.auth.admin.createUser({
email,
password,
email_confirm: emailConfirm,
});
const { data, error } = await adminClient.auth.admin.createUser({
email,
password,
email_confirm: emailConfirm,
});
if (error) {
logger.error({ error }, `Error creating user`);
throw new Error(`Error creating user: ${error.message}`);
}
if (error) {
logger.error({ error }, `Error creating user`);
throw new Error(`Error creating user: ${error.message}`);
}
logger.info(
{ userId: data.user.id },
`Super Admin has successfully created a new user`,
);
logger.info(
{ userId: data.user.id },
`Super Admin has successfully created a new user`,
);
revalidatePath(`/admin/accounts`);
revalidatePath(`/admin/accounts`);
return {
success: true,
user: data.user,
};
});
return {
success: true,
user: data.user,
};
},
{
schema: CreateUserSchema,
},
),
);
/**
* @name resetPasswordAction
* @description Reset a user's password by sending a password reset email.
*/
export const resetPasswordAction = adminActionClient
.schema(ResetPasswordSchema)
.action(async ({ parsedInput: { userId } }) => {
const service = getAdminAuthService();
const logger = await getLogger();
export const resetPasswordAction = adminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
const logger = await getLogger();
logger.info({ userId }, `Super Admin is resetting user password...`);
logger.info({ userId }, `Super Admin is resetting user password...`);
const result = await service.resetPassword(userId);
const result = await service.resetPassword(userId);
logger.info(
{ userId },
`Super Admin has successfully sent password reset email`,
);
logger.info(
{ userId },
`Super Admin has successfully sent password reset email`,
);
return result;
});
return result;
},
{
schema: ResetPasswordSchema,
},
),
);
function revalidateAdmin() {
revalidatePath(`/admin/accounts/[id]`, 'page');

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
const ConfirmationSchema = z.object({
confirmation: z.custom<string>((value) => value === 'CONFIRM'),

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
export const CreateUserSchema = z.object({
email: z.string().email({ message: 'Please enter a valid email address' }),
@@ -8,4 +8,4 @@ export const CreateUserSchema = z.object({
emailConfirm: z.boolean().default(false).optional(),
});
export type CreateUserSchemaType = z.output<typeof CreateUserSchema>;
export type CreateUserSchemaType = z.infer<typeof CreateUserSchema>;

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
/**
* Schema for resetting a user's password

View File

@@ -2,7 +2,7 @@ import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import * as z from 'zod';
import { z } from 'zod';
import { Database } from '@kit/supabase/database';

View File

@@ -1,23 +0,0 @@
import 'server-only';
import { notFound } from 'next/navigation';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { isSuperAdmin } from './is-super-admin';
/**
* @name adminActionClient
* @description Safe action client for admin-only actions.
* Extends authActionClient with super admin verification.
*/
export const adminActionClient = authActionClient.use(async ({ next, ctx }) => {
const isAdmin = await isSuperAdmin(getSupabaseServerClient());
if (!isAdmin) {
notFound();
}
return next({ ctx });
});

View File

@@ -28,14 +28,15 @@
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@marsidev/react-turnstile": "catalog:",
"@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "catalog:",
"@tanstack/react-query": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
"next-intl": "catalog:",
"react-hook-form": "catalog:",
"react-i18next": "catalog:",
"sonner": "^2.0.7",
"zod": "catalog:"
},

View File

@@ -1,4 +1,4 @@
import { TriangleAlert } from 'lucide-react';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import {
WeakPasswordError,
@@ -33,25 +33,23 @@ export function AuthErrorAlert({
return <WeakPasswordErrorAlert reasons={error.reasons} />;
}
const DefaultError = <Trans i18nKey="auth.errors.default" />;
const errorCode =
error instanceof Error
? 'code' in error && typeof error.code === 'string'
? error.code
: error.message
: error;
const DefaultError = <Trans i18nKey="auth:errors.default" />;
const errorCode = error instanceof Error ? error.message : error;
return (
<Alert variant={'destructive'}>
<TriangleAlert className={'w-4'} />
<ExclamationTriangleIcon className={'w-4'} />
<AlertTitle>
<Trans i18nKey={`auth.errorAlertHeading`} />
<Trans i18nKey={`auth:errorAlertHeading`} />
</AlertTitle>
<AlertDescription data-test={'auth-error-message'}>
<Trans i18nKey={`auth.errors.${errorCode}`} defaults={DefaultError} />
<Trans
i18nKey={`auth:errors.${errorCode}`}
defaults={'<DefaultError />'}
components={{ DefaultError }}
/>
</AlertDescription>
</Alert>
);
@@ -64,21 +62,21 @@ function WeakPasswordErrorAlert({
}) {
return (
<Alert variant={'destructive'}>
<TriangleAlert className={'w-4'} />
<ExclamationTriangleIcon className={'w-4'} />
<AlertTitle>
<Trans i18nKey={'auth.errors.weakPassword.title'} />
<Trans i18nKey={'auth:errors.weakPassword.title'} />
</AlertTitle>
<AlertDescription data-test={'auth-error-message'}>
<Trans i18nKey={'auth.errors.weakPassword.description'} />
<Trans i18nKey={'auth:errors.weakPassword.description'} />
{reasons.length > 0 && (
<ul className="mt-2 list-inside list-disc space-y-1 text-xs">
{reasons.map((reason) => (
<li key={reason}>
<Trans
i18nKey={`auth.errors.weakPassword.reasons.${reason}`}
i18nKey={`auth:errors.weakPassword.reasons.${reason}`}
defaults={reason}
/>
</li>

View File

@@ -1,7 +1,7 @@
'use client';
import { Mail } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useTranslation } from 'react-i18next';
import {
InputGroup,
@@ -10,7 +10,7 @@ import {
} from '@kit/ui/input-group';
export function EmailInput(props: React.ComponentProps<'input'>) {
const t = useTranslations('auth');
const { t } = useTranslation('auth');
return (
<InputGroup className="dark:bg-background">

View File

@@ -7,7 +7,7 @@ import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { UserCheck } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription } from '@kit/ui/alert';
import { If } from '@kit/ui/if';
@@ -36,7 +36,7 @@ export function ExistingAccountHintImpl({
useLastAuthMethod();
const params = useSearchParams();
const t = useTranslations();
const { t } = useTranslation();
const isInvite = params.get('invite_token');
@@ -53,13 +53,13 @@ export function ExistingAccountHintImpl({
switch (methodType) {
case 'password':
return 'auth.methodPassword';
return 'auth:methodPassword';
case 'otp':
return 'auth.methodOtp';
return 'auth:methodOtp';
case 'magic_link':
return 'auth.methodMagicLink';
return 'auth:methodMagicLink';
default:
return 'auth.methodDefault';
return 'auth:methodDefault';
}
}, [methodType, isOAuth, providerName]);
@@ -73,10 +73,10 @@ export function ExistingAccountHintImpl({
<Alert data-test={'existing-account-hint'} className={className}>
<UserCheck className="h-4 w-4" />
<AlertDescription className={'text-xs'}>
<AlertDescription>
<Trans
i18nKey="auth.existingAccountHint"
values={{ methodName: t(methodDescription) }}
i18nKey="auth:existingAccountHint"
values={{ method: t(methodDescription) }}
components={{
method: <span className="font-medium" />,
signInLink: (

View File

@@ -32,13 +32,13 @@ function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) {
const methodKey = useMemo(() => {
switch (methodType) {
case 'password':
return 'auth.methodPassword';
return 'auth:methodPassword';
case 'otp':
return 'auth.methodOtp';
return 'auth:methodOtp';
case 'magic_link':
return 'auth.methodMagicLink';
return 'auth:methodMagicLink';
case 'oauth':
return 'auth.methodOauth';
return 'auth:methodOauth';
default:
return null;
}
@@ -61,10 +61,10 @@ function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) {
<Lightbulb className="h-3 w-3" />
<span>
<Trans i18nKey="auth.lastUsedMethodPrefix" />{' '}
<Trans i18nKey="auth:lastUsedMethodPrefix" />{' '}
<If condition={isOAuth && Boolean(providerName)}>
<Trans
i18nKey="auth.methodOauthWithProvider"
i18nKey="auth:methodOauthWithProvider"
values={{ provider: providerName }}
components={{
provider: <span className="text-muted-foreground font-medium" />,

View File

@@ -1,10 +1,10 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Check, TriangleAlert } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { CheckIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { useAppEvents } from '@kit/shared/events';
import { useSignInWithOtp } from '@kit/supabase/hooks/use-sign-in-with-otp';
@@ -44,7 +44,7 @@ export function MagicLinkAuthContainer({
};
}) {
const captcha = useCaptcha({ siteKey: captchaSiteKey });
const t = useTranslations();
const { t } = useTranslation();
const signInWithOtpMutation = useSignInWithOtp();
const appEvents = useAppEvents();
const { recordAuthMethod } = useLastAuthMethod();
@@ -90,9 +90,9 @@ export function MagicLinkAuthContainer({
};
toast.promise(promise, {
loading: t('auth.sendingEmailLink'),
success: t(`auth.sendLinkSuccessToast`),
error: t(`auth.errors.linkTitle`),
loading: t('auth:sendingEmailLink'),
success: t(`auth:sendLinkSuccessToast`),
error: t(`auth:errors.linkTitle`),
});
captcha.reset();
@@ -116,7 +116,7 @@ export function MagicLinkAuthContainer({
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common.emailAddress'} />
<Trans i18nKey={'common:emailAddress'} />
</FormLabel>
<FormControl>
@@ -133,20 +133,17 @@ export function MagicLinkAuthContainer({
<TermsAndConditionsFormField />
</If>
<Button
type="submit"
disabled={signInWithOtpMutation.isPending || captchaLoading}
>
<Button disabled={signInWithOtpMutation.isPending || captchaLoading}>
<If condition={captchaLoading}>
<Trans i18nKey={'auth.verifyingCaptcha'} />
<Trans i18nKey={'auth:verifyingCaptcha'} />
</If>
<If condition={signInWithOtpMutation.isPending && !captchaLoading}>
<Trans i18nKey={'auth.sendingEmailLink'} />
<Trans i18nKey={'auth:sendingEmailLink'} />
</If>
<If condition={!signInWithOtpMutation.isPending && !captchaLoading}>
<Trans i18nKey={'auth.sendEmailLink'} />
<Trans i18nKey={'auth:sendEmailLink'} />
</If>
</Button>
</div>
@@ -158,14 +155,14 @@ export function MagicLinkAuthContainer({
function SuccessAlert() {
return (
<Alert variant={'success'}>
<Check className={'h-4'} />
<CheckIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'auth.sendLinkSuccess'} />
<Trans i18nKey={'auth:sendLinkSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'auth.sendLinkSuccessDescription'} />
<Trans i18nKey={'auth:sendLinkSuccessDescription'} />
</AlertDescription>
</Alert>
);
@@ -174,14 +171,14 @@ function SuccessAlert() {
function ErrorAlert() {
return (
<Alert variant={'destructive'}>
<TriangleAlert className={'h-4'} />
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'auth.errors.linkTitle'} />
<Trans i18nKey={'auth:errors.linkTitle'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'auth.errors.linkDescription'} />
<Trans i18nKey={'auth:errors.linkDescription'} />
</AlertDescription>
</Alert>
);

View File

@@ -5,10 +5,10 @@ import { useEffect, useEffectEvent } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useMutation } from '@tanstack/react-query';
import { TriangleAlert } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { z } from 'zod';
import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
@@ -94,7 +94,7 @@ export function MultiFactorChallengeContainer({
<div className={'flex flex-col items-center gap-y-6'}>
<div className="flex flex-col items-center gap-y-4">
<Heading level={5}>
<Trans i18nKey={'auth.verifyCodeHeading'} />
<Trans i18nKey={'auth:verifyCodeHeading'} />
</Heading>
</div>
@@ -102,15 +102,15 @@ export function MultiFactorChallengeContainer({
<div className={'flex flex-col gap-y-4'}>
<If condition={verifyMFAChallenge.error}>
<Alert variant={'destructive'}>
<TriangleAlert className={'h-5'} />
<ExclamationTriangleIcon className={'h-5'} />
<AlertTitle>
<Trans i18nKey={'account.invalidVerificationCodeHeading'} />
<Trans i18nKey={'account:invalidVerificationCodeHeading'} />
</AlertTitle>
<AlertDescription>
<Trans
i18nKey={'account.invalidVerificationCodeDescription'}
i18nKey={'account:invalidVerificationCodeDescription'}
/>
</AlertDescription>
</Alert>
@@ -143,7 +143,7 @@ export function MultiFactorChallengeContainer({
<FormDescription className="text-center">
<Trans
i18nKey={'account.verifyActivationCodeDescription'}
i18nKey={'account:verifyActivationCodeDescription'}
/>
</FormDescription>
@@ -156,7 +156,6 @@ export function MultiFactorChallengeContainer({
</div>
<Button
type="submit"
className="w-full"
data-test={'submit-mfa-button'}
disabled={
@@ -167,13 +166,13 @@ export function MultiFactorChallengeContainer({
>
<If condition={verifyMFAChallenge.isPending}>
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
<Trans i18nKey={'account.verifyingCode'} />
<Trans i18nKey={'account:verifyingCode'} />
</span>
</If>
<If condition={verifyMFAChallenge.isSuccess}>
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
<Trans i18nKey={'auth.redirecting'} />
<Trans i18nKey={'auth:redirecting'} />
</span>
</If>
@@ -182,7 +181,7 @@ export function MultiFactorChallengeContainer({
!verifyMFAChallenge.isPending && !verifyMFAChallenge.isSuccess
}
>
<Trans i18nKey={'account.submitVerificationCode'} />
<Trans i18nKey={'account:submitVerificationCode'} />
</If>
</Button>
</div>
@@ -256,7 +255,7 @@ function FactorsListContainer({
<Spinner />
<div className={'text-sm'}>
<Trans i18nKey={'account.loadingFactors'} />
<Trans i18nKey={'account:loadingFactors'} />
</div>
</div>
);
@@ -266,14 +265,14 @@ function FactorsListContainer({
return (
<div className={'w-full'}>
<Alert variant={'destructive'}>
<TriangleAlert className={'h-4'} />
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account.factorsListError'} />
<Trans i18nKey={'account:factorsListError'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account.factorsListErrorDescription'} />
<Trans i18nKey={'account:factorsListErrorDescription'} />
</AlertDescription>
</Alert>
</div>
@@ -286,7 +285,7 @@ function FactorsListContainer({
<div className={'animate-in fade-in flex flex-col space-y-4 duration-500'}>
<div>
<span className={'font-medium'}>
<Trans i18nKey={'account.selectFactor'} />
<Trans i18nKey={'account:selectFactor'} />
</span>
</div>

View File

@@ -114,7 +114,7 @@ export const OauthProviders: React.FC<{
}}
>
<Trans
i18nKey={'auth.signInWithProvider'}
i18nKey={'auth:signInWithProvider'}
values={{
provider: getProviderName(provider),
}}

View File

@@ -4,7 +4,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { z } from 'zod';
import { useSignInWithOtp } from '@kit/supabase/hooks/use-sign-in-with-otp';
import { useVerifyOtp } from '@kit/supabase/hooks/use-verify-otp';
@@ -132,7 +132,7 @@ export function OtpSignInContainer(props: OtpSignInContainerProps) {
</FormControl>
<FormDescription>
<Trans i18nKey="common.otp.enterCodeFromEmail" />
<Trans i18nKey="common:otp.enterCodeFromEmail" />
</FormDescription>
<FormMessage />
@@ -149,10 +149,10 @@ export function OtpSignInContainer(props: OtpSignInContainerProps) {
{verifyMutation.isPending ? (
<>
<Spinner className="mr-2 h-4 w-4" />
<Trans i18nKey="common.otp.verifying" />
<Trans i18nKey="common:otp.verifying" />
</>
) : (
<Trans i18nKey="common.otp.verifyCode" />
<Trans i18nKey="common:otp.verifyCode" />
)}
</Button>
@@ -166,7 +166,7 @@ export function OtpSignInContainer(props: OtpSignInContainerProps) {
});
}}
>
<Trans i18nKey="common.otp.requestNewCode" />
<Trans i18nKey="common:otp.requestNewCode" />
</Button>
</div>
</form>
@@ -191,7 +191,7 @@ function OtpEmailForm({
defaultValues: { email: '' },
});
const handleSendOtp = async ({ email }: z.output<typeof EmailSchema>) => {
const handleSendOtp = async ({ email }: z.infer<typeof EmailSchema>) => {
await signInMutation.mutateAsync({
email,
options: { captchaToken: captcha.token, shouldCreateUser },
@@ -230,10 +230,10 @@ function OtpEmailForm({
{signInMutation.isPending ? (
<>
<Spinner className="mr-2 h-4 w-4" />
<Trans i18nKey="common.otp.sendingCode" />
<Trans i18nKey="common:otp.sendingCode" />
</>
) : (
<Trans i18nKey="common.otp.sendVerificationCode" />
<Trans i18nKey="common:otp.sendVerificationCode" />
)}
</Button>
</form>

View File

@@ -1,9 +1,9 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { useRequestResetPassword } from '@kit/supabase/hooks/use-request-reset-password';
import { Alert, AlertDescription } from '@kit/ui/alert';
@@ -31,7 +31,7 @@ export function PasswordResetRequestContainer(params: {
redirectPath: string;
captchaSiteKey?: string;
}) {
const t = useTranslations('auth');
const { t } = useTranslation('auth');
const resetPasswordMutation = useRequestResetPassword();
const captcha = useCaptcha({ siteKey: params.captchaSiteKey });
const captchaLoading = !captcha.isReady;
@@ -51,7 +51,7 @@ export function PasswordResetRequestContainer(params: {
<If condition={success}>
<Alert variant={'success'}>
<AlertDescription>
<Trans i18nKey={'auth.passwordResetSuccessMessage'} />
<Trans i18nKey={'auth:passwordResetSuccessMessage'} />
</AlertDescription>
</Alert>
</If>
@@ -85,7 +85,7 @@ export function PasswordResetRequestContainer(params: {
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common.emailAddress'} />
<Trans i18nKey={'common:emailAddress'} />
</FormLabel>
<FormControl>
@@ -111,15 +111,15 @@ export function PasswordResetRequestContainer(params: {
!resetPasswordMutation.isPending && !captchaLoading
}
>
<Trans i18nKey={'auth.passwordResetLabel'} />
<Trans i18nKey={'auth:passwordResetLabel'} />
</If>
<If condition={resetPasswordMutation.isPending}>
<Trans i18nKey={'auth.passwordResetLabel'} />
<Trans i18nKey={'auth:passwordResetLabel'} />
</If>
<If condition={captchaLoading}>
<Trans i18nKey={'auth.verifyingCaptcha'} />
<Trans i18nKey={'auth:verifyingCaptcha'} />
</If>
</Button>
</div>

View File

@@ -27,7 +27,7 @@ export function PasswordSignInContainer({
const captchaLoading = !captcha.isReady;
const onSubmit = useCallback(
async (credentials: z.output<typeof PasswordSignInSchema>) => {
async (credentials: z.infer<typeof PasswordSignInSchema>) => {
try {
const data = await signInMutation.mutateAsync({
...credentials,

View File

@@ -4,8 +4,8 @@ import Link from 'next/link';
import { zodResolver } from '@hookform/resolvers/zod';
import { ArrowRight, Mail } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import type { z } from 'zod';
import { Button } from '@kit/ui/button';
@@ -33,12 +33,12 @@ export function PasswordSignInForm({
loading = false,
redirecting = false,
}: {
onSubmit: (params: z.output<typeof PasswordSignInSchema>) => unknown;
onSubmit: (params: z.infer<typeof PasswordSignInSchema>) => unknown;
captchaLoading: boolean;
loading: boolean;
redirecting: boolean;
}) {
const t = useTranslations('auth');
const { t } = useTranslation('auth');
const form = useForm({
resolver: zodResolver(PasswordSignInSchema),
@@ -94,14 +94,15 @@ export function PasswordSignInForm({
<div>
<Button
nativeButton={false}
render={<Link href={'/auth/password-reset'} />}
asChild
type={'button'}
size={'sm'}
variant={'link'}
className={'text-xs'}
>
<Trans i18nKey={'auth.passwordForgottenQuestion'} />
<Link href={'/auth/password-reset'}>
<Trans i18nKey={'auth:passwordForgottenQuestion'} />
</Link>
</Button>
</div>
</FormItem>
@@ -117,19 +118,19 @@ export function PasswordSignInForm({
>
<If condition={redirecting}>
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
<Trans i18nKey={'auth.redirecting'} />
<Trans i18nKey={'auth:redirecting'} />
</span>
</If>
<If condition={loading}>
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
<Trans i18nKey={'auth.signingIn'} />
<Trans i18nKey={'auth:signingIn'} />
</span>
</If>
<If condition={captchaLoading}>
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
<Trans i18nKey={'auth.verifyingCaptcha'} />
<Trans i18nKey={'auth:verifyingCaptcha'} />
</span>
</If>
@@ -139,7 +140,7 @@ export function PasswordSignInForm({
'animate-in fade-in slide-in-from-bottom-24 flex items-center'
}
>
<Trans i18nKey={'auth.signInWithEmail'} />
<Trans i18nKey={'auth:signInWithEmail'} />
<ArrowRight
className={

View File

@@ -1,6 +1,6 @@
'use client';
import { CheckCircle } from 'lucide-react';
import { CheckCircledIcon } from '@radix-ui/react-icons';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { If } from '@kit/ui/if';
@@ -71,14 +71,14 @@ export function EmailPasswordSignUpContainer({
function SuccessAlert() {
return (
<Alert variant={'success'}>
<CheckCircle className={'w-4'} />
<CheckCircledIcon className={'w-4'} />
<AlertTitle>
<Trans i18nKey={'auth.emailConfirmationAlertHeading'} />
<Trans i18nKey={'auth:emailConfirmationAlertHeading'} />
</AlertTitle>
<AlertDescription data-test={'email-confirmation-alert'}>
<Trans i18nKey={'auth.emailConfirmationAlertBody'} />
<Trans i18nKey={'auth:emailConfirmationAlertBody'} />
</AlertDescription>
</Alert>
);

View File

@@ -102,7 +102,7 @@ export function PasswordSignUpForm({
</FormControl>
<FormDescription>
<Trans i18nKey={'auth.repeatPasswordDescription'} />
<Trans i18nKey={'auth:repeatPasswordDescription'} />
</FormDescription>
<FormMessage />
@@ -123,13 +123,13 @@ export function PasswordSignUpForm({
>
<If condition={captchaLoading}>
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
<Trans i18nKey={'auth.verifyingCaptcha'} />
<Trans i18nKey={'auth:verifyingCaptcha'} />
</span>
</If>
<If condition={loading && !captchaLoading}>
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
<Trans i18nKey={'auth.signingUp'} />
<Trans i18nKey={'auth:signingUp'} />
</span>
</If>
@@ -139,7 +139,7 @@ export function PasswordSignUpForm({
'animate-in fade-in slide-in-from-bottom-24 flex items-center'
}
>
<Trans i18nKey={'auth.signUpWithEmail'} />
<Trans i18nKey={'auth:signUpWithEmail'} />
<ArrowRight
className={

View File

@@ -3,7 +3,7 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { z } from 'zod';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -40,12 +40,12 @@ export function ResendAuthLinkForm(props: {
return (
<Alert variant={'success'}>
<AlertTitle>
<Trans i18nKey={'auth.resendLinkSuccess'} />
<Trans i18nKey={'auth:resendLinkSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans
i18nKey={'auth.resendLinkSuccessDescription'}
i18nKey={'auth:resendLinkSuccessDescription'}
defaults={'Success!'}
/>
</AlertDescription>
@@ -85,17 +85,17 @@ export function ResendAuthLinkForm(props: {
}}
/>
<Button type="submit" disabled={resendLink.isPending || captchaLoading}>
<Button disabled={resendLink.isPending || captchaLoading}>
<If condition={captchaLoading}>
<Trans i18nKey={'auth.verifyingCaptcha'} />
<Trans i18nKey={'auth:verifyingCaptcha'} />
</If>
<If condition={resendLink.isPending && !captchaLoading}>
<Trans i18nKey={'auth.resendingLink'} />
<Trans i18nKey={'auth:resendingLink'} />
</If>
<If condition={!resendLink.isPending && !captchaLoading}>
<Trans i18nKey={'auth.resendLink'} defaults={'Resend Link'} />
<Trans i18nKey={'auth:resendLink'} defaults={'Resend Link'} />
</If>
</Button>
</form>

View File

@@ -86,7 +86,7 @@ export function SignInMethodsContainer(props: {
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2">
<Trans i18nKey="auth.orContinueWith" />
<Trans i18nKey="auth:orContinueWith" />
</span>
</div>
</div>

View File

@@ -78,7 +78,7 @@ export function SignUpMethodsContainer(props: {
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2">
<Trans i18nKey="auth.orContinueWith" />
<Trans i18nKey="auth:orContinueWith" />
</span>
</div>
</div>

View File

@@ -21,7 +21,7 @@ export function TermsAndConditionsFormField(
<div className={'text-xs'}>
<Trans
i18nKey={'auth.acceptTermsAndConditions'}
i18nKey={'auth:acceptTermsAndConditions'}
components={{
TermsOfServiceLink: (
<Link
@@ -29,7 +29,7 @@ export function TermsAndConditionsFormField(
className={'underline'}
href={'/terms-of-service'}
>
<Trans i18nKey={'auth.termsOfService'} />
<Trans i18nKey={'auth:termsOfService'} />
</Link>
),
PrivacyPolicyLink: (
@@ -38,7 +38,7 @@ export function TermsAndConditionsFormField(
className={'underline'}
href={'/privacy-policy'}
>
<Trans i18nKey={'auth.privacyPolicy'} />
<Trans i18nKey={'auth:privacyPolicy'} />
</Link>
),
}}

View File

@@ -3,9 +3,9 @@
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { TriangleAlert } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
@@ -31,7 +31,7 @@ export function UpdatePasswordForm(params: {
}) {
const updateUser = useUpdateUser();
const router = useRouter();
const t = useTranslations();
const { t } = useTranslation();
const form = useForm({
resolver: zodResolver(PasswordResetSchema),
@@ -68,7 +68,7 @@ export function UpdatePasswordForm(params: {
router.replace(params.redirectTo);
toast.success(t('account.updatePasswordSuccessMessage'));
toast.success(t('account:updatePasswordSuccessMessage'));
})}
>
<div className={'flex-col space-y-2.5'}>
@@ -94,7 +94,7 @@ export function UpdatePasswordForm(params: {
</FormControl>
<FormDescription>
<Trans i18nKey={'common.repeatPassword'} />
<Trans i18nKey={'common:repeatPassword'} />
</FormDescription>
<FormMessage />
@@ -107,7 +107,7 @@ export function UpdatePasswordForm(params: {
type="submit"
className={'w-full'}
>
<Trans i18nKey={'auth.passwordResetLabel'} />
<Trans i18nKey={'auth:passwordResetLabel'} />
</Button>
</div>
</form>
@@ -122,7 +122,7 @@ function ErrorState(props: {
code: string;
};
}) {
const t = useTranslations('auth');
const { t } = useTranslation('auth');
const errorMessage = t(`errors.${props.error.code}`, {
defaultValue: t('errors.resetPasswordError'),
@@ -131,17 +131,17 @@ function ErrorState(props: {
return (
<div className={'flex flex-col space-y-4'}>
<Alert variant={'destructive'}>
<TriangleAlert className={'s-6'} />
<ExclamationTriangleIcon className={'s-6'} />
<AlertTitle>
<Trans i18nKey={'common.genericError'} />
<Trans i18nKey={'common:genericError'} />
</AlertTitle>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
<Button onClick={props.onRetry} variant={'outline'}>
<Trans i18nKey={'common.retry'} />
<Trans i18nKey={'common:retry'} />
</Button>
</div>
);

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema';

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
import { PasswordSchema } from './password.schema';

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema';

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
/**
* Password requirements
@@ -36,11 +36,13 @@ export function refineRepeatPassword(
) {
if (data.password !== data.repeatPassword) {
ctx.addIssue({
message: 'auth.errors.passwordsDoNotMatch',
message: 'auth:errors.passwordsDoNotMatch',
path: ['repeatPassword'],
code: 'custom',
});
}
return true;
}
function validatePassword(password: string, ctx: z.RefinementCtx) {
@@ -50,7 +52,7 @@ function validatePassword(password: string, ctx: z.RefinementCtx) {
if (specialCharsCount < 1) {
ctx.addIssue({
message: 'auth.errors.minPasswordSpecialChars',
message: 'auth:errors.minPasswordSpecialChars',
code: 'custom',
});
}
@@ -61,7 +63,7 @@ function validatePassword(password: string, ctx: z.RefinementCtx) {
if (numbersCount < 1) {
ctx.addIssue({
message: 'auth.errors.minPasswordNumbers',
message: 'auth:errors.minPasswordNumbers',
code: 'custom',
});
}
@@ -70,9 +72,11 @@ function validatePassword(password: string, ctx: z.RefinementCtx) {
if (requirements.uppercase) {
if (!/[A-Z]/.test(password)) {
ctx.addIssue({
message: 'auth.errors.uppercasePassword',
message: 'auth:errors.uppercasePassword',
code: 'custom',
});
}
}
return true;
}

View File

@@ -23,9 +23,9 @@
"@tanstack/react-query": "catalog:",
"@types/react": "catalog:",
"lucide-react": "catalog:",
"next-intl": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
"react-dom": "catalog:",
"react-i18next": "catalog:"
},
"prettier": "@kit/prettier-config",
"typesVersions": {

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from 'react';
import { Bell, CircleAlert, Info, TriangleAlert, XIcon } from 'lucide-react';
import { useLocale, useTranslations } from 'next-intl';
import { useTranslation } from 'react-i18next';
import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
@@ -19,8 +19,7 @@ export function NotificationsPopover(params: {
accountIds: string[];
onClick?: (notification: Notification) => void;
}) {
const t = useTranslations();
const locale = useLocale();
const { i18n, t } = useTranslation();
const [open, setOpen] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
@@ -54,7 +53,7 @@ export function NotificationsPopover(params: {
(new Date().getTime() - date.getTime()) / (1000 * 60 * 60 * 24),
);
const formatter = new Intl.RelativeTimeFormat(locale, {
const formatter = new Intl.RelativeTimeFormat(i18n.language, {
numeric: 'auto',
});
@@ -62,7 +61,7 @@ export function NotificationsPopover(params: {
time = Math.floor((new Date().getTime() - date.getTime()) / (1000 * 60));
if (time < 5) {
return t('common.justNow');
return t('common:justNow');
}
if (time < 60) {
@@ -111,42 +110,39 @@ export function NotificationsPopover(params: {
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
size="sm"
className="text-secondary-foreground text-muted-foreground relative hover:bg-transparent"
variant="ghost"
/>
}
>
<Bell className={'size-4 min-h-3 min-w-3'} />
<PopoverTrigger asChild>
<Button className={'relative h-9 w-9'} variant={'ghost'}>
<Bell className={'min-h-4 min-w-4'} />
<span
className={cn(
`fade-in animate-in zoom-in absolute top-1 right-1 mt-0 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-red-500 text-[0.65rem] text-white`,
{
hidden: !notifications.length,
},
)}
>
{notifications.length}
</span>
<span
className={cn(
`fade-in animate-in zoom-in absolute top-1 right-1 mt-0 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-red-500 text-[0.65rem] text-white`,
{
hidden: !notifications.length,
},
)}
>
{notifications.length}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
className={'flex w-full max-w-96 flex-col lg:min-w-64'}
className={'flex w-full max-w-96 flex-col p-0 lg:min-w-64'}
align={'start'}
collisionPadding={20}
sideOffset={10}
>
<div className={'flex items-center text-sm font-semibold'}>
{t('common.notifications')}
<div className={'flex items-center px-3 py-2 text-sm font-semibold'}>
{t('common:notifications')}
</div>
<Separator />
<If condition={!notifications.length}>
<div className={'text-sm'}>{t('common.noNotifications')}</div>
<div className={'px-3 py-2 text-sm'}>
{t('common:noNotifications')}
</div>
</If>
<div

View File

@@ -44,11 +44,10 @@
"date-fns": "^4.1.0",
"lucide-react": "catalog:",
"next": "catalog:",
"next-intl": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "catalog:",
"react-i18next": "catalog:",
"zod": "catalog:"
},
"prettier": "@kit/prettier-config",

View File

@@ -1,5 +1,14 @@
'use client';
import { useMemo, useState, useTransition } from 'react';
import { isRedirectError } from 'next/dist/client/components/redirect-error';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, useWatch } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
@@ -7,9 +16,24 @@ import {
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import { CreateTeamAccountForm } from './create-team-account-form';
import {
CreateTeamSchema,
NON_LATIN_REGEX,
} from '../schema/create-team.schema';
import { createTeamAccountAction } from '../server/actions/create-team-account-server-actions';
export function CreateTeamAccountDialog(
props: React.PropsWithChildren<{
@@ -18,24 +42,171 @@ export function CreateTeamAccountDialog(
}>,
) {
return (
<Dialog
open={props.isOpen}
onOpenChange={props.setIsOpen}
disablePointerDismissal
>
<DialogContent>
<Dialog open={props.isOpen} onOpenChange={props.setIsOpen}>
<DialogContent
onEscapeKeyDown={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams.createTeamModalHeading'} />
<Trans i18nKey={'teams:createTeamModalHeading'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'teams.createTeamModalDescription'} />
<Trans i18nKey={'teams:createTeamModalDescription'} />
</DialogDescription>
</DialogHeader>
<CreateTeamAccountForm onCancel={() => props.setIsOpen(false)} />
<CreateOrganizationAccountForm onClose={() => props.setIsOpen(false)} />
</DialogContent>
</Dialog>
);
}
function CreateOrganizationAccountForm(props: { onClose: () => void }) {
const [error, setError] = useState<{ message?: string } | undefined>();
const [pending, startTransition] = useTransition();
const form = useForm({
defaultValues: {
name: '',
slug: '',
},
resolver: zodResolver(CreateTeamSchema),
});
const nameValue = useWatch({ control: form.control, name: 'name' });
const showSlugField = useMemo(
() => NON_LATIN_REGEX.test(nameValue ?? ''),
[nameValue],
);
return (
<Form {...form}>
<form
data-test={'create-team-form'}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
const result = await createTeamAccountAction(data);
if (result.error) {
setError({ message: result.message });
}
} catch (e) {
if (!isRedirectError(e)) {
setError({});
}
}
});
})}
>
<div className={'flex flex-col space-y-4'}>
<If condition={error}>
<CreateOrganizationErrorAlert message={error?.message} />
</If>
<FormField
name={'name'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:teamNameLabel'} />
</FormLabel>
<FormControl>
<Input
data-test={'team-name-input'}
required
minLength={2}
maxLength={50}
placeholder={''}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:teamNameDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<If condition={showSlugField}>
<FormField
name={'slug'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:teamSlugLabel'} />
</FormLabel>
<FormControl>
<Input
data-test={'team-slug-input'}
required
minLength={2}
maxLength={50}
placeholder={'my-team'}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:teamSlugDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
</If>
<div className={'flex justify-end space-x-2'}>
<Button
variant={'outline'}
type={'button'}
disabled={pending}
onClick={props.onClose}
>
<Trans i18nKey={'common:cancel'} />
</Button>
<Button data-test={'confirm-create-team-button'} disabled={pending}>
{pending ? (
<Trans i18nKey={'teams:creatingTeam'} />
) : (
<Trans i18nKey={'teams:createTeamSubmitLabel'} />
)}
</Button>
</div>
</div>
</form>
</Form>
);
}
function CreateOrganizationErrorAlert(props: { message?: string }) {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:createTeamErrorHeading'} />
</AlertTitle>
<AlertDescription>
{props.message ? (
<Trans i18nKey={props.message} defaults={props.message} />
) : (
<Trans i18nKey={'teams:createTeamErrorMessage'} />
)}
</AlertDescription>
</Alert>
);
}

View File

@@ -1,183 +0,0 @@
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm, useWatch } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import {
CreateTeamSchema,
NON_LATIN_REGEX,
} from '../schema/create-team.schema';
import { createTeamAccountAction } from '../server/actions/create-team-account-server-actions';
export function CreateTeamAccountForm(props: {
onCancel?: () => void;
submitLabel?: string;
}) {
const [error, setError] = useState<{ message?: string } | undefined>();
const { execute, isPending } = useAction(createTeamAccountAction, {
onExecute: () => {
setError(undefined);
},
onSuccess: ({ data }) => {
if (data?.error) {
setError({ message: data.message });
}
},
onError: () => {
setError({});
},
});
const form = useForm({
defaultValues: {
name: '',
slug: '',
},
resolver: zodResolver(CreateTeamSchema),
});
const nameValue = useWatch({ control: form.control, name: 'name' });
const showSlugField = NON_LATIN_REGEX.test(nameValue ?? '');
return (
<Form {...form}>
<form
data-test={'create-team-form'}
onSubmit={form.handleSubmit((data) => execute(data))}
>
<div className={'flex flex-col space-y-4'}>
<If condition={error}>
<CreateTeamAccountErrorAlert message={error?.message} />
</If>
<FormField
name={'name'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams.teamNameLabel'} />
</FormLabel>
<FormControl>
<Input
data-test={'team-name-input'}
required
minLength={2}
maxLength={50}
placeholder={''}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams.teamNameDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<If condition={showSlugField}>
<FormField
name={'slug'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams.teamSlugLabel'} />
</FormLabel>
<FormControl>
<Input
data-test={'team-slug-input'}
required
minLength={2}
maxLength={50}
placeholder={'my-team'}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams.teamSlugDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
</If>
<div className={'flex justify-end space-x-2'}>
<If condition={!!props.onCancel}>
<Button
variant={'outline'}
type={'button'}
disabled={isPending}
onClick={props.onCancel}
>
<Trans i18nKey={'common.cancel'} />
</Button>
</If>
<Button
type="submit"
data-test={'confirm-create-team-button'}
disabled={isPending}
>
{isPending ? (
<Trans i18nKey={'teams.creatingTeam'} />
) : (
<Trans
i18nKey={props.submitLabel ?? 'teams.createTeamSubmitLabel'}
/>
)}
</Button>
</div>
</div>
</form>
</Form>
);
}
function CreateTeamAccountErrorAlert(props: { message?: string }) {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams.createTeamErrorHeading'} />
</AlertTitle>
<AlertDescription>
{props.message ? (
<Trans i18nKey={props.message} defaults={props.message} />
) : (
<Trans i18nKey={'teams.createTeamErrorMessage'} />
)}
</AlertDescription>
</Alert>
);
}

View File

@@ -5,5 +5,4 @@ export * from './invitations/account-invitations-table';
export * from './settings/team-account-settings-container';
export * from './invitations/accept-invitation-container';
export * from './create-team-account-dialog';
export * from './create-team-account-form';
export * from './team-account-workspace-context';

View File

@@ -1,16 +1,12 @@
'use client';
import Image from 'next/image';
import { useAction } from 'next-safe-action/hooks';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator';
import { Trans } from '@kit/ui/trans';
import { acceptInvitationAction } from '../../server/actions/team-invitations-server-actions';
import { InvitationSubmitButton } from './invitation-submit-button';
import { SignOutInvitationButton } from './sign-out-invitation-button';
export function AcceptInvitationContainer(props: {
@@ -32,13 +28,11 @@ export function AcceptInvitationContainer(props: {
nextPath: string;
};
}) {
const { execute, isPending } = useAction(acceptInvitationAction);
return (
<div className={'flex flex-col items-center space-y-4'}>
<Heading className={'text-center'} level={4}>
<Trans
i18nKey={'teams.acceptInvitationHeading'}
i18nKey={'teams:acceptInvitationHeading'}
values={{
accountName: props.invitation.account.name,
}}
@@ -59,7 +53,7 @@ export function AcceptInvitationContainer(props: {
<div className={'text-muted-foreground text-center text-sm'}>
<Trans
i18nKey={'teams.acceptInvitationDescription'}
i18nKey={'teams:acceptInvitationDescription'}
values={{
accountName: props.invitation.account.name,
}}
@@ -70,24 +64,20 @@ export function AcceptInvitationContainer(props: {
<form
data-test={'join-team-form'}
className={'w-full'}
onSubmit={(e) => {
e.preventDefault();
execute({
inviteToken: props.inviteToken,
nextPath: props.paths.nextPath,
});
}}
action={acceptInvitationAction}
>
<Button type={'submit'} className={'w-full'} disabled={isPending}>
<Trans
i18nKey={isPending ? 'teams.joiningTeam' : 'teams.continueAs'}
values={{
accountName: props.invitation.account.name,
email: props.email,
}}
/>
</Button>
<input type="hidden" name={'inviteToken'} value={props.inviteToken} />
<input
type={'hidden'}
name={'nextPath'}
value={props.paths.nextPath}
/>
<InvitationSubmitButton
email={props.email}
accountName={props.invitation.account.name}
/>
</form>
<Separator />
@@ -95,7 +85,7 @@ export function AcceptInvitationContainer(props: {
<SignOutInvitationButton nextPath={props.paths.signOutNext} />
<span className={'text-muted-foreground text-center text-xs'}>
<Trans i18nKey={'teams.signInWithDifferentAccountDescription'} />
<Trans i18nKey={'teams:signInWithDifferentAccountDescription'} />
</span>
</div>
</div>

View File

@@ -4,7 +4,7 @@ import { useMemo, useState } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import { Ellipsis } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useTranslation } from 'react-i18next';
import { Database } from '@kit/supabase/database';
import { Badge } from '@kit/ui/badge';
@@ -43,7 +43,7 @@ export function AccountInvitationsTable({
invitations,
permissions,
}: AccountInvitationsTableProps) {
const t = useTranslations('teams');
const { t } = useTranslation('teams');
const [search, setSearch] = useState('');
const columns = useGetColumns(permissions);
@@ -82,7 +82,7 @@ function useGetColumns(permissions: {
canRemoveInvitation: boolean;
currentUserRoleHierarchy: number;
}): ColumnDef<Invitations[0]>[] {
const t = useTranslations('teams');
const { t } = useTranslation('teams');
return useMemo(
() => [
@@ -96,7 +96,7 @@ function useGetColumns(permissions: {
return (
<span
data-test={'invitation-email'}
className={'flex items-center gap-x-2 text-left'}
className={'flex items-center space-x-4 text-left'}
>
<span>
<ProfileAvatar text={email} />
@@ -172,21 +172,19 @@ function ActionsDropdown({
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant={'ghost'} size={'icon'}>
<Ellipsis className={'h-5 w-5'} />
</Button>
}
/>
<DropdownMenuTrigger asChild>
<Button variant={'ghost'} size={'icon'}>
<Ellipsis className={'h-5 w-5'} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-52">
<DropdownMenuContent>
<If condition={permissions.canUpdateInvitation}>
<DropdownMenuItem
data-test={'update-invitation-trigger'}
onClick={() => setIsUpdatingRole(true)}
>
<Trans i18nKey={'teams.updateInvitation'} />
<Trans i18nKey={'teams:updateInvitation'} />
</DropdownMenuItem>
<If condition={getIsInviteExpired(invitation.expires_at)}>
@@ -194,7 +192,7 @@ function ActionsDropdown({
data-test={'renew-invitation-trigger'}
onClick={() => setIsRenewingInvite(true)}
>
<Trans i18nKey={'teams.renewInvitation'} />
<Trans i18nKey={'teams:renewInvitation'} />
</DropdownMenuItem>
</If>
</If>
@@ -204,7 +202,7 @@ function ActionsDropdown({
data-test={'remove-invitation-trigger'}
onClick={() => setIsDeletingInvite(true)}
>
<Trans i18nKey={'teams.removeInvitation'} />
<Trans i18nKey={'teams:removeInvitation'} />
</DropdownMenuItem>
</If>
</DropdownMenuContent>

View File

@@ -1,6 +1,4 @@
'use client';
import { useAction } from 'next-safe-action/hooks';
import { useState, useTransition } from 'react';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
@@ -32,11 +30,11 @@ export function DeleteInvitationDialog({
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey="teams.deleteInvitation" />
<Trans i18nKey="team:deleteInvitation" />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey="teams.deleteInvitationDialogDescription" />
<Trans i18nKey="team:deleteInvitationDialogDescription" />
</AlertDialogDescription>
</AlertDialogHeader>
@@ -56,34 +54,43 @@ function DeleteInvitationForm({
invitationId: number;
setIsOpen: (isOpen: boolean) => void;
}) {
const { execute, isPending, hasErrored } = useAction(deleteInvitationAction, {
onSuccess: () => setIsOpen(false),
});
const [isSubmitting, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const onInvitationRemoved = () => {
startTransition(async () => {
try {
await deleteInvitationAction({ invitationId });
setIsOpen(false);
} catch {
setError(true);
}
});
};
return (
<form
data-test={'delete-invitation-form'}
onSubmit={(e) => {
e.preventDefault();
execute({ invitationId });
}}
>
<form data-test={'delete-invitation-form'} action={onInvitationRemoved}>
<div className={'flex flex-col space-y-6'}>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'common.modalConfirmationQuestion'} />
<Trans i18nKey={'common:modalConfirmationQuestion'} />
</p>
<If condition={hasErrored}>
<If condition={error}>
<RemoveInvitationErrorAlert />
</If>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common.cancel'} />
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<Button type={'submit'} variant={'destructive'} disabled={isPending}>
<Trans i18nKey={'teams.deleteInvitation'} />
<Button
type={'submit'}
variant={'destructive'}
disabled={isSubmitting}
>
<Trans i18nKey={'teams:deleteInvitation'} />
</Button>
</AlertDialogFooter>
</div>
@@ -95,11 +102,11 @@ function RemoveInvitationErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams.deleteInvitationErrorTitle'} />
<Trans i18nKey={'teams:deleteInvitationErrorTitle'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'teams.deleteInvitationErrorMessage'} />
<Trans i18nKey={'teams:deleteInvitationErrorMessage'} />
</AlertDescription>
</Alert>
);

View File

@@ -14,7 +14,7 @@ export function InvitationSubmitButton(props: {
return (
<Button type={'submit'} className={'w-full'} disabled={pending}>
<Trans
i18nKey={pending ? 'teams.joiningTeam' : 'teams.continueAs'}
i18nKey={pending ? 'teams:joiningTeam' : 'teams:continueAs'}
values={{
accountName: props.accountName,
email: props.email,

View File

@@ -1,6 +1,4 @@
'use client';
import { useAction } from 'next-safe-action/hooks';
import { useState, useTransition } from 'react';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
@@ -34,12 +32,12 @@ export function RenewInvitationDialog({
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey="team.renewInvitation" />
<Trans i18nKey="team:renewInvitation" />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans
i18nKey="team.renewInvitationDialogDescription"
i18nKey="team:renewInvitationDialogDescription"
values={{ email }}
/>
</AlertDialogDescription>
@@ -61,33 +59,42 @@ function RenewInvitationForm({
invitationId: number;
setIsOpen: (isOpen: boolean) => void;
}) {
const { execute, isPending, hasErrored } = useAction(renewInvitationAction, {
onSuccess: () => setIsOpen(false),
});
const [isSubmitting, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const inInvitationRenewed = () => {
startTransition(async () => {
try {
await renewInvitationAction({ invitationId });
setIsOpen(false);
} catch {
setError(true);
}
});
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
execute({ invitationId });
}}
>
<form action={inInvitationRenewed}>
<div className={'flex flex-col space-y-6'}>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'common.modalConfirmationQuestion'} />
<Trans i18nKey={'common:modalConfirmationQuestion'} />
</p>
<If condition={hasErrored}>
<If condition={error}>
<RenewInvitationErrorAlert />
</If>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common.cancel'} />
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<Button data-test={'confirm-renew-invitation'} disabled={isPending}>
<Trans i18nKey={'teams.renewInvitation'} />
<Button
data-test={'confirm-renew-invitation'}
disabled={isSubmitting}
>
<Trans i18nKey={'teams:renewInvitation'} />
</Button>
</AlertDialogFooter>
</div>
@@ -99,11 +106,11 @@ function RenewInvitationErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams.renewInvitationErrorTitle'} />
<Trans i18nKey={'teams:renewInvitationErrorTitle'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'teams.renewInvitationErrorDescription'} />
<Trans i18nKey={'teams:renewInvitationErrorDescription'} />
</AlertDescription>
</Alert>
);

View File

@@ -24,7 +24,7 @@ export function SignOutInvitationButton(
window.location.assign(safePath);
}}
>
<Trans i18nKey={'teams.signInWithDifferentAccount'} />
<Trans i18nKey={'teams:signInWithDifferentAccount'} />
</Button>
);
}

View File

@@ -1,9 +1,8 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
@@ -51,11 +50,11 @@ export function UpdateInvitationDialog({
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams.updateMemberRoleModalHeading'} />
<Trans i18nKey={'teams:updateMemberRoleModalHeading'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'teams.updateMemberRoleModalDescription'} />
<Trans i18nKey={'teams:updateMemberRoleModalDescription'} />
</DialogDescription>
</DialogHeader>
@@ -81,11 +80,24 @@ function UpdateInvitationForm({
userRoleHierarchy: number;
setIsOpen: (isOpen: boolean) => void;
}>) {
const t = useTranslations('teams');
const { t } = useTranslation('teams');
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const { execute, isPending, hasErrored } = useAction(updateInvitationAction, {
onSuccess: () => setIsOpen(false),
});
const onSubmit = ({ role }: { role: Role }) => {
startTransition(async () => {
try {
await updateInvitationAction({
invitationId,
role,
});
setIsOpen(false);
} catch {
setError(true);
}
});
};
const form = useForm({
resolver: zodResolver(
@@ -110,12 +122,10 @@ function UpdateInvitationForm({
<Form {...form}>
<form
data-test={'update-invitation-form'}
onSubmit={form.handleSubmit(({ role }) => {
execute({ invitationId, role });
})}
onSubmit={form.handleSubmit(onSubmit)}
className={'flex flex-col space-y-6'}
>
<If condition={hasErrored}>
<If condition={error}>
<UpdateRoleErrorAlert />
</If>
@@ -125,7 +135,7 @@ function UpdateInvitationForm({
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams.roleLabel'} />
<Trans i18nKey={'teams:roleLabel'} />
</FormLabel>
<FormControl>
@@ -135,18 +145,16 @@ function UpdateInvitationForm({
roles={roles}
currentUserRole={userRole}
value={field.value}
onChange={(newRole) => {
if (newRole) {
form.setValue(field.name, newRole);
}
}}
onChange={(newRole) =>
form.setValue(field.name, newRole)
}
/>
)}
</RolesDataProvider>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams.updateRoleDescription'} />
<Trans i18nKey={'teams:updateRoleDescription'} />
</FormDescription>
<FormMessage />
@@ -155,8 +163,8 @@ function UpdateInvitationForm({
}}
/>
<Button type={'submit'} disabled={isPending}>
<Trans i18nKey={'teams.updateRoleSubmitLabel'} />
<Button type={'submit'} disabled={pending}>
<Trans i18nKey={'teams:updateRoleSubmitLabel'} />
</Button>
</form>
</Form>
@@ -167,11 +175,11 @@ function UpdateRoleErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams.updateRoleErrorHeading'} />
<Trans i18nKey={'teams:updateRoleErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'teams.updateRoleErrorMessage'} />
<Trans i18nKey={'teams:updateRoleErrorMessage'} />
</AlertDescription>
</Alert>
);

View File

@@ -4,7 +4,7 @@ import { useMemo, useState } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import { Ellipsis } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useTranslation } from 'react-i18next';
import { Database } from '@kit/supabase/database';
import { Badge } from '@kit/ui/badge';
@@ -53,7 +53,7 @@ export function AccountMembersTable({
canManageRoles,
}: AccountMembersTableProps) {
const [search, setSearch] = useState('');
const t = useTranslations('teams');
const { t } = useTranslation('teams');
const permissions = {
canUpdateRole: (targetRole: number) => {
@@ -123,7 +123,7 @@ function useGetColumns(
currentRoleHierarchy: number;
},
): ColumnDef<Members[0]>[] {
const t = useTranslations('teams');
const { t } = useTranslation('teams');
return useMemo(
() => [
@@ -136,7 +136,7 @@ function useGetColumns(
const isSelf = member.user_id === params.currentUserId;
return (
<span className={'flex items-center gap-x-2 text-left'}>
<span className={'flex items-center space-x-4 text-left'}>
<span>
<ProfileAvatar
displayName={displayName}
@@ -144,13 +144,11 @@ function useGetColumns(
/>
</span>
<span className={'flex items-center gap-x-2'}>
<span>{displayName}</span>
<span>{displayName}</span>
<If condition={isSelf}>
<Badge variant={'secondary'}>{t('youLabel')}</Badge>
</If>
</span>
<If condition={isSelf}>
<Badge variant={'outline'}>{t('youLabel')}</Badge>
</If>
</span>
);
},
@@ -173,7 +171,13 @@ function useGetColumns(
<RoleBadge role={role} />
<If condition={isPrimaryOwner}>
<Badge variant={'warning'}>{t('primaryOwnerLabel')}</Badge>
<span
className={
'rounded-md bg-yellow-400 px-2.5 py-1 text-xs font-medium dark:text-black'
}
>
{t('primaryOwnerLabel')}
</span>
</If>
</span>
);
@@ -219,10 +223,6 @@ function ActionsDropdown({
const isCurrentUser = member.user_id === currentUserId;
const isPrimaryOwner = member.primary_owner_user_id === member.user_id;
const [activeDialog, setActiveDialog] = useState<
'updateRole' | 'transferOwnership' | 'removeMember' | null
>(null);
if (isCurrentUser || isPrimaryOwner) {
return null;
}
@@ -246,66 +246,50 @@ function ActionsDropdown({
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant={'ghost'} size={'icon'}>
<Ellipsis className={'h-5 w-5'} />
</Button>
}
/>
<DropdownMenuTrigger asChild>
<Button variant={'ghost'} size={'icon'}>
<Ellipsis className={'h-5 w-5'} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className={'min-w-52'}>
<DropdownMenuContent>
<If condition={canUpdateRole}>
<DropdownMenuItem onClick={() => setActiveDialog('updateRole')}>
<Trans i18nKey={'teams.updateRole'} />
</DropdownMenuItem>
<UpdateMemberRoleDialog
userId={member.user_id}
userRole={member.role}
teamAccountId={currentTeamAccountId}
userRoleHierarchy={currentRoleHierarchy}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trans i18nKey={'teams:updateRole'} />
</DropdownMenuItem>
</UpdateMemberRoleDialog>
</If>
<If condition={permissions.canTransferOwnership}>
<DropdownMenuItem
onClick={() => setActiveDialog('transferOwnership')}
<TransferOwnershipDialog
targetDisplayName={member.name ?? member.email}
accountId={member.account_id}
userId={member.user_id}
>
<Trans i18nKey={'teams.transferOwnership'} />
</DropdownMenuItem>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trans i18nKey={'teams:transferOwnership'} />
</DropdownMenuItem>
</TransferOwnershipDialog>
</If>
<If condition={canRemoveFromAccount}>
<DropdownMenuItem onClick={() => setActiveDialog('removeMember')}>
<Trans i18nKey={'teams.removeMember'} />
</DropdownMenuItem>
<RemoveMemberDialog
teamAccountId={currentTeamAccountId}
userId={member.user_id}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trans i18nKey={'teams:removeMember'} />
</DropdownMenuItem>
</RemoveMemberDialog>
</If>
</DropdownMenuContent>
</DropdownMenu>
{activeDialog === 'updateRole' && (
<UpdateMemberRoleDialog
open
onOpenChange={(open) => !open && setActiveDialog(null)}
userId={member.user_id}
userRole={member.role}
teamAccountId={currentTeamAccountId}
userRoleHierarchy={currentRoleHierarchy}
/>
)}
{activeDialog === 'transferOwnership' && (
<TransferOwnershipDialog
open
onOpenChange={(open) => !open && setActiveDialog(null)}
targetDisplayName={member.name ?? member.email}
accountId={member.account_id}
userId={member.user_id}
/>
)}
{activeDialog === 'removeMember' && (
<RemoveMemberDialog
open
onOpenChange={(open) => !open && setActiveDialog(null)}
teamAccountId={currentTeamAccountId}
userId={member.user_id}
/>
)}
</>
);
}

View File

@@ -1,13 +1,12 @@
'use client';
import { useState } from 'react';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Mail, Plus, X } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useAction } from 'next-safe-action/hooks';
import { useFieldArray, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
@@ -65,24 +64,9 @@ export function InviteMembersDialogContainer({
accountSlug: string;
userRoleHierarchy: number;
}>) {
const [pending, startTransition] = useTransition();
const [isOpen, setIsOpen] = useState(false);
const t = useTranslations('teams');
const { execute, isPending } = useAction(createInvitationsAction, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success(t('inviteMembersSuccessMessage'));
} else {
toast.error(t('inviteMembersErrorMessage'));
}
setIsOpen(false);
},
onError: () => {
toast.error(t('inviteMembersErrorMessage'));
setIsOpen(false);
},
});
const { t } = useTranslation('teams');
// Evaluate policies when dialog is open
const {
@@ -92,17 +76,17 @@ export function InviteMembersDialogContainer({
} = useFetchInvitationsPolicies({ accountSlug, isOpen });
return (
<Dialog open={isOpen} onOpenChange={setIsOpen} disablePointerDismissal>
<DialogTrigger render={children as React.ReactElement} />
<Dialog open={isOpen} onOpenChange={setIsOpen} modal>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams.inviteMembersHeading'} />
<Trans i18nKey={'teams:inviteMembersHeading'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'teams.inviteMembersDescription'} />
<Trans i18nKey={'teams:inviteMembersDescription'} />
</DialogDescription>
</DialogHeader>
@@ -111,7 +95,7 @@ export function InviteMembersDialogContainer({
<Spinner className="h-6 w-6" />
<span className="text-muted-foreground text-sm">
<Trans i18nKey="teams.checkingPolicies" />
<Trans i18nKey="teams:checkingPolicies" />
</span>
</div>
</If>
@@ -120,7 +104,7 @@ export function InviteMembersDialogContainer({
<Alert variant="destructive">
<AlertDescription>
<Trans
i18nKey="teams.policyCheckError"
i18nKey="teams:policyCheckError"
values={{ error: policiesError?.message }}
/>
</AlertDescription>
@@ -142,12 +126,28 @@ export function InviteMembersDialogContainer({
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
{(roles) => (
<InviteMembersForm
pending={isPending}
pending={pending}
roles={roles}
onSubmit={(data) => {
execute({
accountSlug,
invitations: data.invitations,
startTransition(async () => {
const toastId = toast.loading(t('invitingMembers'));
const result = await createInvitationsAction({
accountSlug,
invitations: data.invitations,
});
if (result.success) {
toast.success(t('inviteMembersSuccessMessage'), {
id: toastId,
});
} else {
toast.error(t('inviteMembersErrorMessage'), {
id: toastId,
});
}
setIsOpen(false);
});
}}
/>
@@ -168,7 +168,7 @@ function InviteMembersForm({
pending: boolean;
roles: string[];
}) {
const t = useTranslations('teams');
const { t } = useTranslation('teams');
const form = useForm({
resolver: zodResolver(InviteMembersSchema),
@@ -237,9 +237,7 @@ function InviteMembersForm({
roles={roles}
value={field.value}
onChange={(role) => {
if (role) {
form.setValue(field.name, role);
}
form.setValue(field.name, role);
}}
/>
</FormControl>
@@ -253,24 +251,22 @@ function InviteMembersForm({
<div className={'flex items-end justify-end'}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
variant={'ghost'}
size={'icon'}
type={'button'}
disabled={fieldArray.fields.length <= 1}
data-test={'remove-invite-button'}
aria-label={t('removeInviteButtonLabel')}
onClick={() => {
fieldArray.remove(index);
form.clearErrors(emailInputName);
}}
>
<X className={'h-4'} />
</Button>
}
/>
<TooltipTrigger asChild>
<Button
variant={'ghost'}
size={'icon'}
type={'button'}
disabled={fieldArray.fields.length <= 1}
data-test={'remove-invite-button'}
aria-label={t('removeInviteButtonLabel')}
onClick={() => {
fieldArray.remove(index);
form.clearErrors(emailInputName);
}}
>
<X className={'h-4'} />
</Button>
</TooltipTrigger>
<TooltipContent>
{t('removeInviteButtonLabel')}
@@ -298,7 +294,7 @@ function InviteMembersForm({
<Plus className={'mr-1 h-3'} />
<span>
<Trans i18nKey={'teams.addAnotherMemberButtonLabel'} />
<Trans i18nKey={'teams:addAnotherMemberButtonLabel'} />
</span>
</Button>
</div>
@@ -309,8 +305,8 @@ function InviteMembersForm({
<Trans
i18nKey={
pending
? 'teams.invitingMembers'
: 'teams.inviteMembersButtonLabel'
? 'teams:invitingMembers'
: 'teams:inviteMembersButtonLabel'
}
/>
</Button>

View File

@@ -19,7 +19,7 @@ export function MembershipRoleSelector({
roles: Role[];
value: Role;
currentUserRole?: Role;
onChange: (role: Role | null) => unknown;
onChange: (role: Role) => unknown;
triggerClassName?: string;
}) {
return (
@@ -28,15 +28,7 @@ export function MembershipRoleSelector({
className={triggerClassName}
data-test={'role-selector-trigger'}
>
<SelectValue>
{(value) =>
value ? (
<Trans i18nKey={`common.roles.${value}.label`} defaults={value} />
) : (
''
)
}
</SelectValue>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -49,7 +41,7 @@ export function MembershipRoleSelector({
value={role}
>
<span className={'text-sm capitalize'}>
<Trans i18nKey={`common.roles.${role}.label`} defaults={role} />
<Trans i18nKey={`common:roles.${role}.label`} defaults={role} />
</span>
</SelectItem>
);

View File

@@ -1,6 +1,4 @@
'use client';
import { useAction } from 'next-safe-action/hooks';
import { useState, useTransition } from 'react';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
@@ -11,6 +9,7 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
@@ -19,34 +18,29 @@ import { Trans } from '@kit/ui/trans';
import { removeMemberFromAccountAction } from '../../server/actions/team-members-server-actions';
export function RemoveMemberDialog({
open,
onOpenChange,
teamAccountId,
userId,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children,
}: React.PropsWithChildren<{
teamAccountId: string;
userId: string;
}) {
}>) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialog>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey="teams.removeMemberModalHeading" />
<Trans i18nKey="teamS:removeMemberModalHeading" />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey={'teams.removeMemberModalDescription'} />
<Trans i18nKey={'teams:removeMemberModalDescription'} />
</AlertDialogDescription>
</AlertDialogHeader>
<RemoveMemberForm
accountId={teamAccountId}
userId={userId}
onSuccess={() => onOpenChange(false)}
/>
<RemoveMemberForm accountId={teamAccountId} userId={userId} />
</AlertDialogContent>
</AlertDialog>
);
@@ -55,46 +49,45 @@ export function RemoveMemberDialog({
function RemoveMemberForm({
accountId,
userId,
onSuccess,
}: {
accountId: string;
userId: string;
onSuccess: () => void;
}) {
const { execute, isPending, hasErrored } = useAction(
removeMemberFromAccountAction,
{
onSuccess: () => onSuccess(),
},
);
const [isSubmitting, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const onMemberRemoved = () => {
startTransition(async () => {
try {
await removeMemberFromAccountAction({ accountId, userId });
} catch {
setError(true);
}
});
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
execute({ accountId, userId });
}}
>
<form action={onMemberRemoved}>
<div className={'flex flex-col space-y-6'}>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'common.modalConfirmationQuestion'} />
<Trans i18nKey={'common:modalConfirmationQuestion'} />
</p>
<If condition={hasErrored}>
<If condition={error}>
<RemoveMemberErrorAlert />
</If>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common.cancel'} />
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<Button
data-test={'confirm-remove-member'}
variant={'destructive'}
disabled={isPending}
disabled={isSubmitting}
>
<Trans i18nKey={'teams.removeMemberSubmitLabel'} />
<Trans i18nKey={'teams:removeMemberSubmitLabel'} />
</Button>
</AlertDialogFooter>
</div>
@@ -106,11 +99,11 @@ function RemoveMemberErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams.removeMemberErrorHeading'} />
<Trans i18nKey={'teams:removeMemberErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'teams.removeMemberErrorMessage'} />
<Trans i18nKey={'teams:removeMemberErrorMessage'} />
</AlertDescription>
</Alert>
);

View File

@@ -25,7 +25,7 @@ export function RoleBadge({ role }: { role: Role }) {
return (
<Badge className={className} variant={isCustom ? 'outline' : 'default'}>
<span data-test={'member-role-badge'}>
<Trans i18nKey={`common.roles.${role}.label`} defaults={role} />
<Trans i18nKey={`common:roles.${role}.label`} defaults={role} />
</span>
</Badge>
);

View File

@@ -1,7 +1,8 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm, useWatch } from 'react-hook-form';
import { VerifyOtpForm } from '@kit/otp/components';
@@ -15,6 +16,7 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { Form } from '@kit/ui/form';
@@ -25,28 +27,30 @@ import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-owner
import { transferOwnershipAction } from '../../server/actions/team-members-server-actions';
export function TransferOwnershipDialog({
open,
onOpenChange,
children,
targetDisplayName,
accountId,
userId,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
accountId: string;
userId: string;
targetDisplayName: string;
}) {
const [open, setOpen] = useState(false);
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey="teams.transferOwnership" />
<Trans i18nKey="team:transferOwnership" />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey="teams.transferOwnershipDescription" />
<Trans i18nKey="team:transferOwnershipDescription" />
</AlertDialogDescription>
</AlertDialogHeader>
@@ -54,7 +58,7 @@ export function TransferOwnershipDialog({
accountId={accountId}
userId={userId}
targetDisplayName={targetDisplayName}
onSuccess={() => onOpenChange(false)}
onSuccess={() => setOpen(false)}
/>
</AlertDialogContent>
</AlertDialog>
@@ -72,15 +76,10 @@ function TransferOrganizationOwnershipForm({
targetDisplayName: string;
onSuccess: () => unknown;
}) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const { data: user } = useUser();
const { execute, isPending, hasErrored } = useAction(
transferOwnershipAction,
{
onSuccess: () => onSuccess(),
},
);
const form = useForm({
resolver: zodResolver(TransferOwnershipConfirmationSchema),
defaultValues: {
@@ -104,7 +103,7 @@ function TransferOrganizationOwnershipForm({
}}
CancelButton={
<AlertDialogCancel>
<Trans i18nKey={'common.cancel'} />
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
}
data-test="verify-otp-form"
@@ -118,17 +117,25 @@ function TransferOrganizationOwnershipForm({
<form
className={'flex flex-col space-y-4 text-sm'}
onSubmit={form.handleSubmit((data) => {
execute(data);
startTransition(async () => {
try {
await transferOwnershipAction(data);
onSuccess();
} catch {
setError(true);
}
});
})}
>
<If condition={hasErrored}>
<If condition={error}>
<TransferOwnershipErrorAlert />
</If>
<div className="border-destructive rounded-md border p-4">
<p className="text-destructive text-sm">
<Trans
i18nKey={'teams.transferOwnershipDisclaimer'}
i18nKey={'teams:transferOwnershipDisclaimer'}
values={{
member: targetDisplayName,
}}
@@ -141,26 +148,26 @@ function TransferOrganizationOwnershipForm({
<div>
<p className={'text-muted-foreground'}>
<Trans i18nKey={'common.modalConfirmationQuestion'} />
<Trans i18nKey={'common:modalConfirmationQuestion'} />
</p>
</div>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common.cancel'} />
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<Button
type={'submit'}
data-test={'confirm-transfer-ownership-button'}
variant={'destructive'}
disabled={isPending}
disabled={pending}
>
<If
condition={isPending}
fallback={<Trans i18nKey={'teams.transferOwnership'} />}
condition={pending}
fallback={<Trans i18nKey={'teams:transferOwnership'} />}
>
<Trans i18nKey={'teams.transferringOwnership'} />
<Trans i18nKey={'teams:transferringOwnership'} />
</If>
</Button>
</AlertDialogFooter>
@@ -173,11 +180,11 @@ function TransferOwnershipErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams.transferTeamErrorHeading'} />
<Trans i18nKey={'teams:transferTeamErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'teams.transferTeamErrorMessage'} />
<Trans i18nKey={'teams:transferTeamErrorMessage'} />
</AlertDescription>
</Alert>
);

View File

@@ -1,12 +1,10 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { AlertDialogCancel } from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import {
Dialog,
@@ -14,6 +12,7 @@ import {
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import {
Form,
@@ -35,30 +34,31 @@ import { RolesDataProvider } from './roles-data-provider';
type Role = string;
export function UpdateMemberRoleDialog({
open,
onOpenChange,
children,
userId,
teamAccountId,
userRole,
userRoleHierarchy,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}: React.PropsWithChildren<{
userId: string;
teamAccountId: string;
userRole: Role;
userRoleHierarchy: number;
}) {
}>) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams.updateMemberRoleModalHeading'} />
<Trans i18nKey={'teams:updateMemberRoleModalHeading'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'teams.updateMemberRoleModalDescription'} />
<Trans i18nKey={'teams:updateMemberRoleModalDescription'} />
</DialogDescription>
</DialogHeader>
@@ -69,7 +69,7 @@ export function UpdateMemberRoleDialog({
teamAccountId={teamAccountId}
userRole={userRole}
roles={data}
onSuccess={() => onOpenChange(false)}
onSuccess={() => setOpen(false)}
/>
)}
</RolesDataProvider>
@@ -91,11 +91,25 @@ function UpdateMemberForm({
roles: Role[];
onSuccess: () => unknown;
}>) {
const t = useTranslations('teams');
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const { t } = useTranslation('teams');
const { execute, isPending, hasErrored } = useAction(updateMemberRoleAction, {
onSuccess: () => onSuccess(),
});
const onSubmit = ({ role }: { role: Role }) => {
startTransition(async () => {
try {
await updateMemberRoleAction({
accountId: teamAccountId,
userId,
role,
});
onSuccess();
} catch {
setError(true);
}
});
};
const form = useForm({
resolver: zodResolver(
@@ -120,16 +134,10 @@ function UpdateMemberForm({
<Form {...form}>
<form
data-test={'update-member-role-form'}
onSubmit={form.handleSubmit(({ role }) => {
execute({
accountId: teamAccountId,
userId,
role,
});
})}
className={'flex w-full flex-col space-y-6'}
onSubmit={form.handleSubmit(onSubmit)}
className={'flex flex-col space-y-6'}
>
<If condition={hasErrored}>
<If condition={error}>
<UpdateRoleErrorAlert />
</If>
@@ -142,15 +150,10 @@ function UpdateMemberForm({
<FormControl>
<MembershipRoleSelector
triggerClassName={'w-full'}
roles={roles}
currentUserRole={userRole}
value={field.value}
onChange={(newRole) => {
if (newRole) {
form.setValue('role', newRole);
}
}}
onChange={(newRole) => form.setValue('role', newRole)}
/>
</FormControl>
@@ -162,19 +165,9 @@ function UpdateMemberForm({
}}
/>
<div className="flex justify-end gap-x-2">
<AlertDialogCancel>
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
<Button
type="submit"
data-test={'confirm-update-member-role'}
disabled={isPending}
>
<Trans i18nKey={'teams.updateRoleSubmitLabel'} />
</Button>
</div>
<Button data-test={'confirm-update-member-role'} disabled={pending}>
<Trans i18nKey={'teams:updateRoleSubmitLabel'} />
</Button>
</form>
</Form>
);
@@ -184,11 +177,11 @@ function UpdateRoleErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams.updateRoleErrorHeading'} />
<Trans i18nKey={'teams:updateRoleErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'teams.updateRoleErrorMessage'} />
<Trans i18nKey={'teams:updateRoleErrorMessage'} />
</AlertDescription>
</Alert>
);

View File

@@ -1,9 +1,10 @@
'use client';
import { useFormStatus } from 'react-dom';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { z } from 'zod';
import { ErrorBoundary } from '@kit/monitoring/components';
import { VerifyOtpForm } from '@kit/otp/components';
@@ -99,12 +100,12 @@ function DeleteTeamContainer(props: {
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-1'}>
<span className={'text-sm font-medium'}>
<Trans i18nKey={'teams.deleteTeam'} />
<Trans i18nKey={'teams:deleteTeam'} />
</span>
<p className={'text-muted-foreground text-sm'}>
<Trans
i18nKey={'teams.deleteTeamDescription'}
i18nKey={'teams:deleteTeamDescription'}
values={{
teamName: props.account.name,
}}
@@ -114,27 +115,25 @@ function DeleteTeamContainer(props: {
<div>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
data-test={'delete-team-trigger'}
type={'button'}
variant={'destructive'}
>
<Trans i18nKey={'teams.deleteTeam'} />
</Button>
}
/>
<AlertDialogTrigger asChild>
<Button
data-test={'delete-team-trigger'}
type={'button'}
variant={'destructive'}
>
<Trans i18nKey={'teams:deleteTeam'} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogContent onEscapeKeyDown={(e) => e.preventDefault()}>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'teams.deletingTeam'} />
<Trans i18nKey={'teams:deletingTeam'} />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans
i18nKey={'teams.deletingTeamDescription'}
i18nKey={'teams:deletingTeamDescription'}
values={{
teamName: props.account.name,
}}
@@ -162,8 +161,6 @@ function DeleteTeamConfirmationForm({
}) {
const { data: user } = useUser();
const { execute, isPending } = useAction(deleteTeamAccountAction);
const form = useForm({
mode: 'onChange',
reValidateMode: 'onChange',
@@ -191,7 +188,7 @@ function DeleteTeamConfirmationForm({
onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })}
CancelButton={
<AlertDialogCancel className={'m-0'}>
<Trans i18nKey={'common.cancel'} />
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
}
/>
@@ -204,10 +201,7 @@ function DeleteTeamConfirmationForm({
<form
data-test={'delete-team-form'}
className={'flex flex-col space-y-4'}
onSubmit={(e) => {
e.preventDefault();
execute({ accountId: id, otp });
}}
action={deleteTeamAccountAction}
>
<div className={'flex flex-col space-y-2'}>
<div
@@ -217,7 +211,7 @@ function DeleteTeamConfirmationForm({
>
<div>
<Trans
i18nKey={'teams.deleteTeamDisclaimer'}
i18nKey={'teams:deleteTeamDisclaimer'}
values={{
teamName: name,
}}
@@ -225,24 +219,20 @@ function DeleteTeamConfirmationForm({
</div>
<div className={'text-sm'}>
<Trans i18nKey={'common.modalConfirmationQuestion'} />
<Trans i18nKey={'common:modalConfirmationQuestion'} />
</div>
</div>
<input type="hidden" value={id} name={'accountId'} />
<input type="hidden" value={otp} name={'otp'} />
</div>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common.cancel'} />
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<Button
type="submit"
data-test={'delete-team-form-confirm-button'}
disabled={isPending}
variant={'destructive'}
>
<Trans i18nKey={'teams.deleteTeam'} />
</Button>
<DeleteTeamSubmitButton />
</AlertDialogFooter>
</form>
</Form>
@@ -250,14 +240,26 @@ function DeleteTeamConfirmationForm({
);
}
function DeleteTeamSubmitButton() {
const { pending } = useFormStatus();
return (
<Button
data-test={'delete-team-form-confirm-button'}
disabled={pending}
variant={'destructive'}
>
<Trans i18nKey={'teams:deleteTeam'} />
</Button>
);
}
function LeaveTeamContainer(props: {
account: {
name: string;
id: string;
};
}) {
const { execute, isPending } = useAction(leaveTeamAccountAction);
const form = useForm({
resolver: zodResolver(
z.object({
@@ -276,7 +278,7 @@ function LeaveTeamContainer(props: {
<div className={'flex flex-col space-y-4'}>
<p className={'text-muted-foreground text-sm'}>
<Trans
i18nKey={'teams.leaveTeamDescription'}
i18nKey={'teams:leaveTeamDescription'}
values={{
teamName: props.account.name,
}}
@@ -284,26 +286,26 @@ function LeaveTeamContainer(props: {
</p>
<AlertDialog>
<AlertDialogTrigger
render={
<AlertDialogTrigger asChild>
<div>
<Button
data-test={'leave-team-button'}
type={'button'}
variant={'destructive'}
>
<Trans i18nKey={'teams.leaveTeam'} />
<Trans i18nKey={'teams:leaveTeam'} />
</Button>
}
/>
</div>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'teams.leavingTeamModalHeading'} />
<Trans i18nKey={'teams:leavingTeamModalHeading'} />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey={'teams.leavingTeamModalDescription'} />
<Trans i18nKey={'teams:leavingTeamModalDescription'} />
</AlertDialogDescription>
</AlertDialogHeader>
@@ -311,20 +313,21 @@ function LeaveTeamContainer(props: {
<Form {...form}>
<form
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit((data) => {
execute({
accountId: props.account.id,
confirmation: data.confirmation,
});
})}
action={leaveTeamAccountAction}
>
<input
type={'hidden'}
value={props.account.id}
name={'accountId'}
/>
<FormField
name={'confirmation'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams.leaveTeamInputLabel'} />
<Trans i18nKey={'teams:leaveTeamInputLabel'} />
</FormLabel>
<FormControl>
@@ -341,7 +344,7 @@ function LeaveTeamContainer(props: {
</FormControl>
<FormDescription>
<Trans i18nKey={'teams.leaveTeamInputDescription'} />
<Trans i18nKey={'teams:leaveTeamInputDescription'} />
</FormDescription>
<FormMessage />
@@ -352,17 +355,10 @@ function LeaveTeamContainer(props: {
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common.cancel'} />
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<Button
type="submit"
data-test={'confirm-leave-organization-button'}
disabled={isPending}
variant={'destructive'}
>
<Trans i18nKey={'teams.leaveTeam'} />
</Button>
<LeaveTeamSubmitButton />
</AlertDialogFooter>
</form>
</Form>
@@ -373,22 +369,36 @@ function LeaveTeamContainer(props: {
);
}
function LeaveTeamSubmitButton() {
const { pending } = useFormStatus();
return (
<Button
data-test={'confirm-leave-organization-button'}
disabled={pending}
variant={'destructive'}
>
<Trans i18nKey={'teams:leaveTeam'} />
</Button>
);
}
function LeaveTeamErrorAlert() {
return (
<div className={'flex flex-col space-y-4'}>
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams.leaveTeamErrorHeading'} />
<Trans i18nKey={'teams:leaveTeamErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common.genericError'} />
<Trans i18nKey={'common:genericError'} />
</AlertDescription>
</Alert>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common.cancel'} />
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
</AlertDialogFooter>
</div>
@@ -400,17 +410,17 @@ function DeleteTeamErrorAlert() {
<div className={'flex flex-col space-y-4'}>
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams.deleteTeamErrorHeading'} />
<Trans i18nKey={'teams:deleteTeamErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common.genericError'} />
<Trans i18nKey={'common:genericError'} />
</AlertDescription>
</Alert>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common.cancel'} />
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
</AlertDialogFooter>
</div>
@@ -422,11 +432,11 @@ function DangerZoneCard({ children }: React.PropsWithChildren) {
<Card className={'border-destructive border'}>
<CardHeader>
<CardTitle>
<Trans i18nKey={'teams.settings.dangerZone'} />
<Trans i18nKey={'teams:settings.dangerZone'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'teams.settings.dangerZoneDescription'} />
<Trans i18nKey={'teams:settings.dangerZoneDescription'} />
</CardDescription>
</CardHeader>

View File

@@ -35,11 +35,11 @@ export function TeamAccountSettingsContainer(props: {
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'teams.settings.teamLogo'} />
<Trans i18nKey={'teams:settings.teamLogo'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'teams.settings.teamLogoDescription'} />
<Trans i18nKey={'teams:settings.teamLogoDescription'} />
</CardDescription>
</CardHeader>
@@ -51,11 +51,11 @@ export function TeamAccountSettingsContainer(props: {
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'teams.settings.teamName'} />
<Trans i18nKey={'teams:settings.teamName'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'teams.settings.teamNameDescription'} />
<Trans i18nKey={'teams:settings.teamNameDescription'} />
</CardDescription>
</CardHeader>

View File

@@ -4,7 +4,7 @@ import { useCallback } from 'react';
import type { SupabaseClient } from '@supabase/supabase-js';
import { useTranslations } from 'next-intl';
import { useTranslation } from 'react-i18next';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { ImageUploader } from '@kit/ui/image-uploader';
@@ -21,7 +21,7 @@ export function UpdateTeamAccountImage(props: {
};
}) {
const client = useSupabase();
const t = useTranslations('teams');
const { t } = useTranslation('teams');
const createToaster = useCallback(
(promise: () => Promise<unknown>) => {
@@ -89,11 +89,11 @@ export function UpdateTeamAccountImage(props: {
>
<div className={'flex flex-col space-y-1'}>
<span className={'text-sm'}>
<Trans i18nKey={'account.profilePictureHeading'} />
<Trans i18nKey={'account:profilePictureHeading'} />
</span>
<span className={'text-xs'}>
<Trans i18nKey={'account.profilePictureSubheading'} />
<Trans i18nKey={'account:profilePictureSubheading'} />
</span>
</div>
</ImageUploader>

View File

@@ -1,10 +1,13 @@
'use client';
import { useTransition } from 'react';
import { isRedirectError } from 'next/dist/client/components/redirect-error';
import { zodResolver } from '@hookform/resolvers/zod';
import { Building, Link } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useAction } from 'next-safe-action/hooks';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Button } from '@kit/ui/button';
import {
@@ -37,7 +40,8 @@ export const UpdateTeamAccountNameForm = (props: {
path: string;
}) => {
const t = useTranslations('teams');
const [pending, startTransition] = useTransition();
const { t } = useTranslation('teams');
const form = useForm({
resolver: zodResolver(TeamNameFormSchema),
@@ -47,21 +51,6 @@ export const UpdateTeamAccountNameForm = (props: {
},
});
const { execute, isPending } = useAction(updateTeamAccountName, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success(t('updateTeamSuccessMessage'));
} else if (data?.error) {
toast.error(t(data.error));
} else {
toast.error(t('updateTeamErrorMessage'));
}
},
onError: () => {
toast.error(t('updateTeamErrorMessage'));
},
});
const nameValue = useWatch({ control: form.control, name: 'name' });
const showSlugField = containsNonLatinCharacters(nameValue || '');
@@ -72,11 +61,41 @@ export const UpdateTeamAccountNameForm = (props: {
data-test={'update-team-account-name-form'}
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit((data) => {
execute({
slug: props.account.slug,
name: data.name,
newSlug: data.newSlug || undefined,
path: props.path,
startTransition(async () => {
const toastId = toast.loading(t('updateTeamLoadingMessage'));
try {
const result = await updateTeamAccountName({
slug: props.account.slug,
name: data.name,
newSlug: data.newSlug || undefined,
path: props.path,
});
if (result.success) {
toast.success(t('updateTeamSuccessMessage'), {
id: toastId,
});
} else if (result.error) {
toast.error(t(result.error), {
id: toastId,
});
} else {
toast.error(t('updateTeamErrorMessage'), {
id: toastId,
});
}
} catch (error) {
if (!isRedirectError(error)) {
toast.error(t('updateTeamErrorMessage'), {
id: toastId,
});
} else {
toast.success(t('updateTeamSuccessMessage'), {
id: toastId,
});
}
}
});
})}
>
@@ -86,7 +105,7 @@ export const UpdateTeamAccountNameForm = (props: {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams.teamNameLabel'} />
<Trans i18nKey={'teams:teamNameLabel'} />
</FormLabel>
<FormControl>
@@ -98,7 +117,7 @@ export const UpdateTeamAccountNameForm = (props: {
<InputGroupInput
data-test={'team-name-input'}
required
placeholder={t('teamNameInputLabel')}
placeholder={t('teams:teamNameInputLabel')}
{...field}
/>
</InputGroup>
@@ -117,7 +136,7 @@ export const UpdateTeamAccountNameForm = (props: {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams.teamSlugLabel'} />
<Trans i18nKey={'teams:teamSlugLabel'} />
</FormLabel>
<FormControl>
@@ -136,7 +155,7 @@ export const UpdateTeamAccountNameForm = (props: {
</FormControl>
<FormDescription>
<Trans i18nKey={'teams.teamSlugDescription'} />
<Trans i18nKey={'teams:teamSlugDescription'} />
</FormDescription>
<FormMessage />
@@ -148,12 +167,11 @@ export const UpdateTeamAccountNameForm = (props: {
<div>
<Button
type="submit"
className={'w-full md:w-auto'}
data-test={'update-team-submit-button'}
disabled={isPending}
disabled={pending}
>
<Trans i18nKey={'teams.updateTeamSubmitLabel'} />
<Trans i18nKey={'teams:updateTeamSubmitLabel'} />
</Button>
</div>
</form>

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
export const AcceptInvitationSchema = z.object({
inviteToken: z.string().uuid(),

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
/**
* @name RESERVED_NAMES_ARRAY
@@ -40,18 +40,20 @@ export function containsNonLatinCharacters(value: string): boolean {
* @description Schema for validating URL-friendly slugs
*/
export const SlugSchema = z
.string()
.string({
description: 'URL-friendly identifier for the team',
})
.min(2)
.max(50)
.regex(SLUG_REGEX, {
message: 'teams.invalidSlugError',
message: 'teams:invalidSlugError',
})
.refine(
(slug) => {
return !RESERVED_NAMES_ARRAY.includes(slug.toLowerCase());
},
{
message: 'teams.reservedNameError',
message: 'teams:reservedNameError',
},
);
@@ -60,7 +62,9 @@ export const SlugSchema = z
* @description Schema for team name - allows non-Latin characters
*/
export const TeamNameSchema = z
.string()
.string({
description: 'The name of the team account',
})
.min(2)
.max(50)
.refine(
@@ -68,7 +72,7 @@ export const TeamNameSchema = z
return !SPECIAL_CHARACTERS_REGEX.test(name);
},
{
message: 'teams.specialCharactersError',
message: 'teams:specialCharactersError',
},
)
.refine(
@@ -76,7 +80,7 @@ export const TeamNameSchema = z
return !RESERVED_NAMES_ARRAY.includes(name.toLowerCase());
},
{
message: 'teams.reservedNameError',
message: 'teams:reservedNameError',
},
);
@@ -89,11 +93,10 @@ export const CreateTeamSchema = z
.object({
name: TeamNameSchema,
// Transform empty strings to undefined before validation
slug: z
.string()
.optional()
.transform((val) => (val === '' ? undefined : val))
.pipe(SlugSchema.optional()),
slug: z.preprocess(
(val) => (val === '' ? undefined : val),
SlugSchema.optional(),
),
})
.refine(
(data) => {
@@ -104,7 +107,7 @@ export const CreateTeamSchema = z
return true;
},
{
message: 'teams.slugRequiredForNonLatinName',
message: 'teams:slugRequiredForNonLatinName',
path: ['slug'],
},
);

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
export const DeleteInvitationSchema = z.object({
invitationId: z.number().int(),

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
export const DeleteTeamAccountSchema = z.object({
accountId: z.string().uuid(),

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
const InviteSchema = z.object({
email: z.string().email(),

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
export const LeaveTeamAccountSchema = z.object({
accountId: z.string().uuid(),

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
export const RemoveMemberSchema = z.object({
accountId: z.string().uuid(),

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
export const RenewInvitationSchema = z.object({
invitationId: z.number().positive(),

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
export const TransferOwnershipConfirmationSchema = z.object({
accountId: z.string().uuid(),
@@ -6,6 +6,6 @@ export const TransferOwnershipConfirmationSchema = z.object({
otp: z.string().min(6),
});
export type TransferOwnershipConfirmationData = z.output<
export type TransferOwnershipConfirmationData = z.infer<
typeof TransferOwnershipConfirmationSchema
>;

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
export const UpdateInvitationSchema = z.object({
invitationId: z.number(),

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
export const RoleSchema = z.object({
role: z.string().min(1),

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
import {
SlugSchema,
@@ -23,7 +23,7 @@ export const TeamNameFormSchema = z
return true;
},
{
message: 'teams.slugRequiredForNonLatinName',
message: 'teams:slugRequiredForNonLatinName',
path: ['newSlug'],
},
);

View File

@@ -1,17 +1,18 @@
'use server';
import 'server-only';
import { redirect } from 'next/navigation';
import { authActionClient } from '@kit/next/safe-action';
import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger';
import { CreateTeamSchema } from '../../schema/create-team.schema';
import { createAccountCreationPolicyEvaluator } from '../policies';
import { createCreateTeamAccountService } from '../services/create-team-account.service';
export const createTeamAccountAction = authActionClient
.schema(CreateTeamSchema)
.action(async ({ parsedInput: { name, slug }, ctx: { user } }) => {
export const createTeamAccountAction = enhanceAction(
async ({ name, slug }, user) => {
const logger = await getLogger();
const service = createCreateTeamAccountService();
@@ -60,7 +61,7 @@ export const createTeamAccountAction = authActionClient
if (error === 'duplicate_slug') {
return {
error: true,
message: 'teams.duplicateSlugError',
message: 'teams:duplicateSlugError',
};
}
@@ -69,4 +70,8 @@ export const createTeamAccountAction = authActionClient
const accountHomePath = '/home/' + data.slug;
redirect(accountHomePath);
});
},
{
schema: CreateTeamSchema,
},
);

View File

@@ -4,7 +4,7 @@ import { redirect } from 'next/navigation';
import type { SupabaseClient } from '@supabase/supabase-js';
import { authActionClient } from '@kit/next/safe-action';
import { enhanceAction } from '@kit/next/actions';
import { createOtpApi } from '@kit/otp';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
@@ -16,11 +16,14 @@ import { createDeleteTeamAccountService } from '../services/delete-team-account.
const enableTeamAccountDeletion =
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION === 'true';
export const deleteTeamAccountAction = authActionClient
.schema(DeleteTeamAccountSchema)
.action(async ({ parsedInput: params, ctx: { user } }) => {
export const deleteTeamAccountAction = enhanceAction(
async (formData: FormData, user) => {
const logger = await getLogger();
const params = DeleteTeamAccountSchema.parse(
Object.fromEntries(formData.entries()),
);
const otpService = createOtpApi(getSupabaseServerClient());
const otpResult = await otpService.verifyToken({
@@ -54,8 +57,12 @@ export const deleteTeamAccountAction = authActionClient
logger.info(ctx, `Team account request successfully sent`);
redirect('/home');
});
return redirect('/home');
},
{
auth: true,
},
);
async function deleteTeamAccount(params: {
accountId: string;

View File

@@ -3,15 +3,17 @@
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { authActionClient } from '@kit/next/safe-action';
import { enhanceAction } from '@kit/next/actions';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { LeaveTeamAccountSchema } from '../../schema/leave-team-account.schema';
import { createLeaveTeamAccountService } from '../services/leave-team-account.service';
export const leaveTeamAccountAction = authActionClient
.schema(LeaveTeamAccountSchema)
.action(async ({ parsedInput: params, ctx: { user } }) => {
export const leaveTeamAccountAction = enhanceAction(
async (formData: FormData, user) => {
const body = Object.fromEntries(formData.entries());
const params = LeaveTeamAccountSchema.parse(body);
const service = createLeaveTeamAccountService(
getSupabaseServerAdminClient(),
);
@@ -23,5 +25,7 @@ export const leaveTeamAccountAction = authActionClient
revalidatePath('/home/[account]', 'layout');
redirect('/home');
});
return redirect('/home');
},
{},
);

View File

@@ -2,15 +2,14 @@
import { redirect } from 'next/navigation';
import { authActionClient } from '@kit/next/safe-action';
import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { UpdateTeamNameSchema } from '../../schema/update-team-name.schema';
export const updateTeamAccountName = authActionClient
.schema(UpdateTeamNameSchema)
.action(async ({ parsedInput: params }) => {
export const updateTeamAccountName = enhanceAction(
async (params) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const { name, path, slug, newSlug } = params;
@@ -41,7 +40,7 @@ export const updateTeamAccountName = authActionClient
if (error.code === '23505') {
return {
success: false,
error: 'teams.duplicateSlugError',
error: 'teams:duplicateSlugError',
};
}
@@ -61,4 +60,8 @@ export const updateTeamAccountName = authActionClient
}
return { success: true };
});
},
{
schema: UpdateTeamNameSchema,
},
);

View File

@@ -3,9 +3,9 @@
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import * as z from 'zod';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
@@ -26,15 +26,8 @@ import { createAccountPerSeatBillingService } from '../services/account-per-seat
* @name createInvitationsAction
* @description Creates invitations for inviting members.
*/
export const createInvitationsAction = authActionClient
.schema(
InviteMembersSchema.and(
z.object({
accountSlug: z.string().min(1),
}),
),
)
.action(async ({ parsedInput: params, ctx: { user } }) => {
export const createInvitationsAction = enhanceAction(
async (params, user) => {
const logger = await getLogger();
logger.info(
@@ -123,15 +116,22 @@ export const createInvitationsAction = authActionClient
success: false,
};
}
});
},
{
schema: InviteMembersSchema.and(
z.object({
accountSlug: z.string().min(1),
}),
),
},
);
/**
* @name deleteInvitationAction
* @description Deletes an invitation specified by the invitation ID.
*/
export const deleteInvitationAction = authActionClient
.schema(DeleteInvitationSchema)
.action(async ({ parsedInput: data }) => {
export const deleteInvitationAction = enhanceAction(
async (data) => {
const client = getSupabaseServerClient();
const service = createAccountInvitationsService(client);
@@ -143,15 +143,18 @@ export const deleteInvitationAction = authActionClient
return {
success: true,
};
});
},
{
schema: DeleteInvitationSchema,
},
);
/**
* @name updateInvitationAction
* @description Updates an invitation.
*/
export const updateInvitationAction = authActionClient
.schema(UpdateInvitationSchema)
.action(async ({ parsedInput: invitation }) => {
export const updateInvitationAction = enhanceAction(
async (invitation) => {
const client = getSupabaseServerClient();
const service = createAccountInvitationsService(client);
@@ -162,18 +165,23 @@ export const updateInvitationAction = authActionClient
return {
success: true,
};
});
},
{
schema: UpdateInvitationSchema,
},
);
/**
* @name acceptInvitationAction
* @description Accepts an invitation to join a team.
*/
export const acceptInvitationAction = authActionClient
.schema(AcceptInvitationSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
export const acceptInvitationAction = enhanceAction(
async (data: FormData, user) => {
const client = getSupabaseServerClient();
const { inviteToken, nextPath } = data;
const { inviteToken, nextPath } = AcceptInvitationSchema.parse(
Object.fromEntries(data),
);
// create the services
const perSeatBillingService = createAccountPerSeatBillingService(client);
@@ -197,17 +205,19 @@ export const acceptInvitationAction = authActionClient
// Increase the seats for the account
await perSeatBillingService.increaseSeats(accountId);
redirect(nextPath);
});
return redirect(nextPath);
},
{},
);
/**
* @name renewInvitationAction
* @description Renews an invitation.
*/
export const renewInvitationAction = authActionClient
.schema(RenewInvitationSchema)
.action(async ({ parsedInput: { invitationId } }) => {
export const renewInvitationAction = enhanceAction(
async (params) => {
const client = getSupabaseServerClient();
const { invitationId } = RenewInvitationSchema.parse(params);
const service = createAccountInvitationsService(client);
@@ -219,7 +229,11 @@ export const renewInvitationAction = authActionClient
return {
success: true,
};
});
},
{
schema: RenewInvitationSchema,
},
);
function revalidateMemberPage() {
revalidatePath('/home/[account]/members', 'page');
@@ -233,7 +247,7 @@ function revalidateMemberPage() {
* @param accountId - The account ID (already fetched to avoid duplicate queries).
*/
async function evaluateInvitationsPolicies(
params: z.output<typeof InviteMembersSchema> & { accountSlug: string },
params: z.infer<typeof InviteMembersSchema> & { accountSlug: string },
user: JWTUserData,
accountId: string,
) {
@@ -268,7 +282,7 @@ async function evaluateInvitationsPolicies(
async function checkInvitationPermissions(
accountId: string,
userId: string,
invitations: z.output<typeof InviteMembersSchema>['invitations'],
invitations: z.infer<typeof InviteMembersSchema>['invitations'],
): Promise<{
allowed: boolean;
reason?: string;

View File

@@ -2,7 +2,7 @@
import { revalidatePath } from 'next/cache';
import { authActionClient } from '@kit/next/safe-action';
import { enhanceAction } from '@kit/next/actions';
import { createOtpApi } from '@kit/otp';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
@@ -17,9 +17,8 @@ import { createAccountMembersService } from '../services/account-members.service
* @name removeMemberFromAccountAction
* @description Removes a member from an account.
*/
export const removeMemberFromAccountAction = authActionClient
.schema(RemoveMemberSchema)
.action(async ({ parsedInput: { accountId, userId } }) => {
export const removeMemberFromAccountAction = enhanceAction(
async ({ accountId, userId }) => {
const client = getSupabaseServerClient();
const service = createAccountMembersService(client);
@@ -32,15 +31,18 @@ export const removeMemberFromAccountAction = authActionClient
revalidatePath('/home/[account]', 'layout');
return { success: true };
});
},
{
schema: RemoveMemberSchema,
},
);
/**
* @name updateMemberRoleAction
* @description Updates the role of a member in an account.
*/
export const updateMemberRoleAction = authActionClient
.schema(UpdateMemberRoleSchema)
.action(async ({ parsedInput: data }) => {
export const updateMemberRoleAction = enhanceAction(
async (data) => {
const client = getSupabaseServerClient();
const service = createAccountMembersService(client);
const adminClient = getSupabaseServerAdminClient();
@@ -52,16 +54,19 @@ export const updateMemberRoleAction = authActionClient
revalidatePath('/home/[account]', 'layout');
return { success: true };
});
},
{
schema: UpdateMemberRoleSchema,
},
);
/**
* @name transferOwnershipAction
* @description Transfers the ownership of an account to another member.
* Requires OTP verification for security.
*/
export const transferOwnershipAction = authActionClient
.schema(TransferOwnershipConfirmationSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
export const transferOwnershipAction = enhanceAction(
async (data, user) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
@@ -132,4 +137,8 @@ export const transferOwnershipAction = authActionClient
return {
success: true,
};
});
},
{
schema: TransferOwnershipConfirmationSchema,
},
);

View File

@@ -1,6 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import * as z from 'zod';
import { z } from 'zod';
import type { Database } from '@kit/supabase/database';
import { JWTUserData } from '@kit/supabase/types';
@@ -29,7 +29,7 @@ class InvitationContextBuilder {
* Build policy context for invitation evaluation with optimized parallel loading
*/
async buildContext(
params: z.output<typeof InviteMembersSchema> & { accountSlug: string },
params: z.infer<typeof InviteMembersSchema> & { accountSlug: string },
user: JWTUserData,
): Promise<FeaturePolicyInvitationContext> {
// Fetch all data in parallel for optimal performance
@@ -43,7 +43,7 @@ class InvitationContextBuilder {
* (avoids duplicate account lookup)
*/
async buildContextWithAccountId(
params: z.output<typeof InviteMembersSchema> & { accountSlug: string },
params: z.infer<typeof InviteMembersSchema> & { accountSlug: string },
user: JWTUserData,
accountId: string,
): Promise<FeaturePolicyInvitationContext> {

Some files were not shown because too many files have changed in this diff Show More