Merge branch 'main' into B2B-65

This commit is contained in:
Danel Kungla
2025-06-17 14:23:41 +03:00
52 changed files with 1458 additions and 415 deletions

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

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 { 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>
);
}

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -52,7 +52,7 @@ function getColumns(): ColumnDef<Memberships>[] {
{
header: 'Role',
cell: ({ row }) => {
return row.original.role;
return row.original.role === 'owner' ? 'HR' : 'Employee';
},
},
{

View File

@@ -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');
}

View File

@@ -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>;

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 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 };
}
}

View File

@@ -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,

View File

@@ -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": ""