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=
|
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-dom": "19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"stripe": "^20.3.1",
|
||||||
"three": "^0.183.2",
|
"three": "^0.183.2",
|
||||||
"zod": "^4.1.11"
|
"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`
|
const VisualColumn = styled.div`
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@media (min-width: ${theme.breakpoints.md}px) {
|
|
||||||
max-width: 672px;
|
|
||||||
position: sticky;
|
|
||||||
top: ${theme.spacing(10)};
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const VisualContainer = styled.div`
|
const VisualContainer = styled.div`
|
||||||
|
|
@ -31,8 +25,10 @@ const VisualContainer = styled.div`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@media (min-width: ${theme.breakpoints.md}px) {
|
@media (min-width: ${theme.breakpoints.md}px) {
|
||||||
height: 705px;
|
aspect-ratio: 672 / 705;
|
||||||
max-width: 672px;
|
height: auto;
|
||||||
|
max-height: 705px;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,23 @@ import { Card } from '../Card/Card';
|
||||||
const GUIDE_INTERSECTION_TOP = '176px';
|
const GUIDE_INTERSECTION_TOP = '176px';
|
||||||
|
|
||||||
const helpedHeadingClassName = css`
|
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) {
|
@media (min-width: ${theme.breakpoints.md}px) {
|
||||||
max-width: 760px;
|
max-width: 760px;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
}
|
|
||||||
|
|
||||||
[data-family='sans'] {
|
&[data-size='xl'] {
|
||||||
white-space: nowrap;
|
font-size: ${theme.font.size(20)};
|
||||||
|
line-height: ${theme.lineHeight(21.5)};
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-family='sans'] {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -96,7 +106,11 @@ export function Scene({ data }: SceneProps) {
|
||||||
sectionRef={sectionRef}
|
sectionRef={sectionRef}
|
||||||
/>
|
/>
|
||||||
<StickyInner ref={innerRef}>
|
<StickyInner ref={innerRef}>
|
||||||
<GuideCrosshair crossX="50%" crossY={GUIDE_INTERSECTION_TOP} zIndex={0} />
|
<GuideCrosshair
|
||||||
|
crossX="50%"
|
||||||
|
crossY={GUIDE_INTERSECTION_TOP}
|
||||||
|
zIndex={0}
|
||||||
|
/>
|
||||||
<HeadlineBlock>
|
<HeadlineBlock>
|
||||||
<EyebrowExitTarget data-helped-exit-target>
|
<EyebrowExitTarget data-helped-exit-target>
|
||||||
<Eyebrow colorScheme="primary" heading={data.eyebrow.heading} />
|
<Eyebrow colorScheme="primary" heading={data.eyebrow.heading} />
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,22 @@ const StyledRightColumn = styled.div`
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: ${theme.breakpoints.md}px) {
|
@media (min-width: ${theme.breakpoints.md}px) {
|
||||||
|
align-items: center;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
max-width: 672px;
|
display: flex;
|
||||||
|
height: calc(100vh - 4.5rem);
|
||||||
|
justify-content: center;
|
||||||
position: sticky;
|
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) {
|
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) {
|
@media (min-width: ${theme.breakpoints.md}px) {
|
||||||
align-items: start;
|
align-items: start;
|
||||||
column-gap: ${theme.spacing(10)};
|
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-bottom: ${theme.spacing(20)};
|
||||||
padding-left: ${theme.spacing(10)};
|
padding-left: ${theme.spacing(10)};
|
||||||
padding-right: ${theme.spacing(10)};
|
padding-right: ${theme.spacing(10)};
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ export function StepperVisualFrame({
|
||||||
alt=""
|
alt=""
|
||||||
className={patternImageClassName}
|
className={patternImageClassName}
|
||||||
fill
|
fill
|
||||||
sizes="(min-width: 921px) 672px, 100vw"
|
sizes="(min-width: 921px) 50vw, 100vw"
|
||||||
src={backgroundSrc}
|
src={backgroundSrc}
|
||||||
/>
|
/>
|
||||||
</PatternBackdrop>
|
</PatternBackdrop>
|
||||||
|
|
@ -132,7 +132,7 @@ export function StepperVisualFrame({
|
||||||
className={shapeImageClassName}
|
className={shapeImageClassName}
|
||||||
fill
|
fill
|
||||||
priority={false}
|
priority={false}
|
||||||
sizes="(min-width: 921px) 672px, 100vw"
|
sizes="(min-width: 921px) 50vw, 100vw"
|
||||||
src={shapeSrc}
|
src={shapeSrc}
|
||||||
/>
|
/>
|
||||||
</ShapeOverlay>
|
</ShapeOverlay>
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ const Grid = styled(Container)`
|
||||||
@media (min-width: ${theme.breakpoints.md}px) {
|
@media (min-width: ${theme.breakpoints.md}px) {
|
||||||
align-items: start;
|
align-items: start;
|
||||||
column-gap: ${theme.spacing(10)};
|
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%;
|
min-height: 100%;
|
||||||
padding-bottom: ${theme.spacing(20)};
|
padding-bottom: ${theme.spacing(20)};
|
||||||
padding-left: ${theme.spacing(10)};
|
padding-left: ${theme.spacing(10)};
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export function StepperVisualFrame({
|
||||||
className={shapeImageClassName}
|
className={shapeImageClassName}
|
||||||
fill
|
fill
|
||||||
priority={false}
|
priority={false}
|
||||||
sizes="(min-width: 921px) 672px, 100vw"
|
sizes="(min-width: 921px) 50vw, 100vw"
|
||||||
src={shapeSrc}
|
src={shapeSrc}
|
||||||
/>
|
/>
|
||||||
</ShapeOverlay>
|
</ShapeOverlay>
|
||||||
|
|
@ -70,7 +70,7 @@ export function StepperVisualFrame({
|
||||||
alt=""
|
alt=""
|
||||||
className={patternImageClassName}
|
className={patternImageClassName}
|
||||||
fill
|
fill
|
||||||
sizes="(min-width: 921px) 672px, 100vw"
|
sizes="(min-width: 921px) 50vw, 100vw"
|
||||||
src={backgroundSrc}
|
src={backgroundSrc}
|
||||||
/>
|
/>
|
||||||
</PatternBackdrop>
|
</PatternBackdrop>
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,22 @@ const VisualColumn = styled.div`
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: ${theme.breakpoints.md}px) {
|
@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;
|
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,26 +54,28 @@ export function Visual({ activeStepIndex, images }: ProductStepperVisualProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VisualColumn>
|
<VisualColumn>
|
||||||
<StepperVisualFrame
|
<VisualFrame>
|
||||||
backgroundSrc={PRODUCT_STEPPER_BACKGROUND}
|
<StepperVisualFrame
|
||||||
shapeSrc={PRODUCT_STEPPER_SHAPE}
|
backgroundSrc={PRODUCT_STEPPER_BACKGROUND}
|
||||||
>
|
shapeSrc={PRODUCT_STEPPER_SHAPE}
|
||||||
{images.map((image, index) => {
|
>
|
||||||
if (!image) return null;
|
{images.map((image, index) => {
|
||||||
|
if (!image) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextImage
|
<NextImage
|
||||||
key={`${image.src}-${index}`}
|
key={`${image.src}-${index}`}
|
||||||
alt={image.alt}
|
alt={image.alt}
|
||||||
className={slideImageClassName}
|
className={slideImageClassName}
|
||||||
data-active={String(index === activeStepIndex)}
|
data-active={String(index === activeStepIndex)}
|
||||||
fill
|
fill
|
||||||
sizes="(min-width: 921px) 672px, 100vw"
|
sizes="(min-width: 921px) 50vw, 100vw"
|
||||||
src={image.src}
|
src={image.src}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</StepperVisualFrame>
|
</StepperVisualFrame>
|
||||||
|
</VisualFrame>
|
||||||
</VisualColumn>
|
</VisualColumn>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,6 @@ import {
|
||||||
} from '@/lib/shared-asset-paths';
|
} from '@/lib/shared-asset-paths';
|
||||||
import { theme } from '@/theme';
|
import { theme } from '@/theme';
|
||||||
import { styled } from '@linaria/react';
|
import { styled } from '@linaria/react';
|
||||||
import {
|
|
||||||
type PointerEvent as ReactPointerEvent,
|
|
||||||
type RefObject,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { FamiliarInterfaceGradientBackdrop } from './FamiliarInterfaceGradientBackdrop';
|
|
||||||
import {
|
import {
|
||||||
IconBuildingSkyscraper,
|
IconBuildingSkyscraper,
|
||||||
IconCalendarEvent,
|
IconCalendarEvent,
|
||||||
|
|
@ -28,14 +19,22 @@ import {
|
||||||
IconUser,
|
IconUser,
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
} from '@tabler/icons-react';
|
} 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 APP_FONT = `'Inter', ${theme.font.family.sans}`;
|
||||||
const TABLER_STROKE = 1.6;
|
const TABLER_STROKE = 1.6;
|
||||||
const SCENE_WIDTH = 411;
|
const SCENE_WIDTH = 411;
|
||||||
const SCENE_HEIGHT = 508;
|
const SCENE_HEIGHT = 508;
|
||||||
const SCENE_SCALE = 1.025;
|
const SCENE_BASE_SCALE = 1.025;
|
||||||
const SCENE_SCALE_MD = SCENE_SCALE * 0.86;
|
const FAMILIAR_INTERFACE_SCENE_VIEWPORT_TRANSFORM = `translateX(-50%) scale(calc(${SCENE_BASE_SCALE} * min(100cqw / ${SCENE_WIDTH}px, 100cqh / ${SCENE_HEIGHT}px)))`;
|
||||||
const SCENE_SCALE_SM = SCENE_SCALE * 0.74;
|
|
||||||
const FIGMA_CARD_WIDTH = 174.301;
|
const FIGMA_CARD_WIDTH = 174.301;
|
||||||
const FIGMA_FIELD_HEIGHT = 22.063;
|
const FIGMA_FIELD_HEIGHT = 22.063;
|
||||||
const FIGMA_FIELD_GAP = 3.677;
|
const FIGMA_FIELD_GAP = 3.677;
|
||||||
|
|
@ -299,17 +298,9 @@ const SceneViewport = styled.div`
|
||||||
left: 50%;
|
left: 50%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
transform: translateX(-50%) scale(${SCENE_SCALE});
|
transform: ${FAMILIAR_INTERFACE_SCENE_VIEWPORT_TRANSFORM};
|
||||||
transform-origin: top center;
|
transform-origin: top center;
|
||||||
width: ${SCENE_WIDTH}px;
|
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`
|
const SceneFrame = styled.div`
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ import { FastPathGradientBackdrop } from './FastPathGradientBackdrop';
|
||||||
const APP_FONT = `'Inter', ${theme.font.family.sans}`;
|
const APP_FONT = `'Inter', ${theme.font.family.sans}`;
|
||||||
const FAST_PATH_NOISE_BACKGROUND =
|
const FAST_PATH_NOISE_BACKGROUND =
|
||||||
'url("/images/home/three-cards-feature/fast-path-background-noise.webp")';
|
'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 TOOLBAR_VERTICAL_PADDING = 16;
|
||||||
const ACTION_BUTTON_HEIGHT = 24;
|
const ACTION_BUTTON_HEIGHT = 24;
|
||||||
const TOOLBAR_TOTAL_HEIGHT =
|
const TOOLBAR_TOTAL_HEIGHT =
|
||||||
|
|
@ -109,6 +112,16 @@ const ConfettiBurstLayer = styled.div`
|
||||||
position: absolute;
|
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<{
|
const ConfettiParticle = styled.div<{
|
||||||
$color: string;
|
$color: string;
|
||||||
$delay: number;
|
$delay: number;
|
||||||
|
|
@ -885,157 +898,162 @@ export function FastPathVisual({
|
||||||
</ConfettiBurstLayer>
|
</ConfettiBurstLayer>
|
||||||
))}
|
))}
|
||||||
</SceneBackdrop>
|
</SceneBackdrop>
|
||||||
<PreviewSurface $active={active} ref={previewSurfaceRef}>
|
<ScaledScene>
|
||||||
<ToolbarRow>
|
<PreviewSurface $active={active} ref={previewSurfaceRef}>
|
||||||
<ActionButton>
|
<ToolbarRow>
|
||||||
<ActionIcon>
|
<ActionButton>
|
||||||
<IconPlus size={14} stroke={TOOLBAR_TABLER_STROKE} />
|
<ActionIcon>
|
||||||
</ActionIcon>
|
<IconPlus size={14} stroke={TOOLBAR_TABLER_STROKE} />
|
||||||
<ActionLabel>New Record</ActionLabel>
|
</ActionIcon>
|
||||||
</ActionButton>
|
<ActionLabel>New Record</ActionLabel>
|
||||||
<ActionButton $iconOnly>
|
</ActionButton>
|
||||||
<ActionIcon>
|
<ActionButton $iconOnly>
|
||||||
<IconChevronUp size={16} stroke={TOOLBAR_TABLER_STROKE} />
|
<ActionIcon>
|
||||||
</ActionIcon>
|
<IconChevronUp size={16} stroke={TOOLBAR_TABLER_STROKE} />
|
||||||
</ActionButton>
|
</ActionIcon>
|
||||||
<ActionButton $iconOnly>
|
</ActionButton>
|
||||||
<ActionIcon>
|
<ActionButton $iconOnly>
|
||||||
<IconChevronDown size={16} stroke={TOOLBAR_TABLER_STROKE} />
|
<ActionIcon>
|
||||||
</ActionIcon>
|
<IconChevronDown size={16} stroke={TOOLBAR_TABLER_STROKE} />
|
||||||
</ActionButton>
|
</ActionIcon>
|
||||||
<ActionButton>
|
</ActionButton>
|
||||||
<ActionIcon>
|
<ActionButton>
|
||||||
<IconDotsVertical size={14} stroke={TOOLBAR_TABLER_STROKE} />
|
<ActionIcon>
|
||||||
</ActionIcon>
|
<IconDotsVertical size={14} stroke={TOOLBAR_TABLER_STROKE} />
|
||||||
<ShortcutDivider />
|
</ActionIcon>
|
||||||
<ActionLabel $muted>⌘K</ActionLabel>
|
<ShortcutDivider />
|
||||||
</ActionButton>
|
<ActionLabel $muted>⌘K</ActionLabel>
|
||||||
</ToolbarRow>
|
</ActionButton>
|
||||||
|
</ToolbarRow>
|
||||||
|
|
||||||
<CommandPalette>
|
<CommandPalette>
|
||||||
<SearchRow>
|
<SearchRow>
|
||||||
<IconChevronLeft
|
<IconChevronLeft
|
||||||
color={COLORS.mutedStrong}
|
|
||||||
size={16}
|
|
||||||
stroke={TOOLBAR_TABLER_STROKE}
|
|
||||||
/>
|
|
||||||
<SearchPlaceholder>Type anything...</SearchPlaceholder>
|
|
||||||
<SearchSparkles>
|
|
||||||
<IconSparkles
|
|
||||||
color={COLORS.mutedStrong}
|
color={COLORS.mutedStrong}
|
||||||
size={14}
|
size={16}
|
||||||
stroke={TOOLBAR_TABLER_STROKE}
|
stroke={TOOLBAR_TABLER_STROKE}
|
||||||
/>
|
/>
|
||||||
</SearchSparkles>
|
<SearchPlaceholder>Type anything...</SearchPlaceholder>
|
||||||
</SearchRow>
|
<SearchSparkles>
|
||||||
|
<IconSparkles
|
||||||
|
color={COLORS.mutedStrong}
|
||||||
|
size={14}
|
||||||
|
stroke={TOOLBAR_TABLER_STROKE}
|
||||||
|
/>
|
||||||
|
</SearchSparkles>
|
||||||
|
</SearchRow>
|
||||||
|
|
||||||
<PaletteBody>
|
<PaletteBody>
|
||||||
<SectionLabel>Record Selection</SectionLabel>
|
<SectionLabel>Record Selection</SectionLabel>
|
||||||
<MenuItem onClick={handleCommandClick}>
|
<MenuItem onClick={handleCommandClick}>
|
||||||
<MenuIconBox>
|
<MenuIconBox>
|
||||||
<IconMail size={MENU_ICON_SIZE} stroke={MENU_TABLER_STROKE} />
|
<IconMail size={MENU_ICON_SIZE} stroke={MENU_TABLER_STROKE} />
|
||||||
</MenuIconBox>
|
</MenuIconBox>
|
||||||
<MenuItemLabel>Send email</MenuItemLabel>
|
<MenuItemLabel>Send email</MenuItemLabel>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
$active
|
$active
|
||||||
data-preview-active="true"
|
data-preview-active="true"
|
||||||
onClick={handleCommandClick}
|
onClick={handleCommandClick}
|
||||||
>
|
>
|
||||||
<PreviewCursor data-preview-cursor="true">
|
<PreviewCursor data-preview-cursor="true">
|
||||||
<PreviewCursorIcon />
|
<PreviewCursorIcon />
|
||||||
</PreviewCursor>
|
</PreviewCursor>
|
||||||
<MenuIconBox>
|
<MenuIconBox>
|
||||||
<IconDatabaseExport
|
<IconDatabaseExport
|
||||||
size={MENU_ICON_SIZE}
|
size={MENU_ICON_SIZE}
|
||||||
stroke={MENU_TABLER_STROKE}
|
stroke={MENU_TABLER_STROKE}
|
||||||
/>
|
/>
|
||||||
</MenuIconBox>
|
</MenuIconBox>
|
||||||
<MenuItemLabel>Export selection as CSV</MenuItemLabel>
|
<MenuItemLabel>Export selection as CSV</MenuItemLabel>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onClick={handleCommandClick}>
|
<MenuItem onClick={handleCommandClick}>
|
||||||
<MenuIconBox>
|
<MenuIconBox>
|
||||||
<IconTrash size={MENU_ICON_SIZE} stroke={MENU_TABLER_STROKE} />
|
<IconTrash
|
||||||
</MenuIconBox>
|
size={MENU_ICON_SIZE}
|
||||||
<MenuItemLabel>Delete 8 records</MenuItemLabel>
|
stroke={MENU_TABLER_STROKE}
|
||||||
</MenuItem>
|
/>
|
||||||
|
</MenuIconBox>
|
||||||
|
<MenuItemLabel>Delete 8 records</MenuItemLabel>
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
<SectionLabel>"Companies" object</SectionLabel>
|
<SectionLabel>"Companies" object</SectionLabel>
|
||||||
<MenuItem onClick={handleCommandClick}>
|
<MenuItem onClick={handleCommandClick}>
|
||||||
<MenuIconBox>
|
<MenuIconBox>
|
||||||
<IconDatabaseImport
|
<IconDatabaseImport
|
||||||
size={MENU_ICON_SIZE}
|
size={MENU_ICON_SIZE}
|
||||||
stroke={MENU_TABLER_STROKE}
|
stroke={MENU_TABLER_STROKE}
|
||||||
/>
|
/>
|
||||||
</MenuIconBox>
|
</MenuIconBox>
|
||||||
<MenuItemLabel>Import data</MenuItemLabel>
|
<MenuItemLabel>Import data</MenuItemLabel>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onClick={handleCommandClick}>
|
<MenuItem onClick={handleCommandClick}>
|
||||||
<MenuIconBox>
|
<MenuIconBox>
|
||||||
<IconBuildingSkyscraper
|
<IconBuildingSkyscraper
|
||||||
size={MENU_ICON_SIZE}
|
size={MENU_ICON_SIZE}
|
||||||
stroke={MENU_TABLER_STROKE}
|
stroke={MENU_TABLER_STROKE}
|
||||||
/>
|
/>
|
||||||
</MenuIconBox>
|
</MenuIconBox>
|
||||||
<MenuItemLabel>Create company</MenuItemLabel>
|
<MenuItemLabel>Create company</MenuItemLabel>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
||||||
<SectionLabel>Navigate</SectionLabel>
|
<SectionLabel>Navigate</SectionLabel>
|
||||||
<MenuItem onClick={handleCommandClick}>
|
<MenuItem onClick={handleCommandClick}>
|
||||||
<MenuIconBox>
|
<MenuIconBox>
|
||||||
<IconArrowUpRight
|
<IconArrowUpRight
|
||||||
size={MENU_ICON_SIZE}
|
size={MENU_ICON_SIZE}
|
||||||
stroke={MENU_TABLER_STROKE}
|
stroke={MENU_TABLER_STROKE}
|
||||||
/>
|
/>
|
||||||
</MenuIconBox>
|
</MenuIconBox>
|
||||||
<MenuItemLabel>Go to People</MenuItemLabel>
|
<MenuItemLabel>Go to People</MenuItemLabel>
|
||||||
<ShortcutHint>
|
<ShortcutHint>
|
||||||
<ShortcutKey>G</ShortcutKey>
|
<ShortcutKey>G</ShortcutKey>
|
||||||
then
|
then
|
||||||
<ShortcutKey>P</ShortcutKey>
|
<ShortcutKey>P</ShortcutKey>
|
||||||
</ShortcutHint>
|
</ShortcutHint>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onClick={handleCommandClick}>
|
<MenuItem onClick={handleCommandClick}>
|
||||||
<MenuIconBox>
|
<MenuIconBox>
|
||||||
<IconArrowUpRight
|
<IconArrowUpRight
|
||||||
size={MENU_ICON_SIZE}
|
size={MENU_ICON_SIZE}
|
||||||
stroke={MENU_TABLER_STROKE}
|
stroke={MENU_TABLER_STROKE}
|
||||||
/>
|
/>
|
||||||
</MenuIconBox>
|
</MenuIconBox>
|
||||||
<MenuItemLabel>Go to Opportunities</MenuItemLabel>
|
<MenuItemLabel>Go to Opportunities</MenuItemLabel>
|
||||||
<ShortcutHint>
|
<ShortcutHint>
|
||||||
<ShortcutKey>G</ShortcutKey>
|
<ShortcutKey>G</ShortcutKey>
|
||||||
then
|
then
|
||||||
<ShortcutKey>O</ShortcutKey>
|
<ShortcutKey>O</ShortcutKey>
|
||||||
</ShortcutHint>
|
</ShortcutHint>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
||||||
<SectionLabel>Settings</SectionLabel>
|
<SectionLabel>Settings</SectionLabel>
|
||||||
<MenuItem onClick={handleCommandClick}>
|
<MenuItem onClick={handleCommandClick}>
|
||||||
<MenuIconBox>
|
<MenuIconBox>
|
||||||
<IconArrowUpRight
|
<IconArrowUpRight
|
||||||
size={MENU_ICON_SIZE}
|
size={MENU_ICON_SIZE}
|
||||||
stroke={MENU_TABLER_STROKE}
|
stroke={MENU_TABLER_STROKE}
|
||||||
/>
|
/>
|
||||||
</MenuIconBox>
|
</MenuIconBox>
|
||||||
<MenuItemLabel>Go to settings</MenuItemLabel>
|
<MenuItemLabel>Go to settings</MenuItemLabel>
|
||||||
<ShortcutHint>
|
<ShortcutHint>
|
||||||
<ShortcutKey>G</ShortcutKey>
|
<ShortcutKey>G</ShortcutKey>
|
||||||
then
|
then
|
||||||
<ShortcutKey>S</ShortcutKey>
|
<ShortcutKey>S</ShortcutKey>
|
||||||
</ShortcutHint>
|
</ShortcutHint>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onClick={handleCommandClick}>
|
<MenuItem onClick={handleCommandClick}>
|
||||||
<MenuIconBox>
|
<MenuIconBox>
|
||||||
<IconMoon size={MENU_ICON_SIZE} stroke={MENU_TABLER_STROKE} />
|
<IconMoon size={MENU_ICON_SIZE} stroke={MENU_TABLER_STROKE} />
|
||||||
</MenuIconBox>
|
</MenuIconBox>
|
||||||
<MenuItemLabel>Switch to dark mode</MenuItemLabel>
|
<MenuItemLabel>Switch to dark mode</MenuItemLabel>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<SectionSpacer />
|
<SectionSpacer />
|
||||||
<SectionSpacer />
|
<SectionSpacer />
|
||||||
</PaletteBody>
|
</PaletteBody>
|
||||||
</CommandPalette>
|
</CommandPalette>
|
||||||
</PreviewSurface>
|
</PreviewSurface>
|
||||||
|
</ScaledScene>
|
||||||
</VisualRoot>
|
</VisualRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ const CardImage = styled.div`
|
||||||
const CardImageFrame = styled.div`
|
const CardImageFrame = styled.div`
|
||||||
background-color: ${theme.colors.primary.border[10]};
|
background-color: ${theme.colors.primary.border[10]};
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
|
container-type: size;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,7 @@ import { LiveDataHeroTable } from './LiveDataHeroTable';
|
||||||
const APP_FONT = `'Inter', ${theme.font.family.sans}`;
|
const APP_FONT = `'Inter', ${theme.font.family.sans}`;
|
||||||
const SCENE_HEIGHT = 508;
|
const SCENE_HEIGHT = 508;
|
||||||
const SCENE_WIDTH = 411;
|
const SCENE_WIDTH = 411;
|
||||||
const SCENE_SCALE = 1;
|
const LIVE_DATA_SCENE_VIEWPORT_TRANSFORM = `translateX(-50%) scale(min(100cqw / ${SCENE_WIDTH}px, 100cqh / ${SCENE_HEIGHT}px))`;
|
||||||
const SCENE_SCALE_MD = 0.86;
|
|
||||||
const SCENE_SCALE_SM = 0.74;
|
|
||||||
const TABLE_PANEL_HOVER_SCALE = 1.012;
|
const TABLE_PANEL_HOVER_SCALE = 1.012;
|
||||||
const TABLER_STROKE = 1.65;
|
const TABLER_STROKE = 1.65;
|
||||||
const FILTER_ICON_STROKE = 1.33;
|
const FILTER_ICON_STROKE = 1.33;
|
||||||
|
|
@ -128,17 +126,9 @@ const SceneViewport = styled.div`
|
||||||
left: 50%;
|
left: 50%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
transform: translateX(-50%) scale(${SCENE_SCALE});
|
transform: ${LIVE_DATA_SCENE_VIEWPORT_TRANSFORM};
|
||||||
transform-origin: top center;
|
transform-origin: top center;
|
||||||
width: 101%;
|
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`
|
const SceneFrame = styled.div`
|
||||||
|
|
@ -181,9 +171,7 @@ const TablePanel = styled.div<{ $active?: boolean }>`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0px;
|
right: 0px;
|
||||||
transform: ${({ $active }) =>
|
transform: ${({ $active }) =>
|
||||||
`translate3d(0, 0, 0) scale(${
|
`translate3d(0, 0, 0) scale(${$active ? TABLE_PANEL_HOVER_SCALE : 1})`};
|
||||||
$active ? TABLE_PANEL_HOVER_SCALE : 1
|
|
||||||
})`};
|
|
||||||
transform-origin: bottom right;
|
transform-origin: bottom right;
|
||||||
transition:
|
transition:
|
||||||
box-shadow 260ms cubic-bezier(0.22, 1, 0.36, 1),
|
box-shadow 260ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
|
@ -895,10 +883,7 @@ export function LiveDataVisual({
|
||||||
$bottom={bobCursor.bottom}
|
$bottom={bobCursor.bottom}
|
||||||
$right={bobCursor.right}
|
$right={bobCursor.right}
|
||||||
>
|
>
|
||||||
<MarkerCursorSlot
|
<MarkerCursorSlot $pressed={phase === 'remove-filter'} $visible>
|
||||||
$pressed={phase === 'remove-filter'}
|
|
||||||
$visible
|
|
||||||
>
|
|
||||||
<MarkerCursor
|
<MarkerCursor
|
||||||
color={COLORS.bobCursor}
|
color={COLORS.bobCursor}
|
||||||
rotation={bobCursor.rotation}
|
rotation={bobCursor.rotation}
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,22 @@ const IllustrationColumn = styled.div`
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: ${theme.breakpoints.md}px) {
|
@media (min-width: ${theme.breakpoints.md}px) {
|
||||||
|
align-items: center;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
max-width: 672px;
|
display: flex;
|
||||||
|
height: calc(100vh - 4.5rem);
|
||||||
|
justify-content: center;
|
||||||
position: sticky;
|
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}
|
onMobileStepIndexChange={setMobileStepIndex}
|
||||||
/>
|
/>
|
||||||
<IllustrationColumn>
|
<IllustrationColumn>
|
||||||
<IllustrationMount illustration={illustration} />
|
<IllustrationFrame>
|
||||||
|
<IllustrationMount illustration={illustration} />
|
||||||
|
</IllustrationFrame>
|
||||||
</IllustrationColumn>
|
</IllustrationColumn>
|
||||||
</Root>
|
</Root>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ const Grid = styled(Container)`
|
||||||
@media (min-width: ${theme.breakpoints.md}px) {
|
@media (min-width: ${theme.breakpoints.md}px) {
|
||||||
align-items: start;
|
align-items: start;
|
||||||
column-gap: ${theme.spacing(10)};
|
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%;
|
min-height: 100%;
|
||||||
padding-bottom: ${theme.spacing(20)};
|
padding-bottom: ${theme.spacing(20)};
|
||||||
padding-left: ${theme.spacing(10)};
|
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"
|
react-markdown: "npm:^10.1.0"
|
||||||
remark-gfm: "npm:^4.0.1"
|
remark-gfm: "npm:^4.0.1"
|
||||||
sharp: "npm:^0.33.5"
|
sharp: "npm:^0.33.5"
|
||||||
|
stripe: "npm:^20.3.1"
|
||||||
three: "npm:^0.183.2"
|
three: "npm:^0.183.2"
|
||||||
zod: "npm:^4.1.11"
|
zod: "npm:^4.1.11"
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue