mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-04 19:44:47 +00:00
Merge branch 'cross-org-idp' into dev
This commit is contained in:
@@ -1,19 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
|
||||
import { FormDescription } from "@app/components/ui/form";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping";
|
||||
import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control } from "react-hook-form";
|
||||
|
||||
type Role = {
|
||||
roleId: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type IdpOrgMappingFieldBinding = {
|
||||
control: unknown;
|
||||
name: string;
|
||||
labelKey?: string;
|
||||
};
|
||||
|
||||
type AutoProvisionConfigWidgetProps = {
|
||||
autoProvision: boolean;
|
||||
onAutoProvisionChange: (checked: boolean) => void;
|
||||
@@ -28,6 +42,11 @@ type AutoProvisionConfigWidgetProps = {
|
||||
onMappingBuilderRulesChange: (rules: MappingBuilderRule[]) => void;
|
||||
rawExpression: string;
|
||||
onRawExpressionChange: (expression: string) => void;
|
||||
orgMappingField: IdpOrgMappingFieldBinding;
|
||||
showAutoProvisionSwitch?: boolean;
|
||||
roleMappingFieldIdPrefix?: string;
|
||||
showFreeformRoleNamesHint?: boolean;
|
||||
autoProvisionSwitchId?: string;
|
||||
};
|
||||
|
||||
export default function AutoProvisionConfigWidget({
|
||||
@@ -43,41 +62,95 @@ export default function AutoProvisionConfigWidget({
|
||||
mappingBuilderRules,
|
||||
onMappingBuilderRulesChange,
|
||||
rawExpression,
|
||||
onRawExpressionChange
|
||||
onRawExpressionChange,
|
||||
orgMappingField,
|
||||
showAutoProvisionSwitch = true,
|
||||
roleMappingFieldIdPrefix = "org-idp-auto-provision",
|
||||
showFreeformRoleNamesHint = false,
|
||||
autoProvisionSwitchId = "auto-provision-toggle"
|
||||
}: AutoProvisionConfigWidgetProps) {
|
||||
const t = useTranslations();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const showMappingTabs = showAutoProvisionSwitch === false || autoProvision;
|
||||
|
||||
const orgMappingLabelKey =
|
||||
orgMappingField.labelKey ?? "orgMappingPathOptional";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="mb-4">
|
||||
<SwitchInput
|
||||
id="auto-provision-toggle"
|
||||
label={t("idpAutoProvisionUsers")}
|
||||
defaultChecked={autoProvision}
|
||||
onCheckedChange={onAutoProvisionChange}
|
||||
disabled={!isPaidUser(tierMatrix.autoProvisioning)}
|
||||
/>
|
||||
</div>
|
||||
{showAutoProvisionSwitch && (
|
||||
<div className="mb-4">
|
||||
<SwitchInput
|
||||
id={autoProvisionSwitchId}
|
||||
label={t("idpAutoProvisionUsers")}
|
||||
defaultChecked={autoProvision}
|
||||
onCheckedChange={onAutoProvisionChange}
|
||||
disabled={!isPaidUser(tierMatrix.autoProvisioning)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{autoProvision && (
|
||||
<RoleMappingConfigFields
|
||||
fieldIdPrefix="org-idp-auto-provision"
|
||||
showFreeformRoleNamesHint={false}
|
||||
roleMappingMode={roleMappingMode}
|
||||
onRoleMappingModeChange={onRoleMappingModeChange}
|
||||
roles={roles}
|
||||
fixedRoleNames={fixedRoleNames}
|
||||
onFixedRoleNamesChange={onFixedRoleNamesChange}
|
||||
mappingBuilderClaimPath={mappingBuilderClaimPath}
|
||||
onMappingBuilderClaimPathChange={
|
||||
onMappingBuilderClaimPathChange
|
||||
}
|
||||
mappingBuilderRules={mappingBuilderRules}
|
||||
onMappingBuilderRulesChange={onMappingBuilderRulesChange}
|
||||
rawExpression={rawExpression}
|
||||
onRawExpressionChange={onRawExpressionChange}
|
||||
/>
|
||||
{showMappingTabs && (
|
||||
<HorizontalTabs
|
||||
clientSide
|
||||
defaultTab={0}
|
||||
items={[
|
||||
{ title: t("roleMapping"), href: "#" },
|
||||
{ title: t("orgMapping"), href: "#" }
|
||||
]}
|
||||
>
|
||||
<div className="space-y-4 mt-4 p-1">
|
||||
<RoleMappingConfigFields
|
||||
fieldIdPrefix={roleMappingFieldIdPrefix}
|
||||
showFreeformRoleNamesHint={
|
||||
showFreeformRoleNamesHint
|
||||
}
|
||||
roleMappingMode={roleMappingMode}
|
||||
onRoleMappingModeChange={onRoleMappingModeChange}
|
||||
roles={roles}
|
||||
fixedRoleNames={fixedRoleNames}
|
||||
onFixedRoleNamesChange={onFixedRoleNamesChange}
|
||||
mappingBuilderClaimPath={mappingBuilderClaimPath}
|
||||
onMappingBuilderClaimPathChange={
|
||||
onMappingBuilderClaimPathChange
|
||||
}
|
||||
mappingBuilderRules={mappingBuilderRules}
|
||||
onMappingBuilderRulesChange={
|
||||
onMappingBuilderRulesChange
|
||||
}
|
||||
rawExpression={rawExpression}
|
||||
onRawExpressionChange={onRawExpressionChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4 mt-4 p-1">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("defaultMappingsOrgDescription")}
|
||||
</p>
|
||||
<FormField
|
||||
control={
|
||||
orgMappingField.control as Control<any>
|
||||
}
|
||||
name={orgMappingField.name}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(orgMappingLabelKey)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="e.g., ends_with(email, '@organization.com')"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</HorizontalTabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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" && (
|
||||
<Image
|
||||
src="/idp/google.png"
|
||||
alt="Google"
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded"
|
||||
/>
|
||||
)}
|
||||
{effectiveType === "azure" && (
|
||||
<Image
|
||||
src="/idp/azure.png"
|
||||
alt="Azure"
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded"
|
||||
/>
|
||||
)}
|
||||
<IdpTypeIcon type={effectiveType} size={16} />
|
||||
<span>{idp.name}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -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" && (
|
||||
<>
|
||||
<Image
|
||||
src="/idp/google.png"
|
||||
alt="Google"
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>{effectiveName}</span>
|
||||
</>
|
||||
)}
|
||||
{effectiveType === "azure" && (
|
||||
<>
|
||||
<Image
|
||||
src="/idp/azure.png"
|
||||
alt="Azure"
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>{effectiveName}</span>
|
||||
</>
|
||||
)}
|
||||
{effectiveType === "oidc" && <span>{effectiveName}</span>}
|
||||
{!["google", "azure", "oidc"].includes(effectiveType) && (
|
||||
<span>{effectiveName}</span>
|
||||
)}
|
||||
<IdpTypeIcon type={effectiveType} size={16} />
|
||||
<span>{effectiveName}</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
53
src/components/IdpTypeIcon.tsx
Normal file
53
src/components/IdpTypeIcon.tsx
Normal file
@@ -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 (
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt ?? defaultAlt}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn("shrink-0 rounded", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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" && (
|
||||
<Image
|
||||
src="/idp/google.png"
|
||||
alt="Google"
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded"
|
||||
/>
|
||||
)}
|
||||
{effectiveType === "azure" && (
|
||||
<Image
|
||||
src="/idp/azure.png"
|
||||
alt="Azure"
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded"
|
||||
/>
|
||||
)}
|
||||
<IdpTypeIcon type={effectiveType} size={16} />
|
||||
<span>{idp.name}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -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<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
onAdd?: () => void;
|
||||
addActions?: DataTableAddAction[];
|
||||
}
|
||||
|
||||
export function IdpDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
onAdd
|
||||
onAdd,
|
||||
addActions
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -27,6 +32,7 @@ export function IdpDataTable<TData, TValue>({
|
||||
searchColumn="name"
|
||||
addButtonText={t("idpAdd")}
|
||||
onAdd={onAdd}
|
||||
addActions={addActions}
|
||||
enableColumnVisibility={true}
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
|
||||
@@ -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,13 @@ 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";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
export type IdpRow = {
|
||||
idpId: number;
|
||||
@@ -29,6 +60,15 @@ export type IdpRow = {
|
||||
variant?: string;
|
||||
};
|
||||
|
||||
type AdminIdpRow = ListUserAdminOrgIdpsResponse["idps"][number];
|
||||
|
||||
function IdpImportRowIcon({
|
||||
type,
|
||||
variant
|
||||
}: Pick<AdminIdpRow, "type" | "variant">) {
|
||||
return <IdpTypeIcon type={type} variant={variant} size={20} />;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
idps: IdpRow[];
|
||||
orgId: string;
|
||||
@@ -37,10 +77,51 @@ type Props = {
|
||||
export default function IdpTable({ idps, orgId }: Props) {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedIdp, setSelectedIdp] = useState<IdpRow | null>(null);
|
||||
const [isUnassociateModalOpen, setIsUnassociateModalOpen] = useState(false);
|
||||
const [selectedUnassociateIdp, setSelectedUnassociateIdp] =
|
||||
useState<IdpRow | null>(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 { isPaidUser } = usePaidStatus();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const canImportOrgOidcIdp = isPaidUser(tierMatrix.orgOidc);
|
||||
|
||||
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 +140,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<IdpRow>[] = [
|
||||
{
|
||||
accessorKey: "idpId",
|
||||
@@ -142,6 +266,14 @@ export default function IdpTable({ idps, orgId }: Props) {
|
||||
{t("viewSettings")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedUnassociateIdp(siteRow);
|
||||
setIsUnassociateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("idpUnassociateMenu")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedIdp(siteRow);
|
||||
@@ -149,7 +281,7 @@ export default function IdpTable({ idps, orgId }: Props) {
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
{t("idpDeleteAllOrgsMenu")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -179,8 +311,8 @@ export default function IdpTable({ idps, orgId }: Props) {
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("idpQuestionRemove")}</p>
|
||||
<p>{t("idpMessageRemove")}</p>
|
||||
<p>{t("idpDeleteGlobalQuestion")}</p>
|
||||
<p>{t("idpDeleteGlobalDescription")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("idpConfirmDelete")}
|
||||
@@ -189,11 +321,126 @@ export default function IdpTable({ idps, orgId }: Props) {
|
||||
title={t("idpDelete")}
|
||||
/>
|
||||
)}
|
||||
{selectedUnassociateIdp && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isUnassociateModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsUnassociateModalOpen(val);
|
||||
setSelectedUnassociateIdp(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("idpUnassociateQuestion")}</p>
|
||||
<p>{t("idpUnassociateDescription")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("idpUnassociateConfirm")}
|
||||
onConfirm={async () =>
|
||||
unassociateIdp(selectedUnassociateIdp.idpId)
|
||||
}
|
||||
string={selectedUnassociateIdp.name}
|
||||
title={t("idpUnassociateTitle")}
|
||||
warningText={t("idpUnassociateWarning")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Credenza
|
||||
open={importDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setImportDialogOpen(open);
|
||||
if (!open) {
|
||||
setImportSearchQuery("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CredenzaContent className="sm:max-w-lg">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("idpImportDialogTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("idpImportDialogDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody
|
||||
className={cn(
|
||||
importSubmitting && "pointer-events-none opacity-60"
|
||||
)}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("idpImportSearchPlaceholder")}
|
||||
value={importSearchQuery}
|
||||
onValueChange={setImportSearchQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("idpImportEmpty")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{shownImportIdps.map((row) => (
|
||||
<CommandItem
|
||||
key={`${row.idpId}:${row.orgId}`}
|
||||
className="items-start gap-3 py-2.5"
|
||||
value={`${row.idpId}:${row.orgId}:${row.orgName}:${row.name}`}
|
||||
disabled={!canImportOrgOidcIdp}
|
||||
onSelect={() => {
|
||||
if (!canImportOrgOidcIdp) {
|
||||
return;
|
||||
}
|
||||
void importIdp(row);
|
||||
}}
|
||||
>
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<IdpImportRowIcon
|
||||
type={row.type}
|
||||
variant={row.variant}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate font-medium leading-tight">
|
||||
{row.orgName}
|
||||
</div>
|
||||
<div className="truncate text-sm leading-tight text-muted-foreground">
|
||||
{row.name}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={importSubmitting}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</CredenzaClose>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
<IdpDataTable
|
||||
columns={columns}
|
||||
data={idps}
|
||||
onAdd={() => router.push(`/${orgId}/settings/idp/create`)}
|
||||
addActions={[
|
||||
{
|
||||
label: t("idpAddActionCreateNew"),
|
||||
onSelect: () => {
|
||||
router.push(`/${orgId}/settings/idp/create`);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t("idpAddActionImportFromOrg"),
|
||||
onSelect: () => {
|
||||
setImportDialogOpen(true);
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -79,10 +79,7 @@ export default function RoleMappingConfigFields({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!supportsMultipleRolesPerUser &&
|
||||
mappingBuilderRules.length > 1
|
||||
) {
|
||||
if (!supportsMultipleRolesPerUser && mappingBuilderRules.length > 1) {
|
||||
onMappingBuilderRulesChange([mappingBuilderRules[0]]);
|
||||
}
|
||||
}, [
|
||||
@@ -95,11 +92,7 @@ export default function RoleMappingConfigFields({
|
||||
if (!supportsMultipleRolesPerUser && fixedRoleNames.length > 1) {
|
||||
onFixedRoleNamesChange([fixedRoleNames[0]]);
|
||||
}
|
||||
}, [
|
||||
supportsMultipleRolesPerUser,
|
||||
fixedRoleNames,
|
||||
onFixedRoleNamesChange
|
||||
]);
|
||||
}, [supportsMultipleRolesPerUser, fixedRoleNames, onFixedRoleNamesChange]);
|
||||
|
||||
const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`;
|
||||
const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`;
|
||||
@@ -116,7 +109,6 @@ export default function RoleMappingConfigFields({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<FormLabel className="mb-2">{t("roleMapping")}</FormLabel>
|
||||
<FormDescription className="mb-4">
|
||||
{t("roleMappingDescription")}
|
||||
</FormDescription>
|
||||
@@ -272,7 +264,9 @@ export default function RoleMappingConfigFields({
|
||||
supportsMultipleRolesPerUser={
|
||||
supportsMultipleRolesPerUser
|
||||
}
|
||||
showRemoveButton={mappingBuilderShowsRemoveColumn}
|
||||
showRemoveButton={
|
||||
mappingBuilderShowsRemoveColumn
|
||||
}
|
||||
rule={rule}
|
||||
onChange={(nextRule) => {
|
||||
const nextRules = mappingBuilderRules.map(
|
||||
@@ -390,12 +384,10 @@ function BuilderRuleRow({
|
||||
text: name
|
||||
}))}
|
||||
setTags={(nextTags) => {
|
||||
const prevRoleTags = rule.roleNames.map(
|
||||
(name) => ({
|
||||
id: name,
|
||||
text: name
|
||||
})
|
||||
);
|
||||
const prevRoleTags = rule.roleNames.map((name) => ({
|
||||
id: name,
|
||||
text: name
|
||||
}));
|
||||
const next =
|
||||
typeof nextTags === "function"
|
||||
? nextTags(prevRoleTags)
|
||||
|
||||
@@ -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: <IdpTypeIcon type="oidc" size={24} />
|
||||
}
|
||||
];
|
||||
if (hideTemplates) {
|
||||
@@ -44,29 +45,13 @@ export function OidcIdpProviderTypeSelect({ value, onTypeChange }: Props) {
|
||||
id: "google",
|
||||
title: t("idpGoogleTitle"),
|
||||
description: t("idpGoogleDescription"),
|
||||
icon: (
|
||||
<Image
|
||||
src="/idp/google.png"
|
||||
alt={t("idpGoogleAlt")}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded"
|
||||
/>
|
||||
)
|
||||
icon: <IdpTypeIcon type="google" size={24} />
|
||||
},
|
||||
{
|
||||
id: "azure",
|
||||
title: t("idpAzureTitle"),
|
||||
description: t("idpAzureDescription"),
|
||||
icon: (
|
||||
<Image
|
||||
src="/idp/azure.png"
|
||||
alt={t("idpAzureAlt")}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded"
|
||||
/>
|
||||
)
|
||||
icon: <IdpTypeIcon type="azure" size={24} />
|
||||
}
|
||||
];
|
||||
}, [hideTemplates, t]);
|
||||
|
||||
@@ -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<TData, TValue> = {
|
||||
tableId: string;
|
||||
addButtonText?: string;
|
||||
onAdd?: () => void;
|
||||
addActions?: DataTableAddAction[];
|
||||
addButtonDisabled?: boolean;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
refreshButtonDisabled?: boolean;
|
||||
@@ -90,6 +101,8 @@ export function ControlledDataTable<TData, TValue>({
|
||||
rows,
|
||||
addButtonText,
|
||||
onAdd,
|
||||
addActions,
|
||||
addButtonDisabled = false,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
refreshButtonDisabled = false,
|
||||
@@ -348,16 +361,49 @@ export function ControlledDataTable<TData, TValue>({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{onAdd && addButtonText && (
|
||||
{addActions && addActions.length > 0 && addButtonText ? (
|
||||
<div>
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
loading={isNavigatingToAddPage}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{addButtonText}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
disabled={
|
||||
addButtonDisabled ||
|
||||
isNavigatingToAddPage
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{addButtonText}
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{addActions.map((action, i) => (
|
||||
<DropdownMenuItem
|
||||
key={i}
|
||||
onSelect={() =>
|
||||
action.onSelect()
|
||||
}
|
||||
>
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : (
|
||||
onAdd &&
|
||||
addButtonText && (
|
||||
<div>
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
loading={isNavigatingToAddPage}
|
||||
disabled={addButtonDisabled}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{addButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -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<TData, TValue> = {
|
||||
columns: ExtendedColumnDef<TData, TValue>[];
|
||||
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<TData, TValue>({
|
||||
title,
|
||||
addButtonText,
|
||||
onAdd,
|
||||
addActions,
|
||||
addButtonDisabled = false,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
@@ -637,13 +647,45 @@ export function DataTable<TData, TValue>({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{onAdd && addButtonText && (
|
||||
{addActions && addActions.length > 0 && addButtonText ? (
|
||||
<div>
|
||||
<Button onClick={onAdd} disabled={addButtonDisabled}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{addButtonText}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
disabled={addButtonDisabled}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{addButtonText}
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{addActions.map((action, i) => (
|
||||
<DropdownMenuItem
|
||||
key={i}
|
||||
onSelect={() =>
|
||||
action.onSelect()
|
||||
}
|
||||
>
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : (
|
||||
onAdd &&
|
||||
addButtonText && (
|
||||
<div>
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
disabled={addButtonDisabled}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{addButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
Reference in New Issue
Block a user