org setup wip

This commit is contained in:
Milo Schwartz 2024-12-21 14:11:10 -05:00
parent 7252876768
commit d1e2b58c81
No known key found for this signature in database
11 changed files with 284 additions and 221 deletions

View file

@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { orgs, roleActions, roles, userOrgs } from "@server/db/schema"; import { Org, orgs, roleActions, roles, userOrgs } from "@server/db/schema";
import response from "@server/utils/response"; import response from "@server/utils/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@ -15,7 +15,7 @@ import { defaultRoleAllowedActions } from "../role";
const createOrgSchema = z const createOrgSchema = z
.object({ .object({
orgId: z.string(), orgId: z.string(),
name: z.string().min(1).max(255), name: z.string().min(1).max(255)
// domain: z.string().min(1).max(255).optional(), // domain: z.string().min(1).max(255).optional(),
}) })
.strict(); .strict();
@ -66,65 +66,82 @@ export async function createOrg(
); );
} }
// create a url from config.app.base_url and get the hostname let error = "";
const domain = new URL(config.app.base_url).hostname; let org: Org | null = null;
const newOrg = await db await db.transaction(async (trx) => {
.insert(orgs) // create a url from config.app.base_url and get the hostname
.values({ const domain = new URL(config.app.base_url).hostname;
orgId,
name,
domain,
})
.returning();
const roleId = await createAdminRole(newOrg[0].orgId); const newOrg = await trx
.insert(orgs)
.values({
orgId,
name,
domain
})
.returning();
if (!roleId) { if (newOrg.length === 0) {
error = "Failed to create organization";
trx.rollback();
return;
}
org = newOrg[0];
const roleId = await createAdminRole(newOrg[0].orgId);
if (!roleId) {
error = "Failed to create Admin role";
trx.rollback();
return;
}
await trx.insert(userOrgs).values({
userId: req.user!.userId,
orgId: newOrg[0].orgId,
roleId: roleId,
isOwner: true
});
const memberRole = await trx
.insert(roles)
.values({
name: "Member",
description: "Members can only view resources",
orgId
})
.returning();
await trx.insert(roleActions).values(
defaultRoleAllowedActions.map((action) => ({
roleId: memberRole[0].roleId,
actionId: action,
orgId
}))
);
});
if (!org) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
`Error creating Admin role` "Failed to createo org"
) )
); );
} }
await db if (error) {
.insert(userOrgs) return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error));
.values({ }
userId: req.user!.userId,
orgId: newOrg[0].orgId,
roleId: roleId,
isOwner: true,
})
.execute();
const memberRole = await db
.insert(roles)
.values({
name: "Member",
description: "Members can only view resources",
orgId,
})
.returning();
await db
.insert(roleActions)
.values(
defaultRoleAllowedActions.map((action) => ({
roleId: memberRole[0].roleId,
actionId: action,
orgId,
}))
)
.execute();
return response(res, { return response(res, {
data: newOrg[0], data: org,
success: true, success: true,
error: false, error: false,
message: "Organization created successfully", message: "Organization created successfully",
status: HttpCode.CREATED, status: HttpCode.CREATED
}); });
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);

View file

