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:
committed by
GitHub
parent
e1bfbc8106
commit
0636f8cf11
@@ -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'}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user