B2B-30: adds personal code to account, company admins invites members
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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';
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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: '' };
|
||||
}
|
||||
|
||||
1
packages/features/team-accounts/src/index.ts
Normal file
1
packages/features/team-accounts/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './server/utils/is-company-admin';
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user