(null);
@@ -67,7 +65,6 @@ export const FileTreeItemComponent = ({
}
}}
onClick={onClick}
- onMouseEnter={onMouseEnter}
>
& {
@@ -14,11 +13,11 @@ export type FileTreeNode = Omit & {
children: FileTreeNode[];
}
-const buildCollapsableTree = (tree: RawFileTreeNode): FileTreeNode => {
+const buildCollapsibleTree = (tree: RawFileTreeNode): FileTreeNode => {
return {
...tree,
isCollapsed: true,
- children: tree.children.map(buildCollapsableTree),
+ children: tree.children.map(buildCollapsibleTree),
}
}
@@ -40,16 +39,15 @@ interface PureFileTreePanelProps {
}
export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps) => {
- const [tree, setTree] = useState(buildCollapsableTree(_tree));
+ const [tree, setTree] = useState(buildCollapsibleTree(_tree));
const scrollAreaRef = useRef(null);
const { navigateToPath } = useBrowseNavigation();
const { repoName, revisionName } = useBrowseParams();
- const { prefetchFileSource } = usePrefetchFileSource();
// @note: When `_tree` changes, it indicates that a new tree has been loaded.
- // In that case, we need to rebuild the collapsable tree.
+ // In that case, we need to rebuild the collapsible tree.
useEffect(() => {
- setTree(buildCollapsableTree(_tree));
+ setTree(buildCollapsibleTree(_tree));
}, [_tree]);
const setIsCollapsed = useCallback((path: string, isCollapsed: boolean) => {
@@ -89,18 +87,6 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps)
}
}, [setIsCollapsed, navigateToPath, repoName, revisionName]);
- // @note: We prefetch the file source when the user hovers over a file.
- // This is to try and mitigate having a loading spinner appear when
- // the user clicks on a file to open it.
- // @see: /browse/[...path]/page.tsx
- const onNodeMouseEnter = useCallback((node: FileTreeNode) => {
- if (node.type !== 'blob') {
- return;
- }
-
- prefetchFileSource(repoName, revisionName ?? 'HEAD', node.path);
- }, [prefetchFileSource, repoName, revisionName]);
-
const renderTree = useCallback((nodes: FileTreeNode, depth = 0): React.ReactNode => {
return (
<>
@@ -115,7 +101,6 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps)
isCollapsed={node.isCollapsed}
isCollapseChevronVisible={node.type === 'tree'}
onClick={() => onNodeClicked(node)}
- onMouseEnter={() => onNodeMouseEnter(node)}
parentRef={scrollAreaRef}
/>
{node.children.length > 0 && !node.isCollapsed && renderTree(node, depth + 1)}
@@ -124,7 +109,7 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps)
})}
>
);
- }, [path, onNodeClicked, onNodeMouseEnter]);
+ }, [path, onNodeClicked]);
const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]);
diff --git a/packages/web/src/hooks/usePrefetchFileSource.ts b/packages/web/src/hooks/usePrefetchFileSource.ts
deleted file mode 100644
index de9cce34..00000000
--- a/packages/web/src/hooks/usePrefetchFileSource.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-'use client';
-
-import { useQueryClient } from "@tanstack/react-query";
-import { useDomain } from "./useDomain";
-import { unwrapServiceError } from "@/lib/utils";
-import { getFileSource } from "@/features/search/fileSourceApi";
-import { useDebounceCallback } from "usehooks-ts";
-
-interface UsePrefetchFileSourceProps {
- debounceDelay?: number;
- staleTime?: number;
-}
-
-export const usePrefetchFileSource = ({
- debounceDelay = 200,
- staleTime = 5 * 60 * 1000, // 5 minutes
-}: UsePrefetchFileSourceProps = {}) => {
- const queryClient = useQueryClient();
- const domain = useDomain();
-
- const prefetchFileSource = useDebounceCallback((repoName: string, revisionName: string, path: string) => {
- queryClient.prefetchQuery({
- queryKey: ['fileSource', repoName, revisionName, path, domain],
- queryFn: () => unwrapServiceError(getFileSource({
- fileName: path,
- repository: repoName,
- branch: revisionName,
- }, domain)),
- staleTime,
- });
- }, debounceDelay);
-
- return { prefetchFileSource };
-}
\ No newline at end of file
diff --git a/packages/web/src/hooks/usePrefetchFolderContents.ts b/packages/web/src/hooks/usePrefetchFolderContents.ts
deleted file mode 100644
index e135cb7a..00000000
--- a/packages/web/src/hooks/usePrefetchFolderContents.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-'use client';
-
-import { useQueryClient } from "@tanstack/react-query";
-import { useDomain } from "./useDomain";
-import { unwrapServiceError } from "@/lib/utils";
-import { getFolderContents } from "@/features/fileTree/actions";
-import { useDebounceCallback } from "usehooks-ts";
-
-interface UsePrefetchFolderContentsProps {
- debounceDelay?: number;
- staleTime?: number;
-}
-
-export const usePrefetchFolderContents = ({
- debounceDelay = 200,
- staleTime = 5 * 60 * 1000, // 5 minutes
-}: UsePrefetchFolderContentsProps = {}) => {
- const queryClient = useQueryClient();
- const domain = useDomain();
-
- const prefetchFolderContents = useDebounceCallback((repoName: string, revisionName: string, path: string) => {
- queryClient.prefetchQuery({
- queryKey: ['tree', repoName, revisionName, path, domain],
- queryFn: () => unwrapServiceError(
- getFolderContents({
- repoName,
- revisionName,
- path,
- }, domain)
- ),
- staleTime,
- });
- }, debounceDelay);
-
- return { prefetchFolderContents };
-}
\ No newline at end of file
diff --git a/packages/web/src/initialize.ts b/packages/web/src/initialize.ts
index 975fa83d..02b74485 100644
--- a/packages/web/src/initialize.ts
+++ b/packages/web/src/initialize.ts
@@ -114,7 +114,7 @@ const syncDeclarativeConfig = async (configPath: string) => {
if (hasPublicAccessEntitlement) {
if (enablePublicAccess && env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true') {
- logger.error(`Audit logging is not supported when public access is enabled. Please disable audit logging or disable public access.`);
+ logger.error(`Audit logging is not supported when public access is enabled. Please disable audit logging (SOURCEBOT_EE_AUDIT_LOGGING_ENABLED) or disable public access.`);
process.exit(1);
}
@@ -159,15 +159,32 @@ const pruneOldGuestUser = async () => {
}
const initSingleTenancy = async () => {
- await prisma.org.upsert({
- where: {
- id: SINGLE_TENANT_ORG_ID,
- },
- update: {},
- create: {
- name: SINGLE_TENANT_ORG_NAME,
- domain: SINGLE_TENANT_ORG_DOMAIN,
- id: SINGLE_TENANT_ORG_ID
+ // Back fill the inviteId if the org has already been created to prevent needing to wipe the db
+ await prisma.$transaction(async (tx) => {
+ const org = await tx.org.findUnique({
+ where: {
+ id: SINGLE_TENANT_ORG_ID,
+ },
+ });
+
+ if (!org) {
+ await tx.org.create({
+ data: {
+ id: SINGLE_TENANT_ORG_ID,
+ name: SINGLE_TENANT_ORG_NAME,
+ domain: SINGLE_TENANT_ORG_DOMAIN,
+ inviteLinkId: crypto.randomUUID(),
+ }
+ });
+ } else if (!org.inviteLinkId) {
+ await tx.org.update({
+ where: {
+ id: SINGLE_TENANT_ORG_ID,
+ },
+ data: {
+ inviteLinkId: crypto.randomUUID(),
+ }
+ });
}
});
@@ -186,17 +203,6 @@ const initSingleTenancy = async () => {
// Load any connections defined declaratively in the config file.
const configPath = env.CONFIG_PATH;
if (configPath) {
- // If we're given a config file, mark the org as onboarded so we don't go through
- // the UI connection onboarding flow
- await prisma.org.update({
- where: {
- id: SINGLE_TENANT_ORG_ID,
- },
- data: {
- isOnboarded: true,
- }
- });
-
await syncDeclarativeConfig(configPath);
// watch for changes assuming it is a local file
diff --git a/packages/web/src/lib/authProviders.ts b/packages/web/src/lib/authProviders.ts
new file mode 100644
index 00000000..ca2a6697
--- /dev/null
+++ b/packages/web/src/lib/authProviders.ts
@@ -0,0 +1,18 @@
+import { getProviders } from "@/auth";
+
+export interface AuthProvider {
+ id: string;
+ name: string;
+}
+
+export const getAuthProviders = (): AuthProvider[] => {
+ const providers = getProviders();
+ return providers.map((provider) => {
+ if (typeof provider === "function") {
+ const providerInfo = provider();
+ return { id: providerInfo.id, name: providerInfo.name };
+ } else {
+ return { id: provider.id, name: provider.name };
+ }
+ });
+};
\ No newline at end of file
diff --git a/packages/web/src/lib/authUtils.ts b/packages/web/src/lib/authUtils.ts
index 2a158c8d..e67f5c2a 100644
--- a/packages/web/src/lib/authUtils.ts
+++ b/packages/web/src/lib/authUtils.ts
@@ -1,15 +1,16 @@
import type { User as AuthJsUser } from "next-auth";
-import { env } from "@/env.mjs";
import { prisma } from "@/prisma";
import { OrgRole } from "@sourcebot/db";
-import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID } from "@/lib/constants";
-import { hasEntitlement } from "@sourcebot/shared";
+import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
+import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
import { isServiceError } from "@/lib/utils";
-import { ServiceErrorException } from "@/lib/serviceError";
-import { createAccountRequest } from "@/actions";
-import { handleJITProvisioning } from "@/ee/features/sso/sso";
+import { orgNotFound, ServiceError, userNotFound } from "@/lib/serviceError";
import { createLogger } from "@sourcebot/logger";
import { getAuditService } from "@/ee/features/audit/factory";
+import { StatusCodes } from "http-status-codes";
+import { ErrorCode } from "./errorCodes";
+import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
+import { incrementOrgSeatCount } from "@/ee/features/billing/serverUtils";
const logger = createLogger('web-auth-utils');
const auditService = getAuditService();
@@ -27,7 +28,7 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
id: "undefined",
type: "user"
},
- orgId: SINGLE_TENANT_ORG_ID, // TODO(mt)
+ orgId: SINGLE_TENANT_ORG_ID,
metadata: {
message: "User ID is undefined on user creation"
}
@@ -35,158 +36,216 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
throw new Error("User ID is undefined on user creation");
}
- // In single-tenant mode, we assign the first user to sign
- // up as the owner of the default org.
- if (env.SOURCEBOT_TENANCY_MODE === 'single') {
- const defaultOrg = await prisma.org.findUnique({
- where: {
- id: SINGLE_TENANT_ORG_ID,
- },
- include: {
- members: {
- where: {
- role: {
- not: OrgRole.GUEST,
- }
+ const defaultOrg = await prisma.org.findUnique({
+ where: {
+ id: SINGLE_TENANT_ORG_ID,
+ },
+ include: {
+ members: {
+ where: {
+ role: {
+ not: OrgRole.GUEST,
}
- },
+ }
+ },
+ }
+ });
+
+ // We expect the default org to have been created on app initialization
+ if (defaultOrg === null) {
+ await auditService.createAudit({
+ action: "user.creation_failed",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ target: {
+ id: user.id,
+ type: "user"
+ },
+ orgId: SINGLE_TENANT_ORG_ID,
+ metadata: {
+ message: "Default org not found on single tenant user creation"
}
});
+ throw new Error("Default org not found on single tenant user creation");
+ }
- if (defaultOrg === null) {
- await auditService.createAudit({
- action: "user.creation_failed",
- actor: {
- id: user.id,
- type: "user"
+ // If this is the first user to sign up, we make them the owner of the default org.
+ const isFirstUser = defaultOrg.members.length === 0;
+ if (isFirstUser) {
+ await prisma.$transaction(async (tx) => {
+ await tx.org.update({
+ where: {
+ id: SINGLE_TENANT_ORG_ID,
},
- target: {
- id: user.id,
- type: "user"
- },
- orgId: SINGLE_TENANT_ORG_ID,
- metadata: {
- message: "Default org not found on single tenant user creation"
- }
- });
- throw new Error("Default org not found on single tenant user creation");
- }
-
- // Only the first user to sign up will be an owner of the default org.
- const isFirstUser = defaultOrg.members.length === 0;
- if (isFirstUser) {
- await prisma.$transaction(async (tx) => {
- await tx.org.update({
- where: {
- id: SINGLE_TENANT_ORG_ID,
- },
- data: {
- members: {
- create: {
- role: OrgRole.OWNER,
- user: {
- connect: {
- id: user.id,
- }
+ data: {
+ members: {
+ create: {
+ role: OrgRole.OWNER,
+ user: {
+ connect: {
+ id: user.id,
}
}
}
}
- });
-
- await tx.user.update({
- where: {
- id: user.id,
- },
- data: {
- pendingApproval: false,
- }
- });
+ }
});
+ });
- await auditService.createAudit({
- action: "user.owner_created",
- actor: {
- id: user.id,
- type: "user"
- },
+ await auditService.createAudit({
+ action: "user.owner_created",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ orgId: SINGLE_TENANT_ORG_ID,
+ target: {
+ id: SINGLE_TENANT_ORG_ID.toString(),
+ type: "org"
+ }
+ });
+ } else if (!defaultOrg.memberApprovalRequired) {
+ const hasAvailability = await orgHasAvailability(defaultOrg.domain);
+ if (!hasAvailability) {
+ logger.warn(`onCreateUser: org ${SINGLE_TENANT_ORG_ID} has reached max capacity. User ${user.id} was not added to the org.`);
+ return;
+ }
+
+ await prisma.userToOrg.create({
+ data: {
+ userId: user.id,
orgId: SINGLE_TENANT_ORG_ID,
- target: {
- id: SINGLE_TENANT_ORG_ID.toString(),
- type: "org"
- }
- });
- } else {
- // TODO(auth): handle multi tenant case
- if (env.AUTH_EE_ENABLE_JIT_PROVISIONING === 'true' && hasEntitlement("sso")) {
- const res = await handleJITProvisioning(user.id, SINGLE_TENANT_ORG_DOMAIN);
- if (isServiceError(res)) {
- logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`);
- await auditService.createAudit({
- action: "user.jit_provisioning_failed",
- actor: {
- id: user.id,
- type: "user"
- },
- target: {
- id: SINGLE_TENANT_ORG_ID.toString(),
- type: "org"
- },
- orgId: SINGLE_TENANT_ORG_ID,
- metadata: {
- message: `Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`
- }
- });
- throw new ServiceErrorException(res);
- }
+ role: OrgRole.MEMBER,
+ }
+ });
+ }
- await auditService.createAudit({
- action: "user.jit_provisioned",
- actor: {
- id: user.id,
- type: "user"
- },
- target: {
- id: SINGLE_TENANT_ORG_ID.toString(),
- type: "org"
- },
- orgId: SINGLE_TENANT_ORG_ID,
- });
- } else {
- const res = await createAccountRequest(user.id, SINGLE_TENANT_ORG_DOMAIN);
- if (isServiceError(res)) {
- logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`);
- await auditService.createAudit({
- action: "user.join_request_creation_failed",
- actor: {
- id: user.id,
- type: "user"
- },
- target: {
- id: SINGLE_TENANT_ORG_ID.toString(),
- type: "org"
- },
- orgId: SINGLE_TENANT_ORG_ID,
- metadata: {
- message: res.message
- }
- });
- throw new ServiceErrorException(res);
- }
+};
- await auditService.createAudit({
- action: "user.join_requested",
- actor: {
- id: user.id,
- type: "user"
- },
- orgId: SINGLE_TENANT_ORG_ID,
- target: {
- id: SINGLE_TENANT_ORG_ID.toString(),
- type: "org"
- },
- });
+export const orgHasAvailability = async (domain: string): Promise => {
+ const org = await prisma.org.findUnique({
+ where: {
+ domain,
+ },
+ });
+
+ if (!org) {
+ logger.error(`orgHasAvailability: org not found for domain ${domain}`);
+ return false;
+ }
+ const members = await prisma.userToOrg.findMany({
+ where: {
+ orgId: org.id,
+ role: {
+ not: OrgRole.GUEST,
+ },
+ },
+ });
+
+ const maxSeats = getSeats();
+ const memberCount = members.length;
+
+ if (maxSeats !== SOURCEBOT_UNLIMITED_SEATS && memberCount >= maxSeats) {
+ logger.error(`orgHasAvailability: org ${org.id} has reached max capacity`);
+ return false;
+ }
+
+ return true;
+}
+
+export const addUserToOrganization = async (userId: string, orgId: number): Promise<{ success: boolean } | ServiceError> => {
+ const user = await prisma.user.findUnique({
+ where: {
+ id: userId,
+ },
+ });
+
+ if (!user) {
+ logger.error(`addUserToOrganization: user not found for id ${userId}`);
+ return userNotFound();
+ }
+
+ const org = await prisma.org.findUnique({
+ where: {
+ id: orgId,
+ },
+ });
+
+ if (!org) {
+ logger.error(`addUserToOrganization: org not found for id ${orgId}`);
+ return orgNotFound();
+ }
+
+ const hasAvailability = await orgHasAvailability(org.domain);
+ if (!hasAvailability) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
+ message: "Organization is at max capacity",
+ } satisfies ServiceError;
+ }
+
+ const res = await prisma.$transaction(async (tx) => {
+ await tx.userToOrg.create({
+ data: {
+ userId: user.id,
+ orgId: org.id,
+ role: OrgRole.MEMBER,
+ }
+ });
+
+ if (IS_BILLING_ENABLED) {
+ const result = await incrementOrgSeatCount(orgId, tx);
+ if (isServiceError(result)) {
+ throw result;
}
}
+
+ // Delete the account request if it exists since we've added the user to the org
+ const accountRequest = await tx.accountRequest.findUnique({
+ where: {
+ requestedById_orgId: {
+ requestedById: user.id,
+ orgId: orgId,
+ }
+ },
+ });
+
+ if (accountRequest) {
+ logger.info(`Deleting account request ${accountRequest.id} for user ${user.id} since they've been added to the org`);
+ await tx.accountRequest.delete({
+ where: {
+ id: accountRequest.id,
+ }
+ });
+ }
+
+ // Delete any invites that may exist for this user since we've added them to the org
+ const invites = await tx.invite.findMany({
+ where: {
+ recipientEmail: user.email!,
+ orgId: org.id,
+ },
+ })
+
+ for (const invite of invites) {
+ logger.info(`Deleting invite ${invite.id} for ${user.email} since they've been added to the org`);
+ await tx.invite.delete({
+ where: {
+ id: invite.id,
+ },
+ });
+ }
+ });
+
+ if (isServiceError(res)) {
+ logger.error(`addUserToOrganization: failed to add user ${userId} to org ${orgId}: ${res.message}`);
+ return res;
}
-};
\ No newline at end of file
+
+ return {
+ success: true,
+ }
+};
\ No newline at end of file
diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts
index 85cc2d08..da2c0bd0 100644
--- a/packages/web/src/lib/errorCodes.ts
+++ b/packages/web/src/lib/errorCodes.ts
@@ -7,16 +7,19 @@ export enum ErrorCode {
INVALID_REQUEST_BODY = 'INVALID_REQUEST_BODY',
NOT_AUTHENTICATED = 'NOT_AUTHENTICATED',
NOT_FOUND = 'NOT_FOUND',
+ USER_NOT_FOUND = 'USER_NOT_FOUND',
+ ORG_NOT_FOUND = 'ORG_NOT_FOUND',
CONNECTION_SYNC_ALREADY_SCHEDULED = 'CONNECTION_SYNC_ALREADY_SCHEDULED',
ORG_DOMAIN_ALREADY_EXISTS = 'ORG_DOMAIN_ALREADY_EXISTS',
ORG_INVALID_SUBSCRIPTION = 'ORG_INVALID_SUBSCRIPTION',
- MEMBER_NOT_FOUND = 'MEMBER_NOT_FOUND',
INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
CONNECTION_NOT_FAILED = 'CONNECTION_NOT_FAILED',
CONNECTION_ALREADY_EXISTS = 'CONNECTION_ALREADY_EXISTS',
OWNER_CANNOT_LEAVE_ORG = 'OWNER_CANNOT_LEAVE_ORG',
INVALID_INVITE = 'INVALID_INVITE',
+ INVALID_INVITE_LINK = 'INVALID_INVITE_LINK',
+ INVITE_LINK_NOT_ENABLED = 'INVITE_LINK_NOT_ENABLED',
STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR',
SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS',
SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS',
diff --git a/packages/web/src/lib/newsData.ts b/packages/web/src/lib/newsData.ts
index 7eb20e5c..79f533c5 100644
--- a/packages/web/src/lib/newsData.ts
+++ b/packages/web/src/lib/newsData.ts
@@ -1,6 +1,12 @@
import { NewsItem } from "./types";
export const newsData: NewsItem[] = [
+ {
+ unique_id: "member-approval",
+ header: "Member Approval",
+ sub_header: "We've added a toggle to control whether new users need to be approved.",
+ url: "https://docs.sourcebot.dev/docs/configuration/auth/inviting-members"
+ },
{
unique_id: "analytics",
header: "Analytics Dashboard",
@@ -9,25 +15,25 @@ export const newsData: NewsItem[] = [
},
{
unique_id: "audit-logs",
- header: "Audit logs",
+ header: "Audit Logs",
sub_header: "We've added support for audit logs",
url: "https://docs.sourcebot.dev/docs/configuration/audit-logs"
},
{
unique_id: "file-explorer",
- header: "File explorer",
+ header: "File Explorer",
sub_header: "We've added support for a file explorer when browsing files.",
url: "https://github.com/sourcebot-dev/sourcebot/releases/tag/v4.2.0"
},
{
unique_id: "structured-logging",
- header: "Structured logging",
+ header: "Structured Logging",
sub_header: "We've added support for structured logging",
url: "https://docs.sourcebot.dev/docs/configuration/structured-logging"
},
{
unique_id: "code-nav",
- header: "Code navigation",
+ header: "Code Navigation",
sub_header: "Built in go-to definition and find references",
url: "https://docs.sourcebot.dev/docs/features/code-navigation"
},
@@ -39,7 +45,7 @@ export const newsData: NewsItem[] = [
},
{
unique_id: "search-contexts",
- header: "Search contexts",
+ header: "Search Contexts",
sub_header: "Filter searches by groups of repos",
url: "https://docs.sourcebot.dev/docs/features/search/search-contexts"
}
diff --git a/packages/web/src/lib/serviceError.ts b/packages/web/src/lib/serviceError.ts
index 71132673..051672e1 100644
--- a/packages/web/src/lib/serviceError.ts
+++ b/packages/web/src/lib/serviceError.ts
@@ -96,6 +96,22 @@ export const notFound = (message?: string): ServiceError => {
}
}
+export const userNotFound = (): ServiceError => {
+ return {
+ statusCode: StatusCodes.NOT_FOUND,
+ errorCode: ErrorCode.USER_NOT_FOUND,
+ message: "User not found",
+ }
+}
+
+export const orgNotFound = (): ServiceError => {
+ return {
+ statusCode: StatusCodes.NOT_FOUND,
+ errorCode: ErrorCode.ORG_NOT_FOUND,
+ message: "Organization not found",
+ }
+}
+
export const orgDomainExists = (): ServiceError => {
return {
statusCode: StatusCodes.CONFLICT,
diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts
index 3526217a..1a3ff68c 100644
--- a/packages/web/src/lib/utils.ts
+++ b/packages/web/src/lib/utils.ts
@@ -19,6 +19,27 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+/**
+ * Gets the base URL from Next.js headers
+ * @param headersList The headers from Next.js headers() function
+ * @returns The base URL (e.g., "https://example.com")
+ */
+export const getBaseUrl = (headersList: Headers): string => {
+ const host = headersList.get('host') || 'localhost:3000';
+ const protocol = headersList.get('x-forwarded-proto') || 'http';
+ return `${protocol}://${host}`;
+}
+
+/**
+ * Creates an invite link URL from the base URL and invite ID
+ * @param baseUrl The base URL of the application
+ * @param inviteLinkId The invite link ID
+ * @returns The complete invite link URL or null if no inviteLinkId
+ */
+export const createInviteLink = (baseUrl: string, inviteLinkId?: string | null): string | null => {
+ return inviteLinkId ? `${baseUrl}/invite?id=${inviteLinkId}` : null;
+}
+
/**
* Adds a list of (potentially undefined) query parameters to a path.
*
@@ -119,7 +140,7 @@ export const getAuthProviderInfo = (providerId: string): AuthProviderInfo => {
id: "microsoft-entra-id",
name: "Microsoft Entra ID",
displayName: "Microsoft Entra ID",
- icon: {
+ icon: {
src: microsoftLogo,
},
};
diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts
index b373ff2f..5e4c5f1c 100644
--- a/packages/web/src/middleware.ts
+++ b/packages/web/src/middleware.ts
@@ -13,7 +13,9 @@ export async function middleware(request: NextRequest) {
if (
url.pathname.startsWith('/login') ||
url.pathname.startsWith('/redeem') ||
- url.pathname.startsWith('/signup')
+ url.pathname.startsWith('/signup') ||
+ url.pathname.startsWith('/invite') ||
+ url.pathname.startsWith('/onboard')
) {
return NextResponse.next();
}
diff --git a/schemas/v2/index.json b/schemas/v2/index.json
index fc04e9d8..67334c2a 100644
--- a/schemas/v2/index.json
+++ b/schemas/v2/index.json
@@ -494,7 +494,7 @@
"type": "string",
"pattern": ".+"
},
- "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.",
+ "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always excluded.",
"default": [],
"examples": [
[