Skip to content

Commit 53ffb9f

Browse files
refactor(web): add authenticatedPage HOC, remove SINGLE_TENANT_ORG_ID from settings pages
Introduces `authenticatedPage` HOC in `middleware/authenticatedPage.tsx` for server component pages. It resolves the auth context (user, org, role, prisma) and optionally gates by role, replacing the manual org-lookup-and-role-check boilerplate in settings pages. Migrates all settings pages to use authenticatedPage, removing direct references to SINGLE_TENANT_ORG_ID from within the (app) route group. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d7ab2da commit 53ffb9f

File tree

10 files changed

+222
-178
lines changed

10 files changed

+222
-178
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# (app) Route Group
2+
3+
## Auth in pages
4+
5+
Use `authenticatedPage` from `@/middleware/authenticatedPage` for all pages in this directory. Do NOT use `SINGLE_TENANT_ORG_ID` or direct `prisma` imports from `@/prisma` to look up the org — use the `org` and `prisma` provided by the auth context instead.
6+
7+
```tsx
8+
import { authenticatedPage } from "@/middleware/authenticatedPage";
9+
10+
export default authenticatedPage(async ({ org, role, prisma }, props) => {
11+
// ...
12+
});
13+
```
14+
15+
Options:
16+
- `{ minRole: OrgRole.OWNER, redirectTo: '/settings' }` — gate by role
17+
- `{ allowAnonymous: true }` — allow unauthenticated access (user may be undefined)
18+
19+
## Layout
20+
21+
The `layout.tsx` in this directory handles authentication, org membership, onboarding, and SSO account linking. Pages do not need to re-check these. See `README.md` for the full guard pipeline.
22+
23+
## Adding new routes
24+
25+
New pages automatically inherit the layout's auth/membership guard. Use `authenticatedPage` if the page needs the auth context (org, user, role, prisma) or role-based gating.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# (app) Route Group
2+
3+
This is a Next.js [route group](https://nextjs.org/docs/app/building-your-application/routing/route-groups). The parenthesized folder name does not affect the URL structure. Routes here are served at the root (e.g., `/search`, `/chat`, `/settings`).
4+
5+
## Why this route group exists
6+
7+
Routes outside `(app)/` (like `/login`, `/signup`, `/invite`, `/onboard`) are accessible without authentication. Routes inside `(app)/` go through the layout's auth and membership guards before rendering.
8+
9+
## What the layout does
10+
11+
The `layout.tsx` acts as a gate and app shell. It runs the following checks in order, short-circuiting if any condition is met:
12+
13+
1. **Org existence** - Looks up the single-tenant org by `SINGLE_TENANT_ORG_ID`. Returns 404 if missing.
14+
2. **Authentication** - If the user is not logged in and anonymous access is not enabled, redirects to `/login` (or renders GCP IAP auth if configured).
15+
3. **Membership** - If the user is logged in but not a member of the org, renders one of:
16+
- `JoinOrganizationCard` if the org does not require approval
17+
- `SubmitJoinRequest` / `PendingApprovalCard` if the org requires approval
18+
4. **Onboarding** - If the org has not completed onboarding, wraps children in `OnboardGuard`.
19+
5. **SSO account linking** - If required SSO providers are not linked, renders `ConnectAccountsCard`.
20+
6. **Mobile splash screen** - Shows an unsupported screen on mobile devices (dismissible via cookie).
21+
22+
After all guards pass, the layout wraps children with shared UI: `SyntaxGuideProvider`, `PermissionSyncBanner`, `GitHubStarToast`, and `UpgradeToast`.
23+
24+
## What the layout does NOT do
25+
26+
- **Role-based access control** - The layout does not check `OWNER` vs `MEMBER`. Pages that require a specific role should use `authenticatedPage` with the `minRole` option.
27+
- **Guarantee a user exists** - When anonymous access is enabled, the user may be undefined.
28+
29+
## Writing pages in (app)
30+
31+
Use the `authenticatedPage` HOC from `@/middleware/authenticatedPage`. It resolves the auth context (`user`, `org`, `role`, `prisma`) and handles redirects on auth failure. This avoids manual org lookups with `SINGLE_TENANT_ORG_ID` — pages inside `(app)/` should not reference that constant directly.
32+
33+
```tsx
34+
import { authenticatedPage } from "@/middleware/authenticatedPage";
35+
36+
// Basic authenticated page
37+
export default authenticatedPage(async ({ prisma }) => {
38+
const data = await prisma.repo.findMany();
39+
return <MyPage data={data} />;
40+
});
41+
42+
// Owner-only page
43+
export default authenticatedPage(async ({ org }) => {
44+
return <AdminPage orgName={org.name} />;
45+
}, { minRole: OrgRole.OWNER, redirectTo: '/settings' });
46+
47+
// Page that allows anonymous access
48+
export default authenticatedPage(async ({ user, prisma }) => {
49+
// user may be undefined
50+
return <PublicPage />;
51+
}, { allowAnonymous: true });
52+
```
Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,8 @@
1-
import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
2-
import { prisma } from "@/prisma";
31
import { OrganizationAccessSettings } from "@/app/components/organizationAccessSettings";
4-
import { isServiceError } from "@/lib/utils";
5-
import { ServiceErrorException } from "@/lib/serviceError";
6-
import { getMe } from "@/actions";
2+
import { authenticatedPage } from "@/middleware/authenticatedPage";
73
import { OrgRole } from "@sourcebot/db";
8-
import { redirect } from "next/navigation";
9-
10-
export default async function AccessPage() {
11-
const org = await prisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } });
12-
if (!org) {
13-
throw new Error("Organization not found");
14-
}
15-
16-
const me = await getMe();
17-
if (isServiceError(me)) {
18-
throw new ServiceErrorException(me);
19-
}
20-
21-
const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role;
22-
if (!userRoleInOrg) {
23-
throw new Error("User role not found");
24-
}
25-
26-
if (userRoleInOrg !== OrgRole.OWNER) {
27-
redirect('/settings');
28-
}
294

5+
export default authenticatedPage(async () => {
306
return (
317
<div className="flex flex-col gap-6">
328
<div>
@@ -46,4 +22,7 @@ export default async function AccessPage() {
4622
<OrganizationAccessSettings />
4723
</div>
4824
)
49-
}
25+
}, {
26+
minRole: OrgRole.OWNER,
27+
redirectTo: '/settings',
28+
});
Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,15 @@
1-
import { getMe } from "@/actions";
2-
import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
3-
import { prisma } from "@/prisma";
41
import { AnalyticsContent } from "@/ee/features/analytics/analyticsContent";
52
import { AnalyticsEntitlementMessage } from "@/ee/features/analytics/analyticsEntitlementMessage";
6-
import { ServiceErrorException } from "@/lib/serviceError";
7-
import { isServiceError } from "@/lib/utils";
3+
import { authenticatedPage } from "@/middleware/authenticatedPage";
84
import { OrgRole } from "@sourcebot/db";
95
import { hasEntitlement } from "@sourcebot/shared";
10-
import { redirect } from "next/navigation";
116

12-
export default async function AnalyticsPage() {
13-
const org = await prisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } });
14-
if (!org) {
15-
throw new Error("Organization not found");
16-
}
17-
18-
const me = await getMe();
19-
if (isServiceError(me)) {
20-
throw new ServiceErrorException(me);
21-
}
7+
export default authenticatedPage(async () => {
8+
const hasAnalyticsEntitlement = hasEntitlement("analytics");
229

23-
const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role;
24-
if (!userRoleInOrg) {
25-
throw new Error("User role not found");
10+
if (!hasAnalyticsEntitlement) {
11+
return <AnalyticsEntitlementMessage />;
2612
}
2713

28-
if (userRoleInOrg !== OrgRole.OWNER) {
29-
redirect('/settings');
30-
}
31-
32-
const hasAnalyticsEntitlement = hasEntitlement("analytics");
33-
34-
if (!hasAnalyticsEntitlement) {
35-
return <AnalyticsEntitlementMessage />;
36-
}
37-
38-
return <AnalyticsContent />;
39-
}
14+
return <AnalyticsContent />;
15+
}, { minRole: OrgRole.OWNER, redirectTo: '/settings' });
Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,12 @@
1-
import { getMe } from "@/actions";
2-
import { ServiceErrorException } from "@/lib/serviceError";
31
import { notFound } from "next/navigation";
4-
import { isServiceError } from "@/lib/utils";
52
import { OrgRole } from "@sourcebot/db";
6-
import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
7-
import { prisma } from "@/prisma";
83
import { env } from "@sourcebot/shared";
4+
import { authenticatedPage } from "@/middleware/authenticatedPage";
95

