B2B-30: adds personal code to account, company admins invites members

This commit is contained in:
devmc-ee
2025-06-22 15:22:07 +03:00
parent 39c02c6d34
commit 251f2a4ef1
50 changed files with 3546 additions and 2611 deletions

View File

@@ -9,6 +9,7 @@
"typecheck": "tsc --noEmit"
},
"exports": {
".": "./src/index.ts",
"./api": "./src/server/api.ts",
"./components": "./src/components/index.ts",
"./hooks/*": "./src/hooks/*.ts",

View File

@@ -0,0 +1,32 @@
import { notFound } from 'next/navigation';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { isCompanyAdmin } from '../server/utils/is-company-admin';
import { isSuperAdmin } from '@kit/admin'
type LayoutOrPageComponent<Params> = React.ComponentType<Params>;
/**
* CompanyGuard is a server component wrapper that checks if the user is a company admin before rendering the component.
* If the user is not a company admin, we redirect to a 404.
* @param Component - The Page or Layout component to wrap
*/
export function CompanyGuard<Params extends object>(
Component: LayoutOrPageComponent<Params>,
) {
return async function AdminGuardServerComponentWrapper(params: Params) {
//@ts-ignore
const { account } = await params.params;
const client = getSupabaseServerClient();
const [isUserSuperAdmin, isUserCompanyAdmin] = await Promise.all(
[isSuperAdmin(client), isCompanyAdmin(client, account)]
);
console.log({ isUserSuperAdmin, isUserCompanyAdmin , params: account})
if (isUserSuperAdmin || isUserCompanyAdmin) {
return <Component {...params} />;
}
// if the user is not a company admin, we redirect to a 404
notFound();
};
}

View File

@@ -6,3 +6,4 @@ export * from './settings/team-account-settings-container';
export * from './invitations/accept-invitation-container';
export * from './create-team-account-dialog';
export * from './team-account-workspace-context';
export * from './company-guard';

View File

@@ -107,6 +107,14 @@ function useGetColumns(permissions: {
);
},
},
{
header: t('personalCode'),
cell: ({ row }) => {
const { personal_code } = row.original;
return personal_code;
},
},
{
header: t('roleLabel'),
cell: ({ row }) => {

View File

@@ -87,7 +87,8 @@ export function AccountMembersTable({
return (
displayName.includes(searchString) ||
member.role.toLowerCase().includes(searchString)
member.role.toLowerCase().includes(searchString) ||
(member.personal_code || '').includes(searchString)
);
})
.sort((prev, next) => {
@@ -160,6 +161,13 @@ function useGetColumns(
return row.original.email ?? '-';
},
},
{
header: t('personalCode'),
accessorKey: 'personal_code',
cell: ({ row }) => {
return row.original.personal_code ?? '-';
},
},
{
header: t('roleLabel'),
cell: ({ row }) => {

View File

@@ -66,7 +66,7 @@ export function InviteMembersDialogContainer({
<Dialog open={isOpen} onOpenChange={setIsOpen} modal>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
<DialogContent className="max-w-[800px]" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams:inviteMembersHeading'} />
@@ -142,13 +142,39 @@ function InviteMembersForm({
{fieldArray.fields.map((field, index) => {
const isFirst = index === 0;
const personalCodeInputName = `invitations.${index}.personal_code` as const;
const emailInputName = `invitations.${index}.email` as const;
const roleInputName = `invitations.${index}.role` as const;
return (
<div data-test={'invite-member-form-item'} key={field.id}>
<div className={'flex items-end gap-x-1 md:space-x-2'}>
<div className={'w-7/12'}>
<div className={'w-4/12'}>
<FormField
name={personalCodeInputName}
render={({ field }) => {
return (
<FormItem>
<If condition={isFirst}>
<FormLabel>{t('Personal code')}</FormLabel>
</If>
<FormControl>
<Input
placeholder={t('personalCode')}
type="text"
required
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className={'w-4/12'}>
<FormField
name={emailInputName}
render={({ field }) => {
@@ -273,5 +299,5 @@ function InviteMembersForm({
}
function createEmptyInviteModel() {
return { email: '', role: 'member' as Role };
return { email: '', role: 'member' as Role, personal_code: '' };
}

View File

@@ -0,0 +1 @@
export * from './server/utils/is-company-admin';

View File

@@ -3,6 +3,9 @@ import { z } from 'zod';
const InviteSchema = z.object({
email: z.string().email(),
role: z.string().min(1).max(100),
personal_code: z.string().regex(/^[1-6]\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}\d$/, {
message: 'Invalid Estonian personal code format',
}),
});
export const InviteMembersSchema = z

View File

@@ -16,6 +16,8 @@ import { RenewInvitationSchema } from '../../schema/renew-invitation.schema';
import { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
import { createAccountInvitationsService } from '../services/account-invitations.service';
import { createAccountPerSeatBillingService } from '../services/account-per-seat-billing.service';
import { createNotificationsApi } from '@kit/notifications/api';
import { getLogger } from '@kit/shared/logger';
/**
* @name createInvitationsAction
@@ -23,14 +25,55 @@ import { createAccountPerSeatBillingService } from '../services/account-per-seat
*/
export const createInvitationsAction = enhanceAction(
async (params) => {
const logger = await getLogger();
const client = getSupabaseServerClient();
const serviceClient = getSupabaseServerAdminClient();
// Create the service
const service = createAccountInvitationsService(client);
const api = createNotificationsApi(serviceClient);
// send invitations
await service.sendInvitations(params);
const { invitations: invitationParams, accountSlug } = params;
const personalCodes = invitationParams.map(({ personal_code }) => personal_code);
const { data: company, error: companyError } = await client
.from('accounts')
.select('id')
.eq('slug', accountSlug);
logger.debug({ company, companyError, personalCodes })
if (companyError || !company?.length || !company[0]) {
throw new Error(`Failed to fetch company id: ${companyError?.message || 'not found'}`);
}
const { data: invitations, error: invitationError } = await serviceClient.rpc(
'get_invitations_with_account_ids',
{
company_id: company[0].id,
personal_codes: personalCodes,
}
);
logger.debug({ invitations, invitationError })
if (invitationError) {
throw new Error(`Failed to fetch invitations with accounts: ${invitationError.message}`);
}
const notificationPromises = invitations
.map(({ invite_token, account_id }) =>
api.createNotification({
account_id: account_id!,
body: `You are invited to join the company: ${accountSlug}`,
link: `/join?invite_token=${invite_token}`,
})
);
await Promise.all(notificationPromises);
logger.info('All invitation notifications are sent')
revalidateMemberPage();
return {

View File

@@ -0,0 +1,24 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
/**
* @name isCompanyAdmin
* @description Check if the current user is a super admin.
* @param client
*/
export async function isCompanyAdmin(client: SupabaseClient<Database>, accountSlug: string) {
try {
const { data, error } = await client.rpc('is_company_admin', {
account_slug: accountSlug,
});
if (error) {
throw error;
}
return data;
} catch {
return false;
}
}