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 { db } from "@server/db";
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 HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@ -15,7 +15,7 @@ import { defaultRoleAllowedActions } from "../role";
const createOrgSchema = z
.object({
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(),
})
.strict();
@ -66,65 +66,82 @@ export async function createOrg(
);
}
// create a url from config.app.base_url and get the hostname
const domain = new URL(config.app.base_url).hostname;
let error = "";
let org: Org | null = null;
const newOrg = await db
.insert(orgs)
.values({
orgId,
name,
domain,
})
.returning();
await db.transaction(async (trx) => {
// create a url from config.app.base_url and get the hostname
const domain = new URL(config.app.base_url).hostname;
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(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Error creating Admin role`
"Failed to createo org"
)
);
}
await db
.insert(userOrgs)
.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();
if (error) {
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error));
}
return response(res, {
data: newOrg[0],
data: org,
success: true,
error: false,
message: "Organization created successfully",
status: HttpCode.CREATED,
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);

View file

@ -37,7 +37,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useToast } from "@app/hooks/useToast";
import { cn, formatAxiosError } from "@app/lib/utils";
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 { useRouter } from "next/navigation";
import { useState } from "react";
@ -180,6 +180,15 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
</CommandEmpty>
<CommandGroup className="[50px]">
<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) => (
<CommandItem
key={org.orgId}

View file

@ -96,18 +96,20 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
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="container mx-auto flex flex-col content-between gap-4 ">
<Header
email={user.email}
orgId={params.orgId}
orgs={orgs}
/>
<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">
<div className="my-4">
<Header
email={user.email}
orgId={params.orgId}
orgs={orgs}
/>
</div>
<TopbarNav items={topNavItems} orgId={params.orgId} />
</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">
<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
) => (
<CommandItem
value={r.resourceId.toString()}
value={r.name}
key={
r.resourceId
}

View file

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

View file

@ -5,13 +5,13 @@ import { cache } from "react";
export const metadata: Metadata = {
title: `Setup - Pangolin`,
description: "",
description: ""
};
export const dynamic = "force-dynamic";
export default async function SetupLayout({
children,
children
}: {
children: React.ReactNode;
}) {
@ -22,5 +22,5 @@ export default async function SetupLayout({
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,
CardDescription,
CardHeader,
CardTitle,
CardTitle
} from "@app/components/ui/card";
import CopyTextBox from "@app/components/CopyTextBox";
import { formatAxiosError } from "@app/lib/utils";
import { createApiClient } from "@app/api";
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";
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() {
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 [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 router = useRouter();
const checkOrgIdAvailability = useCallback(async (value: string) => {
try {
const res = await api.get(`/org/checkId`, {
params: {
orgId: value,
},
orgId: value
}
});
setOrgIdTaken(res.status !== 404);
} catch (error) {
console.error("Error checking org ID availability:", error);
setOrgIdTaken(false);
}
}, []);
const debouncedCheckOrgIdAvailability = useCallback(
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) => {
return name.toLowerCase().replace(/\s+/g, "-");
};
const handleNext = async () => {
if (currentStep === "org") {
const res = await api
.put(`/org`, {
orgId: orgId,
name: orgName,
})
.catch((e) => {
toast({
variant: "destructive",
title: "Error creating org",
description: formatAxiosError(e),
});
});
async function orgSubmit(values: z.infer<typeof orgSchema>) {
if (orgIdTaken) {
return;
}
setLoading(true);
try {
const res = await api.put(`/org`, {
orgId: values.orgId,
name: values.orgName
});
if (res && res.status === 201) {
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 = () => {
if (currentStep === "site") setCurrentStep("org");
else if (currentStep === "resources") setCurrentStep("site");
};
setLoading(false);
}
return (
<>
<Card className="w-full max-w-2xl mx-auto">
<Card>
<CardHeader>
<CardTitle>Setup Your Environment</CardTitle>
<CardTitle>Setup</CardTitle>
<CardDescription>
Create your organization, site, and resources.
Create your organization, site, and resources
</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-8">
<section className="space-y-6">
<div className="flex justify-between mb-2">
<div className="flex flex-col items-center">
<div
@ -171,108 +179,134 @@ export default function StepperForm() {
</span>
</div>
</div>
<div className="flex items-center">
<div className="flex-1 h-px bg-border"></div>
<div className="flex-1 h-px bg-border"></div>
</div>
</div>
{currentStep === "org" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="orgName">
Organization Name
</Label>
<Input
id="orgName"
value={orgName}
onChange={(e) => {
setOrgName(e.target.value);
setOrgId(generateId(e.target.value));
<Separator />
{currentStep === "org" && (
<Form {...orgForm}>
<form
onSubmit={orgForm.handleSubmit(orgSubmit)}
className="space-y-8"
>
<FormField
control={orgForm.control}
name="orgName"
render={({ field }) => (
<FormItem>
<FormLabel>
Organization Name
</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
</Link>
) : null}
<Button
type="button"
id="button"
onClick={handleNext}
>
Create
</Button>
</div>
</div>
</Button>
</div>
)}
</section>
</CardContent>
</Card>
</>
@ -281,7 +315,7 @@ export default function StepperForm() {
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;

View file

@ -15,7 +15,7 @@ const Command = React.forwardRef<
<CommandPrimitive
ref={ref}
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
)}
{...props}

View file

@ -47,7 +47,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
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
)}
{...props}

View file

@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
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
)}
{...props}

View file

@ -75,7 +75,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
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" &&
"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