diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 733e73a0..ac229058 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -24,7 +24,7 @@ import Stripe from "stripe"; import { render } from "@react-email/components"; import InviteUserEmail from "./emails/inviteUserEmail"; import { createTransport } from "nodemailer"; -import { repositoryQuerySchema } from "./lib/schemas"; +import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; import { RepositoryQuery } from "./lib/types"; import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "./lib/constants"; @@ -117,6 +117,52 @@ export const createOrg = (name: string, domain: string): Promise<{ id: number } } }); +export const updateOrgName = async (name: string, domain: string) => + withAuth((session) => + withOrgMembership(session, domain, async ({ orgId }) => { + const { success } = orgNameSchema.safeParse(name); + if (!success) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: "Invalid organization url", + } satisfies ServiceError; + } + + await prisma.org.update({ + where: { id: orgId }, + data: { name }, + }); + + return { + success: true, + } + }, /* minRequiredRole = */ OrgRole.OWNER) + ) + +export const updateOrgDomain = async (newDomain: string, existingDomain: string) => + withAuth((session) => + withOrgMembership(session, existingDomain, async ({ orgId }) => { + const { success } = await orgDomainSchema.safeParseAsync(newDomain); + if (!success) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: "Invalid organization url", + } satisfies ServiceError; + } + + await prisma.org.update({ + where: { id: orgId }, + data: { domain: newDomain }, + }); + + return { + success: true, + } + }, /* minRequiredRole = */ OrgRole.OWNER), + ) + export const completeOnboarding = async (domain: string): Promise<{ success: boolean } | ServiceError> => withAuth((session) => withOrgMembership(session, domain, async ({ orgId }) => { diff --git a/packages/web/src/app/[domain]/onboard/components/inviteTeam.tsx b/packages/web/src/app/[domain]/onboard/components/inviteTeam.tsx index c4505e17..2aa9bb9c 100644 --- a/packages/web/src/app/[domain]/onboard/components/inviteTeam.tsx +++ b/packages/web/src/app/[domain]/onboard/components/inviteTeam.tsx @@ -3,7 +3,7 @@ import { createInvites } from "@/actions"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter } from "@/components/ui/card"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { isServiceError } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -77,6 +77,7 @@ export const InviteTeam = ({ nextStep }: InviteTeamProps) => {
Email Address + {`Invite members to access your organization's Sourcebot instance.`} {form.watch('emails').map((_, index) => ( >({ + resolver: zodResolver(formSchema), + defaultValues: { + domain: orgDomain, + }, + }) + const { isSubmitting } = form.formState; + + const onSubmit = useCallback(async (data: z.infer) => { + const result = await updateOrgDomain(data.domain, domain); + if (isServiceError(result)) { + toast({ + description: `❌ Failed to update organization url. Reason: ${result.message}`, + }) + captureEvent('wa_org_domain_updated_fail', { + error: result.errorCode, + }); + } else { + toast({ + description: "✅ Organization url updated successfully", + }); + captureEvent('wa_org_domain_updated_success', {}); + router.replace(`/${data.domain}/settings`); + } + }, [domain, router, toast, captureEvent]); + + return ( + <> + + + + Organization URL + + {`Your organization's URL namespace. This is where your organization's Sourcebot instance will be accessible.`} + + + + + ( + + +
+
{NEXT_PUBLIC_ROOT_DOMAIN}/
+ +
+
+ +
+ )} + /> +
+ + + + + + + Are you sure? + + Any links pointing to the current organization URL will no longer work. + + + + Cancel + { + e.preventDefault(); + form.handleSubmit(onSubmit)(e); + setIsDialogOpen(false); + }} + > + Continue + + + + + +
+ + +
+
+ + + ) +} diff --git a/packages/web/src/app/[domain]/settings/(general)/components/changeOrgNameCard.tsx b/packages/web/src/app/[domain]/settings/(general)/components/changeOrgNameCard.tsx new file mode 100644 index 00000000..3c88d10e --- /dev/null +++ b/packages/web/src/app/[domain]/settings/(general)/components/changeOrgNameCard.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { updateOrgName } from "@/actions"; +import { useToast } from "@/components/hooks/use-toast"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { useDomain } from "@/hooks/useDomain"; +import { orgNameSchema } from "@/lib/schemas"; +import { isServiceError } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { OrgRole } from "@sourcebot/db"; +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +const formSchema = z.object({ + name: orgNameSchema, +}) + +interface ChangeOrgNameCardProps { + currentUserRole: OrgRole, + orgName: string, +} + +export function ChangeOrgNameCard({ orgName, currentUserRole }: ChangeOrgNameCardProps) { + const domain = useDomain() + const { toast } = useToast() + const captureEvent = useCaptureEvent(); + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: orgName, + }, + }) + const { isSubmitting } = form.formState; + + const onSubmit = useCallback(async (data: z.infer) => { + const result = await updateOrgName(data.name, domain); + if (isServiceError(result)) { + toast({ + description: `❌ Failed to update organization name. Reason: ${result.message}`, + }) + captureEvent('wa_org_name_updated_fail', { + error: result.errorCode, + }); + } else { + toast({ + description: "✅ Organization name updated successfully", + }); + captureEvent('wa_org_name_updated_success', {}); + router.refresh(); + } + }, [domain, router, toast, captureEvent]); + + return ( + + + + Organization Name + + {`Your organization's visible name within Sourceobot. For example, the name of your company or department.`} + + +
+ + ( + + + + + + + )} + /> +
+ +
+ + +
+
+ ) +} + diff --git a/packages/web/src/app/[domain]/settings/(general)/page.tsx b/packages/web/src/app/[domain]/settings/(general)/page.tsx index 0f643c2b..e6d4c554 100644 --- a/packages/web/src/app/[domain]/settings/(general)/page.tsx +++ b/packages/web/src/app/[domain]/settings/(general)/page.tsx @@ -1,7 +1,47 @@ +import { auth } from "@/auth"; +import { ChangeOrgNameCard } from "./components/changeOrgNameCard"; +import { isServiceError } from "@/lib/utils"; +import { getCurrentUserRole } from "@/actions"; +import { getOrgFromDomain } from "@/data/org"; +import { ChangeOrgDomainCard } from "./components/changeOrgDomainCard"; +interface GeneralSettingsPageProps { + params: { + domain: string; + } +} + +export default async function GeneralSettingsPage({ params: { domain } }: GeneralSettingsPageProps) { + const session = await auth(); + if (!session) { + return null; + } + + const currentUserRole = await getCurrentUserRole(domain) + if (isServiceError(currentUserRole)) { + return
Failed to fetch user role. Please contact us at team@sourcebot.dev if this issue persists.
+ } + + const org = await getOrgFromDomain(domain) + if (!org) { + return
Failed to fetch organization. Please contact us at team@sourcebot.dev if this issue persists.
+ } -export default async function GeneralSettingsPage() { return ( -

todo

+
+
+

General Settings

+
+ + + + +
) } diff --git a/packages/web/src/app/[domain]/settings/billing/changeBillingEmailCard.tsx b/packages/web/src/app/[domain]/settings/billing/changeBillingEmailCard.tsx index 49a90c25..878ef20e 100644 --- a/packages/web/src/app/[domain]/settings/billing/changeBillingEmailCard.tsx +++ b/packages/web/src/app/[domain]/settings/billing/changeBillingEmailCard.tsx @@ -1,121 +1,109 @@ "use client" +import { changeSubscriptionBillingEmail } from "@/actions" +import { useToast } from "@/components/hooks/use-toast" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form" import { Input } from "@/components/ui/input" -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" -import { changeSubscriptionBillingEmail, getSubscriptionBillingEmail } from "@/actions" -import { isServiceError } from "@/lib/utils" +import useCaptureEvent from "@/hooks/useCaptureEvent" import { useDomain } from "@/hooks/useDomain" +import { isServiceError } from "@/lib/utils" +import { zodResolver } from "@hookform/resolvers/zod" import { OrgRole } from "@sourcebot/db" -import { useEffect, useState } from "react" -import { Mail } from "lucide-react" +import { Loader2 } from "lucide-react" +import { useRouter } from "next/navigation" +import { useState } from "react" import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" import * as z from "zod" -import { useToast } from "@/components/hooks/use-toast"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; + const formSchema = z.object({ - email: z.string().email("Please enter a valid email address"), + email: z.string().email("Please enter a valid email address"), }) interface ChangeBillingEmailCardProps { - currentUserRole: OrgRole + currentUserRole: OrgRole, + billingEmail: string } -export function ChangeBillingEmailCard({ currentUserRole }: ChangeBillingEmailCardProps) { - const domain = useDomain() - const [billingEmail, setBillingEmail] = useState("") - const [isLoading, setIsLoading] = useState(false) - const { toast } = useToast() - const captureEvent = useCaptureEvent(); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - email: "", - }, - }) +export function ChangeBillingEmailCard({ currentUserRole, billingEmail }: ChangeBillingEmailCardProps) { + const domain = useDomain() + const [isLoading, setIsLoading] = useState(false) + const { toast } = useToast() + const captureEvent = useCaptureEvent(); + const router = useRouter() - useEffect(() => { - const fetchBillingEmail = async () => { - const email = await getSubscriptionBillingEmail(domain) - if (!isServiceError(email)) { - setBillingEmail(email) - } else { - captureEvent('wa_billing_email_fetch_fail', { - error: email.errorCode, - }) - } - } - fetchBillingEmail() - }, [domain, captureEvent]) + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: billingEmail, + }, + }) - const onSubmit = async (values: z.infer) => { - setIsLoading(true) - const newEmail = values.email || billingEmail - const result = await changeSubscriptionBillingEmail(domain, newEmail) - if (!isServiceError(result)) { - setBillingEmail(newEmail) - form.reset({ email: "" }) - toast({ - description: "✅ Billing email updated successfully!", - }) - captureEvent('wa_billing_email_updated_success', {}) - } else { - toast({ - description: "❌ Failed to update billing email. Please try again.", - }) - captureEvent('wa_billing_email_updated_fail', { - error: result.message, - }) + const onSubmit = async (values: z.infer) => { + setIsLoading(true) + const newEmail = values.email || billingEmail + const result = await changeSubscriptionBillingEmail(domain, newEmail) + if (!isServiceError(result)) { + toast({ + description: "✅ Billing email updated successfully!", + }) + captureEvent('wa_billing_email_updated_success', {}) + router.refresh() + } else { + toast({ + description: "❌ Failed to update billing email. Please try again.", + }) + captureEvent('wa_billing_email_updated_fail', { + error: result.message, + }) + } + setIsLoading(false) } - setIsLoading(false) - } - return ( - - - - - Billing Email - - The email address for your billing account - - -
- - ( - - Email address - - - - - - )} - /> -
- -
- - -
-
- ) + return ( + + + + Billing Email + + The email address for your billing account + + +
+ + ( + + + + + + + )} + /> +
+ +
+ + +
+
+ ) } diff --git a/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx b/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx index ec7a7b4b..9a6af18f 100644 --- a/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx +++ b/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx @@ -8,29 +8,27 @@ import { getCustomerPortalSessionLink } from "@/actions" import { useDomain } from "@/hooks/useDomain"; import { OrgRole } from "@sourcebot/db"; import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { ExternalLink, Loader2 } from "lucide-react"; + export function ManageSubscriptionButton({ currentUserRole }: { currentUserRole: OrgRole }) { const [isLoading, setIsLoading] = useState(false) const router = useRouter() const domain = useDomain(); const captureEvent = useCaptureEvent(); + const redirectToCustomerPortal = async () => { setIsLoading(true) - try { - const session = await getCustomerPortalSessionLink(domain) - if (isServiceError(session)) { - captureEvent('wa_manage_subscription_button_create_portal_session_fail', { - error: session.errorCode, - }) - } else { - router.push(session) - captureEvent('wa_manage_subscription_button_create_portal_session_success', {}) - } - } catch (_error) { + const session = await getCustomerPortalSessionLink(domain); + if (isServiceError(session)) { captureEvent('wa_manage_subscription_button_create_portal_session_fail', { - error: "Unknown error", - }) - } finally { - setIsLoading(false) + error: session.errorCode, + }); + setIsLoading(false); + } else { + captureEvent('wa_manage_subscription_button_create_portal_session_success', {}) + router.push(session) + // @note: we don't want to set isLoading to false here since we want to show the loading + // spinner until the page is redirected. } } @@ -42,7 +40,9 @@ export function ManageSubscriptionButton({ currentUserRole }: { currentUserRole: disabled={isLoading || !isOwner} title={!isOwner ? "Only the owner of the org can manage the subscription" : undefined} > - {isLoading ? "Creating customer portal..." : "Manage Subscription"} + {isLoading && } + Manage Subscription + ) diff --git a/packages/web/src/app/[domain]/settings/billing/page.tsx b/packages/web/src/app/[domain]/settings/billing/page.tsx index 53401cbd..dd35af44 100644 --- a/packages/web/src/app/[domain]/settings/billing/page.tsx +++ b/packages/web/src/app/[domain]/settings/billing/page.tsx @@ -1,12 +1,10 @@ import type { Metadata } from "next" import { CalendarIcon, DollarSign, Users } from "lucide-react" - import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { ManageSubscriptionButton } from "./manageSubscriptionButton" import { getSubscriptionData, getCurrentUserRole, getSubscriptionBillingEmail } from "@/actions" import { isServiceError } from "@/lib/utils" import { ChangeBillingEmailCard } from "./changeBillingEmailCard" -import { CreditCard } from "lucide-react" export const metadata: Metadata = { title: "Billing | Settings", @@ -50,11 +48,10 @@ export default async function BillingPage({
{/* Billing Email Card */} - + - Subscription Plan diff --git a/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx b/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx index 65d99584..4b1c73c8 100644 --- a/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx +++ b/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx @@ -101,6 +101,7 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) => diff --git a/packages/web/src/app/onboard/components/orgCreateForm.tsx b/packages/web/src/app/onboard/components/orgCreateForm.tsx index 47255be9..46d1da28 100644 --- a/packages/web/src/app/onboard/components/orgCreateForm.tsx +++ b/packages/web/src/app/onboard/components/orgCreateForm.tsx @@ -1,9 +1,9 @@ "use client" -import { checkIfOrgDomainExists, createOrg } from "../../../actions" +import { createOrg } from "../../../actions" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form" +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" @@ -15,6 +15,7 @@ import { useRouter } from "next/navigation"; import { Card } from "@/components/ui/card" import { NEXT_PUBLIC_ROOT_DOMAIN } from "@/lib/environment.client"; import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { orgNameSchema, orgDomainSchema } from "@/lib/schemas" export function OrgCreateForm() { @@ -24,24 +25,8 @@ export function OrgCreateForm() { const [isLoading, setIsLoading] = useState(false); const onboardingFormSchema = z.object({ - name: z.string() - .min(2, { message: "Organization name must be at least 3 characters long." }) - .max(30, { message: "Organization name must be at most 30 characters long." }), - domain: z.string() - .min(2, { message: "Organization domain must be at least 3 characters long." }) - .max(20, { message: "Organization domain must be at most 20 characters long." }) - .regex(/^[a-z][a-z-]*[a-z]$/, { - message: "Domain must start and end with a letter, and can only contain lowercase letters and dashes.", - }) - .refine(async (domain) => { - const doesDomainExist = await checkIfOrgDomainExists(domain); - if (!isServiceError(doesDomainExist)) { - captureEvent('wa_onboard_org_create_fail', { - error: "Domain already exists", - }) - } - return isServiceError(doesDomainExist) || !doesDomainExist; - }, "This domain is already taken."), + name: orgNameSchema, + domain: orgDomainSchema, }) const form = useForm>({ @@ -80,13 +65,14 @@ export function OrgCreateForm() { return (
- + ( - + Organization Name + {`Your organization's visible name within Sourcebot. For example, the name of your company or department.`} ( - - Organization Domain + + Organization URL + {`Your organization's URL namespace. This is where your organization's Sourcebot instance will be accessible.`} -
-
{NEXT_PUBLIC_ROOT_DOMAIN}/
- +
+
{NEXT_PUBLIC_ROOT_DOMAIN}/
+
diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index 80f17662..735220d0 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -221,6 +221,16 @@ export type PosthogEventMap = { ////////////////////////////////////////////////////////////////// wa_mobile_unsupported_splash_screen_dismissed: {}, wa_mobile_unsupported_splash_screen_displayed: {}, + ////////////////////////////////////////////////////////////////// + wa_org_name_updated_success: {}, + wa_org_name_updated_fail: { + error: string, + }, + ////////////////////////////////////////////////////////////////// + wa_org_domain_updated_success: {}, + wa_org_domain_updated_fail: { + error: string, + }, } export type PosthogEvent = keyof PosthogEventMap; \ No newline at end of file diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index 07b8e4cd..b61464ae 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -1,5 +1,7 @@ +import { checkIfOrgDomainExists } from "@/actions"; import { RepoIndexingStatus } from "@sourcebot/db"; import { z } from "zod"; +import { isServiceError } from "./utils"; export const searchRequestSchema = z.object({ query: z.string(), maxMatchDisplayCount: z.number(), @@ -188,3 +190,34 @@ export const verifyCredentialsResponseSchema = z.object({ email: z.string().optional(), image: z.string().optional(), }); + +export const orgNameSchema = z.string().min(2, { message: "Organization name must be at least 3 characters long." }); + +export const orgDomainSchema = z.string() + .min(2, { message: "Url must be at least 3 characters long." }) + .max(50, { message: "Url must be at most 50 characters long." }) + .regex(/^[a-z][a-z-]*[a-z]$/, { + message: "Url must start and end with a letter, and can only contain lowercase letters and dashes.", + }) + .refine((domain) => { + const reserved = [ + 'api', + 'login', + 'signup', + 'onboard', + 'redeem', + 'account', + 'settings', + 'staging', + 'support', + 'docs', + 'blog', + 'contact', + 'status' + ]; + return !reserved.includes(domain); + }, "This url is reserved for internal use.") + .refine(async (domain) => { + const doesDomainExist = await checkIfOrgDomainExists(domain); + return isServiceError(doesDomainExist) || !doesDomainExist; + }, "This url is already taken."); \ No newline at end of file