Skip to content

General settings + cleanup #221

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Feb 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion packages/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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>

</>
)
}
Original file line number Diff line number Diff line change
@@ -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>
)
}

44 changes: 42 additions & 2 deletions packages/web/src/app/[domain]/settings/(general)/page.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}

Loading