chore: bump version to 2.23.2 and enhance team account creation (#440)

* chore: bump version to 2.23.2 and enhance team account creation

- Updated application version from 2.23.1 to 2.23.2 in package.json.
- Enhanced team account creation to support slugs for non-Latin names, including validation and UI updates.
- Updated localization files to reflect new slug requirements and error messages.
- Refactored related schemas and server actions to accommodate slug handling in team account creation and updates.

* refactor: remove old trigger and function for adding current user to new account

- Dropped the trigger "add_current_user_to_new_account" and the associated function from the database schema.
- Updated permissions for the function public.create_team_account to ensure proper access control.
This commit is contained in:
Giancarlo Buomprisco
2026-01-08 14:18:13 +01:00
committed by GitHub
parent e1bfbc8106
commit 0636f8cf11
21 changed files with 2042 additions and 1619 deletions

View File

@@ -91,13 +91,23 @@ export class TeamAccountsPageObject {
}).toPass(); }).toPass();
} }
async tryCreateTeam(teamName: string) { async tryCreateTeam(teamName: string, slug?: string) {
await this.page.locator('[data-test="create-team-form"] input').fill(''); const nameInput = this.page.locator(
await this.page.waitForTimeout(200); '[data-test="create-team-form"] [data-test="team-name-input"]',
);
await this.page await nameInput.fill('');
.locator('[data-test="create-team-form"] input') await nameInput.fill(teamName);
.fill(teamName);
// If slug is provided (for non-Latin names), fill the slug field
if (slug) {
const slugInput = this.page.locator(
'[data-test="create-team-form"] [data-test="team-slug-input"]',
);
await expect(slugInput).toBeVisible();
await slugInput.fill(slug);
}
return this.page.click('[data-test="create-team-form"] button:last-child'); return this.page.click('[data-test="create-team-form"] button:last-child');
} }
@@ -106,7 +116,14 @@ export class TeamAccountsPageObject {
await this.openAccountsSelector(); await this.openAccountsSelector();
await this.page.click('[data-test="create-team-account-trigger"]'); await this.page.click('[data-test="create-team-account-trigger"]');
await this.page.fill('[data-test="create-team-form"] input', teamName);
await this.page.fill(
'[data-test="create-team-form"] [data-test="team-name-input"]',
teamName,
);
// Slug field is only shown for non-Latin names, so we don't fill it for Latin names
// The database trigger will auto-generate the slug from the name
const click = this.page.click( const click = this.page.click(
'[data-test="create-team-form"] button:last-child', '[data-test="create-team-form"] button:last-child',
@@ -115,23 +132,77 @@ export class TeamAccountsPageObject {
const response = this.page.waitForURL(`/home/${slug}`); const response = this.page.waitForURL(`/home/${slug}`);
await Promise.all([click, response]); await Promise.all([click, response]);
// Verify user landed on the team page
await expect(this.page).toHaveURL(`/home/${slug}`);
// Verify the team was created and appears in the selector
await this.openAccountsSelector();
await expect(this.getTeamFromSelector(teamName)).toBeVisible();
// Close the selector
await this.page.keyboard.press('Escape');
} }
async updateName(name: string, slug: string) { async createTeamWithNonLatinName(teamName: string, slug: string) {
await this.openAccountsSelector();
await this.page.click('[data-test="create-team-account-trigger"]');
await this.page.fill(
'[data-test="create-team-form"] [data-test="team-name-input"]',
teamName,
);
// Wait for slug field to appear (triggered by non-Latin name)
await expect(this.getSlugField()).toBeVisible();
await this.page.fill(
'[data-test="create-team-form"] [data-test="team-slug-input"]',
slug,
);
const click = this.page.click(
'[data-test="create-team-form"] button:last-child',
);
const response = this.page.waitForURL(`/home/${slug}`);
await Promise.all([click, response]);
// Verify user landed on the team page
await expect(this.page).toHaveURL(`/home/${slug}`);
// Verify the team was created and appears in the selector
await this.openAccountsSelector();
await expect(this.getTeamFromSelector(teamName)).toBeVisible();
// Close the selector
await this.page.keyboard.press('Escape');
}
getSlugField() {
return this.page.locator(
'[data-test="create-team-form"] [data-test="team-slug-input"]',
);
}
async updateTeamName(name: string) {
await expect(async () => { await expect(async () => {
await this.page.fill( await this.page.fill(
'[data-test="update-team-account-name-form"] input', '[data-test="update-team-account-name-form"] input',
name, name,
); );
const click = this.page.click( await Promise.all([
'[data-test="update-team-account-name-form"] button', this.page.click('[data-test="update-team-account-name-form"] button'),
this.page.waitForResponse((response) => {
return (
response.url().includes('settings') &&
response.request().method() === 'POST'
); );
}),
// the slug should be updated to match the new team name ]);
const response = this.page.waitForURL(`**/home/${slug}/settings`);
return Promise.all([click, response]);
}).toPass(); }).toPass();
} }
@@ -165,8 +236,11 @@ export class TeamAccountsPageObject {
await this.page.click(`[data-test="role-option-${newRole}"]`); await this.page.click(`[data-test="role-option-${newRole}"]`);
// Wait for the update to complete and page to reload // Wait for the update to complete and page to reload
const response = this.page.waitForResponse(response => { const response = this.page.waitForResponse((response) => {
return response.url().includes('members') && response.request().method() === 'POST' return (
response.url().includes('members') &&
response.request().method() === 'POST'
);
}); });
return Promise.all([ return Promise.all([

View File

@@ -65,22 +65,20 @@ test.describe('Team Accounts', () => {
await teamAccounts.setup(); await teamAccounts.setup();
}); });
test('user can update their team name (and slug)', async ({ page }) => { test('user can update their team name', async ({ page }) => {
const teamAccounts = new TeamAccountsPageObject(page); const teamAccounts = new TeamAccountsPageObject(page);
const { teamName, slug } = teamAccounts.createTeamName(); const newTeamName = `Updated-Team-${(Math.random() * 100000000).toFixed(0)}`;
await teamAccounts.goToSettings(); await teamAccounts.goToSettings();
const request = teamAccounts.updateName(teamName, slug); // Update just the name (slug stays the same for Latin names)
await teamAccounts.updateTeamName(newTeamName);
// the slug should be updated to match the new team name await page.waitForTimeout(500);
const newUrl = page.waitForURL(`**/home/${slug}/settings`);
await Promise.all([request, newUrl]);
await teamAccounts.openAccountsSelector(); await teamAccounts.openAccountsSelector();
await expect(teamAccounts.getTeamFromSelector(teamName)).toBeVisible(); await expect(teamAccounts.getTeamFromSelector(newTeamName)).toBeVisible();
}); });
test('cannot create a Team account using reserved names', async ({ test('cannot create a Team account using reserved names', async ({
@@ -176,54 +174,73 @@ test.describe('Team Accounts', () => {
await expectError(); await expectError();
}); });
test('cannot create a Team account using non-latin characters', async ({ test('can create a Team account with non-Latin name when providing a slug', async ({
page, page,
}) => { }) => {
const teamAccounts = new TeamAccountsPageObject(page); const teamAccounts = new TeamAccountsPageObject(page);
await teamAccounts.createTeam(); await teamAccounts.createTeam();
const random = (Math.random() * 100000000).toFixed(0);
const slug = `korean-team-${random}`;
// Create team with Korean name
await teamAccounts.createTeamWithNonLatinName('한국 팀', slug);
// Verify we're on the team page
await expect(page).toHaveURL(`/home/${slug}`);
// Verify team appears in selector
await teamAccounts.openAccountsSelector();
await expect(teamAccounts.getTeamFromSelector('한국 팀')).toBeVisible();
});
test('slug validation shows error for invalid characters', async ({
page,
}) => {
const teamAccounts = new TeamAccountsPageObject(page);
await teamAccounts.createTeam();
// Use non-Latin name to trigger the slug field visibility
await teamAccounts.openAccountsSelector(); await teamAccounts.openAccountsSelector();
await page.click('[data-test="create-team-account-trigger"]'); await page.click('[data-test="create-team-account-trigger"]');
function expectNonLatinError() { await page.fill(
return expect( '[data-test="create-team-form"] [data-test="team-name-input"]',
page.getByText( 'テストチーム',
'This name can only contain Latin characters (a-z), numbers, spaces, and hyphens.', );
),
).toBeVisible();
}
// Test Cyrillic characters // Wait for slug field to appear (triggered by non-Latin name)
await teamAccounts.tryCreateTeam('Тест Команда'); await expect(teamAccounts.getSlugField()).toBeVisible();
await expectNonLatinError();
// Test Chinese characters // Test invalid slug with uppercase
await teamAccounts.tryCreateTeam('测试团队'); await page.fill(
await expectNonLatinError(); '[data-test="create-team-form"] [data-test="team-slug-input"]',
'Invalid-Slug',
);
// Test Japanese characters await page.click('[data-test="create-team-form"] button:last-child');
await teamAccounts.tryCreateTeam('テストチーム');
await expectNonLatinError();
// Test Arabic characters
await teamAccounts.tryCreateTeam('فريق اختبار');
await expectNonLatinError();
// Test mixed Latin and non-Latin
await teamAccounts.tryCreateTeam('Test Команда');
await expectNonLatinError();
// Test emoji
await teamAccounts.tryCreateTeam('Test Team 🚀');
await expectNonLatinError();
// Ensure valid Latin names still work (should NOT show error)
await teamAccounts.tryCreateTeam('Valid Team Name 123');
await expect( await expect(
page.getByText( page.getByText(
'This name can only contain Latin characters (a-z), numbers, spaces, and hyphens.', 'Only English letters (a-z), numbers (0-9), and hyphens (-) are allowed',
{ exact: true },
), ),
).not.toBeVisible(); ).toBeVisible();
// Test invalid slug with non-Latin characters
await page.fill(
'[data-test="create-team-form"] [data-test="team-slug-input"]',
'тест-slug',
);
await page.click('[data-test="create-team-form"] button:last-child');
await expect(
page.getByText(
'Only English letters (a-z), numbers (0-9), and hyphens (-) are allowed',
{ exact: true },
),
).toBeVisible();
}); });
}); });

View File

@@ -743,7 +743,7 @@ export type Database = {
Returns: Json Returns: Json
} }
create_team_account: { create_team_account: {
Args: { account_name: string } Args: { account_name: string; account_slug?: string }
Returns: { Returns: {
created_at: string | null created_at: string | null
created_by: string | null created_by: string | null

View File

@@ -160,7 +160,11 @@
"leaveTeamInputDescription": "By leaving the team, you will no longer have access to it.", "leaveTeamInputDescription": "By leaving the team, you will no longer have access to it.",
"reservedNameError": "This name is reserved. Please choose a different one.", "reservedNameError": "This name is reserved. Please choose a different one.",
"specialCharactersError": "This name cannot contain special characters. Please choose a different one.", "specialCharactersError": "This name cannot contain special characters. Please choose a different one.",
"nonLatinCharactersError": "This name can only contain Latin characters (a-z), numbers, spaces, and hyphens.", "teamSlugLabel": "Team URL",
"teamSlugDescription": "Only English letters (a-z), numbers (0-9), and hyphens (-) are allowed. Example: my-team-name",
"slugRequiredForNonLatinName": "Since your team name uses non-English characters, please provide a URL using only English letters",
"invalidSlugError": "Only English letters (a-z), numbers (0-9), and hyphens (-) are allowed",
"duplicateSlugError": "This URL is already taken. Please choose a different one.",
"checkingPolicies": "Loading. Please wait...", "checkingPolicies": "Loading. Please wait...",
"policyCheckError": "We are unable to verify invitations restrictions. Please try again.", "policyCheckError": "We are unable to verify invitations restrictions. Please try again.",
"invitationsBlockedMultiple": "Invitations are currently not allowed for the following reasons:", "invitationsBlockedMultiple": "Invitations are currently not allowed for the following reasons:",

View File

@@ -0,0 +1,67 @@
drop policy "create_org_account" on "public"."accounts";
drop function if exists "public"."create_team_account"(text);
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.create_team_account(account_name text, user_id uuid, account_slug text DEFAULT NULL::text)
RETURNS public.accounts
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO ''
AS $function$
declare
new_account public.accounts;
owner_role varchar(50);
begin
if (not public.is_set('enable_team_accounts')) then
raise exception 'Team accounts are not enabled';
end if;
-- Get the highest system role for the owner
select public.get_upper_system_role() into owner_role;
-- Insert the new team account
-- The slug will be auto-generated from name by the "set_slug_from_account_name"
-- trigger if account_slug is null
insert into public.accounts(
name,
slug,
is_personal_account,
primary_owner_user_id)
values (
account_name,
account_slug,
false,
user_id)
returning * into new_account;
-- Create membership for the owner (atomic with account creation)
insert into public.accounts_memberships(
account_id,
user_id,
account_role)
values (
new_account.id,
user_id,
coalesce(owner_role, 'owner'));
return new_account;
end;
$function$
;
-- Revoke from all roles first to ensure exclusivity
revoke all on function public.create_team_account(text, uuid, text) from public;
revoke all on function public.create_team_account(text, uuid, text) from authenticated;
-- Grant only to service_role
grant execute on function public.create_team_account(text, uuid, text) to service_role;
-- Drop trigger (handled by the new function)
drop trigger if exists "add_current_user_to_new_account" on "public"."accounts";
drop function if exists "kit"."add_current_user_to_new_account"();

View File

@@ -223,37 +223,6 @@ $$ language plpgsql;
grant grant
execute on function public.get_upper_system_role () to service_role; execute on function public.get_upper_system_role () to service_role;
-- Function "kit.add_current_user_to_new_account"
-- Trigger to add the current user to a new account as the primary owner
create
or replace function kit.add_current_user_to_new_account () returns trigger language plpgsql security definer
set
search_path = '' as $$
begin
if new.primary_owner_user_id = auth.uid() then
insert into public.accounts_memberships(
account_id,
user_id,
account_role)
values(
new.id,
auth.uid(),
public.get_upper_system_role());
end if;
return NEW;
end;
$$;
-- trigger the function whenever a new account is created
create trigger "add_current_user_to_new_account"
after insert on public.accounts for each row
when (new.is_personal_account = false)
execute function kit.add_current_user_to_new_account ();
-- create a trigger to update the account email when the primary owner email is updated -- create a trigger to update the account email when the primary owner email is updated
create create
or replace function kit.handle_update_user_email () returns trigger language plpgsql security definer or replace function kit.handle_update_user_email () returns trigger language plpgsql security definer
@@ -470,36 +439,62 @@ execute procedure kit.setup_new_user ();
* ------------------------------------------------------- * -------------------------------------------------------
*/ */
-- Function "public.create_team_account" -- Function "public.create_team_account"
-- Create a team account if team accounts are enabled -- Create a team account with membership in a single transaction
-- Called by service_role only (Policies API enforced in application layer)
create create
or replace function public.create_team_account (account_name text) returns public.accounts or replace function public.create_team_account (
account_name text,
user_id uuid,
account_slug text default null
) returns public.accounts
language plpgsql
security definer
set set
search_path = '' as $$ search_path = '' as $$
declare declare
new_account public.accounts; new_account public.accounts;
owner_role varchar(50);
begin begin
if (not public.is_set('enable_team_accounts')) then if (not public.is_set('enable_team_accounts')) then
raise exception 'Team accounts are not enabled'; raise exception 'Team accounts are not enabled';
end if; end if;
-- Get the highest system role for the owner
select public.get_upper_system_role() into owner_role;
-- Insert the new team account
-- The slug will be auto-generated from name by the "set_slug_from_account_name"
-- trigger if account_slug is null
insert into public.accounts( insert into public.accounts(
name, name,
is_personal_account) slug,
is_personal_account,
primary_owner_user_id)
values ( values (
account_name, account_name,
false) account_slug,
returning false,
* into new_account; user_id)
returning * into new_account;
-- Create membership for the owner (atomic with account creation)
insert into public.accounts_memberships(
account_id,
user_id,
account_role)
values (
new_account.id,
user_id,
coalesce(owner_role, 'owner'));
return new_account; return new_account;
end; end;
$$ language plpgsql; $$;
grant grant
execute on function public.create_team_account (text) to authenticated, execute on function public.create_team_account (text, uuid, text) to service_role;
service_role;
-- RLS(public.accounts) -- RLS(public.accounts)
-- Authenticated users can delete team accounts -- Authenticated users can delete team accounts

View File

@@ -9,12 +9,14 @@ select tests.create_supabase_user('test1', 'test1@test.com');
select tests.create_supabase_user('test2'); select tests.create_supabase_user('test2');
-- Create an team account -- Create team account using service_role (function is now service_role only)
set local role service_role;
select public.create_team_account('Test', tests.get_supabase_uid('test1'));
-- Switch back to authenticated user for testing
select makerkit.authenticate_as('test1'); select makerkit.authenticate_as('test1');
select public.create_team_account('Test');
-- the owner account has permissions to manage members -- the owner account has permissions to manage members
select row_eq( select row_eq(
$$ select public.has_permission( $$ select public.has_permission(

View File

@@ -9,14 +9,16 @@ select tests.create_supabase_user('test1', 'test1@test.com');
select tests.create_supabase_user('test2'); select tests.create_supabase_user('test2');
-- Create an team account -- Create team accounts using service_role (function is now service_role only)
set local role service_role;
select public.create_team_account('Test', tests.get_supabase_uid('test1'));
select public.create_team_account('Test', tests.get_supabase_uid('test1'));
select public.create_team_account('Test', tests.get_supabase_uid('test1'));
-- Switch back to authenticated user for testing
select makerkit.authenticate_as('test1'); select makerkit.authenticate_as('test1');
select public.create_team_account('Test');
select public.create_team_account('Test');
select public.create_team_account('Test');
-- should automatically create slugs for the accounts -- should automatically create slugs for the accounts
select row_eq( select row_eq(
$$ select slug from public.accounts where name = 'Test' and slug = 'test' $$, $$ select slug from public.accounts where name = 'Test' and slug = 'test' $$,

View File

@@ -12,12 +12,19 @@ select
select select
tests.create_supabase_user('test2'); tests.create_supabase_user('test2');
-- Create an team account -- Create an team account (without explicit slug, should auto-generate)
select -- DON'T authenticate first - the add_current_user_to_new_account trigger
makerkit.authenticate_as('test1'); -- would also create a membership if auth.uid() = primary_owner_user_id
-- The function already creates the membership, so we avoid duplicate by keeping auth.uid() NULL
set local role service_role;
select select
public.create_team_account('Test'); public.create_team_account('Test', tests.get_supabase_uid('test1'));
-- Reset to postgres and then authenticate as test1 for proper RLS context
set local role postgres;
select
makerkit.authenticate_as('test1');
select select
row_eq($$ row_eq($$
@@ -28,6 +35,44 @@ select
'test'::text, 'Test'::varchar), 'test'::text, 'Test'::varchar),
'Users can create a team account'); 'Users can create a team account');
-- Test creating team account with explicit slug parameter
select
tests.create_supabase_user('slugtest1', 'slugtest1@test.com');
-- Switch to service_role to call the function
set local role service_role;
select
public.create_team_account('Custom Team Name', tests.get_supabase_uid('slugtest1'), 'custom-slug-123');
-- Switch back to authenticated user for testing
select
makerkit.authenticate_as('slugtest1');
select
row_eq($$
select
primary_owner_user_id, is_personal_account, slug, name
from makerkit.get_account_by_slug('custom-slug-123') $$,
row (tests.get_supabase_uid('slugtest1'), false,
'custom-slug-123'::text, 'Custom Team Name'::varchar),
'Users can create a team account with custom slug');
-- Verify membership is created for custom slug team
select
row_eq($$
select
account_role from public.accounts_memberships
where
account_id = (select id from public.accounts where slug = 'custom-slug-123')
and user_id = tests.get_supabase_uid('slugtest1')
$$, row ('owner'::varchar),
'The primary owner should have the owner role for team with custom slug');
-- Switch back to test1 for testing the original 'test' account
select
makerkit.authenticate_as('test1');
-- Should be the primary owner of the team account by default -- Should be the primary owner of the team account by default
select select
row_eq($$ row_eq($$
@@ -106,12 +151,13 @@ create or replace function kit.single_account_per_owner()
declare declare
total_accounts int; total_accounts int;
begin begin
-- Check if this user already owns an account by checking NEW.primary_owner_user_id
select select
count(id) count(id)
from from
public.accounts public.accounts
where where
primary_owner_user_id = auth.uid() into total_accounts; primary_owner_user_id = NEW.primary_owner_user_id into total_accounts;
if total_accounts > 0 then if total_accounts > 0 then
raise exception 'User can only own 1 account'; raise exception 'User can only own 1 account';
@@ -129,14 +175,13 @@ create trigger single_account_per_owner
before insert on public.accounts for each row before insert on public.accounts for each row
execute function kit.single_account_per_owner(); execute function kit.single_account_per_owner();
-- Create an team account -- Try to create another team account for the same owner (should fail due to trigger)
select set local role service_role;
makerkit.authenticate_as('test1');
select select
throws_ok( throws_ok(
$$ select $$ select
public.create_team_account('Test2') $$, 'User can only own 1 account'); public.create_team_account('Test2', tests.get_supabase_uid('test1')) $$, 'User can only own 1 account');
set local role postgres; set local role postgres;
@@ -151,11 +196,10 @@ select
tests.create_supabase_user('updatetest2', 'updatetest2@test.com'); tests.create_supabase_user('updatetest2', 'updatetest2@test.com');
-- Create a team account for update tests -- Create a team account for update tests
select set local role service_role;
makerkit.authenticate_as('updatetest1');
select select
public.create_team_account('UpdateTeam'); public.create_team_account('UpdateTeam', tests.get_supabase_uid('updatetest1'));
-- Add updatetest2 as a member -- Add updatetest2 as a member
set local role postgres; set local role postgres;
@@ -259,11 +303,10 @@ select
tests.create_supabase_user('roletest2', 'roletest2@test.com'); tests.create_supabase_user('roletest2', 'roletest2@test.com');
-- Create a team account for role tests -- Create a team account for role tests
select set local role service_role;
makerkit.authenticate_as('roletest1');
select select
public.create_team_account('RoleTeam'); public.create_team_account('RoleTeam', tests.get_supabase_uid('roletest1'));
-- Add roletest2 as a member -- Add roletest2 as a member
set local role postgres; set local role postgres;
@@ -333,11 +376,10 @@ select
tests.create_supabase_user('deletetest2', 'deletetest2@test.com'); tests.create_supabase_user('deletetest2', 'deletetest2@test.com');
-- Create a team account for delete tests -- Create a team account for delete tests
select set local role service_role;
makerkit.authenticate_as('deletetest1');
select select
public.create_team_account('DeleteTeam'); public.create_team_account('DeleteTeam', tests.get_supabase_uid('deletetest1'));
-- Add deletetest2 as a member -- Add deletetest2 as a member
set local role postgres; set local role postgres;
@@ -383,8 +425,8 @@ select tests.create_supabase_user('permtest2', 'permtest2@test.com');
select tests.create_supabase_user('permtest3', 'permtest3@test.com'); select tests.create_supabase_user('permtest3', 'permtest3@test.com');
-- Create a team account for permission tests -- Create a team account for permission tests
select makerkit.authenticate_as('permtest1'); set local role service_role;
select public.create_team_account('PermTeam'); select public.create_team_account('PermTeam', tests.get_supabase_uid('permtest1'));
-- Get the account ID for PermTeam to avoid NULL references -- Get the account ID for PermTeam to avoid NULL references
set local role postgres; set local role postgres;
@@ -470,8 +512,8 @@ select tests.create_supabase_user('hiertest3', 'hiertest3@test.com');
select tests.create_supabase_user('hiertest4', 'hiertest4@test.com'); select tests.create_supabase_user('hiertest4', 'hiertest4@test.com');
-- Create a team account for hierarchy tests -- Create a team account for hierarchy tests
select makerkit.authenticate_as('hiertest1'); set local role service_role;
select public.create_team_account('HierTeam'); select public.create_team_account('HierTeam', tests.get_supabase_uid('hiertest1'));
-- Add users with different roles -- Add users with different roles
set local role postgres; set local role postgres;
@@ -540,8 +582,8 @@ select tests.create_supabase_user('vistest2', 'vistest2@test.com');
select tests.create_supabase_user('vistest3', 'vistest3@test.com'); select tests.create_supabase_user('vistest3', 'vistest3@test.com');
-- Create a team account -- Create a team account
select makerkit.authenticate_as('vistest1'); set local role service_role;
select public.create_team_account('VisTeam'); select public.create_team_account('VisTeam', tests.get_supabase_uid('vistest1'));
-- Add vistest2 as a member -- Add vistest2 as a member
set local role postgres; set local role postgres;
@@ -578,8 +620,8 @@ select tests.create_supabase_user('functest1', 'functest1@test.com');
select tests.create_supabase_user('functest2', 'functest2@test.com'); select tests.create_supabase_user('functest2', 'functest2@test.com');
-- Create team account -- Create team account
select makerkit.authenticate_as('functest1'); set local role service_role;
select public.create_team_account('FuncTeam'); select public.create_team_account('FuncTeam', tests.get_supabase_uid('functest1'));
-- Test: get_account_members function properly restricts data -- Test: get_account_members function properly restricts data
select makerkit.authenticate_as('functest2'); select makerkit.authenticate_as('functest2');
@@ -619,10 +661,11 @@ select tests.create_supabase_user('ownerupdate1', 'ownerupdate1@test.com');
select tests.create_supabase_user('ownerupdate2', 'ownerupdate2@test.com'); select tests.create_supabase_user('ownerupdate2', 'ownerupdate2@test.com');
-- Create team account -- Create team account
select makerkit.authenticate_as('ownerupdate1'); set local role service_role;
select public.create_team_account('TeamChange'); select public.create_team_account('TeamChange', tests.get_supabase_uid('ownerupdate1'));
-- Update the team name as the owner -- Update the team name as the owner
select makerkit.authenticate_as('ownerupdate1');
select lives_ok( select lives_ok(
$$ UPDATE public.accounts $$ UPDATE public.accounts
SET name = 'Updated Owner Team' SET name = 'Updated Owner Team'
@@ -668,23 +711,16 @@ select
tests.create_supabase_user('crosstest2', 'crosstest2@test.com'); tests.create_supabase_user('crosstest2', 'crosstest2@test.com');
-- Create first team account with crosstest1 as owner -- Create first team account with crosstest1 as owner
select set local role service_role;
makerkit.authenticate_as('crosstest1');
select select
public.create_team_account('TeamA'); public.create_team_account('TeamA', tests.get_supabase_uid('crosstest1'));
-- Create second team account with crosstest2 as owner -- Create second team account with crosstest2 as owner
select select
makerkit.authenticate_as('crosstest2'); public.create_team_account('TeamB', tests.get_supabase_uid('crosstest2'));
select
public.create_team_account('TeamB');
-- Add crosstest2 as a member to TeamA -- Add crosstest2 as a member to TeamA
select
makerkit.authenticate_as('crosstest1');
set local role postgres; set local role postgres;
-- Add member to first team -- Add member to first team
@@ -767,6 +803,31 @@ select
'TeamB name should remain unchanged after attempted update by non-member' 'TeamB name should remain unchanged after attempted update by non-member'
); );
-- Test 7: Security - Public/anon role cannot execute create_team_account
select
tests.create_supabase_user('securitytest1', 'securitytest1@test.com');
-- Test as anon role (public) - should get permission denied (either for schema or function)
set local role anon;
select
throws_ok(
$$ select public.create_team_account('SecurityTeam', tests.get_supabase_uid('securitytest1')) $$,
'permission denied for schema public',
'Anonymous/public role should not be able to execute create_team_account'
);
-- Test as authenticated role (still should fail - only service_role is allowed)
select
makerkit.authenticate_as('securitytest1');
select
throws_ok(
$$ select public.create_team_account('SecurityTeam', tests.get_supabase_uid('securitytest1')) $$,
'permission denied for function create_team_account',
'Authenticated role should not be able to execute create_team_account directly'
);
select select
* *
from from

View File

@@ -9,15 +9,15 @@ select plan(12);
--- Create test users --- Create test users
select tests.create_supabase_user('trigger_test_user1', 'test1@example.com'); select tests.create_supabase_user('trigger_test_user1', 'test1@example.com');
-- Authenticate as test user
select makerkit.authenticate_as('trigger_test_user1');
------------ ------------
--- Test accounts table timestamp triggers - INSERT --- Test accounts table timestamp triggers - INSERT
------------ ------------
INSERT INTO public.accounts (name, is_personal_account) -- Use service_role to insert (create_org_account policy was removed)
VALUES ('Test Account', false); set role service_role;
INSERT INTO public.accounts (name, is_personal_account, primary_owner_user_id)
VALUES ('Test Account', false, tests.get_supabase_uid('trigger_test_user1'));
SELECT ok( SELECT ok(
(SELECT created_at IS NOT NULL FROM public.accounts WHERE name = 'Test Account'), (SELECT created_at IS NOT NULL FROM public.accounts WHERE name = 'Test Account'),
@@ -38,9 +38,9 @@ SELECT ok(
--- Test invitations table timestamp triggers - INSERT --- Test invitations table timestamp triggers - INSERT
------------ ------------
-- Create a team account for invitation testing -- Create a team account for invitation testing (still in service_role from above)
INSERT INTO public.accounts (name, is_personal_account) INSERT INTO public.accounts (name, is_personal_account, primary_owner_user_id)
VALUES ('Invitation Test Team', false); VALUES ('Invitation Test Team', false, tests.get_supabase_uid('trigger_test_user1'));
-- Switch to service_role to insert invitations (INSERT policy removed, handled by server action) -- Switch to service_role to insert invitations (INSERT policy removed, handled by server action)
set role service_role; set role service_role;
@@ -56,9 +56,7 @@ VALUES (
now() + interval '7 days' now() + interval '7 days'
); );
-- Switch back to authenticated user for assertion -- Stay in service_role for assertions (testing triggers, not RLS)
select makerkit.authenticate_as('trigger_test_user1');
SELECT ok( SELECT ok(
(SELECT created_at IS NOT NULL FROM public.invitations WHERE email = 'invitee@example.com'), (SELECT created_at IS NOT NULL FROM public.invitations WHERE email = 'invitee@example.com'),
'invitations: created_at should be set automatically on insert' 'invitations: created_at should be set automatically on insert'

View File

@@ -13,12 +13,19 @@ select tests.create_supabase_user('user_tracking_test1', 'tracking1@example.com'
--- Test accounts table user tracking triggers - INSERT --- Test accounts table user tracking triggers - INSERT
------------ ------------
-- Authenticate as first user for insert -- Authenticate first to set JWT claims (for auth.uid() in triggers)
select makerkit.authenticate_as('user_tracking_test1'); select makerkit.authenticate_as('user_tracking_test1');
-- Switch to service_role for INSERT (create_org_account policy was removed)
-- but JWT claims are preserved so auth.uid() still works in triggers
set local role service_role;
-- Test INSERT: created_by and updated_by should be set to current user -- Test INSERT: created_by and updated_by should be set to current user
INSERT INTO public.accounts (name, is_personal_account) INSERT INTO public.accounts (name, is_personal_account, primary_owner_user_id)
VALUES ('User Tracking Test Account', false); VALUES ('User Tracking Test Account', false, tests.get_supabase_uid('user_tracking_test1'));
-- Switch back to authenticated for assertions
select makerkit.authenticate_as('user_tracking_test1');
SELECT ok( SELECT ok(
(SELECT created_by = tests.get_supabase_uid('user_tracking_test1') (SELECT created_by = tests.get_supabase_uid('user_tracking_test1')

View File

@@ -7,15 +7,15 @@ select no_plan();
select tests.create_supabase_user('update_test_owner', 'update-owner@test.com'); select tests.create_supabase_user('update_test_owner', 'update-owner@test.com');
select tests.create_supabase_user('update_test_member', 'update-member@test.com'); select tests.create_supabase_user('update_test_member', 'update-member@test.com');
-- Authenticate as owner to create team account -- Create team account using service_role and create_team_account function
select makerkit.authenticate_as('update_test_owner'); -- DON'T authenticate first - the add_current_user_to_new_account trigger
-- would also create a membership if auth.uid() = primary_owner_user_id
-- The function already creates the membership, so we avoid duplicate by keeping auth.uid() NULL
set local role service_role;
-- Create a team account (owner is added automatically via trigger) select public.create_team_account('Update Test Team', tests.get_supabase_uid('update_test_owner'));
insert into public.accounts (name, is_personal_account)
values ('Update Test Team', false);
-- Add member to the team with 'member' role using service_role -- Add member to the team with 'member' role (still in service_role)
set role service_role;
insert into public.accounts_memberships (account_id, user_id, account_role) insert into public.accounts_memberships (account_id, user_id, account_role)
values ( values (

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-supabase-saas-kit-turbo", "name": "next-supabase-saas-kit-turbo",
"version": "2.23.1", "version": "2.23.2",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"engines": { "engines": {

View File

@@ -1,11 +1,11 @@
'use client'; 'use client';
import { useState, useTransition } from 'react'; import { useMemo, useState, useTransition } from 'react';
import { isRedirectError } from 'next/dist/client/components/redirect-error'; import { isRedirectError } from 'next/dist/client/components/redirect-error';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
@@ -29,7 +29,10 @@ import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { CreateTeamSchema } from '../schema/create-team.schema'; import {
CreateTeamSchema,
NON_LATIN_REGEX,
} from '../schema/create-team.schema';
import { createTeamAccountAction } from '../server/actions/create-team-account-server-actions'; import { createTeamAccountAction } from '../server/actions/create-team-account-server-actions';
export function CreateTeamAccountDialog( export function CreateTeamAccountDialog(
@@ -67,10 +70,18 @@ function CreateOrganizationAccountForm(props: { onClose: () => void }) {
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
name: '', name: '',
slug: '',
}, },
resolver: zodResolver(CreateTeamSchema), resolver: zodResolver(CreateTeamSchema),
}); });
const nameValue = useWatch({ control: form.control, name: 'name' });
const showSlugField = useMemo(
() => NON_LATIN_REGEX.test(nameValue ?? ''),
[nameValue],
);
return ( return (
<Form {...form}> <Form {...form}>
<form <form
@@ -107,7 +118,7 @@ function CreateOrganizationAccountForm(props: { onClose: () => void }) {
<FormControl> <FormControl>
<Input <Input
data-test={'create-team-name-input'} data-test={'team-name-input'}
required required
minLength={2} minLength={2}
maxLength={50} maxLength={50}
@@ -126,6 +137,38 @@ function CreateOrganizationAccountForm(props: { onClose: () => void }) {
}} }}
/> />
<If condition={showSlugField}>
<FormField
name={'slug'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:teamSlugLabel'} />
</FormLabel>
<FormControl>
<Input
data-test={'team-slug-input'}
required
minLength={2}
maxLength={50}
placeholder={'my-team'}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:teamSlugDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
</If>
<div className={'flex justify-end space-x-2'}> <div className={'flex justify-end space-x-2'}>
<Button <Button
variant={'outline'} variant={'outline'}

View File

@@ -5,18 +5,21 @@ import { useTransition } from 'react';
import { isRedirectError } from 'next/dist/client/components/redirect-error'; import { isRedirectError } from 'next/dist/client/components/redirect-error';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Building } from 'lucide-react'; import { Building, Link } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel,
FormMessage, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { import {
InputGroup, InputGroup,
InputGroupAddon, InputGroupAddon,
@@ -25,6 +28,7 @@ import {
import { toast } from '@kit/ui/sonner'; import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { containsNonLatinCharacters } from '../../schema/create-team.schema';
import { TeamNameFormSchema } from '../../schema/update-team-name.schema'; import { TeamNameFormSchema } from '../../schema/update-team-name.schema';
import { updateTeamAccountName } from '../../server/actions/team-details-server-actions'; import { updateTeamAccountName } from '../../server/actions/team-details-server-actions';
@@ -43,9 +47,13 @@ export const UpdateTeamAccountNameForm = (props: {
resolver: zodResolver(TeamNameFormSchema), resolver: zodResolver(TeamNameFormSchema),
defaultValues: { defaultValues: {
name: props.account.name, name: props.account.name,
newSlug: '',
}, },
}); });
const nameValue = useWatch({ control: form.control, name: 'name' });
const showSlugField = containsNonLatinCharacters(nameValue || '');
return ( return (
<div className={'space-y-8'}> <div className={'space-y-8'}>
<Form {...form}> <Form {...form}>
@@ -60,6 +68,7 @@ export const UpdateTeamAccountNameForm = (props: {
const result = await updateTeamAccountName({ const result = await updateTeamAccountName({
slug: props.account.slug, slug: props.account.slug,
name: data.name, name: data.name,
newSlug: data.newSlug || undefined,
path: props.path, path: props.path,
}); });
@@ -67,6 +76,10 @@ export const UpdateTeamAccountNameForm = (props: {
toast.success(t('updateTeamSuccessMessage'), { toast.success(t('updateTeamSuccessMessage'), {
id: toastId, id: toastId,
}); });
} else if (result.error) {
toast.error(t(result.error), {
id: toastId,
});
} else { } else {
toast.error(t('updateTeamErrorMessage'), { toast.error(t('updateTeamErrorMessage'), {
id: toastId, id: toastId,
@@ -91,6 +104,10 @@ export const UpdateTeamAccountNameForm = (props: {
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem> <FormItem>
<FormLabel>
<Trans i18nKey={'teams:teamNameLabel'} />
</FormLabel>
<FormControl> <FormControl>
<InputGroup className="dark:bg-background"> <InputGroup className="dark:bg-background">
<InputGroupAddon align="inline-start"> <InputGroupAddon align="inline-start">
@@ -112,6 +129,42 @@ export const UpdateTeamAccountNameForm = (props: {
}} }}
/> />
<If condition={showSlugField}>
<FormField
name={'newSlug'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:teamSlugLabel'} />
</FormLabel>
<FormControl>
<InputGroup className="dark:bg-background">
<InputGroupAddon align="inline-start">
<Link className="h-4 w-4" />
</InputGroupAddon>
<InputGroupInput
data-test={'team-slug-input'}
required
placeholder={'my-team'}
{...field}
/>
</InputGroup>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:teamSlugDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
</If>
<div> <div>
<Button <Button
className={'w-full md:w-auto'} className={'w-full md:w-auto'}

View File

@@ -15,12 +15,51 @@ const RESERVED_NAMES_ARRAY = [
const SPECIAL_CHARACTERS_REGEX = /[!@#$%^&*()+=[\]{};':"\\|,.<>/?]/; const SPECIAL_CHARACTERS_REGEX = /[!@#$%^&*()+=[\]{};':"\\|,.<>/?]/;
/** /**
* Regex that matches only Latin characters (a-z, A-Z), numbers, spaces, and hyphens * Regex that detects non-Latin scripts (Korean, Japanese, Chinese, Cyrillic, Arabic, Hebrew, Thai)
* Does NOT match extended Latin characters like café, naïve, Zürich
*/ */
const LATIN_ONLY_REGEX = /^[a-zA-Z0-9\s-]+$/; export const NON_LATIN_REGEX =
/[\u0400-\u04FF\u0590-\u05FF\u0600-\u06FF\u0E00-\u0E7F\u1100-\u11FF\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/;
/**
* Regex for valid slugs: lowercase letters, numbers, and hyphens
* Must start and end with alphanumeric, hyphens only in middle
*/
const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
/**
* @name containsNonLatinCharacters
* @description Checks if a string contains non-Latin characters
*/
export function containsNonLatinCharacters(value: string): boolean {
return NON_LATIN_REGEX.test(value);
}
/**
* @name SlugSchema
* @description Schema for validating URL-friendly slugs
*/
export const SlugSchema = z
.string({
description: 'URL-friendly identifier for the team',
})
.min(2)
.max(50)
.regex(SLUG_REGEX, {
message: 'teams:invalidSlugError',
})
.refine(
(slug) => {
return !RESERVED_NAMES_ARRAY.includes(slug.toLowerCase());
},
{
message: 'teams:reservedNameError',
},
);
/** /**
* @name TeamNameSchema * @name TeamNameSchema
* @description Schema for team name - allows non-Latin characters
*/ */
export const TeamNameSchema = z export const TeamNameSchema = z
.string({ .string({
@@ -36,14 +75,6 @@ export const TeamNameSchema = z
message: 'teams:specialCharactersError', message: 'teams:specialCharactersError',
}, },
) )
.refine(
(name) => {
return LATIN_ONLY_REGEX.test(name);
},
{
message: 'teams:nonLatinCharactersError',
},
)
.refine( .refine(
(name) => { (name) => {
return !RESERVED_NAMES_ARRAY.includes(name.toLowerCase()); return !RESERVED_NAMES_ARRAY.includes(name.toLowerCase());
@@ -56,7 +87,27 @@ export const TeamNameSchema = z
/** /**
* @name CreateTeamSchema * @name CreateTeamSchema
* @description Schema for creating a team account * @description Schema for creating a team account
* When the name contains non-Latin characters, a slug is required
*/ */
export const CreateTeamSchema = z.object({ export const CreateTeamSchema = z
.object({
name: TeamNameSchema, name: TeamNameSchema,
}); // Transform empty strings to undefined before validation
slug: z.preprocess(
(val) => (val === '' ? undefined : val),
SlugSchema.optional(),
),
})
.refine(
(data) => {
if (containsNonLatinCharacters(data.name)) {
return !!data.slug;
}
return true;
},
{
message: 'teams:slugRequiredForNonLatinName',
path: ['slug'],
},
);

View File

@@ -1,12 +1,34 @@
import { z } from 'zod'; import { z } from 'zod';
import { TeamNameSchema } from './create-team.schema'; import {
SlugSchema,
TeamNameSchema,
containsNonLatinCharacters,
} from './create-team.schema';
export const TeamNameFormSchema = z.object({ export const TeamNameFormSchema = z
.object({
name: TeamNameSchema, name: TeamNameSchema,
}); // Transform empty strings to undefined before validation
newSlug: z.preprocess(
(val) => (val === '' ? undefined : val),
SlugSchema.optional(),
),
})
.refine(
(data) => {
if (containsNonLatinCharacters(data.name)) {
return !!data.newSlug;
}
return true;
},
{
message: 'teams:slugRequiredForNonLatinName',
path: ['newSlug'],
},
);
export const UpdateTeamNameSchema = TeamNameFormSchema.merge( export const UpdateTeamNameSchema = TeamNameFormSchema.and(
z.object({ z.object({
slug: z.string().min(1).max(255), slug: z.string().min(1).max(255),
path: z.string().min(1).max(255), path: z.string().min(1).max(255),

View File

@@ -1,20 +1,20 @@
'use server'; 'use server';
import 'server-only';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { enhanceAction } from '@kit/next/actions'; import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger'; import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateTeamSchema } from '../../schema/create-team.schema'; import { CreateTeamSchema } from '../../schema/create-team.schema';
import { createAccountCreationPolicyEvaluator } from '../policies'; import { createAccountCreationPolicyEvaluator } from '../policies';
import { createCreateTeamAccountService } from '../services/create-team-account.service'; import { createCreateTeamAccountService } from '../services/create-team-account.service';
export const createTeamAccountAction = enhanceAction( export const createTeamAccountAction = enhanceAction(
async ({ name }, user) => { async ({ name, slug }, user) => {
const logger = await getLogger(); const logger = await getLogger();
const client = getSupabaseServerClient(); const service = createCreateTeamAccountService();
const service = createCreateTeamAccountService(client);
const ctx = { const ctx = {
name: 'team-accounts.create', name: 'team-accounts.create',
@@ -52,12 +52,19 @@ export const createTeamAccountAction = enhanceAction(
} }
} }
// Service throws on error, so no need to check for error const { data, error } = await service.createNewOrganizationAccount({
const { data } = await service.createNewOrganizationAccount({
name, name,
userId: user.id, userId: user.id,
slug,
}); });
if (error === 'duplicate_slug') {
return {
error: true,
message: 'teams:duplicateSlugError',
};
}
logger.info(ctx, `Team account created`); logger.info(ctx, `Team account created`);
const accountHomePath = '/home/' + data.slug; const accountHomePath = '/home/' + data.slug;

View File

@@ -12,7 +12,9 @@ export const updateTeamAccountName = enhanceAction(
async (params) => { async (params) => {
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const logger = await getLogger(); const logger = await getLogger();
const { name, path, slug } = params; const { name, path, slug, newSlug } = params;
const slugToUpdate = newSlug ?? slug;
const ctx = { const ctx = {
name: 'team-accounts.update', name: 'team-accounts.update',
@@ -25,7 +27,7 @@ export const updateTeamAccountName = enhanceAction(
.from('accounts') .from('accounts')
.update({ .update({
name, name,
slug, slug: slugToUpdate,
}) })
.match({ .match({
slug, slug,
@@ -34,17 +36,25 @@ export const updateTeamAccountName = enhanceAction(
.single(); .single();
if (error) { if (error) {
// Handle duplicate slug error
if (error.code === '23505') {
return {
success: false,
error: 'teams:duplicateSlugError',
};
}
logger.error({ ...ctx, error }, `Failed to update team name`); logger.error({ ...ctx, error }, `Failed to update team name`);
throw error; throw error;
} }
const newSlug = data.slug; const updatedSlug = data.slug;
logger.info(ctx, `Team name updated`); logger.info(ctx, `Team name updated`);
if (newSlug) { if (updatedSlug && updatedSlug !== slug) {
const nextPath = path.replace('[account]', newSlug); const nextPath = path.replace('[account]', updatedSlug);
redirect(nextPath); redirect(nextPath);
} }

View File

@@ -1,45 +1,57 @@
import 'server-only'; import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger'; import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
export function createCreateTeamAccountService( export function createCreateTeamAccountService() {
client: SupabaseClient<Database>, return new CreateTeamAccountService();
) {
return new CreateTeamAccountService(client);
} }
class CreateTeamAccountService { class CreateTeamAccountService {
private readonly namespace = 'accounts.create-team-account'; private readonly namespace = 'accounts.create-team-account';
constructor(private readonly client: SupabaseClient<Database>) {} async createNewOrganizationAccount(params: {
name: string;
async createNewOrganizationAccount(params: { name: string; userId: string }) { userId: string;
slug?: string;
}) {
const client = getSupabaseServerAdminClient();
const logger = await getLogger(); const logger = await getLogger();
const ctx = { ...params, namespace: this.namespace }; const ctx = { ...params, namespace: this.namespace };
logger.info(ctx, `Creating new team account...`); logger.info(ctx, `Creating new team account...`);
const { error, data } = await this.client.rpc('create_team_account', { // Call the RPC function which handles:
// 1. Checking if team accounts are enabled
// 2. Creating the account with name, slug, and primary_owner_user_id
// 3. Creating membership for the owner (atomic transaction)
const { error, data } = await client.rpc('create_team_account', {
account_name: params.name, account_name: params.name,
user_id: params.userId,
account_slug: params.slug,
}); });
if (error) { if (error) {
logger.error( // Handle duplicate slug error
{ if (error.code === '23505' && error.message.includes('slug')) {
error, logger.warn(
...ctx, { ...ctx, slug: params.slug },
}, `Duplicate slug detected, rejecting team creation`,
`Error creating team account`,
); );
return {
data: null,
error: 'duplicate_slug' as const,
};
}
logger.error({ error, ...ctx }, `Error creating team account`);
throw new Error('Error creating team account'); throw new Error('Error creating team account');
} }
logger.info(ctx, `Team account created successfully`); logger.info(ctx, `Team account created successfully`);
return { data, error }; return { data, error: null };
} }
} }

File diff suppressed because it is too large Load Diff