Skip to content

Commit 70e309b

Browse files
Redeem UX pass (#204)
1 parent fee0767 commit 70e309b

File tree

9 files changed

+281
-192
lines changed

9 files changed

+281
-192
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Org" ADD COLUMN "imageUrl" TEXT;

packages/db/prisma/schema.prisma

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,22 +59,22 @@ model Repo {
5959
}
6060

6161
model Connection {
62-
id Int @id @default(autoincrement())
63-
name String
64-
config Json
65-
createdAt DateTime @default(now())
66-
updatedAt DateTime @updatedAt
67-
syncedAt DateTime?
68-
repos RepoToConnection[]
69-
syncStatus ConnectionSyncStatus @default(SYNC_NEEDED)
70-
syncStatusMetadata Json?
62+
id Int @id @default(autoincrement())
63+
name String
64+
config Json
65+
createdAt DateTime @default(now())
66+
updatedAt DateTime @updatedAt
67+
syncedAt DateTime?
68+
repos RepoToConnection[]
69+
syncStatus ConnectionSyncStatus @default(SYNC_NEEDED)
70+
syncStatusMetadata Json?
7171
7272
// The type of connection (e.g., github, gitlab, etc.)
73-
connectionType String
73+
connectionType String
7474
7575
// The organization that owns this connection
76-
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
77-
orgId Int
76+
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
77+
orgId Int
7878
}
7979

8080
model RepoToConnection {
@@ -121,6 +121,7 @@ model Org {
121121
repos Repo[]
122122
secrets Secret[]
123123
isOnboarded Boolean @default(false)
124+
imageUrl String?
124125
125126
stripeCustomerId String?
126127
stripeSubscriptionStatus StripeSubscriptionStatus?

packages/web/src/actions.ts

Lines changed: 97 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -570,65 +570,115 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{
570570
);
571571

572572

573-
export const redeemInvite = async (invite: Invite, userId: string): Promise<{ success: boolean } | ServiceError> =>
574-
withAuth(async () => {
575-
try {
576-
const res = await prisma.$transaction(async (tx) => {
577-
const org = await tx.org.findUnique({
578-
where: {
579-
id: invite.orgId,
580-
}
581-
});
573+
export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> =>
574+
withAuth(async (session) => {
575+
const invite = await prisma.invite.findUnique({
576+
where: {
577+
id: inviteId,
578+
},
579+
include: {
580+
org: true,
581+
}
582+
});
582583

583-
if (!org) {
584-
return notFound();
585-
}
586-
587-
// @note: we need to use the internal subscription fetch here since we would otherwise fail the `withOrgMembership` check.
588-
const subscription = await _fetchSubscriptionForOrg(org.id, tx);
589-
if (subscription) {
590-
if (isServiceError(subscription)) {
591-
return subscription;
592-
}
584+
if (!invite) {
585+
return notFound();
586+
}
593587

594-
const existingSeatCount = subscription.items.data[0].quantity;
595-
const newSeatCount = (existingSeatCount || 1) + 1
588+
const user = await getUser(session.user.id);
589+
if (!user) {
590+
return notFound();
591+
}
596592

597-
const stripe = getStripe();
598-
await stripe.subscriptionItems.update(
599-
subscription.items.data[0].id,
600-
{
601-
quantity: newSeatCount,
602-
proration_behavior: 'create_prorations',
603-
}
604-
)
593+
// Check if the user is the recipient of the invite
594+
if (user.email !== invite.recipientEmail) {
595+
return notFound();
596+
}
597+
598+
const res = await prisma.$transaction(async (tx) => {
599+
// @note: we need to use the internal subscription fetch here since we would otherwise fail the `withOrgMembership` check.
600+
const subscription = await _fetchSubscriptionForOrg(invite.orgId, tx);
601+
if (subscription) {
602+
if (isServiceError(subscription)) {
603+
return subscription;
605604
}
606605

607-
await tx.userToOrg.create({
608-
data: {
609-
userId,
610-
orgId: invite.orgId,
611-
role: "MEMBER",
612-
}
613-
});
606+
const existingSeatCount = subscription.items.data[0].quantity;
607+
const newSeatCount = (existingSeatCount || 1) + 1
614608

615-
await tx.invite.delete({
616-
where: {
617-
id: invite.id,
609+
const stripe = getStripe();
610+
await stripe.subscriptionItems.update(
611+
subscription.items.data[0].id,
612+
{
613+
quantity: newSeatCount,
614+
proration_behavior: 'create_prorations',
618615
}
619-
});
616+
)
617+
}
618+
619+
await tx.userToOrg.create({
620+
data: {
621+
userId: user.id,
622+
orgId: invite.orgId,
623+
role: "MEMBER",
624+
}
620625
});
621626

622-
if (isServiceError(res)) {
623-
return res;
627+
await tx.invite.delete({
628+
where: {
629+
id: invite.id,
630+
}
631+
});
632+
});
633+
634+
if (isServiceError(res)) {
635+
return res;
636+
}
637+
638+
return {
639+
success: true,
640+
}
641+
});
642+
643+
export const getInviteInfo = async (inviteId: string) =>
644+
withAuth(async (session) => {
645+
const user = await getUser(session.user.id);
646+
if (!user) {
647+
return notFound();
648+
}
649+
650+
const invite = await prisma.invite.findUnique({
651+
where: {
652+
id: inviteId,
653+
},
654+
include: {
655+
org: true,
656+
host: true,
624657
}
658+
});
625659

626-
return {
627-
success: true,
660+
if (!invite) {
661+
return notFound();
662+
}
663+
664+
if (invite.recipientEmail !== user.email) {
665+
return notFound();
666+
}
667+
668+
return {
669+
id: invite.id,
670+
orgName: invite.org.name,
671+
orgImageUrl: invite.org.imageUrl ?? undefined,
672+
orgDomain: invite.org.domain,
673+
host: {
674+
name: invite.host.name ?? undefined,
675+
email: invite.host.email!,
676+
avatarUrl: invite.host.image ?? undefined,
677+
},
678+
recipient: {
679+
name: user.name ?? undefined,
680+
email: user.email!,
628681
}
629-
} catch (error) {
630-
console.error("Failed to redeem invite:", error);
631-
return unexpectedError("Failed to redeem invite");
632682
}
633683
});
634684

packages/web/src/app/[domain]/settings/billing/page.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ export default async function BillingPage({
4949
<h3 className="text-lg font-medium">Billing</h3>
5050
<p className="text-sm text-muted-foreground">Manage your subscription and billing information</p>
5151
</div>
52-
<Separator />
5352
<div className="grid gap-6">
5453
{/* Billing Email Card */}
5554
<ChangeBillingEmailCard currentUserRole={currentUserRole} />

packages/web/src/app/onboard/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export default async function Onboarding() {
1212
}
1313

1414
return (
15-
<div className="flex flex-col items-center min-h-screen p-12 bg-backgroundSecondary fade-in-20 relative">
15+
<div className="flex flex-col items-center min-h-screen p-12 bg-backgroundSecondary relative">
1616
<OnboardHeader
1717
title="Setup your organization"
1818
description="Create a organization for your team to search and share code across your repositories."

packages/web/src/app/redeem/components/acceptInviteButton.tsx

Lines changed: 0 additions & 53 deletions
This file was deleted.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
'use client';
2+
3+
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
4+
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
5+
import Link from "next/link";
6+
import { Avatar, AvatarImage } from "@/components/ui/avatar";
7+
import placeholderAvatar from "@/public/placeholder_avatar.png";
8+
import { ArrowRight, Loader2 } from "lucide-react";
9+
import { Button } from "@/components/ui/button";
10+
import { useCallback, useState } from "react";
11+
import { redeemInvite } from "@/actions";
12+
import { useRouter } from "next/navigation";
13+
import { useToast } from "@/components/hooks/use-toast";
14+
import { isServiceError } from "@/lib/utils";
15+
16+
interface AcceptInviteCardProps {
17+
inviteId: string;
18+
orgName: string;
19+
orgDomain: string;
20+
orgImageUrl?: string;
21+
host: {
22+
name?: string;
23+
email: string;
24+
avatarUrl?: string;
25+
};
26+
recipient: {
27+
name?: string;
28+
email: string;
29+
};
30+
}
31+
32+
export const AcceptInviteCard = ({ inviteId, orgName, orgDomain, orgImageUrl, host, recipient }: AcceptInviteCardProps) => {
33+
const [isLoading, setIsLoading] = useState(false);
34+
const router = useRouter();
35+
const { toast } = useToast();
36+
37+
const onRedeemInvite = useCallback(() => {
38+
setIsLoading(true);
39+
redeemInvite(inviteId)
40+
.then((response) => {
41+
if (isServiceError(response)) {
42+
toast({
43+
description: `Failed to redeem invite with error: ${response.message}`,
44+
variant: "destructive",
45+
});
46+
} else {
47+
toast({
48+
description: `✅ You are now a member of the ${orgName} organization.`,
49+
});
50+
router.push(`/${orgDomain}`);
51+
}
52+
})
53+
.finally(() => {
54+
setIsLoading(false);
55+
});
56+
}, [inviteId, orgDomain, orgName, router, toast]);
57+
58+
return (
59+
<Card className="p-12 max-w-lg">
60+
<CardHeader className="text-center">
61+
<SourcebotLogo
62+
className="h-16 w-auto mx-auto mb-2"
63+
size="large"
64+
/>
65+
<CardTitle className="font-medium text-2xl">
66+
Join <strong>{orgName}</strong>
67+
</CardTitle>
68+
</CardHeader>
69+
<CardContent className="mt-3">
70+
<p>
71+
Hello {recipient.name?.split(' ')[0] ?? recipient.email},
72+
</p>
73+
<p className="mt-5">
74+
<InvitedByText email={host.email} name={host.name} /> invited you to join the <strong>{orgName}</strong> organization.
75+
</p>
76+
<div className="flex fex-row items-center justify-center gap-2 mt-12">
77+
<Avatar className="w-14 h-14">
78+
<AvatarImage src={host.avatarUrl ?? placeholderAvatar.src} />
79+
</Avatar>
80+
<ArrowRight className="w-4 h-4 text-muted-foreground" />
81+
<Avatar className="w-14 h-14">
82+
<AvatarImage src={orgImageUrl ?? placeholderAvatar.src} />
83+
</Avatar>
84+
</div>
85+
<Button
86+
className="mt-12 mx-auto w-full"
87+
disabled={isLoading}
88+
onClick={onRedeemInvite}
89+
>
90+
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
91+
Accept Invite
92+
</Button>
93+
</CardContent>
94+
</Card>
95+
)
96+
}
97+
98+
const InvitedByText = ({ email, name }: { email: string, name?: string }) => {
99+
const emailElement = <Link href={`mailto:${email}`} className="text-blue-500 hover:text-blue-600">
100+
{email}
101+
</Link>;
102+
103+
if (name) {
104+
const firstName = name.split(' ')[0];
105+
return <span><strong>{firstName}</strong> ({emailElement})</span>;
106+
}
107+
108+
return emailElement;
109+
}

0 commit comments

Comments
 (0)