From a39c21e4e7df472b122b3ca946088f039eda4be9 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 31 Jul 2025 12:27:30 +0300 Subject: [PATCH] fix company creation for admin and inviting of new employees --- .env | 3 + README.md | 14 + app/auth/verify/page.tsx | 12 +- lib/types/company.ts | 6 + .../components/personal-account-dropdown.tsx | 4 +- .../admin-create-company-dialog.tsx | 47 ++- .../src/lib/server/admin-server-actions.ts | 7 +- .../server/schema/create-company.schema.ts | 6 +- .../admin-create-company-account.service.ts | 8 +- .../multi-factor-challenge-container.tsx | 12 +- .../src/server/notifications.service.ts | 6 + .../team-invitations-server-actions.ts | 5 +- .../services/account-invitations.service.ts | 7 +- packages/supabase/src/database.types.ts | 57 +++- public/locales/et/teams.json | 3 +- ...20250730091700_fix_company_invitations.sql | 280 ++++++++++++++++++ .../20250730165200_create_team_account.sql | 67 +++++ ...0253107112400_invitation_email_webhook.sql | 9 + 18 files changed, 496 insertions(+), 57 deletions(-) create mode 100644 supabase/migrations/20250730091700_fix_company_invitations.sql create mode 100644 supabase/migrations/20250730165200_create_team_account.sql create mode 100644 supabase/migrations/20253107112400_invitation_email_webhook.sql diff --git a/.env b/.env index c515217..e7b866f 100644 --- a/.env +++ b/.env @@ -42,7 +42,10 @@ NEXT_PUBLIC_LANGUAGE_PRIORITY=application NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true NEXT_PUBLIC_REALTIME_NOTIFICATIONS=true +WEBHOOK_SENDER_PROVIDER=postgres + # MAILER DEV +MAILER_PROVIDER=nodemailer EMAIL_SENDER=info@medreport.ee EMAIL_USER= # refer to your email provider's documentation EMAIL_PASSWORD= # refer to your email provider's documentation diff --git a/README.md b/README.md index 4fba666..1a9499b 100644 --- a/README.md +++ b/README.md @@ -100,3 +100,17 @@ To access admin pages follow these steps: - [View emails](http://localhost:1080/#/) - Mail server is running on `localhost:1025` without password + +## Webhook + +Webhooks requires specific envs in supabase. Add the following in sql editor: + +```sql +alter system set webhook.invitations.secret = 'WEBHOOKSECRET'; +``` + +```sql +alter system set webhook.invitations.url = 'http://host.docker.internal:3000/api/db/webhook'; + +select pg_reload_conf(); +``` diff --git a/app/auth/verify/page.tsx b/app/auth/verify/page.tsx index 8d275bf..2123611 100644 --- a/app/auth/verify/page.tsx +++ b/app/auth/verify/page.tsx @@ -39,17 +39,7 @@ async function VerifyPage(props: Props) { redirect(pathsConfig.auth.signIn); } - const nextPath = (await props.searchParams).next; - const redirectPath = nextPath ?? pathsConfig.app.home; - - return ( - - ); + return ; } export default withI18n(VerifyPage); diff --git a/lib/types/company.ts b/lib/types/company.ts index 7fbc4d6..1230e67 100644 --- a/lib/types/company.ts +++ b/lib/types/company.ts @@ -4,3 +4,9 @@ export interface CompanySubmitData { email: string; phone?: string; } + +export interface CompanyInvitationData { + invitationToken: string; + email: string; + companyName: string; +} diff --git a/packages/features/accounts/src/components/personal-account-dropdown.tsx b/packages/features/accounts/src/components/personal-account-dropdown.tsx index 98812d5..933d099 100644 --- a/packages/features/accounts/src/components/personal-account-dropdown.tsx +++ b/packages/features/accounts/src/components/personal-account-dropdown.tsx @@ -163,9 +163,9 @@ export function PersonalAccountDropdown({ - - 0}> + + { return ( - + - - - + + + ); + }} + /> + + { + return ( + + + + + + + + diff --git a/packages/features/admin/src/lib/server/admin-server-actions.ts b/packages/features/admin/src/lib/server/admin-server-actions.ts index e648b0b..7903186 100644 --- a/packages/features/admin/src/lib/server/admin-server-actions.ts +++ b/packages/features/admin/src/lib/server/admin-server-actions.ts @@ -239,7 +239,7 @@ export const resetPasswordAction = adminAction( ); export const createCompanyAccountAction = enhanceAction( - async ({ name }, user) => { + async ({ name, ownerPersonalCode }, user) => { const logger = await getLogger(); const client = getSupabaseServerClient(); const service = createCreateCompanyAccountService(client); @@ -254,7 +254,7 @@ export const createCompanyAccountAction = enhanceAction( const { data, error } = await service.createNewOrganizationAccount({ name, - userId: user.id, + ownerPersonalCode, }); if (error) { @@ -266,8 +266,7 @@ export const createCompanyAccountAction = enhanceAction( } logger.info(ctx, `Company account created`); - - redirect(`/home/${data.slug}/settings`); + redirect(`/admin/accounts/${data.id}`); }, { schema: CreateCompanySchema, diff --git a/packages/features/admin/src/lib/server/schema/create-company.schema.ts b/packages/features/admin/src/lib/server/schema/create-company.schema.ts index 4fc0a04..54e40ea 100644 --- a/packages/features/admin/src/lib/server/schema/create-company.schema.ts +++ b/packages/features/admin/src/lib/server/schema/create-company.schema.ts @@ -46,7 +46,11 @@ export const CompanyNameSchema = z */ export const CreateCompanySchema = z.object({ name: CompanyNameSchema, + ownerPersonalCode: 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 CreateCompanySchemaType = z.infer; - diff --git a/packages/features/admin/src/lib/server/services/admin-create-company-account.service.ts b/packages/features/admin/src/lib/server/services/admin-create-company-account.service.ts index 566fe8c..0a9fe98 100644 --- a/packages/features/admin/src/lib/server/services/admin-create-company-account.service.ts +++ b/packages/features/admin/src/lib/server/services/admin-create-company-account.service.ts @@ -16,7 +16,10 @@ class CreateTeamAccountService { constructor(private readonly client: SupabaseClient) {} - async createNewOrganizationAccount(params: { name: string; userId: string }) { + async createNewOrganizationAccount(params: { + name: string; + ownerPersonalCode: string; + }) { const logger = await getLogger(); const ctx = { ...params, namespace: this.namespace }; @@ -26,12 +29,13 @@ class CreateTeamAccountService { .schema('medreport') .rpc('create_team_account', { account_name: params.name, + new_personal_code: params.ownerPersonalCode, }); if (error) { logger.error( { - error, + error: error.message, ...ctx, }, `Error creating company account`, diff --git a/packages/features/auth/src/components/multi-factor-challenge-container.tsx b/packages/features/auth/src/components/multi-factor-challenge-container.tsx index fb6f930..2de18df 100644 --- a/packages/features/auth/src/components/multi-factor-challenge-container.tsx +++ b/packages/features/auth/src/components/multi-factor-challenge-container.tsx @@ -34,20 +34,18 @@ import { import { Spinner } from '@kit/ui/spinner'; import { Trans } from '@kit/ui/trans'; +import pathsConfig from '~/config/paths.config'; + export function MultiFactorChallengeContainer({ - paths, userId, }: React.PropsWithChildren<{ userId: string; - paths: { - redirectPath: string; - }; }>) { const router = useRouter(); const verifyMFAChallenge = useVerifyMFAChallenge({ onSuccess: () => { - router.replace('/'); + router.replace(pathsConfig.app.home); }, }); @@ -206,6 +204,10 @@ function useVerifyMFAChallenge({ onSuccess }: { onSuccess: () => void }) { }); if (response.error) { + console.warn( + { error: response.error.message }, + 'Failed to verify MFA challenge', + ); throw response.error; } diff --git a/packages/features/notifications/src/server/notifications.service.ts b/packages/features/notifications/src/server/notifications.service.ts index 3bb8d4c..265a45b 100644 --- a/packages/features/notifications/src/server/notifications.service.ts +++ b/packages/features/notifications/src/server/notifications.service.ts @@ -2,6 +2,7 @@ import 'server-only'; import { SupabaseClient } from '@supabase/supabase-js'; +import { getLogger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; type Notification = Database['medreport']['Tables']['notifications']; @@ -14,12 +15,17 @@ class NotificationsService { constructor(private readonly client: SupabaseClient) {} async createNotification(params: Notification['Insert']) { + const logger = await getLogger(); const { error } = await this.client .schema('medreport') .from('notifications') .insert(params); if (error) { + logger.error( + { ...params }, + `Could not create notification: ${error.message}`, + ); throw error; } } diff --git a/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts b/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts index 74d69ce..4f11f93 100644 --- a/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts +++ b/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts @@ -54,8 +54,9 @@ export const createInvitationsAction = enhanceAction( ); } - const { data: invitations, error: invitationError } = - await serviceClient.rpc('get_invitations_with_account_ids', { + const { data: invitations, error: invitationError } = await serviceClient + .schema('medreport') + .rpc('get_invitations_with_account_ids', { company_id: company[0].id, personal_codes: personalCodes, }); diff --git a/packages/features/team-accounts/src/server/services/account-invitations.service.ts b/packages/features/team-accounts/src/server/services/account-invitations.service.ts index a118f19..ea18e78 100644 --- a/packages/features/team-accounts/src/server/services/account-invitations.service.ts +++ b/packages/features/team-accounts/src/server/services/account-invitations.service.ts @@ -51,7 +51,10 @@ class AccountInvitationsService { }); if (error) { - logger.error(ctx, `Failed to remove invitation`); + logger.error( + { ...ctx, error: error.message }, + `Failed to remove invitation`, + ); throw error; } @@ -184,7 +187,7 @@ class AccountInvitationsService { throw new Error('Account not found'); } - + console.log('property', invitations, accountSlug); const response = await this.client .schema('medreport') .rpc('add_invitations_to_account', { diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index 48906af..cec67c4 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -218,10 +218,24 @@ export type Database = { { foreignKeyName: "account_params_account_id_fkey" columns: ["account_id"] - isOneToOne: true + isOneToOne: false referencedRelation: "accounts" referencedColumns: ["id"] }, + { + foreignKeyName: "account_params_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "account_params_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, ] } accounts: { @@ -316,7 +330,13 @@ export type Database = { user_id?: string } Relationships: [ - + { + foreignKeyName: "accounts_memberships_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, { foreignKeyName: "accounts_memberships_account_id_fkey" columns: ["account_id"] @@ -932,6 +952,7 @@ export type Database = { id: number invite_token: string invited_by: string + personal_code: string | null role: string updated_at: string } @@ -943,6 +964,7 @@ export type Database = { id?: number invite_token: string invited_by: string + personal_code?: string | null role: string updated_at?: string } @@ -954,6 +976,7 @@ export type Database = { id?: number invite_token?: string invited_by?: string + personal_code?: string | null role?: string updated_at?: string } @@ -1543,7 +1566,7 @@ export type Database = { add_invitations_to_account: { Args: { account_slug: string - invitations: Database["public"]["CompositeTypes"]["invitation"][] + invitations: Database["medreport"]["CompositeTypes"]["invitation"][] } Returns: Database["medreport"]["Tables"]["invitations"]["Row"][] } @@ -1561,6 +1584,7 @@ export type Database = { id: number invite_token: string invited_by: string + personal_code: string | null role: string updated_at: string } @@ -1577,7 +1601,9 @@ export type Database = { Returns: Json } create_team_account: { - Args: { account_name: string } + Args: + | { account_name: string } + | { account_name: string; new_personal_code: string } Returns: { city: string | null created_at: string | null @@ -1609,6 +1635,7 @@ export type Database = { created_at: string updated_at: string expires_at: string + personal_code: string inviter_name: string inviter_email: string }[] @@ -1633,6 +1660,14 @@ export type Database = { Args: Record Returns: Json } + get_invitations_with_account_ids: { + Args: { company_id: string; personal_codes: string[] } + Returns: { + invite_token: string + personal_code: string + account_id: string + }[] + } get_nonce_status: { Args: { p_id: string } Returns: Json @@ -1846,7 +1881,11 @@ export type Database = { | "paused" } CompositeTypes: { - [_ in never]: never + invitation: { + email: string | null + role: string | null + personal_code: string | null + } } } public: { @@ -7531,14 +7570,6 @@ export type Database = { [_ in never]: never } Functions: { - get_invitations_with_account_ids: { - Args: { company_id: string; personal_codes: string[] } - Returns: { - invite_token: string - personal_code: string - account_id: string - }[] - } has_permission: { Args: { user_id: string diff --git a/public/locales/et/teams.json b/public/locales/et/teams.json index 9500fd3..3953e7f 100644 --- a/public/locales/et/teams.json +++ b/public/locales/et/teams.json @@ -160,5 +160,6 @@ "leaveTeamInputDescription": "By leaving the company, you will no longer have access to it.", "reservedNameError": "This name is reserved. Please choose a different one.", "specialCharactersError": "This name cannot contain special characters. Please choose a different one.", - "personalCode": "Isikukood" + "personalCode": "Isikukood", + "teamOwnerPersonalCodeLabel": "Omaniku isikukood" } diff --git a/supabase/migrations/20250730091700_fix_company_invitations.sql b/supabase/migrations/20250730091700_fix_company_invitations.sql new file mode 100644 index 0000000..ffaebb0 --- /dev/null +++ b/supabase/migrations/20250730091700_fix_company_invitations.sql @@ -0,0 +1,280 @@ +drop function if exists medreport.add_invitations_to_account(text, medreport.invitation[]); + +drop type if exists medreport.invitation; + +create type medreport.invitation as ( + email text, + role text, + personal_code char(11) +); + +create or replace function medreport.add_invitations_to_account ( + account_slug text, + invitations medreport.invitation[] +) returns medreport.invitations[] + set search_path = '' +as $$ +declare + new_invitation medreport.invitations; + all_invitations medreport.invitations[] := array[]::medreport.invitations[]; + invite_token text; + invite medreport.invitation; +begin + foreach invite in array invitations loop + invite_token := extensions.uuid_generate_v4(); + + insert into medreport.invitations ( + email, + account_id, + invited_by, + role, + invite_token, + personal_code + ) + values ( + invite.email, + (select id from medreport.accounts where slug = account_slug), + auth.uid(), + invite.role, + invite_token, + invite.personal_code + ) + returning * into new_invitation; + + all_invitations := array_append(all_invitations, new_invitation); + end loop; + + return all_invitations; +end; +$$ language plpgsql; + +grant execute on function medreport.add_invitations_to_account(text, medreport.invitation[]) to authenticated; + +grant + execute on function medreport.has_permission (uuid, uuid, medreport.app_permissions) to authenticated, + service_role; + +grant + execute on function medreport.has_more_elevated_role (uuid, uuid, varchar) to authenticated, + service_role; + +grant + execute on function medreport.has_same_role_hierarchy_level (uuid, uuid, varchar) to authenticated, + service_role; + +ALTER TABLE medreport.invitations ALTER COLUMN id DROP DEFAULT; +ALTER TABLE medreport.invitations DROP COLUMN id; +ALTER TABLE medreport.invitations ADD COLUMN id UUID NOT NULL DEFAULT gen_random_uuid(); +ALTER TABLE medreport.invitations ADD PRIMARY KEY (id); + +ALTER TABLE medreport.invitations DROP CONSTRAINT invitations_pkey; +ALTER TABLE medreport.invitations DROP COLUMN id; + +ALTER TABLE medreport.invitations ADD COLUMN id SERIAL PRIMARY KEY; + +DROP FUNCTION IF EXISTS medreport.get_account_invitations(account_slug text); + +create function medreport.get_account_invitations (account_slug text) +returns table ( + id integer, + email varchar(255), + account_id uuid, + invited_by uuid, + role varchar(50), + created_at timestamptz, + updated_at timestamptz, + expires_at timestamptz, + personal_code text, + inviter_name varchar, + inviter_email varchar +) +set search_path = '' +as $$ +begin + return query + select + invitation.id, + invitation.email, + invitation.account_id, + invitation.invited_by, + invitation.role, + invitation.created_at, + invitation.updated_at, + invitation.expires_at, + invitation.personal_code, + account.name, + account.email + from + medreport.invitations as invitation + join medreport.accounts as account on invitation.account_id = account.id + where + account.slug = account_slug; +end; +$$ language plpgsql; + +grant execute on function medreport.get_account_invitations(text) to authenticated, service_role; + + +drop policy if exists invitations_read_self on medreport.invitations; +drop policy if exists invitations_create_self on medreport.invitations; +drop policy if exists invitations_update on medreport.invitations; +drop policy if exists invitations_delete on medreport.invitations; +drop policy if exists super_admins_access_invitations on medreport.invitations; + + +create policy invitations_read_self on medreport.invitations for + select + to authenticated using (medreport.has_role_on_account (account_id)); + +create policy invitations_create_self on medreport.invitations for insert to authenticated + with + check ( + medreport.is_set ('enable_team_accounts') + and medreport.has_permission ( + ( + select + auth.uid () + ), + account_id, + 'invites.manage'::medreport.app_permissions + ) + and (medreport.has_more_elevated_role ( + ( + select + auth.uid () + ), + account_id, + role + ) or medreport.has_same_role_hierarchy_level( + ( + select + auth.uid () + ), + account_id, + role + )) + ); + +create policy invitations_update on medreport.invitations + for update + to authenticated using ( + medreport.has_permission ( + ( + select + auth.uid () + ), + account_id, + 'invites.manage'::medreport.app_permissions + ) + and medreport.has_more_elevated_role ( + ( + select + auth.uid () + ), + account_id, + role + ) + ) + with + check ( + medreport.has_permission ( + ( + select + auth.uid () + ), + account_id, + 'invites.manage'::medreport.app_permissions + ) + and medreport.has_more_elevated_role ( + ( + select + auth.uid () + ), + account_id, + role + ) + ); + +create policy invitations_delete on medreport.invitations for delete to authenticated using ( + medreport.has_role_on_account (account_id) + and medreport.has_permission ( + ( + select + auth.uid () + ), + account_id, + 'invites.manage'::medreport.app_permissions + ) + ); + +create policy super_admins_access_invitations + on medreport.invitations + as permissive + for select + to authenticated + using (medreport.is_super_admin()); + +INSERT INTO medreport.config DEFAULT VALUES; + +DROP FUNCTION public.get_invitations_with_account_ids( + company_id uuid, + personal_codes text[] +); + +ALTER TABLE "medreport"."invitations" ADD COLUMN IF NOT EXISTS personal_code text; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'accounts_personal_code_unique' + ) THEN + ALTER TABLE medreport.accounts + ADD CONSTRAINT accounts_personal_code_unique UNIQUE (personal_code); + END IF; +END$$; + +ALTER TABLE medreport.invitations +ALTER COLUMN personal_code TYPE text; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'invitations_personal_code_unique' + ) THEN + ALTER TABLE medreport.invitations + ADD CONSTRAINT invitations_personal_code_unique UNIQUE (personal_code); + END IF; +END$$; + +create or replace function medreport.get_invitations_with_account_ids( + company_id uuid, + personal_codes text[] +) +returns table ( + invite_token text, + personal_code text, + account_id uuid +) +language sql +as $$ + select + i.invite_token, + i.personal_code, + a.id as account_id + from medreport.invitations i + join medreport.accounts a on a.personal_code = i.personal_code + where i.account_id = company_id + and i.personal_code = any(personal_codes); +$$; + +grant + execute on function medreport.get_invitations_with_account_ids (uuid, text[]) to authenticated, + service_role; + +GRANT USAGE, SELECT ON SEQUENCE medreport.invitations_id_seq TO authenticated; + +DROP FUNCTION IF EXISTS public.has_permission(uuid, uuid, medreport.app_permissions); \ No newline at end of file diff --git a/supabase/migrations/20250730165200_create_team_account.sql b/supabase/migrations/20250730165200_create_team_account.sql new file mode 100644 index 0000000..0eec9b8 --- /dev/null +++ b/supabase/migrations/20250730165200_create_team_account.sql @@ -0,0 +1,67 @@ +DROP TRIGGER IF EXISTS add_current_user_to_new_account ON medreport.accounts; + +create or replace function medreport.create_team_account ( + account_name text, + new_personal_code text +) returns medreport.accounts + security definer + set search_path = '' +as $$ +declare + existing_account medreport.accounts; + current_user uuid := (select auth.uid())::uuid; + new_account medreport.accounts; +begin + if not medreport.is_set('enable_team_accounts') then + raise exception 'Team accounts are not enabled'; + end if; + + insert into medreport.accounts( + name, + is_personal_account) + values ( + account_name, + false) + returning + * into new_account; + + -- Try to find existing account + select * + into existing_account + from medreport.accounts + where personal_code = new_personal_code + limit 1; + + -- If not found, fail + if not found then + raise exception 'No account found with personal_code = %', new_personal_code; + end if; + + -- Insert membership + insert into medreport.accounts_memberships ( + user_id, + account_id, + account_role, + created_by, + updated_by, + created_at, + updated_at, + has_seen_confirmation + ) + values ( + existing_account.id, + new_account.id, + 'owner', + null, + null, + now(), + now(), + false + ) + on conflict do nothing; + + return new_account; +end; +$$ language plpgsql; + +grant execute on function medreport.create_team_account (text, text) to authenticated, service_role; diff --git a/supabase/migrations/20253107112400_invitation_email_webhook.sql b/supabase/migrations/20253107112400_invitation_email_webhook.sql new file mode 100644 index 0000000..b8d461c --- /dev/null +++ b/supabase/migrations/20253107112400_invitation_email_webhook.sql @@ -0,0 +1,9 @@ +create trigger "trigger_invitation_email" after insert +on "medreport"."invitations" for each row +execute function "supabase_functions"."http_request"( + 'http://host.docker.internal:3000/api/db/webhook', + 'POST', + '{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}', + '{}', + '1000' +); \ No newline at end of file