diff --git a/apps/web/app/home/(user)/_components/home-accounts-list.tsx b/apps/web/app/home/(user)/_components/home-accounts-list.tsx index 3d6c36302..7c8e30dbb 100644 --- a/apps/web/app/home/(user)/_components/home-accounts-list.tsx +++ b/apps/web/app/home/(user)/_components/home-accounts-list.tsx @@ -19,10 +19,12 @@ import { loadUserWorkspace } from '../_lib/server/load-user-workspace'; import { HomeAddAccountButton } from './home-add-account-button'; export function HomeAccountsList() { - const { accounts } = use(loadUserWorkspace()); + const { accounts, canCreateTeamAccount } = use(loadUserWorkspace()); if (!accounts.length) { - return ; + return ( + + ); } return ( @@ -42,12 +44,17 @@ export function HomeAccountsList() { ); } -function HomeAccountsListEmptyState() { +function HomeAccountsListEmptyState(props: { + canCreateTeamAccount: { allowed: boolean; reason?: string }; +}) { return (
- + diff --git a/apps/web/app/home/(user)/_components/home-add-account-button.tsx b/apps/web/app/home/(user)/_components/home-add-account-button.tsx index 4a2cabba8..5d94cf5be 100644 --- a/apps/web/app/home/(user)/_components/home-add-account-button.tsx +++ b/apps/web/app/home/(user)/_components/home-add-account-button.tsx @@ -4,19 +4,54 @@ import { useState } from 'react'; import { CreateTeamAccountDialog } from '@kit/team-accounts/components'; import { Button } from '@kit/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@kit/ui/tooltip'; 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 canCreate = props.canCreateTeamAccount?.allowed ?? true; + const reason = props.canCreateTeamAccount?.reason; + + const button = ( + + ); + return ( <> - + {!canCreate && reason ? ( + + + + {button} + + + + + + + ) : ( + button + )} void }) { - const [error, setError] = useState(); + const [error, setError] = useState<{ message?: string } | undefined>(); const [pending, startTransition] = useTransition(); const form = useForm({ @@ -78,14 +78,14 @@ function CreateOrganizationAccountForm(props: { onClose: () => void }) { onSubmit={form.handleSubmit((data) => { startTransition(async () => { try { - const { error } = await createTeamAccountAction(data); + const result = await createTeamAccountAction(data); - if (error) { - setError(true); + if (result.error) { + setError({ message: result.message }); } - } catch (error) { - if (!isRedirectError(error)) { - setError(true); + } catch (e) { + if (!isRedirectError(e)) { + setError({}); } } }); @@ -93,7 +93,7 @@ function CreateOrganizationAccountForm(props: { onClose: () => void }) { >
- + void }) { ); } -function CreateOrganizationErrorAlert() { +function CreateOrganizationErrorAlert(props: { message?: string }) { return ( @@ -158,7 +158,11 @@ function CreateOrganizationErrorAlert() { - + {props.message ? ( + + ) : ( + + )} ); diff --git a/packages/features/team-accounts/src/server/actions/create-team-account-server-actions.ts b/packages/features/team-accounts/src/server/actions/create-team-account-server-actions.ts index a741c2049..66257d220 100644 --- a/packages/features/team-accounts/src/server/actions/create-team-account-server-actions.ts +++ b/packages/features/team-accounts/src/server/actions/create-team-account-server-actions.ts @@ -7,6 +7,7 @@ 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( @@ -23,19 +24,40 @@ export const createTeamAccountAction = enhanceAction( logger.info(ctx, `Creating team account...`); - const { data, error } = await service.createNewOrganizationAccount({ + // Check policies before creating + const evaluator = createAccountCreationPolicyEvaluator(); + + if (await evaluator.hasPoliciesForStage('submission')) { + 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 { + 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, }); - if (error) { - logger.error({ ...ctx, error }, `Failed to create team account`); - - return { - error: true, - }; - } - logger.info(ctx, `Team account created`); const accountHomePath = '/home/' + data.slug; diff --git a/packages/features/team-accounts/src/server/policies/create-account-policies.ts b/packages/features/team-accounts/src/server/policies/create-account-policies.ts new file mode 100644 index 000000000..afac1f740 --- /dev/null +++ b/packages/features/team-accounts/src/server/policies/create-account-policies.ts @@ -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 diff --git a/packages/features/team-accounts/src/server/policies/create-account-policy-evaluator.ts b/packages/features/team-accounts/src/server/policies/create-account-policy-evaluator.ts new file mode 100644 index 000000000..6cbb71c05 --- /dev/null +++ b/packages/features/team-accounts/src/server/policies/create-account-policy-evaluator.ts @@ -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; + canCreateAccount( + context: FeaturePolicyCreateAccountContext, + stage: 'preliminary' | 'submission', + ): Promise; +} + +/** + * Creates a create account policy evaluator + */ +export function createAccountCreationPolicyEvaluator(): CreateAccountPolicyEvaluator { + const evaluator = + createPoliciesEvaluator(); + + 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, + ); + }, + }; +} diff --git a/packages/features/team-accounts/src/server/policies/feature-policy-create-account-context.ts b/packages/features/team-accounts/src/server/policies/feature-policy-create-account-context.ts new file mode 100644 index 000000000..9485f65b1 --- /dev/null +++ b/packages/features/team-accounts/src/server/policies/feature-policy-create-account-context.ts @@ -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; +} diff --git a/packages/features/team-accounts/src/server/policies/index.ts b/packages/features/team-accounts/src/server/policies/index.ts index 9906edd22..3fdd053f2 100644 --- a/packages/features/team-accounts/src/server/policies/index.ts +++ b/packages/features/team-accounts/src/server/policies/index.ts @@ -1,7 +1,10 @@ +// Invitation policies export { createInvitationsPolicyEvaluator } from './invitation-policies'; - -// Context building export { createInvitationContextBuilder } from './invitation-context-builder'; - -// Type exports 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'; diff --git a/packages/next/package.json b/packages/next/package.json index a1e90216b..68070f01c 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -21,6 +21,7 @@ "@kit/supabase": "workspace:*", "@kit/tsconfig": "workspace:*", "@supabase/supabase-js": "catalog:", + "@types/node": "catalog:", "next": "catalog:", "zod": "catalog:" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e09a71f9f..072c7f742 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -906,6 +906,9 @@ importers: '@tanstack/react-query': specifier: 'catalog:' version: 5.90.12(react@19.2.3) + '@types/node': + specifier: 'catalog:' + version: 25.0.3 '@types/react': specifier: 'catalog:' version: 19.2.7 @@ -1313,6 +1316,9 @@ importers: '@supabase/supabase-js': specifier: 'catalog:' version: 2.89.0 + '@types/node': + specifier: 'catalog:' + version: 25.0.3 next: 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) @@ -14442,7 +14448,7 @@ snapshots: eslint: 9.39.2(jiti@2.6.1) 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-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-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)) @@ -14481,7 +14487,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 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: - supports-color @@ -14496,7 +14502,7 @@ snapshots: transitivePeerDependencies: - 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: '@rtsao/scc': 1.1.0 array-includes: 3.1.9