Feature Policies API + Invitations Policies (#375)
- Added Feature Policy API: a declarative system to enable/disable/modify default behavior in the SaaS kit - Team invitation policies with pre-checks using the Feature Policy API: Invite Members dialog now shows loading, errors, and clear reasons when invitations are blocked - Version bump to 2.16.0 and widespread dependency updates (Supabase, React types, react-i18next, etc.). - Added comprehensive docs for the new policy system and orchestrators. - Subscription cancellations now trigger immediate invoicing explicitly
This commit is contained in:
committed by
GitHub
parent
3c13b5ec1e
commit
1dd6fdad22
@@ -8,11 +8,11 @@
|
||||
"format": "prettier --check --write \"**/*.{js,cjs,mjs,ts,tsx,md,json}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^2.0.34",
|
||||
"@ai-sdk/openai": "^2.0.38",
|
||||
"@faker-js/faker": "^10.0.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@tanstack/react-query": "5.90.2",
|
||||
"ai": "5.0.51",
|
||||
"ai": "5.0.56",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "15.5.4",
|
||||
"nodemailer": "^7.0.6",
|
||||
@@ -32,7 +32,7 @@
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/nodemailer": "7.0.1",
|
||||
"@types/react": "19.1.13",
|
||||
"@types/react": "19.1.15",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"pino-pretty": "13.0.0",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"author": "Makerkit",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@supabase/supabase-js": "2.58.0",
|
||||
"@types/node": "^24.5.2",
|
||||
"dotenv": "17.2.2",
|
||||
"node-html-parser": "^7.0.1",
|
||||
|
||||
@@ -115,18 +115,17 @@ export class InvitationsPageObject {
|
||||
async acceptInvitation() {
|
||||
console.log('Accepting invitation...');
|
||||
|
||||
const click = this.page
|
||||
.locator('[data-test="join-team-form"] button[type="submit"]')
|
||||
.click();
|
||||
|
||||
const response = this.page.waitForResponse((response) => {
|
||||
return (
|
||||
response.url().includes('/join') &&
|
||||
response.request().method() === 'POST'
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.all([click, response]);
|
||||
await Promise.all([
|
||||
this.page
|
||||
.locator('[data-test="join-team-form"] button[type="submit"]')
|
||||
.click(),
|
||||
this.page.waitForResponse((response) => {
|
||||
return (
|
||||
response.url().includes('/join') &&
|
||||
response.request().method() === 'POST'
|
||||
);
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log('Invitation accepted');
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ test.describe('Team Invitation with MFA Flow', () => {
|
||||
const invitations = new InvitationsPageObject(page);
|
||||
|
||||
await auth.loginAsUser({
|
||||
email: 'test@makerkit.dev',
|
||||
email: 'owner@makerkit.dev',
|
||||
});
|
||||
|
||||
const teamName = `test-team-${Math.random().toString(36).substring(2, 15)}`;
|
||||
|
||||
69
apps/web/app/home/[account]/members/policies/route.ts
Normal file
69
apps/web/app/home/[account]/members/policies/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import {
|
||||
createInvitationContextBuilder,
|
||||
createInvitationsPolicyEvaluator,
|
||||
} from '@kit/team-accounts/policies';
|
||||
|
||||
export const GET = enhanceRouteHandler(
|
||||
async function ({ params, user }) {
|
||||
const client = getSupabaseServerClient();
|
||||
const { account } = z.object({ account: z.string() }).parse(params);
|
||||
|
||||
try {
|
||||
// Evaluate with standard evaluator
|
||||
const evaluator = createInvitationsPolicyEvaluator();
|
||||
const hasPolicies = await evaluator.hasPoliciesForStage('preliminary');
|
||||
|
||||
if (!hasPolicies) {
|
||||
return NextResponse.json({
|
||||
allowed: true,
|
||||
reasons: [],
|
||||
metadata: {
|
||||
policiesEvaluated: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
noPoliciesConfigured: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Build context for policy evaluation (empty invitations for testing)
|
||||
const contextBuilder = createInvitationContextBuilder(client);
|
||||
|
||||
const context = await contextBuilder.buildContext(
|
||||
{
|
||||
invitations: [],
|
||||
accountSlug: account,
|
||||
},
|
||||
user,
|
||||
);
|
||||
|
||||
// validate against policies
|
||||
const result = await evaluator.canInvite(context, 'preliminary');
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
allowed: false,
|
||||
reasons: [
|
||||
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
],
|
||||
metadata: {
|
||||
error: true,
|
||||
originalError:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
},
|
||||
);
|
||||
@@ -56,7 +56,7 @@
|
||||
"@marsidev/react-turnstile": "^1.3.1",
|
||||
"@nosecone/next": "1.0.0-beta.12",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@supabase/supabase-js": "2.58.0",
|
||||
"@tanstack/react-query": "5.90.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -67,7 +67,7 @@
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"react-hook-form": "^7.63.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-i18next": "^16.0.0",
|
||||
"recharts": "2.15.3",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^3.25.74"
|
||||
@@ -79,13 +79,13 @@
|
||||
"@next/bundle-analyzer": "15.5.4",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/react": "19.1.13",
|
||||
"@types/react": "19.1.15",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"cssnano": "^7.1.1",
|
||||
"pino-pretty": "13.0.0",
|
||||
"prettier": "^3.6.2",
|
||||
"supabase": "2.45.5",
|
||||
"supabase": "2.47.2",
|
||||
"tailwindcss": "4.1.13",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.9.2"
|
||||
|
||||
@@ -159,5 +159,17 @@
|
||||
"leaveTeamInputLabel": "Please type LEAVE to confirm leaving the team.",
|
||||
"leaveTeamInputDescription": "By leaving the team, you will no longer have access to it.",
|
||||
"reservedNameError": "This name is reserved. Please choose a different one.",
|
||||
"specialCharactersError": "This name cannot contain special characters. Please choose a different one."
|
||||
"specialCharactersError": "This name cannot contain special characters. Please choose a different one.",
|
||||
"checkingPolicies": "Loading. Please wait...",
|
||||
"policyCheckError": "We are unable to verify invitations restrictions. Please try again.",
|
||||
"invitationsBlockedMultiple": "Invitations are currently not allowed for the following reasons:",
|
||||
"invitationsBlockedDefault": "Invitations are currently not allowed due to policy restrictions.",
|
||||
"policyErrors": {
|
||||
"subscriptionRequired": "An active subscription is required to invite team members.",
|
||||
"paddleTrialRestriction": "Cannot invite members during trial period with per-seat billing on Paddle."
|
||||
},
|
||||
"policyRemediation": {
|
||||
"subscriptionRequired": "Please upgrade your plan or activate your subscription",
|
||||
"paddleTrialRestriction": "Wait until trial period ends or upgrade to full plan"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user