Merge branch 'main' into B2B-65
This commit is contained in:
@@ -24,6 +24,7 @@ import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { CreateTeamAccountDialog } from '../../../team-accounts/src/components/create-team-account-dialog';
|
||||
import { usePersonalAccountData } from '../hooks/use-personal-account-data';
|
||||
import { useUserWorkspace } from '../hooks/use-user-workspace';
|
||||
|
||||
interface AccountSelectorProps {
|
||||
accounts: Array<{
|
||||
@@ -63,6 +64,7 @@ export function AccountSelector({
|
||||
const [isCreatingAccount, setIsCreatingAccount] = useState<boolean>(false);
|
||||
const { t } = useTranslation('teams');
|
||||
const personalData = usePersonalAccountData(userId);
|
||||
const { user } = useUserWorkspace();
|
||||
|
||||
const value = useMemo(() => {
|
||||
return selectedAccount ?? PERSONAL_ACCOUNT_SLUG;
|
||||
@@ -89,6 +91,16 @@ export function AccountSelector({
|
||||
<PersonIcon className="h-5 w-5" />
|
||||
);
|
||||
|
||||
const isSuperAdmin = useMemo(() => {
|
||||
const factors = user?.factors ?? [];
|
||||
const hasAdminRole = user?.app_metadata.role === 'super-admin';
|
||||
const hasTotpFactor = factors.some(
|
||||
(factor) => factor.factor_type === 'totp' && factor.status === 'verified',
|
||||
);
|
||||
|
||||
return hasAdminRole && hasTotpFactor;
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -172,7 +184,6 @@ export function AccountSelector({
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t('searchAccount')} className="h-9" />
|
||||
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
@@ -251,7 +262,7 @@ export function AccountSelector({
|
||||
|
||||
<Separator />
|
||||
|
||||
<If condition={features.enableTeamCreation}>
|
||||
<If condition={features.enableTeamCreation && isSuperAdmin}>
|
||||
<div className={'p-1'}>
|
||||
<Button
|
||||
data-test={'create-team-account-trigger'}
|
||||
@@ -274,7 +285,7 @@ export function AccountSelector({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<If condition={features.enableTeamCreation}>
|
||||
<If condition={features.enableTeamCreation && isSuperAdmin}>
|
||||
<CreateTeamAccountDialog
|
||||
isOpen={isCreatingAccount}
|
||||
setIsOpen={setIsCreatingAccount}
|
||||
|
||||
@@ -149,7 +149,7 @@ async function PersonalAccountPage(props: { account: Account }) {
|
||||
<SubscriptionsTable accountId={props.account.id} />
|
||||
|
||||
<div className={'divider-divider-x flex flex-col gap-y-2.5'}>
|
||||
<Heading level={6}>Teams</Heading>
|
||||
<Heading level={6}>Companies</Heading>
|
||||
|
||||
<div>
|
||||
<AdminMembershipsTable memberships={memberships} />
|
||||
@@ -205,16 +205,15 @@ async function TeamAccountPage(props: {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Badge variant={'outline'}>Team Account</Badge>
|
||||
<Badge variant={'outline'}>Company Account</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={'flex flex-col gap-y-8'}>
|
||||
<SubscriptionsTable accountId={props.account.id} />
|
||||
|
||||
<div className={'flex flex-col gap-y-2.5'}>
|
||||
<Heading level={6}>Team Members</Heading>
|
||||
<Heading level={6}>Company Employees</Heading>
|
||||
|
||||
<AdminMembersTable members={members} />
|
||||
</div>
|
||||
|
||||
@@ -132,7 +132,7 @@ function AccountsTableFilters(props: {
|
||||
<SelectLabel>Account Type</SelectLabel>
|
||||
|
||||
<SelectItem value={'all'}>All accounts</SelectItem>
|
||||
<SelectItem value={'team'}>Team</SelectItem>
|
||||
<SelectItem value={'team'}>Company</SelectItem>
|
||||
<SelectItem value={'personal'}>Personal</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
@@ -183,7 +183,7 @@ function getColumns(): ColumnDef<Account>[] {
|
||||
id: 'type',
|
||||
header: 'Type',
|
||||
cell: ({ row }) => {
|
||||
return row.original.is_personal_account ? 'Personal' : 'Team';
|
||||
return row.original.is_personal_account ? 'Personal' : 'Company';
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -248,7 +248,7 @@ function getColumns(): ColumnDef<Account>[] {
|
||||
<If condition={!isPersonalAccount}>
|
||||
<AdminDeleteAccountDialog accountId={row.original.id}>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
Delete Team Account
|
||||
Delete Company Account
|
||||
</DropdownMenuItem>
|
||||
</AdminDeleteAccountDialog>
|
||||
</If>
|
||||
|
||||
@@ -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 { createCompanyAccountAction } 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 createCompanyAccountAction(data);
|
||||
|
||||
if (!error) {
|
||||
toast.success('Company created 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>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
|
||||
const result = await createUserAction(data);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('User creates successfully');
|
||||
toast.success('User created successfully');
|
||||
form.reset();
|
||||
|
||||
setOpen(false);
|
||||
|
||||
@@ -36,10 +36,10 @@ export async function AdminDashboard() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Team Accounts</CardTitle>
|
||||
<CardTitle>Company Accounts</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
The number of team accounts that have been created.
|
||||
The number of company accounts that have been created.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -49,43 +49,6 @@ export async function AdminDashboard() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Paying Customers</CardTitle>
|
||||
<CardDescription>
|
||||
The number of paying customers with active subscriptions.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{data.subscriptions}</Figure>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Trials</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
The number of trial subscriptions currently active.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{data.trials}</Figure>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<p className={'text-muted-foreground w-max text-xs'}>
|
||||
The above data is estimated and may not be 100% accurate.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ function getColumns(): ColumnDef<Memberships>[] {
|
||||
{
|
||||
header: 'Role',
|
||||
cell: ({ row }) => {
|
||||
return row.original.role;
|
||||
return row.original.role === 'owner' ? 'HR' : 'Employee';
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -20,6 +20,8 @@ import { ResetPasswordSchema } from './schema/reset-password.schema';
|
||||
import { createAdminAccountsService } from './services/admin-accounts.service';
|
||||
import { createAdminAuthUserService } from './services/admin-auth-user.service';
|
||||
import { adminAction } from './utils/admin-action';
|
||||
import { CreateCompanySchema } from './schema/create-company.schema';
|
||||
import { createCreateCompanyAccountService } from './services/admin-create-company-account.service';
|
||||
|
||||
/**
|
||||
* @name banUserAction
|
||||
@@ -222,6 +224,42 @@ export const resetPasswordAction = adminAction(
|
||||
),
|
||||
);
|
||||
|
||||
export const createCompanyAccountAction = enhanceAction(
|
||||
async ({ name }, user) => {
|
||||
const logger = await getLogger();
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createCreateCompanyAccountService(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() {
|
||||
revalidatePath('/admin', 'layout');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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) => {
|
||||
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>;
|
||||
|
||||
@@ -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 createCreateCompanyAccountService(
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -38,9 +38,7 @@ export const createTeamAccountAction = enhanceAction(
|
||||
|
||||
logger.info(ctx, `Team account created`);
|
||||
|
||||
const accountHomePath = '/home/' + data.slug;
|
||||
|
||||
redirect(accountHomePath);
|
||||
redirect(`/home/${data.slug}/settings`);
|
||||
},
|
||||
{
|
||||
schema: CreateTeamSchema,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "./tailwind.config.ts",
|
||||
"css": "../../apps/web/styles/globals.css",
|
||||
"css": "../../styles/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
|
||||
Reference in New Issue
Block a user