Skip to content

Commit d811d9b

Browse files
feat(v5): License reactivation flow (#1201)
* wip on reactivation flow * add banner for license bound to another instance error case
1 parent 33c714c commit d811d9b

15 files changed

Lines changed: 231 additions & 8 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "License" ADD COLUMN "lastSyncErrorCode" TEXT;

packages/db/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ model License {
318318
trialEnd DateTime?
319319
hasPaymentMethod Boolean?
320320
lastSyncAt DateTime?
321+
lastSyncErrorCode String?
321322
createdAt DateTime @default(now())
322323
updatedAt DateTime @updatedAt
323324
}

packages/shared/src/entitlements.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ const makeLicense = (overrides: Partial<License> = {}): License => ({
6666
trialEnd: null,
6767
hasPaymentMethod: null,
6868
lastSyncAt: new Date(),
69+
lastSyncErrorCode: null,
6970
createdAt: new Date(),
7071
updatedAt: new Date(),
7172
...overrides,
@@ -215,6 +216,43 @@ describe('getEntitlements', () => {
215216
expect(getEntitlements(license)).toEqual(['sso']);
216217
});
217218
});
219+
220+
describe('online license rebound elsewhere', () => {
221+
test('returns empty when lastSyncErrorCode is ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE', () => {
222+
const license = makeLicense({
223+
status: 'active',
224+
entitlements: ['sso'],
225+
lastSyncAt: new Date(),
226+
lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE',
227+
});
228+
expect(getEntitlements(license)).toEqual([]);
229+
});
230+
231+
test('returns entitlements when lastSyncErrorCode is some other error code', () => {
232+
// Only ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE invalidates the
233+
// local license. Other sync errors are persisted for visibility but
234+
// don't strip entitlements (avoids paging operators on transient
235+
// upstream issues).
236+
const license = makeLicense({
237+
status: 'active',
238+
entitlements: ['sso'],
239+
lastSyncAt: new Date(),
240+
lastSyncErrorCode: 'UNKNOWN_STRIPE_PRODUCT',
241+
});
242+
expect(getEntitlements(license)).toEqual(['sso']);
243+
});
244+
245+
test('offline license overrides the rebound-elsewhere gate', () => {
246+
// Offline licenses don't rely on /ping, so a stale online error
247+
// should not affect them.
248+
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey();
249+
const license = makeLicense({
250+
status: 'active',
251+
lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE',
252+
});
253+
expect(getEntitlements(license).length).toBeGreaterThan(0);
254+
});
255+
});
218256
});
219257

