Skip to content

Commit 675dbaf

Browse files
wip on e2e checkout flow
1 parent 396f6b9 commit 675dbaf

File tree

8 files changed

+355
-125
lines changed

8 files changed

+355
-125
lines changed
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"use client";
2+
3+
import { useState, useCallback } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { Input } from "@/components/ui/input";
6+
import { Button } from "@/components/ui/button";
7+
import { LoadingButton } from "@/components/ui/loading-button";
8+
import { SettingsCard } from "../components/settingsCard";
9+
import {
10+
AlertDialog,
11+
AlertDialogAction,
12+
AlertDialogCancel,
13+
AlertDialogContent,
14+
AlertDialogDescription,
15+
AlertDialogFooter,
16+
AlertDialogHeader,
17+
AlertDialogTitle,
18+
} from "@/components/ui/alert-dialog";
19+
import { activateLicense, deactivateLicense } from "@/ee/features/lighthouse/actions";
20+
import { isServiceError } from "@/lib/utils";
21+
import { useToast } from "@/components/hooks/use-toast";
22+
import { Separator } from "@/components/ui/separator";
23+
24+
interface ActivationCodeCardProps {
25+
isActivated: boolean;
26+
}
27+
28+
export function ActivationCodeCard({ isActivated }: ActivationCodeCardProps) {
29+
const [activationCode, setActivationCode] = useState("");
30+
const [isActivating, setIsActivating] = useState(false);
31+
const [isRemoveDialogOpen, setIsRemoveDialogOpen] = useState(false);
32+
const router = useRouter();
33+
const { toast } = useToast();
34+
35+
const handleActivate = useCallback(() => {
36+
if (!activationCode.trim()) {
37+
return;
38+
}
39+
40+
setIsActivating(true);
41+
activateLicense(activationCode.trim())
42+
.then((response) => {
43+
if (isServiceError(response)) {
44+
toast({
45+
description: `Failed to activate license: ${response.message}`,
46+
variant: "destructive",
47+
});
48+
} else {
49+
toast({
50+
description: "License activated successfully.",
51+
});
52+
setActivationCode("");
53+
router.refresh();
54+
}
55+
})
56+
.finally(() => {
57+
setIsActivating(false);
58+
});
59+
}, [activationCode, toast, router]);
60+
61+
const handleDeactivate = useCallback(() => {
62+
deactivateLicense()
63+
.then((response) => {
64+
if (isServiceError(response)) {
65+
toast({
66+
description: `Failed to remove license: ${response.message}`,
67+
variant: "destructive",
68+
});
69+
} else {
70+
toast({
71+
description: "License removed successfully.",
72+
});
73+
router.refresh();
74+
}
75+
});
76+
}, [toast, router]);
77+
78+
return (
79+
<>
80+
<SettingsCard>
81+
<div className="flex flex-col gap-2">
82+
<p className="font-medium">Activation code</p>
83+
<p className="text-sm text-muted-foreground">
84+
Enter your activation code to enable your enterprise license.
85+
</p>
86+
<Separator className="my-2" />
87+
<div>
88+
{isActivated ? (
89+
<div className="flex items-center gap-3">
90+
<code className="text-sm bg-muted px-3 py-2 rounded-md font-mono flex-1">
91+
sb_act_••••
92+
</code>
93+
<Button
94+
variant="destructive"
95+
size="sm"
96+
onClick={() => setIsRemoveDialogOpen(true)}
97+
>
98+
Remove
99+
</Button>
100+
</div>
101+
) : (
102+
<div className="flex items-center gap-3">
103+
<Input
104+
placeholder="sb_act_..."
105+
value={activationCode}
106+
onChange={(e) => setActivationCode(e.target.value)}
107+
onKeyDown={(e) => {
108+
if (e.key === "Enter") {
109+
handleActivate();
110+
}
111+
}}
112+
disabled={isActivating}
113+
className="font-mono"
114+
/>
115+
<LoadingButton
116+
size="sm"
117+
onClick={handleActivate}
118+
loading={isActivating}
119+
disabled={!activationCode.trim()}
120+
>
121+
Activate
122+
</LoadingButton>
123+
</div>
124+
)}
125+
</div>
126+
</div>
127+
</SettingsCard>
128+
129+
<AlertDialog open={isRemoveDialogOpen} onOpenChange={setIsRemoveDialogOpen}>
130+
<AlertDialogContent>
131+
<AlertDialogHeader>
132+
<AlertDialogTitle>Remove activation code</AlertDialogTitle>
133+
<AlertDialogDescription>
134+
Are you sure you want to remove this activation code? Your deployment will lose access to enterprise features.
135+
</AlertDialogDescription>
136+
</AlertDialogHeader>
137+
<AlertDialogFooter>
138+
<AlertDialogCancel>Cancel</AlertDialogCancel>
139+
<AlertDialogAction
140+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
141+
onClick={handleDeactivate}
142+
>
143+
Remove
144+
</AlertDialogAction>
145+
</AlertDialogFooter>
146+
</AlertDialogContent>
147+
</AlertDialog>
148+
</>
149+
);
150+
}
Lines changed: 16 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,119 +1,24 @@
1-
import { getOfflineLicenseKey, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
2-
import { getEntitlements, getPlan } from "@/lib/entitlements";
3-
import { Button } from "@/components/ui/button";
4-
import { Info, Mail } from "lucide-react";
5-
import { getOrgMembers } from "@/actions";
6-
import { isServiceError } from "@/lib/utils";
7-
import { ServiceErrorException } from "@/lib/serviceError";
81
import { authenticatedPage } from "@/middleware/authenticatedPage";
92
import { OrgRole } from "@sourcebot/db";
3+
import { ActivationCodeCard } from "./activationCodeCard";
4+
import { PurchaseButton } from "./purchaseButton";
105

11-
export default authenticatedPage(async () => {
12-
const licenseKey = getOfflineLicenseKey();
13-
const entitlements = await getEntitlements();
14-
const plan = await getPlan();
15-
16-
if (!licenseKey) {
17-
return (
18-
<div className="flex flex-col gap-6">
19-
<div>
20-
<h3 className="text-lg font-medium">License</h3>
21-
<p className="text-sm text-muted-foreground">View your license details.</p>
22-
</div>
23-
24-
<div className="flex flex-col items-center justify-center p-8 border rounded-md bg-card">
25-
<Info className="h-12 w-12 text-muted-foreground mb-4" />
26-
<h3 className="text-lg font-medium mb-2">No License Found</h3>
27-
<p className="text-sm text-muted-foreground text-center max-w-md mb-6">
28-
Check out the <a href="https://docs.sourcebot.dev/docs/license-key" target="_blank" rel="noopener noreferrer" className="text-primary">docs</a> for more information.
29-
</p>
30-
<div className="mb-8 max-w-md rounded-lg bg-slate-50 p-4 dark:bg-slate-800">
31-
<p className="text-base text-center">
32-
Want to try out Sourcebot&apos;s enterprise features? Reach out to us and we&apos;ll get back to you within
33-
a couple hours with a trial license.
34-
</p>
35-
</div>
36-
<Button asChild>
37-
<a href={`https://sourcebot.dev/contact`} target="_blank" rel="noopener noreferrer">
38-
<Mail className="h-4 w-4 mr-2" />
39-
Request a trial license
40-
</a>
41-
</Button>
42-
</div>
43-
</div>
44-
)
45-
}
46-
47-
const members = await getOrgMembers();
48-
if (isServiceError(members)) {
49-
throw new ServiceErrorException(members);
50-
}
51-
52-
const numMembers = members.length;
53-
const expiryDate = new Date(licenseKey.expiryDate);
54-
const isExpired = expiryDate < new Date();
55-
const seats = licenseKey.seats;
56-
const isUnlimited = seats === SOURCEBOT_UNLIMITED_SEATS;
6+
export default authenticatedPage(async ({ prisma, org }) => {
7+
const license = await prisma.license.findUnique({
8+
where: { orgId: org.id },
9+
});
5710

5811
return (
5912
<div className="flex flex-col gap-6">
60-
<div className="flex items-start justify-between">
61-
<div>
62-
<h3 className="text-lg font-medium">License</h3>
63-
<p className="text-sm text-muted-foreground">View your license details.</p>
64-
</div>
65-
66-
<Button asChild>
67-
<a href={`mailto:team@sourcebot.dev?subject=License Support - ${licenseKey.id}&body=License ID: ${licenseKey.id}`}>
68-
<Mail className="h-4 w-4 mr-2" />
69-
Contact Support
70-
</a>
71-
</Button>
72-
</div>
73-
74-
<div className="grid gap-6">
75-
<div className="border rounded-md p-6 bg-card">
76-
<h4 className="text-base font-medium mb-4">License Details</h4>
77-
78-
<div className="grid gap-4">
79-
<div className="grid grid-cols-2 gap-4">
80-
<div className="text-sm text-muted-foreground">License ID</div>
81-
<div className="text-sm font-mono">{licenseKey.id}</div>
82-
</div>
83-
84-
<div className="grid grid-cols-2 gap-4">
85-
<div className="text-sm text-muted-foreground">Plan</div>
86-
<div className="text-sm font-mono">{plan}</div>
87-
</div>
88-
89-
<div className="grid grid-cols-2 gap-4">
90-
<div className="text-sm text-muted-foreground">Entitlements</div>
91-
<div className="text-sm font-mono">{entitlements?.join(", ") || "None"}</div>
92-
</div>
93-
94-
<div className="grid grid-cols-2 gap-4">
95-
<div className="text-sm text-muted-foreground">Seats</div>
96-
<div className="text-sm font-mono">
97-
{isUnlimited ? 'Unlimited' : `${numMembers} / ${seats}`}
98-
</div>
99-
</div>
100-
101-
<div className="grid grid-cols-2 gap-4">
102-
<div className="text-sm text-muted-foreground">Expiry Date</div>
103-
<div className={`text-sm font-mono ${isExpired ? 'text-destructive' : ''}`}>
104-
{expiryDate.toLocaleString("en-US", {
105-
hour: "2-digit",
106-
minute: "2-digit",
107-
month: "long",
108-
day: "numeric",
109-
year: "numeric",
110-
timeZoneName: "short"
111-
})} {isExpired && '(Expired)'}
112-
</div>
113-
</div>
114-
</div>
115-
</div>
13+
<div>
14+
<h3 className="text-lg font-medium">License</h3>
15+
<p className="text-sm text-muted-foreground">Manage your license.</p>
11616
</div>
17+
<ActivationCodeCard isActivated={!!license} />
18+
<PurchaseButton />
11719
</div>
118-
)
119-
}, { minRole: OrgRole.OWNER, redirectTo: '/settings' });
20+
);
21+
}, {
22+
minRole: OrgRole.OWNER,
23+
redirectTo: '/settings'
24+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"use client";
2+
3+
import { useState, useCallback } from "react";
4+
import { LoadingButton } from "@/components/ui/loading-button";
5+
import { createCheckoutSession } from "@/ee/features/lighthouse/actions";
6+
import { isServiceError } from "@/lib/utils";
7+
import { useToast } from "@/components/hooks/use-toast";
8+
9+
export function PurchaseButton() {
10+
const [isLoading, setIsLoading] = useState(false);
11+
const { toast } = useToast();
12+
13+
const handleClick = useCallback(() => {
14+
setIsLoading(true);
15+
16+
const successUrl = `${window.location.origin}/settings/license?checkout=success`;
17+
const cancelUrl = `${window.location.origin}/settings/license`;
18+
19+
createCheckoutSession(successUrl, cancelUrl)
20+
.then((response) => {
21+
if (isServiceError(response)) {
22+
toast({
23+
description: `Failed to start checkout: ${response.message}`,
24+
variant: "destructive",
25+
});
26+
} else {
27+
window.location.href = response.url;
28+
}
29+
})
30+
.finally(() => {
31+
setIsLoading(false);
32+
});
33+
}, [toast]);
34+
35+
return (
36+
<LoadingButton
37+
variant="outline"
38+
onClick={handleClick}
39+
loading={isLoading}
40+
>
41+
Purchase a license
42+
</LoadingButton>
43+
);
44+
}

packages/web/src/app/(app)/settings/linked-accounts/page.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { isServiceError } from "@/lib/utils";
44
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
55
import { ShieldCheck } from "lucide-react";
66
import { LinkedAccountProviderCard } from "@/ee/features/sso/components/linkedAccountProviderCard";
7-
import { SettingsCardGroup } from "../components/settingsCard";
87

98
export default async function LinkedAccountsPage() {
109
const linkedAccounts = await getLinkedAccounts();

0 commit comments

Comments
 (0)