Add stepper

This commit is contained in:
Owen Schwartz 2024-10-14 19:30:38 -04:00
parent b67e03677c
commit 0599421975
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
9 changed files with 365 additions and 27 deletions

11
bruno/Orgs/Check Id.bru Normal file
View file

@ -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
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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..."));
}
}

View file

@ -1,7 +1,8 @@
import { Request, Response, NextFunction } from 'express'; 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 { orgs } from '@server/db/schema'; import { eq } from 'drizzle-orm';
import { orgs, 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';
@ -10,8 +11,9 @@ import logger from '@server/logger';
import { createSuperuserRole } from '@server/db/ensureActions'; import { createSuperuserRole } from '@server/db/ensureActions';
const createOrgSchema = z.object({ 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), // domain: z.string().min(1).max(255).optional(),
}); });
const MAX_ORGS = 5; 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 // 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
const hasPermission = await checkUserActionPermission(ActionsEnum.createOrg, req); // Check if the user has permission
if (!hasPermission) { // const hasPermission = await checkUserActionPermission(ActionsEnum.createOrg, req);
return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have permission to perform this action')); // 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({ const newOrg = await db.insert(orgs).values({
orgId,
name, name,
domain, domain: ""
}).returning(); }).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, { return response(res, {
data: newOrg[0], data: newOrg[0],

View file

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

215
src/app/setup/page.tsx Normal file
View file

@ -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)
}
}