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:
committed by
GitHub
parent
5237d34e6f
commit
d5dc6f2528
@@ -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:",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user