diff --git a/messages/en-US.json b/messages/en-US.json index d1ce572bd..abc4f2928 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2899,5 +2899,22 @@ "httpDestUpdatedSuccess": "Destination updated successfully", "httpDestCreatedSuccess": "Destination created successfully", "httpDestUpdateFailed": "Failed to update destination", - "httpDestCreateFailed": "Failed to create destination" + "httpDestCreateFailed": "Failed to create destination", + "idpAddActionCreateNew": "Create new identity provider", + "idpAddActionImportFromOrg": "Import from another organization", + "idpImportDialogTitle": "Import Identity Provider", + "idpImportDialogDescription": "Choose an identity provider from an organization where you are an admin. It will be linked to this organization.", + "idpImportSearchPlaceholder": "Search by organization or provider name...", + "idpImportEmpty": "No identity providers found.", + "idpImportedDescription": "Identity provider imported successfully.", + "idpDeleteGlobalQuestion": "Are you sure you want to permanently delete this identity provider?", + "idpDeleteGlobalDescription": "This will permanently delete the identity provider from all organizations it is associated with.", + "idpUnassociateTitle": "Unassociate Identity Provider", + "idpUnassociateQuestion": "Are you sure you want to unassociate this identity provider from this organization?", + "idpUnassociateDescription": "All users associated with this identity provider will be removed from this organization, but the identity provider will still continue to exist for other associated organizations.", + "idpUnassociateConfirm": "Confirm Unassociate Identity Provider", + "idpUnassociateWarning": "This cannot be undone for this organization.", + "idpUnassociatedDescription": "Identity provider unassociated from this organization successfully", + "idpUnassociateMenu": "Unassociate", + "idpDeleteAllOrgsMenu": "Delete" } diff --git a/public/idp/openid.png b/public/idp/openid.png new file mode 100644 index 000000000..d4422c872 Binary files /dev/null and b/public/idp/openid.png differ diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 1be4eaaca..53960b743 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -40,7 +40,9 @@ import { verifyRoleAccess, verifyUserAccess, verifyUserCanSetUserOrgRoles, - verifySiteProvisioningKeyAccess + verifySiteProvisioningKeyAccess, + verifyIsLoggedInUser, + verifyAdmin } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { @@ -94,6 +96,17 @@ authenticated.put( orgIdp.createOrgOidcIdp ); +authenticated.post( + "/org/:orgId/idp/:idpId/import", + verifyValidLicense, + verifyValidSubscription(tierMatrix.orgOidc), + verifyOrgAccess, + verifyLimits, + verifyAdmin, + logActionAudit(ActionsEnum.createIdp), + orgIdp.importOrgIdp +); + authenticated.post( "/org/:orgId/idp/:idpId/oidc", verifyValidLicense, @@ -116,6 +129,16 @@ authenticated.delete( orgIdp.deleteOrgIdp ); +authenticated.delete( + "/org/:orgId/idp/:idpId/association", + verifyValidLicense, + verifyOrgAccess, + verifyIdpAccess, + verifyUserHasAction(ActionsEnum.deleteIdp), + logActionAudit(ActionsEnum.deleteIdp), + orgIdp.unassociateOrgIdp +); + authenticated.get( "/org/:orgId/idp/:idpId", verifyValidLicense, @@ -125,16 +148,14 @@ authenticated.get( orgIdp.getOrgIdp ); -authenticated.get( - "/org/:orgId/idp", - verifyValidLicense, - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.listIdps), - orgIdp.listOrgIdps -); - authenticated.get("/org/:orgId/idp", orgIdp.listOrgIdps); // anyone can see this; it's just a list of idp names and ids +authenticated.get( + "/user/:userId/admin-org-idps", + verifyIsLoggedInUser, + orgIdp.listUserAdminOrgIdps +); + authenticated.get( "/org/:orgId/certificate/:domainId/:domain", verifyValidLicense, diff --git a/server/private/routers/orgIdp/importOrgIdp.ts b/server/private/routers/orgIdp/importOrgIdp.ts new file mode 100644 index 000000000..906c504d8 --- /dev/null +++ b/server/private/routers/orgIdp/importOrgIdp.ts @@ -0,0 +1,225 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +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 { idp, idpOrg, orgs, roles, userOrgs } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types"; +import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; +import privateConfig from "#private/lib/config"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + idpId: z.coerce.number().int().positive() +}); + +const bodySchema = z.strictObject({ + sourceOrgId: z.string().nonempty() +}); + +async function userIsOrgAdmin( + userId: string, + orgId: string, + session: Request["session"] +): Promise { + const [userOrgRow] = await db + .select() + .from(userOrgs) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (!userOrgRow) { + return false; + } + + const policyCheck = await checkOrgAccessPolicy({ + orgId, + userId, + session + }); + if (!policyCheck.allowed || policyCheck.error) { + return false; + } + + const roleIds = await getUserOrgRoleIds(userId, orgId); + if (roleIds.length === 0) { + return false; + } + + const [adminRole] = await db + .select() + .from(roles) + .where(and(inArray(roles.roleId, roleIds), eq(roles.isAdmin, true))) + .limit(1); + + return !!adminRole; +} + +export async function importOrgIdp( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId: targetOrgId, idpId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { sourceOrgId } = parsedBody.data; + + if ( + privateConfig.getRawPrivateConfig().app.identity_provider_mode !== + "org" + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." + ) + ); + } + + if (sourceOrgId === targetOrgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Source and target organization must be different" + ) + ); + } + + const userId = req.user!.userId; + + const sourceLinked = await db + .select() + .from(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, sourceOrgId))) + .limit(1); + + if (sourceLinked.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "IdP not found for the source organization" + ) + ); + } + + const sourceAdmin = await userIsOrgAdmin( + userId, + sourceOrgId, + req.session + ); + if (!sourceAdmin) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You must be an organization admin in the source organization where this IdP is linked" + ) + ); + } + + const [targetOrg] = await db + .select({ orgId: orgs.orgId }) + .from(orgs) + .where(eq(orgs.orgId, targetOrgId)) + .limit(1); + + if (!targetOrg) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Target organization not found" + ) + ); + } + + const [existingIdp] = await db + .select() + .from(idp) + .where(eq(idp.idpId, idpId)) + .limit(1); + + if (!existingIdp) { + return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found")); + } + + const alreadyTarget = await db + .select() + .from(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, targetOrgId))) + .limit(1); + + if (alreadyTarget.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "This IdP is already linked to the target organization" + ) + ); + } + + await db.insert(idpOrg).values({ + idpId, + orgId: targetOrgId, + roleMapping: null, + orgMapping: `'${targetOrgId}'` + }); + + const redirectUrl = await generateOidcRedirectUrl(idpId, targetOrgId); + + return response(res, { + data: { + idpId, + redirectUrl + }, + success: true, + error: false, + message: "Org IdP imported successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/orgIdp/index.ts b/server/private/routers/orgIdp/index.ts index e3f967f86..2997468be 100644 --- a/server/private/routers/orgIdp/index.ts +++ b/server/private/routers/orgIdp/index.ts @@ -12,7 +12,10 @@ */ export * from "./createOrgOidcIdp"; +export * from "./importOrgIdp"; export * from "./getOrgIdp"; export * from "./listOrgIdps"; +export * from "./listUserAdminOrgIdps"; export * from "./updateOrgOidcIdp"; export * from "./deleteOrgIdp"; +export * from "./unassociateOrgIdp"; diff --git a/server/private/routers/orgIdp/listUserAdminOrgIdps.ts b/server/private/routers/orgIdp/listUserAdminOrgIdps.ts new file mode 100644 index 000000000..78faa48fa --- /dev/null +++ b/server/private/routers/orgIdp/listUserAdminOrgIdps.ts @@ -0,0 +1,160 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, idpOidcConfig } from "@server/db"; +import { idp, idpOrg, orgs, roles, userOrgRoles } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types"; + +const querySchema = z.strictObject({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.int().nonnegative()) +}); + +const paramsSchema = z.strictObject({ + userId: z.string().nonempty() +}); + +async function getOrgIdsWhereUserIsAdmin(userId: string): Promise { + const rows = await db + .select({ orgId: userOrgRoles.orgId }) + .from(userOrgRoles) + .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where(and(eq(userOrgRoles.userId, userId), eq(roles.isAdmin, true))); + return [...new Set(rows.map((r) => r.orgId))]; +} + +async function queryIdpsForOrgs( + orgIds: string[], + limit: number, + offset: number +) { + return db + .select({ + idpId: idp.idpId, + orgId: idpOrg.orgId, + orgName: orgs.name, + name: idp.name, + type: idp.type, + variant: idpOidcConfig.variant, + tags: idp.tags + }) + .from(idpOrg) + .where(inArray(idpOrg.orgId, orgIds)) + .innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId)) + .innerJoin(idp, eq(idp.idpId, idpOrg.idpId)) + .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idpOrg.idpId)) + .orderBy(sql`idp.name DESC`) + .limit(limit) + .offset(offset); +} + +async function countIdpsForOrgs(orgIds: string[]) { + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(idpOrg) + .innerJoin(idp, eq(idp.idpId, idpOrg.idpId)) + .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idpOrg.idpId)) + .where(inArray(idpOrg.orgId, orgIds)); + return count; +} + +export async function listUserAdminOrgIdps( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { userId } = parsedParams.data; + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const adminOrgIds = await getOrgIdsWhereUserIsAdmin(userId); + + if (adminOrgIds.length === 0) { + return response(res, { + data: { + idps: [], + pagination: { + total: 0, + limit, + offset + } + }, + success: true, + error: false, + message: "Org Idps retrieved successfully", + status: HttpCode.OK + }); + } + + const list = await queryIdpsForOrgs(adminOrgIds, limit, offset); + const total = await countIdpsForOrgs(adminOrgIds); + + return response(res, { + data: { + idps: list, + pagination: { + total, + limit, + offset + } + }, + success: true, + error: false, + message: "Org Idps retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/orgIdp/unassociateOrgIdp.ts b/server/private/routers/orgIdp/unassociateOrgIdp.ts new file mode 100644 index 000000000..e6b0a6a2e --- /dev/null +++ b/server/private/routers/orgIdp/unassociateOrgIdp.ts @@ -0,0 +1,109 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, idpOrg } 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 { and, eq, sql } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import privateConfig from "#private/lib/config"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + idpId: z.coerce.number().int().positive() + }) + .strict(); + +export async function unassociateOrgIdp( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, idpId } = parsedParams.data; + + if ( + privateConfig.getRawPrivateConfig().app.identity_provider_mode !== + "org" + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." + ) + ); + } + + const [association] = await db + .select() + .from(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))) + .limit(1); + + if (!association) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `IdP with ID ${idpId} is not associated with organization ${orgId}` + ) + ); + } + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(idpOrg) + .where(eq(idpOrg.idpId, idpId)); + + if (count <= 1) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "This is the last organization associated with this identity provider. Delete it instead." + ) + ); + } + + await db + .delete(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))); + + return response(res, { + data: null, + success: true, + error: false, + message: "Org IdP unassociated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/orgIdp/types.ts b/server/routers/orgIdp/types.ts index f6f581eed..40dbb2cf4 100644 --- a/server/routers/orgIdp/types.ts +++ b/server/routers/orgIdp/types.ts @@ -25,3 +25,22 @@ export type ListOrgIdpsResponse = { offset: number; }; }; + +export type ListUserAdminOrgIdpsEntry = { + idpId: number; + orgId: string; + orgName: string; + name: string; + type: string; + variant: string; + tags: string | null; +}; + +export type ListUserAdminOrgIdpsResponse = { + idps: ListUserAdminOrgIdpsEntry[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 0263d2b72..858ac8da8 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -46,7 +46,7 @@ import { Checkbox } from "@app/components/ui/checkbox"; import { ListIdpsResponse } from "@server/routers/idp"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; -import Image from "next/image"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import OrgRolesTagField from "@app/components/OrgRolesTagField"; @@ -152,31 +152,8 @@ export default function Page() { const getIdpIcon = (variant: string | null) => { if (!variant) return null; - - switch (variant.toLowerCase()) { - case "google": - return ( - {t("idpGoogleAlt")} - ); - case "azure": - return ( - {t("idpAzureAlt")} - ); - default: - return null; - } + const type = variant.toLowerCase(); + return ; }; const validFor = [ @@ -340,15 +317,16 @@ export default function Page() { const roleIds = values.roles.map((r) => parseInt(r.id, 10)); - const res = await api.post>( - `/org/${orgId}/create-invite`, - { - email: values.email, - roleIds, - validHours: parseInt(values.validForHours), - sendEmail - } - ) + const res = await api + .post>( + `/org/${orgId}/create-invite`, + { + email: values.email, + roleIds, + validHours: parseInt(values.validForHours), + sendEmail + } + ) .catch((e) => { if (e.response?.status === 409) { toast({ diff --git a/src/components/IdpLoginButtons.tsx b/src/components/IdpLoginButtons.tsx index 50d849812..4fc4c9901 100644 --- a/src/components/IdpLoginButtons.tsx +++ b/src/components/IdpLoginButtons.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { Button } from "@app/components/ui/button"; import { Alert, AlertDescription } from "@app/components/ui/alert"; import { useTranslations } from "next-intl"; -import Image from "next/image"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; import { generateOidcUrlProxy, type GenerateOidcUrlResponse @@ -135,24 +135,7 @@ export default function IdpLoginButtons({ disabled={loading} loading={loading} > - {effectiveType === "google" && ( - Google - )} - {effectiveType === "azure" && ( - Azure - )} + {idp.name} ); diff --git a/src/components/IdpTypeBadge.tsx b/src/components/IdpTypeBadge.tsx index b0e90660b..d18c96d9b 100644 --- a/src/components/IdpTypeBadge.tsx +++ b/src/components/IdpTypeBadge.tsx @@ -1,7 +1,7 @@ "use client"; import { Badge } from "@app/components/ui/badge"; -import Image from "next/image"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; type IdpTypeBadgeProps = { type: string; @@ -29,34 +29,8 @@ export default function IdpTypeBadge({ variant="secondary" className="inline-flex items-center space-x-1 w-fit" > - {effectiveType === "google" && ( - <> - Google - {effectiveName} - - )} - {effectiveType === "azure" && ( - <> - Azure - {effectiveName} - - )} - {effectiveType === "oidc" && {effectiveName}} - {!["google", "azure", "oidc"].includes(effectiveType) && ( - {effectiveName} - )} + + {effectiveName} ); } diff --git a/src/components/IdpTypeIcon.tsx b/src/components/IdpTypeIcon.tsx new file mode 100644 index 000000000..be49f9654 --- /dev/null +++ b/src/components/IdpTypeIcon.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { cn } from "@app/lib/cn"; +import Image from "next/image"; +import { ReactNode } from "react"; + +type Props = { + type?: string | null; + variant?: string | null; + size?: number; + className?: string; + alt?: string; + fallback?: ReactNode; +}; + +export default function IdpTypeIcon({ + type, + variant, + size = 16, + className, + alt, + fallback = null +}: Props) { + const effectiveType = (variant || type || "").toLowerCase(); + + let src: string | null = null; + let defaultAlt = ""; + + if (effectiveType === "google") { + src = "/idp/google.png"; + defaultAlt = "Google"; + } else if (effectiveType === "azure") { + src = "/idp/azure.png"; + defaultAlt = "Azure"; + } else if (effectiveType === "oidc") { + src = "/idp/openid.png"; + defaultAlt = "OAuth2/OIDC"; + } + + if (!src) { + return <>{fallback}; + } + + return ( + {alt + ); +} diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index c3b1fc384..e87a8b1a8 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -27,7 +27,6 @@ import { LockIcon } from "lucide-react"; import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton"; import { createApiClient } from "@app/lib/api"; import Link from "next/link"; -import Image from "next/image"; import { GenerateOidcUrlResponse } from "@server/routers/idp"; import { Separator } from "./ui/separator"; import { useTranslations } from "next-intl"; @@ -37,6 +36,7 @@ import { } from "@app/actions/server"; import { redirect as redirectTo } from "next/navigation"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; // @ts-ignore import { loadReoScript } from "reodotdev"; import { build } from "@server/build"; @@ -393,24 +393,7 @@ export default function LoginForm({ loginWithIdp(idp.idpId); }} > - {effectiveType === "google" && ( - Google - )} - {effectiveType === "azure" && ( - Azure - )} + {idp.name} ); diff --git a/src/components/OrgIdpDataTable.tsx b/src/components/OrgIdpDataTable.tsx index 9a45f49e8..7e3f7ab65 100644 --- a/src/components/OrgIdpDataTable.tsx +++ b/src/components/OrgIdpDataTable.tsx @@ -1,19 +1,24 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { DataTable } from "@app/components/ui/data-table"; +import { + DataTable, + type DataTableAddAction +} from "@app/components/ui/data-table"; import { useTranslations } from "next-intl"; interface DataTableProps { columns: ColumnDef[]; data: TData[]; onAdd?: () => void; + addActions?: DataTableAddAction[]; } export function IdpDataTable({ columns, data, - onAdd + onAdd, + addActions }: DataTableProps) { const t = useTranslations(); @@ -27,6 +32,7 @@ export function IdpDataTable({ searchColumn="name" addButtonText={t("idpAdd")} onAdd={onAdd} + addActions={addActions} enableColumnVisibility={true} stickyRightColumn="actions" /> diff --git a/src/components/OrgIdpTable.tsx b/src/components/OrgIdpTable.tsx index 8f53f4847..ebcdca334 100644 --- a/src/components/OrgIdpTable.tsx +++ b/src/components/OrgIdpTable.tsx @@ -4,13 +4,37 @@ import { ColumnDef } from "@tanstack/react-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { IdpDataTable } from "@app/components/OrgIdpDataTable"; import { Button } from "@app/components/ui/button"; -import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; -import { useState } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + ArrowRight, + ArrowUpDown, + KeyRound, + MoreHorizontal +} from "lucide-react"; +import { useMemo, useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useUserContext } from "@app/hooks/useUserContext"; import { useRouter } from "next/navigation"; import { DropdownMenu, @@ -21,6 +45,11 @@ import { import Link from "next/link"; import { useTranslations } from "next-intl"; import IdpTypeBadge from "@app/components/IdpTypeBadge"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; +import { useQuery } from "@tanstack/react-query"; +import { useDebounce } from "use-debounce"; +import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types"; +import { cn } from "@app/lib/cn"; export type IdpRow = { idpId: number; @@ -29,6 +58,15 @@ export type IdpRow = { variant?: string; }; +type AdminIdpRow = ListUserAdminOrgIdpsResponse["idps"][number]; + +function IdpImportRowIcon({ + type, + variant +}: Pick) { + return ; +} + type Props = { idps: IdpRow[]; orgId: string; @@ -37,10 +75,48 @@ type Props = { export default function IdpTable({ idps, orgId }: Props) { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedIdp, setSelectedIdp] = useState(null); + const [isUnassociateModalOpen, setIsUnassociateModalOpen] = useState(false); + const [selectedUnassociateIdp, setSelectedUnassociateIdp] = + useState(null); + const [importDialogOpen, setImportDialogOpen] = useState(false); + const [importSearchQuery, setImportSearchQuery] = useState(""); + const [importSubmitting, setImportSubmitting] = useState(false); + const [debouncedImportSearch] = useDebounce(importSearchQuery, 150); + const api = createApiClient(useEnvContext()); + const { user } = useUserContext(); const router = useRouter(); const t = useTranslations(); + const { data: adminIdpsRaw = [] } = useQuery({ + queryKey: ["admin-org-idps", user.userId], + queryFn: async () => { + const res = await api.get<{ + data: ListUserAdminOrgIdpsResponse; + }>(`/user/${user.userId}/admin-org-idps`); + return res.data.data.idps; + }, + enabled: importDialogOpen && !!user?.userId + }); + + const importableIdps = useMemo(() => { + const localIds = new Set(idps.map((i) => i.idpId)); + return adminIdpsRaw.filter( + (row) => row.orgId !== orgId && !localIds.has(row.idpId) + ); + }, [adminIdpsRaw, orgId, idps]); + + const shownImportIdps = useMemo(() => { + const q = debouncedImportSearch.trim().toLowerCase(); + if (!q) { + return importableIdps; + } + return importableIdps.filter((row) => { + const hay = `${row.orgName} ${row.name}`.toLowerCase(); + return hay.includes(q); + }); + }, [importableIdps, debouncedImportSearch]); + const deleteIdp = async (idpId: number) => { try { await api.delete(`/org/${orgId}/idp/${idpId}`); @@ -59,6 +135,49 @@ export default function IdpTable({ idps, orgId }: Props) { } }; + const importIdp = async (row: AdminIdpRow) => { + setImportSubmitting(true); + try { + await api.post(`/org/${orgId}/idp/${row.idpId}/import`, { + sourceOrgId: row.orgId + }); + toast({ + title: t("success"), + description: t("idpImportedDescription") + }); + setImportDialogOpen(false); + setImportSearchQuery(""); + router.refresh(); + router.push(`/${orgId}/settings/idp/${row.idpId}/general`); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setImportSubmitting(false); + } + }; + + const unassociateIdp = async (idpId: number) => { + try { + await api.delete(`/org/${orgId}/idp/${idpId}/association`); + toast({ + title: t("success"), + description: t("idpUnassociatedDescription") + }); + setIsUnassociateModalOpen(false); + router.refresh(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } + }; + const columns: ExtendedColumnDef[] = [ { accessorKey: "idpId", @@ -142,6 +261,14 @@ export default function IdpTable({ idps, orgId }: Props) { {t("viewSettings")} + { + setSelectedUnassociateIdp(siteRow); + setIsUnassociateModalOpen(true); + }} + > + {t("idpUnassociateMenu")} + { setSelectedIdp(siteRow); @@ -149,7 +276,7 @@ export default function IdpTable({ idps, orgId }: Props) { }} > - {t("delete")} + {t("idpDeleteAllOrgsMenu")} @@ -179,8 +306,8 @@ export default function IdpTable({ idps, orgId }: Props) { }} dialog={
-

{t("idpQuestionRemove")}

-

{t("idpMessageRemove")}

+

{t("idpDeleteGlobalQuestion")}

+

{t("idpDeleteGlobalDescription")}

} buttonText={t("idpConfirmDelete")} @@ -189,11 +316,122 @@ export default function IdpTable({ idps, orgId }: Props) { title={t("idpDelete")} /> )} + {selectedUnassociateIdp && ( + { + setIsUnassociateModalOpen(val); + setSelectedUnassociateIdp(null); + }} + dialog={ +
+

{t("idpUnassociateQuestion")}

+

{t("idpUnassociateDescription")}

+
+ } + buttonText={t("idpUnassociateConfirm")} + onConfirm={async () => + unassociateIdp(selectedUnassociateIdp.idpId) + } + string={selectedUnassociateIdp.name} + title={t("idpUnassociateTitle")} + warningText={t("idpUnassociateWarning")} + /> + )} + + { + setImportDialogOpen(open); + if (!open) { + setImportSearchQuery(""); + } + }} + > + + + + {t("idpImportDialogTitle")} + + + {t("idpImportDialogDescription")} + + + + + + + + {t("idpImportEmpty")} + + + {shownImportIdps.map((row) => ( + { + void importIdp(row); + }} + > +
+ +
+
+
+ {row.orgName} +
+
+ {row.name} +
+
+
+ ))} +
+
+
+
+ + + + + +
+
router.push(`/${orgId}/settings/idp/create`)} + addActions={[ + { + label: t("idpAddActionCreateNew"), + onSelect: () => { + router.push(`/${orgId}/settings/idp/create`); + } + }, + { + label: t("idpAddActionImportFromOrg"), + onSelect: () => { + setImportDialogOpen(true); + } + } + ]} /> ); diff --git a/src/components/idp/OidcIdpProviderTypeSelect.tsx b/src/components/idp/OidcIdpProviderTypeSelect.tsx index 4665d9c0d..038254ebe 100644 --- a/src/components/idp/OidcIdpProviderTypeSelect.tsx +++ b/src/components/idp/OidcIdpProviderTypeSelect.tsx @@ -6,8 +6,8 @@ import { } from "@app/components/StrategySelect"; import { useEnvContext } from "@app/hooks/useEnvContext"; import type { IdpOidcProviderType } from "@app/lib/idp/oidcIdpProviderDefaults"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; import { useTranslations } from "next-intl"; -import Image from "next/image"; import { useEffect, useMemo } from "react"; type Props = { @@ -32,7 +32,8 @@ export function OidcIdpProviderTypeSelect({ value, onTypeChange }: Props) { { id: "oidc", title: "OAuth2/OIDC", - description: t("idpOidcDescription") + description: t("idpOidcDescription"), + icon: } ]; if (hideTemplates) { @@ -44,29 +45,13 @@ export function OidcIdpProviderTypeSelect({ value, onTypeChange }: Props) { id: "google", title: t("idpGoogleTitle"), description: t("idpGoogleDescription"), - icon: ( - {t("idpGoogleAlt")} - ) + icon: }, { id: "azure", title: t("idpAzureTitle"), description: t("idpAzureDescription"), - icon: ( - {t("idpAzureAlt")} - ) + icon: } ]; }, [hideTemplates, t]); diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx index 34a35455c..1690d92a8 100644 --- a/src/components/ui/controlled-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -18,12 +18,14 @@ import { TableRow } from "@/components/ui/table"; import { DataTablePagination } from "@app/components/DataTablePagination"; +import type { DataTableAddAction } from "@app/components/ui/data-table"; import { Button } from "@app/components/ui/button"; import { Card, CardContent, CardHeader } from "@app/components/ui/card"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger @@ -31,7 +33,14 @@ import { import { Input } from "@app/components/ui/input"; import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility"; -import { Columns, Filter, Plus, RefreshCw, Search } from "lucide-react"; +import { + ChevronDown, + Columns, + Filter, + Plus, + RefreshCw, + Search +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; @@ -67,6 +76,8 @@ type ControlledDataTableProps = { tableId: string; addButtonText?: string; onAdd?: () => void; + addActions?: DataTableAddAction[]; + addButtonDisabled?: boolean; onRefresh?: () => void; isRefreshing?: boolean; refreshButtonDisabled?: boolean; @@ -90,6 +101,8 @@ export function ControlledDataTable({ rows, addButtonText, onAdd, + addActions, + addButtonDisabled = false, onRefresh, isRefreshing, refreshButtonDisabled = false, @@ -348,16 +361,49 @@ export function ControlledDataTable({ )} - {onAdd && addButtonText && ( + {addActions && addActions.length > 0 && addButtonText ? (
- + + + + + + {addActions.map((action, i) => ( + + action.onSelect() + } + > + {action.label} + + ))} + +
+ ) : ( + onAdd && + addButtonText && ( +
+ +
+ ) )} diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index c62afd329..cf252f3ea 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -33,7 +33,7 @@ import { Button } from "@app/components/ui/button"; import { useEffect, useMemo, useRef, useState } from "react"; import { Input } from "@app/components/ui/input"; import { DataTablePagination } from "@app/components/DataTablePagination"; -import { Plus, Search, RefreshCw, Columns, Filter } from "lucide-react"; +import { ChevronDown, Plus, Search, RefreshCw, Columns, Filter } from "lucide-react"; import { Card, CardContent, @@ -46,6 +46,7 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger @@ -165,12 +166,20 @@ export type DataTablePaginationState = PaginationState & { export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void; +/** When set (non-empty), replaces the single add button with a dropdown; `onAdd` is not used. */ +export type DataTableAddAction = { + label: string; + onSelect: () => void; +}; + type DataTableProps = { columns: ExtendedColumnDef[]; data: TData[]; title?: string; addButtonText?: string; onAdd?: () => void; + /** Prefer over `onAdd` when non-empty. */ + addActions?: DataTableAddAction[]; addButtonDisabled?: boolean; onRefresh?: () => void; isRefreshing?: boolean; @@ -205,6 +214,7 @@ export function DataTable({ title, addButtonText, onAdd, + addActions, addButtonDisabled = false, onRefresh, isRefreshing, @@ -637,13 +647,45 @@ export function DataTable({ )} - {onAdd && addButtonText && ( + {addActions && addActions.length > 0 && addButtonText ? (
- + + + + + + {addActions.map((action, i) => ( + + action.onSelect() + } + > + {action.label} + + ))} + +
+ ) : ( + onAdd && + addButtonText && ( +
+ +
+ ) )}