10-
export default async function ApiKeysLayout({ children }: { children: React.ReactNode }) {
11-
const org = await prisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } });
12-
if (!org) {
13-
throw new Error("Organization not found");
14-
}
15-
16-
const me = await getMe();
17-
if (isServiceError(me)) {
18-
throw new ServiceErrorException(me);
19-
}
20-
21-
const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role;
22-
if (!userRoleInOrg) {
23-
throw new Error("User role not found");
24-
}
25-
26-
if (env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'true' && userRoleInOrg !== OrgRole.OWNER) {
6+
export default authenticatedPage<{ children: React.ReactNode }>(async ({ role }, { children }) => {
7+
if (env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'true' && role !== OrgRole.OWNER) {
278
return notFound();
289
}
2910

30-
return children;
31-
}
11+
return <>{children}</>;
12+
});
Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,13 @@
1-
import { getMe } from "@/actions";
2-
import { isServiceError } from "@/lib/utils";
31
import { env } from "@sourcebot/shared";
42
import { OrgRole } from "@sourcebot/db";
5-
import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
6-
import { prisma } from "@/prisma";
73
import { ApiKeysPage } from "./apiKeysPage";
4+
import { authenticatedPage } from "@/middleware/authenticatedPage";
85

9-
export default async function Page() {
6+
export default authenticatedPage(async ({ role }) => {
107
let canCreateApiKey = true;
118
if (env.DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS === 'true') {
12-
const [org, me] = await Promise.all([prisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } }), getMe()]);
13-
if (org && !isServiceError(me)) {
14-
const role = me.memberships.find((m) => m.id === org.id)?.role;
15-
canCreateApiKey = role === OrgRole.OWNER;
16-
}
9+
canCreateApiKey = role === OrgRole.OWNER;
1710
}
1811

1912
return <ApiKeysPage canCreateApiKey={canCreateApiKey} />;
20-
}
13+
});
Lines changed: 4 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,6 @@
1-
import { getMe } from "@/actions";
2-
import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
3-
import { prisma } from "@/prisma";
4-
import { ServiceErrorException } from "@/lib/serviceError";
5-
import { notFound } from "next/navigation";
6-
import { isServiceError } from "@/lib/utils";
1+
import { authenticatedPage } from "@/middleware/authenticatedPage";
72
import { OrgRole } from "@sourcebot/db";
83

9-
10-
interface ConnectionsLayoutProps {
11-
children: React.ReactNode;
12-
}
13-
14-
export default async function ConnectionsLayout({ children }: ConnectionsLayoutProps) {
15-
const org = await prisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } });
16-
if (!org) {
17-
throw new Error("Organization not found");
18-
}
19-
20-
const me = await getMe();
21-
if (isServiceError(me)) {
22-
throw new ServiceErrorException(me);
23-
}
24-
25-
const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role;
26-
if (!userRoleInOrg) {
27-
throw new Error("User role not found");
28-
}
29-
30-
if (userRoleInOrg !== OrgRole.OWNER) {
31-
return notFound();
32-
}
33-
34-
return children;
35-
}
4+
export default authenticatedPage<{ children: React.ReactNode }>(async (_auth, { children }) => {
5+
return <>{children}</>;
6+
}, { minRole: OrgRole.OWNER, redirectTo: '/settings' });

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

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,13 @@
11
import { getLicenseKey, getEntitlements, getPlan, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
22
import { Button } from "@/components/ui/button";
33
import { Info, Mail } from "lucide-react";
4-
import { getMe, getOrgMembers } from "@/actions";
4+
import { getOrgMembers } from "@/actions";
55
import { isServiceError } from "@/lib/utils";
66
import { ServiceErrorException } from "@/lib/serviceError";
7-
import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
8-
import { prisma } from "@/prisma";
7+
import { authenticatedPage } from "@/middleware/authenticatedPage";
98
import { OrgRole } from "@sourcebot/db";
10-
import { redirect } from "next/navigation";
11-
12-
export default async function LicensePage() {
13-
const org = await prisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } });
14-
if (!org) {
15-
throw new Error("Organization not found");
16-
}
17-
18-
const me = await getMe();
19-
if (isServiceError(me)) {
20-
throw new ServiceErrorException(me);
21-
}
22-
23-
const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role;
24-
if (!userRoleInOrg) {
25-
throw new Error("User role not found");
26-
}
27-
28-
if (userRoleInOrg !== OrgRole.OWNER) {
29-
redirect('/settings');
30-
}
319

10+
export default authenticatedPage(async () => {
3211
const licenseKey = getLicenseKey();
3312
const entitlements = getEntitlements();
3413
const plan = getPlan();
@@ -136,4 +115,4 @@ export default async function LicensePage() {
136115
</div>
137116
</div>
138117
)
139-
}
118+
}, { minRole: OrgRole.OWNER, redirectTo: '/settings' });

0 commit comments

Comments
 (0)