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
|
@ -15,3 +15,4 @@ export * from "./verifySetResourceUsers";
|
|||
export * from "./verifyUserInRole";
|
||||
export * from "./verifyAccessTokenAccess";
|
||||
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,
|
||||
verifyUserAccess,
|
||||
getUserOrgs,
|
||||
verifyUserIsServerAdmin
|
||||
verifyUserIsServerAdmin,
|
||||
verifyIsLoggedInUser
|
||||
} from "@server/middlewares";
|
||||
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
|
@ -47,7 +48,10 @@ authenticated.use(verifySessionUserMiddleware);
|
|||
|
||||
authenticated.get("/org/checkId", org.checkId);
|
||||
authenticated.put("/org", getUserOrgs, org.createOrg);
|
||||
authenticated.get("/orgs", getUserOrgs, org.listOrgs); // TODO we need to check the orgs here
|
||||
|
||||
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
|
||||
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId",
|
||||
verifyOrgAccess,
|
||||
|
@ -507,23 +511,11 @@ authenticated.post(
|
|||
idp.updateOidcIdp
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/idp/:idpId",
|
||||
verifyUserIsServerAdmin,
|
||||
idp.deleteIdp
|
||||
);
|
||||
authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
|
||||
|
||||
authenticated.get(
|
||||
"/idp",
|
||||
verifyUserIsServerAdmin,
|
||||
idp.listIdps
|
||||
);
|
||||
authenticated.get("/idp", verifyUserIsServerAdmin, idp.listIdps);
|
||||
|
||||
authenticated.get(
|
||||
"/idp/:idpId",
|
||||
verifyUserIsServerAdmin,
|
||||
idp.getIdp
|
||||
);
|
||||
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
|
||||
|
||||
authenticated.put(
|
||||
"/idp/:idpId/org/:orgId",
|
||||
|
@ -531,6 +523,12 @@ authenticated.put(
|
|||
idp.createIdpOrgPolicy
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/idp/:idpId/org/:orgId",
|
||||
verifyUserIsServerAdmin,
|
||||
idp.updateIdpOrgPolicy
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/idp/:idpId/org/:orgId",
|
||||
verifyUserIsServerAdmin,
|
||||
|
@ -631,17 +629,8 @@ authRouter.post(
|
|||
resource.authWithAccessToken
|
||||
);
|
||||
|
||||
authRouter.post(
|
||||
"/access-token",
|
||||
resource.authWithAccessToken
|
||||
);
|
||||
authRouter.post("/access-token", resource.authWithAccessToken);
|
||||
|
||||
authRouter.post(
|
||||
"/idp/:idpId/oidc/generate-url",
|
||||
idp.generateOidcUrl
|
||||
);
|
||||
authRouter.post("/idp/:idpId/oidc/generate-url", idp.generateOidcUrl);
|
||||
|
||||
authRouter.post(
|
||||
"/idp/:idpId/oidc/validate-callback",
|
||||
idp.validateOidcCallback
|
||||
);
|
||||
authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback);
|
||||
|
|
|
@ -77,10 +77,13 @@ export async function createIdpOrgPolicy(
|
|||
const [existing] = await db
|
||||
.select()
|
||||
.from(idp)
|
||||
.leftJoin(idpOrg, eq(idpOrg.orgId, orgId))
|
||||
.where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId)));
|
||||
.leftJoin(
|
||||
idpOrg,
|
||||
and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId))
|
||||
)
|
||||
.where(eq(idp.idpId, idpId));
|
||||
|
||||
if (!existing.idp) {
|
||||
if (!existing?.idp) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
|
|
|
@ -8,3 +8,4 @@ export * from "./getIdp";
|
|||
export * from "./createIdpOrgPolicy";
|
||||
export * from "./deleteIdpOrgPolicy";
|
||||
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 "./deleteOrg";
|
||||
export * from "./updateOrg";
|
||||
export * from "./listOrgs";
|
||||
export * from "./listUserOrgs";
|
||||
export * from "./checkId";
|
||||
export * from "./getOrgOverview";
|
||||
export * from "./listOrgs";
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
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 HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { sql, inArray } from "drizzle-orm";
|
||||
import { sql, inArray, eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
@ -27,8 +27,8 @@ const listOrgsSchema = z.object({
|
|||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/orgs",
|
||||
description: "List all organizations in the system",
|
||||
path: "/user/:userId/orgs",
|
||||
description: "List all organizations in the system.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
query: listOrgsSchema
|
||||
|
@ -59,37 +59,15 @@ export async function listOrgs(
|
|||
|
||||
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
|
||||
.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));
|
||||
.from(orgs);
|
||||
const totalCount = totalCountResult[0].count;
|
||||
|
||||
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
|
||||
} from "@/components/ui/card";
|
||||
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 {
|
||||
SettingsContainer,
|
||||
|
@ -43,6 +43,7 @@ import {
|
|||
SettingsSectionForm,
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string()
|
||||
|
@ -57,6 +58,7 @@ export default function GeneralPage() {
|
|||
const router = useRouter();
|
||||
const { org } = useOrgContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { user } = useUserContext();
|
||||
|
||||
const [loadingDelete, setLoadingDelete] = useState(false);
|
||||
const [loadingSave, setLoadingSave] = useState(false);
|
||||
|
@ -101,7 +103,9 @@ export default function GeneralPage() {
|
|||
|
||||
async function pickNewOrgAndNavigate() {
|
||||
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.data.data.orgs.length > 0) {
|
||||
|
@ -237,9 +241,7 @@ export default function GeneralPage() {
|
|||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Danger Zone
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionTitle>Danger Zone</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Once you delete this org, there is no going back. Please
|
||||
be certain.
|
||||
|
|
|
@ -11,7 +11,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
|
|||
import { redirect } from "next/navigation";
|
||||
import { internal } from "@app/lib/api";
|
||||
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 { cache } from "react";
|
||||
import { GetOrgUserResponse } from "@server/routers/user";
|
||||
|
@ -62,10 +62,13 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||
redirect(`/${params.orgId}`);
|
||||
}
|
||||
|
||||
let orgs: ListOrgsResponse["orgs"] = [];
|
||||
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||
try {
|
||||
const getOrgs = cache(() =>
|
||||
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
|
||||
internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||
`/user/${user.userId}/orgs`,
|
||||
cookie
|
||||
)
|
||||
);
|
||||
const res = await getOrgs();
|
||||
if (res && res.data.data.orgs) {
|
||||
|
|
|
@ -39,6 +39,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||
{
|
||||
title: "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 { cache } from "react";
|
||||
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 { AxiosResponse } from "axios";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
|
@ -31,10 +31,13 @@ export default async function AdminLayout(props: LayoutProps) {
|
|||
}
|
||||
|
||||
const cookie = await authCookieHeader();
|
||||
let orgs: ListOrgsResponse["orgs"] = [];
|
||||
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||
try {
|
||||
const getOrgs = cache(() =>
|
||||
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
|
||||
internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||
`/user/${user.userId}/orgs`,
|
||||
cookie
|
||||
)
|
||||
);
|
||||
const res = await getOrgs();
|
||||
if (res && res.data.data.orgs) {
|
||||
|
|
|
@ -2,7 +2,7 @@ import { internal } from "@app/lib/api";
|
|||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { ListOrgsResponse } from "@server/routers/org";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
|
@ -36,10 +36,7 @@ export default async function Page(props: {
|
|||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!user.emailVerified &&
|
||||
env.flags.emailVerificationRequired
|
||||
) {
|
||||
if (!user.emailVerified && env.flags.emailVerificationRequired) {
|
||||
if (params.redirect) {
|
||||
const safe = cleanRedirect(params.redirect);
|
||||
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 {
|
||||
const res = await internal.get<AxiosResponse<ListOrgsResponse>>(
|
||||
`/orgs`,
|
||||
const res = await internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||
`/user/${user.userId}/orgs`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
|
||||
|
@ -61,24 +58,19 @@ export default async function Page(props: {
|
|||
} catch (e) {}
|
||||
|
||||
if (!orgs.length) {
|
||||
if (
|
||||
!env.flags.disableUserCreateOrg ||
|
||||
user.serverAdmin
|
||||
) {
|
||||
if (!env.flags.disableUserCreateOrg || user.serverAdmin) {
|
||||
redirect("/setup");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<Layout
|
||||
orgs={orgs}
|
||||
navItems={rootNavItems}
|
||||
showBreadcrumbs={false}
|
||||
>
|
||||
<Layout orgs={orgs} navItems={rootNavItems} showBreadcrumbs={false}>
|
||||
<div className="w-full max-w-md mx-auto md:mt-32 mt-4">
|
||||
<OrganizationLanding
|
||||
disableCreateOrg={env.flags.disableUserCreateOrg && !user.serverAdmin}
|
||||
disableCreateOrg={
|
||||
env.flags.disableUserCreateOrg && !user.serverAdmin
|
||||
}
|
||||
organizations={orgs.map((org) => ({
|
||||
name: org.name,
|
||||
id: org.orgId
|
||||
|
|
|
@ -4,7 +4,7 @@ import React, { useState } from "react";
|
|||
import { SidebarNav } from "@app/components/SidebarNav";
|
||||
import { OrgSelector } from "@app/components/OrgSelector";
|
||||
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 { Button } from "@app/components/ui/button";
|
||||
import { ExternalLink, Menu, X, Server } from "lucide-react";
|
||||
|
@ -26,7 +26,7 @@ import { useUserContext } from "@app/hooks/useUserContext";
|
|||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
orgId?: string;
|
||||
orgs?: ListOrgsResponse["orgs"];
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
navItems?: Array<{
|
||||
title: string;
|
||||
href: string;
|
||||
|
|
|
@ -17,7 +17,7 @@ import {
|
|||
} from "@app/components/ui/popover";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
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 { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
@ -25,7 +25,7 @@ import { useUserContext } from "@app/hooks/useUserContext";
|
|||
|
||||
interface OrgSelectorProps {
|
||||
orgId?: string;
|
||||
orgs?: ListOrgsResponse["orgs"];
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
}
|
||||
|
||||
export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
|
||||
|
|
|
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
@ -10,16 +10,13 @@ import {
|
|||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface InfoPopupProps {
|
||||
text: string;
|
||||
text?: string;
|
||||
info: string;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function InfoPopup({ text, info }: InfoPopupProps) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{text}</span>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
export function InfoPopup({ text, info, trigger }: InfoPopupProps) {
|
||||
const defaultTrigger = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
@ -28,6 +25,14 @@ export function InfoPopup({ text, info }: InfoPopupProps) {
|
|||
<Info className="h-4 w-4" />
|
||||
<span className="sr-only">Show info</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{text && <span>{text}</span>}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
{trigger ?? defaultTrigger}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<p className="text-sm text-muted-foreground">{info}</p>
|
||||
|
|
|
@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||
return (
|
||||
<textarea
|
||||
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
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue