Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions ee/apps/den-api/src/organization-limits.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { eq, sql } from "@openwork-ee/den-db/drizzle"
import { MemberTable, OrganizationTable, WorkerTable } from "@openwork-ee/den-db/schema"
import { and, eq, gt, sql } from "@openwork-ee/den-db/drizzle"
import { InvitationTable, MemberTable, OrganizationTable, WorkerTable } from "@openwork-ee/den-db/schema"
import { db } from "./db.js"

export const DEFAULT_ORGANIZATION_LIMITS = {
Expand Down Expand Up @@ -116,6 +116,15 @@ async function countOrganizationMembers(organizationId: OrganizationId) {
return Number(rows[0]?.count ?? 0)
}

async function countPendingOrganizationInvitations(organizationId: OrganizationId) {
const rows = await db
.select({ count: sql<number>`count(*)` })
.from(InvitationTable)
.where(and(eq(InvitationTable.organizationId, organizationId), eq(InvitationTable.status, "pending"), gt(InvitationTable.expiresAt, new Date())))

return Number(rows[0]?.count ?? 0)
}

async function countOrganizationWorkers(organizationId: OrganizationId) {
const rows = await db
.select({ count: sql<number>`count(*)` })
Expand All @@ -129,7 +138,7 @@ export async function getOrganizationLimitStatus(organizationId: OrganizationId,
const metadata = await getOrInitializeOrganizationMetadata(organizationId)
const currentCount =
limitType === "members"
? await countOrganizationMembers(organizationId)
? (await countOrganizationMembers(organizationId)) + (await countPendingOrganizationInvitations(organizationId))
: await countOrganizationWorkers(organizationId)

const limit = metadata.limits[limitType]
Expand Down
24 changes: 13 additions & 11 deletions ee/apps/den-api/src/routes/org/invitations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,6 @@ export function registerOrgInvitationRoutes<T extends { Variables: OrgRouteVaria
}, 409)
}

const memberLimit = await getOrganizationLimitStatus(payload.organization.id, "members")
if (memberLimit.exceeded) {
return c.json({
error: "org_limit_reached",
limitType: "members",
limit: memberLimit.limit,
currentCount: memberLimit.currentCount,
message: `This workspace currently supports up to ${memberLimit.limit} members. Contact support to increase the limit.`,
}, 409)
}

const existingInvitation = await db
.select()
.from(InvitationTable)
Expand All @@ -75,6 +64,19 @@ export function registerOrgInvitationRoutes<T extends { Variables: OrgRouteVaria
)
.limit(1)

if (!existingInvitation[0]) {
const memberLimit = await getOrganizationLimitStatus(payload.organization.id, "members")
if (memberLimit.exceeded) {
return c.json({
error: "org_limit_reached",
limitType: "members",
limit: memberLimit.limit,
currentCount: memberLimit.currentCount,
message: `This workspace currently supports up to ${memberLimit.limit} members. Contact support to increase the limit.`,
}, 409)
}
}

const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7)
const invitationId = existingInvitation[0]?.id ?? createInvitationId()

Expand Down
89 changes: 85 additions & 4 deletions ee/apps/den-web/app/(den)/_components/organization-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,55 @@ import { useDenFlow } from "../_providers/den-flow-provider";

type SettingsTab = "profile" | "organizations";

const PENDING_ORG_DRAFT_STORAGE_KEY = "openwork-den-pending-org-draft";

function readPendingOrgDraft(userEmail: string | null | undefined) {
if (typeof window === "undefined" || !userEmail) {
return null;
}

const raw = window.localStorage.getItem(PENDING_ORG_DRAFT_STORAGE_KEY);
if (!raw) {
return null;
}

try {
const parsed = JSON.parse(raw) as { name?: unknown; email?: unknown };
if (typeof parsed.name !== "string" || typeof parsed.email !== "string") {
return null;
}

return parsed.email === userEmail ? parsed.name.trim() : null;
} catch {
return null;
}
}

function writePendingOrgDraft(name: string, userEmail: string | null | undefined) {
if (typeof window === "undefined" || !userEmail) {
return;
}

window.localStorage.setItem(
PENDING_ORG_DRAFT_STORAGE_KEY,
JSON.stringify({
name,
email: userEmail,
}),
);
}

function clearPendingOrgDraft() {
if (typeof window === "undefined") {
return;
}

window.localStorage.removeItem(PENDING_ORG_DRAFT_STORAGE_KEY);
}

export function OrganizationScreen() {
const router = useRouter();
const { user, sessionHydrated, signOut } = useDenFlow();
const { user, sessionHydrated, signOut, billingSummary } = useDenFlow();
const [orgs, setOrgs] = useState<DenOrgSummary[]>([]);
const [busy, setBusy] = useState(true);
const [error, setError] = useState<string | null>(null);
Expand All @@ -20,6 +66,7 @@ export function OrganizationScreen() {
const [createName, setCreateName] = useState("");
const [createBusy, setCreateBusy] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [hasPendingOrgDraft, setHasPendingOrgDraft] = useState(false);

const userDisplayName = useMemo(() => {
const trimmedName = user?.name?.trim();
Expand All @@ -35,6 +82,8 @@ export function OrganizationScreen() {

const activeOrg = useMemo(() => orgs.find((org) => org.isActive) ?? null, [orgs]);
const showDirectCreateFlow = orgs.length === 0;
const returningToSavedDraft = showDirectCreateFlow && hasPendingOrgDraft;
const draftReadyAfterCheckout = returningToSavedDraft && Boolean(billingSummary?.hasActivePlan);

useEffect(() => {
if (!sessionHydrated) return;
Expand Down Expand Up @@ -74,6 +123,28 @@ export function OrganizationScreen() {
};
}, [sessionHydrated, user, router]);

useEffect(() => {
if (!user?.email) {
setHasPendingOrgDraft(false);
return;
}

if (!showDirectCreateFlow) {
clearPendingOrgDraft();
setHasPendingOrgDraft(false);
return;
}

const savedName = readPendingOrgDraft(user.email);
if (!savedName) {
setHasPendingOrgDraft(false);
return;
}

setCreateName((current) => current || savedName);
setHasPendingOrgDraft(true);
}, [showDirectCreateFlow, user?.email]);

async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const trimmed = createName.trim();
Expand All @@ -89,6 +160,8 @@ export function OrganizationScreen() {

if (!response.ok) {
if (response.status === 402) {
writePendingOrgDraft(trimmed, user?.email);
setHasPendingOrgDraft(true);
setCreateBusy(false);
router.push("/checkout");
return;
Expand All @@ -106,6 +179,8 @@ export function OrganizationScreen() {
throw new Error("Organization was created, but no slug was returned.");
}

clearPendingOrgDraft();
setHasPendingOrgDraft(false);
router.push(getOrgDashboardRoute(nextSlug));
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Failed to create organization.");
Expand Down Expand Up @@ -153,9 +228,15 @@ export function OrganizationScreen() {
</div>
<div className="min-w-0">
<p className="text-sm font-medium uppercase tracking-[0.18em] text-gray-400">OpenWork Cloud</p>
<h1 className="mt-2 text-3xl font-semibold tracking-[-0.03em] text-gray-950">Create your first organization.</h1>
<h1 className="mt-2 text-3xl font-semibold tracking-[-0.03em] text-gray-950">
{returningToSavedDraft ? "Finish creating your organization." : "Create your first organization."}
</h1>
<p className="mt-3 max-w-xl text-sm leading-6 text-gray-500">
Name the workspace you want to set up for your team. If billing is enabled, the plan step comes right after this.
{draftReadyAfterCheckout
? "Your plan is ready. Confirm the workspace name below and create the workspace to finish setup."
: returningToSavedDraft
? "We saved your workspace name so you can pick up right where you left off."
: "Name the workspace you want to set up for your team. If billing is enabled, the plan step comes right after this."}
</p>
</div>
</div>
Expand Down Expand Up @@ -188,7 +269,7 @@ export function OrganizationScreen() {
disabled={createBusy || !createName.trim()}
className="rounded-2xl bg-gray-900 px-5 py-3 text-sm font-medium text-white transition-colors hover:bg-gray-800 disabled:opacity-50"
>
{createBusy ? "Creating..." : "Continue"}
{createBusy ? "Creating..." : returningToSavedDraft ? "Create organization" : "Continue"}
</button>
<p className="text-sm text-gray-500">Signed in as {user?.email ?? "your account"}</p>
</div>
Expand Down
Loading