mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 12:25:22 +00:00
General settings + cleanup (#221)
* General settings * Add alert to org domain change
This commit is contained in:
parent
072f77b19a
commit
041eab14eb
12 changed files with 504 additions and 152 deletions
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) =>
|
|||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="max-w-md"
|
||||
placeholder="melissa@example.com"
|
||||
/>
|
||||
</FormControl>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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.");
|
||||
Loading…
Reference in a new issue