@ -37,7 +37,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import { cn, formatAxiosError } from "@app/lib/utils"; import { cn, formatAxiosError } from "@app/lib/utils";
import { ListOrgsResponse } from "@server/routers/org"; import { ListOrgsResponse } from "@server/routers/org";
import { Check, ChevronsUpDown } from "lucide-react"; import { Check, ChevronsUpDown, Plus } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
@ -180,6 +180,15 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
</CommandEmpty> </CommandEmpty>
<CommandGroup className="[50px]"> <CommandGroup className="[50px]">
<CommandList> <CommandList>
<CommandItem
className="flex items-center border border-input mb-2 cursor-pointer"
onSelect={(currentValue) => {
router.push("/setup");
}}
>
<Plus className="mr-2 h-4 w-4"/>
New Organization
</CommandItem>
{orgs.map((org) => ( {orgs.map((org) => (
<CommandItem <CommandItem
key={org.orgId} key={org.orgId}

View file

@ -96,18 +96,20 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return ( return (
<> <>
<div className="w-full border-b bg-neutral-100 dark:bg-neutral-800 mb-6 select-none sm:px-0 px-3 pt-3"> <div className="w-full border-b bg-neutral-100 dark:bg-neutral-800 select-none sm:px-0 px-3 fixed top-0 z-10">
<div className="container mx-auto flex flex-col content-between gap-4 "> <div className="container mx-auto flex flex-col content-between">
<Header <div className="my-4">
email={user.email} <Header
orgId={params.orgId} email={user.email}
orgs={orgs} orgId={params.orgId}
/> orgs={orgs}
/>
</div>
<TopbarNav items={topNavItems} orgId={params.orgId} /> <TopbarNav items={topNavItems} orgId={params.orgId} />
</div> </div>
</div> </div>
<div className="container mx-auto sm:px-0 px-3">{children}</div> <div className="container mx-auto sm:px-0 px-3 pt-[165px]">{children}</div>
<footer className="w-full mt-6 py-3"> <footer className="w-full mt-6 py-3">
<div className="container mx-auto flex justify-end items-center px-3 sm:px-0 text-sm text-neutral-300 dark:text-neutral-700 space-x-3 select-none"> <div className="container mx-auto flex justify-end items-center px-3 sm:px-0 text-sm text-neutral-300 dark:text-neutral-700 space-x-3 select-none">

View file

@ -285,7 +285,7 @@ export default function CreateShareLinkForm({
r r
) => ( ) => (
<CommandItem <CommandItem
value={r.resourceId.toString()} value={r.name}
key={ key={
r.resourceId r.resourceId
} }

View file

@ -6,11 +6,11 @@
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 0 0.0% 10.0%; --foreground: 20 5.0% 10.0%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 0 0% 100%; --card-foreground: 20 5.0% 10.0%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 0 0% 100%; --popover-foreground: 20 5.0% 10.0%;
--primary: 24.6 95% 53.1%; --primary: 24.6 95% 53.1%;
--primary-foreground: 60 9.1% 97.8%; --primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%; --secondary: 60 4.8% 95.9%;
@ -33,11 +33,11 @@
} }
.dark { .dark {
--background: 0 0.0% 10.0%; --background: 20 5.0% 10.0%;
--foreground: 60 9.1% 97.8%; --foreground: 60 9.1% 97.8%;
--card: 0 0.0% 10.0%; --card: 20 5.0% 10.0%;
--card-foreground: 60 9.1% 97.8%; --card-foreground: 60 9.1% 97.8%;
--popover: 0 0.0% 10.0%; --popover: 20 5.0% 10.0%;
--popover-foreground: 60 9.1% 97.8%; --popover-foreground: 60 9.1% 97.8%;
--primary: 20.5 90.2% 48.2%; --primary: 20.5 90.2% 48.2%;
--primary-foreground: 60 9.1% 97.8%; --primary-foreground: 60 9.1% 97.8%;
@ -70,3 +70,4 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }

View file

@ -5,13 +5,13 @@ import { cache } from "react";
export const metadata: Metadata = { export const metadata: Metadata = {
title: `Setup - Pangolin`, title: `Setup - Pangolin`,
description: "", description: ""
}; };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function SetupLayout({ export default async function SetupLayout({
children, children
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
@ -22,5 +22,5 @@ export default async function SetupLayout({
redirect("/?redirect=/setup"); redirect("/?redirect=/setup");
} }
return <div className="mt-32">{children}</div>; return <div className="w-full max-w-2xl mx-auto p-3 md:mt-32">{children}</div>;
} }

View file

@ -11,104 +11,112 @@ import {
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle
} from "@app/components/ui/card"; } from "@app/components/ui/card";
import CopyTextBox from "@app/components/CopyTextBox"; import CopyTextBox from "@app/components/CopyTextBox";
import { formatAxiosError } from "@app/lib/utils"; import { formatAxiosError } from "@app/lib/utils";
import { createApiClient } from "@app/api"; import { createApiClient } from "@app/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { Separator } from "@/components/ui/separator";
import { z } from "zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Alert, AlertDescription } from "@app/components/ui/alert";
type Step = "org" | "site" | "resources"; type Step = "org" | "site" | "resources";
const orgSchema = z.object({
orgName: z.string().min(1, { message: "Organization name is required" }),
orgId: z.string().min(1, { message: "Organization ID is required" })
});
export default function StepperForm() { export default function StepperForm() {
const [currentStep, setCurrentStep] = useState<Step>("org"); const [currentStep, setCurrentStep] = useState<Step>("org");
const [orgName, setOrgName] = useState("");
const [orgId, setOrgId] = useState("");
const [siteName, setSiteName] = useState("");
const [resourceName, setResourceName] = useState("");
const [orgCreated, setOrgCreated] = useState(false);
const [orgIdTaken, setOrgIdTaken] = useState(false); const [orgIdTaken, setOrgIdTaken] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const orgForm = useForm<z.infer<typeof orgSchema>>({
resolver: zodResolver(orgSchema),
defaultValues: {
orgName: "",
orgId: ""
}
});
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const router = useRouter();
const checkOrgIdAvailability = useCallback(async (value: string) => { const checkOrgIdAvailability = useCallback(async (value: string) => {
try { try {
const res = await api.get(`/org/checkId`, { const res = await api.get(`/org/checkId`, {
params: { params: {
orgId: value, orgId: value
}, }
}); });
setOrgIdTaken(res.status !== 404); setOrgIdTaken(res.status !== 404);
} catch (error) { } catch (error) {
console.error("Error checking org ID availability:", error);
setOrgIdTaken(false); setOrgIdTaken(false);
} }
}, []); }, []);
const debouncedCheckOrgIdAvailability = useCallback( const debouncedCheckOrgIdAvailability = useCallback(
debounce(checkOrgIdAvailability, 300), debounce(checkOrgIdAvailability, 300),
[checkOrgIdAvailability], [checkOrgIdAvailability]
); );
useEffect(() => {
if (orgId) {
debouncedCheckOrgIdAvailability(orgId);
}
}, [orgId, debouncedCheckOrgIdAvailability]);
const showOrgIdError = () => {
if (orgIdTaken) {
return (
<p className="text-sm text-red-500">
This ID is already taken. Please choose another.
</p>
);
}
return null;
};
const generateId = (name: string) => { const generateId = (name: string) => {
return name.toLowerCase().replace(/\s+/g, "-"); return name.toLowerCase().replace(/\s+/g, "-");
}; };
const handleNext = async () => { async function orgSubmit(values: z.infer<typeof orgSchema>) {
if (currentStep === "org") { if (orgIdTaken) {
const res = await api return;
.put(`/org`, { }
orgId: orgId,
name: orgName, setLoading(true);
})
.catch((e) => { try {
toast({ const res = await api.put(`/org`, {
variant: "destructive", orgId: values.orgId,
title: "Error creating org", name: values.orgName
description: formatAxiosError(e), });
});
});
if (res && res.status === 201) { if (res && res.status === 201) {
setCurrentStep("site"); setCurrentStep("site");
setOrgCreated(true);
} }
} else if (currentStep === "site") setCurrentStep("resources"); } catch (e) {
}; console.error(e);
setError(
formatAxiosError(e, "An error occurred while creating org")
);
}
const handlePrevious = () => { setLoading(false);
if (currentStep === "site") setCurrentStep("org"); }
else if (currentStep === "resources") setCurrentStep("site");
};
return ( return (
<> <>
<Card className="w-full max-w-2xl mx-auto"> <Card>
<CardHeader> <CardHeader>
<CardTitle>Setup Your Environment</CardTitle> <CardTitle>Setup</CardTitle>
<CardDescription> <CardDescription>
Create your organization, site, and resources. Create your organization, site, and resources
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="mb-8"> <section className="space-y-6">
<div className="flex justify-between mb-2"> <div className="flex justify-between mb-2">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div <div
@ -171,108 +179,134 @@ export default function StepperForm() {
</span> </span>
</div> </div>
</div> </div>
<div className="flex items-center">
<div className="flex-1 h-px bg-border"></div> <Separator />
<div className="flex-1 h-px bg-border"></div>
</div> {currentStep === "org" && (
</div> <Form {...orgForm}>
{currentStep === "org" && ( <form
<div className="space-y-4"> onSubmit={orgForm.handleSubmit(orgSubmit)}
<div className="space-y-2"> className="space-y-8"
<Label htmlFor="orgName"> >
Organization Name <FormField
</Label> control={orgForm.control}
<Input name="orgName"
id="orgName" render={({ field }) => (
value={orgName} <FormItem>
onChange={(e) => { <FormLabel>
setOrgName(e.target.value); Organization Name
setOrgId(generateId(e.target.value)); </FormLabel>
<FormControl>
<Input
placeholder="Name your new organization"
type="text"
{...field}
onChange={(e) => {
const orgId =
generateId(
e.target
.value
);
orgForm.setValue(
"orgId",
orgId
);
orgForm.setValue(
"orgName",
e.target.value
);
debouncedCheckOrgIdAvailability(
orgId
);
}}
/>
</FormControl>
<FormMessage />
<FormDescription>
This is the display name for
your organization.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={orgForm.control}
name="orgId"
render={({ field }) => (
<FormItem>
<FormLabel>
Organization ID
</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Enter unique organization ID"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
This is the unique
identifier for your
organization. This is
separate from the display
name.
</FormDescription>
</FormItem>
)}
/>
{orgIdTaken && (
<Alert variant="destructive">
<AlertDescription>
Organization ID is already
taken. Please choose a different
one.
</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertDescription>
{error}
</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button
type="submit"
loading={loading}
disabled={
error !== null ||
loading ||
orgIdTaken
}
>
Create Organization
</Button>
</div>
</form>
</Form>
)}
{currentStep === "site" && (
<div className="flex justify-end">
<Button
type="submit"
variant="outline"
onClick={() => {
router.push(
`/${orgForm.getValues().orgId}/settings/sites`
);
}} }}
placeholder="Enter organization name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="orgId">Organization ID</Label>
<Input
id="orgId"
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
/>
{showOrgIdError()}
<p className="text-sm text-muted-foreground">
This ID is automatically generated from the
organization name and must be unique.
</p>
</div>
</div>
)}
{currentStep === "site" && (
<div className="space-y-8">
<div className="space-y-2">
<Label htmlFor="siteName">Site Name</Label>
<Input
id="siteName"
value={siteName}
onChange={(e) =>
setSiteName(e.target.value)
}
placeholder="Enter site name"
required
/>
</div>
</div>
)}
{currentStep === "resources" && (
<div className="space-y-8">
<div className="space-y-2">
<Label htmlFor="resourceName">
Resource Name
</Label>
<Input
id="resourceName"
value={resourceName}
onChange={(e) =>
setResourceName(e.target.value)
}
placeholder="Enter resource name"
required
/>
</div>
</div>
)}
<div className="flex justify-between pt-4">
<Button
type="button"
variant="outline"
onClick={handlePrevious}
disabled={
currentStep === "org" ||
(currentStep === "site" && orgCreated)
}
>
Previous
</Button>
<div className="flex items-center space-x-2">
{currentStep !== "org" ? (
<Link
href={`/${orgId}/settings/sites`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
> >
Skip for now Skip for now
</Link> </Button>
) : null} </div>
)}
<Button </section>
type="button"
id="button"
onClick={handleNext}
>
Create
</Button>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
</> </>
@ -281,7 +315,7 @@ export default function StepperForm() {
function debounce<T extends (...args: any[]) => any>( function debounce<T extends (...args: any[]) => any>(
func: T, func: T,
wait: number, wait: number
): (...args: Parameters<T>) => void { ): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null; let timeout: NodeJS.Timeout | null = null;

View file

@ -15,7 +15,7 @@ const Command = React.forwardRef<
<CommandPrimitive <CommandPrimitive
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-foreground", "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className className
)} )}
{...props} {...props}

View file

@ -47,7 +47,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className className
)} )}
{...props} {...props}

View file

@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className className
)} )}
{...props} {...props}

View file

@ -75,7 +75,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className