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

@@ -10,7 +10,7 @@ import {
ChevronsUpDown,
Home,
LogOut,
MessageCircleQuestion,
UserCircle,
Shield,
} from 'lucide-react';
@@ -170,12 +170,12 @@ export function PersonalAccountDropdown({
<DropdownMenuItem asChild>
<Link
className={'s-full flex cursor-pointer items-center space-x-2'}
href={'/docs'}
href={'/home/settings'}
>
<MessageCircleQuestion className={'h-5'} />
<UserCircle className={'h-5'} />
<span>
<Trans i18nKey={'common:documentation'} />
<Trans i18nKey={'common:routes.profile'} />
</span>
</Link>
</DropdownMenuItem>

View File

@@ -47,23 +47,61 @@ class AccountsApi {
}
/**
* @name loadUserAccounts
* Load the user accounts.
*/
* @name loadUserAccounts
* Load only user-owned accounts (not just memberships).
*/
async loadUserAccounts() {
const authUser = await this.client.auth.getUser();
const {
data,
error: userError,
} = authUser
if (userError) {
throw userError;
}
const { user } = data;
const { data: accounts, error } = await this.client
.from('user_accounts')
.select(`name, slug, picture_url`);
.from('accounts_memberships')
.select(`
account_id,
user_accounts (
name,
slug,
picture_url
)
`)
.eq('user_id', user.id)
.eq('account_role', 'owner');
if (error) {
throw error;
}
return accounts.map(({ name, slug, picture_url }) => {
return accounts.map(({ user_accounts }) => ({
label: user_accounts.name,
value: user_accounts.slug,
image: user_accounts.picture_url,
}));
}
async loadTempUserAccounts() {
const { data: accounts, error } = await this.client
.from('user_accounts')
.select(`name, slug`);
if (error) {
throw error;
}
return accounts.map(({ name, slug }) => {
return {
label: name,
value: slug,
image: picture_url,
};
});
}

View File

@@ -28,6 +28,12 @@ import { AdminMembersTable } from './admin-members-table';
import { AdminMembershipsTable } from './admin-memberships-table';
import { AdminReactivateUserDialog } from './admin-reactivate-user-dialog';
import {
AccountInvitationsTable,
AccountMembersTable,
InviteMembersDialogContainer,
} from '@kit/team-accounts/components';
type Account = Tables<'accounts'>;
type Membership = Tables<'accounts_memberships'>;
@@ -146,8 +152,6 @@ async function PersonalAccountPage(props: { account: Account }) {
</div>
<div className={'flex flex-col gap-y-8'}>
<SubscriptionsTable accountId={props.account.id} />
<div className={'divider-divider-x flex flex-col gap-y-2.5'}>
<Heading level={6}>Companies</Heading>
@@ -213,7 +217,7 @@ async function TeamAccountPage(props: {
<div className={'flex flex-col gap-y-8'}>
<div className={'flex flex-col gap-y-2.5'}>
<Heading level={6}>Company Employees</Heading>
<Heading level={6}>Company Members</Heading>
<AdminMembersTable members={members} />
</div>

View File

@@ -179,6 +179,14 @@ function getColumns(): ColumnDef<Account>[] {
header: 'Email',
accessorKey: 'email',
},
{
id: 'personalCode',
header: 'Personal Code',
accessorKey: 'personalCode',
cell: ({ row }) => {
return row.original.personal_code ?? '-';
},
},
{
id: 'type',
header: 'Type',

View File

@@ -48,8 +48,9 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
email: '',
password: '',
emailConfirm: false,
personalCode: ''
},
mode: 'onChange',
mode: 'onBlur',
});
const onSubmit = (data: CreateUserSchemaType) => {
@@ -98,6 +99,25 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
</Alert>
</If>
<FormField
name={'personalCode'}
render={({ field }) => (
<FormItem>
<FormLabel>Personal code</FormLabel>
<FormControl>
<Input
required
type="text"
placeholder={'48506040199'}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name={'email'}
render={({ field }) => (

View File

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

View File

@@ -160,7 +160,7 @@ export const deleteAccountAction = adminAction(
*/
export const createUserAction = adminAction(
enhanceAction(
async ({ email, password, emailConfirm }) => {
async ({ email, password, emailConfirm, personalCode }) => {
const adminClient = getSupabaseServerAdminClient();
const logger = await getLogger();
@@ -182,6 +182,16 @@ export const createUserAction = adminAction(
`Super Admin has successfully created a new user`,
);
const { error: accountError } = await adminClient
.from('accounts')
.update({ personal_code: personalCode })
.eq('id', data.user.id);
if (accountError) {
logger.error({ accountError }, 'Error inserting personal code to accounts');
throw new Error(`Error saving personal code: ${accountError.message}`);
}
revalidateAdmin();
return {

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const CreateUserProfileSchema = z.object({
personalCode: 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 type CreateUserProfileSchemaType = z.infer<typeof CreateUserProfileSchema>;

View File

@@ -1,6 +1,9 @@
import { z } from 'zod';
export const CreateUserSchema = z.object({
personalCode: 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',
}),
email: z.string().email({ message: 'Please enter a valid email address' }),
password: z
.string()

View File

@@ -30,6 +30,7 @@ interface PasswordSignUpFormProps {
displayTermsCheckbox?: boolean;
onSubmit: (params: {
personalCode: string;
email: string;
password: string;
repeatPassword: string;
@@ -48,6 +49,7 @@ export function PasswordSignUpForm({
const form = useForm({
resolver: zodResolver(PasswordSignUpSchema),
defaultValues: {
personalCode: '',
email: defaultValues?.email ?? '',
password: '',
repeatPassword: '',
@@ -60,6 +62,29 @@ export function PasswordSignUpForm({
className={'flex w-full flex-col gap-y-4'}
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name={'personalCode'}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:personalCode'} />
</FormLabel>
<FormControl>
<Input
data-test={'personal-code-input'}
required
type="text"
placeholder={t('personalCodePlaceholder')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={'email'}

View File

@@ -11,6 +11,7 @@ import { Trans } from '@kit/ui/trans';
import { MagicLinkAuthContainer } from './magic-link-auth-container';
import { OauthProviders } from './oauth-providers';
import { EmailPasswordSignUpContainer } from './password-sign-up-container';
import { redirect } from 'next/navigation';
export function SignUpMethodsContainer(props: {
paths: {
@@ -41,6 +42,7 @@ export function SignUpMethodsContainer(props: {
emailRedirectTo={redirectUrl}
defaultValues={defaultValues}
displayTermsCheckbox={props.displayTermsCheckbox}
onSignUp={() => redirect(redirectUrl)}
/>
</If>

View File

@@ -8,6 +8,7 @@ import { useAppEvents } from '@kit/shared/events';
import { useSignUpWithEmailAndPassword } from '@kit/supabase/hooks/use-sign-up-with-email-password';
type SignUpCredentials = {
personalCode: string;
email: string;
password: string;
};
@@ -46,7 +47,6 @@ export function usePasswordSignUpFlow({
emailRedirectTo,
captchaToken,
});
// emit event to track sign up
appEvents.emit({
type: 'user.signedUp',

View File

@@ -4,6 +4,9 @@ import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema';
export const PasswordSignUpSchema = z
.object({
personalCode: 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',
}),
email: z.string().email(),
password: RefinedPasswordSchema,
repeatPassword: RefinedPasswordSchema,

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

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import { useMutation } from '@tanstack/react-query';
import { useSupabase } from './use-supabase';
interface Credentials {
personalCode: string;
email: string;
password: string;
emailRedirectTo: string;
@@ -14,13 +15,17 @@ export function useSignUpWithEmailAndPassword() {
const mutationKey = ['auth', 'sign-up-with-email-password'];
const mutationFn = async (params: Credentials) => {
const { emailRedirectTo, captchaToken, ...credentials } = params;
const { emailRedirectTo, captchaToken, personalCode, ...credentials } = params;
// TODO?: should be a validation of unique personal code before registration
const response = await client.auth.signUp({
...credentials,
options: {
emailRedirectTo,
captchaToken,
data: {
personalCode
}
},
});