소스 검색

Add stepper

Owen Schwartz 9 달 전
부모
커밋
0599421975

+ 11 - 0
bruno/Orgs/Check Id.bru

@@ -0,0 +1,11 @@
+meta {
+  name: Check Id
+  type: http
+  seq: 2
+}
+
+get {
+  url: http://localhost:3000/api/v1/org/checkId
+  body: none
+  auth: none
+}

+ 25 - 15
server/auth/actions.ts

@@ -56,11 +56,17 @@ export enum ActionsEnum {
 
 export async function checkUserActionPermission(actionId: string, req: Request): Promise<boolean> {
     const userId = req.user?.userId;
+    let onlyCheckUser = false;
+
+    if (actionId = ActionsEnum.createOrg) {
+        onlyCheckUser = true;
+    }
+
     if (!userId) {
         throw createHttpError(HttpCode.UNAUTHORIZED, 'User not authenticated');
     }
 
-    if (!req.userOrgId) {
+    if (!req.userOrgId && !onlyCheckUser) {
         throw createHttpError(HttpCode.BAD_REQUEST, 'Organization ID is required');
     }
 
@@ -68,10 +74,10 @@ export async function checkUserActionPermission(actionId: string, req: Request):
         let userOrgRoleId = req.userOrgRoleId;
 
         // If userOrgRoleId is not available on the request, fetch it
-        if (userOrgRoleId === undefined) {
+        if (userOrgRoleId === undefined && !onlyCheckUser) {
             const userOrgRole = await db.select()
                 .from(userOrgs)
-                .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, req.userOrgId)))
+                .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, req.userOrgId!)))
                 .limit(1);
 
             if (userOrgRole.length === 0) {
@@ -88,7 +94,7 @@ export async function checkUserActionPermission(actionId: string, req: Request):
                 and(
                     eq(userActions.userId, userId),
                     eq(userActions.actionId, actionId),
-                    eq(userActions.orgId, req.userOrgId)
+                    eq(userActions.orgId, req.userOrgId!) // TODO: we cant pass the org id if we are not checking the org
                 )
             )
             .limit(1);
@@ -96,20 +102,24 @@ export async function checkUserActionPermission(actionId: string, req: Request):
         if (userActionPermission.length > 0) {
             return true;
         }
