feat: enhance team account creation with policy checks and UI updates (#436)

* feat: enhance team account creation with policy checks and UI updates
This commit is contained in:
Giancarlo Buomprisco
2026-01-06 12:50:18 +01:00
committed by GitHub
parent ab57b24518
commit 5237d34e6f
14 changed files with 223 additions and 39 deletions

View File

@@ -19,10 +19,12 @@ import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
import { HomeAddAccountButton } from './home-add-account-button'; import { HomeAddAccountButton } from './home-add-account-button';
export function HomeAccountsList() { export function HomeAccountsList() {
const { accounts } = use(loadUserWorkspace()); const { accounts, canCreateTeamAccount } = use(loadUserWorkspace());
if (!accounts.length) { if (!accounts.length) {
return <HomeAccountsListEmptyState />; return (
<HomeAccountsListEmptyState canCreateTeamAccount={canCreateTeamAccount} />
);
} }
return ( return (
@@ -42,12 +44,17 @@ export function HomeAccountsList() {
); );
} }
function HomeAccountsListEmptyState() { function HomeAccountsListEmptyState(props: {
canCreateTeamAccount: { allowed: boolean; reason?: string };
}) {
return ( return (
<div className={'flex flex-1'}> <div className={'flex flex-1'}>
<EmptyState> <EmptyState>
<EmptyStateButton asChild> <EmptyStateButton asChild>
<HomeAddAccountButton className={'mt-4'} /> <HomeAddAccountButton
className={'mt-4'}
canCreateTeamAccount={props.canCreateTeamAccount}
/>
</EmptyStateButton> </EmptyStateButton>
<EmptyStateHeading> <EmptyStateHeading>
<Trans i18nKey={'account:noTeamsYet'} /> <Trans i18nKey={'account:noTeamsYet'} />

View File

@@ -4,19 +4,54 @@ import { useState } from 'react';
import { CreateTeamAccountDialog } from '@kit/team-accounts/components'; import { CreateTeamAccountDialog } from '@kit/team-accounts/components';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@kit/ui/tooltip';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
export function HomeAddAccountButton(props: { className?: string }) { interface HomeAddAccountButtonProps {
className?: string;
canCreateTeamAccount?: {
allowed: boolean;
reason?: string;
};
}
export function HomeAddAccountButton(props: HomeAddAccountButtonProps) {
const [isAddingAccount, setIsAddingAccount] = useState(false); const [isAddingAccount, setIsAddingAccount] = useState(false);
return ( const canCreate = props.canCreateTeamAccount?.allowed ?? true;
<> const reason = props.canCreateTeamAccount?.reason;
const button = (
<Button <Button
className={props.className} className={props.className}
onClick={() => setIsAddingAccount(true)} onClick={() => setIsAddingAccount(true)}
disabled={!canCreate}
> >
<Trans i18nKey={'account:createTeamButtonLabel'} /> <Trans i18nKey={'account:createTeamButtonLabel'} />
</Button> </Button>
);
return (
<>
{!canCreate && reason ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-not-allowed">{button}</span>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey={reason} defaults={reason} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
button
)}
<CreateTeamAccountDialog <CreateTeamAccountDialog
isOpen={isAddingAccount} isOpen={isAddingAccount}

View File

@@ -2,6 +2,7 @@ import { cache } from 'react';
import { createAccountsApi } from '@kit/accounts/api'; import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createAccountCreationPolicyEvaluator } from '@kit/team-accounts/policies';
import featureFlagsConfig from '~/config/feature-flags.config'; import featureFlagsConfig from '~/config/feature-flags.config';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component'; import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
@@ -34,9 +35,41 @@ async function workspaceLoader() {
requireUserInServerComponent(), requireUserInServerComponent(),
]); ]);
// Check if user can create team accounts (policy check)
const canCreateTeamAccount = shouldLoadAccounts
? await checkCanCreateTeamAccount(user.id)
: { allowed: false, reason: undefined };
return { return {
accounts, accounts,
workspace, workspace,
user, user,
canCreateTeamAccount,
};
}
/**
* Check if the user can create a team account based on policies.
* Preliminary checks run without account name - name validation happens during submission.
*/
async function checkCanCreateTeamAccount(userId: string) {
const evaluator = createAccountCreationPolicyEvaluator();
const hasPolicies = await evaluator.hasPoliciesForStage('preliminary');
if (!hasPolicies) {
return { allowed: true, reason: undefined };
}
const context = {
timestamp: new Date().toISOString(),
userId,
accountName: '',
};
const result = await evaluator.canCreateAccount(context, 'preliminary');
return {
allowed: result.allowed,
reason: result.reasons[0],
}; };
} }

View File

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

View File

@@ -31,6 +31,7 @@
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "catalog:", "@supabase/supabase-js": "catalog:",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:", "@types/react": "catalog:",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"next": "catalog:", "next": "catalog:",

View File

@@ -14,7 +14,6 @@
"./hooks/*": "./src/hooks/*.ts", "./hooks/*": "./src/hooks/*.ts",
"./webhooks": "./src/server/services/webhooks/index.ts", "./webhooks": "./src/server/services/webhooks/index.ts",
"./policies": "./src/server/policies/index.ts", "./policies": "./src/server/policies/index.ts",
"./policies/orchestrator": "./src/server/policies/orchestrator.ts",
"./services/account-invitations.service": "./src/server/services/account-invitations.service.ts" "./services/account-invitations.service": "./src/server/services/account-invitations.service.ts"
}, },
"dependencies": { "dependencies": {

View File

@@ -61,7 +61,7 @@ export function CreateTeamAccountDialog(
} }
function CreateOrganizationAccountForm(props: { onClose: () => void }) { function CreateOrganizationAccountForm(props: { onClose: () => void }) {
const [error, setError] = useState<boolean>(); const [error, setError] = useState<{ message?: string } | undefined>();
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const form = useForm({ const form = useForm({
@@ -78,14 +78,14 @@ function CreateOrganizationAccountForm(props: { onClose: () => void }) {
onSubmit={form.handleSubmit((data) => { onSubmit={form.handleSubmit((data) => {
startTransition(async () => { startTransition(async () => {
try { try {
const { error } = await createTeamAccountAction(data); const result = await createTeamAccountAction(data);
if (error) { if (result.error) {
setError(true); setError({ message: result.message });
} }
} catch (error) { } catch (e) {
if (!isRedirectError(error)) { if (!isRedirectError(e)) {
setError(true); setError({});
} }
} }
}); });
@@ -93,7 +93,7 @@ function CreateOrganizationAccountForm(props: { onClose: () => void }) {
> >
<div className={'flex flex-col space-y-4'}> <div className={'flex flex-col space-y-4'}>
<If condition={error}> <If condition={error}>
<CreateOrganizationErrorAlert /> <CreateOrganizationErrorAlert message={error?.message} />
</If> </If>
<FormField <FormField
@@ -150,7 +150,7 @@ function CreateOrganizationAccountForm(props: { onClose: () => void }) {
); );
} }
function CreateOrganizationErrorAlert() { function CreateOrganizationErrorAlert(props: { message?: string }) {
return ( return (
<Alert variant={'destructive'}> <Alert variant={'destructive'}>
<AlertTitle> <AlertTitle>
@@ -158,7 +158,11 @@ function CreateOrganizationErrorAlert() {
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
{props.message ? (
<Trans i18nKey={props.message} defaults={props.message} />
) : (
<Trans i18nKey={'teams:createTeamErrorMessage'} /> <Trans i18nKey={'teams:createTeamErrorMessage'} />
)}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
); );

View File

@@ -7,6 +7,7 @@ import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; 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 { createCreateTeamAccountService } from '../services/create-team-account.service'; import { createCreateTeamAccountService } from '../services/create-team-account.service';
export const createTeamAccountAction = enhanceAction( export const createTeamAccountAction = enhanceAction(
@@ -23,18 +24,39 @@ export const createTeamAccountAction = enhanceAction(
logger.info(ctx, `Creating team account...`); logger.info(ctx, `Creating team account...`);
const { data, error } = await service.createNewOrganizationAccount({ // Check policies before creating
name, const evaluator = createAccountCreationPolicyEvaluator();
userId: user.id,
});
if (error) { if (await evaluator.hasPoliciesForStage('submission')) {
logger.error({ ...ctx, error }, `Failed to create team account`); const policyContext = {
timestamp: new Date().toISOString(),
userId: user.id,
accountName: name,
};
const result = await evaluator.canCreateAccount(
policyContext,
'submission',
);
if (!result.allowed) {
logger.warn(
{ ...ctx, reasons: result.reasons },
`Policy denied team account creation`,
);
return { return {
error: true, error: true,
message: result.reasons[0] ?? 'Policy denied account creation',
}; };
} }
}
// Service throws on error, so no need to check for error
const { data } = await service.createNewOrganizationAccount({
name,
userId: user.id,
});
logger.info(ctx, `Team account created`); logger.info(ctx, `Team account created`);

View File

@@ -0,0 +1,10 @@
import 'server-only';
import { createPolicyRegistry } from '@kit/policies';
/**
* Feature-specific registry for create account policies.
*/
export const createAccountPolicyRegistry = createPolicyRegistry();
// Register policies here

View File

@@ -0,0 +1,50 @@
import 'server-only';
import type { EvaluationResult } from '@kit/policies';
import { createPoliciesEvaluator } from '@kit/policies';
import { createAccountPolicyRegistry } from './create-account-policies';
import type { FeaturePolicyCreateAccountContext } from './feature-policy-create-account-context';
export interface CreateAccountPolicyEvaluator {
hasPoliciesForStage(stage: 'preliminary' | 'submission'): Promise<boolean>;
canCreateAccount(
context: FeaturePolicyCreateAccountContext,
stage: 'preliminary' | 'submission',
): Promise<EvaluationResult>;
}
/**
* Creates a create account policy evaluator
*/
export function createAccountCreationPolicyEvaluator(): CreateAccountPolicyEvaluator {
const evaluator =
createPoliciesEvaluator<FeaturePolicyCreateAccountContext>();
return {
/**
* Checks if there are any create account policies for the given stage
* @param stage - The stage to check if there are any policies for
*/
async hasPoliciesForStage(stage: 'preliminary' | 'submission') {
return evaluator.hasPoliciesForStage(createAccountPolicyRegistry, stage);
},
/**
* Evaluates the create account policies for the given context and stage
* @param context - The context for the create account policy
* @param stage - The stage to evaluate the policies for
*/
async canCreateAccount(
context: FeaturePolicyCreateAccountContext,
stage: 'preliminary' | 'submission',
) {
return evaluator.evaluate(
createAccountPolicyRegistry,
context,
'ALL',
stage,
);
},
};
}

View File

@@ -0,0 +1,13 @@
import type { PolicyContext } from '@kit/policies';
/**
* Minimal context for create account policies.
* Policies can fetch additional data internally using getSupabaseServerClient().
*/
export interface FeaturePolicyCreateAccountContext extends PolicyContext {
/** The ID of the user creating the account */
userId: string;
/** The name of the account being created (empty string for preliminary checks) */
accountName: string;
}

View File

@@ -1,7 +1,10 @@
// Invitation policies
export { createInvitationsPolicyEvaluator } from './invitation-policies'; export { createInvitationsPolicyEvaluator } from './invitation-policies';
// Context building
export { createInvitationContextBuilder } from './invitation-context-builder'; export { createInvitationContextBuilder } from './invitation-context-builder';
// Type exports
export type { FeaturePolicyInvitationContext } from './feature-policy-invitation-context'; export type { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';
// Create account policies
export { createAccountCreationPolicyEvaluator } from './create-account-policy-evaluator';
export type { CreateAccountPolicyEvaluator } from './create-account-policy-evaluator';
export { createAccountPolicyRegistry } from './create-account-policies';
export type { FeaturePolicyCreateAccountContext } from './feature-policy-create-account-context';

View File

@@ -21,6 +21,7 @@
"@kit/supabase": "workspace:*", "@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@supabase/supabase-js": "catalog:", "@supabase/supabase-js": "catalog:",
"@types/node": "catalog:",
"next": "catalog:", "next": "catalog:",
"zod": "catalog:" "zod": "catalog:"
}, },

12
pnpm-lock.yaml generated
View File

@@ -906,6 +906,9 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: 'catalog:' specifier: 'catalog:'
version: 5.90.12(react@19.2.3) version: 5.90.12(react@19.2.3)
'@types/node':
specifier: 'catalog:'
version: 25.0.3
'@types/react': '@types/react':
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.7 version: 19.2.7
@@ -1313,6 +1316,9 @@ importers:
'@supabase/supabase-js': '@supabase/supabase-js':
specifier: 'catalog:' specifier: 'catalog:'
version: 2.89.0 version: 2.89.0
'@types/node':
specifier: 'catalog:'
version: 25.0.3
next: next:
specifier: 'catalog:' specifier: 'catalog:'
version: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -14442,7 +14448,7 @@ snapshots:
eslint: 9.39.2(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1))
@@ -14481,7 +14487,7 @@ snapshots:
tinyglobby: 0.2.15 tinyglobby: 0.2.15
unrs-resolver: 1.11.1 unrs-resolver: 1.11.1
optionalDependencies: optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -14496,7 +14502,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
dependencies: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.9 array-includes: 3.1.9