diff --git a/bruno/Orgs/Check Id.bru b/bruno/Orgs/Check Id.bru new file mode 100644 index 0000000..17b6395 --- /dev/null +++ b/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 +} diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 56fbb38..ae43c25 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -56,11 +56,17 @@ export enum ActionsEnum { export async function checkUserActionPermission(actionId: string, req: Request): Promise { 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); diff --git a/server/db/ensureActions.ts b/server/db/ensureActions.ts index a3fb175..95951da 100644 --- a/server/db/ensureActions.ts +++ b/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; } \ No newline at end of file diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index c38c129..c0639c0 100644 --- a/server/routers/auth/signup.ts +++ b/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); diff --git a/server/routers/external.ts b/server/routers/external.ts index d8125f7..33f5e9f 100644 --- a/server/routers/external.ts +++ b/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); diff --git a/server/routers/org/checkId.ts b/server/routers/org/checkId.ts new file mode 100644 index 0000000..741738e --- /dev/null +++ b/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 { + 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...")); + } +} diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 4c79ce8..92c299a 100644 --- a/server/routers/org/createOrg.ts +++ b/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; + + // 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 { name, domain } = parsedBody.data; - 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], diff --git a/server/routers/org/index.ts b/server/routers/org/index.ts index c8ea87b..2e90442 100644 --- a/server/routers/org/index.ts +++ b/server/routers/org/index.ts @@ -2,4 +2,5 @@ export * from "./getOrg"; export * from "./createOrg"; export * from "./deleteOrg"; export * from "./updateOrg"; -export * from "./listOrgs"; \ No newline at end of file +export * from "./listOrgs"; +export * from "./checkId"; \ No newline at end of file diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx new file mode 100644 index 0000000..4c053f9 --- /dev/null +++ b/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('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 ( +

+ This ID is already taken. Please choose another. +

+ ); + } + 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 ( +
+

Setup Your Environment

+
+
+
+
+ 1 +
+ Create Org +
+
+
+ 2 +
+ Create Site +
+
+
+ 3 +
+ Create Resources +
+
+
+
+
+
+
+ {currentStep === 'org' && ( +
+
+ + { setOrgName(e.target.value); setOrgId(generateId(e.target.value)) }} + placeholder="Enter organization name" + required + /> +
+
+ + setOrgId(e.target.value)} + /> + {showOrgIdError()} +

+ This ID is automatically generated from the organization name and must be unique. +

+
+
+ )} + {currentStep === 'site' && ( +
+
+ + setSiteName(e.target.value)} + placeholder="Enter site name" + required + /> +
+
+ )} + {currentStep === 'resources' && ( +
+
+ + setResourceName(e.target.value)} + placeholder="Enter resource name" + required + /> +
+
+ )} +
+ +
+ {currentStep !== 'org' ? ( + + Skip for now + + ) : null} + + + +
+ +
+
+ ) +} + +function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: NodeJS.Timeout | null = null + + return (...args: Parameters) => { + if (timeout) clearTimeout(timeout) + + timeout = setTimeout(() => { + func(...args) + }, wait) + } +} \ No newline at end of file