mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-15 08:25:01 +02:00
ability to remove user from org
This commit is contained in:
parent
2852d62258
commit
fadfaf1f0b
28 changed files with 718 additions and 264 deletions
|
@ -7,7 +7,7 @@
|
||||||
// targets,
|
// targets,
|
||||||
// } from "@server/db/schema";
|
// } from "@server/db/schema";
|
||||||
// import db from "@server/db";
|
// import db from "@server/db";
|
||||||
// import { createSuperuserRole } from "@server/db/ensureActions";
|
// import { createSuperUserRole } from "@server/db/ensureActions";
|
||||||
|
|
||||||
async function insertDummyData() {
|
async function insertDummyData() {
|
||||||
// // Insert dummy orgs
|
// // Insert dummy orgs
|
||||||
|
@ -21,7 +21,7 @@ async function insertDummyData() {
|
||||||
// .returning()
|
// .returning()
|
||||||
// .get();
|
// .get();
|
||||||
|
|
||||||
// await createSuperuserRole(org1.orgId!);
|
// await createSuperUserRole(org1.orgId!);
|
||||||
|
|
||||||
// const org2 = db
|
// const org2 = db
|
||||||
// .insert(orgs)
|
// .insert(orgs)
|
||||||
|
@ -33,7 +33,7 @@ async function insertDummyData() {
|
||||||
// .returning()
|
// .returning()
|
||||||
// .get();
|
// .get();
|
||||||
|
|
||||||
// await createSuperuserRole(org2.orgId!);
|
// await createSuperUserRole(org2.orgId!);
|
||||||
|
|
||||||
// // Insert dummy exit nodes
|
// // Insert dummy exit nodes
|
||||||
// const exitNode1 = db
|
// const exitNode1 = db
|
||||||
|
|
|
@ -15,7 +15,7 @@ export async function ensureActions() {
|
||||||
const defaultRoles = await db
|
const defaultRoles = await db
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(eq(roles.isSuperuserRole, true))
|
.where(eq(roles.isSuperUserRole, true))
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Add new actions
|
// Add new actions
|
||||||
|
@ -38,15 +38,15 @@ export async function ensureActions() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSuperuserRole(orgId: string) {
|
export async function createSuperUserRole(orgId: string) {
|
||||||
// Create the Default role if it doesn't exist
|
// Create the Default role if it doesn't exist
|
||||||
const [insertedRole] = await db
|
const [insertedRole] = await db
|
||||||
.insert(roles)
|
.insert(roles)
|
||||||
.values({
|
.values({
|
||||||
orgId,
|
orgId,
|
||||||
isSuperuserRole: true,
|
isSuperUserRole: true,
|
||||||
name: 'Superuser',
|
name: 'Super User',
|
||||||
description: 'Superuser role with all actions'
|
description: 'Super User role with all actions'
|
||||||
})
|
})
|
||||||
.returning({ roleId: roles.roleId })
|
.returning({ roleId: roles.roleId })
|
||||||
.execute();
|
.execute();
|
||||||
|
@ -56,7 +56,7 @@ export async function createSuperuserRole(orgId: string) {
|
||||||
const actionIds = await db.select().from(actions).execute();
|
const actionIds = await db.select().from(actions).execute();
|
||||||
|
|
||||||
if (actionIds.length === 0) {
|
if (actionIds.length === 0) {
|
||||||
logger.info('No actions to assign to the Superuser role');
|
logger.info('No actions to assign to the Super User role');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -131,7 +131,7 @@ export const roles = sqliteTable("roles", {
|
||||||
orgId: text("orgId").references(() => orgs.orgId, {
|
orgId: text("orgId").references(() => orgs.orgId, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
isSuperuserRole: integer("isSuperuserRole", { mode: "boolean" }),
|
isSuperUserRole: integer("isSuperUserRole", { mode: "boolean" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
});
|
});
|
||||||
|
|
75
server/emails/templates/SendInviteLink.tsx
Normal file
75
server/emails/templates/SendInviteLink.tsx
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
Tailwind,
|
||||||
|
Button,
|
||||||
|
} from "@react-email/components";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
interface SendInviteLinkProps {
|
||||||
|
email: string;
|
||||||
|
inviteLink: string;
|
||||||
|
orgName: string;
|
||||||
|
inviterName?: string;
|
||||||
|
expiresInDays: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SendInviteLink = ({
|
||||||
|
email,
|
||||||
|
inviteLink,
|
||||||
|
orgName,
|
||||||
|
inviterName,
|
||||||
|
expiresInDays,
|
||||||
|
}: SendInviteLinkProps) => {
|
||||||
|
const previewText = `${inviterName} invited to join ${orgName}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind>
|
||||||
|
<Body className="font-sans">
|
||||||
|
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8">
|
||||||
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
|
You're invite to join a Fossorial organization
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-base text-gray-700 mt-4">
|
||||||
|
Hi {email || "there"},
|
||||||
|
</Text>
|
||||||
|
<Text className="text-base text-gray-700 mt-2">
|
||||||
|
You’ve been invited to join the organization{" "}
|
||||||
|
{orgName}
|
||||||
|
{inviterName ? ` by ${inviterName}.` : ""}. Please
|
||||||
|
access the link below to accept the invite.
|
||||||
|
</Text>
|
||||||
|
<Text className="text-base text-gray-700 mt-2">
|
||||||
|
This invite will expire in{" "}
|
||||||
|
<b>{expiresInDays} days.</b>
|
||||||
|
</Text>
|
||||||
|
<Section className="text-center my-6">
|
||||||
|
<Button
|
||||||
|
href={inviteLink}
|
||||||
|
className="rounded-md bg-gray-600 px-[12px] py-[12px] text-center font-semibold text-white cursor-pointer"
|
||||||
|
>
|
||||||
|
Accept invitation to {orgName}
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
<Text className="text-sm text-gray-500 mt-6">
|
||||||
|
Best regards,
|
||||||
|
<br />
|
||||||
|
Fossorial
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SendInviteLink;
|
|
@ -11,7 +11,7 @@ export * from "./verifyResourceAccess";
|
||||||
export * from "./verifyTargetAccess";
|
export * from "./verifyTargetAccess";
|
||||||
export * from "./verifyRoleAccess";
|
export * from "./verifyRoleAccess";
|
||||||
export * from "./verifyUserAccess";
|
export * from "./verifyUserAccess";
|
||||||
export * from "./verifySuperuser";
|
export * from "./verifySuperUser";
|
||||||
export * from "./verifyEmail";
|
export * from "./verifyEmail";
|
||||||
export * from "./requestEmailVerificationCode";
|
export * from "./requestEmailVerificationCode";
|
||||||
export * from "./changePassword";
|
export * from "./changePassword";
|
||||||
|
|
|
@ -4,7 +4,7 @@ import db from "@server/db";
|
||||||
import { users, emailVerificationCodes } from "@server/db/schema";
|
import { users, emailVerificationCodes } from "@server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { sendEmail } from "@server/emails";
|
import { sendEmail } from "@server/emails";
|
||||||
import VerifyEmail from "@server/emails/templates/verifyEmailCode";
|
import VerifyEmail from "@server/emails/templates/VerifyEmailCode";
|
||||||
import config from "@server/config";
|
import config from "@server/config";
|
||||||
|
|
||||||
export async function sendEmailVerificationCode(
|
export async function sendEmailVerificationCode(
|
||||||
|
|
|
@ -1,21 +1,29 @@
|
||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { db } from '@server/db';
|
import { db } from "@server/db";
|
||||||
import { userOrgs } from '@server/db/schema';
|
import { userOrgs } from "@server/db/schema";
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from 'http-errors';
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from '@server/types/HttpCode';
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { AuthenticatedRequest } from '@server/types/Auth';
|
import { AuthenticatedRequest } from "@server/types/Auth";
|
||||||
|
|
||||||
export function verifyOrgAccess(req: Request, res: Response, next: NextFunction) {
|
export function verifyOrgAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
const userId = req.user!.userId; // Assuming you have user information in the request
|
const userId = req.user!.userId; // Assuming you have user information in the request
|
||||||
const orgId = req.params.orgId;
|
const orgId = req.params.orgId;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(createHttpError(HttpCode.UNAUTHORIZED, 'User not authenticated'));
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return next(createHttpError(HttpCode.BAD_REQUEST, 'Invalid organization ID'));
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
db.select()
|
db.select()
|
||||||
|
@ -23,7 +31,12 @@ export function verifyOrgAccess(req: Request, res: Response, next: NextFunction)
|
||||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result.length === 0) {
|
if (result.length === 0) {
|
||||||
next(createHttpError(HttpCode.FORBIDDEN, 'User does not have access to this organization'));
|
next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// User has access, attach the user's role to the request for potential future use
|
// User has access, attach the user's role to the request for potential future use
|
||||||
req.userOrgRoleId = result[0].roleId;
|
req.userOrgRoleId = result[0].roleId;
|
||||||
|
@ -32,6 +45,11 @@ export function verifyOrgAccess(req: Request, res: Response, next: NextFunction)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, 'Error verifying organization access'));
|
next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying organization access"
|
||||||
|
)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import createHttpError from 'http-errors';
|
||||||
import HttpCode from '@server/types/HttpCode';
|
import HttpCode from '@server/types/HttpCode';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
|
||||||
export async function verifySuperuser(req: Request, res: Response, next: NextFunction) {
|
export async function verifySuperUser(req: Request, res: Response, next: NextFunction) {
|
||||||
const userId = req.user?.userId; // Assuming you have user information in the request
|
const userId = req.user?.userId; // Assuming you have user information in the request
|
||||||
const orgId = req.userOrgId;
|
const orgId = req.userOrgId;
|
||||||
|
|
||||||
|
@ -30,14 +30,14 @@ export async function verifySuperuser(req: Request, res: Response, next: NextFun
|
||||||
}
|
}
|
||||||
|
|
||||||
// get userOrgRole[0].roleId
|
// get userOrgRole[0].roleId
|
||||||
// Check if the user's role in the organization is a superuser role
|
// Check if the user's role in the organization is a Super User role
|
||||||
const userRole = await db.select()
|
const userRole = await db.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(eq(roles.roleId, userOrgRole[0].roleId))
|
.where(eq(roles.roleId, userOrgRole[0].roleId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (userRole.length === 0 || !userRole[0].isSuperuserRole) {
|
if (userRole.length === 0 || !userRole[0].isSuperUserRole) {
|
||||||
return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have superuser access'));
|
return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have Super User access'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
|
|
|
@ -1,37 +1,62 @@
|
||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { db } from '@server/db';
|
import { db } from "@server/db";
|
||||||
import { sites, userOrgs, userSites, roleSites, roles } from '@server/db/schema';
|
import {
|
||||||
import { and, eq, or } from 'drizzle-orm';
|
sites,
|
||||||
import createHttpError from 'http-errors';
|
userOrgs,
|
||||||
import HttpCode from '@server/types/HttpCode';
|
userSites,
|
||||||
|
roleSites,
|
||||||
|
roles,
|
||||||
|
} from "@server/db/schema";
|
||||||
|
import { and, eq, or } from "drizzle-orm";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
|
||||||
export async function verifyUserAccess(req: Request, res: Response, next: NextFunction) {
|
export async function verifyUserAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
const userId = req.user!.userId; // Assuming you have user information in the request
|
const userId = req.user!.userId; // Assuming you have user information in the request
|
||||||
const reqUserId = req.params.userId || req.body.userId || req.query.userId;
|
const reqUserId = req.params.userId || req.body.userId || req.query.userId;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(createHttpError(HttpCode.UNAUTHORIZED, 'User not authenticated'));
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!reqUserId) {
|
if (!reqUserId) {
|
||||||
return next(createHttpError(HttpCode.BAD_REQUEST, 'Invalid user ID'));
|
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user ID"));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const userOrg = await db
|
||||||
const userOrg = await db.select()
|
.select()
|
||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, req.userOrgId!)))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, reqUserId),
|
||||||
|
eq(userOrgs.orgId, req.userOrgId!)
|
||||||
|
)
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (userOrg.length === 0) {
|
if (userOrg.length === 0) {
|
||||||
return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have access to this user'));
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this user"
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we reach here, the user doesn't have access to the site
|
return next();
|
||||||
return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have access to this site'));
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, 'Error verifying site access'));
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error checking if user has access to this user"
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@ import {
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifyTargetAccess,
|
verifyTargetAccess,
|
||||||
verifyRoleAccess,
|
verifyRoleAccess,
|
||||||
verifySuperuser,
|
verifySuperUser,
|
||||||
verifyUserInRole,
|
verifyUserInRole,
|
||||||
verifyUserAccess,
|
verifyUserAccess,
|
||||||
} from "./auth";
|
} from "./auth";
|
||||||
|
@ -40,7 +40,7 @@ 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);
|
||||||
authenticated.post("/org/:orgId", verifyOrgAccess, org.updateOrg);
|
authenticated.post("/org/:orgId", verifyOrgAccess, org.updateOrg);
|
||||||
authenticated.delete("/org/:orgId", verifyOrgAccess, org.deleteOrg);
|
// authenticated.delete("/org/:orgId", verifyOrgAccess, org.deleteOrg);
|
||||||
|
|
||||||
authenticated.put("/org/:orgId/site", verifyOrgAccess, site.createSite);
|
authenticated.put("/org/:orgId/site", verifyOrgAccess, site.createSite);
|
||||||
authenticated.get("/org/:orgId/sites", verifyOrgAccess, site.listSites);
|
authenticated.get("/org/:orgId/sites", verifyOrgAccess, site.listSites);
|
||||||
|
@ -52,7 +52,7 @@ authenticated.get(
|
||||||
site.pickSiteDefaults
|
site.pickSiteDefaults
|
||||||
);
|
);
|
||||||
authenticated.get("/site/:siteId", verifySiteAccess, site.getSite);
|
authenticated.get("/site/:siteId", verifySiteAccess, site.getSite);
|
||||||
authenticated.get("/site/:siteId/roles", verifySiteAccess, site.listSiteRoles);
|
// authenticated.get("/site/:siteId/roles", verifySiteAccess, site.listSiteRoles);
|
||||||
authenticated.post("/site/:siteId", verifySiteAccess, site.updateSite);
|
authenticated.post("/site/:siteId", verifySiteAccess, site.updateSite);
|
||||||
authenticated.delete("/site/:siteId", verifySiteAccess, site.deleteSite);
|
authenticated.delete("/site/:siteId", verifySiteAccess, site.deleteSite);
|
||||||
|
|
||||||
|
@ -75,11 +75,11 @@ authenticated.post(
|
||||||
); // maybe make this /invite/create instead
|
); // maybe make this /invite/create instead
|
||||||
authenticated.post("/invite/accept", user.acceptInvite);
|
authenticated.post("/invite/accept", user.acceptInvite);
|
||||||
|
|
||||||
authenticated.get(
|
// authenticated.get(
|
||||||
"/resource/:resourceId/roles",
|
// "/resource/:resourceId/roles",
|
||||||
verifyResourceAccess,
|
// verifyResourceAccess,
|
||||||
resource.listResourceRoles
|
// resource.listResourceRoles
|
||||||
);
|
// );
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/resource/:resourceId",
|
"/resource/:resourceId",
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
|
@ -121,85 +121,85 @@ authenticated.delete(
|
||||||
// authenticated.put(
|
// authenticated.put(
|
||||||
// "/org/:orgId/role",
|
// "/org/:orgId/role",
|
||||||
// verifyOrgAccess,
|
// verifyOrgAccess,
|
||||||
// verifySuperuser,
|
// verifySuperUser,
|
||||||
// role.createRole
|
// role.createRole
|
||||||
// );
|
// );
|
||||||
authenticated.get("/org/:orgId/roles", verifyOrgAccess, role.listRoles);
|
// authenticated.get("/org/:orgId/roles", verifyOrgAccess, role.listRoles);
|
||||||
authenticated.get(
|
// authenticated.get(
|
||||||
"/role/:roleId",
|
// "/role/:roleId",
|
||||||
verifyRoleAccess,
|
// verifyRoleAccess,
|
||||||
verifyUserInRole,
|
// verifyUserInRole,
|
||||||
role.getRole
|
// role.getRole
|
||||||
);
|
// );
|
||||||
// authenticated.post(
|
// authenticated.post(
|
||||||
// "/role/:roleId",
|
// "/role/:roleId",
|
||||||
// verifyRoleAccess,
|
// verifyRoleAccess,
|
||||||
// verifySuperuser,
|
// verifySuperUser,
|
||||||
// role.updateRole
|
// role.updateRole
|
||||||
// );
|
// );
|
||||||
// authenticated.delete(
|
// authenticated.delete(
|
||||||
// "/role/:roleId",
|
// "/role/:roleId",
|
||||||
// verifyRoleAccess,
|
// verifyRoleAccess,
|
||||||
// verifySuperuser,
|
// verifySuperUser,
|
||||||
// role.deleteRole
|
// role.deleteRole
|
||||||
// );
|
// );
|
||||||
|
|
||||||
authenticated.put(
|
// authenticated.put(
|
||||||
"/role/:roleId/site",
|
// "/role/:roleId/site",
|
||||||
verifyRoleAccess,
|
// verifyRoleAccess,
|
||||||
verifyUserInRole,
|
// verifyUserInRole,
|
||||||
role.addRoleSite
|
// role.addRoleSite
|
||||||
);
|
// );
|
||||||
authenticated.delete(
|
// authenticated.delete(
|
||||||
"/role/:roleId/site",
|
// "/role/:roleId/site",
|
||||||
verifyRoleAccess,
|
// verifyRoleAccess,
|
||||||
verifyUserInRole,
|
// verifyUserInRole,
|
||||||
role.removeRoleSite
|
// role.removeRoleSite
|
||||||
);
|
// );
|
||||||
authenticated.get(
|
// authenticated.get(
|
||||||
"/role/:roleId/sites",
|
// "/role/:roleId/sites",
|
||||||
verifyRoleAccess,
|
// verifyRoleAccess,
|
||||||
verifyUserInRole,
|
// verifyUserInRole,
|
||||||
role.listRoleSites
|
// role.listRoleSites
|
||||||
);
|
// );
|
||||||
authenticated.put(
|
// authenticated.put(
|
||||||
"/role/:roleId/resource",
|
// "/role/:roleId/resource",
|
||||||
verifyRoleAccess,
|
// verifyRoleAccess,
|
||||||
verifyUserInRole,
|
// verifyUserInRole,
|
||||||
role.addRoleResource
|
// role.addRoleResource
|
||||||
);
|
// );
|
||||||
authenticated.delete(
|
// authenticated.delete(
|
||||||
"/role/:roleId/resource",
|
// "/role/:roleId/resource",
|
||||||
verifyRoleAccess,
|
// verifyRoleAccess,
|
||||||
verifyUserInRole,
|
// verifyUserInRole,
|
||||||
role.removeRoleResource
|
// role.removeRoleResource
|
||||||
);
|
// );
|
||||||
authenticated.get(
|
// authenticated.get(
|
||||||
"/role/:roleId/resources",
|
// "/role/:roleId/resources",
|
||||||
verifyRoleAccess,
|
// verifyRoleAccess,
|
||||||
verifyUserInRole,
|
// verifyUserInRole,
|
||||||
role.listRoleResources
|
// role.listRoleResources
|
||||||
);
|
// );
|
||||||
authenticated.put(
|
// authenticated.put(
|
||||||
"/role/:roleId/action",
|
// "/role/:roleId/action",
|
||||||
verifyRoleAccess,
|
// verifyRoleAccess,
|
||||||
verifyUserInRole,
|
// verifyUserInRole,
|
||||||
role.addRoleAction
|
// role.addRoleAction
|
||||||
);
|
// );
|
||||||
authenticated.delete(
|
// authenticated.delete(
|
||||||
"/role/:roleId/action",
|
// "/role/:roleId/action",
|
||||||
verifyRoleAccess,
|
// verifyRoleAccess,
|
||||||
verifyUserInRole,
|
// verifyUserInRole,
|
||||||
verifySuperuser,
|
// verifySuperUser,
|
||||||
role.removeRoleAction
|
// role.removeRoleAction
|
||||||
);
|
// );
|
||||||
authenticated.get(
|
// authenticated.get(
|
||||||
"/role/:roleId/actions",
|
// "/role/:roleId/actions",
|
||||||
verifyRoleAccess,
|
// verifyRoleAccess,
|
||||||
verifyUserInRole,
|
// verifyUserInRole,
|
||||||
verifySuperuser,
|
// verifySuperUser,
|
||||||
role.listRoleActions
|
// role.listRoleActions
|
||||||
);
|
// );
|
||||||
|
|
||||||
unauthenticated.get("/user", verifySessionMiddleware, user.getUser);
|
unauthenticated.get("/user", verifySessionMiddleware, user.getUser);
|
||||||
|
|
||||||
|
@ -211,44 +211,44 @@ authenticated.delete(
|
||||||
user.removeUserOrg
|
user.removeUserOrg
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.put(
|
// authenticated.put(
|
||||||
"/user/:userId/site",
|
// "/user/:userId/site",
|
||||||
verifySiteAccess,
|
// verifySiteAccess,
|
||||||
verifyUserAccess,
|
// verifyUserAccess,
|
||||||
role.addRoleSite
|
// role.addRoleSite
|
||||||
);
|
// );
|
||||||
authenticated.delete(
|
// authenticated.delete(
|
||||||
"/user/:userId/site",
|
// "/user/:userId/site",
|
||||||
verifySiteAccess,
|
// verifySiteAccess,
|
||||||
verifyUserAccess,
|
// verifyUserAccess,
|
||||||
role.removeRoleSite
|
// role.removeRoleSite
|
||||||
);
|
// );
|
||||||
authenticated.put(
|
// authenticated.put(
|
||||||
"/user/:userId/resource",
|
// "/user/:userId/resource",
|
||||||
verifyResourceAccess,
|
// verifyResourceAccess,
|
||||||
verifyUserAccess,
|
// verifyUserAccess,
|
||||||
role.addRoleResource
|
// role.addRoleResource
|
||||||
);
|
// );
|
||||||
authenticated.delete(
|
// authenticated.delete(
|
||||||
"/user/:userId/resource",
|
// "/user/:userId/resource",
|
||||||
verifyResourceAccess,
|
// verifyResourceAccess,
|
||||||
verifyUserAccess,
|
// verifyUserAccess,
|
||||||
role.removeRoleResource
|
// role.removeRoleResource
|
||||||
);
|
// );
|
||||||
authenticated.put(
|
// authenticated.put(
|
||||||
"/org/:orgId/user/:userId/action",
|
// "/org/:orgId/user/:userId/action",
|
||||||
verifyOrgAccess,
|
// verifyOrgAccess,
|
||||||
verifyUserAccess,
|
// verifyUserAccess,
|
||||||
verifySuperuser,
|
// verifySuperUser,
|
||||||
role.addRoleAction
|
// role.addRoleAction
|
||||||
);
|
// );
|
||||||
authenticated.delete(
|
// authenticated.delete(
|
||||||
"/org/:orgId/user/:userId/action",
|
// "/org/:orgId/user/:userId/action",
|
||||||
verifyOrgAccess,
|
// verifyOrgAccess,
|
||||||
verifyUserAccess,
|
// verifyUserAccess,
|
||||||
verifySuperuser,
|
// verifySuperUser,
|
||||||
role.removeRoleAction
|
// role.removeRoleAction
|
||||||
);
|
// );
|
||||||
|
|
||||||
// Auth routes
|
// Auth routes
|
||||||
export const authRouter = Router();
|
export const authRouter = Router();
|
||||||
|
|
|
@ -8,7 +8,7 @@ import HttpCode from '@server/types/HttpCode';
|
||||||
import createHttpError from 'http-errors';
|
import createHttpError from 'http-errors';
|
||||||
import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions';
|
import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { createSuperuserRole } from '@server/db/ensureActions';
|
import { createSuperUserRole } from '@server/db/ensureActions';
|
||||||
import config, { APP_PATH } from "@server/config";
|
import config, { APP_PATH } from "@server/config";
|
||||||
import { fromError } from 'zod-validation-error';
|
import { fromError } from 'zod-validation-error';
|
||||||
|
|
||||||
|
@ -75,13 +75,13 @@ export async function createOrg(req: Request, res: Response, next: NextFunction)
|
||||||
domain
|
domain
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
const roleId = await createSuperuserRole(newOrg[0].orgId);
|
const roleId = await createSuperUserRole(newOrg[0].orgId);
|
||||||
|
|
||||||
if (!roleId) {
|
if (!roleId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
`Error creating superuser role`
|
`Error creating Super User role`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -87,27 +87,27 @@ export async function createResource(req: Request, res: Response, next: NextFunc
|
||||||
subdomain,
|
subdomain,
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
// find the superuser roleId and also add the resource to the superuser role
|
// find the Super User roleId and also add the resource to the Super User role
|
||||||
const superuserRole = await db.select()
|
const superUserRole = await db.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(and(eq(roles.isSuperuserRole, true), eq(roles.orgId, orgId)))
|
.where(and(eq(roles.isSuperUserRole, true), eq(roles.orgId, orgId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (superuserRole.length === 0) {
|
if (superUserRole.length === 0) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.NOT_FOUND,
|
HttpCode.NOT_FOUND,
|
||||||
`Superuser role not found`
|
`Super User role not found`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(roleResources).values({
|
await db.insert(roleResources).values({
|
||||||
roleId: superuserRole[0].roleId,
|
roleId: superUserRole[0].roleId,
|
||||||
resourceId: newResource[0].resourceId,
|
resourceId: newResource[0].resourceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (req.userOrgRoleId != superuserRole[0].roleId) {
|
if (req.userOrgRoleId != superUserRole[0].roleId) {
|
||||||
// make sure the user can access the resource
|
// make sure the user can access the resource
|
||||||
await db.insert(userResources).values({
|
await db.insert(userResources).values({
|
||||||
userId: req.user?.userId!,
|
userId: req.user?.userId!,
|
||||||
|
|
|
@ -51,7 +51,7 @@ export async function listResourceRoles(
|
||||||
roleId: roles.roleId,
|
roleId: roles.roleId,
|
||||||
name: roles.name,
|
name: roles.name,
|
||||||
description: roles.description,
|
description: roles.description,
|
||||||
isSuperuserRole: roles.isSuperuserRole,
|
isSuperUserRole: roles.isSuperUserRole,
|
||||||
})
|
})
|
||||||
.from(roleResources)
|
.from(roleResources)
|
||||||
.innerJoin(roles, eq(roleResources.roleId, roles.roleId))
|
.innerJoin(roles, eq(roleResources.roleId, roles.roleId))
|
||||||
|
|
|
@ -61,11 +61,11 @@ export async function deleteRole(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role[0].isSuperuserRole) {
|
if (role[0].isSuperUserRole) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
`Cannot delete a superuser role`
|
`Cannot delete a Super User role`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -80,7 +80,7 @@ export async function listRoles(
|
||||||
.select({
|
.select({
|
||||||
roleId: roles.roleId,
|
roleId: roles.roleId,
|
||||||
orgId: roles.orgId,
|
orgId: roles.orgId,
|
||||||
isSuperuserRole: roles.isSuperuserRole,
|
isSuperUserRole: roles.isSuperUserRole,
|
||||||
name: roles.name,
|
name: roles.name,
|
||||||
description: roles.description,
|
description: roles.description,
|
||||||
orgName: orgs.name,
|
orgName: orgs.name,
|
||||||
|
|
|
@ -81,11 +81,11 @@ export async function updateRole(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role[0].isSuperuserRole) {
|
if (role[0].isSuperUserRole) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
`Cannot update a superuser role`
|
`Cannot update a Super User role`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -107,25 +107,25 @@ export async function createSite(
|
||||||
subnet,
|
subnet,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
// find the superuser roleId and also add the resource to the superuser role
|
// find the Super User roleId and also add the resource to the Super User role
|
||||||
const superuserRole = await db
|
const superUserRole = await db
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(and(eq(roles.isSuperuserRole, true), eq(roles.orgId, orgId)))
|
.where(and(eq(roles.isSuperUserRole, true), eq(roles.orgId, orgId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (superuserRole.length === 0) {
|
if (superUserRole.length === 0) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.NOT_FOUND, `Superuser role not found`)
|
createHttpError(HttpCode.NOT_FOUND, `Super User role not found`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(roleSites).values({
|
await db.insert(roleSites).values({
|
||||||
roleId: superuserRole[0].roleId,
|
roleId: superUserRole[0].roleId,
|
||||||
siteId: newSite.siteId,
|
siteId: newSite.siteId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (req.userOrgRoleId != superuserRole[0].roleId) {
|
if (req.userOrgRoleId != superUserRole[0].roleId) {
|
||||||
// make sure the user can access the site
|
// make sure the user can access the site
|
||||||
db.insert(userSites).values({
|
db.insert(userSites).values({
|
||||||
userId: req.user?.userId!,
|
userId: req.user?.userId!,
|
||||||
|
|
|
@ -51,7 +51,7 @@ export async function listSiteRoles(
|
||||||
roleId: roles.roleId,
|
roleId: roles.roleId,
|
||||||
name: roles.name,
|
name: roles.name,
|
||||||
description: roles.description,
|
description: roles.description,
|
||||||
isSuperuserRole: roles.isSuperuserRole,
|
isSuperUserRole: roles.isSuperUserRole,
|
||||||
})
|
})
|
||||||
.from(roleSites)
|
.from(roleSites)
|
||||||
.innerJoin(roles, eq(roleSites.roleId, roles.roleId))
|
.innerJoin(roles, eq(roleSites.roleId, roles.roleId))
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
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 { userInvites, userOrgs, users } from "@server/db/schema";
|
import { orgs, userInvites, userOrgs, users } from "@server/db/schema";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import response from "@server/utils/response";
|
import response from "@server/utils/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
@ -13,6 +13,8 @@ import { createDate, TimeSpan } from "oslo";
|
||||||
import config from "@server/config";
|
import config from "@server/config";
|
||||||
import { hashPassword } from "@server/auth/password";
|
import { hashPassword } from "@server/auth/password";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { sendEmail } from "@server/emails";
|
||||||
|
import SendInviteLink from "@server/emails/templates/SendInviteLink";
|
||||||
|
|
||||||
const inviteUserParamsSchema = z.object({
|
const inviteUserParamsSchema = z.object({
|
||||||
orgId: z.string(),
|
orgId: z.string(),
|
||||||
|
@ -31,6 +33,8 @@ export type InviteUserResponse = {
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const inviteTracker: Record<string, { timestamps: number[] }> = {};
|
||||||
|
|
||||||
export async function inviteUser(
|
export async function inviteUser(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
|
@ -73,6 +77,39 @@ export async function inviteUser(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentTime = Date.now();
|
||||||
|
const oneHourAgo = currentTime - 3600000;
|
||||||
|
|
||||||
|
if (!inviteTracker[email]) {
|
||||||
|
inviteTracker[email] = { timestamps: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
inviteTracker[email].timestamps = inviteTracker[
|
||||||
|
email
|
||||||
|
].timestamps.filter((timestamp) => timestamp > oneHourAgo);
|
||||||
|
|
||||||
|
if (inviteTracker[email].timestamps.length >= 3) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.TOO_MANY_REQUESTS,
|
||||||
|
"User has already been invited 3 times in the last hour"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
inviteTracker[email].timestamps.push(currentTime);
|
||||||
|
|
||||||
|
const org = await db
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
if (!org.length) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Organization not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const existingUser = await db
|
const existingUser = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
|
@ -116,6 +153,21 @@ export async function inviteUser(
|
||||||
|
|
||||||
const inviteLink = `${config.app.base_url}/invite?token=${inviteId}-${token}`;
|
const inviteLink = `${config.app.base_url}/invite?token=${inviteId}-${token}`;
|
||||||
|
|
||||||
|
await sendEmail(
|
||||||
|
SendInviteLink({
|
||||||
|
email,
|
||||||
|
inviteLink,
|
||||||
|
expiresInDays: (validHours / 24).toString(),
|
||||||
|
orgName: orgId,
|
||||||
|
inviterName: req.user?.email,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
to: email,
|
||||||
|
from: config.email?.no_reply,
|
||||||
|
subject: "You're invited to join a Fossorial organization",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return response<InviteUserResponse>(res, {
|
return response<InviteUserResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
inviteLink,
|
inviteLink,
|
||||||
|
|
|
@ -11,7 +11,7 @@ import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
const removeUserSchema = z.object({
|
const removeUserSchema = z.object({
|
||||||
userId: z.string().uuid(),
|
userId: z.string(),
|
||||||
orgId: z.string(),
|
orgId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -33,7 +33,6 @@ export async function removeUserOrg(
|
||||||
|
|
||||||
const { userId, orgId } = parsedParams.data;
|
const { userId, orgId } = parsedParams.data;
|
||||||
|
|
||||||
// Check if the user has permission to list sites
|
|
||||||
const hasPermission = await checkUserActionPermission(
|
const hasPermission = await checkUserActionPermission(
|
||||||
ActionsEnum.removeUser,
|
ActionsEnum.removeUser,
|
||||||
req
|
req
|
||||||
|
@ -56,7 +55,7 @@ export async function removeUserOrg(
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "User deleted successfully",
|
message: "User remove from org successfully",
|
||||||
status: HttpCode.OK,
|
status: HttpCode.OK,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -79,7 +79,7 @@ export const columns: ColumnDef<SiteRow>[] = [
|
||||||
.then(() => {
|
.then(() => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
@ -98,7 +98,12 @@ export const columns: ColumnDef<SiteRow>[] = [
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<button onClick={() => deleteSite(siteRow.id)} className="text-red-600 hover:text-red-800 hover:underline cursor-pointer">Delete</button>
|
<button
|
||||||
|
onClick={() => deleteSite(siteRow.id)}
|
||||||
|
className="text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
|
@ -122,7 +122,13 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Credenza open={open} onOpenChange={setOpen}>
|
<Credenza open={open} onOpenChange={(val) => {
|
||||||
|
setOpen(val);
|
||||||
|
setInviteLink(null);
|
||||||
|
setLoading(false);
|
||||||
|
setExpiresInDays(1);
|
||||||
|
form.reset();
|
||||||
|
}}>
|
||||||
<CredenzaContent>
|
<CredenzaContent>
|
||||||
<CredenzaHeader>
|
<CredenzaHeader>
|
||||||
<CredenzaTitle>Invite User</CredenzaTitle>
|
<CredenzaTitle>Invite User</CredenzaTitle>
|
||||||
|
@ -257,7 +263,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
||||||
type="submit"
|
type="submit"
|
||||||
form="invite-user-form"
|
form="invite-user-form"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={inviteLink !== null}
|
disabled={inviteLink !== null || loading}
|
||||||
>
|
>
|
||||||
Create Invitation
|
Create Invitation
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -12,60 +12,178 @@ import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||||
import { UsersDataTable } from "./UsersDataTable";
|
import { UsersDataTable } from "./UsersDataTable";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import InviteUserForm from "./InviteUserForm";
|
import InviteUserForm from "./InviteUserForm";
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
import api from "@app/api";
|
||||||
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
|
import { useToast } from "@app/hooks/useToast";
|
||||||
|
|
||||||
export type UserRow = {
|
export type UserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
status: string;
|
||||||
|
role: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const columns: ColumnDef<UserRow>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: "email",
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Email
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const userRow = row.original;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
||||||
<span className="sr-only">Open menu</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem>Edit access</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
type UsersTableProps = {
|
type UsersTableProps = {
|
||||||
users: UserRow[];
|
users: UserRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UsersTable({ users }: UsersTableProps) {
|
export default function UsersTable({ users }: UsersTableProps) {
|
||||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [userToRemove, setUserToRemove] = useState<UserRow | null>(null);
|
||||||
|
|
||||||
|
const user = useUserContext();
|
||||||
|
const { org } = useOrgContext();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const columns: ColumnDef<UserRow>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "email",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "role",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Role
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const userRow = row.original;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>Manage user</DropdownMenuItem>
|
||||||
|
{userRow.email !== user?.email && (
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<button
|
||||||
|
className="text-red-600 hover:text-red-800"
|
||||||
|
onClick={() => {
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
setUserToRemove(userRow);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove User
|
||||||
|
</button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function removeUser() {
|
||||||
|
if (userToRemove) {
|
||||||
|
const res = await api
|
||||||
|
.delete(`/org/${org!.org.orgId}/user/${userToRemove.id}`)
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to remove user",
|
||||||
|
description:
|
||||||
|
e.message ??
|
||||||
|
"An error occurred while removing the user.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
toast({
|
||||||
|
variant: "default",
|
||||||
|
title: "User removed",
|
||||||
|
description: `The user ${userToRemove.email} has been removed from the organization.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
setOpen={(val) => {
|
||||||
|
setIsDeleteModalOpen(val);
|
||||||
|
setUserToRemove(null);
|
||||||
|
}}
|
||||||
|
dialog={
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">
|
||||||
|
Are you sure you want to remove{" "}
|
||||||
|
<b>{userToRemove?.email}</b> from the organization?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mb-2">
|
||||||
|
Once removed, this user will no longer have access
|
||||||
|
to the organization. You can always re-invite them
|
||||||
|
later, but they will need to accept the invitation
|
||||||
|
again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
To confirm, please type the email address of the
|
||||||
|
user below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
buttonText="Confirm remove user"
|
||||||
|
onConfirm={removeUser}
|
||||||
|
string={userToRemove?.email ?? ""}
|
||||||
|
title="Remove user from organization"
|
||||||
|
/>
|
||||||
|
|
||||||
<InviteUserForm
|
<InviteUserForm
|
||||||
open={isInviteModalOpen}
|
open={isInviteModalOpen}
|
||||||
setOpen={setIsInviteModalOpen}
|
setOpen={setIsInviteModalOpen}
|
||||||
|
|
|
@ -6,6 +6,8 @@ import UsersTable, { UserRow } from "./components/UsersTable";
|
||||||
import { GetOrgResponse } from "@server/routers/org";
|
import { GetOrgResponse } from "@server/routers/org";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
|
import UserProvider from "@app/providers/UserProvider";
|
||||||
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
|
|
||||||
type UsersPageProps = {
|
type UsersPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
|
@ -14,6 +16,9 @@ type UsersPageProps = {
|
||||||
export default async function UsersPage(props: UsersPageProps) {
|
export default async function UsersPage(props: UsersPageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
|
const getUser = cache(verifySession);
|
||||||
|
const user = await getUser();
|
||||||
|
|
||||||
let users: ListUsersResponse["users"] = [];
|
let users: ListUsersResponse["users"] = [];
|
||||||
const res = await internal
|
const res = await internal
|
||||||
.get<AxiosResponse<ListUsersResponse>>(
|
.get<AxiosResponse<ListUsersResponse>>(
|
||||||
|
@ -49,6 +54,8 @@ export default async function UsersPage(props: UsersPageProps) {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
status: "Confirmed",
|
||||||
|
role: user.roleName || "",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -64,9 +71,11 @@ export default async function UsersPage(props: UsersPageProps) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<OrgProvider org={org}>
|
<UserProvider user={user!}>
|
||||||
<UsersTable users={userRows} />
|
<OrgProvider org={org}>
|
||||||
</OrgProvider>
|
<UsersTable users={userRows} />
|
||||||
|
</OrgProvider>
|
||||||
|
</UserProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { internal } from "@app/api";
|
import { internal } from "@app/api";
|
||||||
import { authCookieHeader } from "@app/api/cookies";
|
import { authCookieHeader } from "@app/api/cookies";
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import { LandingProvider } from "@app/providers/LandingProvider";
|
import UserProvider from "@app/providers/UserProvider";
|
||||||
import { ListOrgsResponse } from "@server/routers/org";
|
import { ListOrgsResponse } from "@server/routers/org";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { ArrowUpRight } from "lucide-react";
|
import { ArrowUpRight } from "lucide-react";
|
||||||
|
@ -42,9 +42,9 @@ export default async function Page(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LandingProvider user={user}>
|
<UserProvider user={user}>
|
||||||
<p>Logged in as {user.email}</p>
|
<p>Logged in as {user.email}</p>
|
||||||
</LandingProvider>
|
</UserProvider>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{orgs.map((org) => (
|
{orgs.map((org) => (
|
||||||
|
|
144
src/components/ConfirmDeleteDialog.tsx
Normal file
144
src/components/ConfirmDeleteDialog.tsx
Normal file
|
@ -0,0 +1,144 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import api from "@app/api";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@app/components/ui/select";
|
||||||
|
import { useToast } from "@app/hooks/useToast";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
InviteUserBody,
|
||||||
|
InviteUserResponse,
|
||||||
|
ListUsersResponse,
|
||||||
|
} from "@server/routers/user";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle,
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
|
import { Description } from "@radix-ui/react-toast";
|
||||||
|
|
||||||
|
type InviteUserFormProps = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
string: string;
|
||||||
|
title: string;
|
||||||
|
dialog: React.ReactNode;
|
||||||
|
buttonText: string;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InviteUserForm({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
string,
|
||||||
|
title,
|
||||||
|
onConfirm,
|
||||||
|
buttonText,
|
||||||
|
dialog,
|
||||||
|
}: InviteUserFormProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
string: z.string().refine((val) => val === string, {
|
||||||
|
message: "Invalid confirmation",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
string: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
form.reset();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
setLoading(true);
|
||||||
|
await onConfirm();
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Credenza
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setOpen(val);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>{title}</CredenzaTitle>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<div className="mb-4">{dialog}</div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="confirm-delete-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="string"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="confirm-delete-form"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,36 +1,39 @@
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
secondary:
|
secondary:
|
||||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
outline: "text-foreground",
|
outline: "text-foreground",
|
||||||
},
|
green: "border-transparent bg-green-300",
|
||||||
},
|
yellow: "border-transparent bg-yellow-300",
|
||||||
defaultVariants: {
|
red: "border-transparent bg-red-300",
|
||||||
variant: "default",
|
},
|
||||||
},
|
},
|
||||||
}
|
defaultVariants: {
|
||||||
)
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export interface BadgeProps
|
export interface BadgeProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
VariantProps<typeof badgeVariants> {}
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
export { Badge, badgeVariants };
|
||||||
|
|
|
@ -4,13 +4,13 @@ import { UserContext } from "@app/contexts/userContext";
|
||||||
import { GetUserResponse } from "@server/routers/user";
|
import { GetUserResponse } from "@server/routers/user";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
type LandingProviderProps = {
|
type UserProviderProps = {
|
||||||
user: GetUserResponse;
|
user: GetUserResponse;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LandingProvider({ user, children }: LandingProviderProps) {
|
export function UserProvider({ user, children }: UserProviderProps) {
|
||||||
return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
|
return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LandingProvider;
|
export default UserProvider;
|
Loading…
Add table
Add a link
Reference in a new issue