Skip to content

Commit 409b881

Browse files
clarified org availability design
1 parent 12f7507 commit 409b881

File tree

10 files changed

+58
-75
lines changed

10 files changed

+58
-75
lines changed

packages/backend/src/entitlements.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {
22
Entitlement,
3-
getSeats as _getSeats,
43
hasEntitlement as _hasEntitlement,
54
getEntitlements as _getEntitlements,
65
} from "@sourcebot/shared";
@@ -13,11 +12,6 @@ const getLicense = async () => {
1312
});
1413
}
1514

16-
export const getSeats = async (): Promise<number> => {
17-
const license = await getLicense();
18-
return _getSeats(license);
19-
}
20-
2115
export const hasEntitlement = async (entitlement: Entitlement): Promise<boolean> => {
2216
const license = await getLicense();
2317
return _hasEntitlement(entitlement, license);

packages/shared/src/constants.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ export const API_KEY_PREFIX = 'sbk_';
1212
export const OAUTH_ACCESS_TOKEN_PREFIX = 'sboa_';
1313
export const OAUTH_REFRESH_TOKEN_PREFIX = 'sbor_';
1414

15-
export const SOURCEBOT_UNLIMITED_SEATS = -1;
16-
1715
/**
1816
* Default settings.
1917
*/

packages/shared/src/entitlements.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { base64Decode } from "./utils.js";
22
import { z } from "zod";
33
import { createLogger } from "./logger.js";
44
import { env } from "./env.server.js";
5-
import { SOURCEBOT_SUPPORT_EMAIL, SOURCEBOT_UNLIMITED_SEATS } from "./constants.js";
5+
import { SOURCEBOT_SUPPORT_EMAIL } from "./constants.js";
66
import { verifySignature } from "./crypto.js";
77
import { License } from "@sourcebot/db";
88

@@ -79,19 +79,6 @@ export const getOfflineLicenseKey = (): LicenseKeyPayload | null => {
7979
return null;
8080
}
8181

82-
export const getSeats = (license: License | null): number => {
83-
const licenseKey = getOfflineLicenseKey();
84-
if (licenseKey) {
85-
return licenseKey.seats;
86-
}
87-
88-
if (license?.seats && isLicenseActive(license)) {
89-
return license.seats;
90-
}
91-
92-
return SOURCEBOT_UNLIMITED_SEATS;
93-
}
94-
9582
export const hasEntitlement = (entitlement: Entitlement, license: License | null) => {
9683
const entitlements = getEntitlements(license);
9784
return entitlements.includes(entitlement);

packages/shared/src/index.server.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
export {
22
hasEntitlement,
33
getOfflineLicenseKey,
4-
getSeats,
54
getEntitlements,
65
} from "./entitlements.js";
76
export type {

packages/web/src/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,7 @@ export const createInvites = async (emails: string[]): Promise<{ success: boolea
545545
});
546546
}
547547

548-
const hasAvailability = await orgHasAvailability();
548+
const hasAvailability = await orgHasAvailability(org.id);
549549
if (!hasAvailability) {
550550
await createAudit({
551551
action: "user.invite_failed",

packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
88
import { useForm } from "react-hook-form";
99
import { useCallback, useState } from "react";
1010
import { z } from "zod";
11-
import { PlusCircleIcon, Loader2, AlertCircle } from "lucide-react";
11+
import { PlusCircleIcon, Loader2, AlertCircle, AlertTriangle } from "lucide-react";
1212
import { OrgRole } from "@prisma/client";
1313
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
1414
import { createInvites } from "@/actions";
@@ -28,10 +28,10 @@ export const inviteMemberFormSchema = z.object({
2828

2929
interface InviteMemberCardProps {
3030
currentUserRole: OrgRole;
31-
seatsAvailable?: boolean;
31+
seatsAvailable: boolean;
3232
}
3333

34-
export const InviteMemberCard = ({ currentUserRole, seatsAvailable = true }: InviteMemberCardProps) => {
34+
export const InviteMemberCard = ({ currentUserRole, seatsAvailable }: InviteMemberCardProps) => {
3535
const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false);
3636
const [isLoading, setIsLoading] = useState(false);
3737
const { toast } = useToast();
@@ -82,20 +82,20 @@ export const InviteMemberCard = ({ currentUserRole, seatsAvailable = true }: Inv
8282

8383
return (
8484
<>
85-
<Card className={!seatsAvailable ? "opacity-70" : ""}>
85+
<Card>
8686
<CardHeader>
8787
<CardTitle>Invite Member</CardTitle>
8888
<CardDescription>Invite new members to your organization.</CardDescription>
8989
</CardHeader>
9090
{!seatsAvailable && (
9191
<div className="px-6 mb-4">
92-
<div className="flex items-start space-x-2.5 p-3 rounded-md border border-gray-700 bg-gray-800/50 text-gray-200 shadow-md">
93-
<AlertCircle className="h-4 w-4 text-amber-400 mt-0.5 flex-shrink-0" />
92+
<div className="flex items-start space-x-2.5 p-3 rounded-md border">
93+
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 flex-shrink-0" />
9494
<div className="flex-1">
95-
<p className="text-sm font-medium leading-tight text-white">
95+
<p className="text-sm font-bold">
9696
Maximum seats reached
9797
</p>
98-
<p className="text-xs mt-1 text-gray-300">
98+
<p className="text-xs mt-1 text-foreground">
9999
You&apos;ve reached the maximum number of seats for your license. Upgrade your plan to invite additional members.
100100
</p>
101101
</div>

packages/web/src/app/(app)/settings/members/page.tsx

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ import { TabSwitcher } from "@/components/ui/tab-switcher";
77
import { InvitesList } from "./components/invitesList";
88
import { getOrgInvites, getMe, getOrgAccountRequests } from "@/actions";
99
import { ServiceErrorException } from "@/lib/serviceError";
10-
import { SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
11-
import { getSeats, hasEntitlement } from "@/lib/entitlements";
10+
import { hasEntitlement } from "@/lib/entitlements";
1211
import { RequestsList } from "./components/requestsList";
1312
import { OrgRole } from "@sourcebot/db";
1413
import { NotificationDot } from "../../components/notificationDot";
1514
import { Badge } from "@/components/ui/badge";
1615
import { authenticatedPage } from "@/middleware/authenticatedPage";
16+
import { orgHasAvailability } from "@/lib/authUtils";
17+
import { getOfflineLicenseKey } from "@sourcebot/shared";
1718

1819
type MembersSettingsPageProps = {
1920
searchParams: Promise<{
@@ -50,9 +51,8 @@ export default authenticatedPage<MembersSettingsPageProps>(async ({ org, role },
5051

5152
const currentTab = tab || "members";
5253

53-
const seats = await getSeats();
54-
const usedSeats = members.length
55-
const seatsAvailable = seats === SOURCEBOT_UNLIMITED_SEATS || usedSeats < seats;
54+
const hasAvailability = await orgHasAvailability(org.id);
55+
const offlineLicenseKey = getOfflineLicenseKey();
5656

5757
return (
5858
<div className="flex flex-col gap-6">
@@ -61,12 +61,12 @@ export default authenticatedPage<MembersSettingsPageProps>(async ({ org, role },
6161
<h3 className="text-lg font-medium">Members</h3>
6262
<p className="text-sm text-muted-foreground">Invite and manage members of your organization.</p>
6363
</div>
64-
{seats && seats !== SOURCEBOT_UNLIMITED_SEATS && (
64+
{offlineLicenseKey?.seats && (
6565
<div className="bg-card px-4 py-2 rounded-md border shadow-sm">
6666
<div className="text-sm">
67-
<span className="text-foreground font-medium">{usedSeats}</span>
67+
<span className="text-foreground font-medium">{members.length}</span>
6868
<span className="text-muted-foreground"> of </span>
69-
<span className="text-foreground font-medium">{seats}</span>
69+
<span className="text-foreground font-medium">{offlineLicenseKey.seats}</span>
7070
<span className="text-muted-foreground"> seats used</span>
7171
</div>
7272
</div>
@@ -75,7 +75,7 @@ export default authenticatedPage<MembersSettingsPageProps>(async ({ org, role },
7575

7676
<InviteMemberCard
7777
currentUserRole={role}
78-
seatsAvailable={seatsAvailable}
78+
seatsAvailable={hasAvailability}
7979
/>
8080

8181
<Tabs value={currentTab}>
@@ -159,4 +159,7 @@ export default authenticatedPage<MembersSettingsPageProps>(async ({ org, role },
159159
</Tabs>
160160
</div>
161161
)
162-
}, { minRole: OrgRole.OWNER, redirectTo: '/settings' });
162+
}, {
163+
minRole: OrgRole.OWNER,
164+
redirectTo: '/settings'
165+
});

packages/web/src/app/invite/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
9898
});
9999
};
100100

101-
const hasAvailability = await orgHasAvailability();
101+
const hasAvailability = await orgHasAvailability(invite.org.id);
102102
if (!hasAvailability) {
103103
await failAuditCallback("Organization is at max capacity");
104104
return {

packages/web/src/lib/authUtils.ts

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@ import type { User as AuthJsUser } from "next-auth";
22
import { __unsafePrisma } from "@/prisma";
33
import { OrgRole } from "@sourcebot/db";
44
import { SINGLE_TENANT_ORG_ID, SOURCEBOT_GUEST_USER_EMAIL, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants";
5-
import { SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
6-
import { getSeats, hasEntitlement } from "@/lib/entitlements";
5+
import { hasEntitlement } from "@/lib/entitlements";
76
import { isServiceError } from "@/lib/utils";
87
import { orgNotFound, ServiceError, userNotFound } from "@/lib/serviceError";
9-
import { createLogger } from "@sourcebot/shared";
8+
import { createLogger, getOfflineLicenseKey } from "@sourcebot/shared";
109
import { createAudit } from "@/ee/features/audit/audit";
1110
import { StatusCodes } from "http-status-codes";
1211
import { ErrorCode } from "./errorCodes";
@@ -49,7 +48,6 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
4948
}
5049
});
5150

52-
// We expect the default org to have been created on app initialization
5351
if (defaultOrg === null) {
5452
await createAudit({
5553
action: "user.creation_failed",
@@ -69,7 +67,8 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
6967
throw new Error("Default org not found on single tenant user creation");
7068
}
7169

72-
// If this is the first user to sign up, we make them the owner of the default org.
70+
// First (non-guest) user to sign up bootstraps the org as its OWNER. This
71+
// is how a fresh deployment gets its initial admin without manual setup.
7372
const isFirstUser = defaultOrg.members.length === 0;
7473
if (isFirstUser) {
7574
await __unsafePrisma.$transaction(async (tx) => {
@@ -104,8 +103,16 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
104103
type: "org"
105104
}
106105
});
107-
} else if (!defaultOrg.memberApprovalRequired) {
108-
const hasAvailability = await orgHasAvailability();
106+
}
107+
108+
// Subsequent users auto-join as MEMBER only when the org is in open
109+
// self-serve mode. If memberApprovalRequired is true, the user is left
110+
// without a membership and must submit an AccountRequest for an owner to
111+
// approve via addUserToOrganization.
112+
else if (!defaultOrg.memberApprovalRequired) {
113+
// Don't exceed the licensed seat count. The user row still exists;
114+
// they just aren't attached to the org until a seat frees up.
115+
const hasAvailability = await orgHasAvailability(defaultOrg.id);
109116
if (!hasAvailability) {
110117
logger.warn(`onCreateUser: org ${SINGLE_TENANT_ORG_ID} has reached max capacity. User ${user.id} was not added to the org.`);
111118
return;
@@ -189,30 +196,31 @@ export const createGuestUser = async (): Promise<ServiceError | boolean> => {
189196
return true;
190197
};
191198

192-
export const orgHasAvailability = async (): Promise<boolean> => {
193-
const org = await __unsafePrisma.org.findUnique({
199+
/**
200+
* Checks to see if the given organization has seat availability.
201+
* Seat availability is determined by the `seats` parameter in
202+
* the offline license key, if available.
203+
*/
204+
export const orgHasAvailability = async (orgId: number): Promise<boolean> => {
205+
const org = await __unsafePrisma.org.findUniqueOrThrow({
194206
where: {
195-
id: SINGLE_TENANT_ORG_ID,
207+
id: orgId,
196208
},
197-
});
198-
199-
if (!org) {
200-
logger.error(`orgHasAvailability: org not found for id ${SINGLE_TENANT_ORG_ID}`);
201-
return false;
202-
}
203-
const members = await __unsafePrisma.userToOrg.findMany({
204-
where: {
205-
orgId: org.id,
206-
role: {
207-
not: OrgRole.GUEST,
209+
include: {
210+
members: {
211+
where: {
212+
role: {
213+
not: OrgRole.GUEST
214+
}
215+
}
208216
},
209-
},
217+
}
210218
});
211219

212-
const maxSeats = await getSeats();
213-
const memberCount = members.length;
220+
const licenseKey = getOfflineLicenseKey();
221+
const memberCount = org.members.length;
214222

215-
if (maxSeats !== SOURCEBOT_UNLIMITED_SEATS && memberCount >= maxSeats) {
223+
if (licenseKey && memberCount >= licenseKey?.seats) {
216224
logger.error(`orgHasAvailability: org ${org.id} has reached max capacity`);
217225
return false;
218226
}
@@ -243,7 +251,7 @@ export const addUserToOrganization = async (userId: string, orgId: number): Prom
243251
return orgNotFound();
244252
}
245253

246-
const hasAvailability = await orgHasAvailability();
254+
const hasAvailability = await orgHasAvailability(org.id);
247255
if (!hasAvailability) {
248256
return {
249257
statusCode: StatusCodes.BAD_REQUEST,

packages/web/src/lib/entitlements.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {
2-
getSeats as _getSeats,
32
getEntitlements as _getEntitlements,
43
hasEntitlement as _hasEntitlement,
54
Entitlement,
@@ -19,11 +18,6 @@ const getSingleTenantLicense = async () => {
1918
}
2019
}
2120

22-
export const getSeats = async () => {
23-
const license = await getSingleTenantLicense();
24-
return _getSeats(license);
25-
}
26-
2721
export const getEntitlements = async () => {
2822
const license = await getSingleTenantLicense();
2923
return _getEntitlements(license);

0 commit comments

Comments
 (0)