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 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 }) => {

View file

@ -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) => {
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
<FormLabel>Email Address</FormLabel>
<FormDescription>{`Invite members to access your organization's Sourcebot instance.`}</FormDescription>
{form.watch('emails').map((_, index) => (
<FormField
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 (
<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"
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 { OrgRole } from "@sourcebot/db"
import { useEffect, useState } from "react"
import { Mail } from "lucide-react"
import { useForm } from "react-hook-form"
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 { useState } from "react"
import { useForm } from "react-hook-form"
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<string>("")
const [isLoading, setIsLoading] = useState(false)
const { toast } = useToast()
const captureEvent = useCaptureEvent();
export function ChangeBillingEmailCard({ currentUserRole, billingEmail }: ChangeBillingEmailCardProps) {
const domain = useDomain()
const [isLoading, setIsLoading] = useState(false)
const { toast } = useToast()
const captureEvent = useCaptureEvent();
const router = useRouter()
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
},
})
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: billingEmail,
},
})
useEffect(() => {
const fetchBillingEmail = async () => {
const email = await getSubscriptionBillingEmail(domain)
if (!isServiceError(email)) {
setBillingEmail(email)
} else {
captureEvent('wa_billing_email_fetch_fail', {
error: email.errorCode,
})
}
const onSubmit = async (values: z.infer<typeof formSchema>) => {
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)
}
fetchBillingEmail()
}, [domain, captureEvent])
const onSubmit = async (values: z.infer<typeof formSchema>) => {
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,
})
}
setIsLoading(false)
}
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Billing Email
</CardTitle>
<CardDescription>The email address for your billing account</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email address</FormLabel>
<FormControl>
<Input
placeholder={billingEmail}
{...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>
)
return (
<Card className="w-full">
<CardHeader className="flex flex-col gap-4">
<CardTitle className="flex items-center gap-2">
Billing Email
</CardTitle>
<CardDescription>The email address for your billing account</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
placeholder={billingEmail}
className="max-w-md"
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 && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
)
}

View file

@ -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 && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Manage Subscription
<ExternalLink className="ml-2 h-4 w-4" />
</Button>
</div>
)

View file

@ -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({
</div>
<div className="grid gap-6">
{/* Billing Email Card */}
<ChangeBillingEmailCard currentUserRole={currentUserRole} />
<ChangeBillingEmailCard billingEmail={billingEmail} currentUserRole={currentUserRole} />
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CreditCard className="h-5 w-5" />
Subscription Plan
</CardTitle>
<CardDescription>

View file

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

View file

@ -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<z.infer<typeof onboardingFormSchema>>({
@ -80,13 +65,14 @@ export function OrgCreateForm() {
return (
<Card className="flex flex-col border p-8 bg-background w-full max-w-md">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-10">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormItem className="flex flex-col gap-2">
<FormLabel>Organization Name</FormLabel>
<FormDescription>{`Your organization's visible name within Sourcebot. For example, the name of your company or department.`}</FormDescription>
<FormControl>
<Input
placeholder="Aperture Labs"
@ -106,12 +92,17 @@ export function OrgCreateForm() {
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel>Organization Domain</FormLabel>
<FormItem className="flex flex-col gap-2">
<FormLabel>Organization URL</FormLabel>
<FormDescription>{`Your organization's URL namespace. This is where your organization's Sourcebot instance will be accessible.`}</FormDescription>
<FormControl>
<div className="flex items-center space-x-2 w-full">
<div className="flex-shrink-0 text-sm text-muted-foreground">{NEXT_PUBLIC_ROOT_DOMAIN}/</div>
<Input placeholder="aperture-labs" {...field} className="flex-1" />
<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="aperture-labs"
{...field}
className="flex-1 rounded-l-none"
/>
</div>
</FormControl>
<FormMessage />

View file

@ -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;

View file

@ -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.");