diff --git a/CHANGELOG.md b/CHANGELOG.md index fa46b346e..e5066dbad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added the ability to configure email code and credentials login from the security settings. [#1303](https://github.com/sourcebot-dev/sourcebot/pull/1303) - Added a list of configured SSO providers from the security settings. [#1303](https://github.com/sourcebot-dev/sourcebot/pull/1303) +- [EE] Added a SCIM 2.0 server for automated user provisioning and deprovisioning from identity providers (Okta, Entra). [#1306](https://github.com/sourcebot-dev/sourcebot/pull/1306) ### Fixed - Validated that `SOURCEBOT_ENCRYPTION_KEY` is exactly 32 characters at startup, failing fast with an actionable message instead of a runtime encryption error. [#1305](https://github.com/sourcebot-dev/sourcebot/pull/1305) diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index 126baf4c3..3fe693a3e 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -1148,6 +1148,11 @@ "MEMBER" ] }, + "suspendedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, "createdAt": { "type": "string", "format": "date-time" @@ -1163,6 +1168,7 @@ "name", "email", "role", + "suspendedAt", "createdAt", "lastActivityAt" ] diff --git a/packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql b/packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql new file mode 100644 index 000000000..22838d192 --- /dev/null +++ b/packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql @@ -0,0 +1,29 @@ +-- AlterTable +ALTER TABLE "Org" ADD COLUMN "isScimEnabled" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "UserToOrg" ADD COLUMN "suspendedAt" TIMESTAMP(3), +ADD COLUMN "scimExternalId" TEXT; + +-- CreateTable +CREATE TABLE "ScimToken" ( + "name" TEXT NOT NULL, + "hash" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUsedAt" TIMESTAMP(3), + "orgId" INTEGER NOT NULL, + + CONSTRAINT "ScimToken_pkey" PRIMARY KEY ("hash") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ScimToken_hash_key" ON "ScimToken"("hash"); + +-- CreateIndex +CREATE INDEX "ScimToken_orgId_idx" ON "ScimToken"("orgId"); + +-- CreateIndex +CREATE INDEX "UserToOrg_orgId_scimExternalId_idx" ON "UserToOrg"("orgId", "scimExternalId"); + +-- AddForeignKey +ALTER TABLE "ScimToken" ADD CONSTRAINT "ScimToken_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260624194710_add_lastactiveat_to_usertoorg/migration.sql b/packages/db/prisma/migrations/20260624194710_add_lastactiveat_to_usertoorg/migration.sql new file mode 100644 index 000000000..ccb3d03e9 --- /dev/null +++ b/packages/db/prisma/migrations/20260624194710_add_lastactiveat_to_usertoorg/migration.sql @@ -0,0 +1,15 @@ +-- AlterTable +ALTER TABLE "UserToOrg" ADD COLUMN "lastActiveAt" TIMESTAMP(3); + +-- Backfill per-membership activity from the global User.lastActiveAt. In a +-- single-tenant deployment a user belongs to exactly one org, so the global +-- timestamp is exactly the per-org timestamp. In multi-tenant deployments this +-- seeds every membership with the user's global last-active time as the best +-- available signal; the per-org value diverges naturally from the next +-- authenticated action onward. Without this, every existing membership would +-- read as "never active" (NULL) until each member's next request. +UPDATE "UserToOrg" AS uto +SET "lastActiveAt" = u."lastActiveAt" +FROM "User" AS u +WHERE uto."userId" = u."id" + AND u."lastActiveAt" IS NOT NULL; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 54444bbe2..4479b2f40 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -272,9 +272,12 @@ model Org { connections Connection[] repos Repo[] apiKeys ApiKey[] + scimTokens ScimToken[] isOnboarded Boolean @default(false) imageUrl String? + isScimEnabled Boolean @default(false) + /// @deprecated This property can be controlled by the environment /// variable `REQUIRE_APPROVAL_NEW_MEMBERS`. To ensure that we use /// the correct setting, use the helper function `isMemberApprovalRequired` @@ -397,7 +400,23 @@ model UserToOrg { role OrgRole @default(MEMBER) + /// When set, the membership is suspended and the user is treated as a + /// non-member for auth purposes (see `getAuthContext`). + suspendedAt DateTime? + + /// The IdP-supplied `externalId` for this membership when provisioned via + /// SCIM. Null for members that joined through invites or self-serve sign-up. + scimExternalId String? + + /// Last time the user performed an authenticated action *in this org*. Unlike + /// `User.lastActiveAt` (which is global to the instance), this is scoped to + /// the membership, so it distinguishes a member who has been active in this + /// org from one who was provisioned but never signed in here. Null means the + /// member has never been active in this org. + lastActiveAt DateTime? + @@id([orgId, userId]) + @@index([orgId, scimExternalId]) } model ApiKey { @@ -414,6 +433,23 @@ model ApiKey { createdById String } +/// Org-scoped bearer token presented by an IdP (Okta, Entra) to authenticate +/// against the SCIM provisioning endpoints. Unlike `ApiKey`, a SCIM token is +/// not tied to a user — it acts on behalf of the SCIM integration for the +/// whole org. Only the HMAC hash of the secret is stored. +model ScimToken { + name String + hash String @id @unique + + createdAt DateTime @default(now()) + lastUsedAt DateTime? + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + @@index([orgId]) +} + model Audit { id String @id @default(cuid()) timestamp DateTime @default(now()) diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 5bb33d146..c299ef1cc 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -11,6 +11,7 @@ export const LEGACY_API_KEY_PREFIX = 'sourcebot-'; export const API_KEY_PREFIX = 'sbk_'; export const OAUTH_ACCESS_TOKEN_PREFIX = 'sboa_'; export const OAUTH_REFRESH_TOKEN_PREFIX = 'sbor_'; +export const SCIM_TOKEN_PREFIX = 'sbscim_'; /** * Default settings. diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts index fbb4be79b..c5b8842be 100644 --- a/packages/shared/src/crypto.ts +++ b/packages/shared/src/crypto.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { env } from './env.server.js'; import { Token } from '@sourcebot/schemas/v3/shared.type'; import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; -import { API_KEY_PREFIX, OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_PREFIX } from './constants.js'; +import { API_KEY_PREFIX, OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_PREFIX, SCIM_TOKEN_PREFIX } from './constants.js'; const algorithm = 'aes-256-cbc'; const ivLength = 16; // 16 bytes for CBC @@ -56,6 +56,16 @@ export function generateApiKey(): { key: string; hash: string } { }; } +export function generateScimToken(): { token: string; hash: string } { + const secret = crypto.randomBytes(32).toString('hex'); + const hash = hashSecret(secret); + + return { + token: `${SCIM_TOKEN_PREFIX}${secret}`, + hash, + }; +} + export function generateOAuthToken(): { token: string; hash: string } { const secret = crypto.randomBytes(32).toString('hex'); const hash = hashSecret(secret); diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index c03b2f56e..85982c147 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -42,7 +42,8 @@ const ALL_ENTITLEMENTS = [ "org-management", "oauth", "ask", - "mcp" + "mcp", + "scim" ] as const; export type Entitlement = (typeof ALL_ENTITLEMENTS)[number]; diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index 6a09b9340..bbe52f31e 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -58,6 +58,7 @@ export { decrypt, hashSecret, generateApiKey, + generateScimToken, generateOAuthToken, generateOAuthRefreshToken, verifySignature, diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs index 48e01b40c..97d000fb8 100644 --- a/packages/web/next.config.mjs +++ b/packages/web/next.config.mjs @@ -59,6 +59,13 @@ const nextConfig = { { source: "/api/mcp", destination: "/api/ee/mcp", + }, + // The SCIM 2.0 server lives under /api/ee/scim/v2 (EE-licensed route + // tree) but is exposed at the clean /scim/v2 path that IdPs (Okta, + // Entra) are configured to send provisioning requests to. + { + source: "/scim/v2/:path*", + destination: "/api/ee/scim/v2/:path*", } ]; }, diff --git a/packages/web/src/__mocks__/prisma.ts b/packages/web/src/__mocks__/prisma.ts index 5e5c28682..556d896fc 100644 --- a/packages/web/src/__mocks__/prisma.ts +++ b/packages/web/src/__mocks__/prisma.ts @@ -17,7 +17,7 @@ export const MOCK_ORG: Org = { updatedAt: new Date(), isOnboarded: true, imageUrl: null, - metadata: null, + isScimEnabled: false, memberApprovalRequired: false, isCredentialsLoginEnabled: true, isEmailCodeLoginEnabled: false, diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index efd996c1b..c64979173 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1,26 +1,20 @@ 'use server'; import { createAudit } from "@/ee/features/audit/audit"; -import { env, getSMTPConnectionURL } from "@sourcebot/shared"; import { ErrorCode } from "@/lib/errorCodes"; -import { notAuthenticated, notFound, ServiceError } from "@/lib/serviceError"; -import { __unsafePrisma } from "@/prisma"; -import { render } from "@react-email/components"; -import { generateApiKey, getTokenFromConfig } from "@sourcebot/shared"; +import { notFound, ServiceError } from "@/lib/serviceError"; +import { sew } from "@/middleware/sew"; import { ConnectionSyncJobStatus, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db"; -import { createLogger } from "@sourcebot/shared"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; +import { createLogger, env, generateApiKey, getTokenFromConfig } from "@sourcebot/shared"; import { StatusCodes } from "http-status-codes"; import { cookies } from "next/headers"; -import { createTransport } from "nodemailer"; -import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; -import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_ID } from "./lib/constants"; -import { RepositoryQuery } from "./lib/types"; -import { getAuthenticatedUser, withAuth, withOptionalAuth } from "./middleware/withAuth"; import { getBrowsePath } from "./app/(app)/browse/hooks/utils"; -import { sew } from "@/middleware/sew"; +import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "./lib/constants"; +import { RepositoryQuery } from "./lib/types"; +import { withAuth, withOptionalAuth } from "./middleware/withAuth"; const logger = createLogger('web-actions'); @@ -375,110 +369,6 @@ export const getRepoInfoByName = async (repoName: string) => sew(() => } })); -// eslint-disable-next-line authz/require-auth-wrapper -- calls getAuthenticatedUser() directly; runs pre-org-membership so cannot use withAuth -export const createAccountRequest = async () => sew(async () => { - const authResult = await getAuthenticatedUser(); - if (!authResult) { - return notAuthenticated(); - } - - const { user } = authResult; - - const org = await __unsafePrisma.org.findUnique({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - }); - - if (!org) { - return notFound("Organization not found"); - } - - const existingRequest = await __unsafePrisma.accountRequest.findUnique({ - where: { - requestedById_orgId: { - requestedById: user.id, - orgId: org.id, - }, - }, - }); - - if (existingRequest) { - logger.warn(`User ${user.id} already has an account request for org ${org.id}. Skipping account request creation.`); - return { - success: true, - existingRequest: true, - } - } - - if (!existingRequest) { - await __unsafePrisma.accountRequest.create({ - data: { - requestedById: user.id, - orgId: org.id, - }, - }); - - const smtpConnectionUrl = getSMTPConnectionURL(); - if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) { - // TODO: This is needed because we can't fetch the origin from the request headers when this is called - // on user creation (the header isn't set when next-auth calls onCreateUser for some reason) - const deploymentUrl = env.AUTH_URL; - - const owners = await __unsafePrisma.user.findMany({ - where: { - orgs: { - some: { - orgId: org.id, - role: "OWNER", - }, - }, - }, - }); - - if (owners.length === 0) { - logger.error(`Failed to find any owners for org ${org.id} when drafting email for account request from ${user.id}`); - } else { - const html = await render(JoinRequestSubmittedEmail({ - baseUrl: deploymentUrl, - requestor: { - name: user.name ?? undefined, - email: user.email, - avatarUrl: user.image ?? undefined, - }, - orgName: org.name, - orgImageUrl: org.imageUrl ?? undefined, - })); - - const ownerEmails = owners - .map((owner) => owner.email) - .filter((email): email is string => email !== null); - - const transport = createTransport(smtpConnectionUrl); - const result = await transport.sendMail({ - to: ownerEmails, - from: env.EMAIL_FROM_ADDRESS, - subject: `New account request for ${org.name} on Sourcebot`, - html, - text: `New account request for ${org.name} on Sourcebot by ${user.name ?? user.email}`, - }); - - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length > 0) { - logger.error(`Failed to send account request email to ${ownerEmails.join(', ')}: ${failed}`); - } - } - } else { - logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping account request email to owner`); - } - } - - return { - success: true, - existingRequest: false, - } -}); - export const getSearchContexts = async () => sew(() => withOptionalAuth(async ({ org, prisma }) => { const searchContexts = await prisma.searchContext.findMany({ diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx index 39edf49df..04ff3caf6 100644 --- a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx @@ -3,7 +3,7 @@ import { auth } from "@/auth"; import { HOME_VIEW_COOKIE_NAME } from "@/lib/constants"; import { HomeView } from "@/hooks/useHomeView"; import { getConnectionStats } from "@/actions"; -import { getOrgAccountRequests } from "@/features/userManagement/actions"; +import { getOrgAccountRequests } from "@/features/membership/actions"; import { isServiceError } from "@/lib/utils"; import { ServiceErrorException } from "@/lib/serviceError"; import { OrgRole } from "@prisma/client"; diff --git a/packages/web/src/app/(app)/components/submitAccountRequestButton.tsx b/packages/web/src/app/(app)/components/submitAccountRequestButton.tsx deleted file mode 100644 index 85398a7db..000000000 --- a/packages/web/src/app/(app)/components/submitAccountRequestButton.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client" - -import { Button } from "@/components/ui/button" -import { Clock } from "lucide-react" -import { useState } from "react" -import { useToast } from "@/components/hooks/use-toast" -import { createAccountRequest } from "@/actions" -import { isServiceError } from "@/lib/utils" -import { useRouter } from "next/navigation" - - -export function SubmitAccountRequestButton() { - const { toast } = useToast() - const router = useRouter() - const [isSubmitting, setIsSubmitting] = useState(false) - - const handleSubmit = async () => { - setIsSubmitting(true) - const result = await createAccountRequest() - if (!isServiceError(result)) { - if (result.existingRequest) { - toast({ - title: "Request Already Submitted", - description: "Your request to join the organization has already been submitted. Please wait for it to be approved.", - variant: "default", - }) - } else { - toast({ - title: "Request Submitted", - description: "Your request to join the organization has been submitted.", - variant: "default", - }) - } - // Refresh the page to trigger layout re-render and show PendingApprovalCard - router.refresh() - } else { - toast({ - title: "Failed to Submit", - description: `There was an error submitting your request. Reason: ${result.message}`, - variant: "destructive", - }) - } - setIsSubmitting(false) - } - - return ( -
{ - e.preventDefault(); - handleSubmit(); - }}> - -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/(app)/components/submitJoinRequest.tsx b/packages/web/src/app/(app)/components/submitJoinRequest.tsx deleted file mode 100644 index fdfdc0a20..000000000 --- a/packages/web/src/app/(app)/components/submitJoinRequest.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch" -import { SourcebotLogo } from "@/app/components/sourcebotLogo" -import { SubmitAccountRequestButton } from "./submitAccountRequestButton" - -export const SubmitJoinRequest = async () => { - return ( -
- - -
-
- - -
-
- - - -
- -
-

