General settings + cleanup (#221)

* General settings

* Add alert to org domain change
This commit is contained in:
Brendan Kellam 2025-02-28 15:58:49 -08:00 committed by GitHub
parent 072f77b19a
commit 041eab14eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 504 additions and 152 deletions

View file

@ -24,7 +24,7 @@ import Stripe from "stripe";
import { render } from "@react-email/components"; import { render } from "@react-email/components";
import InviteUserEmail from "./emails/inviteUserEmail"; import InviteUserEmail from "./emails/inviteUserEmail";
import { createTransport } from "nodemailer"; import { createTransport } from "nodemailer";
import { repositoryQuerySchema } from "./lib/schemas"; import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
import { RepositoryQuery } from "./lib/types"; import { RepositoryQuery } from "./lib/types";
import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "./lib/constants"; 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> => export const completeOnboarding = async (domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) => withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => { withOrgMembership(session, domain, async ({ orgId }) => {

View file

@ -3,7 +3,7 @@
import { createInvites } from "@/actions"; import { createInvites } from "@/actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card"; 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 { Input } from "@/components/ui/input";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@ -77,6 +77,7 @@ export const InviteTeam = ({ nextStep }: InviteTeamProps) => {
<form onSubmit={form.handleSubmit(onSubmit)}> <form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<FormLabel>Email Address</FormLabel> <FormLabel>Email Address</FormLabel>
<FormDescription>{`Invite members to access your organization's Sourcebot instance.`}</FormDescription>
{form.watch('emails').map((_, index) => ( {form.watch('emails').map((_, index) => (
<FormField <FormField
key={index} key={index}

View file

@ -0,0 +1,138 @@
'use client';
import { updateOrgDomain } from "@/actions";
import { useToast } from "@/components/hooks/use-toast";
import { AlertDialog, AlertDialogFooter, AlertDialogHeader, AlertDialogContent, AlertDialogAction, AlertDialogCancel, AlertDialogDescription, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
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 useCaptureEvent from "@/hooks/useCaptureEvent";
import { useDomain } from "@/hooks/useDomain";
import { NEXT_PUBLIC_ROOT_DOMAIN } from "@/lib/environment.client";
import { orgDomainSchema } from "@/lib/schemas";
import { isServiceError } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { OrgRole } from "@sourcebot/db";
import { Loader2, TriangleAlert } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
const formSchema = z.object({
domain: orgDomainSchema,
})
interface ChangeOrgDomainCardProps {
currentUserRole: OrgRole,
orgDomain: string,
}
export function ChangeOrgDomainCard({ orgDomain, currentUserRole }: ChangeOrgDomainCardProps) {
const domain = useDomain()
const { toast } = useToast()
const captureEvent = useCaptureEvent();
const router = useRouter();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
domain: orgDomain,
},
})
const { isSubmitting } = form.formState;
const onSubmit = useCallback(async (data: z.infer<typeof formSchema>) => {
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 (
<>
<Card className="w-full">
<CardHeader className="flex flex-col gap-4">
<CardTitle className="flex items-center gap-2">
Organization URL
</CardTitle>
<CardDescription>{`Your organization's URL namespace. This is where your organization's Sourcebot instance will be accessible.`}</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center w-full">
<div className="flex-shrink-0 text-sm text-muted-foreground bg-backgroundSecondary rounded-md rounded-r-none border border-r-0 px-3 py-[9px]">{NEXT_PUBLIC_ROOT_DOMAIN}/</div>
<Input
placeholder={orgDomain}
{...field}
disabled={currentUserRole !== OrgRole.OWNER}
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the organization url" : undefined}
className="flex-1 rounded-l-none max-w-xs"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<AlertDialogTrigger asChild>
<Button
disabled={isSubmitting || currentUserRole !== OrgRole.OWNER}
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the organization url" : undefined}
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2"><TriangleAlert className="h-4 w-4 text-destructive" /> Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
Any links pointing to the current organization URL will <strong>no longer work</strong>.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
form.handleSubmit(onSubmit)(e);
setIsDialogOpen(false);
}}
>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</form>
</Form>
</CardContent>
</Card>
</>
)
}

View file

@ -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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: orgName,
},
})
const { isSubmitting } = form.formState;
const onSubmit = useCallback(async (data: z.infer<typeof formSchema>) => {
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 (
<Card>
<CardHeader className="flex flex-col gap-4">
<CardTitle>
Organization Name
</CardTitle>
<CardDescription>{`Your organization's visible name within Sourceobot. For example, the name of your company or department.`}</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
placeholder={orgName}
className="max-w-sm"
disabled={currentUserRole !== OrgRole.OWNER}
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the organization name" : undefined}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={isSubmitting || currentUserRole !== OrgRole.OWNER}
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the organization name" : undefined}
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
)
}

View file

@ -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 <div>Failed to fetch user role. Please contact us at team@sourcebot.dev if this issue persists.</div>
}
const org = await getOrgFromDomain(domain)
if (!org) {
return <div>Failed to fetch organization. Please contact us at team@sourcebot.dev if this issue persists.</div>
}
export default async function GeneralSettingsPage() {
return ( return (
<p>todo</p> <div className="flex flex-col gap-6">
<div>
<h3 className="text-lg font-medium">General Settings</h3>
</div>
<ChangeOrgNameCard
orgName={org.name}
currentUserRole={currentUserRole}
/>
<ChangeOrgDomainCard
orgDomain={org.domain}
currentUserRole={currentUserRole}
/>
</div>
) )
} }

View file

@ -1,121 +1,109 @@
"use client" "use client"
import { changeSubscriptionBillingEmail } from "@/actions"
import { useToast } from "@/components/hooks/use-toast"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 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 { Input } from "@/components/ui/input"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" import useCaptureEvent from "@/hooks/useCaptureEvent"
import { changeSubscriptionBillingEmail, getSubscriptionBillingEmail } from "@/actions"
import { isServiceError } from "@/lib/utils"
import { useDomain } from "@/hooks/useDomain" import { useDomain } from "@/hooks/useDomain"
import { OrgRole } from "@sourcebot/db" import { isServiceError } from "@/lib/utils"
import { useEffect, useState } from "react"
import { Mail } from "lucide-react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { OrgRole } from "@sourcebot/db"
import { Loader2 } from "lucide-react"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { useForm } from "react-hook-form"
import * as z from "zod" import * as z from "zod"
import { useToast } from "@/components/hooks/use-toast";
import useCaptureEvent from "@/hooks/useCaptureEvent";
const formSchema = z.object({ 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 { interface ChangeBillingEmailCardProps {
currentUserRole: OrgRole currentUserRole: OrgRole,
billingEmail: string
} }
export function ChangeBillingEmailCard({ currentUserRole }: ChangeBillingEmailCardProps) { export function ChangeBillingEmailCard({ currentUserRole, billingEmail }: ChangeBillingEmailCardProps) {
const domain = useDomain() const domain = useDomain()
const [billingEmail, setBillingEmail] = useState<string>("") const [isLoading, setIsLoading] = useState(false)
const [isLoading, setIsLoading] = useState(false) const { toast } = useToast()
const { toast } = useToast() const captureEvent = useCaptureEvent();
const captureEvent = useCaptureEvent(); const router = useRouter()
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
email: "", email: billingEmail,
}, },
}) })
useEffect(() => { const onSubmit = async (values: z.infer<typeof formSchema>) => {
const fetchBillingEmail = async () => { setIsLoading(true)
const email = await getSubscriptionBillingEmail(domain) const newEmail = values.email || billingEmail
if (!isServiceError(email)) { const result = await changeSubscriptionBillingEmail(domain, newEmail)
setBillingEmail(email) if (!isServiceError(result)) {
} else { toast({
captureEvent('wa_billing_email_fetch_fail', { description: "✅ Billing email updated successfully!",
error: email.errorCode, })
}) 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)
} }
fetchBillingEmail()
}, [domain, captureEvent])
const onSubmit = async (values: z.infer<typeof formSchema>) => { return (
setIsLoading(true) <Card className="w-full">
const newEmail = values.email || billingEmail <CardHeader className="flex flex-col gap-4">
const result = await changeSubscriptionBillingEmail(domain, newEmail) <CardTitle className="flex items-center gap-2">
if (!isServiceError(result)) { Billing Email
setBillingEmail(newEmail) </CardTitle>
form.reset({ email: "" }) <CardDescription>The email address for your billing account</CardDescription>
toast({ </CardHeader>
description: "✅ Billing email updated successfully!", <CardContent>
}) <Form {...form}>
captureEvent('wa_billing_email_updated_success', {}) <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
} else { <FormField
toast({ control={form.control}
description: "❌ Failed to update billing email. Please try again.", name="email"
}) render={({ field }) => (
captureEvent('wa_billing_email_updated_fail', { <FormItem>
error: result.message, <FormControl>
}) <Input
} {...field}
setIsLoading(false) placeholder={billingEmail}
} className="max-w-md"
disabled={currentUserRole !== OrgRole.OWNER}
return ( title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
<Card className="w-full"> />
<CardHeader> </FormControl>
<CardTitle className="flex items-center gap-2"> <FormMessage />
<Mail className="h-5 w-5" /> </FormItem>
Billing Email )}
</CardTitle> />
<CardDescription>The email address for your billing account</CardDescription> <div className="flex justify-end">
</CardHeader> <Button
<CardContent> type="submit"
<Form {...form}> disabled={isLoading || currentUserRole !== OrgRole.OWNER}
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
<FormField >
control={form.control} {isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
name="email" Save
render={({ field }) => ( </Button>
<FormItem> </div>
<FormLabel>Email address</FormLabel> </form>
<FormControl> </Form>
<Input </CardContent>
placeholder={billingEmail} </Card>
{...field} )
disabled={currentUserRole !== OrgRole.OWNER}
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={isLoading || currentUserRole !== OrgRole.OWNER}
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
>
{isLoading ? "Updating..." : "Update Billing Email"}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
)
} }

View file

@ -8,29 +8,27 @@ import { getCustomerPortalSessionLink } from "@/actions"
import { useDomain } from "@/hooks/useDomain"; import { useDomain } from "@/hooks/useDomain";
import { OrgRole } from "@sourcebot/db"; import { OrgRole } from "@sourcebot/db";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
import { ExternalLink, Loader2 } from "lucide-react";
export function ManageSubscriptionButton({ currentUserRole }: { currentUserRole: OrgRole }) { export function ManageSubscriptionButton({ currentUserRole }: { currentUserRole: OrgRole }) {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const router = useRouter() const router = useRouter()
const domain = useDomain(); const domain = useDomain();
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
const redirectToCustomerPortal = async () => { const redirectToCustomerPortal = async () => {
setIsLoading(true) setIsLoading(true)
try { const session = await getCustomerPortalSessionLink(domain);
const session = await getCustomerPortalSessionLink(domain) if (isServiceError(session)) {
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) {
captureEvent('wa_manage_subscription_button_create_portal_session_fail', { captureEvent('wa_manage_subscription_button_create_portal_session_fail', {
error: "Unknown error", error: session.errorCode,
}) });
} finally { setIsLoading(false);
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} disabled={isLoading || !isOwner}
title={!isOwner ? "Only the owner of the org can manage the subscription" : undefined} title={!isOwner ? "Only the owner of the org can manage the subscription" : undefined}
> >
{isLoading ? "Creating customer portal..." : "Manage Subscription"} {isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Manage Subscription
<ExternalLink className="ml-2 h-4 w-4" />
</Button> </Button>
</div> </div>
) )

View file

@ -1,12 +1,10 @@
import type { Metadata } from "next" import type { Metadata } from "next"
import { CalendarIcon, DollarSign, Users } from "lucide-react" import { CalendarIcon, DollarSign, Users } from "lucide-react"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { ManageSubscriptionButton } from "./manageSubscriptionButton" import { ManageSubscriptionButton } from "./manageSubscriptionButton"
import { getSubscriptionData, getCurrentUserRole, getSubscriptionBillingEmail } from "@/actions" import { getSubscriptionData, getCurrentUserRole, getSubscriptionBillingEmail } from "@/actions"
import { isServiceError } from "@/lib/utils" import { isServiceError } from "@/lib/utils"
import { ChangeBillingEmailCard } from "./changeBillingEmailCard" import { ChangeBillingEmailCard } from "./changeBillingEmailCard"
import { CreditCard } from "lucide-react"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Billing | Settings", title: "Billing | Settings",
@ -50,11 +48,10 @@ export default async function BillingPage({
</div> </div>
<div className="grid gap-6"> <div className="grid gap-6">
{/* Billing Email Card */} {/* Billing Email Card */}
<ChangeBillingEmailCard currentUserRole={currentUserRole} /> <ChangeBillingEmailCard billingEmail={billingEmail} currentUserRole={currentUserRole} />
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<CreditCard className="h-5 w-5" />
Subscription Plan Subscription Plan
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>

View file

@ -101,6 +101,7 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) =>
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
className="max-w-md"
placeholder="melissa@example.com" placeholder="melissa@example.com"
/> />
</FormControl> </FormControl>

View file

@ -1,9 +1,9 @@
"use client" "use client"
import { checkIfOrgDomainExists, createOrg } from "../../../actions" import { createOrg } from "../../../actions"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" 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 { useForm } from "react-hook-form"
import { z } from "zod" import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
@ -15,6 +15,7 @@ import { useRouter } from "next/navigation";
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card"
import { NEXT_PUBLIC_ROOT_DOMAIN } from "@/lib/environment.client"; import { NEXT_PUBLIC_ROOT_DOMAIN } from "@/lib/environment.client";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
import { orgNameSchema, orgDomainSchema } from "@/lib/schemas"
export function OrgCreateForm() { export function OrgCreateForm() {
@ -24,24 +25,8 @@ export function OrgCreateForm() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const onboardingFormSchema = z.object({ const onboardingFormSchema = z.object({
name: z.string() name: orgNameSchema,
.min(2, { message: "Organization name must be at least 3 characters long." }) domain: orgDomainSchema,
.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."),
}) })
const form = useForm<z.infer<typeof onboardingFormSchema>>({ const form = useForm<z.infer<typeof onboardingFormSchema>>({
@ -80,13 +65,14 @@ export function OrgCreateForm() {
return ( return (
<Card className="flex flex-col border p-8 bg-background w-full max-w-md"> <Card className="flex flex-col border p-8 bg-background w-full max-w-md">
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-10">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem className="flex flex-col gap-2">
<FormLabel>Organization Name</FormLabel> <FormLabel>Organization Name</FormLabel>
<FormDescription>{`Your organization's visible name within Sourcebot. For example, the name of your company or department.`}</FormDescription>
<FormControl> <FormControl>
<Input <Input
placeholder="Aperture Labs" placeholder="Aperture Labs"
@ -106,12 +92,17 @@ export function OrgCreateForm() {
control={form.control} control={form.control}
name="domain" name="domain"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem className="flex flex-col gap-2">
<FormLabel>Organization Domain</FormLabel> <FormLabel>Organization URL</FormLabel>
<FormDescription>{`Your organization's URL namespace. This is where your organization's Sourcebot instance will be accessible.`}</FormDescription>
<FormControl> <FormControl>
<div className="flex items-center space-x-2 w-full"> <div className="flex items-center w-full">
<div className="flex-shrink-0 text-sm text-muted-foreground">{NEXT_PUBLIC_ROOT_DOMAIN}/</div> <div className="flex-shrink-0 text-sm text-muted-foreground bg-backgroundSecondary rounded-md rounded-r-none border border-r-0 px-3 py-[9px]">{NEXT_PUBLIC_ROOT_DOMAIN}/</div>
<Input placeholder="aperture-labs" {...field} className="flex-1" /> <Input
placeholder="aperture-labs"
{...field}
className="flex-1 rounded-l-none"
/>
</div> </div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

View file

@ -221,6 +221,16 @@ export type PosthogEventMap = {
////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////
wa_mobile_unsupported_splash_screen_dismissed: {}, wa_mobile_unsupported_splash_screen_dismissed: {},
wa_mobile_unsupported_splash_screen_displayed: {}, 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; export type PosthogEvent = keyof PosthogEventMap;

View file

@ -1,5 +1,7 @@
import { checkIfOrgDomainExists } from "@/actions";
import { RepoIndexingStatus } from "@sourcebot/db"; import { RepoIndexingStatus } from "@sourcebot/db";
import { z } from "zod"; import { z } from "zod";
import { isServiceError } from "./utils";
export const searchRequestSchema = z.object({ export const searchRequestSchema = z.object({
query: z.string(), query: z.string(),
maxMatchDisplayCount: z.number(), maxMatchDisplayCount: z.number(),
@ -188,3 +190,34 @@ export const verifyCredentialsResponseSchema = z.object({
email: z.string().optional(), email: z.string().optional(),
image: 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.");