diff --git a/CHANGELOG.md b/CHANGELOG.md
index f4da16c2..68996d2f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fixed issue with external source code links being broken for paths with spaces. [#364](https://github.com/sourcebot-dev/sourcebot/pull/364)
+- Revamped onboarding experience. [#370](https://github.com/sourcebot-dev/sourcebot/pull/376)
- Makes base retry indexing configuration configurable and move from a default of `5s` to `60s`. [#377](https://github.com/sourcebot-dev/sourcebot/pull/377)
- Fixed issue where files would sometimes never load in the code browser. [#365](https://github.com/sourcebot-dev/sourcebot/pull/365)
diff --git a/docs/docs.json b/docs/docs.json
index c8b3888e..3af230d7 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -52,7 +52,7 @@
"group": "Configuration",
"pages": [
{
- "group": "Connecting your code",
+ "group": "Indexing your code",
"pages": [
"docs/connections/overview",
"docs/connections/github",
@@ -72,7 +72,10 @@
"group": "Authentication",
"pages": [
"docs/configuration/auth/overview",
- "docs/configuration/auth/roles-and-permissions"
+ "docs/configuration/auth/providers",
+ "docs/configuration/auth/inviting-members",
+ "docs/configuration/auth/roles-and-permissions",
+ "docs/configuration/auth/faq"
]
},
"docs/configuration/transactional-emails",
diff --git a/docs/docs/configuration/audit-logs.mdx b/docs/docs/configuration/audit-logs.mdx
index 2a3c0624..f229caaf 100644
--- a/docs/docs/configuration/audit-logs.mdx
+++ b/docs/docs/configuration/audit-logs.mdx
@@ -125,7 +125,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `user.join_requested` | `user` | `org` |
| `user.join_request_approve_failed` | `user` | `account_join_request` |
| `user.join_request_approved` | `user` | `account_join_request` |
-| `user.join_request_removed` | `user` | `account_join_request` |
| `user.invite_failed` | `user` | `org` |
| `user.invites_created` | `user` | `org` |
| `user.invite_accept_failed` | `user` | `invite` |
diff --git a/docs/docs/configuration/auth/faq.mdx b/docs/docs/configuration/auth/faq.mdx
new file mode 100644
index 00000000..5d37bc66
--- /dev/null
+++ b/docs/docs/configuration/auth/faq.mdx
@@ -0,0 +1,46 @@
+---
+title: FAQ
+---
+
+This page covers a range of frequently asked questions about Sourcebot's built-in authentication system.
+
+
+
+ No, at this time it's not possible to disable the authentication system. If this is preventing you from deploying Sourcebot
+ within your organization please [reach out](https://www.sourcebot.dev/contact)
+
+
+
+ Every user must register an account within your Sourcebot deployment. However, this dosn't mean their access
+ is restricted.
+
+ Unless member approval is required, anyone can sign up for an account on your deployment and immediately be granted access.
+
+
+
+ **No data related to authentication (or your code) leaves your deployment**. Authentication is handled
+ purely by your deployment and the authentication providers you configure.
+
+ This data does not leave your device and is stored within in the database managed by your deployment. If you're
+ using credential login, passwords are encrypted at rest and in transit.
+
+
+
+ Please note that IAP bridges are an enterprise feature
+ Sourcebot supports connecting your identity proxy directly into the built-in auth system using an IAP bridge. This allows Sourcebot to
+ register and authenticate automatically on a successful identity proxy log in.
+
+ Sourcebot currently supports [GCP IAP](/docs/configuration/auth/providers#gcp-iap). If you're using a different IAP
+ and require support, please [reach out](https://www.sourcebot.dev/contact)
+
+
+
+ Sourcebot uses [Auth.js](https://authjs.dev/) as its underlying authentication framework. Auth.js provides authentication providers
+ (credientials, Google, GitHub, etc) and an interface to enable user registration and log in. Internally, Auth.js uses JWT to provide
+ Sourcebot secure and reliable information about user authentication.
+
+
+
+
+Have a question that's not answered here? Submit it on our [GitHub discussions](https://github.com/sourcebot-dev/sourcebot/discussions)
+page and we'll get back to you as soon as we can!
\ No newline at end of file
diff --git a/docs/docs/configuration/auth/inviting-members.mdx b/docs/docs/configuration/auth/inviting-members.mdx
new file mode 100644
index 00000000..d67e497b
--- /dev/null
+++ b/docs/docs/configuration/auth/inviting-members.mdx
@@ -0,0 +1,30 @@
+---
+title: Inviting Members
+sidebarTitle: Inviting members
+---
+
+There are various ways to configure how members can join a Sourcebot deployment.
+
+## Member Approval
+
+**By default, Sourcebot requires new members to be approved by the owner of the deployment**. This section explains how approvals work and how
+to configure this behavior.
+
+### Configuration
+Member approval can be configured by the owner of the deployment by navigating to **Settings -> Members**:
+
+
+
+### Managing Requests
+
+If member approval is enabled, new members will be asked to submit a join request after signing up. They will not have access to the Sourcebot deployment
+until this request is approved by the owner.
+
+The owner can see and manage all pending join requests by navigating to **Settings -> Members**.
+
+## Invite link
+
+If member approval is required, an owner of the deployment can enable an invite link. When enabled, users
+can use this invite link to register and be automatically added to the organization without approval:
+
+
\ No newline at end of file
diff --git a/docs/docs/configuration/auth/overview.mdx b/docs/docs/configuration/auth/overview.mdx
index 3b11a4a9..732fef35 100644
--- a/docs/docs/configuration/auth/overview.mdx
+++ b/docs/docs/configuration/auth/overview.mdx
@@ -4,124 +4,23 @@ title: Overview
If you're deploying Sourcebot behind a domain, you must set the [AUTH_URL](/docs/configuration/environment-variables) environment variable.
-Sourcebot has built-in authentication that gates access to your organization. OAuth, email codes, and email / password are supported.
+Sourcebot's built-in authentication system gates your deployment, and allows administrators to manage users and their permissions.
-The first account that's registered on a Sourcebot deployment is made the owner. All other users who register must be [approved](/docs/configuration/auth/overview#approving-new-members) by the owner.
+
+
+ Configure additional authentication providers for your deployment.
+
+
+ Learn how to configure how members join your deployment.
+
+
+ Learn more about the different roles and permissions in Sourcebot.
+
+
+ Have a question about Sourcebot's auth system? We might have the answers here.
+
+
-
-
-
-# Approving New Members
-
-All account registrations after the first account must be approved by the owner. The owner can see all join requests by going into **Settings -> Members**.
-
-If you have an [enterprise license](/docs/license-key), you can enable [AUTH_EE_ENABLE_JIT_PROVISIONING](/docs/configuration/auth/overview#enterprise-authentication-providers) to
-have Sourcebot accounts automatically created and approved on registration.
-
-You can setup emails to be sent when new join requests are created/approved by configurating [transactional emails](/docs/configuration/transactional-emails)
-# Authentication Providers
-
-To enable an authentication provider in Sourcebot, configure the required environment variables for the provider. Under the hood, Sourcebot uses Auth.js which supports [many providers](https://authjs.dev/getting-started/authentication/oauth). Submit a [feature request on GitHub](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas) if you want us to add support for a specific provider.
-
-## Core Authentication Providers
-
-### Email / Password
----
-Email / password authentication is enabled by default. It can be **disabled** by setting `AUTH_CREDENTIALS_LOGIN_ENABLED` to `false`.
-
-### Email codes
----
-Email codes are 6 digit codes sent to a provided email. Email codes are enabled when transactional emails are configured using the following environment variables:
-
-- `AUTH_EMAIL_CODE_LOGIN_ENABLED`
-- `SMTP_CONNECTION_URL`
-- `EMAIL_FROM_ADDRESS`
-
-
-See [transactional emails](/docs/configuration/transactional-emails) for more details.
-
-## Enterprise Authentication Providers
-
-The following authentication providers require an [enterprise license](/docs/license-key) to be enabled.
-
-By default, a new user registering using these providers must have their join request accepted by the owner of the organization to join. To allow a user to join automatically when
-they register for the first time, set the `AUTH_EE_ENABLE_JIT_PROVISIONING` environment variable to `true`.
-
-### GitHub
----
-
-[Auth.js GitHub Provider Docs](https://authjs.dev/getting-started/providers/github)
-
-**Required environment variables:**
-- `AUTH_EE_GITHUB_CLIENT_ID`
-- `AUTH_EE_GITHUB_CLIENT_SECRET`
-
-Optional environment variables:
-- `AUTH_EE_GITHUB_BASE_URL` - Base URL for GitHub Enterprise (defaults to https://github.com)
-
-### GitLab
----
-
-[Auth.js GitLab Provider Docs](https://authjs.dev/getting-started/providers/gitlab)
-
-**Required environment variables:**
-- `AUTH_EE_GITLAB_CLIENT_ID`
-- `AUTH_EE_GITLAB_CLIENT_SECRET`
-
-Optional environment variables:
-- `AUTH_EE_GITLAB_BASE_URL` - Base URL for GitLab instance (defaults to https://gitlab.com)
-
-### Google
----
-
-[Auth.js Google Provider Docs](https://authjs.dev/getting-started/providers/google)
-
-**Required environment variables:**
-- `AUTH_EE_GOOGLE_CLIENT_ID`
-- `AUTH_EE_GOOGLE_CLIENT_SECRET`
-
-### GCP IAP
----
-
-If you're running Sourcebot in an environment that blocks egress, make sure you allow the [IAP IP ranges](https://www.gstatic.com/ipranges/goog.json)
-
-Custom provider built to enable automatic Sourcebot account registration/login when using GCP IAP.
-
-**Required environment variables**
-- `AUTH_EE_GCP_IAP_ENABLED`
-- `AUTH_EE_GCP_IAP_AUDIENCE`
- - This can be found by selecting the ⋮ icon next to the IAP-enabled backend service and pressing `Get JWT audience code`
-
-### Okta
----
-
-[Auth.js Okta Provider Docs](https://authjs.dev/getting-started/providers/okta)
-
-**Required environment variables:**
-- `AUTH_EE_OKTA_CLIENT_ID`
-- `AUTH_EE_OKTA_CLIENT_SECRET`
-- `AUTH_EE_OKTA_ISSUER`
-
-### Keycloak
----
-
-[Auth.js Keycloak Provider Docs](https://authjs.dev/getting-started/providers/keycloak)
-
-**Required environment variables:**
-- `AUTH_EE_KEYCLOAK_CLIENT_ID`
-- `AUTH_EE_KEYCLOAK_CLIENT_SECRET`
-- `AUTH_EE_KEYCLOAK_ISSUER`
-
-### Microsoft Entra ID
-
-[Auth.js Microsoft Entra ID Provider Docs](https://authjs.dev/getting-started/providers/microsoft-entra-id)
-
-**Required environment variables:**
-- `AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID`
-- `AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET`
-- `AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER`
-
----
# Troubleshooting
diff --git a/docs/docs/configuration/auth/providers.mdx b/docs/docs/configuration/auth/providers.mdx
new file mode 100644
index 00000000..ae52ea46
--- /dev/null
+++ b/docs/docs/configuration/auth/providers.mdx
@@ -0,0 +1,105 @@
+---
+title: Providers
+---
+
+Sourcebot supports a wide range of different authentication providers through it's integration with [Auth.js](https://authjs.dev/). This page
+highlights how to configure the various supported providers.
+
+If theres an authentication provider you'd like us to support, please [reach out](https://www.sourcebot.dev/contact).
+
+# Core Authentication Providers
+
+### Email / Password
+---
+Email / password authentication is enabled by default. It can be **disabled** by setting `AUTH_CREDENTIALS_LOGIN_ENABLED` to `false`.
+
+### Email codes
+---
+Email codes are 6 digit codes sent to a provided email. Email codes are enabled when transactional emails are configured using the following environment variables:
+
+- `AUTH_EMAIL_CODE_LOGIN_ENABLED`
+- `SMTP_CONNECTION_URL`
+- `EMAIL_FROM_ADDRESS`
+
+
+See [transactional emails](/docs/configuration/transactional-emails) for more details.
+
+# Enterprise Authentication Providers
+
+The following authentication providers require an [enterprise license](/docs/license-key) to be enabled.
+
+### GitHub
+---
+
+[Auth.js GitHub Provider Docs](https://authjs.dev/getting-started/providers/github)
+
+**Required environment variables:**
+- `AUTH_EE_GITHUB_CLIENT_ID`
+- `AUTH_EE_GITHUB_CLIENT_SECRET`
+
+Optional environment variables:
+- `AUTH_EE_GITHUB_BASE_URL` - Base URL for GitHub Enterprise (defaults to https://github.com)
+
+### GitLab
+---
+
+[Auth.js GitLab Provider Docs](https://authjs.dev/getting-started/providers/gitlab)
+
+**Required environment variables:**
+- `AUTH_EE_GITLAB_CLIENT_ID`
+- `AUTH_EE_GITLAB_CLIENT_SECRET`
+
+Optional environment variables:
+- `AUTH_EE_GITLAB_BASE_URL` - Base URL for GitLab instance (defaults to https://gitlab.com)
+
+### Google
+---
+
+[Auth.js Google Provider Docs](https://authjs.dev/getting-started/providers/google)
+
+**Required environment variables:**
+- `AUTH_EE_GOOGLE_CLIENT_ID`
+- `AUTH_EE_GOOGLE_CLIENT_SECRET`
+
+### GCP IAP
+---
+
+If you're running Sourcebot in an environment that blocks egress, make sure you allow the [IAP IP ranges](https://www.gstatic.com/ipranges/goog.json)
+
+Custom provider built to enable automatic Sourcebot account registration/login when using GCP IAP.
+
+**Required environment variables**
+- `AUTH_EE_GCP_IAP_ENABLED`
+- `AUTH_EE_GCP_IAP_AUDIENCE`
+ - This can be found by selecting the ⋮ icon next to the IAP-enabled backend service and pressing `Get JWT audience code`
+
+### Okta
+---
+
+[Auth.js Okta Provider Docs](https://authjs.dev/getting-started/providers/okta)
+
+**Required environment variables:**
+- `AUTH_EE_OKTA_CLIENT_ID`
+- `AUTH_EE_OKTA_CLIENT_SECRET`
+- `AUTH_EE_OKTA_ISSUER`
+
+### Keycloak
+---
+
+[Auth.js Keycloak Provider Docs](https://authjs.dev/getting-started/providers/keycloak)
+
+**Required environment variables:**
+- `AUTH_EE_KEYCLOAK_CLIENT_ID`
+- `AUTH_EE_KEYCLOAK_CLIENT_SECRET`
+- `AUTH_EE_KEYCLOAK_ISSUER`
+
+### Microsoft Entra ID
+
+[Auth.js Microsoft Entra ID Provider Docs](https://authjs.dev/getting-started/providers/microsoft-entra-id)
+
+**Required environment variables:**
+- `AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID`
+- `AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET`
+- `AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER`
+
+---
\ No newline at end of file
diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx
index 86df22b4..9378b023 100644
--- a/docs/docs/configuration/environment-variables.mdx
+++ b/docs/docs/configuration/environment-variables.mdx
@@ -41,7 +41,6 @@ The following environment variables allow you to configure your Sourcebot deploy
| Variable | Default | Description |
| :------- | :------ | :---------- |
| `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `true` |
{
diff --git a/packages/web/src/app/[domain]/components/resubmitAccountRequestButton.tsx b/packages/web/src/app/[domain]/components/submitAccountRequestButton.tsx
similarity index 73%
rename from packages/web/src/app/[domain]/components/resubmitAccountRequestButton.tsx
rename to packages/web/src/app/[domain]/components/submitAccountRequestButton.tsx
index ec4df43e..291e5f50 100644
--- a/packages/web/src/app/[domain]/components/resubmitAccountRequestButton.tsx
+++ b/packages/web/src/app/[domain]/components/submitAccountRequestButton.tsx
@@ -6,14 +6,16 @@ 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"
-interface ResubmitButtonProps {
+interface SubmitButtonProps {
domain: string
userId: string
}
-export function ResubmitAccountRequestButton({ domain, userId }: ResubmitButtonProps) {
+export function SubmitAccountRequestButton({ domain, userId }: SubmitButtonProps) {
const { toast } = useToast()
+ const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async () => {
@@ -28,19 +30,20 @@ export function ResubmitAccountRequestButton({ domain, userId }: ResubmitButtonP
})
} else {
toast({
- title: "Request Resubmitted",
- description: "Your request to join the organization has been resubmitted.",
+ 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 Resubmit",
- description: `There was an error resubmitting your request. Reason: ${result.message}`,
+ title: "Failed to Submit",
+ description: `There was an error submitting your request. Reason: ${result.message}`,
variant: "destructive",
})
}
-
setIsSubmitting(false)
}
@@ -57,7 +60,7 @@ export function ResubmitAccountRequestButton({ domain, userId }: ResubmitButtonP
disabled={isSubmitting}
>
- {isSubmitting ? "Submitting..." : "Resubmit Request"}
+ {isSubmitting ? "Submitting..." : "Submit Request"}
)
diff --git a/packages/web/src/app/[domain]/components/submitJoinRequest.tsx b/packages/web/src/app/[domain]/components/submitJoinRequest.tsx
new file mode 100644
index 00000000..b79fbfa5
--- /dev/null
+++ b/packages/web/src/app/[domain]/components/submitJoinRequest.tsx
@@ -0,0 +1,55 @@
+import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"
+import { SourcebotLogo } from "@/app/components/sourcebotLogo"
+import { auth } from "@/auth"
+import { SubmitAccountRequestButton } from "./submitAccountRequestButton"
+
+interface SubmitJoinRequestProps {
+ domain: string
+}
+
+export const SubmitJoinRequest = async ({ domain }: SubmitJoinRequestProps) => {
+ const session = await auth()
+ const userId = session?.user?.id
+
+ if (!userId) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Request Access
+
+
+ Submit a request to join this organization
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx
index bca8bad8..f24accd3 100644
--- a/packages/web/src/app/[domain]/layout.tsx
+++ b/packages/web/src/app/[domain]/layout.tsx
@@ -14,10 +14,14 @@ import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
import { notFound, redirect } from "next/navigation";
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
import { PendingApprovalCard } from "./components/pendingApproval";
+import { SubmitJoinRequest } from "./components/submitJoinRequest";
import { hasEntitlement } from "@sourcebot/shared";
import { getPublicAccessStatus } from "@/ee/features/publicAccess/publicAccess";
import { env } from "@/env.mjs";
import { GcpIapAuth } from "./components/gcpIapAuth";
+import { getMemberApprovalRequired } from "@/actions";
+import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
+import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
interface LayoutProps {
children: React.ReactNode,
@@ -34,43 +38,60 @@ export default async function Layout({
return notFound();
}
+ const session = await auth();
const publicAccessEnabled = hasEntitlement("public-access") && await getPublicAccessStatus(domain);
- if (!publicAccessEnabled) {
- const session = await auth();
- if (!session) {
- const ssoEntitlement = await hasEntitlement("sso");
- if (ssoEntitlement && env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) {
- return ;
- } else {
- redirect('/login');
- }
- }
-
+
+ // If the user is authenticated, we must check if they're a member of the org
+ if (session) {
const membership = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
orgId: org.id,
userId: session.user.id
}
- },
+ },
include: {
user: true
}
});
-
+
+ // There's two reasons why a user might not be a member of an org:
+ // 1. The org doesn't require member approval, but the org was at max capacity when the user registered. In this case, we show them
+ // 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) {
- const user = await prisma.user.findUnique({
+ const memberApprovalRequired = await getMemberApprovalRequired(domain);
+ if (!memberApprovalRequired) {
+ return (
+
+
+
+ Security Notice: Authentication data is managed by your deployment and is encrypted at rest. Zero data leaves your deployment.{' '}
+
+ Learn more
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/packages/web/src/app/components/dividerSet.tsx b/packages/web/src/app/components/dividerSet.tsx
new file mode 100644
index 00000000..1312cdde
--- /dev/null
+++ b/packages/web/src/app/components/dividerSet.tsx
@@ -0,0 +1,13 @@
+import { Fragment } from "react";
+import { TextSeparator } from "./textSeparator";
+
+export const DividerSet = ({ elements }: { elements: React.ReactNode[] }) => {
+ return elements.map((child, index) => {
+ return (
+
+ {child}
+ {index < elements.length - 1 && }
+
+ );
+ });
+};
\ No newline at end of file
diff --git a/packages/web/src/app/components/inviteLinkToggle.tsx b/packages/web/src/app/components/inviteLinkToggle.tsx
new file mode 100644
index 00000000..feaef814
--- /dev/null
+++ b/packages/web/src/app/components/inviteLinkToggle.tsx
@@ -0,0 +1,130 @@
+"use client"
+
+import { useState } from "react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Switch } from "@/components/ui/switch"
+import { Copy, Check } from "lucide-react"
+import { useToast } from "@/components/hooks/use-toast"
+import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
+import { setInviteLinkEnabled } from "@/actions"
+import { isServiceError } from "@/lib/utils"
+
+interface InviteLinkToggleProps {
+ inviteLinkEnabled: boolean
+ inviteLink: string | null
+}
+
+export function InviteLinkToggle({ inviteLinkEnabled, inviteLink }: InviteLinkToggleProps) {
+ const [enabled, setEnabled] = useState(inviteLinkEnabled)
+ const [isLoading, setIsLoading] = useState(false)
+ const [copied, setCopied] = useState(false)
+ const { toast } = useToast()
+
+
+ const handleToggle = async (checked: boolean) => {
+ setIsLoading(true)
+ try {
+ const result = await setInviteLinkEnabled(SINGLE_TENANT_ORG_DOMAIN, checked)
+
+ if (isServiceError(result)) {
+ toast({
+ title: "Error",
+ description: "Failed to update invite link setting",
+ variant: "destructive",
+ })
+ return
+ }
+
+ setEnabled(checked)
+
+ } catch (error) {
+ console.error("Error updating invite link setting:", error)
+ toast({
+ title: "Error",
+ description: "Failed to update invite link setting",
+ variant: "destructive",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleCopy = async () => {
+ if (!inviteLink) return
+
+ try {
+ await navigator.clipboard.writeText(inviteLink)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ } catch (err) {
+ console.error("Failed to copy text: ", err)
+ toast({
+ title: "Error",
+ description: "Failed to copy invite link to clipboard",
+ variant: "destructive",
+ })
+ }
+ }
+
+ return (
+
+
+
+
+ Enable invite link
+
+
+
+ When enabled, team members can use the invite link to join your organization without requiring approval.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You can find this link again in the Settings → Members page.
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/app/components/joinOrganizationButton.tsx b/packages/web/src/app/components/joinOrganizationButton.tsx
new file mode 100644
index 00000000..9bed8556
--- /dev/null
+++ b/packages/web/src/app/components/joinOrganizationButton.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { useRouter } from "next/navigation";
+import { useToast } from "@/components/hooks/use-toast";
+import { useState } from "react";
+import { Loader2 } from "lucide-react";
+import { joinOrganization } from "../invite/actions";
+import { isServiceError } from "@/lib/utils";
+import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
+
+export function JoinOrganizationButton({ inviteLinkId }: { inviteLinkId?: string }) {
+ const [isLoading, setIsLoading] = useState(false);
+ const router = useRouter();
+ const { toast } = useToast();
+
+ const handleJoinOrganization = async () => {
+ setIsLoading(true);
+
+ try {
+ const result = await joinOrganization(SINGLE_TENANT_ORG_ID, inviteLinkId);
+
+ if (isServiceError(result)) {
+ toast({
+ title: "Failed to join organization",
+ description: result.message,
+ variant: "destructive",
+ });
+ return;
+ }
+
+ router.refresh();
+ } catch (error) {
+ console.error("Error joining organization:", error);
+ toast({
+ title: "Error",
+ description: "An unexpected error occurred. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/packages/web/src/app/components/joinOrganizationCard.tsx b/packages/web/src/app/components/joinOrganizationCard.tsx
new file mode 100644
index 00000000..bb7b1d39
--- /dev/null
+++ b/packages/web/src/app/components/joinOrganizationCard.tsx
@@ -0,0 +1,23 @@
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { SourcebotLogo } from "@/app/components/sourcebotLogo";
+import { JoinOrganizationButton } from "./joinOrganizationButton";
+
+export function JoinOrganizationCard({ inviteLinkId }: { inviteLinkId?: string }) {
+ return (
+
+
+
+
+
+
+
+
+ Welcome to Sourcebot! Click the button below to join this organization.
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/packages/web/src/app/components/providerButton.tsx b/packages/web/src/app/components/providerButton.tsx
new file mode 100644
index 00000000..e67789d4
--- /dev/null
+++ b/packages/web/src/app/components/providerButton.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { useState } from "react";
+import { cn } from "@/lib/utils";
+import Image from "next/image";
+import { LoadingButton } from "@/components/ui/loading-button";
+
+interface ProviderButtonProps {
+ name: string;
+ logo: { src: string, className?: string } | null;
+ onClick: () => void | Promise;
+ className?: string;
+ context: "login" | "signup";
+}
+
+export const ProviderButton = ({
+ name,
+ logo,
+ onClick,
+ className,
+ context,
+}: ProviderButtonProps) => {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleClick = async () => {
+ setIsLoading(true);
+ try {
+ await onClick();
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ {logo && }
+ {context === "login" ? `Sign in with ${name}` : `Sign up with ${name}`}
+
+ );
+};
\ No newline at end of file
diff --git a/packages/web/src/app/invite/actions.ts b/packages/web/src/app/invite/actions.ts
new file mode 100644
index 00000000..99bb0256
--- /dev/null
+++ b/packages/web/src/app/invite/actions.ts
@@ -0,0 +1,52 @@
+"use server";
+
+import { withAuth } from "@/actions";
+import { isServiceError } from "@/lib/utils";
+import { orgNotFound, ServiceError } from "@/lib/serviceError";
+import { sew } from "@/actions";
+import { addUserToOrganization } from "@/lib/authUtils";
+import { prisma } from "@/prisma";
+import { StatusCodes } from "http-status-codes";
+import { ErrorCode } from "@/lib/errorCodes";
+
+export const joinOrganization = (orgId: number, inviteLinkId?: string) => sew(async () =>
+ withAuth(async (userId) => {
+ const org = await prisma.org.findUnique({
+ where: {
+ id: orgId,
+ },
+ });
+
+ if (!org) {
+ return orgNotFound();
+ }
+
+ // If member approval is required we must be using a valid invite link
+ if (org.memberApprovalRequired) {
+ if (!org.inviteLinkEnabled) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVITE_LINK_NOT_ENABLED,
+ message: "Invite link is not enabled.",
+ } satisfies ServiceError;
+ }
+
+ if (org.inviteLinkId !== inviteLinkId) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_INVITE_LINK,
+ message: "Invalid invite link.",
+ } satisfies ServiceError;
+ }
+ }
+
+ const addUserToOrgRes = await addUserToOrganization(userId, org.id);
+ if (isServiceError(addUserToOrgRes)) {
+ return addUserToOrgRes;
+ }
+
+ return {
+ success: true,
+ }
+ })
+)
\ No newline at end of file
diff --git a/packages/web/src/app/invite/page.tsx b/packages/web/src/app/invite/page.tsx
new file mode 100644
index 00000000..92fa01cf
--- /dev/null
+++ b/packages/web/src/app/invite/page.tsx
@@ -0,0 +1,86 @@
+import { auth } from "@/auth";
+import { prisma } from "@/prisma";
+import { getOrgFromDomain } from "@/data/org";
+import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
+import { notFound, redirect } from "next/navigation";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { SourcebotLogo } from "@/app/components/sourcebotLogo";
+import { AuthMethodSelector } from "@/app/components/authMethodSelector";
+import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
+import { getAuthProviders } from "@/lib/authProviders";
+import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
+
+interface InvitePageProps {
+ searchParams: {
+ id?: string;
+ };
+}
+
+export default async function InvitePage({ searchParams }: InvitePageProps) {
+ const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
+ if (!org || !org.isOnboarded) {
+ return redirect("/onboard");
+ }
+
+ const inviteLinkId = searchParams.id;
+ if (!org.inviteLinkEnabled || !inviteLinkId || org.inviteLinkId !== inviteLinkId) {
+ return notFound();
+ }
+
+ const session = await auth();
+ if (!session) {
+ const providers = getAuthProviders();
+ return ;
+ }
+
+ const membership = await prisma.userToOrg.findUnique({
+ where: {
+ orgId_userId: {
+ orgId: org.id,
+ userId: session.user.id
+ }
+ }
+ });
+
+ // If already a member, redirect to the organization
+ if (membership) {
+ redirect(`/${SINGLE_TENANT_ORG_DOMAIN}`);
+ }
+
+ // User is logged in but not a member, show join invitation
+ return (
+
+ When enabled, new users will need approval from an organization owner before they can access your deployment.{" "}
+
+ Learn More
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/app/onboard/components/onboardHeader.tsx b/packages/web/src/app/onboard/components/onboardHeader.tsx
deleted file mode 100644
index 17281d76..00000000
--- a/packages/web/src/app/onboard/components/onboardHeader.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { SourcebotLogo } from "@/app/components/sourcebotLogo"
-import { OnboardingSteps } from "@/lib/constants";
-import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
-
-interface OnboardHeaderProps {
- title: string
- description: string
- step: OnboardingSteps
-}
-
-export const OnboardHeader = ({ title, description, step: currentStep }: OnboardHeaderProps) => {
- const steps = Object.values(OnboardingSteps)
- .filter(s => s !== OnboardingSteps.Complete)
- .filter(s => !IS_BILLING_ENABLED ? s !== OnboardingSteps.Checkout : true);
-
- return (
-
-
-
- {title}
-
-
- {description}
-
-
- {steps.map((step, index) => (
-
- ))}
-
-
- )
-}
\ No newline at end of file
diff --git a/packages/web/src/app/onboard/components/orgCreateForm.tsx b/packages/web/src/app/onboard/components/orgCreateForm.tsx
deleted file mode 100644
index 371db309..00000000
--- a/packages/web/src/app/onboard/components/orgCreateForm.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-"use client"
-
-import { createOrg } from "../../../actions"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from "@/components/ui/form"
-import { useForm } from "react-hook-form"
-import { z } from "zod"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useCallback, useState } from "react";
-import { isServiceError } from "@/lib/utils"
-import { Loader2 } from "lucide-react"
-import { useToast } from "@/components/hooks/use-toast"
-import { useRouter } from "next/navigation";
-import { Card } from "@/components/ui/card"
-import useCaptureEvent from "@/hooks/useCaptureEvent";
-import { orgNameSchema, orgDomainSchema } from "@/lib/schemas"
-
-interface OrgCreateFormProps {
- rootDomain: string;
-}
-
-export function OrgCreateForm({ rootDomain }: OrgCreateFormProps) {
- const { toast } = useToast();
- const router = useRouter();
- const captureEvent = useCaptureEvent();
- const [isLoading, setIsLoading] = useState(false);
-
- const onboardingFormSchema = z.object({
- name: orgNameSchema,
- domain: orgDomainSchema,
- })
-
- const form = useForm>({
- resolver: zodResolver(onboardingFormSchema),
- defaultValues: {
- name: "",
- domain: "",
- }
- });
-
- const onSubmit = useCallback(async (data: z.infer) => {
- setIsLoading(true);
- const response = await createOrg(data.name, data.domain);
- if (isServiceError(response)) {
- toast({
- description: `❌ Failed to create organization. Reason: ${response.message}`
- })
- captureEvent('wa_onboard_org_create_fail', {
- error: response.errorCode,
- })
- setIsLoading(false);
- } else {
- router.push(`/${data.domain}/onboard`);
- captureEvent('wa_onboard_org_create_success', {});
- // @note: we don't want to set isLoading to false here since we want to show the loading
- // spinner until the page is redirected.
- }
- }, [router, toast, captureEvent]);
-
- const handleNameChange = (e: React.ChangeEvent) => {
- const name = e.target.value
- const domain = name.toLowerCase().replace(/[^a-zA-Z\s]/g, "").replace(/\s+/g, "-")
- form.setValue("domain", domain)
- }
-
- return (
-
-
-
-
- )
-}
diff --git a/packages/web/src/app/onboard/page.tsx b/packages/web/src/app/onboard/page.tsx
index 9389a794..fb7677b5 100644
--- a/packages/web/src/app/onboard/page.tsx
+++ b/packages/web/src/app/onboard/page.tsx
@@ -1,28 +1,410 @@
-import { OrgCreateForm } from "./components/orgCreateForm";
-import { auth } from "@/auth";
-import { redirect } from "next/navigation";
-import { OnboardHeader } from "./components/onboardHeader";
-import { OnboardingSteps } from "@/lib/constants";
-import { LogoutEscapeHatch } from "../components/logoutEscapeHatch";
-import { headers } from "next/headers";
+import type React from "react"
-export default async function Onboarding() {
+import { Card, CardContent } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { AuthMethodSelector } from "@/app/components/authMethodSelector"
+import { SourcebotLogo } from "@/app/components/sourcebotLogo"
+import { auth } from "@/auth";
+import { getAuthProviders } from "@/lib/authProviders";
+import { MemberApprovalRequiredToggle } from "./components/memberApprovalRequiredToggle";
+import { CompleteOnboardingButton } from "./components/completeOnboardingButton";
+import { getOrgFromDomain } from "@/data/org";
+import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
+import { prisma } from "@/prisma";
+import { OrgRole } from "@sourcebot/db";
+import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
+import { redirect } from "next/navigation";
+import { BetweenHorizonalStart, GitBranchIcon, LockIcon } from "lucide-react";
+import { hasEntitlement } from "@sourcebot/shared";
+import { env } from "@/env.mjs";
+import { GcpIapAuth } from "@/app/[domain]/components/gcpIapAuth";
+import { headers } from "next/headers";
+import { getBaseUrl, createInviteLink } from "@/lib/utils";
+
+interface OnboardingProps {
+ searchParams?: { step?: string };
+}
+
+interface OnboardingStep {
+ id: string
+ title: string
+ subtitle: React.ReactNode
+ component: React.ReactNode
+}
+
+interface ResourceCard {
+ id: string
+ title: string
+ description: string
+ href: string
+ icon?: React.ReactNode
+}
+
+export default async function Onboarding({ searchParams }: OnboardingProps) {
+ const providers = getAuthProviders();
+ const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
const session = await auth();
- if (!session) {
- redirect("/login");
+
+ if (!org) {
+ return
Error loading organization
;
}
- const host = (await headers()).get('host') ?? '';
+ // Get the current URL to construct the full invite link
+ const headersList = headers();
+ const baseUrl = getBaseUrl(headersList);
+ const inviteLink = createInviteLink(baseUrl, org.inviteLinkId);
+
+ if (org && org.isOnboarded) {
+ redirect('/');
+ }
+
+ // Check if user is authenticated but not the owner
+ if (session?.user) {
+ if (org) {
+ const membership = await prisma.userToOrg.findUnique({
+ where: {
+ orgId_userId: {
+ orgId: org.id,
+ userId: session.user.id
+ }
+ }
+ });
+
+ if (!membership || membership.role !== OrgRole.OWNER) {
+ return ;
+ }
+ }
+ }
+
+ // If we're using an IAP bridge we need to sign them in now and then redirect them back to the onboarding page
+ const ssoEntitlement = await hasEntitlement("sso");
+ if (ssoEntitlement && env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) {
+ return ;
+ }
+
+ // Determine current step based on URL parameter and authentication state
+ const stepParam = searchParams?.step ? parseInt(searchParams.step) : 0;
+ const currentStep = session?.user ? Math.max(2, stepParam) : Math.max(0, Math.min(stepParam, 1));
+
+ const resourceCards: ResourceCard[] = [
+ {
+ id: "code-host-connections",
+ title: "Code Host Connections",
+ description: "Learn how to index repos across Sourcebot's supported platforms",
+ href: "https://docs.sourcebot.dev/docs/connections/overview",
+ icon: ,
+ },
+ {
+ id: "authentication-system",
+ title: "Authentication System",
+ description: "Learn how to setup additional auth providers, invite members, and more",
+ href: "https://docs.sourcebot.dev/docs/configuration/auth",
+ icon: ,
+ },
+ {
+ id: "mcp-server",
+ title: "MCP Server",
+ description: "Learn how to setup Sourcebot's MCP server to provide code context to your AI agents",
+ href: "https://docs.sourcebot.dev/docs/features/mcp-server",
+ icon: ,
+ }
+ ]
+
+ const steps: OnboardingStep[] = [
+ {
+ id: "welcome",
+ title: "Welcome to Sourcebot",
+ subtitle: "This onboarding flow will guide you through creating your owner account and configuring your organization.",
+ component: (
+
+
+
+ ),
+ },
+ {
+ id: "owner-signup",
+ title: "Create Owner Account",
+ subtitle: (
+ <>
+ Use your preferred authentication method to create your owner account. To set up additional authentication providers, check out our{" "}
+
+ documentation
+ .
+ >
+ ),
+ component: (
+
+
+
+ ),
+ },
+ {
+ id: "configure-org",
+ title: "Configure Your Organization",
+ subtitle: "Set up your organization's security settings.",
+ component: (
+
+
+
+
+ ),
+ },
+ {
+ id: "complete",
+ title: "You're All Set!",
+ subtitle: (
+ <>
+ Your Sourcebot deployment is ready. Check out these resources to learn how to get the most out of Sourcebot.
+