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
@@ -12,7 +12,10 @@
|
||||
"./api": "./src/server/api.ts",
|
||||
"./components": "./src/components/index.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/orchestrator": "./src/server/policies/orchestrator.ts",
|
||||
"./services/account-invitations.service": "./src/server/services/account-invitations.service.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"nanoid": "^5.1.6"
|
||||
@@ -27,15 +30,16 @@
|
||||
"@kit/monitoring": "workspace:*",
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/otp": "workspace:*",
|
||||
"@kit/policies": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@supabase/supabase-js": "2.58.0",
|
||||
"@tanstack/react-query": "5.90.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/react": "19.1.13",
|
||||
"@types/react": "19.1.15",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -44,7 +48,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",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Spinner } from '@kit/ui/spinner';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -62,6 +65,13 @@ export function InviteMembersDialogContainer({
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { t } = useTranslation('teams');
|
||||
|
||||
// Evaluate policies when dialog is open
|
||||
const {
|
||||
data: policiesResult,
|
||||
isLoading: isLoadingPolicies,
|
||||
error: policiesError,
|
||||
} = useFetchInvitationsPolicies({ accountSlug, isOpen });
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen} modal>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
@@ -77,30 +87,70 @@ export function InviteMembersDialogContainer({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
|
||||
{(roles) => (
|
||||
<InviteMembersForm
|
||||
pending={pending}
|
||||
roles={roles}
|
||||
onSubmit={(data) => {
|
||||
startTransition(() => {
|
||||
const promise = createInvitationsAction({
|
||||
accountSlug,
|
||||
invitations: data.invitations,
|
||||
});
|
||||
<If condition={isLoadingPolicies}>
|
||||
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
|
||||
<Spinner className="h-6 w-6" />
|
||||
|
||||
toast.promise(() => promise, {
|
||||
loading: t('invitingMembers'),
|
||||
success: t('inviteMembersSuccessMessage'),
|
||||
error: t('inviteMembersErrorMessage'),
|
||||
});
|
||||
<span className="text-muted-foreground text-sm">
|
||||
<Trans i18nKey="teams:checkingPolicies" />
|
||||
</span>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
setIsOpen(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</RolesDataProvider>
|
||||
<If condition={policiesError}>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey="teams:policyCheckError"
|
||||
values={{ error: policiesError?.message }}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<If condition={policiesResult && !policiesResult.allowed}>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey={policiesResult?.reasons[0]}
|
||||
defaults={policiesResult?.reasons[0]}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<If condition={policiesResult?.allowed}>
|
||||
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
|
||||
{(roles) => (
|
||||
<InviteMembersForm
|
||||
pending={pending}
|
||||
roles={roles}
|
||||
onSubmit={(data) => {
|
||||
startTransition(async () => {
|
||||
const toastId = toast.loading(t('invitingMembers'));
|
||||
|
||||
const result = await createInvitationsAction({
|
||||
accountSlug,
|
||||
invitations: data.invitations,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(t('inviteMembersSuccessMessage'), {
|
||||
id: toastId,
|
||||
});
|
||||
} else {
|
||||
toast.error(t('inviteMembersErrorMessage'), {
|
||||
id: toastId,
|
||||
});
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</RolesDataProvider>
|
||||
</If>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -275,3 +325,27 @@ function InviteMembersForm({
|
||||
function createEmptyInviteModel() {
|
||||
return { email: '', role: 'member' as Role };
|
||||
}
|
||||
|
||||
function useFetchInvitationsPolicies({
|
||||
accountSlug,
|
||||
isOpen,
|
||||
}: {
|
||||
accountSlug: string;
|
||||
isOpen: boolean;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: ['invitation-policies', accountSlug],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`./members/policies`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
enabled: isOpen,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,5 +38,6 @@ function useFetchRoles(props: { maxRoleHierarchy: number }) {
|
||||
|
||||
return data.map((item) => item.name);
|
||||
},
|
||||
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,14 +6,18 @@ import { redirect } from 'next/navigation';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { JWTUserData } from '@kit/supabase/types';
|
||||
|
||||
import { AcceptInvitationSchema } from '../../schema/accept-invitation.schema';
|
||||
import { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
|
||||
import { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||
import { RenewInvitationSchema } from '../../schema/renew-invitation.schema';
|
||||
import { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
|
||||
import { createInvitationContextBuilder } from '../policies/invitation-context-builder';
|
||||
import { createInvitationsPolicyEvaluator } from '../policies/invitation-policies';
|
||||
import { createAccountInvitationsService } from '../services/account-invitations.service';
|
||||
import { createAccountPerSeatBillingService } from '../services/account-per-seat-billing.service';
|
||||
|
||||
@@ -22,20 +26,47 @@ import { createAccountPerSeatBillingService } from '../services/account-per-seat
|
||||
* @description Creates invitations for inviting members.
|
||||
*/
|
||||
export const createInvitationsAction = enhanceAction(
|
||||
async (params) => {
|
||||
const client = getSupabaseServerClient();
|
||||
async (params, user) => {
|
||||
const logger = await getLogger();
|
||||
|
||||
// Create the service
|
||||
logger.info(
|
||||
{ params, userId: user.id },
|
||||
'User requested to send invitations',
|
||||
);
|
||||
|
||||
// Evaluate invitation policies
|
||||
const policiesResult = await evaluateInvitationsPolicies(params, user);
|
||||
|
||||
// If the invitations are not allowed, throw an error
|
||||
if (!policiesResult.allowed) {
|
||||
logger.info(
|
||||
{ reasons: policiesResult?.reasons, userId: user.id },
|
||||
'Invitations blocked by policies',
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
reasons: policiesResult?.reasons,
|
||||
};
|
||||
}
|
||||
|
||||
// invitations are allowed, so continue with the action
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createAccountInvitationsService(client);
|
||||
|
||||
// send invitations
|
||||
await service.sendInvitations(params);
|
||||
try {
|
||||
await service.sendInvitations(params);
|
||||
|
||||
revalidateMemberPage();
|
||||
revalidateMemberPage();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
schema: InviteMembersSchema.and(
|
||||
@@ -157,3 +188,30 @@ export const renewInvitationAction = enhanceAction(
|
||||
function revalidateMemberPage() {
|
||||
revalidatePath('/home/[account]/members', 'page');
|
||||
}
|
||||
|
||||
/**
|
||||
* @name evaluateInvitationsPolicies
|
||||
* @description Evaluates invitation policies with performance optimization.
|
||||
* @param params - The invitations to evaluate (emails and roles).
|
||||
*/
|
||||
async function evaluateInvitationsPolicies(
|
||||
params: z.infer<typeof InviteMembersSchema> & { accountSlug: string },
|
||||
user: JWTUserData,
|
||||
) {
|
||||
const evaluator = createInvitationsPolicyEvaluator();
|
||||
const hasPolicies = await evaluator.hasPoliciesForStage('submission');
|
||||
|
||||
// No policies to evaluate, skip
|
||||
if (!hasPolicies) {
|
||||
return {
|
||||
allowed: true,
|
||||
reasons: [],
|
||||
};
|
||||
}
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const builder = createInvitationContextBuilder(client);
|
||||
const context = await builder.buildContext(params, user);
|
||||
|
||||
return evaluator.canInvite(context, 'submission');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { PolicyContext, PolicyResult } from '@kit/policies';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
/**
|
||||
* Invitation policy context that extends the base PolicyContext
|
||||
* from @kit/policies for invitation-specific data.
|
||||
*/
|
||||
export interface FeaturePolicyInvitationContext extends PolicyContext {
|
||||
/** The account slug being invited to */
|
||||
accountSlug: string;
|
||||
|
||||
/** The account ID being invited to (same as accountId from base) */
|
||||
accountId: string;
|
||||
|
||||
/** Current subscription data for the account */
|
||||
subscription?: {
|
||||
id: string;
|
||||
status: Database['public']['Enums']['subscription_status'];
|
||||
provider: Database['public']['Enums']['billing_provider'];
|
||||
active: boolean;
|
||||
trial_starts_at?: string;
|
||||
trial_ends_at?: string;
|
||||
items: Array<{
|
||||
id: string;
|
||||
type: Database['public']['Enums']['subscription_item_type'];
|
||||
quantity: number;
|
||||
product_id: string;
|
||||
variant_id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
/** Current number of members in the account */
|
||||
currentMemberCount: number;
|
||||
|
||||
/** The invitations being attempted */
|
||||
invitations: Array<{
|
||||
email: string;
|
||||
role: string;
|
||||
}>;
|
||||
|
||||
/** The user performing the invitation */
|
||||
invitingUser: {
|
||||
id: string;
|
||||
email?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Invitation policy result that extends the base PolicyResult
|
||||
* from @kit/policies while maintaining backward compatibility.
|
||||
*/
|
||||
export interface FeaturePolicyInvitationResult extends PolicyResult {
|
||||
/** Whether the invitations are allowed */
|
||||
allowed: boolean;
|
||||
|
||||
/** Human-readable reason if not allowed */
|
||||
reason?: string;
|
||||
|
||||
/** Additional metadata for logging/debugging */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { createInvitationsPolicyEvaluator } from './invitation-policies';
|
||||
|
||||
// Context building
|
||||
export { createInvitationContextBuilder } from './invitation-context-builder';
|
||||
|
||||
// Type exports
|
||||
export type { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import { JWTUserData } from '@kit/supabase/types';
|
||||
|
||||
import { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||
import type { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';
|
||||
|
||||
/**
|
||||
* Creates an invitation context builder
|
||||
* @param client - The Supabase client
|
||||
* @returns
|
||||
*/
|
||||
export function createInvitationContextBuilder(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
return new InvitationContextBuilder(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invitation context builder
|
||||
*/
|
||||
class InvitationContextBuilder {
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* Build policy context for invitation evaluation with optimized parallel loading
|
||||
*/
|
||||
async buildContext(
|
||||
params: z.infer<typeof InviteMembersSchema> & { accountSlug: string },
|
||||
user: JWTUserData,
|
||||
): Promise<FeaturePolicyInvitationContext> {
|
||||
// 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
|
||||
const [subscription, memberCount] = await Promise.all([
|
||||
this.getSubscription(account.id),
|
||||
this.getMemberCount(account.id),
|
||||
]);
|
||||
|
||||
return {
|
||||
// Base PolicyContext fields
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: {
|
||||
accountSlug: params.accountSlug,
|
||||
invitationCount: params.invitations.length,
|
||||
invitingUserEmail: user.email as string,
|
||||
},
|
||||
|
||||
// Invitation-specific fields
|
||||
accountSlug: params.accountSlug,
|
||||
accountId: account.id,
|
||||
subscription,
|
||||
currentMemberCount: memberCount,
|
||||
invitations: params.invitations,
|
||||
invitingUser: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the account from the database
|
||||
* @param accountSlug - The slug of the account to get
|
||||
* @returns
|
||||
*/
|
||||
private async getAccount(accountSlug: string) {
|
||||
const { data: account } = await this.client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', accountSlug)
|
||||
.single();
|
||||
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the subscription from the database
|
||||
* @param accountId - The ID of the account to get the subscription for
|
||||
* @returns
|
||||
*/
|
||||
private async getSubscription(accountId: string) {
|
||||
const { data: subscription } = await this.client
|
||||
.from('subscriptions')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
status,
|
||||
active,
|
||||
trial_starts_at,
|
||||
trial_ends_at,
|
||||
billing_provider,
|
||||
subscription_items(
|
||||
id,
|
||||
type,
|
||||
quantity,
|
||||
product_id,
|
||||
variant_id
|
||||
)
|
||||
`,
|
||||
)
|
||||
.eq('account_id', accountId)
|
||||
.eq('active', true)
|
||||
.single();
|
||||
|
||||
return subscription
|
||||
? {
|
||||
id: subscription.id,
|
||||
status: subscription.status,
|
||||
provider: subscription.billing_provider,
|
||||
active: subscription.active,
|
||||
trial_starts_at: subscription.trial_starts_at || undefined,
|
||||
trial_ends_at: subscription.trial_ends_at || undefined,
|
||||
items:
|
||||
subscription.subscription_items?.map((item) => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
quantity: item.quantity,
|
||||
product_id: item.product_id,
|
||||
variant_id: item.variant_id,
|
||||
})) || [],
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the member count from the database
|
||||
* @param accountId - The ID of the account to get the member count for
|
||||
* @returns
|
||||
*/
|
||||
private async getMemberCount(accountId: string) {
|
||||
const { count } = await this.client
|
||||
.from('accounts_memberships')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('account_id', accountId);
|
||||
|
||||
return count || 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createPoliciesEvaluator } from '@kit/policies';
|
||||
|
||||
import type { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';
|
||||
import { invitationPolicyRegistry } from './policies';
|
||||
|
||||
/**
|
||||
* Creates an invitation evaluator
|
||||
*/
|
||||
export function createInvitationsPolicyEvaluator() {
|
||||
const evaluator = createPoliciesEvaluator<FeaturePolicyInvitationContext>();
|
||||
|
||||
return {
|
||||
/**
|
||||
* Checks if there are any invitation policies for the given stage
|
||||
* @param stage - The stage to check if there are any invitation policies for
|
||||
*/
|
||||
async hasPoliciesForStage(stage: 'preliminary' | 'submission') {
|
||||
return evaluator.hasPoliciesForStage(invitationPolicyRegistry, stage);
|
||||
},
|
||||
|
||||
/**
|
||||
* Evaluates the invitation policies for the given context and stage
|
||||
* @param context - The context for the invitation policy
|
||||
* @param stage - The stage to evaluate the invitation policies for
|
||||
* @returns
|
||||
*/
|
||||
async canInvite(
|
||||
context: FeaturePolicyInvitationContext,
|
||||
stage: 'preliminary' | 'submission',
|
||||
) {
|
||||
return evaluator.evaluate(
|
||||
invitationPolicyRegistry,
|
||||
context,
|
||||
'ALL',
|
||||
stage,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { allow, definePolicy, deny } from '@kit/policies';
|
||||
import { createPolicyRegistry } from '@kit/policies';
|
||||
|
||||
import { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';
|
||||
|
||||
/**
|
||||
* Feature-specific registry for invitation policies
|
||||
*/
|
||||
export const invitationPolicyRegistry = createPolicyRegistry();
|
||||
|
||||
/**
|
||||
* Subscription required policy
|
||||
* Checks if the account has an active subscription
|
||||
*/
|
||||
export const subscriptionRequiredInvitationsPolicy =
|
||||
definePolicy<FeaturePolicyInvitationContext>({
|
||||
id: 'subscription-required',
|
||||
stages: ['preliminary', 'submission'],
|
||||
evaluate: async ({ subscription }) => {
|
||||
if (!subscription || !subscription.active) {
|
||||
return deny({
|
||||
code: 'SUBSCRIPTION_REQUIRED',
|
||||
message: 'teams:policyErrors.subscriptionRequired',
|
||||
remediation: 'teams:policyRemediation.subscriptionRequired',
|
||||
});
|
||||
}
|
||||
|
||||
return allow();
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Paddle billing policy
|
||||
* Checks if the account has a paddle subscription and is in a trial period
|
||||
*/
|
||||
export const paddleBillingInvitationsPolicy =
|
||||
definePolicy<FeaturePolicyInvitationContext>({
|
||||
id: 'paddle-billing',
|
||||
stages: ['preliminary', 'submission'],
|
||||
evaluate: async ({ subscription }) => {
|
||||
// combine with subscriptionRequiredPolicy if subscription must be required
|
||||
if (!subscription) {
|
||||
return allow();
|
||||
}
|
||||
|
||||
// Paddle specific constraint: cannot update subscription items during trial
|
||||
if (
|
||||
subscription.provider === 'paddle' &&
|
||||
subscription.status === 'trialing'
|
||||
) {
|
||||
const hasPerSeatItems = subscription.items.some(
|
||||
(item) => item.type === 'per_seat',
|
||||
);
|
||||
|
||||
if (hasPerSeatItems) {
|
||||
return deny({
|
||||
code: 'PADDLE_TRIAL_RESTRICTION',
|
||||
message: 'teams:policyErrors.paddleTrialRestriction',
|
||||
remediation: 'teams:policyRemediation.paddleTrialRestriction',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allow();
|
||||
},
|
||||
});
|
||||
|
||||
// register policies below to apply them
|
||||
//
|
||||
//
|
||||
@@ -12,6 +12,10 @@ import type { DeleteInvitationSchema } from '../../schema/delete-invitation.sche
|
||||
import type { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||
import type { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
|
||||
|
||||
/**
|
||||
*
|
||||
* Create an account invitations service.
|
||||
*/
|
||||
export function createAccountInvitationsService(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
|
||||
@@ -44,7 +44,8 @@ class AccountPerSeatBillingService {
|
||||
subscription_items !inner (
|
||||
quantity,
|
||||
id,
|
||||
type
|
||||
type,
|
||||
variant_id
|
||||
)
|
||||
`,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user