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

@@ -1,11 +1,11 @@
'use client';
import { useState, useTransition } from 'react';
import { useMemo, useState, useTransition } from 'react';
import { isRedirectError } from 'next/dist/client/components/redirect-error';
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 { Button } from '@kit/ui/button';
@@ -29,7 +29,10 @@ import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
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';
export function CreateTeamAccountDialog(
@@ -67,10 +70,18 @@ function CreateOrganizationAccountForm(props: { onClose: () => void }) {
const form = useForm({
defaultValues: {
name: '',
slug: '',
},
resolver: zodResolver(CreateTeamSchema),
});
const nameValue = useWatch({ control: form.control, name: 'name' });
const showSlugField = useMemo(
() => NON_LATIN_REGEX.test(nameValue ?? ''),
[nameValue],
);
return (
<Form {...form}>
<form
@@ -107,7 +118,7 @@ function CreateOrganizationAccountForm(props: { onClose: () => void }) {
<FormControl>
<Input
data-test={'create-team-name-input'}
data-test={'team-name-input'}
required
minLength={2}
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'}>
<Button
variant={'outline'}

View File

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

View File

@@ -15,12 +15,51 @@ const RESERVED_NAMES_ARRAY = [
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
* @description Schema for team name - allows non-Latin characters
*/
export const TeamNameSchema = z
.string({
@@ -36,14 +75,6 @@ export const TeamNameSchema = z
message: 'teams:specialCharactersError',
},
)
.refine(
(name) => {
return LATIN_ONLY_REGEX.test(name);
},
{
message: 'teams:nonLatinCharactersError',
},
)
.refine(
(name) => {
return !RESERVED_NAMES_ARRAY.includes(name.toLowerCase());
@@ -56,7 +87,27 @@ export const TeamNameSchema = z
/**
* @name CreateTeamSchema
* @description Schema for creating a team account
* When the name contains non-Latin characters, a slug is required
*/
export const CreateTeamSchema = z.object({
name: TeamNameSchema,
});
export const CreateTeamSchema = z
.object({
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 { TeamNameSchema } from './create-team.schema';
import {
SlugSchema,
TeamNameSchema,
containsNonLatinCharacters,
} from './create-team.schema';
export const TeamNameFormSchema = z.object({
name: TeamNameSchema,
});
export const TeamNameFormSchema = z
.object({
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({
slug: z.string().min(1).max(255),
path: z.string().min(1).max(255),

View File

@@ -1,20 +1,20 @@
'use server';
import 'server-only';
import { redirect } from 'next/navigation';
import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateTeamSchema } from '../../schema/create-team.schema';
import { createAccountCreationPolicyEvaluator } from '../policies';
import { createCreateTeamAccountService } from '../services/create-team-account.service';
export const createTeamAccountAction = enhanceAction(
async ({ name }, user) => {
async ({ name, slug }, user) => {
const logger = await getLogger();
const client = getSupabaseServerClient();
const service = createCreateTeamAccountService(client);
const service = createCreateTeamAccountService();
const ctx = {
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 } = await service.createNewOrganizationAccount({
const { data, error } = await service.createNewOrganizationAccount({
name,
userId: user.id,
slug,
});
if (error === 'duplicate_slug') {
return {
error: true,
message: 'teams:duplicateSlugError',
};
}
logger.info(ctx, `Team account created`);
const accountHomePath = '/home/' + data.slug;

View File

@@ -12,7 +12,9 @@ export const updateTeamAccountName = enhanceAction(
async (params) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const { name, path, slug } = params;
const { name, path, slug, newSlug } = params;
const slugToUpdate = newSlug ?? slug;
const ctx = {
name: 'team-accounts.update',
@@ -25,7 +27,7 @@ export const updateTeamAccountName = enhanceAction(
.from('accounts')
.update({
name,
slug,
slug: slugToUpdate,
})
.match({
slug,
@@ -34,17 +36,25 @@ export const updateTeamAccountName = enhanceAction(
.single();
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`);
throw error;
}
const newSlug = data.slug;
const updatedSlug = data.slug;
logger.info(ctx, `Team name updated`);
if (newSlug) {
const nextPath = path.replace('[account]', newSlug);
if (updatedSlug && updatedSlug !== slug) {
const nextPath = path.replace('[account]', updatedSlug);
redirect(nextPath);
}

View File

@@ -1,45 +1,57 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
export function createCreateTeamAccountService(
client: SupabaseClient<Database>,
) {
return new CreateTeamAccountService(client);
export function createCreateTeamAccountService() {
return new CreateTeamAccountService();
}
class CreateTeamAccountService {
private readonly namespace = 'accounts.create-team-account';
constructor(private readonly client: SupabaseClient<Database>) {}
async createNewOrganizationAccount(params: { name: string; userId: string }) {
async createNewOrganizationAccount(params: {
name: string;
userId: string;
slug?: string;
}) {
const client = getSupabaseServerAdminClient();
const logger = await getLogger();
const ctx = { ...params, namespace: this.namespace };
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,
user_id: params.userId,
account_slug: params.slug,
});
if (error) {
logger.error(
{
error,
...ctx,
},
`Error creating team account`,
);
// Handle duplicate slug error
if (error.code === '23505' && error.message.includes('slug')) {
logger.warn(
{ ...ctx, slug: params.slug },
`Duplicate slug detected, rejecting team creation`,
);
return {
data: null,
error: 'duplicate_slug' as const,
};
}
logger.error({ error, ...ctx }, `Error creating team account`);
throw new Error('Error creating team account');
}
logger.info(ctx, `Team account created successfully`);
return { data, error };
return { data, error: null };
}
}