mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
[Website] Self-host billing migration and some responsiveness fixes. (#19894)
Closes the following issues. https://github.com/twentyhq/core-team-issues/issues/2371 https://github.com/twentyhq/core-team-issues/issues/2379 https://github.com/twentyhq/core-team-issues/issues/2383
This commit is contained in:
parent
755f1c92d1
commit
83bc6d1a1b
28 changed files with 1229 additions and 229 deletions
|
|
@ -1 +1,16 @@
|
|||
PARTNER_APPLICATION_WEBHOOK_URL=
|
||||
|
||||
# Public site URL (used for Stripe checkout success URL and billing portal return)
|
||||
NEXT_PUBLIC_WEBSITE_URL=
|
||||
|
||||
# Stripe — self-hosted enterprise checkout & subscription APIs
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=
|
||||
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=
|
||||
|
||||
# RS256 key pair used to sign enterprise license JWTs (use literal \n in PEM for env)
|
||||
ENTERPRISE_JWT_PRIVATE_KEY=
|
||||
ENTERPRISE_JWT_PUBLIC_KEY=
|
||||
|
||||
# Optional: short-lived validity token length in days (default 30)
|
||||
# ENTERPRISE_VALIDITY_TOKEN_DURATION_DAYS=
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"stripe": "^20.3.1",
|
||||
"three": "^0.183.2",
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import { signEnterpriseKey } from '@/shared/enterprise/enterprise-jwt';
|
||||
import { getStripeClient } from '@/shared/enterprise/stripe-client';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const sessionId = url.searchParams.get('session_id');
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing session_id parameter' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId, {
|
||||
expand: ['subscription', 'customer'],
|
||||
});
|
||||
|
||||
// Subscriptions that begin in a free trial complete with
|
||||
// `payment_status: 'no_payment_required'`, so accept both successful states.
|
||||
const SUCCESSFUL_PAYMENT_STATUSES: Array<typeof session.payment_status> = [
|
||||
'paid',
|
||||
'no_payment_required',
|
||||
];
|
||||
|
||||
if (
|
||||
session.status !== 'complete' ||
|
||||
!SUCCESSFUL_PAYMENT_STATUSES.includes(session.payment_status)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Checkout session is not completed' },
|
||||
{ status: 402 },
|
||||
);
|
||||
}
|
||||
|
||||
const subscription = session.subscription;
|
||||
|
||||
if (!subscription || typeof subscription === 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Subscription not found' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const ACTIVATABLE_SUBSCRIPTION_STATUSES: Array<typeof subscription.status> =
|
||||
['active', 'trialing'];
|
||||
|
||||
if (!ACTIVATABLE_SUBSCRIPTION_STATUSES.includes(subscription.status)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Subscription is not active',
|
||||
status: subscription.status,
|
||||
},
|
||||
{ status: 402 },
|
||||
);
|
||||
}
|
||||
|
||||
const customer = session.customer;
|
||||
const licensee =
|
||||
customer && typeof customer !== 'string' && !customer.deleted
|
||||
? (customer.name ?? customer.email ?? 'Unknown')
|
||||
: 'Unknown';
|
||||
|
||||
const enterpriseKey = signEnterpriseKey(subscription.id, licensee);
|
||||
|
||||
return NextResponse.json({
|
||||
enterpriseKey,
|
||||
licensee,
|
||||
subscriptionId: subscription.id,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `Activation error: ${message}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import {
|
||||
getEnterprisePriceId,
|
||||
getStripeClient,
|
||||
} from '@/shared/enterprise/stripe-client';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
const body = await request.json();
|
||||
const billingInterval =
|
||||
body.billingInterval === 'yearly' ? 'yearly' : 'monthly';
|
||||
const priceId = getEnterprisePriceId(billingInterval);
|
||||
const websiteUrl = process.env.NEXT_PUBLIC_WEBSITE_URL;
|
||||
const defaultSuccessUrl = websiteUrl
|
||||
? `${websiteUrl}/enterprise/activate?session_id={CHECKOUT_SESSION_ID}`
|
||||
: undefined;
|
||||
const successUrl = body.successUrl ?? defaultSuccessUrl;
|
||||
|
||||
if (!successUrl || typeof successUrl !== 'string') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Missing successUrl or NEXT_PUBLIC_WEBSITE_URL for checkout redirect',
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: body.seatCount ?? 1,
|
||||
},
|
||||
],
|
||||
success_url: successUrl,
|
||||
subscription_data: {
|
||||
trial_period_days: 30,
|
||||
metadata: {
|
||||
source: 'enterprise-self-hosted',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `Checkout error: ${message}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { verifyEnterpriseKey } from '@/shared/enterprise/enterprise-jwt';
|
||||
import { getStripeClient } from '@/shared/enterprise/stripe-client';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { enterpriseKey, returnUrl } = body;
|
||||
|
||||
if (!enterpriseKey || typeof enterpriseKey !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing enterpriseKey' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const payload = verifyEnterpriseKey(enterpriseKey);
|
||||
|
||||
if (!payload) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid enterprise key' },
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
const subscription = await stripe.subscriptions.retrieve(payload.sub);
|
||||
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string'
|
||||
? subscription.customer
|
||||
: subscription.customer.id;
|
||||
|
||||
const frontendUrl = process.env.NEXT_PUBLIC_WEBSITE_URL;
|
||||
|
||||
if (!frontendUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NEXT_PUBLIC_WEBSITE_URL is not configured' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const fullReturnUrl = returnUrl
|
||||
? `${frontendUrl}${returnUrl}`
|
||||
: frontendUrl;
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: fullReturnUrl,
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `Portal error: ${message}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { verifyEnterpriseKey } from '@/shared/enterprise/enterprise-jwt';
|
||||
import { getStripeClient } from '@/shared/enterprise/stripe-client';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { enterpriseKey, seatCount } = body;
|
||||
|
||||
if (!enterpriseKey || typeof enterpriseKey !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing enterpriseKey' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof seatCount !== 'number' || seatCount < 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid seatCount' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const payload = verifyEnterpriseKey(enterpriseKey);
|
||||
|
||||
if (!payload) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid enterprise key' },
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(payload.sub);
|
||||
|
||||
const NON_UPDATABLE_STATUSES = ['canceled', 'incomplete_expired'];
|
||||
|
||||
if (
|
||||
NON_UPDATABLE_STATUSES.includes(subscription.status) ||
|
||||
subscription.cancel_at_period_end
|
||||
) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
reason: 'Subscription is canceled or scheduled for cancellation',
|
||||
seatCount: subscription.items.data[0]?.quantity ?? 0,
|
||||
subscriptionId: payload.sub,
|
||||
});
|
||||
}
|
||||
|
||||
if (!subscription.items.data[0]) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No subscription item found' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const subscriptionItemId = subscription.items.data[0].id;
|
||||
|
||||
await stripe.subscriptions.update(payload.sub, {
|
||||
items: [
|
||||
{
|
||||
id: subscriptionItemId,
|
||||
quantity: seatCount,
|
||||
},
|
||||
],
|
||||
proration_behavior: 'create_prorations',
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
seatCount,
|
||||
subscriptionId: payload.sub,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `Seat update error: ${message}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { verifyEnterpriseKey } from '@/shared/enterprise/enterprise-jwt';
|
||||
import { getStripeClient } from '@/shared/enterprise/stripe-client';
|
||||
import { getSubscriptionCurrentPeriodEnd } from '@/shared/enterprise/stripe-subscription-helpers';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { enterpriseKey } = body;
|
||||
|
||||
if (!enterpriseKey || typeof enterpriseKey !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing enterpriseKey' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const payload = verifyEnterpriseKey(enterpriseKey);
|
||||
|
||||
if (!payload) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid enterprise key' },
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
const subscription = await stripe.subscriptions.retrieve(payload.sub);
|
||||
|
||||
const rawCancelAt = subscription.cancel_at;
|
||||
const rawCancelAtPeriodEnd = subscription.cancel_at_period_end;
|
||||
const rawCurrentPeriodEnd = getSubscriptionCurrentPeriodEnd(subscription);
|
||||
|
||||
const effectiveCancelAt =
|
||||
rawCancelAt ??
|
||||
(rawCancelAtPeriodEnd && rawCurrentPeriodEnd
|
||||
? rawCurrentPeriodEnd
|
||||
: null);
|
||||
|
||||
const isCancellationScheduled =
|
||||
subscription.status !== 'canceled' && effectiveCancelAt !== null;
|
||||
|
||||
return NextResponse.json({
|
||||
subscriptionId: subscription.id,
|
||||
status: subscription.status,
|
||||
cancelAt: effectiveCancelAt,
|
||||
currentPeriodEnd: rawCurrentPeriodEnd,
|
||||
isCancellationScheduled,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `Status error: ${message}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import {
|
||||
signValidityToken,
|
||||
verifyEnterpriseKey,
|
||||
} from '@/shared/enterprise/enterprise-jwt';
|
||||
import { getStripeClient } from '@/shared/enterprise/stripe-client';
|
||||
import { getSubscriptionCurrentPeriodEnd } from '@/shared/enterprise/stripe-subscription-helpers';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { enterpriseKey } = body;
|
||||
|
||||
if (!enterpriseKey || typeof enterpriseKey !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing enterpriseKey' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const payload = verifyEnterpriseKey(enterpriseKey);
|
||||
|
||||
if (!payload) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid enterprise key' },
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(payload.sub);
|
||||
|
||||
const activeStatuses = ['active', 'trialing'];
|
||||
|
||||
if (!activeStatuses.includes(subscription.status)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Subscription is not active',
|
||||
status: subscription.status,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const rawCancelAt = subscription.cancel_at;
|
||||
const rawCancelAtPeriodEnd = subscription.cancel_at_period_end;
|
||||
const rawCurrentPeriodEnd = getSubscriptionCurrentPeriodEnd(subscription);
|
||||
const effectiveCancelAt =
|
||||
rawCancelAt ??
|
||||
(rawCancelAtPeriodEnd && rawCurrentPeriodEnd
|
||||
? rawCurrentPeriodEnd
|
||||
: null);
|
||||
|
||||
const validityToken = signValidityToken(payload.sub, {
|
||||
subscriptionCancelAt: effectiveCancelAt,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
validityToken,
|
||||
licensee: payload.licensee,
|
||||
subscriptionId: payload.sub,
|
||||
subscriptionStatus: subscription.status,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `Validation error: ${message}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
'use client';
|
||||
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type ActivationResult = {
|
||||
enterpriseKey: string;
|
||||
licensee: string;
|
||||
subscriptionId: string;
|
||||
};
|
||||
|
||||
const PageWrap = styled.div`
|
||||
box-sizing: border-box;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-top: ${theme.spacing(12)};
|
||||
max-width: 700px;
|
||||
min-height: 60vh;
|
||||
padding-left: ${theme.spacing(4)};
|
||||
padding-right: ${theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: ${theme.font.size(8)};
|
||||
font-weight: 600;
|
||||
margin-bottom: ${theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const ErrorBox = styled.div`
|
||||
background-color: ${theme.colors.primary.border[5]};
|
||||
border: 1px solid ${theme.colors.accent.pink[70]};
|
||||
border-radius: ${theme.radius(2)};
|
||||
color: ${theme.colors.accent.pink[100]};
|
||||
padding: ${theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const SuccessLead = styled.p`
|
||||
color: ${theme.colors.accent.green[100]};
|
||||
margin-bottom: ${theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const LicenseeRow = styled.div`
|
||||
margin-bottom: ${theme.spacing(6)};
|
||||
`;
|
||||
|
||||
const KeyLabel = styled.div`
|
||||
font-weight: 600;
|
||||
margin-bottom: ${theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const KeyHint = styled.p`
|
||||
color: ${theme.colors.primary.text[60]};
|
||||
font-size: ${theme.font.size(3)};
|
||||
margin-bottom: ${theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const KeyBlock = styled.div`
|
||||
background-color: ${theme.colors.primary.border[5]};
|
||||
border: 1px solid ${theme.colors.primary.border[20]};
|
||||
border-radius: ${theme.radius(2)};
|
||||
font-family: ${theme.font.family.mono};
|
||||
font-size: ${theme.font.size(2)};
|
||||
line-height: 1.5;
|
||||
padding: ${theme.spacing(4)};
|
||||
padding-right: ${theme.spacing(16)};
|
||||
position: relative;
|
||||
word-break: break-all;
|
||||
`;
|
||||
|
||||
const CopyButton = styled.button<{ $copied: boolean }>`
|
||||
background-color: ${({ $copied }) =>
|
||||
$copied ? theme.colors.accent.green[100] : theme.colors.primary.text[100]};
|
||||
border: none;
|
||||
border-radius: ${theme.radius(1)};
|
||||
color: ${theme.colors.primary.background[100]};
|
||||
cursor: pointer;
|
||||
font-size: ${theme.font.size(2)};
|
||||
padding: ${theme.spacing(2)} ${theme.spacing(3)};
|
||||
position: absolute;
|
||||
right: ${theme.spacing(2)};
|
||||
top: ${theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const NextStepsBox = styled.div`
|
||||
background-color: ${theme.colors.primary.border[5]};
|
||||
border: 1px solid ${theme.colors.accent.blue[70]};
|
||||
border-radius: ${theme.radius(2)};
|
||||
margin-top: ${theme.spacing(8)};
|
||||
padding: ${theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const NextStepsList = styled.ol`
|
||||
line-height: 1.75;
|
||||
margin-top: ${theme.spacing(2)};
|
||||
padding-left: ${theme.spacing(5)};
|
||||
`;
|
||||
|
||||
export function EnterpriseActivateClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const sessionId = searchParams.get('session_id');
|
||||
const [result, setResult] = useState<ActivationResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setError('No session ID provided. Please complete the checkout first.');
|
||||
setLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const activate = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/enterprise/activate?session_id=${encodeURIComponent(sessionId)}`,
|
||||
);
|
||||
const data: { error?: string } & Partial<ActivationResult> =
|
||||
await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error ?? 'Activation failed');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.enterpriseKey && data.licensee && data.subscriptionId) {
|
||||
setResult({
|
||||
enterpriseKey: data.enterpriseKey,
|
||||
licensee: data.licensee,
|
||||
subscriptionId: data.subscriptionId,
|
||||
});
|
||||
} else {
|
||||
setError('Activation response was incomplete.');
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to activate enterprise key. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void activate();
|
||||
}, [sessionId]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(result.enterpriseKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrap>
|
||||
<Title>{'Enterprise activation'}</Title>
|
||||
|
||||
{loading && <p>{'Activating your enterprise license…'}</p>}
|
||||
|
||||
{error !== null && <ErrorBox>{error}</ErrorBox>}
|
||||
|
||||
{result !== null && (
|
||||
<div>
|
||||
<SuccessLead>
|
||||
{'Your enterprise license has been activated successfully.'}
|
||||
</SuccessLead>
|
||||
|
||||
<LicenseeRow>
|
||||
<strong>{'Licensee:'}</strong> {result.licensee}
|
||||
</LicenseeRow>
|
||||
|
||||
<KeyLabel>{'Your enterprise key'}</KeyLabel>
|
||||
<KeyHint>
|
||||
{
|
||||
'Copy this key and paste it into your Twenty self-hosted instance settings.'
|
||||
}
|
||||
</KeyHint>
|
||||
|
||||
<KeyBlock>
|
||||
{result.enterpriseKey}
|
||||
<CopyButton
|
||||
$copied={copied}
|
||||
onClick={() => void handleCopy()}
|
||||
type="button"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</CopyButton>
|
||||
</KeyBlock>
|
||||
|
||||
<NextStepsBox>
|
||||
<strong>{'Next steps'}</strong>
|
||||
<NextStepsList>
|
||||
<li>{'Copy the enterprise key above.'}</li>
|
||||
<li>
|
||||
{'Open your Twenty self-hosted instance Settings → Enterprise.'}
|
||||
</li>
|
||||
<li>{'Paste the key and click Activate.'}</li>
|
||||
</NextStepsList>
|
||||
</NextStepsBox>
|
||||
</div>
|
||||
)}
|
||||
</PageWrap>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { theme } from '@/theme';
|
||||
import { css } from '@linaria/core';
|
||||
import type { Metadata } from 'next';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { EnterpriseActivateClient } from './EnterpriseActivateClient';
|
||||
|
||||
const activateFallbackClassName = css`
|
||||
box-sizing: border-box;
|
||||
color: ${theme.colors.primary.text[60]};
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-top: ${theme.spacing(12)};
|
||||
max-width: 700px;
|
||||
padding-left: ${theme.spacing(4)};
|
||||
padding-right: ${theme.spacing(4)};
|
||||
`;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Enterprise activation | Twenty',
|
||||
description:
|
||||
'Complete activation for your Twenty self-hosted enterprise license.',
|
||||
};
|
||||
|
||||
function EnterpriseActivateFallback() {
|
||||
return (
|
||||
<div className={activateFallbackClassName}>{'Loading activation…'}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EnterpriseActivatePage() {
|
||||
return (
|
||||
<Suspense fallback={<EnterpriseActivateFallback />}>
|
||||
<EnterpriseActivateClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,12 +13,6 @@ const GLB_URL = '/illustrations/why-twenty/stepper/logo.glb';
|
|||
const VisualColumn = styled.div`
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
max-width: 672px;
|
||||
position: sticky;
|
||||
top: ${theme.spacing(10)};
|
||||
}
|
||||
`;
|
||||
|
||||
const VisualContainer = styled.div`
|
||||
|
|
@ -31,8 +25,10 @@ const VisualContainer = styled.div`
|
|||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
height: 705px;
|
||||
max-width: 672px;
|
||||
aspect-ratio: 672 / 705;
|
||||
height: auto;
|
||||
max-height: 705px;
|
||||
min-height: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -12,14 +12,24 @@ import { Card } from '../Card/Card';
|
|||
const GUIDE_INTERSECTION_TOP = '176px';
|
||||
|
||||
const helpedHeadingClassName = css`
|
||||
&[data-size='xl'] {
|
||||
font-size: clamp(${theme.font.size(8)}, 9.5vw, ${theme.font.size(15)});
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
max-width: 760px;
|
||||
white-space: pre-line;
|
||||
|
||||
&[data-size='xl'] {
|
||||
font-size: ${theme.font.size(20)};
|
||||
line-height: ${theme.lineHeight(21.5)};
|
||||
}
|
||||
|
||||
[data-family='sans'] {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ScrollStage = styled.section`
|
||||
|
|
@ -96,7 +106,11 @@ export function Scene({ data }: SceneProps) {
|
|||
sectionRef={sectionRef}
|
||||
/>
|
||||
<StickyInner ref={innerRef}>
|
||||
<GuideCrosshair crossX="50%" crossY={GUIDE_INTERSECTION_TOP} zIndex={0} />
|
||||
<GuideCrosshair
|
||||
crossX="50%"
|
||||
crossY={GUIDE_INTERSECTION_TOP}
|
||||
zIndex={0}
|
||||
/>
|
||||
<HeadlineBlock>
|
||||
<EyebrowExitTarget data-helped-exit-target>
|
||||
<Eyebrow colorScheme="primary" heading={data.eyebrow.heading} />
|
||||
|
|
|
|||
|
|
@ -11,10 +11,22 @@ const StyledRightColumn = styled.div`
|
|||
}
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
align-items: center;
|
||||
align-self: start;
|
||||
max-width: 672px;
|
||||
display: flex;
|
||||
height: calc(100vh - 4.5rem);
|
||||
justify-content: center;
|
||||
position: sticky;
|
||||
top: calc(4.5rem + (100vh - 4.5rem) * 0.5 - 368px);
|
||||
top: 4.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const VisualFrame = styled.div`
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
max-width: 672px;
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
@ -23,5 +35,9 @@ type RightColumnProps = {
|
|||
};
|
||||
|
||||
export function RightColumn({ children }: RightColumnProps) {
|
||||
return <StyledRightColumn>{children}</StyledRightColumn>;
|
||||
return (
|
||||
<StyledRightColumn>
|
||||
<VisualFrame>{children}</VisualFrame>
|
||||
</StyledRightColumn>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const StyledContainer = styled(Container)`
|
|||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
align-items: start;
|
||||
column-gap: ${theme.spacing(10)};
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 672px);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
padding-bottom: ${theme.spacing(20)};
|
||||
padding-left: ${theme.spacing(10)};
|
||||
padding-right: ${theme.spacing(10)};
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export function StepperVisualFrame({
|
|||
alt=""
|
||||
className={patternImageClassName}
|
||||
fill
|
||||
sizes="(min-width: 921px) 672px, 100vw"
|
||||
sizes="(min-width: 921px) 50vw, 100vw"
|
||||
src={backgroundSrc}
|
||||
/>
|
||||
</PatternBackdrop>
|
||||
|
|
@ -132,7 +132,7 @@ export function StepperVisualFrame({
|
|||
className={shapeImageClassName}
|
||||
fill
|
||||
priority={false}
|
||||
sizes="(min-width: 921px) 672px, 100vw"
|
||||
sizes="(min-width: 921px) 50vw, 100vw"
|
||||
src={shapeSrc}
|
||||
/>
|
||||
</ShapeOverlay>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const Grid = styled(Container)`
|
|||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
align-items: start;
|
||||
column-gap: ${theme.spacing(10)};
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 672px);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
min-height: 100%;
|
||||
padding-bottom: ${theme.spacing(20)};
|
||||
padding-left: ${theme.spacing(10)};
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export function StepperVisualFrame({
|
|||
className={shapeImageClassName}
|
||||
fill
|
||||
priority={false}
|
||||
sizes="(min-width: 921px) 672px, 100vw"
|
||||
sizes="(min-width: 921px) 50vw, 100vw"
|
||||
src={shapeSrc}
|
||||
/>
|
||||
</ShapeOverlay>
|
||||
|
|
@ -70,7 +70,7 @@ export function StepperVisualFrame({
|
|||
alt=""
|
||||
className={patternImageClassName}
|
||||
fill
|
||||
sizes="(min-width: 921px) 672px, 100vw"
|
||||
sizes="(min-width: 921px) 50vw, 100vw"
|
||||
src={backgroundSrc}
|
||||
/>
|
||||
</PatternBackdrop>
|
||||
|
|
|
|||
|
|
@ -17,9 +17,22 @@ const VisualColumn = styled.div`
|
|||
}
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
max-width: 672px;
|
||||
align-items: center;
|
||||
align-self: start;
|
||||
display: flex;
|
||||
height: calc(100vh - 4.5rem);
|
||||
justify-content: center;
|
||||
position: sticky;
|
||||
top: calc(4.5rem + (100vh - 4.5rem) * 0.5 - 368px);
|
||||
top: 4.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const VisualFrame = styled.div`
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
max-width: 672px;
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
@ -41,6 +54,7 @@ export function Visual({ activeStepIndex, images }: ProductStepperVisualProps) {
|
|||
|
||||
return (
|
||||
<VisualColumn>
|
||||
<VisualFrame>
|
||||
<StepperVisualFrame
|
||||
backgroundSrc={PRODUCT_STEPPER_BACKGROUND}
|
||||
shapeSrc={PRODUCT_STEPPER_SHAPE}
|
||||
|
|
@ -55,12 +69,13 @@ export function Visual({ activeStepIndex, images }: ProductStepperVisualProps) {
|
|||
className={slideImageClassName}
|
||||
data-active={String(index === activeStepIndex)}
|
||||
fill
|
||||
sizes="(min-width: 921px) 672px, 100vw"
|
||||
sizes="(min-width: 921px) 50vw, 100vw"
|
||||
src={image.src}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</StepperVisualFrame>
|
||||
</VisualFrame>
|
||||
</VisualColumn>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,15 +7,6 @@ import {
|
|||
} from '@/lib/shared-asset-paths';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { FamiliarInterfaceGradientBackdrop } from './FamiliarInterfaceGradientBackdrop';
|
||||
import {
|
||||
IconBuildingSkyscraper,
|
||||
IconCalendarEvent,
|
||||
|
|
@ -28,14 +19,22 @@ import {
|
|||
IconUser,
|
||||
IconUserCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { FamiliarInterfaceGradientBackdrop } from './FamiliarInterfaceGradientBackdrop';
|
||||
|
||||
const APP_FONT = `'Inter', ${theme.font.family.sans}`;
|
||||
const TABLER_STROKE = 1.6;
|
||||
const SCENE_WIDTH = 411;
|
||||
const SCENE_HEIGHT = 508;
|
||||
const SCENE_SCALE = 1.025;
|
||||
const SCENE_SCALE_MD = SCENE_SCALE * 0.86;
|
||||
const SCENE_SCALE_SM = SCENE_SCALE * 0.74;
|
||||
const SCENE_BASE_SCALE = 1.025;
|
||||
const FAMILIAR_INTERFACE_SCENE_VIEWPORT_TRANSFORM = `translateX(-50%) scale(calc(${SCENE_BASE_SCALE} * min(100cqw / ${SCENE_WIDTH}px, 100cqh / ${SCENE_HEIGHT}px)))`;
|
||||
const FIGMA_CARD_WIDTH = 174.301;
|
||||
const FIGMA_FIELD_HEIGHT = 22.063;
|
||||
const FIGMA_FIELD_GAP = 3.677;
|
||||
|
|
@ -299,17 +298,9 @@ const SceneViewport = styled.div`
|
|||
left: 50%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%) scale(${SCENE_SCALE});
|
||||
transform: ${FAMILIAR_INTERFACE_SCENE_VIEWPORT_TRANSFORM};
|
||||
transform-origin: top center;
|
||||
width: ${SCENE_WIDTH}px;
|
||||
|
||||
@media (max-width: ${theme.breakpoints.md - 1}px) {
|
||||
transform: translateX(-50%) scale(${SCENE_SCALE_MD});
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
transform: translateX(-50%) scale(${SCENE_SCALE_SM});
|
||||
}
|
||||
`;
|
||||
|
||||
const SceneFrame = styled.div`
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ import { FastPathGradientBackdrop } from './FastPathGradientBackdrop';
|
|||
const APP_FONT = `'Inter', ${theme.font.family.sans}`;
|
||||
const FAST_PATH_NOISE_BACKGROUND =
|
||||
'url("/images/home/three-cards-feature/fast-path-background-noise.webp")';
|
||||
const SCENE_DESIGN_WIDTH = 411;
|
||||
const SCENE_DESIGN_HEIGHT = 524;
|
||||
const FAST_PATH_SCALED_SCENE_TRANSFORM = `scale(min(100cqw / ${SCENE_DESIGN_WIDTH}px, 100cqh / ${SCENE_DESIGN_HEIGHT}px))`;
|
||||
const TOOLBAR_VERTICAL_PADDING = 16;
|
||||
const ACTION_BUTTON_HEIGHT = 24;
|
||||
const TOOLBAR_TOTAL_HEIGHT =
|
||||
|
|
@ -109,6 +112,16 @@ const ConfettiBurstLayer = styled.div`
|
|||
position: absolute;
|
||||
`;
|
||||
|
||||
const ScaledScene = styled.div`
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: ${FAST_PATH_SCALED_SCENE_TRANSFORM};
|
||||
transform-origin: bottom right;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const ConfettiParticle = styled.div<{
|
||||
$color: string;
|
||||
$delay: number;
|
||||
|
|
@ -885,6 +898,7 @@ export function FastPathVisual({
|
|||
</ConfettiBurstLayer>
|
||||
))}
|
||||
</SceneBackdrop>
|
||||
<ScaledScene>
|
||||
<PreviewSurface $active={active} ref={previewSurfaceRef}>
|
||||
<ToolbarRow>
|
||||
<ActionButton>
|
||||
|
|
@ -955,7 +969,10 @@ export function FastPathVisual({
|
|||
</MenuItem>
|
||||
<MenuItem onClick={handleCommandClick}>
|
||||
<MenuIconBox>
|
||||
<IconTrash size={MENU_ICON_SIZE} stroke={MENU_TABLER_STROKE} />
|
||||
<IconTrash
|
||||
size={MENU_ICON_SIZE}
|
||||
stroke={MENU_TABLER_STROKE}
|
||||
/>
|
||||
</MenuIconBox>
|
||||
<MenuItemLabel>Delete 8 records</MenuItemLabel>
|
||||
</MenuItem>
|
||||
|
|
@ -1036,6 +1053,7 @@ export function FastPathVisual({
|
|||
</PaletteBody>
|
||||
</CommandPalette>
|
||||
</PreviewSurface>
|
||||
</ScaledScene>
|
||||
</VisualRoot>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const CardImage = styled.div`
|
|||
const CardImageFrame = styled.div`
|
||||
background-color: ${theme.colors.primary.border[10]};
|
||||
border-radius: 2px;
|
||||
container-type: size;
|
||||
height: 100%;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
|
|
|
|||
|
|
@ -17,9 +17,7 @@ import { LiveDataHeroTable } from './LiveDataHeroTable';
|
|||
const APP_FONT = `'Inter', ${theme.font.family.sans}`;
|
||||
const SCENE_HEIGHT = 508;
|
||||
const SCENE_WIDTH = 411;
|
||||
const SCENE_SCALE = 1;
|
||||
const SCENE_SCALE_MD = 0.86;
|
||||
const SCENE_SCALE_SM = 0.74;
|
||||
const LIVE_DATA_SCENE_VIEWPORT_TRANSFORM = `translateX(-50%) scale(min(100cqw / ${SCENE_WIDTH}px, 100cqh / ${SCENE_HEIGHT}px))`;
|
||||
const TABLE_PANEL_HOVER_SCALE = 1.012;
|
||||
const TABLER_STROKE = 1.65;
|
||||
const FILTER_ICON_STROKE = 1.33;
|
||||
|
|
@ -128,17 +126,9 @@ const SceneViewport = styled.div`
|
|||
left: 50%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%) scale(${SCENE_SCALE});
|
||||
transform: ${LIVE_DATA_SCENE_VIEWPORT_TRANSFORM};
|
||||
transform-origin: top center;
|
||||
width: 101%;
|
||||
|
||||
@media (max-width: ${theme.breakpoints.md - 1}px) {
|
||||
transform: translateX(-50%) scale(${SCENE_SCALE_MD});
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
transform: translateX(-50%) scale(${SCENE_SCALE_SM});
|
||||
}
|
||||
`;
|
||||
|
||||
const SceneFrame = styled.div`
|
||||
|
|
@ -181,9 +171,7 @@ const TablePanel = styled.div<{ $active?: boolean }>`
|
|||
position: absolute;
|
||||
right: 0px;
|
||||
transform: ${({ $active }) =>
|
||||
`translate3d(0, 0, 0) scale(${
|
||||
$active ? TABLE_PANEL_HOVER_SCALE : 1
|
||||
})`};
|
||||
`translate3d(0, 0, 0) scale(${$active ? TABLE_PANEL_HOVER_SCALE : 1})`};
|
||||
transform-origin: bottom right;
|
||||
transition:
|
||||
box-shadow 260ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
|
|
@ -895,10 +883,7 @@ export function LiveDataVisual({
|
|||
$bottom={bobCursor.bottom}
|
||||
$right={bobCursor.right}
|
||||
>
|
||||
<MarkerCursorSlot
|
||||
$pressed={phase === 'remove-filter'}
|
||||
$visible
|
||||
>
|
||||
<MarkerCursorSlot $pressed={phase === 'remove-filter'} $visible>
|
||||
<MarkerCursor
|
||||
color={COLORS.bobCursor}
|
||||
rotation={bobCursor.rotation}
|
||||
|
|
|
|||
|
|
@ -19,10 +19,22 @@ const IllustrationColumn = styled.div`
|
|||
}
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
align-items: center;
|
||||
align-self: start;
|
||||
max-width: 672px;
|
||||
display: flex;
|
||||
height: calc(100vh - 4.5rem);
|
||||
justify-content: center;
|
||||
position: sticky;
|
||||
top: calc(4.5rem + (100vh - 4.5rem) * 0.5 - 368px);
|
||||
top: 4.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const IllustrationFrame = styled.div`
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
max-width: 672px;
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
@ -73,7 +85,9 @@ export function Flow({ body, heading, illustration }: FlowProps) {
|
|||
onMobileStepIndexChange={setMobileStepIndex}
|
||||
/>
|
||||
<IllustrationColumn>
|
||||
<IllustrationFrame>
|
||||
<IllustrationMount illustration={illustration} />
|
||||
</IllustrationFrame>
|
||||
</IllustrationColumn>
|
||||
</Root>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const Grid = styled(Container)`
|
|||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
align-items: start;
|
||||
column-gap: ${theme.spacing(10)};
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 672px);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
min-height: 100%;
|
||||
padding-bottom: ${theme.spacing(20)};
|
||||
padding-left: ${theme.spacing(10)};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
import * as crypto from 'crypto';
|
||||
|
||||
export type EnterpriseKeyPayload = {
|
||||
sub: string;
|
||||
licensee: string;
|
||||
iat: number;
|
||||
};
|
||||
|
||||
export type EnterpriseValidityPayload = {
|
||||
sub: string;
|
||||
status: 'valid';
|
||||
iat: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
const ALGORITHM = 'RS256';
|
||||
const DEFAULT_VALIDITY_TOKEN_DURATION_DAYS = 30;
|
||||
|
||||
const getValidityTokenDurationDays = (): number => {
|
||||
const value = process.env.ENTERPRISE_VALIDITY_TOKEN_DURATION_DAYS;
|
||||
|
||||
if (value === undefined || value === '') {
|
||||
return DEFAULT_VALIDITY_TOKEN_DURATION_DAYS;
|
||||
}
|
||||
|
||||
const parsed = parseInt(value, 10);
|
||||
|
||||
if (Number.isNaN(parsed) || parsed < 1) {
|
||||
return DEFAULT_VALIDITY_TOKEN_DURATION_DAYS;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export type SignValidityTokenOptions = {
|
||||
subscriptionCancelAt: number | null;
|
||||
};
|
||||
|
||||
const computeValidityExp = (
|
||||
nowSeconds: number,
|
||||
durationDays: number,
|
||||
subscriptionCancelAt: number | null,
|
||||
): number => {
|
||||
const defaultExp = nowSeconds + durationDays * 24 * 60 * 60;
|
||||
|
||||
if (subscriptionCancelAt === null || subscriptionCancelAt <= 0) {
|
||||
return defaultExp;
|
||||
}
|
||||
|
||||
return Math.min(defaultExp, subscriptionCancelAt);
|
||||
};
|
||||
|
||||
const getPrivateKey = (): string => {
|
||||
const key = process.env.ENTERPRISE_JWT_PRIVATE_KEY;
|
||||
|
||||
if (!key) {
|
||||
throw new Error('ENTERPRISE_JWT_PRIVATE_KEY is not configured');
|
||||
}
|
||||
|
||||
return key.replace(/\\n/g, '\n');
|
||||
};
|
||||
|
||||
const base64UrlEncode = (data: string): string => {
|
||||
return Buffer.from(data)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
};
|
||||
|
||||
const base64UrlDecode = (data: string): string => {
|
||||
const padded = data + '='.repeat((4 - (data.length % 4)) % 4);
|
||||
const base64 = padded.replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
return Buffer.from(base64, 'base64').toString('utf-8');
|
||||
};
|
||||
|
||||
const signJwt = (
|
||||
payload: Record<string, unknown>,
|
||||
privateKey: string,
|
||||
): string => {
|
||||
const header = { alg: ALGORITHM, typ: 'JWT' };
|
||||
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
const signature = crypto
|
||||
.sign('sha256', Buffer.from(signingInput), {
|
||||
key: privateKey,
|
||||
padding: crypto.constants.RSA_PKCS1_PADDING,
|
||||
})
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
return `${signingInput}.${signature}`;
|
||||
};
|
||||
|
||||
export const verifyJwt = <T extends Record<string, unknown>>(
|
||||
token: string,
|
||||
publicKey: string,
|
||||
): T | null => {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [encodedHeader, encodedPayload, signature] = parts;
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
const signatureBuffer = Buffer.from(
|
||||
signature.replace(/-/g, '+').replace(/_/g, '/') +
|
||||
'='.repeat((4 - (signature.length % 4)) % 4),
|
||||
'base64',
|
||||
);
|
||||
|
||||
const isValid = crypto.verify(
|
||||
'sha256',
|
||||
Buffer.from(signingInput),
|
||||
{
|
||||
key: publicKey,
|
||||
padding: crypto.constants.RSA_PKCS1_PADDING,
|
||||
},
|
||||
signatureBuffer,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(base64UrlDecode(encodedPayload)) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const signEnterpriseKey = (
|
||||
subscriptionId: string,
|
||||
licensee: string,
|
||||
): string => {
|
||||
const payload: EnterpriseKeyPayload = {
|
||||
sub: subscriptionId,
|
||||
licensee,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
return signJwt(payload, getPrivateKey());
|
||||
};
|
||||
|
||||
export const signValidityToken = (
|
||||
subscriptionId: string,
|
||||
options?: SignValidityTokenOptions,
|
||||
): string => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const durationDays = getValidityTokenDurationDays();
|
||||
const subscriptionCancelAt = options?.subscriptionCancelAt ?? null;
|
||||
const exp = computeValidityExp(now, durationDays, subscriptionCancelAt);
|
||||
|
||||
const payload: EnterpriseValidityPayload = {
|
||||
sub: subscriptionId,
|
||||
status: 'valid',
|
||||
iat: now,
|
||||
exp,
|
||||
};
|
||||
|
||||
return signJwt(payload, getPrivateKey());
|
||||
};
|
||||
|
||||
export const verifyEnterpriseKey = (
|
||||
token: string,
|
||||
): EnterpriseKeyPayload | null => {
|
||||
const publicKey = getPublicKey();
|
||||
|
||||
return verifyJwt<EnterpriseKeyPayload>(token, publicKey);
|
||||
};
|
||||
|
||||
const getPublicKey = (): string => {
|
||||
const key = process.env.ENTERPRISE_JWT_PUBLIC_KEY;
|
||||
|
||||
if (!key) {
|
||||
throw new Error('ENTERPRISE_JWT_PUBLIC_KEY is not configured');
|
||||
}
|
||||
|
||||
return key.replace(/\\n/g, '\n');
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import Stripe from 'stripe';
|
||||
|
||||
let stripeInstance: Stripe | null = null;
|
||||
|
||||
export const getStripeClient = (): Stripe => {
|
||||
if (!stripeInstance) {
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
|
||||
if (!secretKey) {
|
||||
throw new Error('STRIPE_SECRET_KEY is not configured');
|
||||
}
|
||||
|
||||
stripeInstance = new Stripe(secretKey, {});
|
||||
}
|
||||
|
||||
return stripeInstance;
|
||||
};
|
||||
|
||||
export const getEnterprisePriceId = (
|
||||
billingInterval: 'monthly' | 'yearly' = 'monthly',
|
||||
): string => {
|
||||
const envKey =
|
||||
billingInterval === 'yearly'
|
||||
? 'STRIPE_ENTERPRISE_YEARLY_PRICE_ID'
|
||||
: 'STRIPE_ENTERPRISE_MONTHLY_PRICE_ID';
|
||||
|
||||
const priceId = process.env[envKey];
|
||||
|
||||
if (!priceId) {
|
||||
throw new Error(`${envKey} is not configured`);
|
||||
}
|
||||
|
||||
return priceId;
|
||||
};
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import type Stripe from 'stripe';
|
||||
|
||||
// Stripe's generated `Subscription` type can lag the REST shape; the API still
|
||||
// exposes period boundaries used for license validity.
|
||||
type SubscriptionWithPeriodBounds = Stripe.Subscription & {
|
||||
current_period_end?: number;
|
||||
};
|
||||
|
||||
export const getSubscriptionCurrentPeriodEnd = (
|
||||
subscription: Stripe.Response<Stripe.Subscription>,
|
||||
): number | null => {
|
||||
const extended =
|
||||
subscription as Stripe.Response<SubscriptionWithPeriodBounds>;
|
||||
const end = extended.current_period_end;
|
||||
|
||||
return typeof end === 'number' ? end : null;
|
||||
};
|
||||
|
|
@ -60945,6 +60945,7 @@ __metadata:
|
|||
react-markdown: "npm:^10.1.0"
|
||||
remark-gfm: "npm:^4.0.1"
|
||||
sharp: "npm:^0.33.5"
|
||||
stripe: "npm:^20.3.1"
|
||||
three: "npm:^0.183.2"
|
||||
zod: "npm:^4.1.11"
|
||||
languageName: unknown
|
||||
|
|
|
|||
Loading…
Reference in a new issue