Unify workspace dropdowns; Update layouts (#458)

Unified Account and Workspace drop-downs; Layout updates, now header lives within the PageBody component; Sidebars now use floating variant
This commit is contained in:
Giancarlo Buomprisco
2026-03-11 14:45:42 +08:00
committed by GitHub
parent ca585e09be
commit 4bc8448a1d
530 changed files with 14398 additions and 11198 deletions

View File

@@ -44,10 +44,11 @@
"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,14 +1,5 @@
'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,
@@ -16,24 +7,9 @@ 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 {
CreateTeamSchema,
NON_LATIN_REGEX,
} from '../schema/create-team.schema';
import { createTeamAccountAction } from '../server/actions/create-team-account-server-actions';
import { CreateTeamAccountForm } from './create-team-account-form';
export function CreateTeamAccountDialog(
props: React.PropsWithChildren<{
@@ -42,171 +18,24 @@ export function CreateTeamAccountDialog(
}>,
) {
return (
<Dialog open={props.isOpen} onOpenChange={props.setIsOpen}>
<DialogContent
onEscapeKeyDown={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<Dialog
open={props.isOpen}
onOpenChange={props.setIsOpen}
disablePointerDismissal
>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams:createTeamModalHeading'} />
<Trans i18nKey={'teams.createTeamModalHeading'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'teams:createTeamModalDescription'} />
<Trans i18nKey={'teams.createTeamModalDescription'} />
</DialogDescription>
</DialogHeader>
<CreateOrganizationAccountForm onClose={() => props.setIsOpen(false)} />
<CreateTeamAccountForm onCancel={() => 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

@@ -0,0 +1,183 @@
'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,4 +5,5 @@ 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,12 +1,16 @@
'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: {
@@ -28,11 +32,13 @@ 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,
}}
@@ -53,7 +59,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,
}}
@@ -64,20 +70,24 @@ export function AcceptInvitationContainer(props: {
<form
data-test={'join-team-form'}
className={'w-full'}
action={acceptInvitationAction}
onSubmit={(e) => {
e.preventDefault();
execute({
inviteToken: props.inviteToken,
nextPath: props.paths.nextPath,
});
}}
>
<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}
/>
<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>
</form>
<Separator />
@@ -85,7 +95,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 { useTranslation } from 'react-i18next';
import { useTranslations } from 'next-intl';
import { Database } from '@kit/supabase/database';
import { Badge } from '@kit/ui/badge';
@@ -43,7 +43,7 @@ export function AccountInvitationsTable({
invitations,
permissions,
}: AccountInvitationsTableProps) {
const { t } = useTranslation('teams');
const t = useTranslations('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 } = useTranslation('teams');
const t = useTranslations('teams');
return useMemo(
() => [
@@ -96,7 +96,7 @@ function useGetColumns(permissions: {
return (
<span
data-test={'invitation-email'}
className={'flex items-center space-x-4 text-left'}
className={'flex items-center gap-x-2 text-left'}
>
<span>
<ProfileAvatar text={email} />
@@ -172,19 +172,21 @@ function ActionsDropdown({
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={'ghost'} size={'icon'}>
<Ellipsis className={'h-5 w-5'} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={
<Button variant={'ghost'} size={'icon'}>
<Ellipsis className={'h-5 w-5'} />
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuContent className="min-w-52">
<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)}>
@@ -192,7 +194,7 @@ function ActionsDropdown({
data-test={'renew-invitation-trigger'}
onClick={() => setIsRenewingInvite(true)}
>
<Trans i18nKey={'teams:renewInvitation'} />
<Trans i18nKey={'teams.renewInvitation'} />
</DropdownMenuItem>
</If>
</If>
@@ -202,7 +204,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,4 +1,6 @@
import { useState, useTransition } from 'react';
'use client';
import { useAction } from 'next-safe-action/hooks';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
@@ -30,11 +32,11 @@ export function DeleteInvitationDialog({
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey="team:deleteInvitation" />
<Trans i18nKey="teams.deleteInvitation" />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey="team:deleteInvitationDialogDescription" />
<Trans i18nKey="teams.deleteInvitationDialogDescription" />
</AlertDialogDescription>
</AlertDialogHeader>
@@ -54,43 +56,34 @@ function DeleteInvitationForm({
invitationId: number;
setIsOpen: (isOpen: boolean) => void;
}) {
const [isSubmitting, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const onInvitationRemoved = () => {
startTransition(async () => {
try {
await deleteInvitationAction({ invitationId });
setIsOpen(false);
} catch {
setError(true);
}
});
};
const { execute, isPending, hasErrored } = useAction(deleteInvitationAction, {
onSuccess: () => setIsOpen(false),
});
return (
<form data-test={'delete-invitation-form'} action={onInvitationRemoved}>
<form
data-test={'delete-invitation-form'}
onSubmit={(e) => {
e.preventDefault();
execute({ invitationId });
}}
>
<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={error}>
<If condition={hasErrored}>
<RemoveInvitationErrorAlert />
</If>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
<Button
type={'submit'}
variant={'destructive'}
disabled={isSubmitting}
>
<Trans i18nKey={'teams:deleteInvitation'} />
<Button type={'submit'} variant={'destructive'} disabled={isPending}>
<Trans i18nKey={'teams.deleteInvitation'} />
</Button>
</AlertDialogFooter>
</div>
@@ -102,11 +95,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,4 +1,6 @@
import { useState, useTransition } from 'react';
'use client';
import { useAction } from 'next-safe-action/hooks';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
@@ -32,12 +34,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>
@@ -59,42 +61,33 @@ function RenewInvitationForm({
invitationId: number;
setIsOpen: (isOpen: boolean) => void;
}) {
const [isSubmitting, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const inInvitationRenewed = () => {
startTransition(async () => {
try {
await renewInvitationAction({ invitationId });
setIsOpen(false);
} catch {
setError(true);
}
});
};
const { execute, isPending, hasErrored } = useAction(renewInvitationAction, {
onSuccess: () => setIsOpen(false),
});
return (
<form action={inInvitationRenewed}>
<form
onSubmit={(e) => {
e.preventDefault();
execute({ invitationId });
}}
>
<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={error}>
<If condition={hasErrored}>
<RenewInvitationErrorAlert />
</If>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
<Button
data-test={'confirm-renew-invitation'}
disabled={isSubmitting}
>
<Trans i18nKey={'teams:renewInvitation'} />
<Button data-test={'confirm-renew-invitation'} disabled={isPending}>
<Trans i18nKey={'teams.renewInvitation'} />
</Button>
</AlertDialogFooter>
</div>
@@ -106,11 +99,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,8 +1,9 @@
import { useState, useTransition } from 'react';
'use client';
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';
@@ -50,11 +51,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>
@@ -80,24 +81,11 @@ function UpdateInvitationForm({
userRoleHierarchy: number;
setIsOpen: (isOpen: boolean) => void;
}>) {
const { t } = useTranslation('teams');
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const t = useTranslations('teams');
const onSubmit = ({ role }: { role: Role }) => {
startTransition(async () => {
try {
await updateInvitationAction({
invitationId,
role,
});
setIsOpen(false);
} catch {
setError(true);
}
});
};
const { execute, isPending, hasErrored } = useAction(updateInvitationAction, {
onSuccess: () => setIsOpen(false),
});
const form = useForm({
resolver: zodResolver(
@@ -122,10 +110,12 @@ function UpdateInvitationForm({
<Form {...form}>
<form
data-test={'update-invitation-form'}
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit(({ role }) => {
execute({ invitationId, role });
})}
className={'flex flex-col space-y-6'}
>
<If condition={error}>
<If condition={hasErrored}>
<UpdateRoleErrorAlert />
</If>
@@ -135,7 +125,7 @@ function UpdateInvitationForm({
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:roleLabel'} />
<Trans i18nKey={'teams.roleLabel'} />
</FormLabel>
<FormControl>
@@ -145,16 +135,18 @@ function UpdateInvitationForm({
roles={roles}
currentUserRole={userRole}
value={field.value}
onChange={(newRole) =>
form.setValue(field.name, newRole)
}
onChange={(newRole) => {
if (newRole) {
form.setValue(field.name, newRole);
}
}}
/>
)}
</RolesDataProvider>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:updateRoleDescription'} />
<Trans i18nKey={'teams.updateRoleDescription'} />
</FormDescription>
<FormMessage />
@@ -163,8 +155,8 @@ function UpdateInvitationForm({
}}
/>
<Button type={'submit'} disabled={pending}>
<Trans i18nKey={'teams:updateRoleSubmitLabel'} />
<Button type={'submit'} disabled={isPending}>
<Trans i18nKey={'teams.updateRoleSubmitLabel'} />
</Button>
</form>
</Form>
@@ -175,11 +167,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 { useTranslation } from 'react-i18next';
import { useTranslations } from 'next-intl';
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 } = useTranslation('teams');
const t = useTranslations('teams');
const permissions = {
canUpdateRole: (targetRole: number) => {
@@ -123,7 +123,7 @@ function useGetColumns(
currentRoleHierarchy: number;
},
): ColumnDef<Members[0]>[] {
const { t } = useTranslation('teams');
const t = useTranslations('teams');
return useMemo(
() => [
@@ -136,7 +136,7 @@ function useGetColumns(
const isSelf = member.user_id === params.currentUserId;
return (
<span className={'flex items-center space-x-4 text-left'}>
<span className={'flex items-center gap-x-2 text-left'}>
<span>
<ProfileAvatar
displayName={displayName}
@@ -144,11 +144,13 @@ function useGetColumns(
/>
</span>
<span>{displayName}</span>
<span className={'flex items-center gap-x-2'}>
<span>{displayName}</span>
<If condition={isSelf}>
<Badge variant={'outline'}>{t('youLabel')}</Badge>
</If>
<If condition={isSelf}>
<Badge variant={'secondary'}>{t('youLabel')}</Badge>
</If>
</span>
</span>
);
},
@@ -171,13 +173,7 @@ function useGetColumns(
<RoleBadge role={role} />
<If condition={isPrimaryOwner}>
<span
className={
'rounded-md bg-yellow-400 px-2.5 py-1 text-xs font-medium dark:text-black'
}
>
{t('primaryOwnerLabel')}
</span>
<Badge variant={'warning'}>{t('primaryOwnerLabel')}</Badge>
</If>
</span>
);
@@ -223,6 +219,10 @@ 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,50 +246,66 @@ function ActionsDropdown({
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={'ghost'} size={'icon'}>
<Ellipsis className={'h-5 w-5'} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={
<Button variant={'ghost'} size={'icon'}>
<Ellipsis className={'h-5 w-5'} />
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuContent className={'min-w-52'}>
<If condition={canUpdateRole}>
<UpdateMemberRoleDialog
userId={member.user_id}
userRole={member.role}
teamAccountId={currentTeamAccountId}
userRoleHierarchy={currentRoleHierarchy}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trans i18nKey={'teams:updateRole'} />
</DropdownMenuItem>
</UpdateMemberRoleDialog>
<DropdownMenuItem onClick={() => setActiveDialog('updateRole')}>
<Trans i18nKey={'teams.updateRole'} />
</DropdownMenuItem>
</If>
<If condition={permissions.canTransferOwnership}>
<TransferOwnershipDialog
targetDisplayName={member.name ?? member.email}
accountId={member.account_id}
userId={member.user_id}
<DropdownMenuItem
onClick={() => setActiveDialog('transferOwnership')}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trans i18nKey={'teams:transferOwnership'} />
</DropdownMenuItem>
</TransferOwnershipDialog>
<Trans i18nKey={'teams.transferOwnership'} />
</DropdownMenuItem>
</If>
<If condition={canRemoveFromAccount}>
<RemoveMemberDialog
teamAccountId={currentTeamAccountId}
userId={member.user_id}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trans i18nKey={'teams:removeMember'} />
</DropdownMenuItem>
</RemoveMemberDialog>
<DropdownMenuItem onClick={() => setActiveDialog('removeMember')}>
<Trans i18nKey={'teams.removeMember'} />
</DropdownMenuItem>
</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,12 +1,13 @@
'use client';
import { useState, useTransition } from 'react';
import { useState } 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';
@@ -64,9 +65,24 @@ export function InviteMembersDialogContainer({
accountSlug: string;
userRoleHierarchy: number;
}>) {
const [pending, startTransition] = useTransition();
const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation('teams');
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);
},
});
// Evaluate policies when dialog is open
const {
@@ -76,17 +92,17 @@ export function InviteMembersDialogContainer({
} = useFetchInvitationsPolicies({ accountSlug, isOpen });
return (
<Dialog open={isOpen} onOpenChange={setIsOpen} modal>
<DialogTrigger asChild>{children}</DialogTrigger>
<Dialog open={isOpen} onOpenChange={setIsOpen} disablePointerDismissal>
<DialogTrigger render={children as React.ReactElement} />
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams:inviteMembersHeading'} />
<Trans i18nKey={'teams.inviteMembersHeading'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'teams:inviteMembersDescription'} />
<Trans i18nKey={'teams.inviteMembersDescription'} />
</DialogDescription>
</DialogHeader>
@@ -95,7 +111,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>
@@ -104,7 +120,7 @@ export function InviteMembersDialogContainer({
<Alert variant="destructive">
<AlertDescription>
<Trans
i18nKey="teams:policyCheckError"
i18nKey="teams.policyCheckError"
values={{ error: policiesError?.message }}
/>
</AlertDescription>
@@ -126,28 +142,12 @@ export function InviteMembersDialogContainer({
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
{(roles) => (
<InviteMembersForm
pending={pending}
pending={isPending}
roles={roles}
onSubmit={(data) => {
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);
execute({
accountSlug,
invitations: data.invitations,
});
}}
/>
@@ -168,7 +168,7 @@ function InviteMembersForm({
pending: boolean;
roles: string[];
}) {
const { t } = useTranslation('teams');
const t = useTranslations('teams');
const form = useForm({
resolver: zodResolver(InviteMembersSchema),
@@ -237,7 +237,9 @@ function InviteMembersForm({
roles={roles}
value={field.value}
onChange={(role) => {
form.setValue(field.name, role);
if (role) {
form.setValue(field.name, role);
}
}}
/>
</FormControl>
@@ -251,22 +253,24 @@ function InviteMembersForm({
<div className={'flex items-end justify-end'}>
<TooltipProvider>
<Tooltip>
<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>
<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>
}
/>
<TooltipContent>
{t('removeInviteButtonLabel')}
@@ -294,7 +298,7 @@ function InviteMembersForm({
<Plus className={'mr-1 h-3'} />
<span>
<Trans i18nKey={'teams:addAnotherMemberButtonLabel'} />
<Trans i18nKey={'teams.addAnotherMemberButtonLabel'} />
</span>
</Button>
</div>
@@ -305,8 +309,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) => unknown;
onChange: (role: Role | null) => unknown;
triggerClassName?: string;
}) {
return (
@@ -28,7 +28,15 @@ export function MembershipRoleSelector({
className={triggerClassName}
data-test={'role-selector-trigger'}
>
<SelectValue />
<SelectValue>
{(value) =>
value ? (
<Trans i18nKey={`common.roles.${value}.label`} defaults={value} />
) : (
''
)
}
</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -41,7 +49,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,4 +1,6 @@
import { useState, useTransition } from 'react';
'use client';
import { useAction } from 'next-safe-action/hooks';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
@@ -9,7 +11,6 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
@@ -18,29 +19,34 @@ import { Trans } from '@kit/ui/trans';
import { removeMemberFromAccountAction } from '../../server/actions/team-members-server-actions';
export function RemoveMemberDialog({
open,
onOpenChange,
teamAccountId,
userId,
children,
}: React.PropsWithChildren<{
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
teamAccountId: string;
userId: string;
}>) {
}) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialog open={open} onOpenChange={onOpenChange}>
<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} />
<RemoveMemberForm
accountId={teamAccountId}
userId={userId}
onSuccess={() => onOpenChange(false)}
/>
</AlertDialogContent>
</AlertDialog>
);
@@ -49,45 +55,46 @@ export function RemoveMemberDialog({
function RemoveMemberForm({
accountId,
userId,
onSuccess,
}: {
accountId: string;
userId: string;
onSuccess: () => void;
}) {
const [isSubmitting, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const onMemberRemoved = () => {
startTransition(async () => {
try {
await removeMemberFromAccountAction({ accountId, userId });
} catch {
setError(true);
}
});
};
const { execute, isPending, hasErrored } = useAction(
removeMemberFromAccountAction,
{
onSuccess: () => onSuccess(),
},
);
return (
<form action={onMemberRemoved}>
<form
onSubmit={(e) => {
e.preventDefault();
execute({ accountId, userId });
}}
>
<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={error}>
<If condition={hasErrored}>
<RemoveMemberErrorAlert />
</If>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
<Button
data-test={'confirm-remove-member'}
variant={'destructive'}
disabled={isSubmitting}
disabled={isPending}
>
<Trans i18nKey={'teams:removeMemberSubmitLabel'} />
<Trans i18nKey={'teams.removeMemberSubmitLabel'} />
</Button>
</AlertDialogFooter>
</div>
@@ -99,11 +106,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,8 +1,7 @@
'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';
@@ -16,7 +15,6 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { Form } from '@kit/ui/form';
@@ -27,30 +25,28 @@ import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-owner
import { transferOwnershipAction } from '../../server/actions/team-members-server-actions';
export function TransferOwnershipDialog({
children,
open,
onOpenChange,
targetDisplayName,
accountId,
userId,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
accountId: string;
userId: string;
targetDisplayName: string;
}) {
const [open, setOpen] = useState(false);
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey="team:transferOwnership" />
<Trans i18nKey="teams.transferOwnership" />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey="team:transferOwnershipDescription" />
<Trans i18nKey="teams.transferOwnershipDescription" />
</AlertDialogDescription>
</AlertDialogHeader>
@@ -58,7 +54,7 @@ export function TransferOwnershipDialog({
accountId={accountId}
userId={userId}
targetDisplayName={targetDisplayName}
onSuccess={() => setOpen(false)}
onSuccess={() => onOpenChange(false)}
/>
</AlertDialogContent>
</AlertDialog>
@@ -76,10 +72,15 @@ 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: {
@@ -103,7 +104,7 @@ function TransferOrganizationOwnershipForm({
}}
CancelButton={
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
}
data-test="verify-otp-form"
@@ -117,25 +118,17 @@ function TransferOrganizationOwnershipForm({
<form
className={'flex flex-col space-y-4 text-sm'}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
await transferOwnershipAction(data);
onSuccess();
} catch {
setError(true);
}
});
execute(data);
})}
>
<If condition={error}>
<If condition={hasErrored}>
<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,
}}
@@ -148,26 +141,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={pending}
disabled={isPending}
>
<If
condition={pending}
fallback={<Trans i18nKey={'teams:transferOwnership'} />}
condition={isPending}
fallback={<Trans i18nKey={'teams.transferOwnership'} />}
>
<Trans i18nKey={'teams:transferringOwnership'} />
<Trans i18nKey={'teams.transferringOwnership'} />
</If>
</Button>
</AlertDialogFooter>
@@ -180,11 +173,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,10 +1,12 @@
import { useState, useTransition } from 'react';
'use client';
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,
@@ -12,7 +14,6 @@ import {
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import {
Form,
@@ -34,31 +35,30 @@ import { RolesDataProvider } from './roles-data-provider';
type Role = string;
export function UpdateMemberRoleDialog({
children,
open,
onOpenChange,
userId,
teamAccountId,
userRole,
userRoleHierarchy,
}: React.PropsWithChildren<{
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
userId: string;
teamAccountId: string;
userRole: Role;
userRoleHierarchy: number;
}>) {
const [open, setOpen] = useState(false);
}) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<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={() => setOpen(false)}
onSuccess={() => onOpenChange(false)}
/>
)}
</RolesDataProvider>
@@ -91,25 +91,11 @@ function UpdateMemberForm({
roles: Role[];
onSuccess: () => unknown;
}>) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const { t } = useTranslation('teams');
const t = useTranslations('teams');
const onSubmit = ({ role }: { role: Role }) => {
startTransition(async () => {
try {
await updateMemberRoleAction({
accountId: teamAccountId,
userId,
role,
});
onSuccess();
} catch {
setError(true);
}
});
};
const { execute, isPending, hasErrored } = useAction(updateMemberRoleAction, {
onSuccess: () => onSuccess(),
});
const form = useForm({
resolver: zodResolver(
@@ -134,10 +120,16 @@ function UpdateMemberForm({
<Form {...form}>
<form
data-test={'update-member-role-form'}
onSubmit={form.handleSubmit(onSubmit)}
className={'flex flex-col space-y-6'}
onSubmit={form.handleSubmit(({ role }) => {
execute({
accountId: teamAccountId,
userId,
role,
});
})}
className={'flex w-full flex-col space-y-6'}
>
<If condition={error}>
<If condition={hasErrored}>
<UpdateRoleErrorAlert />
</If>
@@ -150,10 +142,15 @@ function UpdateMemberForm({
<FormControl>
<MembershipRoleSelector
triggerClassName={'w-full'}
roles={roles}
currentUserRole={userRole}
value={field.value}
onChange={(newRole) => form.setValue('role', newRole)}
onChange={(newRole) => {
if (newRole) {
form.setValue('role', newRole);
}
}}
/>
</FormControl>
@@ -165,9 +162,19 @@ function UpdateMemberForm({
}}
/>
<Button data-test={'confirm-update-member-role'} disabled={pending}>
<Trans i18nKey={'teams:updateRoleSubmitLabel'} />
</Button>
<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>
</form>
</Form>
);
@@ -177,11 +184,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,10 +1,9 @@
'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 { z } from 'zod';
import * as z from 'zod';
import { ErrorBoundary } from '@kit/monitoring/components';
import { VerifyOtpForm } from '@kit/otp/components';
@@ -100,12 +99,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,
}}
@@ -115,25 +114,27 @@ function DeleteTeamContainer(props: {
<div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
data-test={'delete-team-trigger'}
type={'button'}
variant={'destructive'}
>
<Trans i18nKey={'teams:deleteTeam'} />
</Button>
</AlertDialogTrigger>
<AlertDialogTrigger
render={
<Button
data-test={'delete-team-trigger'}
type={'button'}
variant={'destructive'}
>
<Trans i18nKey={'teams.deleteTeam'} />
</Button>
}
/>
<AlertDialogContent onEscapeKeyDown={(e) => e.preventDefault()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'teams:deletingTeam'} />
<Trans i18nKey={'teams.deletingTeam'} />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans
i18nKey={'teams:deletingTeamDescription'}
i18nKey={'teams.deletingTeamDescription'}
values={{
teamName: props.account.name,
}}
@@ -161,6 +162,8 @@ function DeleteTeamConfirmationForm({
}) {
const { data: user } = useUser();
const { execute, isPending } = useAction(deleteTeamAccountAction);
const form = useForm({
mode: 'onChange',
reValidateMode: 'onChange',
@@ -188,7 +191,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>
}
/>
@@ -201,7 +204,10 @@ function DeleteTeamConfirmationForm({
<form
data-test={'delete-team-form'}
className={'flex flex-col space-y-4'}
action={deleteTeamAccountAction}
onSubmit={(e) => {
e.preventDefault();
execute({ accountId: id, otp });
}}
>
<div className={'flex flex-col space-y-2'}>
<div
@@ -211,7 +217,7 @@ function DeleteTeamConfirmationForm({
>
<div>
<Trans
i18nKey={'teams:deleteTeamDisclaimer'}
i18nKey={'teams.deleteTeamDisclaimer'}
values={{
teamName: name,
}}
@@ -219,20 +225,24 @@ 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>
<DeleteTeamSubmitButton />
<Button
type="submit"
data-test={'delete-team-form-confirm-button'}
disabled={isPending}
variant={'destructive'}
>
<Trans i18nKey={'teams.deleteTeam'} />
</Button>
</AlertDialogFooter>
</form>
</Form>
@@ -240,26 +250,14 @@ 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({
@@ -278,7 +276,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,
}}
@@ -286,26 +284,26 @@ function LeaveTeamContainer(props: {
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<div>
<AlertDialogTrigger
render={
<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>
@@ -313,21 +311,20 @@ function LeaveTeamContainer(props: {
<Form {...form}>
<form
className={'flex flex-col space-y-4'}
action={leaveTeamAccountAction}
onSubmit={form.handleSubmit((data) => {
execute({
accountId: props.account.id,
confirmation: data.confirmation,
});
})}
>
<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>
@@ -344,7 +341,7 @@ function LeaveTeamContainer(props: {
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:leaveTeamInputDescription'} />
<Trans i18nKey={'teams.leaveTeamInputDescription'} />
</FormDescription>
<FormMessage />
@@ -355,10 +352,17 @@ function LeaveTeamContainer(props: {
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
<LeaveTeamSubmitButton />
<Button
type="submit"
data-test={'confirm-leave-organization-button'}
disabled={isPending}
variant={'destructive'}
>
<Trans i18nKey={'teams.leaveTeam'} />
</Button>
</AlertDialogFooter>
</form>
</Form>
@@ -369,36 +373,22 @@ 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>
@@ -410,17 +400,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>
@@ -432,11 +422,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 { useTranslation } from 'react-i18next';
import { useTranslations } from 'next-intl';
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 } = useTranslation('teams');
const t = useTranslations('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,13 +1,10 @@
'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 {
@@ -40,8 +37,7 @@ export const UpdateTeamAccountNameForm = (props: {
path: string;
}) => {
const [pending, startTransition] = useTransition();
const { t } = useTranslation('teams');
const t = useTranslations('teams');
const form = useForm({
resolver: zodResolver(TeamNameFormSchema),
@@ -51,6 +47,21 @@ 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 || '');
@@ -61,41 +72,11 @@ export const UpdateTeamAccountNameForm = (props: {
data-test={'update-team-account-name-form'}
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit((data) => {
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,
});
}
}
execute({
slug: props.account.slug,
name: data.name,
newSlug: data.newSlug || undefined,
path: props.path,
});
})}
>
@@ -105,7 +86,7 @@ export const UpdateTeamAccountNameForm = (props: {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:teamNameLabel'} />
<Trans i18nKey={'teams.teamNameLabel'} />
</FormLabel>
<FormControl>
@@ -117,7 +98,7 @@ export const UpdateTeamAccountNameForm = (props: {
<InputGroupInput
data-test={'team-name-input'}
required
placeholder={t('teams:teamNameInputLabel')}
placeholder={t('teamNameInputLabel')}
{...field}
/>
</InputGroup>
@@ -136,7 +117,7 @@ export const UpdateTeamAccountNameForm = (props: {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:teamSlugLabel'} />
<Trans i18nKey={'teams.teamSlugLabel'} />
</FormLabel>
<FormControl>
@@ -155,7 +136,7 @@ export const UpdateTeamAccountNameForm = (props: {
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:teamSlugDescription'} />
<Trans i18nKey={'teams.teamSlugDescription'} />
</FormDescription>
<FormMessage />
@@ -167,11 +148,12 @@ export const UpdateTeamAccountNameForm = (props: {
<div>
<Button
type="submit"
className={'w-full md:w-auto'}
data-test={'update-team-submit-button'}
disabled={pending}
disabled={isPending}
>
<Trans i18nKey={'teams:updateTeamSubmitLabel'} />
<Trans i18nKey={'teams.updateTeamSubmitLabel'} />
</Button>
</div>
</form>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as 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.infer<
export type TransferOwnershipConfirmationData = z.output<
typeof TransferOwnershipConfirmationSchema
>;

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as 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,18 +1,17 @@
'use server';
import 'server-only';
import { redirect } from 'next/navigation';
import { enhanceAction } from '@kit/next/actions';
import { authActionClient } from '@kit/next/safe-action';
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 = enhanceAction(
async ({ name, slug }, user) => {
export const createTeamAccountAction = authActionClient
.schema(CreateTeamSchema)
.action(async ({ parsedInput: { name, slug }, ctx: { user } }) => {
const logger = await getLogger();
const service = createCreateTeamAccountService();
@@ -61,7 +60,7 @@ export const createTeamAccountAction = enhanceAction(
if (error === 'duplicate_slug') {
return {
error: true,
message: 'teams:duplicateSlugError',
message: 'teams.duplicateSlugError',
};
}
@@ -70,8 +69,4 @@ export const createTeamAccountAction = enhanceAction(
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 { enhanceAction } from '@kit/next/actions';
import { authActionClient } from '@kit/next/safe-action';
import { createOtpApi } from '@kit/otp';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
@@ -16,14 +16,11 @@ import { createDeleteTeamAccountService } from '../services/delete-team-account.
const enableTeamAccountDeletion =
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION === 'true';
export const deleteTeamAccountAction = enhanceAction(
async (formData: FormData, user) => {
export const deleteTeamAccountAction = authActionClient
.schema(DeleteTeamAccountSchema)
.action(async ({ parsedInput: params, ctx: { user } }) => {
const logger = await getLogger();
const params = DeleteTeamAccountSchema.parse(
Object.fromEntries(formData.entries()),
);
const otpService = createOtpApi(getSupabaseServerClient());
const otpResult = await otpService.verifyToken({
@@ -57,12 +54,8 @@ export const deleteTeamAccountAction = enhanceAction(
logger.info(ctx, `Team account request successfully sent`);
return redirect('/home');
},
{
auth: true,
},
);
redirect('/home');
});
async function deleteTeamAccount(params: {
accountId: string;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import * as 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.infer<typeof InviteMembersSchema> & { accountSlug: string },
params: z.output<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.infer<typeof InviteMembersSchema> & { accountSlug: string },
params: z.output<typeof InviteMembersSchema> & { accountSlug: string },
user: JWTUserData,
accountId: string,
): Promise<FeaturePolicyInvitationContext> {

View File

@@ -20,8 +20,8 @@ export const subscriptionRequiredInvitationsPolicy =
if (!subscription || !subscription.active) {
return deny({
code: 'SUBSCRIPTION_REQUIRED',
message: 'teams:policyErrors.subscriptionRequired',
remediation: 'teams:policyRemediation.subscriptionRequired',
message: 'teams.policyErrors.subscriptionRequired',
remediation: 'teams.policyRemediation.subscriptionRequired',
});
}
@@ -55,8 +55,8 @@ export const paddleBillingInvitationsPolicy =
if (hasPerSeatItems) {
return deny({
code: 'PADDLE_TRIAL_RESTRICTION',
message: 'teams:policyErrors.paddleTrialRestriction',
remediation: 'teams:policyRemediation.paddleTrialRestriction',
message: 'teams.policyErrors.paddleTrialRestriction',
remediation: 'teams.policyRemediation.paddleTrialRestriction',
});
}
}

View File

@@ -1,6 +1,6 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import * as z from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Database, Tables } from '@kit/supabase/database';
@@ -18,22 +18,22 @@ const env = z
.object({
invitePath: z
.string({
required_error: 'The property invitePath is required',
error: 'The property invitePath is required',
})
.min(1),
siteURL: z
.string({
required_error: 'NEXT_PUBLIC_SITE_URL is required',
error: 'NEXT_PUBLIC_SITE_URL is required',
})
.min(1),
productName: z
.string({
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
})
.min(1),
emailSender: z
.string({
required_error: 'EMAIL_SENDER is required',
error: 'EMAIL_SENDER is required',
})
.min(1),
})

View File

@@ -3,7 +3,7 @@ import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { addDays, formatISO } from 'date-fns';
import { z } from 'zod';
import * as z from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
@@ -37,7 +37,7 @@ class AccountInvitationsService {
* @description Removes an invitation from the database.
* @param params
*/
async deleteInvitation(params: z.infer<typeof DeleteInvitationSchema>) {
async deleteInvitation(params: z.output<typeof DeleteInvitationSchema>) {
const logger = await getLogger();
const ctx = {
@@ -70,7 +70,7 @@ class AccountInvitationsService {
* @param params
* @description Updates an invitation in the database.
*/
async updateInvitation(params: z.infer<typeof UpdateInvitationSchema>) {
async updateInvitation(params: z.output<typeof UpdateInvitationSchema>) {
const logger = await getLogger();
const ctx = {
@@ -107,7 +107,7 @@ class AccountInvitationsService {
}
async validateInvitation(
invitation: z.infer<typeof InviteMembersSchema>['invitations'][number],
invitation: z.output<typeof InviteMembersSchema>['invitations'][number],
accountSlug: string,
) {
const { data: members, error } = await this.client.rpc(
@@ -141,7 +141,7 @@ class AccountInvitationsService {
invitations,
invitedBy,
}: {
invitations: z.infer<typeof InviteMembersSchema>['invitations'];
invitations: z.output<typeof InviteMembersSchema>['invitations'];
accountSlug: string;
invitedBy: string;
}) {

View File

@@ -2,7 +2,7 @@ import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import * as z from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
@@ -26,7 +26,7 @@ class AccountMembersService {
* @description Removes a member from an account.
* @param params
*/
async removeMemberFromAccount(params: z.infer<typeof RemoveMemberSchema>) {
async removeMemberFromAccount(params: z.output<typeof RemoveMemberSchema>) {
const logger = await getLogger();
const ctx = {
@@ -75,7 +75,7 @@ class AccountMembersService {
* @param adminClient
*/
async updateMemberRole(
params: z.infer<typeof UpdateMemberRoleSchema>,
params: z.output<typeof UpdateMemberRoleSchema>,
adminClient: SupabaseClient<Database>,
) {
const logger = await getLogger();
@@ -145,7 +145,7 @@ class AccountMembersService {
* @param adminClient
*/
async transferOwnership(
params: z.infer<typeof TransferOwnershipConfirmationSchema>,
params: z.output<typeof TransferOwnershipConfirmationSchema>,
adminClient: SupabaseClient<Database>,
) {
const logger = await getLogger();

View File

@@ -2,7 +2,7 @@ import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import * as z from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
@@ -32,7 +32,7 @@ class LeaveTeamAccountService {
* @description Leave a team account
* @param params
*/
async leaveTeamAccount(params: z.infer<typeof Schema>) {
async leaveTeamAccount(params: z.output<typeof Schema>) {
const logger = await getLogger();
const ctx = {