- Request Access -

-

- Submit a request to join this organization -

-
-
- -
-
- -
-
-
-
-
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx index ec355a56d..5120f6bcc 100644 --- a/packages/web/src/app/(app)/layout.tsx +++ b/packages/web/src/app/(app)/layout.tsx @@ -10,17 +10,20 @@ import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, OPTIONAL_PROVID import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide"; import { SyntaxGuideProvider } from "./components/syntaxGuideProvider"; import { notFound, redirect } from "next/navigation"; -import { PendingApprovalCard } from "./components/pendingApproval"; -import { SubmitJoinRequest } from "./components/submitJoinRequest"; +import { PendingApprovalCard } from "../../features/membership/components/pendingApprovalCard"; +import { SubmitJoinRequestCard } from "../../features/membership/components/submitJoinRequestCard"; +import { NotProvisionedCard } from "@/features/membership/components/notProvisionedCard"; +import { isScimEnabled } from "@/features/scim/utils"; import { env, getOfflineLicenseMetadata, SOURCEBOT_VERSION, isMemberApprovalRequired } from "@sourcebot/shared"; import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements"; import { GcpIapAuth } from "./components/gcpIapAuth"; -import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard"; +import { JoinOrganizationCard } from "@/features/membership/components/joinOrganizationCard"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; import { GitHubStarToast } from "./components/githubStarToast"; import { getLinkedAccounts } from "@/ee/features/sso/actions"; import { BannerSlot } from "./components/banners/bannerSlot"; import { BannerHeightObserver } from "./components/banners/bannerHeightObserver"; +import { activeOrPendingMembershipWhere } from "@/features/membership/utils"; import { getPermissionSyncStatus } from "../api/(server)/ee/permissionSyncStatus/api"; import { OrgRole } from "@sourcebot/db"; import { ServiceErrorException } from "@/lib/serviceError"; @@ -63,8 +66,9 @@ export default async function Layout(props: LayoutProps) { where: { orgId_userId: { orgId: org.id, - userId: session.user.id - } + userId: session.user.id, + }, + ...activeOrPendingMembershipWhere(), }, include: { user: true @@ -76,26 +80,25 @@ export default async function Layout(props: LayoutProps) { // the join organization card to allow them to join the org if seat capacity is freed up. This card handles checking if the org has available seats. // 2. The org requires member approval, and they haven't been approved yet. In this case, we allow them to submit a request to join the org. if (!membership) { + if (await isScimEnabled(org)) { + return ; + } + if (!isMemberApprovalRequired(org)) { - return ( -
- - -
- ) - } else { - const hasPendingApproval = await __unsafePrisma.accountRequest.findFirst({ - where: { - orgId: org.id, - requestedById: session.user.id - } - }); - - if (hasPendingApproval) { - return - } else { - return + return ; + } + + const hasPendingApproval = await __unsafePrisma.accountRequest.findFirst({ + where: { + orgId: org.id, + requestedById: session.user.id } + }); + + if (hasPendingApproval) { + return + } else { + return } } diff --git a/packages/web/src/app/(app)/settings/accountAskAgent/layout.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/layout.tsx new file mode 100644 index 000000000..cdf17facf --- /dev/null +++ b/packages/web/src/app/(app)/settings/accountAskAgent/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function AccountAskAgentSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/analytics/layout.tsx b/packages/web/src/app/(app)/settings/analytics/layout.tsx new file mode 100644 index 000000000..ec8f6d691 --- /dev/null +++ b/packages/web/src/app/(app)/settings/analytics/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function AnalyticsSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/apiKeys/layout.tsx b/packages/web/src/app/(app)/settings/apiKeys/layout.tsx index dfc24fa63..7fdba3f2b 100644 --- a/packages/web/src/app/(app)/settings/apiKeys/layout.tsx +++ b/packages/web/src/app/(app)/settings/apiKeys/layout.tsx @@ -2,11 +2,12 @@ import { notFound } from "next/navigation"; import { OrgRole } from "@sourcebot/db"; import { env } from "@sourcebot/shared"; import { authenticatedPage } from "@/middleware/authenticatedPage"; +import { SettingsContainer } from "../components/settingsContainer"; export default authenticatedPage<{ children: React.ReactNode }>(async ({ role }, { children }) => { if (env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'true' && role !== OrgRole.OWNER) { return notFound(); } - return <>{children}; + return {children}; }); diff --git a/packages/web/src/app/(app)/settings/components/settingsCard.tsx b/packages/web/src/app/(app)/settings/components/settingsCard.tsx index 6649e002b..a9c267506 100644 --- a/packages/web/src/app/(app)/settings/components/settingsCard.tsx +++ b/packages/web/src/app/(app)/settings/components/settingsCard.tsx @@ -30,15 +30,19 @@ interface BasicSettingsCardProps { description?: string; children: ReactNode; footer?: ReactNode; + badge?: ReactNode; className?: string; } -export function BasicSettingsCard({ name, description, children, footer, className }: BasicSettingsCardProps) { +export function BasicSettingsCard({ name, description, children, footer, badge, className }: BasicSettingsCardProps) { return (
-

{name}

+
+

{name}

+ {badge} +
{description && (

{description}

)} diff --git a/packages/web/src/app/(app)/settings/components/settingsContainer.tsx b/packages/web/src/app/(app)/settings/components/settingsContainer.tsx new file mode 100644 index 000000000..26ec413d1 --- /dev/null +++ b/packages/web/src/app/(app)/settings/components/settingsContainer.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { cn } from "@/lib/utils"; + +type SettingsContainerVariant = "centered" | "full"; + +interface SettingsContainerProps { + children: React.ReactNode; + variant?: SettingsContainerVariant; +} + +export const SettingsContainer = ({ children, variant = "centered" }: SettingsContainerProps) => { + const isFull = variant === "full"; + return ( +
+
+
{children}
+
+
+ ); +}; diff --git a/packages/web/src/app/(app)/settings/connections/layout.tsx b/packages/web/src/app/(app)/settings/connections/layout.tsx index ec521d14e..c89c498e6 100644 --- a/packages/web/src/app/(app)/settings/connections/layout.tsx +++ b/packages/web/src/app/(app)/settings/connections/layout.tsx @@ -1,6 +1,7 @@ import { authenticatedPage } from "@/middleware/authenticatedPage"; import { OrgRole } from "@sourcebot/db"; +import { SettingsContainer } from "../components/settingsContainer"; export default authenticatedPage<{ children: React.ReactNode }>(async (_auth, { children }) => { - return <>{children}; + return {children}; }, { minRole: OrgRole.OWNER, redirectTo: '/settings' }); diff --git a/packages/web/src/app/(app)/settings/general/layout.tsx b/packages/web/src/app/(app)/settings/general/layout.tsx new file mode 100644 index 000000000..e5b1afe41 --- /dev/null +++ b/packages/web/src/app/(app)/settings/general/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function GeneralSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx index cf640ac11..422785ced 100644 --- a/packages/web/src/app/(app)/settings/layout.tsx +++ b/packages/web/src/app/(app)/settings/layout.tsx @@ -4,7 +4,7 @@ import { redirect } from "next/navigation"; import { auth } from "@/auth"; import { isServiceError } from "@/lib/utils"; import { getConnectionStats } from "@/actions"; -import { getOrgAccountRequests } from "@/features/userManagement/actions"; +import { getOrgAccountRequests } from "@/features/membership/actions"; import { ServiceErrorException } from "@/lib/serviceError"; import { OrgRole } from "@prisma/client"; import { env } from "@sourcebot/shared"; @@ -33,13 +33,7 @@ export default async function SettingsLayout( } return ( -
-
-
-
{children}
-
-
-
+ <>{children} ) } diff --git a/packages/web/src/app/(app)/settings/license/layout.tsx b/packages/web/src/app/(app)/settings/license/layout.tsx new file mode 100644 index 000000000..24d60263c --- /dev/null +++ b/packages/web/src/app/(app)/settings/license/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function LicenseSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/license/page.tsx b/packages/web/src/app/(app)/settings/license/page.tsx index 419422ee8..83ba0a7d2 100644 --- a/packages/web/src/app/(app)/settings/license/page.tsx +++ b/packages/web/src/app/(app)/settings/license/page.tsx @@ -15,6 +15,7 @@ import { getAllInvoices } from "@/ee/features/lighthouse/actions"; import { syncWithLighthouse } from "@/features/billing/servicePing"; import { isServiceError } from "@/lib/utils"; import { getYearlyTermStatus } from "./types"; +import { activeMembershipWhere } from "@/features/membership/utils"; type LicensePageProps = { searchParams?: Promise>; @@ -49,7 +50,12 @@ export default authenticatedPage(async ({ prisma, org }, props : await prisma.license.findUnique({ where: { orgId: org.id } }); const yearlyTermStatus = getYearlyTermStatus(license); - const currentUserCount = await prisma.userToOrg.count({ where: { orgId: org.id } }); + const currentActiveUserCount = await prisma.userToOrg.count({ + where: { + orgId: org.id, + ...activeMembershipWhere(), + }, + }); const invoicesResult = license ? await getAllInvoices() : null; const invoices = invoicesResult && !isServiceError(invoicesResult) ? invoicesResult : []; @@ -97,7 +103,7 @@ export default authenticatedPage(async ({ prisma, org }, props && !isOnlineLicenseInactive && yearlyTermStatus && ( )} diff --git a/packages/web/src/app/(app)/settings/linked-accounts/layout.tsx b/packages/web/src/app/(app)/settings/linked-accounts/layout.tsx new file mode 100644 index 000000000..7f5a6d9ee --- /dev/null +++ b/packages/web/src/app/(app)/settings/linked-accounts/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function LinkedAccountsSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/mcp/layout.tsx b/packages/web/src/app/(app)/settings/mcp/layout.tsx new file mode 100644 index 000000000..8ffa6bb7a --- /dev/null +++ b/packages/web/src/app/(app)/settings/mcp/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function McpSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx b/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx deleted file mode 100644 index b81c16996..000000000 --- a/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx +++ /dev/null @@ -1,193 +0,0 @@ -'use client'; - -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { useCallback, useState } from "react"; -import { z } from "zod"; -import { PlusCircleIcon, Loader2, AlertTriangle } from "lucide-react"; -import { OrgRole } from "@prisma/client"; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; -import { createInvites } from "@/features/userManagement/actions"; -import { isServiceError } from "@/lib/utils"; -import { useToast } from "@/components/hooks/use-toast"; -import { useRouter } from "next/navigation"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; -export const inviteMemberFormSchema = z.object({ - emails: z.array(z.object({ - email: z.string().email() - })) - .refine((emails) => { - const emailSet = new Set(emails.map(e => e.email.toLowerCase())); - return emailSet.size === emails.length; - }, "Duplicate email addresses are not allowed") -}); - -interface InviteMemberCardProps { - currentUserRole: OrgRole; - seatsAvailable: boolean; -} - -export const InviteMemberCard = ({ currentUserRole, seatsAvailable }: InviteMemberCardProps) => { - const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const { toast } = useToast(); - const router = useRouter(); - const captureEvent = useCaptureEvent(); - - const form = useForm>({ - resolver: zodResolver(inviteMemberFormSchema), - defaultValues: { - emails: [{ email: "" }] - }, - }); - - const addEmailField = useCallback(() => { - const emails = form.getValues().emails; - form.setValue('emails', [...emails, { email: "" }]); - }, [form]); - - const onSubmit = useCallback((data: z.infer) => { - setIsLoading(true); - createInvites(data.emails.map(e => e.email)) - .then((res) => { - if (isServiceError(res)) { - toast({ - description: `❌ Failed to invite members. Reason: ${res.message}` - }); - captureEvent('wa_invite_member_card_invite_fail', { - errorCode: res.errorCode, - num_emails: data.emails.length, - }); - } else { - form.reset(); - router.push(`?tab=invites`); - toast({ - description: `✅ Successfully invited ${data.emails.length} members` - }); - captureEvent('wa_invite_member_card_invite_success', { - num_emails: data.emails.length, - }); - } - }) - .finally(() => { - setIsLoading(false); - }); - }, [form, toast, router, captureEvent]); - - const isDisabled = !seatsAvailable || currentUserRole !== OrgRole.OWNER || isLoading; - - return ( - <> - - - Invite Member - Invite new members to your organization. - - {!seatsAvailable && ( -
-
- -
-

- Maximum seats reached -

-

- You've reached the maximum number of seats for your license. Upgrade your plan to invite additional members. -

-
-
-
- )} -
- setIsInviteDialogOpen(true))}> - - Email Address - {form.watch('emails').map((_, index) => ( - ( - - - - - - - )} - /> - ))} - {form.formState.errors.emails?.root?.message && ( - {form.formState.errors.emails.root.message} - )} - - - - - -
- -
- - - - Invite Team Members - - {`Your team is growing! By confirming, you will be inviting ${form.getValues().emails.length} new members to your organization.`} - - -
-
- {form.getValues().emails.map(({ email }, index) => ( -

- {email} -

- ))} -
-
- - captureEvent('wa_invite_member_card_invite_cancel', { - num_emails: form.getValues().emails.length, - })}>Cancel - onSubmit(form.getValues())} - > - Invite - - -
-
- - ) -} \ No newline at end of file diff --git a/packages/web/src/app/(app)/settings/members/components/invitesList.tsx b/packages/web/src/app/(app)/settings/members/components/invitesList.tsx deleted file mode 100644 index b5ebb4883..000000000 --- a/packages/web/src/app/(app)/settings/members/components/invitesList.tsx +++ /dev/null @@ -1,214 +0,0 @@ -'use client'; - -import { OrgRole } from "@sourcebot/db"; -import { useToast } from "@/components/hooks/use-toast"; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { createPathWithQueryParams, isServiceError } from "@/lib/utils"; -import { UserAvatar } from "@/components/userAvatar"; -import { Copy, MoreVertical, Search } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; -import { cancelInvite } from "@/features/userManagement/actions"; -import { useRouter } from "next/navigation"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; -interface Invite { - id: string; - email: string; - createdAt: Date; -} - -interface InviteListProps { - invites: Invite[] - currentUserRole: OrgRole -} - -export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => { - const [searchQuery, setSearchQuery] = useState("") - const [dateSort, setDateSort] = useState<"newest" | "oldest">("newest") - const [isCancelInviteDialogOpen, setIsCancelInviteDialogOpen] = useState(false) - const [inviteToCancel, setInviteToCancel] = useState(null) - const { toast } = useToast(); - const router = useRouter(); - const captureEvent = useCaptureEvent(); - - const filteredInvites = useMemo(() => { - return invites - .filter((invite) => { - const searchLower = searchQuery.toLowerCase(); - const matchesSearch = - invite.email.toLowerCase().includes(searchLower); - return matchesSearch; - }) - .sort((a, b) => { - return dateSort === "newest" - ? b.createdAt.getTime() - a.createdAt.getTime() - : a.createdAt.getTime() - b.createdAt.getTime() - }); - }, [invites, searchQuery, dateSort]); - - const onCancelInvite = useCallback((inviteId: string) => { - cancelInvite(inviteId) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to cancel invite. Reason: ${response.message}` - }) - captureEvent('wa_invites_list_cancel_invite_fail', { - errorCode: response.errorCode, - }) - } else { - toast({ - description: `✅ Invite cancelled successfully.` - }) - captureEvent('wa_invites_list_cancel_invite_success', {}) - router.refresh(); - } - }); - }, [toast, router, captureEvent]); - - return ( -
-
-
- - setSearchQuery(e.target.value)} - /> -
- - -
- -
-
- {invites.length === 0 || (filteredInvites.length === 0 && searchQuery.length > 0) ? ( -
-

No Pending Invitations Found

-

- {filteredInvites.length === 0 && searchQuery.length > 0 ? "No pending invitations found matching your filters." : "Use the form above to invite new members."} -

-
- ) : ( - filteredInvites.map((invite) => ( -
-
- -
-
{invite.email}
-
-
-
- - - - - - - { - navigator.clipboard.writeText(invite.email) - .then(() => { - toast({ - description: `✅ Email copied to clipboard.` - }) - captureEvent('wa_invites_list_copy_email_success', {}) - }) - .catch(() => { - toast({ - description: `❌ Failed to copy email.` - }) - captureEvent('wa_invites_list_copy_email_fail', {}) - }) - }} - > - Copy email - - {currentUserRole === OrgRole.OWNER && ( - { - setIsCancelInviteDialogOpen(true); - setInviteToCancel(invite); - }} - > - Cancel invite - - )} - - -
-
- )) - )} -
-
- - - - Cancel Invite - - Are you sure you want to cancel this invite for {inviteToCancel?.email}? - - - - - Back - - { - onCancelInvite(inviteToCancel?.id ?? ""); - }} - > - Cancel - - - - -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/(app)/settings/members/components/membersList.tsx b/packages/web/src/app/(app)/settings/members/components/membersList.tsx deleted file mode 100644 index 9976f11de..000000000 --- a/packages/web/src/app/(app)/settings/members/components/membersList.tsx +++ /dev/null @@ -1,430 +0,0 @@ -'use client'; - -import { Input } from "@/components/ui/input"; -import { Search, MoreVertical } from "lucide-react"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { Button } from "@/components/ui/button"; -import { useCallback, useMemo, useState } from "react"; -import { OrgRole } from "@prisma/client"; -import { UserAvatar } from "@/components/userAvatar"; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; -import { promoteToOwner, demoteToMember } from "@/ee/features/userManagement/actions"; -import { leaveOrg, removeMemberFromOrg } from "@/features/userManagement/actions"; -import { isServiceError } from "@/lib/utils"; -import { useToast } from "@/components/hooks/use-toast"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useRouter } from "next/navigation"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; -import Link from "next/link"; - -type Member = { - id: string - email: string - name?: string - role: OrgRole - joinedAt: Date - avatarUrl?: string -} - -export interface MembersListProps { - members: Member[], - currentUserId: string, - currentUserRole: OrgRole, - orgName: string, - hasOrgManagement: boolean, -} - -const ROLES_AND_PERMISSIONS_DOCS_LINK = "https://docs.sourcebot.dev/docs/configuration/auth/roles-and-permissions" - -export const MembersList = ({ members, currentUserId, currentUserRole, orgName, hasOrgManagement }: MembersListProps) => { - const [searchQuery, setSearchQuery] = useState("") - const [roleFilter, setRoleFilter] = useState<"all" | OrgRole>("all") - const [dateSort, setDateSort] = useState<"newest" | "oldest">("newest") - const [memberToRemove, setMemberToRemove] = useState(null) - const [memberToPromote, setMemberToPromote] = useState(null) - const [memberToDemote, setMemberToDemote] = useState(null) - const { toast } = useToast() - const [isRemoveDialogOpen, setIsRemoveDialogOpen] = useState(false) - const [isPromoteDialogOpen, setIsPromoteDialogOpen] = useState(false) - const [isDemoteDialogOpen, setIsDemoteDialogOpen] = useState(false) - const [isLeaveOrgDialogOpen, setIsLeaveOrgDialogOpen] = useState(false) - const router = useRouter(); - const captureEvent = useCaptureEvent(); - - const ownerCount = useMemo(() => members.filter(m => m.role === OrgRole.OWNER).length, [members]); - - const filteredMembers = useMemo(() => { - return members - .filter((member) => { - const searchLower = searchQuery.toLowerCase(); - const matchesSearch = - member.name?.toLowerCase().includes(searchLower) || member.email.toLowerCase().includes(searchLower); - const matchesRole = roleFilter === "all" || member.role === roleFilter; - return matchesSearch && matchesRole; - }) - .sort((a, b) => { - return dateSort === "newest" - ? b.joinedAt.getTime() - a.joinedAt.getTime() - : a.joinedAt.getTime() - b.joinedAt.getTime() - }); - }, [members, searchQuery, roleFilter, dateSort]); - - const onRemoveMember = useCallback((memberId: string) => { - removeMemberFromOrg(memberId) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to remove member. Reason: ${response.message}` - }) - captureEvent('wa_members_list_remove_member_fail', { - errorCode: response.errorCode, - }) - } else { - toast({ - description: `✅ Member removed successfully.` - }) - captureEvent('wa_members_list_remove_member_success', {}) - router.refresh(); - } - }); - }, [toast, router, captureEvent]); - - const onPromoteToOwner = useCallback((memberId: string) => { - promoteToOwner(memberId) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to promote member. Reason: ${response.message}` - }) - captureEvent('wa_members_list_promote_to_owner_fail', { - errorCode: response.errorCode, - }) - } else { - toast({ - description: `✅ Member promoted to owner.` - }) - captureEvent('wa_members_list_promote_to_owner_success', {}) - router.refresh(); - } - }); - }, [toast, router, captureEvent]); - - const onDemoteToMember = useCallback((memberId: string) => { - demoteToMember(memberId) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to demote owner. Reason: ${response.message}` - }) - captureEvent('wa_members_list_demote_to_member_fail', { - errorCode: response.errorCode, - }) - } else { - toast({ - description: `✅ Owner demoted to member.` - }) - captureEvent('wa_members_list_demote_to_member_success', {}) - router.refresh(); - } - }); - }, [toast, router, captureEvent]); - - const onLeaveOrg = useCallback(() => { - leaveOrg() - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to leave organization. Reason: ${response.message}` - }) - captureEvent('wa_members_list_leave_org_fail', { - errorCode: response.errorCode, - }) - } else { - toast({ - description: `✅ You have left the organization.` - }) - captureEvent('wa_members_list_leave_org_success', {}) - router.refresh(); - } - }); - }, [toast, router, captureEvent]); - - return ( -
-
-
-
- - setSearchQuery(e.target.value)} - /> -
- - - - -
- -
-
- {filteredMembers.length === 0 ? ( -
-

No Members Found

-

- No members found matching your filters. -

-
- ) : ( - filteredMembers.map((member) => ( -
-
- -
-
{member.name}
-
{member.email}
-
-
-
- {member.role.toLowerCase()} - - - - - - { - navigator.clipboard.writeText(member.email) - .then(() => { - toast({ - description: `✅ Email copied to clipboard.` - }) - }) - .catch(() => { - toast({ - description: `❌ Failed to copy email.` - }) - }) - }} - > - Copy email - - {member.id !== currentUserId && currentUserRole === OrgRole.OWNER && member.role !== OrgRole.OWNER && ( - - - - { - setMemberToPromote(member); - setIsPromoteDialogOpen(true); - }} - > - Promote to owner - - - - {!hasOrgManagement && ( - - Upgrade your plan to manage roles. Learn more - - )} - - )} - {currentUserRole === OrgRole.OWNER && member.role === OrgRole.OWNER && ( - - - - { - setMemberToDemote(member); - setIsDemoteDialogOpen(true); - }} - > - Demote to member - - - - {(ownerCount <= 1 || !hasOrgManagement) && ( - - {!hasOrgManagement - ? <>Upgrade your plan to manage roles. Learn more - : "Cannot demote the last owner. Promote another member to owner first." - } - - )} - - )} - {member.id !== currentUserId && currentUserRole === OrgRole.OWNER && ( - { - setMemberToRemove(member); - setIsRemoveDialogOpen(true); - }} - > - Remove - - )} - {member.id === currentUserId && ( - - - - { - setIsLeaveOrgDialogOpen(true); - }} - > - Leave organization - - - - {currentUserRole === OrgRole.OWNER && ownerCount <= 1 && ( - - You are the last owner. Promote another member to owner before leaving. - - )} - - )} - - -
-
- )) - )} -
-
- - - - Remove Member - - {`Are you sure you want to remove ${memberToRemove?.name ?? memberToRemove?.email}?`} - - - - Cancel - { - onRemoveMember(memberToRemove?.id ?? ""); - }} - > - Remove - - - - - - - - Promote to Owner - - {`Are you sure you want to promote ${memberToPromote?.name ?? memberToPromote?.email} to owner? They will have full administrative access.`} - - - - Cancel - { - onPromoteToOwner(memberToPromote?.id ?? ""); - }} - > - Promote - - - - - - - - Demote to Member - - {memberToDemote?.id === currentUserId - ? `Are you sure you want to step down as owner? You will lose administrative access.` - : `Are you sure you want to demote ${memberToDemote?.name ?? memberToDemote?.email} from owner to member? They will lose administrative access.` - } - - - - Cancel - { - onDemoteToMember(memberToDemote?.id ?? ""); - }} - > - Demote - - - - - - - - Leave Organization - - {`Are you sure you want to leave the organization?`} - - - - Cancel - - Leave - - - - -
-
- ) -} diff --git a/packages/web/src/app/(app)/settings/members/components/requestsList.tsx b/packages/web/src/app/(app)/settings/members/components/requestsList.tsx deleted file mode 100644 index 2412f0eb7..000000000 --- a/packages/web/src/app/(app)/settings/members/components/requestsList.tsx +++ /dev/null @@ -1,232 +0,0 @@ -'use client'; - -import { OrgRole } from "@sourcebot/db"; -import { useToast } from "@/components/hooks/use-toast"; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { isServiceError } from "@/lib/utils"; -import { UserAvatar } from "@/components/userAvatar"; -import { CheckCircle, Search, XCircle } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; -import { rejectAccountRequest, approveAccountRequest } from "@/features/userManagement/actions"; -import { useRouter } from "next/navigation"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; - -interface Request { - id: string; - email: string; - createdAt: Date; - name?: string; - image?: string; -} - -interface RequestsListProps { - requests: Request[] - currentUserRole: OrgRole -} - -export const RequestsList = ({ requests, currentUserRole }: RequestsListProps) => { - const [searchQuery, setSearchQuery] = useState("") - const [dateSort, setDateSort] = useState<"newest" | "oldest">("newest") - const [isApproveRequestDialogOpen, setIsApproveRequestDialogOpen] = useState(false) - const [isRejectRequestDialogOpen, setIsRejectRequestDialogOpen] = useState(false) - const [requestToAction, setRequestToAction] = useState(null) - const { toast } = useToast(); - const router = useRouter(); - const captureEvent = useCaptureEvent(); - - const filteredRequests = useMemo(() => { - return requests - .filter((request) => { - const searchLower = searchQuery.toLowerCase(); - const matchesSearch = - request.email.toLowerCase().includes(searchLower) || - (request.name?.toLowerCase().includes(searchLower) || false); - return matchesSearch; - }) - .sort((a, b) => { - return dateSort === "newest" - ? b.createdAt.getTime() - a.createdAt.getTime() - : a.createdAt.getTime() - b.createdAt.getTime() - }); - }, [requests, searchQuery, dateSort]); - - const onApproveRequest = useCallback((requestId: string) => { - approveAccountRequest(requestId) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to approve request. Reason: ${response.message}` - }) - captureEvent('wa_requests_list_approve_request_fail', { - errorCode: response.errorCode, - }) - } else { - toast({ - description: `✅ Request approved successfully.` - }) - captureEvent('wa_requests_list_approve_request_success', {}) - router.refresh(); - } - }); - }, [toast, router, captureEvent]); - - const onRejectRequest = useCallback((requestId: string) => { - rejectAccountRequest(requestId) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to reject request.` - }) - captureEvent('wa_requests_list_reject_request_fail', { - errorCode: response.errorCode, - }) - } else { - toast({ - description: `✅ Request rejected successfully.` - }) - captureEvent('wa_requests_list_reject_request_success', {}) - router.refresh(); - } - }); - }, [toast, router, captureEvent]); - - return ( -
-
-
- - setSearchQuery(e.target.value)} - /> -
- - -
- -
-
- {requests.length === 0 || (filteredRequests.length === 0 && searchQuery.length > 0) ? ( -
-

No Pending Requests Found

-

- {filteredRequests.length === 0 && searchQuery.length > 0 ? "No pending requests found matching your filters." : "There are currently no pending requests to join your organization."} -

-
- ) : ( - filteredRequests.map((request) => ( -
-
- -
-
{request.name || request.email}
-
{request.email}
-
-
-
- {currentUserRole === OrgRole.OWNER && ( - <> - - - - )} -
-
- )) - )} -
-
- - {/* Approve Request Dialog */} - - - - Approve Request - - Are you sure you want to approve the request from {requestToAction?.email}? They will be added as a member to your organization. - - - - - Back - - { - onApproveRequest(requestToAction?.id ?? ""); - }} - > - Approve - - - - - - {/* Reject Request Dialog */} - - - - Reject Request - - Are you sure you want to reject the request from {requestToAction?.email}? - - - - - Back - - { - onRejectRequest(requestToAction?.id ?? ""); - }} - > - Reject - - - - -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/(app)/settings/members/inviteMembersDialog.tsx b/packages/web/src/app/(app)/settings/members/inviteMembersDialog.tsx new file mode 100644 index 000000000..bac881928 --- /dev/null +++ b/packages/web/src/app/(app)/settings/members/inviteMembersDialog.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Textarea } from "@/components/ui/textarea"; +import { useToast } from "@/components/hooks/use-toast"; +import { createInvites } from "@/features/membership/actions"; +import { isServiceError } from "@/lib/utils"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; + +interface InviteMembersDialogProps { + className?: string; +} + +const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const parseEmails = (value: string) => { + return value + .split(",") + .map((email) => email.trim()) + .filter(Boolean); +}; + +const inviteMembersFormSchema = z.object({ + emails: z.string().trim().superRefine((value, ctx) => { + const emails = parseEmails(value); + + if (emails.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter at least one email address.", + }); + return; + } + + const invalidEmail = emails.find((email) => !emailPattern.test(email)); + if (invalidEmail) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${invalidEmail} is not a valid email address.`, + }); + return; + } + + const normalizedEmails = emails.map((email) => email.toLowerCase()); + if (new Set(normalizedEmails).size !== normalizedEmails.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Duplicate email addresses are not allowed.", + }); + } + }), +}); + +type InviteMembersFormValues = z.infer; + +export const InviteMembersDialog = ({ className }: InviteMembersDialogProps) => { + const [open, setOpen] = useState(false); + const [shouldFocusEmails, setShouldFocusEmails] = useState(false); + const emailsTextareaRef = useRef(null); + const router = useRouter(); + const { toast } = useToast(); + const captureEvent = useCaptureEvent(); + const form = useForm({ + resolver: zodResolver(inviteMembersFormSchema), + defaultValues: { + emails: "", + }, + }); + + const isSubmitting = form.formState.isSubmitting; + const emailsValue = form.watch("emails"); + const emails = parseEmails(emailsValue); + const emailsRegistration = form.register("emails"); + + const focusEmailsField = () => { + form.setFocus("emails"); + window.setTimeout(() => { + emailsTextareaRef.current?.focus({ preventScroll: true }); + }, 0); + }; + + useEffect(() => { + if (!shouldFocusEmails || isSubmitting) { + return; + } + + focusEmailsField(); + setShouldFocusEmails(false); + // `form` is intentionally omitted here. The effect should run when the + // failed submit settles and the textarea is no longer disabled. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldFocusEmails, isSubmitting]); + + const handleOpenChange = (nextOpen: boolean) => { + if (isSubmitting) { + return; + } + + if (!nextOpen && emailsValue.trim().length > 0) { + captureEvent("wa_invite_member_card_invite_cancel", { + num_emails: emails.length, + }); + } + + if (!nextOpen) { + form.reset(); + } + + setOpen(nextOpen); + }; + + const onSubmit = async (values: InviteMembersFormValues) => { + const emails = parseEmails(values.emails); + try { + const result = await createInvites(emails); + if (isServiceError(result)) { + form.setError("emails", { + type: "server", + message: result.message, + }, { + shouldFocus: true, + }); + setShouldFocusEmails(true); + toast({ description: `Failed to send invites. Reason: ${result.message}` }); + captureEvent("wa_invite_member_card_invite_fail", { + errorCode: result.errorCode, + num_emails: emails.length, + }); + return; + } + + toast({ description: `Successfully sent ${emails.length} invite${emails.length === 1 ? "" : "s"}.` }); + captureEvent("wa_invite_member_card_invite_success", { + num_emails: emails.length, + }); + form.reset(); + setOpen(false); + router.refresh(); + } catch { + form.setError("emails", { + type: "server", + message: "Something went wrong while sending invites.", + }, { + shouldFocus: true, + }); + setShouldFocusEmails(true); + toast({ description: "Failed to send invites." }); + } + }; + + const onInvalidSubmit = () => { + setShouldFocusEmails(true); + }; + + const isSendDisabled = isSubmitting || emails.length === 0; + + return ( + + + + + Invite members + + Invite new members to your organization. + + +
+ + ( + + Email + +