B2B-30: add button to create company from super admin page

This commit is contained in:
devmc-ee
2025-06-15 12:45:55 +03:00
parent 9d869becaa
commit 6e83d25a8c
8 changed files with 301 additions and 10 deletions

View File

@@ -15,6 +15,7 @@ import {
SidebarHeader, SidebarHeader,
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
useSidebar,
} from '@kit/ui/shadcn-sidebar'; } from '@kit/ui/shadcn-sidebar';
import { AppLogo } from '~/components/app-logo'; import { AppLogo } from '~/components/app-logo';
@@ -22,11 +23,12 @@ import { ProfileAccountDropdownContainer } from '~/components/personal-account-d
export function AdminSidebar() { export function AdminSidebar() {
const path = usePathname(); const path = usePathname();
const { open } = useSidebar();
return ( return (
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
<SidebarHeader className={'m-2'}> <SidebarHeader className={'m-2'}>
<AppLogo href={'/admin'} className="max-w-full" /> <AppLogo href={'/admin'} className="max-w-full" compact={!open} />
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
@@ -64,4 +66,4 @@ export function AdminSidebar() {
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
); );
} }

View File

@@ -2,6 +2,7 @@ import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs';
import { AdminAccountsTable } from '@kit/admin/components/admin-accounts-table'; import { AdminAccountsTable } from '@kit/admin/components/admin-accounts-table';
import { AdminCreateUserDialog } from '@kit/admin/components/admin-create-user-dialog'; import { AdminCreateUserDialog } from '@kit/admin/components/admin-create-user-dialog';
import { AdminCreateCompanyDialog } from '@kit/admin/components/admin-create-company-dialog';
import { AdminGuard } from '@kit/admin/components/admin-guard'; import { AdminGuard } from '@kit/admin/components/admin-guard';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
@@ -30,10 +31,14 @@ async function AccountsPage(props: AdminAccountsPageProps) {
return ( return (
<> <>
<PageHeader description={<AppBreadcrumbs />}> <PageHeader description={<AppBreadcrumbs />}>
<div className="flex justify-end"> <div className="flex justify-end gap-2">
<AdminCreateUserDialog> <AdminCreateUserDialog>
<Button data-test="admin-create-user-button">Create User</Button> <Button data-test="admin-create-user-button">Create Personal Account</Button>
</AdminCreateUserDialog> </AdminCreateUserDialog>
<AdminCreateCompanyDialog>
<Button>Create Company Account</Button>
</AdminCreateCompanyDialog>
</div> </div>
</PageHeader> </PageHeader>

View File

@@ -4,29 +4,33 @@ import { MedReportLogo } from './med-report-title';
function LogoImage({ function LogoImage({
className, className,
compact = false,
}: { }: {
className?: string; className?: string;
width?: number; width?: number;
compact?: boolean;
}) { }) {
return <MedReportLogo className={className} />; return <MedReportLogo compact={compact} className={className} />;
} }
export function AppLogo({ export function AppLogo({
href, href,
label, label,
className, className,
compact = false,
}: { }: {
href?: string | null; href?: string | null;
className?: string; className?: string;
label?: string; label?: string;
compact?: boolean;
}) { }) {
if (href === null) { if (href === null) {
return <LogoImage className={className} />; return <LogoImage className={className} compact={compact} />;
} }
return ( return (
<Link aria-label={label ?? 'Home Page'} href={href ?? '/'} prefetch={true}> <Link aria-label={label ?? 'Home Page'} href={href ?? '/'} prefetch={true}>
<LogoImage className={className} /> <LogoImage className={className} compact={compact} />
</Link> </Link>
); );
} }

View File

@@ -1,11 +1,11 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MedReportSmallLogo } from "@/public/assets/med-report-small-logo"; import { MedReportSmallLogo } from "@/public/assets/med-report-small-logo";
export const MedReportLogo = ({ className }: { className?: string }) => ( export const MedReportLogo = ({ className, compact = false }: { className?: string, compact?: boolean }) => (
<div className={cn('flex gap-2 justify-center', className)}> <div className={cn('flex gap-2 justify-center', className)}>
<MedReportSmallLogo /> <MedReportSmallLogo />
<span className="text-foreground text-lg font-semibold tracking-tighter"> {!compact && <span className="text-foreground text-lg font-semibold tracking-tighter">
MedReport MedReport
</span> </span>}
</div> </div>
); );

View File

@@ -0,0 +1,144 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
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 { toast } from '@kit/ui/sonner';
import { createTeamAccountAction } from '../lib/server/admin-server-actions';
import { CreateCompanySchema, CreateCompanySchemaType } from '../lib/server/schema/create-company.schema';
import { Trans } from '@kit/ui/trans';
export function AdminCreateCompanyDialog(props: React.PropsWithChildren) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const form = useForm<CreateCompanySchemaType>({
resolver: zodResolver(CreateCompanySchema),
defaultValues: {
name: '',
},
mode: 'onChange',
});
const onSubmit = (data: CreateCompanySchemaType) => {
startTransition(async () => {
try {
const error = await createTeamAccountAction(data);
if (!error) {
toast.success('Company creates successfully');
form.reset();
setOpen(false);
setError(null);
} else {
setError('Something went wrong with company creation');
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Error');
}
});
};
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Create New Company Account</AlertDialogTitle>
<AlertDialogDescription>
Complete the form below to create a new company account.
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
data-test={'admin-create-user-form'}
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit(onSubmit)}
>
<If condition={!!error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</If>
<FormField
name={'name'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:teamNameLabel'} />
</FormLabel>
<FormControl>
<Input
data-test={'create-team-name-input'}
required
minLength={2}
maxLength={50}
placeholder={''}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:teamNameDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button disabled={pending} type={'submit'}>
{pending ? 'Creating...' : 'Create Company'}
</Button>
</AlertDialogFooter>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -20,6 +20,8 @@ import { ResetPasswordSchema } from './schema/reset-password.schema';
import { createAdminAccountsService } from './services/admin-accounts.service'; import { createAdminAccountsService } from './services/admin-accounts.service';
import { createAdminAuthUserService } from './services/admin-auth-user.service'; import { createAdminAuthUserService } from './services/admin-auth-user.service';
import { adminAction } from './utils/admin-action'; import { adminAction } from './utils/admin-action';
import { CreateCompanySchema } from './schema/create-company.schema';
import { createCreateTeamAccountService } from './services/create-team-account.service';
/** /**
* @name banUserAction * @name banUserAction
@@ -222,6 +224,42 @@ export const resetPasswordAction = adminAction(
), ),
); );
export const createTeamAccountAction = enhanceAction(
async ({ name }, user) => {
const logger = await getLogger();
const client = getSupabaseServerClient();
const service = createCreateTeamAccountService(client);
const ctx = {
name: 'team-accounts.create',
userId: user.id,
accountName: name,
};
logger.info(ctx, `Creating company account...`);
const { data, error } = await service.createNewOrganizationAccount({
name,
userId: user.id,
});
if (error) {
logger.error({ ...ctx, error }, `Failed to create company account`);
return {
error: true,
};
}
logger.info(ctx, `Company account created`);
redirect(`/home/${data.slug}/settings`);
},
{
schema: CreateCompanySchema,
},
);
function revalidateAdmin() { function revalidateAdmin() {
revalidatePath('/admin', 'layout'); revalidatePath('/admin', 'layout');
} }

View File

@@ -0,0 +1,53 @@
import { z } from 'zod';
/**
* @name RESERVED_NAMES_ARRAY
* @description Array of reserved names for team accounts
* This is a list of names that cannot be used for team accounts as they are reserved for other purposes.
* Please include any new reserved names here.
*/
const RESERVED_NAMES_ARRAY = [
'settings',
'billing',
// please add more reserved names here
];
const SPECIAL_CHARACTERS_REGEX = /[!@#$%^&*()+=[\]{};':"\\|,.<>/?]/;
/**
* @name CompanyNameSchema
*/
export const CompanyNameSchema = z
.string({
description: 'The name of the company account',
})
.min(2)
.max(50)
.refine(
(name) => {
console.log(name);
return !SPECIAL_CHARACTERS_REGEX.test(name);
},
{
message: 'teams:specialCharactersError',
},
)
.refine(
(name) => {
return !RESERVED_NAMES_ARRAY.includes(name.toLowerCase());
},
{
message: 'teams:reservedNameError',
},
);
/**
* @name CreateCompanySchema
* @description Schema for creating a team account
*/
export const CreateCompanySchema = z.object({
name: CompanyNameSchema,
});
export type CreateCompanySchemaType = z.infer<typeof CreateCompanySchema>;

View File

@@ -0,0 +1,45 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
export function createCreateTeamAccountService(
client: SupabaseClient<Database>,
) {
return new CreateTeamAccountService(client);
}
class CreateTeamAccountService {
private readonly namespace = 'accounts.create-team-account';
constructor(private readonly client: SupabaseClient<Database>) {}
async createNewOrganizationAccount(params: { name: string; userId: string }) {
const logger = await getLogger();
const ctx = { ...params, namespace: this.namespace };
logger.info(ctx, `Creating new company account...`);
const { error, data } = await this.client.rpc('create_team_account', {
account_name: params.name,
});
if (error) {
logger.error(
{
error,
...ctx,
},
`Error creating company account`,
);
throw new Error('Error creating company account');
}
logger.info(ctx, `Company account created successfully`);
return { data, error };
}
}