+        if (!onlyCheckUser) {
 
-        // If no direct permission, check role-based permission
-        const roleActionPermission = await db.select()
-            .from(roleActions)
-            .where(
-                and(
-                    eq(roleActions.actionId, actionId),
-                    eq(roleActions.roleId, userOrgRoleId),
-                    eq(roleActions.orgId, req.userOrgId)
+            // If no direct permission, check role-based permission
+            const roleActionPermission = await db.select()
+                .from(roleActions)
+                .where(
+                    and(
+                        eq(roleActions.actionId, actionId),
+                        eq(roleActions.roleId, userOrgRoleId!),
+                        eq(roleActions.orgId, req.userOrgId!)
+                    )
                 )
-            )
-            .limit(1);
+                .limit(1);
+
+            return roleActionPermission.length > 0;
+        }
 
-        return roleActionPermission.length > 0;
+        return false;
 
     } catch (error) {
         console.error('Error checking user action permission:', error);

+ 2 - 0
server/db/ensureActions.ts

@@ -64,4 +64,6 @@ export async function createSuperuserRole(orgId: string) {
     await db.insert(roleActions)
         .values(actionIds.map(action => ({ roleId, actionId: action.actionId, orgId })))
         .execute();
+
+    return roleId;
 }

+ 9 - 1
server/routers/auth/signup.ts

@@ -3,7 +3,7 @@ import db from "@server/db";
 import { hash } from "@node-rs/argon2";
 import HttpCode from "@server/types/HttpCode";
 import { z } from "zod";
-import { users } from "@server/db/schema";
+import { userActions, users } from "@server/db/schema";
 import { fromError } from "zod-validation-error";
 import createHttpError from "http-errors";
 import response from "@server/utils/response";
@@ -18,6 +18,7 @@ import {
     generateSessionToken,
     serializeSessionCookie,
 } from "@server/auth";
+import { ActionsEnum } from "@server/auth/actions";
 
 export const signupBodySchema = z.object({
     email: z.string().email(),
@@ -100,6 +101,13 @@ export async function signup(
             dateCreated: moment().toISOString(),
         });
 
+        // give the user their default permissions: 
+        // await db.insert(userActions).values({
+        //     userId: userId,
+        //     actionId: ActionsEnum.createOrg,
+        //     orgId: null,
+        // });
+
         const token = generateSessionToken();
         await createSession(token, userId);
         const cookie = serializeSessionCookie(token);

+ 1 - 0
server/routers/external.ts

@@ -35,6 +35,7 @@ unauthenticated.get("/", (_, res) => {
 export const authenticated = Router();
 authenticated.use(verifySessionUserMiddleware);
 
+authenticated.get("/org/checkId", org.checkId);
 authenticated.put("/org", getUserOrgs, org.createOrg);
 authenticated.get("/orgs", getUserOrgs, org.listOrgs); // TODO we need to check the orgs here
 authenticated.get("/org/:orgId", verifyOrgAccess, org.getOrg);

+ 55 - 0
server/routers/org/checkId.ts

@@ -0,0 +1,55 @@
+import { Request, Response, NextFunction } from 'express';
+import { z } from 'zod';
+import { db } from '@server/db';
+import { orgs } from '@server/db/schema';
+import { eq } from 'drizzle-orm';
+import response from "@server/utils/response";
+import HttpCode from '@server/types/HttpCode';
+import createHttpError from 'http-errors';
+import logger from '@server/logger';
+
+const getOrgSchema = z.object({
+    orgId: z.string()
+});
+
+export async function checkId(req: Request, res: Response, next: NextFunction): Promise<any> {
+    try {
+        const parsedQuery = getOrgSchema.safeParse(req.query);
+        if (!parsedQuery.success) {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    parsedQuery.error.errors.map(e => e.message).join(', ')
+                )
+            );
+        }
+
+        const { orgId } = parsedQuery.data;
+
+        const org = await db.select()
+            .from(orgs)
+            .where(eq(orgs.orgId, orgId))
+            .limit(1);
+
+        if (org.length > 0) {
+            return response(res, {
+                data: {},
+                success: true,
+                error: false,
+                message: "Organization ID already exists",
+                status: HttpCode.OK,
+            });
+        }
+
+        return response(res, {
+            data: {},
+            success: true,
+            error: false,
+            message: "Organization ID is available",
+            status: HttpCode.NOT_FOUND,
+        });
+    } catch (error) {
+        logger.error(error);
+        return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred..."));
+    }
+}

+ 45 - 10
server/routers/org/createOrg.ts

@@ -1,7 +1,8 @@
 import { Request, Response, NextFunction } from 'express';
 import { z } from 'zod';
 import { db } from '@server/db';
-import { orgs } from '@server/db/schema';
+import { eq } from 'drizzle-orm';
+import { orgs, userOrgs } from '@server/db/schema';
 import response from "@server/utils/response";
 import HttpCode from '@server/types/HttpCode';
 import createHttpError from 'http-errors';
@@ -10,8 +11,9 @@ import logger from '@server/logger';
 import { createSuperuserRole } from '@server/db/ensureActions';
 
 const createOrgSchema = z.object({
+    orgId: z.string(),
     name: z.string().min(1).max(255),
-    domain: z.string().min(1).max(255),
+    // domain: z.string().min(1).max(255).optional(),
 });
 
 const MAX_ORGS = 5;
@@ -38,20 +40,53 @@ export async function createOrg(req: Request, res: Response, next: NextFunction)
             );
         }
 
-        // Check if the user has permission to list sites
-        const hasPermission = await checkUserActionPermission(ActionsEnum.createOrg, req);
-        if (!hasPermission) {
-            return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have permission to perform this action'));
-        }
+        // TODO: we cant do this when they create an org because they are not in an org yet... maybe we need to make the org id optional on the userActions table
+        // Check if the user has permission 
+        // const hasPermission = await checkUserActionPermission(ActionsEnum.createOrg, req);
+        // if (!hasPermission) {
+        //     return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have permission to perform this action'));
+        // }
+
+        const { orgId, name } = parsedBody.data;
 
-        const { name, domain } = parsedBody.data;
+        // make sure the orgId is unique
+        const orgExists = await db.select()
+            .from(orgs)
+            .where(eq(orgs.orgId, orgId))
+            .limit(1);
+        
+        if (orgExists.length > 0) {
+            return next(
+                createHttpError(
+                    HttpCode.CONFLICT,
+                    `Organization with ID ${orgId} already exists`
+                )
+            );
+        }
 
         const newOrg = await db.insert(orgs).values({
+            orgId,
             name,
-            domain,
+            domain: ""
         }).returning();
 
-        await createSuperuserRole(newOrg[0].orgId);
+        const roleId = await createSuperuserRole(newOrg[0].orgId);
+
+        if (!roleId) {
+            return next(
+                createHttpError(
+                    HttpCode.INTERNAL_SERVER_ERROR,
+                    `Error creating superuser role`
+                )
+            );
+        }
+
+        // put the user in the super user role
+        await db.insert(userOrgs).values({
+            userId: req.user!.userId,
+            orgId: newOrg[0].orgId,
+            roleId: roleId,
+        }).execute();
 
         return response(res, {
             data: newOrg[0],

+ 2 - 1
server/routers/org/index.ts

@@ -2,4 +2,5 @@ export * from "./getOrg";
 export * from "./createOrg";
 export * from "./deleteOrg";
 export * from "./updateOrg";
-export * from "./listOrgs";
+export * from "./listOrgs";
+export * from "./checkId";

+ 215 - 0
src/app/setup/page.tsx

@@ -0,0 +1,215 @@
+'use client'
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import Link from 'next/link'
+import api from '@app/api'
+import { toast } from '@app/hooks/use-toast'
+import { useCallback, useEffect, useState } from 'react';
+
+type Step = 'org' | 'site' | 'resources'
+
+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 checkOrgIdAvailability = useCallback(async (value: string) => {
+        try {
+            const res = await api.get(`/org/checkId`, {
+                params: {
+                    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]
+    )
+
+    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({
+                        title: "Error creating org..."
+                    });
+                });
+
+            if (res && res.status === 201) {
+                setCurrentStep('site')
+                setOrgCreated(true)
+            }
+
+        }
+        else if (currentStep === 'site') setCurrentStep('resources')
+    }
+
+    const handlePrevious = () => {
+        if (currentStep === 'site') setCurrentStep('org')
+        else if (currentStep === 'resources') setCurrentStep('site')
+    }
+
+
+    return (
+        <div className="w-full max-w-2xl mx-auto p-6">
+            <h2 className="text-2xl font-bold mb-6">Setup Your Environment</h2>
+            <div className="mb-8">
+                <div className="flex justify-between mb-2">
+                    <div className="flex flex-col items-center">
+                        <div className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === 'org' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'}`}>
+                            1
+                        </div>
+                        <span className={`text-sm font-medium ${currentStep === 'org' ? 'text-primary' : 'text-muted-foreground'}`}>Create Org</span>
+                    </div>
+                    <div className="flex flex-col items-center">
+                        <div className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === 'site' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'}`}>
+                            2
+                        </div>
+                        <span className={`text-sm font-medium ${currentStep === 'site' ? 'text-primary' : 'text-muted-foreground'}`}>Create Site</span>
+                    </div>
+                    <div className="flex flex-col items-center">
+                        <div className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === 'resources' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'}`}>
+                            3
+                        </div>
+                        <span className={`text-sm font-medium ${currentStep === 'resources' ? 'text-primary' : 'text-muted-foreground'}`}>Create Resources</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)) }}
+                            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-6">
+                    <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-6">
+                    <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}/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>
+        </div>
+    )
+}
+
+function debounce<T extends (...args: any[]) => any>(
+    func: T,
+    wait: number
+): (...args: Parameters<T>) => void {
+    let timeout: NodeJS.Timeout | null = null
+
+    return (...args: Parameters<T>) => {
+        if (timeout) clearTimeout(timeout)
+
+        timeout = setTimeout(() => {
+            func(...args)
+        }, wait)
+    }
+}