2.23.0: Enforce Policies API for invitations and creating accounts; added WeakPassword handling; Fix dialog open/closed states (#439)

* chore: bump version to 2.22.1 and update dependencies

- Updated application version from 2.22.0 to 2.22.1 in package.json.
- Updated various dependencies including @marsidev/react-turnstile to 1.4.1, @stripe/react-stripe-js to 5.4.1, @stripe/stripe-js to 8.6.1, and react-hook-form to 7.70.0.
- Adjusted lucide-react version to be referenced from the catalog across multiple package.json files.
- Enhanced consistency in pnpm-lock.yaml and pnpm-workspace.yaml with updated package versions.

* chore: bump version to 2.23.0 and update dependencies

- Updated application version from 2.22.1 to 2.23.0 in package.json.
- Upgraded turbo dependency from 2.7.1 to 2.7.3 in package.json and pnpm-lock.yaml.
- Enhanced end-to-end testing documentation in AGENTS.md and CLAUDE.md with instructions for running tests.
- Updated AuthPageObject to use a new secret for user creation in auth.po.ts.
- Refactored team ownership transfer and member role update dialogs to close on success.
- Improved error handling for weak passwords in AuthErrorAlert component.
- Adjusted database schemas and tests to reflect changes in invitation policies and role management.
This commit is contained in:
Giancarlo Buomprisco
2026-01-07 17:00:11 +01:00
committed by GitHub
parent 5237d34e6f
commit d5dc6f2528
41 changed files with 2896 additions and 1922 deletions

View File

@@ -42,7 +42,7 @@
"@types/react-dom": "catalog:",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.562.0",
"lucide-react": "catalog:",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",

View File

@@ -37,8 +37,10 @@ export function TransferOwnershipDialog({
userId: string;
targetDisplayName: string;
}) {
const [open, setOpen] = useState(false);
return (
<AlertDialog>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent>
@@ -56,6 +58,7 @@ export function TransferOwnershipDialog({
accountId={accountId}
userId={userId}
targetDisplayName={targetDisplayName}
onSuccess={() => setOpen(false)}
/>
</AlertDialogContent>
</AlertDialog>
@@ -66,10 +69,12 @@ function TransferOrganizationOwnershipForm({
accountId,
userId,
targetDisplayName,
onSuccess,
}: {
userId: string;
accountId: string;
targetDisplayName: string;
onSuccess: () => unknown;
}) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
@@ -115,6 +120,8 @@ function TransferOrganizationOwnershipForm({
startTransition(async () => {
try {
await transferOwnershipAction(data);
onSuccess();
} catch {
setError(true);
}

View File

@@ -45,9 +45,12 @@ export function UpdateMemberRoleDialog({
userRole: Role;
userRoleHierarchy: number;
}>) {
const [open, setOpen] = useState(false);
return (
<Dialog>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -66,6 +69,7 @@ export function UpdateMemberRoleDialog({
teamAccountId={teamAccountId}
userRole={userRole}
roles={data}
onSuccess={() => setOpen(false)}
/>
)}
</RolesDataProvider>
@@ -79,11 +83,13 @@ function UpdateMemberForm({
userRole,
teamAccountId,
roles,
onSuccess,
}: React.PropsWithChildren<{
userId: string;
userRole: Role;
teamAccountId: string;
roles: Role[];
onSuccess: () => unknown;
}>) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
@@ -97,6 +103,8 @@ function UpdateMemberForm({
userId,
role,
});
onSuccess();
} catch {
setError(true);
}

View File

@@ -7,6 +7,7 @@ import { z } from 'zod';
import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { JWTUserData } from '@kit/supabase/types';
@@ -34,8 +35,52 @@ export const createInvitationsAction = enhanceAction(
'User requested to send invitations',
);
// Evaluate invitation policies
const policiesResult = await evaluateInvitationsPolicies(params, user);
const client = getSupabaseServerClient();
// Get account ID from slug (needed for permission checks and policies)
const { data: account, error: accountError } = await client
.from('accounts')
.select('id')
.eq('slug', params.accountSlug)
.single();
if (accountError || !account) {
logger.error(
{ accountSlug: params.accountSlug, error: accountError },
'Account not found',
);
return {
success: false,
reasons: ['Account not found'],
};
}
// Check invitation permissions (replaces RLS policy checks)
const permissionsResult = await checkInvitationPermissions(
account.id,
user.id,
params.invitations,
);
if (!permissionsResult.allowed) {
logger.info(
{ reason: permissionsResult.reason, userId: user.id },
'Invitations blocked by permission check',
);
return {
success: false,
reasons: permissionsResult.reason ? [permissionsResult.reason] : [],
};
}
// Evaluate custom invitation policies
const policiesResult = await evaluateInvitationsPolicies(
params,
user,
account.id,
);
// If the invitations are not allowed, throw an error
if (!policiesResult.allowed) {
@@ -51,11 +96,15 @@ export const createInvitationsAction = enhanceAction(
}
// invitations are allowed, so continue with the action
const client = getSupabaseServerClient();
const service = createAccountInvitationsService(client);
// Use admin client since we've already validated permissions
const adminClient = getSupabaseServerAdminClient();
const service = createAccountInvitationsService(adminClient);
try {
await service.sendInvitations(params);
await service.sendInvitations({
...params,
invitedBy: user.id,
});
revalidateMemberPage();
@@ -194,10 +243,13 @@ function revalidateMemberPage() {
* @name evaluateInvitationsPolicies
* @description Evaluates invitation policies with performance optimization.
* @param params - The invitations to evaluate (emails and roles).
* @param user - The user performing the invitation.
* @param accountId - The account ID (already fetched to avoid duplicate queries).
*/
async function evaluateInvitationsPolicies(
params: z.infer<typeof InviteMembersSchema> & { accountSlug: string },
user: JWTUserData,
accountId: string,
) {
const evaluator = createInvitationsPolicyEvaluator();
const hasPolicies = await evaluator.hasPoliciesForStage('submission');
@@ -212,7 +264,92 @@ async function evaluateInvitationsPolicies(
const client = getSupabaseServerClient();
const builder = createInvitationContextBuilder(client);
const context = await builder.buildContext(params, user);
const context = await builder.buildContextWithAccountId(
params,
user,
accountId,
);
return evaluator.canInvite(context, 'submission');
}
/**
* @name checkInvitationPermissions
* @description Checks if the user has permission to invite members and
* validates role hierarchy for each invitation.
* Optimized to batch all checks in parallel.
*/
async function checkInvitationPermissions(
accountId: string,
userId: string,
invitations: z.infer<typeof InviteMembersSchema>['invitations'],
): Promise<{
allowed: boolean;
reason?: string;
}> {
const client = getSupabaseServerClient();
const logger = await getLogger();
const ctx = {
name: 'checkInvitationPermissions',
userId,
accountId,
};
// Get unique roles from invitations to minimize RPC calls
const uniqueRoles = [...new Set(invitations.map((inv) => inv.role))];
// Run all checks in parallel: permission check + role hierarchy checks for each unique role
const [permissionResult, ...roleResults] = await Promise.all([
client.rpc('has_permission', {
user_id: userId,
account_id: accountId,
permission_name:
'invites.manage' as Database['public']['Enums']['app_permissions'],
}),
...uniqueRoles.map((role) =>
Promise.all([
client.rpc('has_more_elevated_role', {
target_user_id: userId,
target_account_id: accountId,
role_name: role,
}),
client.rpc('has_same_role_hierarchy_level', {
target_user_id: userId,
target_account_id: accountId,
role_name: role,
}),
]).then(([elevated, sameLevel]) => ({
role,
allowed: elevated.data || sameLevel.data,
})),
),
]);
// Check permission first
if (!permissionResult.data) {
logger.info(ctx, 'User does not have invites.manage permission');
return {
allowed: false,
reason: 'You do not have permission to invite members',
};
}
// Check role hierarchy results
const failedRole = roleResults.find((result) => !result.allowed);
if (failedRole) {
logger.info(
{ ...ctx, role: failedRole.role },
'User cannot invite to a role higher than their own',
);
return {
allowed: false,
reason: `You cannot invite members with the "${failedRole.role}" role`,
};
}
return { allowed: true };
}

View File

@@ -35,10 +35,22 @@ class InvitationContextBuilder {
// Fetch all data in parallel for optimal performance
const account = await this.getAccount(params.accountSlug);
// Fetch subscription and member count in parallel using account ID
return this.buildContextWithAccountId(params, user, account.id);
}
/**
* Build policy context when account ID is already known
* (avoids duplicate account lookup)
*/
async buildContextWithAccountId(
params: z.infer<typeof InviteMembersSchema> & { accountSlug: string },
user: JWTUserData,
accountId: string,
): Promise<FeaturePolicyInvitationContext> {
// Fetch subscription and member count in parallel
const [subscription, memberCount] = await Promise.all([
this.getSubscription(account.id),
this.getMemberCount(account.id),
this.getSubscription(accountId),
this.getMemberCount(accountId),
]);
return {
@@ -52,7 +64,7 @@ class InvitationContextBuilder {
// Invitation-specific fields
accountSlug: params.accountSlug,
accountId: account.id,
accountId,
subscription,
currentMemberCount: memberCount,
invitations: params.invitations,

View File

@@ -139,9 +139,11 @@ class AccountInvitationsService {
async sendInvitations({
accountSlug,
invitations,
invitedBy,
}: {
invitations: z.infer<typeof InviteMembersSchema>['invitations'];
accountSlug: string;
invitedBy: string;
}) {
const logger = await getLogger();
@@ -188,6 +190,7 @@ class AccountInvitationsService {
const response = await this.client.rpc('add_invitations_to_account', {
invitations,
account_slug: accountSlug,
invited_by: invitedBy,
});
if (response.error) {