Skip to content

Commit 041eab1

Browse files
General settings + cleanup (#221)
* General settings * Add alert to org domain change
1 parent 072f77b commit 041eab1

File tree

12 files changed

+503
-151
lines changed

12 files changed

+503
-151
lines changed

packages/web/src/actions.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import Stripe from "stripe";
2424
import { render } from "@react-email/components";
2525
import InviteUserEmail from "./emails/inviteUserEmail";
2626
import { createTransport } from "nodemailer";
27-
import { repositoryQuerySchema } from "./lib/schemas";
27+
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
2828
import { RepositoryQuery } from "./lib/types";
2929
import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "./lib/constants";
3030

@@ -117,6 +117,52 @@ export const createOrg = (name: string, domain: string): Promise<{ id: number }
117117
}
118118
});
119119

120+
export const updateOrgName = async (name: string, domain: string) =>
121+
withAuth((session) =>
122+
withOrgMembership(session, domain, async ({ orgId }) => {
123+
const { success } = orgNameSchema.safeParse(name);
124+
if (!success) {
125+
return {
126+
statusCode: StatusCodes.BAD_REQUEST,
127+
errorCode: ErrorCode.INVALID_REQUEST_BODY,
128+
message: "Invalid organization url",
129+
} satisfies ServiceError;
130+
}
131+
132+
await prisma.org.update({
133+
where: { id: orgId },
134+
data: { name },
135+
});
136+
137+
return {
138+
success: true,
139+
}
140+
}, /* minRequiredRole = */ OrgRole.OWNER)
141+
)
142+
143+
export const updateOrgDomain = async (newDomain: string, existingDomain: string) =>
144+
withAuth((session) =>
145+
withOrgMembership(session, existingDomain, async ({ orgId }) => {
146+
const { success } = await orgDomainSchema.safeParseAsync(newDomain);
147+
if (!success) {
148+
return {
149+
statusCode: StatusCodes.BAD_REQUEST,
150+
errorCode: ErrorCode.INVALID_REQUEST_BODY,
151+
message: "Invalid organization url",
152+
} satisfies ServiceError;
153+
}
154+
155+
await prisma.org.update({
156+
where: { id: orgId },
157+
data: { domain: newDomain },
158+
});
159+
160+
return {
161+
success: true,
162+
}
163+
}, /* minRequiredRole = */ OrgRole.OWNER),
164+
)
165+
120166
export const completeOnboarding = async (domain: string): Promise<{ success: boolean } | ServiceError> =>
121167
withAuth((session) =>
122168
withOrgMembership(session, domain, async ({ orgId }) => {

packages/web/src/app/[domain]/onboard/components/inviteTeam.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { createInvites } from "@/actions";
44
import { Button } from "@/components/ui/button";
55
import { Card, CardContent, CardFooter } from "@/components/ui/card";
6-
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
6+
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
77
import { Input } from "@/components/ui/input";
88
import { isServiceError } from "@/lib/utils";
99
import { zodResolver } from "@hookform/resolvers/zod";
@@ -77,6 +77,7 @@ export const InviteTeam = ({ nextStep }: InviteTeamProps) => {
7777
<form onSubmit={form.handleSubmit(onSubmit)}>
7878
<CardContent className="space-y-4">
7979
<FormLabel>Email Address</FormLabel>
80+
<FormDescription>{`Invite members to access your organization's Sourcebot instance.`}</FormDescription>
8081
{form.watch('emails').map((_, index) => (
8182
<FormField
8283
key={index}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
'use client';
2+
3+
import { updateOrgDomain } from "@/actions";
4+
import { useToast } from "@/components/hooks/use-toast";
5+
import { AlertDialog, AlertDialogFooter, AlertDialogHeader, AlertDialogContent, AlertDialogAction, AlertDialogCancel, AlertDialogDescription, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
6+
import { Button } from "@/components/ui/button";
7+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
8+
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
9+
import { Input } from "@/components/ui/input";
10+
import useCaptureEvent from "@/hooks/useCaptureEvent";
11+
import { useDomain } from "@/hooks/useDomain";
12+
import { NEXT_PUBLIC_ROOT_DOMAIN } from "@/lib/environment.client";
13+
import { orgDomainSchema } from "@/lib/schemas";
14+
import { isServiceError } from "@/lib/utils";
15+
import { zodResolver } from "@hookform/resolvers/zod";
16+
import { OrgRole } from "@sourcebot/db";
17+
import { Loader2, TriangleAlert } from "lucide-react";
18+
import { useRouter } from "next/navigation";
19+
import { useCallback, useState } from "react";
20+
import { useForm } from "react-hook-form";
21+
import * as z from "zod";
22+
23+
const formSchema = z.object({
24+
domain: orgDomainSchema,
25+
})
26+
27+
interface ChangeOrgDomainCardProps {
28+
currentUserRole: OrgRole,
29+
orgDomain: string,
30+
}
31+
32+
export function ChangeOrgDomainCard({ orgDomain, currentUserRole }: ChangeOrgDomainCardProps) {
33+
const domain = useDomain()
34+
const { toast } = useToast()
35+
const captureEvent = useCaptureEvent();
36+
const router = useRouter();
37+
const [isDialogOpen, setIsDialogOpen] = useState(false);
38+
const form = useForm<z.infer<typeof formSchema>>({
39+
resolver: zodResolver(formSchema),
40+
defaultValues: {
41+
domain: orgDomain,
42+
},
43+
})
44+
const { isSubmitting } = form.formState;
45+
46+
const onSubmit = useCallback(async (data: z.infer<typeof formSchema>) => {
47+
const result = await updateOrgDomain(data.domain, domain);
48+
if (isServiceError(result)) {
49+
toast({
50+
description: `❌ Failed to update organization url. Reason: ${result.message}`,
51+
})
52+
captureEvent('wa_org_domain_updated_fail', {
53+
error: result.errorCode,
54+
});
55+
} else {
56+
toast({
57+
description: "✅ Organization url updated successfully",
58+
});
59+
captureEvent('wa_org_domain_updated_success', {});
60+
router.replace(`/${data.domain}/settings`);
61+
}
62+
}, [domain, router, toast, captureEvent]);
63+
64+
return (
65+
<>
66+
<Card className="w-full">
67+
<CardHeader className="flex flex-col gap-4">
68+
<CardTitle className="flex items-center gap-2">
69+
Organization URL
70+
</CardTitle>
71+
<CardDescription>{`Your organization's URL namespace. This is where your organization's Sourcebot instance will be accessible.`}</CardDescription>
72+
</CardHeader>
73+
<CardContent>
74+
<Form {...form}>
75+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
76+
<FormField
77+
control={form.control}
78+
name="domain"
79+
render={({ field }) => (
80+
<FormItem>
81+
<FormControl>
82+
<div className="flex items-center w-full">
83+
<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>
84+
<Input
85+
placeholder={orgDomain}
86+
{...field}
87+
disabled={currentUserRole !== OrgRole.OWNER}
88+
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the organization url" : undefined}
89+
className="flex-1 rounded-l-none max-w-xs"
90+
/>
91+
</div>
92+
</FormControl>
93+
<FormMessage />
94+
</FormItem>
95+
)}
96+
/>
97+
<div className="flex justify-end">
98+
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
99+
<AlertDialogTrigger asChild>
100+
<Button
101+
disabled={isSubmitting || currentUserRole !== OrgRole.OWNER}
102+
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the organization url" : undefined}
103+
>
104+
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
105+
Save
106+
</Button>
107+
</AlertDialogTrigger>
108+
<AlertDialogContent>
109+
<AlertDialogHeader>
110+
<AlertDialogTitle className="flex items-center gap-2"><TriangleAlert className="h-4 w-4 text-destructive" /> Are you sure?</AlertDialogTitle>
111+
<AlertDialogDescription>
112+
Any links pointing to the current organization URL will <strong>no longer work</strong>.
113+
</AlertDialogDescription>
114+
</AlertDialogHeader>
115+
<AlertDialogFooter>
116+
<AlertDialogCancel>Cancel</AlertDialogCancel>
117+
<AlertDialogAction
118+
onClick={(e) => {
119+
e.preventDefault();
120+
form.handleSubmit(onSubmit)(e);
121+
setIsDialogOpen(false);
122+
}}
123+
>
124+
Continue
125+
</AlertDialogAction>
126+
</AlertDialogFooter>
127+
</AlertDialogContent>
128+
</AlertDialog>
129+
130+
</div>
131+
</form>
132+
</Form>
133+
</CardContent>
134+
</Card>
135+
136+
</>
137+
)
138+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
'use client';
2+
3+
import { updateOrgName } from "@/actions";
4+
import { useToast } from "@/components/hooks/use-toast";
5+
import { Button } from "@/components/ui/button";
6+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7+
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
8+
import { Input } from "@/components/ui/input";
9+
import useCaptureEvent from "@/hooks/useCaptureEvent";
10+
import { useDomain } from "@/hooks/useDomain";
11+
import { orgNameSchema } from "@/lib/schemas";
12+
import { isServiceError } from "@/lib/utils";
13+
import { zodResolver } from "@hookform/resolvers/zod";
14+
import { OrgRole } from "@sourcebot/db";
15+
import { Loader2 } from "lucide-react";
16+
import { useRouter } from "next/navigation";
17+
import { useCallback } from "react";
18+
import { useForm } from "react-hook-form";
19+
import * as z from "zod";
20+
21+
const formSchema = z.object({
22+
name: orgNameSchema,
23+
})
24+
25+
interface ChangeOrgNameCardProps {
26+
currentUserRole: OrgRole,
27+
orgName: string,
28+
}
29+
30+
export function ChangeOrgNameCard({ orgName, currentUserRole }: ChangeOrgNameCardProps) {
31+
const domain = useDomain()
32+
const { toast } = useToast()
33+
const captureEvent = useCaptureEvent();
34+
const router = useRouter();
35+
36+
const form = useForm<z.infer<typeof formSchema>>({
37+
resolver: zodResolver(formSchema),
38+
defaultValues: {
39+
name: orgName,
40+
},
41+
})
42+
const { isSubmitting } = form.formState;
43+
44+
const onSubmit = useCallback(async (data: z.infer<typeof formSchema>) => {
45+
const result = await updateOrgName(data.name, domain);
46+
if (isServiceError(result)) {
47+
toast({
48+
description: `❌ Failed to update organization name. Reason: ${result.message}`,
49+
})
50+
captureEvent('wa_org_name_updated_fail', {
51+
error: result.errorCode,
52+
});
53+
} else {
54+
toast({
55+
description: "✅ Organization name updated successfully",
56+
});
57+
captureEvent('wa_org_name_updated_success', {});
58+
router.refresh();
59+
}
60+
}, [domain, router, toast, captureEvent]);
61+
62+
return (
63+
<Card>
64+
<CardHeader className="flex flex-col gap-4">
65+
<CardTitle>
66+
Organization Name
67+
</CardTitle>
68+
<CardDescription>{`Your organization's visible name within Sourceobot. For example, the name of your company or department.`}</CardDescription>
69+
</CardHeader>
70+
<CardContent>
71+
<Form {...form}>
72+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
73+
<FormField
74+
control={form.control}
75+
name="name"
76+
render={({ field }) => (
77+
<FormItem>
78+
<FormControl>
79+
<Input
80+
{...field}
81+
placeholder={orgName}
82+
className="max-w-sm"
83+
disabled={currentUserRole !== OrgRole.OWNER}
84+
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the organization name" : undefined}
85+
/>
86+
</FormControl>
87+
<FormMessage />
88+
</FormItem>
89+
)}
90+
/>
91+
<div className="flex justify-end">
92+
<Button
93+
type="submit"
94+
disabled={isSubmitting || currentUserRole !== OrgRole.OWNER}
95+
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the organization name" : undefined}
96+
>
97+
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
98+
Save
99+
</Button>
100+
</div>
101+
</form>
102+
</Form>
103+
</CardContent>
104+
</Card>
105+
)
106+
}
107+
Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,47 @@
1+
import { auth } from "@/auth";
2+
import { ChangeOrgNameCard } from "./components/changeOrgNameCard";
3+
import { isServiceError } from "@/lib/utils";
4+
import { getCurrentUserRole } from "@/actions";
5+
import { getOrgFromDomain } from "@/data/org";
6+
import { ChangeOrgDomainCard } from "./components/changeOrgDomainCard";
7+
interface GeneralSettingsPageProps {
8+
params: {
9+
domain: string;
10+
}
11+
}
12+
13+
export default async function GeneralSettingsPage({ params: { domain } }: GeneralSettingsPageProps) {
14+
const session = await auth();
15+
if (!session) {
16+
return null;
17+
}
18+
19+
const currentUserRole = await getCurrentUserRole(domain)
20+
if (isServiceError(currentUserRole)) {
21+
return <div>Failed to fetch user role. Please contact us at team@sourcebot.dev if this issue persists.</div>
22+
}
23+
24+
const org = await getOrgFromDomain(domain)
25+
if (!org) {
26+
return <div>Failed to fetch organization. Please contact us at team@sourcebot.dev if this issue persists.</div>
27+
}
128

2-
export default async function GeneralSettingsPage() {
329
return (
4-
<p>todo</p>
30+
<div className="flex flex-col gap-6">
31+
<div>
32+
<h3 className="text-lg font-medium">General Settings</h3>
33+
</div>
34+
35+
<ChangeOrgNameCard
36+
orgName={org.name}
37+
currentUserRole={currentUserRole}
38+
/>
39+
40+
<ChangeOrgDomainCard
41+
orgDomain={org.domain}
42+
currentUserRole={currentUserRole}
43+
/>
44+
</div>
545
)
646
}
747

0 commit comments

Comments
 (0)