220258
describe('hasEntitlement', () => {

packages/shared/src/entitlements.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ const getValidOnlineLicense = (_license: License | null): License | null => {
107107
_license.status &&
108108
ACTIVE_ONLINE_LICENSE_STATUSES.includes(_license.status as LicenseStatus) &&
109109
_license.lastSyncAt &&
110-
(Date.now() - _license.lastSyncAt.getTime()) <= STALE_ONLINE_LICENSE_THRESHOLD_MS
110+
(Date.now() - _license.lastSyncAt.getTime()) <= STALE_ONLINE_LICENSE_THRESHOLD_MS &&
111+
_license.lastSyncErrorCode !== 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE'
111112
) {
112113
return _license;
113114
}

packages/web/src/app/(app)/components/banners/bannerResolver.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const makeLicense = (overrides: Partial<License> = {}): License => ({
4747
trialEnd: null,
4848
hasPaymentMethod: null,
4949
lastSyncAt: NOW,
50+
lastSyncErrorCode: null,
5051
createdAt: NOW,
5152
updatedAt: NOW,
5253
...overrides,
@@ -447,6 +448,81 @@ describe('resolveActiveBanner', () => {
447448
});
448449
});
449450

451+
describe('license rebound elsewhere', () => {
452+
test('lastSyncErrorCode is rebound code → banner (non-dismissible, everyone)', () => {
453+
const result = resolveActiveBanner(makeContext({
454+
license: makeLicense({
455+
status: 'active',
456+
lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE',
457+
}),
458+
}));
459+
expect(result?.id).toBe('licenseReboundElsewhere');
460+
expect(result?.dismissible).toBe(false);
461+
expect(result?.audience).toBe('everyone');
462+
});
463+
464+
test('null lastSyncErrorCode → no rebound banner', () => {
465+
const result = resolveActiveBanner(makeContext({
466+
license: makeLicense({ status: 'active', lastSyncErrorCode: null }),
467+
}));
468+
expect(result?.id).not.toBe('licenseReboundElsewhere');
469+
});
470+
471+
test('other lastSyncErrorCode does not fire rebound banner', () => {
472+
const result = resolveActiveBanner(makeContext({
473+
license: makeLicense({
474+
status: 'active',
475+
lastSyncErrorCode: 'UNKNOWN_STRIPE_PRODUCT',
476+
}),
477+
}));
478+
expect(result?.id).not.toBe('licenseReboundElsewhere');
479+
});
480+
481+
test('offline license suppresses rebound banner', () => {
482+
const result = resolveActiveBanner(makeContext({
483+
offlineLicense: makeOfflineLicense(),
484+
license: makeLicense({
485+
status: 'active',
486+
lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE',
487+
}),
488+
}));
489+
expect(result).toBeNull();
490+
});
491+
492+
test('rebound banner shown to non-owners', () => {
493+
// This is a hard lockout — everyone sees it.
494+
const result = resolveActiveBanner(makeContext({
495+
role: OrgRole.MEMBER,
496+
license: makeLicense({
497+
status: 'active',
498+
lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE',
499+
}),
500+
}));
501+
expect(result?.id).toBe('licenseReboundElsewhere');
502+
});
503+
504+
test('rebound outranks enforced ping staleness', () => {
505+
const result = resolveActiveBanner(makeContext({
506+
license: makeLicense({
507+
status: 'active',
508+
lastSyncAt: new Date(NOW.getTime() - 14 * 24 * 60 * 60 * 1000),
509+
lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE',
510+
}),
511+
}));
512+
expect(result?.id).toBe('licenseReboundElsewhere');
513+
});
514+
515+
test('license expired outranks rebound', () => {
516+
const result = resolveActiveBanner(makeContext({
517+
license: makeLicense({
518+
status: 'canceled',
519+
lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE',
520+
}),
521+
}));
522+
expect(result?.id).toBe('licenseExpired');
523+
});
524+
});
525+
450526
describe('trial', () => {
451527
test('status trialing + future trialEnd → trial banner', () => {
452528
const result = resolveActiveBanner(makeContext({

packages/web/src/app/(app)/components/banners/bannerResolver.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@ import { BannerPriority, type BannerDescriptor, type BannerId } from "./types";
99
import { PermissionSyncBanner } from "./permissionSyncBanner";
1010
import { LicenseExpiredBanner } from "./licenseExpiredBanner";
1111
import { LicenseExpiryHeadsUpBanner } from "./licenseExpiryHeadsUpBanner";
12+
import { LicenseReboundElsewhereBanner } from "./licenseReboundElsewhereBanner";
1213
import { InvoicePastDueBanner } from "./invoicePastDueBanner";
1314
import { ServicePingFailedBanner } from "./servicePingFailedBanner";
1415
import { TrialBanner } from "./trialBanner";
1516

17+
// Mirrors the value in `lighthouse: lambda/serviceError.ts` and the gating
18+
// constant in `packages/shared/src/entitlements.ts`.
19+
const LICENSE_REBOUND_ELSEWHERE_ERROR_CODE = 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE';
20+
1621
const EXPIRY_HEADS_UP_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;
1722

1823
export interface BannerContext {
@@ -66,6 +71,19 @@ function buildCandidates(ctx: BannerContext): BannerDescriptor[] {
6671
});
6772
}
6873

74+
if (
75+
!ctx.offlineLicense
76+
&& ctx.license?.lastSyncErrorCode === LICENSE_REBOUND_ELSEWHERE_ERROR_CODE
77+
) {
78+
banners.push({
79+
id: 'licenseReboundElsewhere',
80+
priority: BannerPriority.LICENSE_REBOUND_ELSEWHERE,
81+
dismissible: false,
82+
audience: 'everyone',
83+
render: (props) => <LicenseReboundElsewhereBanner {...props} />,
84+
});
85+
}
86+
6987
if (
7088
!ctx.offlineLicense
7189
&& ctx.license?.status === 'trialing'

packages/web/src/app/(app)/components/banners/bannerSlot.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ type BannerSlotProps = Omit<BannerContext, 'dismissals' | 'today' | 'now'>;
66

77
const KNOWN_BANNER_IDS: BannerId[] = [
88
'licenseExpired',
9+
'licenseReboundElsewhere',
910
'invoicePastDue',
1011
'permissionSync',
1112
'licenseExpiryHeadsUp',
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import Link from "next/link";
2+
import { AlertTriangle } from "lucide-react";
3+
import { OrgRole } from "@sourcebot/db";
4+
import { Button } from "@/components/ui/button";
5+
import { BannerShell } from "./bannerShell";
6+
import type { BannerProps } from "./types";
7+
8+
// @nocheckin: This should instead be a docs page that explains the enterprise offering.
9+
const ENTERPRISE_OFFERING_DOCS_LINK = "https://sourcebot.dev/pricing";
10+
11+
export function LicenseReboundElsewhereBanner({ id, dismissible, role }: BannerProps) {
12+
const isOwner = role === OrgRole.OWNER;
13+
14+
const whatsAffectedLink = (
15+
<a href={ENTERPRISE_OFFERING_DOCS_LINK} target="_blank" rel="noopener noreferrer">
16+
What&apos;s affected?
17+
</a>
18+
);
19+
20+
const description = isOwner
21+
? <>This license is currently activated on a different Sourcebot install, and enterprise features have been disabled here. To use it on this install, deactivate and reactivate the license. {whatsAffectedLink}</>
22+
: <>This license is currently activated on a different Sourcebot install, and enterprise features have been disabled. Contact your organization administrator to restore access. {whatsAffectedLink}</>;
23+
24+
return (
25+
<BannerShell
26+
id={id}
27+
dismissible={dismissible}
28+
icon={<AlertTriangle className="h-4 w-4 mt-0.5 text-destructive" />}
29+
title="License activated on another instance"
30+
description={description}
31+
action={isOwner ? (
32+
<Button asChild size="sm" variant="outline">
33+
<Link href="/settings/license">Manage license</Link>
34+
</Button>
35+
) : undefined}
36+
/>
37+
);
38+
}

packages/web/src/app/(app)/components/banners/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { OrgRole } from "@sourcebot/db";
33

44
export const BannerPriority = {
55
LICENSE_EXPIRED: 100,
6+
LICENSE_REBOUND_ELSEWHERE: 97,
67
SERVICE_PING_ENFORCED: 95,
78
INVOICE_PAST_DUE: 90,
89
PERMISSION_SYNC: 50,
@@ -13,6 +14,7 @@ export const BannerPriority = {
1314

1415
export type BannerId =
1516
| 'licenseExpired'
17+
| 'licenseReboundElsewhere'
1618
| 'invoicePastDue'
1719
| 'permissionSync'
1820
| 'licenseExpiryHeadsUp'

packages/web/src/app/(app)/settings/license/currentPlanCard.tsx renamed to packages/web/src/app/(app)/settings/license/onlineLicenseCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import { cn, formatCurrency } from "@/lib/utils";
77
import { SettingsCard } from "../components/settingsCard";
88
import { PlanActionsMenu } from "./planActionsMenu";
99

10-
interface CurrentPlanCardProps {
10+
interface OnlineLicenseCardProps {
1111
license: License;
1212
}
1313

14-
export function CurrentPlanCard({ license }: CurrentPlanCardProps) {
14+
export function OnlineLicenseCard({ license }: OnlineLicenseCardProps) {
1515
const {
1616
planName,
1717
unitAmount,

0 commit comments

Comments
 (0)