mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-09 21:44:51 +02:00
create, delete, and update idp org policies
This commit is contained in:
parent
3bab90891f
commit
99188233db
22 changed files with 1036 additions and 108 deletions
|
@ -14,4 +14,5 @@ export * from "./verifyAdmin";
|
||||||
export * from "./verifySetResourceUsers";
|
export * from "./verifySetResourceUsers";
|
||||||
export * from "./verifyUserInRole";
|
export * from "./verifyUserInRole";
|
||||||
export * from "./verifyAccessTokenAccess";
|
export * from "./verifyAccessTokenAccess";
|
||||||
export * from "./verifyUserIsServerAdmin";
|
export * from "./verifyUserIsServerAdmin";
|
||||||
|
export * from "./verifyIsLoggedInUser";
|
||||||
|
|
44
server/middlewares/verifyIsLoggedInUser.ts
Normal file
44
server/middlewares/verifyIsLoggedInUser.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
|
||||||
|
export async function verifyIsLoggedInUser(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const reqUserId =
|
||||||
|
req.params.userId || req.body.userId || req.query.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow server admins to access any user
|
||||||
|
if (req.user?.serverAdmin) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reqUserId !== userId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User only has access to their own account"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error checking if user has access to this user"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,7 +25,8 @@ import {
|
||||||
verifySetResourceUsers,
|
verifySetResourceUsers,
|
||||||
verifyUserAccess,
|
verifyUserAccess,
|
||||||
getUserOrgs,
|
getUserOrgs,
|
||||||
verifyUserIsServerAdmin
|
verifyUserIsServerAdmin,
|
||||||
|
verifyIsLoggedInUser
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
|
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
|
||||||
import { ActionsEnum } from "@server/auth/actions";
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
|
@ -47,7 +48,10 @@ authenticated.use(verifySessionUserMiddleware);
|
||||||
|
|
||||||
authenticated.get("/org/checkId", org.checkId);
|
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", verifyUserIsServerAdmin, org.listOrgs);
|
||||||
|
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId",
|
"/org/:orgId",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
@ -507,23 +511,11 @@ authenticated.post(
|
||||||
idp.updateOidcIdp
|
idp.updateOidcIdp
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
|
||||||
"/idp/:idpId",
|
|
||||||
verifyUserIsServerAdmin,
|
|
||||||
idp.deleteIdp
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get("/idp", verifyUserIsServerAdmin, idp.listIdps);
|
||||||
"/idp",
|
|
||||||
verifyUserIsServerAdmin,
|
|
||||||
idp.listIdps
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
|
||||||
"/idp/:idpId",
|
|
||||||
verifyUserIsServerAdmin,
|
|
||||||
idp.getIdp
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/idp/:idpId/org/:orgId",
|
"/idp/:idpId/org/:orgId",
|
||||||
|
@ -531,6 +523,12 @@ authenticated.put(
|
||||||
idp.createIdpOrgPolicy
|
idp.createIdpOrgPolicy
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/idp/:idpId/org/:orgId",
|
||||||
|
verifyUserIsServerAdmin,
|
||||||
|
idp.updateIdpOrgPolicy
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/idp/:idpId/org/:orgId",
|
"/idp/:idpId/org/:orgId",
|
||||||
verifyUserIsServerAdmin,
|
verifyUserIsServerAdmin,
|
||||||
|
@ -631,17 +629,8 @@ authRouter.post(
|
||||||
resource.authWithAccessToken
|
resource.authWithAccessToken
|
||||||
);
|
);
|
||||||
|
|
||||||
authRouter.post(
|
authRouter.post("/access-token", resource.authWithAccessToken);
|
||||||
"/access-token",
|
|
||||||
resource.authWithAccessToken
|
|
||||||
);
|
|
||||||
|
|
||||||
authRouter.post(
|
authRouter.post("/idp/:idpId/oidc/generate-url", idp.generateOidcUrl);
|
||||||
"/idp/:idpId/oidc/generate-url",
|
|
||||||
idp.generateOidcUrl
|
|
||||||
);
|
|
||||||
|
|
||||||
authRouter.post(
|
authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback);
|
||||||
"/idp/:idpId/oidc/validate-callback",
|
|
||||||
idp.validateOidcCallback
|
|
||||||
);
|
|
||||||
|
|
|
@ -77,10 +77,13 @@ export async function createIdpOrgPolicy(
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(idp)
|
.from(idp)
|
||||||
.leftJoin(idpOrg, eq(idpOrg.orgId, orgId))
|
.leftJoin(
|
||||||
.where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId)));
|
idpOrg,
|
||||||
|
and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId))
|
||||||
|
)
|
||||||
|
.where(eq(idp.idpId, idpId));
|
||||||
|
|
||||||
if (!existing.idp) {
|
if (!existing?.idp) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|
|
@ -8,3 +8,4 @@ export * from "./getIdp";
|
||||||
export * from "./createIdpOrgPolicy";
|
export * from "./createIdpOrgPolicy";
|
||||||
export * from "./deleteIdpOrgPolicy";
|
export * from "./deleteIdpOrgPolicy";
|
||||||
export * from "./listIdpOrgPolicies";
|
export * from "./listIdpOrgPolicies";
|
||||||
|
export * from "./updateIdpOrgPolicy";
|
||||||
|
|
126
server/routers/idp/updateIdpOrgPolicy.ts
Normal file
126
server/routers/idp/updateIdpOrgPolicy.ts
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { idp, idpOrg } from "@server/db/schemas";
|
||||||
|
|
||||||
|
const paramsSchema = z
|
||||||
|
.object({
|
||||||
|
idpId: z.coerce.number(),
|
||||||
|
orgId: z.string()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const bodySchema = z
|
||||||
|
.object({
|
||||||
|
roleMapping: z.string().optional(),
|
||||||
|
orgMapping: z.string().optional()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type UpdateIdpOrgPolicyResponse = {};
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/idp/{idpId}/org/{orgId}",
|
||||||
|
description: "Update an IDP org policy.",
|
||||||
|
tags: [OpenAPITags.Idp],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: bodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateIdpOrgPolicy(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { idpId, orgId } = parsedParams.data;
|
||||||
|
const { roleMapping, orgMapping } = parsedBody.data;
|
||||||
|
|
||||||
|
// Check if IDP and policy exist
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(idp)
|
||||||
|
.leftJoin(
|
||||||
|
idpOrg,
|
||||||
|
and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId))
|
||||||
|
)
|
||||||
|
.where(eq(idp.idpId, idpId));
|
||||||
|
|
||||||
|
if (!existing?.idp) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"An IDP with this ID does not exist."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing.idpOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"A policy for this IDP and org does not exist."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the policy
|
||||||
|
await db
|
||||||
|
.update(idpOrg)
|
||||||
|
.set({
|
||||||
|
roleMapping,
|
||||||
|
orgMapping
|
||||||
|
})
|
||||||
|
.where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId)));
|
||||||
|
|
||||||
|
return response<UpdateIdpOrgPolicyResponse>(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Policy updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ export * from "./getOrg";
|
||||||
export * from "./createOrg";
|
export * from "./createOrg";
|
||||||
export * from "./deleteOrg";
|
export * from "./deleteOrg";
|
||||||
export * from "./updateOrg";
|
export * from "./updateOrg";
|
||||||
export * from "./listOrgs";
|
export * from "./listUserOrgs";
|
||||||
export * from "./checkId";
|
export * from "./checkId";
|
||||||
export * from "./getOrgOverview";
|
export * from "./getOrgOverview";
|
||||||
|
export * from "./listOrgs";
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
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 { Org, orgs } from "@server/db/schemas";
|
import { Org, orgs, userOrgs } from "@server/db/schemas";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { sql, inArray } from "drizzle-orm";
|
import { sql, inArray, eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromZodError } from "zod-validation-error";
|
import { fromZodError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
@ -27,8 +27,8 @@ const listOrgsSchema = z.object({
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/orgs",
|
path: "/user/:userId/orgs",
|
||||||
description: "List all organizations in the system",
|
description: "List all organizations in the system.",
|
||||||
tags: [OpenAPITags.Org],
|
tags: [OpenAPITags.Org],
|
||||||
request: {
|
request: {
|
||||||
query: listOrgsSchema
|
query: listOrgsSchema
|
||||||
|
@ -59,37 +59,15 @@ export async function listOrgs(
|
||||||
|
|
||||||
const { limit, offset } = parsedQuery.data;
|
const { limit, offset } = parsedQuery.data;
|
||||||
|
|
||||||
// Use the userOrgs passed from the middleware
|
|
||||||
const userOrgIds = req.userOrgIds;
|
|
||||||
|
|
||||||
if (!userOrgIds || userOrgIds.length === 0) {
|
|
||||||
return response<ListOrgsResponse>(res, {
|
|
||||||
data: {
|
|
||||||
orgs: [],
|
|
||||||
pagination: {
|
|
||||||
total: 0,
|
|
||||||
limit,
|
|
||||||
offset
|
|
||||||
}
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "No organizations found for the user",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const organizations = await db
|
const organizations = await db
|
||||||
.select()
|
.select()
|
||||||
.from(orgs)
|
.from(orgs)
|
||||||
.where(inArray(orgs.orgId, userOrgIds))
|
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
|
||||||
const totalCountResult = await db
|
const totalCountResult = await db
|
||||||
.select({ count: sql<number>`cast(count(*) as integer)` })
|
.select({ count: sql<number>`cast(count(*) as integer)` })
|
||||||
.from(orgs)
|
.from(orgs);
|
||||||
.where(inArray(orgs.orgId, userOrgIds));
|
|
||||||
const totalCount = totalCountResult[0].count;
|
const totalCount = totalCountResult[0].count;
|
||||||
|
|
||||||
return response<ListOrgsResponse>(res, {
|
return response<ListOrgsResponse>(res, {
|
||||||
|
|
141
server/routers/org/listUserOrgs.ts
Normal file
141
server/routers/org/listUserOrgs.ts
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { Org, orgs, userOrgs } from "@server/db/schemas";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { sql, inArray, eq } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromZodError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const listOrgsParamsSchema = z.object({
|
||||||
|
userId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
const listOrgsSchema = z.object({
|
||||||
|
limit: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("1000")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive()),
|
||||||
|
offset: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("0")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().nonnegative())
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/user/:userId/orgs",
|
||||||
|
description: "List all organizations for a user.",
|
||||||
|
tags: [OpenAPITags.Org, OpenAPITags.User],
|
||||||
|
request: {
|
||||||
|
query: listOrgsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ListUserOrgsResponse = {
|
||||||
|
orgs: Org[];
|
||||||
|
pagination: { total: number; limit: number; offset: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listUserOrgs(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = listOrgsSchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromZodError(parsedQuery.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { limit, offset } = parsedQuery.data;
|
||||||
|
|
||||||
|
const parsedParams = listOrgsParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromZodError(parsedParams.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = parsedParams.data;
|
||||||
|
|
||||||
|
const userOrganizations = await db
|
||||||
|
.select({
|
||||||
|
orgId: userOrgs.orgId,
|
||||||
|
roleId: userOrgs.roleId
|
||||||
|
})
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(eq(userOrgs.userId, userId));
|
||||||
|
|
||||||
|
const userOrgIds = userOrganizations.map((org) => org.orgId);
|
||||||
|
|
||||||
|
if (!userOrgIds || userOrgIds.length === 0) {
|
||||||
|
return response<ListUserOrgsResponse>(res, {
|
||||||
|
data: {
|
||||||
|
orgs: [],
|
||||||
|
pagination: {
|
||||||
|
total: 0,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "No organizations found for the user",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizations = await db
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(inArray(orgs.orgId, userOrgIds))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
const totalCountResult = await db
|
||||||
|
.select({ count: sql<number>`cast(count(*) as integer)` })
|
||||||
|
.from(orgs)
|
||||||
|
.where(inArray(orgs.orgId, userOrgIds));
|
||||||
|
const totalCount = totalCountResult[0].count;
|
||||||
|
|
||||||
|
return response<ListUserOrgsResponse>(res, {
|
||||||
|
data: {
|
||||||
|
orgs: organizations,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Organizations retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"An error occurred..."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -31,7 +31,7 @@ import {
|
||||||
CardTitle
|
CardTitle
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { DeleteOrgResponse, ListOrgsResponse } from "@server/routers/org";
|
import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import { redirect, useRouter } from "next/navigation";
|
import { redirect, useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
|
@ -43,6 +43,7 @@ import {
|
||||||
SettingsSectionForm,
|
SettingsSectionForm,
|
||||||
SettingsSectionFooter
|
SettingsSectionFooter
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z.object({
|
||||||
name: z.string()
|
name: z.string()
|
||||||
|
@ -57,6 +58,7 @@ export default function GeneralPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
const { user } = useUserContext();
|
||||||
|
|
||||||
const [loadingDelete, setLoadingDelete] = useState(false);
|
const [loadingDelete, setLoadingDelete] = useState(false);
|
||||||
const [loadingSave, setLoadingSave] = useState(false);
|
const [loadingSave, setLoadingSave] = useState(false);
|
||||||
|
@ -101,7 +103,9 @@ export default function GeneralPage() {
|
||||||
|
|
||||||
async function pickNewOrgAndNavigate() {
|
async function pickNewOrgAndNavigate() {
|
||||||
try {
|
try {
|
||||||
const res = await api.get<AxiosResponse<ListOrgsResponse>>(`/orgs`);
|
const res = await api.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||||
|
`/user/${user.userId}/orgs`
|
||||||
|
);
|
||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
if (res.data.data.orgs.length > 0) {
|
if (res.data.data.orgs.length > 0) {
|
||||||
|
@ -237,9 +241,7 @@ export default function GeneralPage() {
|
||||||
|
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>Danger Zone</SettingsSectionTitle>
|
||||||
Danger Zone
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
Once you delete this org, there is no going back. Please
|
Once you delete this org, there is no going back. Please
|
||||||
be certain.
|
be certain.
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { GetOrgResponse, ListOrgsResponse } from "@server/routers/org";
|
import { GetOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { GetOrgUserResponse } from "@server/routers/user";
|
import { GetOrgUserResponse } from "@server/routers/user";
|
||||||
|
@ -62,10 +62,13 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
redirect(`/${params.orgId}`);
|
redirect(`/${params.orgId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let orgs: ListOrgsResponse["orgs"] = [];
|
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||||
try {
|
try {
|
||||||
const getOrgs = cache(() =>
|
const getOrgs = cache(() =>
|
||||||
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
|
internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||||
|
`/user/${user.userId}/orgs`,
|
||||||
|
cookie
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const res = await getOrgs();
|
const res = await getOrgs();
|
||||||
if (res && res.data.data.orgs) {
|
if (res && res.data.data.orgs) {
|
||||||
|
|
|
@ -39,6 +39,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
{
|
{
|
||||||
title: "General",
|
title: "General",
|
||||||
href: `/admin/idp/${params.idpId}/general`
|
href: `/admin/idp/${params.idpId}/general`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Organization Policies",
|
||||||
|
href: `/admin/idp/${params.idpId}/policies`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
28
src/app/admin/idp/[idpId]/policies/PolicyDataTable.tsx
Normal file
28
src/app/admin/idp/[idpId]/policies/PolicyDataTable.tsx
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { DataTable } from "@app/components/ui/data-table";
|
||||||
|
|
||||||
|
interface DataTableProps<TData, TValue> {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
onAdd: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PolicyDataTable<TData, TValue>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
onAdd
|
||||||
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
title="Organization Policies"
|
||||||
|
searchPlaceholder="Search organization policies..."
|
||||||
|
searchColumn="orgId"
|
||||||
|
addButtonText="Add Organization Policy"
|
||||||
|
onAdd={onAdd}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
154
src/app/admin/idp/[idpId]/policies/PolicyTable.tsx
Normal file
154
src/app/admin/idp/[idpId]/policies/PolicyTable.tsx
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
|
Trash2,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
ArrowRight
|
||||||
|
} from "lucide-react";
|
||||||
|
import { PolicyDataTable } from "./PolicyDataTable";
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@app/components/ui/dropdown-menu";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
|
||||||
|
export interface PolicyRow {
|
||||||
|
orgId: string;
|
||||||
|
roleMapping?: string;
|
||||||
|
orgMapping?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
policies: PolicyRow[];
|
||||||
|
onDelete: (orgId: string) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (policy: PolicyRow) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props) {
|
||||||
|
const columns: ColumnDef<PolicyRow>[] = [
|
||||||
|
{
|
||||||
|
id: "dots",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = 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
|
||||||
|
onClick={() => {
|
||||||
|
onDelete(r.orgId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-red-500">Delete</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "orgId",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Organization ID
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "roleMapping",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Role Mapping
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const mapping = row.original.roleMapping;
|
||||||
|
return mapping ? (
|
||||||
|
<InfoPopup
|
||||||
|
text={mapping.length > 50 ? `${mapping.substring(0, 50)}...` : mapping}
|
||||||
|
info={mapping}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
"--"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "orgMapping",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Organization Mapping
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const mapping = row.original.orgMapping;
|
||||||
|
return mapping ? (
|
||||||
|
<InfoPopup
|
||||||
|
text={mapping.length > 50 ? `${mapping.substring(0, 50)}...` : mapping}
|
||||||
|
info={mapping}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
"--"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const policy = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button
|
||||||
|
variant={"outlinePrimary"}
|
||||||
|
className="ml-2"
|
||||||
|
onClick={() => onEdit(policy)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return <PolicyDataTable columns={columns} data={policies} onAdd={onAdd} />;
|
||||||
|
}
|
453
src/app/admin/idp/[idpId]/policies/page.tsx
Normal file
453
src/app/admin/idp/[idpId]/policies/page.tsx
Normal file
|
@ -0,0 +1,453 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
|
import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react";
|
||||||
|
import PolicyTable, { PolicyRow } from "./PolicyTable";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { ListOrgsResponse } from "@server/routers/org";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@app/components/ui/popover";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList
|
||||||
|
} from "@app/components/ui/command";
|
||||||
|
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Textarea } from "@app/components/ui/textarea";
|
||||||
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
|
||||||
|
type Organization = {
|
||||||
|
orgId: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const policyFormSchema = z.object({
|
||||||
|
orgId: z.string().min(1, { message: "Organization is required" }),
|
||||||
|
roleMapping: z.string().optional(),
|
||||||
|
orgMapping: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
type PolicyFormValues = z.infer<typeof policyFormSchema>;
|
||||||
|
|
||||||
|
export default function PoliciesPage() {
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const router = useRouter();
|
||||||
|
const { idpId } = useParams();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [policies, setPolicies] = useState<PolicyRow[]>([]);
|
||||||
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
|
const [editingPolicy, setEditingPolicy] = useState<PolicyRow | null>(null);
|
||||||
|
|
||||||
|
const form = useForm<PolicyFormValues>({
|
||||||
|
resolver: zodResolver(policyFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
orgId: "",
|
||||||
|
roleMapping: "",
|
||||||
|
orgMapping: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadPolicies = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/idp/${idpId}/org`);
|
||||||
|
if (res.status === 200) {
|
||||||
|
setPolicies(res.data.data.policies);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: formatAxiosError(e),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadOrganizations = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<AxiosResponse<ListOrgsResponse>>("/orgs");
|
||||||
|
if (res.status === 200) {
|
||||||
|
const existingOrgIds = policies.map((p) => p.orgId);
|
||||||
|
const availableOrgs = res.data.data.orgs.filter(
|
||||||
|
(org) => !existingOrgIds.includes(org.orgId)
|
||||||
|
);
|
||||||
|
setOrganizations(availableOrgs);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: formatAxiosError(e),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
await loadPolicies();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, [idpId]);
|
||||||
|
|
||||||
|
const onAddPolicy = async (data: PolicyFormValues) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.put(`/idp/${idpId}/org/${data.orgId}`, {
|
||||||
|
roleMapping: data.roleMapping,
|
||||||
|
orgMapping: data.orgMapping
|
||||||
|
});
|
||||||
|
if (res.status === 201) {
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Policy added successfully"
|
||||||
|
});
|
||||||
|
loadPolicies();
|
||||||
|
setShowAddDialog(false);
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: formatAxiosError(e),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEditPolicy = async (data: PolicyFormValues) => {
|
||||||
|
if (!editingPolicy) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post(
|
||||||
|
`/idp/${idpId}/org/${editingPolicy.orgId}`,
|
||||||
|
{
|
||||||
|
roleMapping: data.roleMapping,
|
||||||
|
orgMapping: data.orgMapping
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (res.status === 200) {
|
||||||
|
setPolicies(
|
||||||
|
policies.map((policy) =>
|
||||||
|
policy.orgId === editingPolicy.orgId
|
||||||
|
? {
|
||||||
|
...policy,
|
||||||
|
roleMapping: data.roleMapping,
|
||||||
|
orgMapping: data.orgMapping
|
||||||
|
}
|
||||||
|
: policy
|
||||||
|
)
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Policy updated successfully"
|
||||||
|
});
|
||||||
|
setShowAddDialog(false);
|
||||||
|
setEditingPolicy(null);
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: formatAxiosError(e),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeletePolicy = async (orgId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await api.delete(`/idp/${idpId}/org/${orgId}`);
|
||||||
|
if (res.status === 200) {
|
||||||
|
setPolicies(
|
||||||
|
policies.filter((policy) => policy.orgId !== orgId)
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Policy deleted successfully"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: formatAxiosError(e),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Alert variant="neutral" className="mb-6">
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
<AlertTitle className="font-semibold">
|
||||||
|
About Organization Policies
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Organization policies are used to control access to
|
||||||
|
organizations based on the user's ID token. You can specify
|
||||||
|
JMESPath expressions to extract role and organization
|
||||||
|
information from the ID token. For more information, see{" "}
|
||||||
|
<Link
|
||||||
|
href=""
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
the documentation
|
||||||
|
<ExternalLink className="ml-1 h-4 w-4 inline" />
|
||||||
|
</Link>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<PolicyTable
|
||||||
|
policies={policies}
|
||||||
|
onDelete={onDeletePolicy}
|
||||||
|
onAdd={() => {
|
||||||
|
loadOrganizations();
|
||||||
|
setEditingPolicy(null);
|
||||||
|
setShowAddDialog(true);
|
||||||
|
}}
|
||||||
|
onEdit={(policy) => {
|
||||||
|
setEditingPolicy(policy);
|
||||||
|
form.reset({
|
||||||
|
orgId: policy.orgId,
|
||||||
|
roleMapping: policy.roleMapping || "",
|
||||||
|
orgMapping: policy.orgMapping || ""
|
||||||
|
});
|
||||||
|
setShowAddDialog(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Credenza
|
||||||
|
open={showAddDialog}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setShowAddDialog(val);
|
||||||
|
setLoading(false);
|
||||||
|
setEditingPolicy(null);
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{editingPolicy
|
||||||
|
? "Edit Organization Policy"
|
||||||
|
: "Add Organization Policy"}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
Configure access for an organization
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(
|
||||||
|
editingPolicy ? onEditPolicy : onAddPolicy
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="policy-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="orgId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel>Organization</FormLabel>
|
||||||
|
{editingPolicy ? (
|
||||||
|
<Input {...field} disabled />
|
||||||
|
) : (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
"justify-between",
|
||||||
|
!field.value &&
|
||||||
|
"text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{field.value
|
||||||
|
? organizations.find(
|
||||||
|
(
|
||||||
|
org
|
||||||
|
) =>
|
||||||
|
org.orgId ===
|
||||||
|
field.value
|
||||||
|
)?.name
|
||||||
|
: "Select organization"}
|
||||||
|
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search org" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
No org
|
||||||
|
found.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{organizations.map(
|
||||||
|
(
|
||||||
|
org
|
||||||
|
) => (
|
||||||
|
<CommandItem
|
||||||
|
value={`${org.orgId}`}
|
||||||
|
key={
|
||||||
|
org.orgId
|
||||||
|
}
|
||||||
|
onSelect={() => {
|
||||||
|
form.setValue(
|
||||||
|
"orgId",
|
||||||
|
org.orgId
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
org.orgId ===
|
||||||
|
field.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
org.name
|
||||||
|
}
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="roleMapping"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Role Mapping Path (Optional)
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
JMESPath to extract role
|
||||||
|
information from the ID token.
|
||||||
|
The result of this expression
|
||||||
|
must return the role name as a
|
||||||
|
string.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="orgMapping"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Organization Mapping Path
|
||||||
|
(Optional)
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
JMESPath to extract organization
|
||||||
|
information from the ID token.
|
||||||
|
This expression must return a
|
||||||
|
truthy value for the user to be
|
||||||
|
allowed to access the
|
||||||
|
organization.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="policy-form"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{editingPolicy ? "Update Policy" : "Add Policy"}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -4,7 +4,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import UserProvider from "@app/providers/UserProvider";
|
import UserProvider from "@app/providers/UserProvider";
|
||||||
import { ListOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
@ -31,10 +31,13 @@ export default async function AdminLayout(props: LayoutProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookie = await authCookieHeader();
|
const cookie = await authCookieHeader();
|
||||||
let orgs: ListOrgsResponse["orgs"] = [];
|
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||||
try {
|
try {
|
||||||
const getOrgs = cache(() =>
|
const getOrgs = cache(() =>
|
||||||
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
|
internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||||
|
`/user/${user.userId}/orgs`,
|
||||||
|
cookie
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const res = await getOrgs();
|
const res = await getOrgs();
|
||||||
if (res && res.data.data.orgs) {
|
if (res && res.data.data.orgs) {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { internal } from "@app/lib/api";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import UserProvider from "@app/providers/UserProvider";
|
import UserProvider from "@app/providers/UserProvider";
|
||||||
import { ListOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
|
@ -36,10 +36,7 @@ export default async function Page(props: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!user.emailVerified && env.flags.emailVerificationRequired) {
|
||||||
!user.emailVerified &&
|
|
||||||
env.flags.emailVerificationRequired
|
|
||||||
) {
|
|
||||||
if (params.redirect) {
|
if (params.redirect) {
|
||||||
const safe = cleanRedirect(params.redirect);
|
const safe = cleanRedirect(params.redirect);
|
||||||
redirect(`/auth/verify-email?redirect=${safe}`);
|
redirect(`/auth/verify-email?redirect=${safe}`);
|
||||||
|
@ -48,10 +45,10 @@ export default async function Page(props: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let orgs: ListOrgsResponse["orgs"] = [];
|
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<AxiosResponse<ListOrgsResponse>>(
|
const res = await internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||||
`/orgs`,
|
`/user/${user.userId}/orgs`,
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -61,24 +58,19 @@ export default async function Page(props: {
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
if (!orgs.length) {
|
if (!orgs.length) {
|
||||||
if (
|
if (!env.flags.disableUserCreateOrg || user.serverAdmin) {
|
||||||
!env.flags.disableUserCreateOrg ||
|
|
||||||
user.serverAdmin
|
|
||||||
) {
|
|
||||||
redirect("/setup");
|
redirect("/setup");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserProvider user={user}>
|
<UserProvider user={user}>
|
||||||
<Layout
|
<Layout orgs={orgs} navItems={rootNavItems} showBreadcrumbs={false}>
|
||||||
orgs={orgs}
|
|
||||||
navItems={rootNavItems}
|
|
||||||
showBreadcrumbs={false}
|
|
||||||
>
|
|
||||||
<div className="w-full max-w-md mx-auto md:mt-32 mt-4">
|
<div className="w-full max-w-md mx-auto md:mt-32 mt-4">
|
||||||
<OrganizationLanding
|
<OrganizationLanding
|
||||||
disableCreateOrg={env.flags.disableUserCreateOrg && !user.serverAdmin}
|
disableCreateOrg={
|
||||||
|
env.flags.disableUserCreateOrg && !user.serverAdmin
|
||||||
|
}
|
||||||
organizations={orgs.map((org) => ({
|
organizations={orgs.map((org) => ({
|
||||||
name: org.name,
|
name: org.name,
|
||||||
id: org.orgId
|
id: org.orgId
|
||||||
|
|
|
@ -4,7 +4,7 @@ import React, { useState } from "react";
|
||||||
import { SidebarNav } from "@app/components/SidebarNav";
|
import { SidebarNav } from "@app/components/SidebarNav";
|
||||||
import { OrgSelector } from "@app/components/OrgSelector";
|
import { OrgSelector } from "@app/components/OrgSelector";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { ListOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import SupporterStatus from "@app/components/SupporterStatus";
|
import SupporterStatus from "@app/components/SupporterStatus";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { ExternalLink, Menu, X, Server } from "lucide-react";
|
import { ExternalLink, Menu, X, Server } from "lucide-react";
|
||||||
|
@ -26,7 +26,7 @@ import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
orgs?: ListOrgsResponse["orgs"];
|
orgs?: ListUserOrgsResponse["orgs"];
|
||||||
navItems?: Array<{
|
navItems?: Array<{
|
||||||
title: string;
|
title: string;
|
||||||
href: string;
|
href: string;
|
||||||
|
|
|
@ -17,7 +17,7 @@ import {
|
||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { ListOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import { Check, ChevronsUpDown, Plus } from "lucide-react";
|
import { Check, ChevronsUpDown, Plus } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
@ -25,7 +25,7 @@ import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
|
||||||
interface OrgSelectorProps {
|
interface OrgSelectorProps {
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
orgs?: ListOrgsResponse["orgs"];
|
orgs?: ListUserOrgsResponse["orgs"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
|
export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
|
||||||
|
@ -121,4 +121,4 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-99 data-[state=open]:zoom-in-99 data-[state=closed]:slide-out-to-top-[5%] data-[state=open]:slide-in-from-bottom-[5%] sm:rounded-lg",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-99 data-[state=open]:zoom-in-99 data-[state=closed]:slide-out-to-bottom-[5%] data-[state=open]:slide-in-from-bottom-[5%] sm:rounded-lg",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
@ -10,24 +10,29 @@ import {
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface InfoPopupProps {
|
interface InfoPopupProps {
|
||||||
text: string;
|
text?: string;
|
||||||
info: string;
|
info: string;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InfoPopup({ text, info }: InfoPopupProps) {
|
export function InfoPopup({ text, info, trigger }: InfoPopupProps) {
|
||||||
|
const defaultTrigger = (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 rounded-full p-0"
|
||||||
|
>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Show info</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span>{text}</span>
|
{text && <span>{text}</span>}
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
{trigger ?? defaultTrigger}
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 rounded-full p-0"
|
|
||||||
>
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Show info</span>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-80">
|
<PopoverContent className="w-80">
|
||||||
<p className="text-sm text-muted-foreground">{info}</p>
|
<p className="text-sm text-muted-foreground">{info}</p>
|
||||||
|
|
|
@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex min-h-[80px] w-full rounded-md border border-input bg-card px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue