Skip to content

Commit 26df52a

Browse files
feedback
1 parent 5e365cb commit 26df52a

19 files changed

Lines changed: 249 additions & 139 deletions

File tree

CLAUDE.md

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ When implementing a new API route, ask the user whether it should be part of the
9696
3. Add the endpoint to the relevant group in the `API Reference` tab of `docs/docs.json`.
9797
4. Regenerate the OpenAPI spec by running `yarn workspace @sourcebot/web openapi:generate`.
9898

99-
Route handlers should validate inputs using Zod schemas.
99+
Route handlers should validate inputs using Zod schemas. Put coercion, defaults, minimums, maximums, and cross-field validation in the schema instead of scattering parsing logic through the handler.
100100

101101
**Query parameters** (GET requests):
102102

@@ -105,8 +105,9 @@ import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/se
105105
import { z } from "zod";
106106

107107
const myQueryParamsSchema = z.object({
108-
q: z.string().default(''),
108+
repo: z.string(),
109109
page: z.coerce.number().int().positive().default(1),
110+
perPage: z.coerce.number().int().positive().max(100).default(50),
110111
});
111112

112113
export const GET = apiHandler(async (request: NextRequest) => {
@@ -124,11 +125,42 @@ export const GET = apiHandler(async (request: NextRequest) => {
124125
);
125126
}
126127

127-
const { q, page } = parsed.data;
128+
const { page, perPage, ...searchParams } = parsed.data;
129+
const skip = (page - 1) * perPage;
128130
// ... rest of handler
129131
});
130132
```
131133

134+
**Search query parameters**:
135+
136+
```ts
137+
import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
138+
import { z } from "zod";
139+
140+
const searchMembersQueryParamsSchema = z.object({
141+
query: z.string().default(''),
142+
});
143+
144+
export const GET = apiHandler(async (request: NextRequest) => {
145+
const rawParams = Object.fromEntries(
146+
Object.keys(searchMembersQueryParamsSchema.shape).map(key => [
147+
key,
148+
request.nextUrl.searchParams.get(key) ?? undefined
149+
])
150+
);
151+
const parsed = searchMembersQueryParamsSchema.safeParse(rawParams);
152+
153+
if (!parsed.success) {
154+
return serviceErrorResponse(
155+
queryParamsSchemaValidationError(parsed.error)
156+
);
157+
}
158+
159+
const { query } = parsed.data;
160+
// ... use query in the database/search call
161+
});
162+
```
163+
132164
**Request body** (POST/PUT/PATCH requests):
133165

134166
```ts
@@ -155,6 +187,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
155187
});
156188
```
157189

190+
Use `safeParseAsync` when the schema has async refinements, as in search routes:
191+
192+
```ts
193+
const body = await request.json();
194+
const parsed = await searchRequestSchema.safeParseAsync(body);
195+
```
196+
158197
## Data Fetching
159198

160199
For GET requests, prefer using API routes with react-query over server actions. This provides caching benefits and better control over data refetching.

docs/docs/configuration/auth/scim.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ When a pending user signs in, Sourcebot moves them to **Active** and they count
7272

7373
When your identity provider deactivates a user by sending `active: false`, Sourcebot marks the user as **Suspended**. Suspended users cannot access the organization, and Sourcebot revokes their active sessions, API keys, and OAuth tokens.
7474

75-
If your identity provider reactivates the user by sending `active: true`, Sourcebot restores their membership. Users who had already become active return to active access; users who had never signed in return to pending.
75+
If your identity provider reactivates the user by sending `active: true`, Sourcebot restores their membership as **Pending**. They become **Active** and billable again only after they sign in and access the organization.
7676

7777

7878
## Roles
@@ -106,6 +106,6 @@ Additional attributes may be sent by your identity provider, but Sourcebot ignor
106106
Sourcebot supports SCIM 2.0.
107107
</Accordion>
108108
<Accordion title="When do SCIM-created users become billable seats?">
109-
SCIM-created users become billable seats after they sign in and access the organization for the first time. Until then, they appear as pending and do not count toward billing. Suspended users also do not count toward billing.
109+
SCIM-created or reactivated users become billable seats after they sign in and access the organization. Until then, they appear as pending and do not count toward billing. Suspended users also do not count toward billing.
110110
</Accordion>
111111
</AccordionGroup>

packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ CREATE UNIQUE INDEX "ScimToken_hash_key" ON "ScimToken"("hash");
2323
CREATE INDEX "ScimToken_orgId_idx" ON "ScimToken"("orgId");
2424

2525
-- CreateIndex
26-
CREATE INDEX "UserToOrg_orgId_scimExternalId_idx" ON "UserToOrg"("orgId", "scimExternalId");
26+
CREATE UNIQUE INDEX "UserToOrg_orgId_scimExternalId_key" ON "UserToOrg"("orgId", "scimExternalId");
2727

2828
-- AddForeignKey
2929
ALTER TABLE "ScimToken" ADD CONSTRAINT "ScimToken_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;

packages/db/prisma/schema.prisma

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ model UserToOrg {
416416
lastActiveAt DateTime?
417417
418418
@@id([orgId, userId])
419-
@@index([orgId, scimExternalId])
419+
@@unique([orgId, scimExternalId])
420420
}
421421

422422
model ApiKey {

packages/web/src/app/(app)/chats/chatsPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ function getColumns(context: ColumnsContext): ColumnDef<Chat>[] {
102102
},
103103
{
104104
id: "actions",
105-
meta: { width: "50px" },
105+
meta: { className: "w-[50px]" },
106106
cell: ({ row }) => <ChatRowActions chat={row.original} />,
107107
},
108108
];

packages/web/src/app/(app)/settings/apiKeys/layout.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { OrgRole } from "@sourcebot/db";
33
import { env } from "@sourcebot/shared";
44
import { authenticatedPage } from "@/middleware/authenticatedPage";
55
import { SettingsContainer } from "../components/settingsContainer";
6+
import { ReactNode } from "react";
67

7-
export default authenticatedPage<{ children: React.ReactNode }>(async ({ role }, { children }) => {
8+
export default authenticatedPage<{ children: ReactNode }>(async ({ role }, { children }) => {
89
if (env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'true' && role !== OrgRole.OWNER) {
910
return notFound();
1011
}

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,8 +320,11 @@ const getColumns = (actionContext: Omit<MembersTableActionsProps, "row">): Colum
320320
header: ({ column }) => <SortableHeader column={column}>Joined</SortableHeader>,
321321
cell: ({ row }) => {
322322
const r = row.original;
323-
const date = r.kind === "member" ? r.joinedAt : r.createdAt;
324-
return <DisplayDate date={date} />;
323+
if (r.kind !== "member") {
324+
return <span className="text-sm text-muted-foreground"></span>;
325+
}
326+
327+
return <DisplayDate date={r.joinedAt} />;
325328
},
326329
},
327330
{
@@ -332,7 +335,7 @@ const getColumns = (actionContext: Omit<MembersTableActionsProps, "row">): Colum
332335
header: ({ column }) => <SortableHeader column={column}>Last seen</SortableHeader>,
333336
cell: ({ row }) => {
334337
const r = row.original;
335-
if (r.kind !== "member") {
338+
if (r.kind !== "member" || r.suspendedAt !== null) {
336339
return <span className="text-sm text-muted-foreground"></span>;
337340
}
338341
if (!r.lastActiveAt) {

packages/web/src/app/(app)/settings/security/components/scimProvisioningSettings.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,18 @@ export function ScimProvisioningSettings({ baseUrl, tokens }: ScimProvisioningSe
101101
};
102102

103103
const handleRevokeToken = async (name: string) => {
104-
const result = await revokeScimToken(name);
105-
if (isServiceError(result)) {
106-
toast({ title: "Error", description: `Failed to revoke SCIM token: ${result.message}`, variant: "destructive" });
107-
return;
104+
try {
105+
const result = await revokeScimToken(name);
106+
if (isServiceError(result)) {
107+
toast({ title: "Error", description: `Failed to revoke SCIM token: ${result.message}`, variant: "destructive" });
108+
return;
109+
}
110+
router.refresh();
111+
toast({ description: "SCIM token revoked" });
112+
} catch (error) {
113+
console.error(error);
114+
toast({ title: "Error", description: `Failed to revoke SCIM token: ${error}`, variant: "destructive" });
108115
}
109-
router.refresh();
110-
toast({ description: "SCIM token revoked" });
111116
};
112117

113118
const sortedTokens = useMemo(

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { SettingsCardGroup } from "../components/settingsCard";
2020
import { Alert, AlertDescription } from "@/components/ui/alert";
2121
import { Info } from "lucide-react";
2222
import { isScimEnabled } from "@/features/scim/utils";
23+
import { ServiceErrorException } from "@/lib/serviceError";
2324

2425
export default authenticatedPage(async ({ org }) => {
2526
const anonymousAccessEnabled = await isAnonymousAccessEnabled();
@@ -29,8 +30,11 @@ export default authenticatedPage(async ({ org }) => {
2930

3031
const hasScimEntitlement = await hasEntitlement("scim");
3132
const scimBaseUrl = `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2`;
32-
const scimTokensResult = hasScimEntitlement ? await getScimTokens() : [];
33-
const scimTokens = isServiceError(scimTokensResult) ? [] : scimTokensResult;
33+
const scimTokens = hasScimEntitlement ? await getScimTokens() : [];
34+
if (isServiceError(scimTokens)) {
35+
throw new ServiceErrorException(scimTokens);
36+
}
37+
3438
const scimEnabled = await isScimEnabled(org)
3539

3640

packages/web/src/app/api/(server)/ee/scim/v2/Users/[id]/route.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ const applyActive = async (orgId: number, userId: string, current: boolean, next
3333
return null;
3434
};
3535

36+
const ensureEmailAvailable = async (prisma: ScimAuthContext['prisma'], userId: string, email: string): Promise<Response | null> => {
37+
const existing = await prisma.user.findUnique({
38+
where: { email },
39+
select: { id: true },
40+
});
41+
if (existing && existing.id !== userId) {
42+
return scimError(409, 'User email is already in use', 'uniqueness');
43+
}
44+
return null;
45+
};
46+
3647
// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth
3748
export const GET = apiHandler(async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) =>
3849
withScimAuth(request, async ({ org, prisma }) => {
@@ -61,16 +72,21 @@ export const PUT = apiHandler(async (request: NextRequest, { params }: { params:
6172

6273
const name = payload.name?.formatted ?? payload.displayName ?? undefined;
6374
const email = resolveEmail(payload);
64-
await prisma.user.update({
65-
where: { id },
66-
data: { name, email },
67-
});
75+
const emailError = await ensureEmailAvailable(prisma, id, email);
76+
if (emailError) {
77+
return emailError;
78+
}
6879

6980
const activeError = await applyActive(org.id, id, membership.suspendedAt == null, coerceActive(payload.active));
7081
if (activeError) {
7182
return activeError;
7283
}
7384

85+
await prisma.user.update({
86+
where: { id },
87+
data: { name, email },
88+
});
89+
7490
const refreshed = await loadMembership(prisma, org.id, id);
7591
return scimJson(toScimUser(refreshed!), 200);
7692
}));
@@ -95,6 +111,18 @@ export const PATCH = apiHandler(async (request: NextRequest, { params }: { param
95111
// ignored rather than rejected, per the SCIM lenient-parsing convention.
96112
const changes = parseScimPatchOperations(parsed.data.Operations);
97113

114+
if (changes.email !== undefined) {
115+
const emailError = await ensureEmailAvailable(prisma, id, changes.email);
116+
if (emailError) {
117+
return emailError;
118+
}
119+
}
120+
121+
const activeError = await applyActive(org.id, id, membership.suspendedAt == null, changes.active);
122+
if (activeError) {
123+
return activeError;
124+
}
125+
98126
if (changes.name !== undefined || changes.email !== undefined) {
99127
await prisma.user.update({
100128
where: { id },
@@ -105,11 +133,6 @@ export const PATCH = apiHandler(async (request: NextRequest, { params }: { param
105133
});
106134
}
107135

108-
const activeError = await applyActive(org.id, id, membership.suspendedAt == null, changes.active);
109-
if (activeError) {
110-
return activeError;
111-
}
112-
113136
const refreshed = await loadMembership(prisma, org.id, id);
114137
return scimJson(toScimUser(refreshed!), 200);
115138
}));

0 commit comments

Comments
 (0)